[{"id":39501,"web_url":"https://patchwork.libcamera.org/comment/39501/","msgid":"<178272928232.14893.17704972927852843604@localhost>","date":"2026-06-29T10:34:42","subject":"Re: [PATCH v4 2/2] utils: tuning: Add AIQB parser for Intel IPU6\n\tsensors","submitter":{"id":184,"url":"https://patchwork.libcamera.org/api/people/184/","name":"Stefan Klug","email":"stefan.klug@ideasonboard.com"},"content":"Hi Javier,\n\nThank you for the patch.\n\nQuoting Javier Tia (2026-05-14 22:01:49)\n> Add a Python script to extract CCMs and AWB chromaticity limits from\n> Intel AIQB binary calibration files, producing a ready-to-use\n> libcamera Simple IPA tuning YAML.\n> \n> AIQB is Intel's proprietary calibration format shipped with Windows\n> camera drivers for Intel IPU6 sensors. Files for Alder Lake and Tiger\n> Lake sensors are available in the ipu6-camera-hal repository under\n> config/linux/ipu6ep/, or can be extracted from OEM Windows driver\n> installers using p7zip and innoextract.\n\nCould you add a link to that repo?\n\n> \n> The script supports record id=25 (advanced color matrices, float\n> format with CCT in Kelvin directly) and falls back to record id=18\n> (integer matrices with autodetected scale). Record id=25 is preferred\n> and present in all Alder Lake AIQB files examined.\n> \n> Tested against OV2740_CJFLE23_ADL.aiqb (Lenovo ThinkPad X1 Carbon\n> Gen 10, extracted from n3ace31w.exe). Other AIQB files may require\n> adjustments if the record layout differs.\n> \n> Signed-off-by: Javier Tia <floss@jetm.me>\n> ---\n>  utils/tuning/parse_aiqb.py | 335 +++++++++++++++++++++++++++++++++++++++++++++\n>  1 file changed, 335 insertions(+)\n> \n> diff --git a/utils/tuning/parse_aiqb.py b/utils/tuning/parse_aiqb.py\n> new file mode 100644\n> index 00000000..2308f967\n> --- /dev/null\n> +++ b/utils/tuning/parse_aiqb.py\n> @@ -0,0 +1,335 @@\n> +#!/usr/bin/env python3\n> +# SPDX-License-Identifier: GPL-2.0-or-later\n> +#\n> +# Parse an Intel AIQB (CPFF) binary to extract CCMs and AWB colour gains\n> +# for use in a libcamera Simple IPA tuning YAML file.\n> +#\n> +# Format reverse-engineered from ipu6-camera-hal headers (ia_cmc_types.h).\n\nI think a link to the repo could be helpful here as well.\n\n> +# AIQB files are available in the ipu6-camera-hal repository at\n> +# config/linux/ipu6ep/, or can be extracted from OEM Windows camera\n> +# driver installers using p7zip and innoextract.\n> +\n> +import argparse\n> +import os\n> +import struct\n> +import sys\n> +from dataclasses import dataclass\n> +\n> +import yaml\n> +\n> +# ia_mkn_record_header: size(u32), fmt_id(u8), key_id(u8), name_id(u16)\n> +REC_HDR = struct.Struct('<IBBH')\n> +REC_HDR_SIZE = 8\n> +\n> +# cmc_name_id enum values\n> +CMC_GENERAL_DATA       = 2\n> +CMC_SENSITIVITY        = 7\n> +CMC_COLOR_MATRICES     = 18\n> +CMC_ADV_COLOR_MATRICES = 25\n\nNit: This is a good candidate for an actual enum.\n\n> +\n> +# cmc_color_matrix_t (84 bytes):\n> +#   int32 light_src_type\n> +#   uint16 r_per_g, b_per_g\n> +#   uint16 cie_x, cie_y\n> +#   int32 matrix_accurate[9]\n> +#   int32 matrix_preferred[9]\n> +COLOR_MATRIX = struct.Struct('<i HH HH 9i 9i')\n> +assert COLOR_MATRIX.size == 84\n> +\n> +# Light source enum -> approximate CCT in Kelvin\n> +LIGHT_SOURCE_CCT = {\n> +    1:  2856,   # A - Incandescent/Tungsten\n> +    4:  5003,   # D50\n> +    5:  5503,   # D55\n> +    6:  6504,   # D65\n> +    7:  7504,   # D75\n> +    8:  5454,   # E (equal energy)\n> +    9:  6430,   # F1 daylight fluorescent\n> +    10: 4230,   # F2 cool white\n> +    11: 3450,   # F3 white\n> +    12: 3000,   # F4 warm white\n> +    13: 6350,   # F5\n> +    14: 4150,   # F6\n> +    15: 6500,   # F7 D65 sim\n> +    16: 5000,   # F8 D50 sim\n> +    17: 4150,   # F9\n> +    18: 5000,   # F10\n> +    19: 4000,   # F11\n> +    20: 3000,   # F12\n> +    22: 2300,   # HZ horizon\n> +}\n> +\n> +# Record chain starts here in all AIQB files checked so far\n> +FIRST_RECORD_OFFSET = 0x50\n> +\n> +\n> +@dataclass\n> +class ColorMatrixRecord:\n> +    light_src_type: int\n> +    r_per_g_raw: int\n> +    b_per_g_raw: int\n> +    cie_x: int\n> +    cie_y: int\n> +    matrix_accurate: tuple\n> +    matrix_preferred: tuple\n> +\n> +\n> +class _FlowList(list):\n> +    \"\"\"YAML sequence serialised as a flow sequence (single line).\"\"\"\n> +\n> +\n> +class _Dumper(yaml.Dumper):\n> +    pass\n> +\n> +\n> +_Dumper.add_representer(\n> +    _FlowList,\n> +    lambda dumper, data: dumper.represent_sequence(\n> +        'tag:yaml.org,2002:seq', data, flow_style=True\n> +    ),\n> +)\n\nThis is neat.\n\n> +\n> +\n> +def walk_records(data):\n> +    records = {}\n> +    offset = FIRST_RECORD_OFFSET\n> +    while offset + REC_HDR_SIZE <= len(data):\n> +        size, fmt_id, key_id, name_id = REC_HDR.unpack_from(data, offset)\n> +        if size < REC_HDR_SIZE or offset + size > len(data):\n> +            break\n> +        records[name_id] = (offset, size)\n> +        offset += size\n> +    return records\n> +\n> +\n> +def extract_general_data(data, offset):\n> +    w, h, bd, co = struct.unpack_from('<HHHH', data, offset + REC_HDR_SIZE)\n> +    return {'width': w, 'height': h, 'bit_depth': bd, 'color_order': co}\n> +\n> +\n> +def extract_sensitivity(data, offset):\n> +    iso, = struct.unpack_from('<H', data, offset + REC_HDR_SIZE)\n> +    return iso\n> +\n> +\n> +def extract_color_matrices(data, offset, size):\n> +    record_end = offset + size\n> +    num, = struct.unpack_from('<H', data, offset + REC_HDR_SIZE)\n> +    matrices = []\n> +    mat_offset = offset + REC_HDR_SIZE + 2\n> +    if mat_offset + num * COLOR_MATRIX.size > record_end:\n> +        num = max(0, (record_end - mat_offset) // COLOR_MATRIX.size)\n> +        print(f\"  WARNING: record id=18 truncated, reading {num} matrices\")\n\nCoud you replace all the print() calls by corresponding logging\nentries? So at the top:\n\nimport logging\n\nlogger = logging.getLogger(__name__)\n\nAnd then:\nlogger.warning(...)\n\nThis will make it easier to evolve the logging down the road.\n\n> +    for i in range(num):\n> +        unpacked = COLOR_MATRIX.unpack_from(data, mat_offset + i * 84)\n> +        rec = ColorMatrixRecord(\n> +            light_src_type=unpacked[0],\n> +            r_per_g_raw=unpacked[1],\n> +            b_per_g_raw=unpacked[2],\n> +            cie_x=unpacked[3],\n> +            cie_y=unpacked[4],\n> +            matrix_accurate=unpacked[5:14],\n> +            matrix_preferred=unpacked[14:23],\n> +        )\n> +        matrices.append({\n> +            'light_src': rec.light_src_type,\n> +            'cct': LIGHT_SOURCE_CCT.get(rec.light_src_type),\n> +            'r_per_g_raw': rec.r_per_g_raw,\n> +            'b_per_g_raw': rec.b_per_g_raw,\n> +            'matrix_raw': rec.matrix_accurate,\n> +        })\n> +    return matrices\n> +\n> +\n> +def extract_advanced_color_matrices(data, offset, size):\n> +    \"\"\"Parse cmc_advanced_color_matrix_correction (record id=25).\n> +\n> +    Layout after the 8-byte record header:\n> +      uint16  num_light_srcs\n> +      uint16  num_sectors\n> +      uint32  hue_of_sectors[num_sectors]\n> +      Per light source (24-byte cmc_acm_color_matrices_info_v101_t):\n> +        uint32  src_type\n> +        float   r_per_g\n> +        float   b_per_g\n> +        float   cie_x\n> +        float   cie_y\n> +        uint32  cct              (Kelvin, directly)\n> +        float   traditional[9]   (3x3 CCM, rows sum to 1.0)\n> +        float   advanced[num_sectors][9]   (per-sector CCMs, skipped)\n> +    \"\"\"\n> +    record_end = offset + size\n> +    pos = offset + REC_HDR_SIZE\n> +    num_ls, num_sectors = struct.unpack_from('<HH', data, pos)\n> +    pos += 4\n> +    pos += num_sectors * 4  # skip hue_of_sectors\n> +\n> +    info_fmt = struct.Struct('<IffffI')  # 24 bytes\n> +    ccm_fmt  = struct.Struct('<9f')     # 36 bytes\n> +    sector_skip = num_sectors * 36\n> +\n> +    matrices = []\n> +    for _ in range(num_ls):\n> +        if pos + info_fmt.size > record_end:\n> +            print(\"  WARNING: record id=25 truncated, stopping early\")\n> +            break\n> +        src_type, rg, bg, cie_x, cie_y, cct_k = info_fmt.unpack_from(data, pos)\n> +        pos += 24\n> +        if pos + ccm_fmt.size > record_end:\n> +            print(\"  WARNING: record id=25 truncated, stopping early\")\n> +            break\n> +        trad = ccm_fmt.unpack_from(data, pos)\n> +        pos += 36\n> +        matrices.append({\n> +            'light_src': src_type,\n> +            'cct': cct_k,\n> +            'r_per_g': rg,\n> +            'b_per_g': bg,\n> +            'matrix_float': trad,\n> +        })\n> +        if pos + sector_skip > record_end:\n> +            print(\"  WARNING: record id=25 truncated in advanced sectors, stopping early\")\n> +            break\n> +        pos += sector_skip\n> +    return matrices\n> +\n> +\n> +def guess_ccm_scale(matrices):\n> +    if not matrices:\n> +        return 8192\n> +    for scale in (8192, 4096, 2048, 1024):\n> +        errors = []\n> +        for m in matrices:\n> +            for row in range(3):\n> +                s = sum(m['matrix_raw'][row * 3:(row + 1) * 3]) / scale\n> +                errors.append(abs(s - 1.0))\n> +        if max(errors) < 0.05:\n> +            return scale\n> +    return 8192\n\nI was wondering how often you hit this condition. Should a exception be\nraised instead of pretending everything is fine?\n\nAnother option could be to just collect all errors and return the scale\nwith the smallest error?\n\n> +\n> +\n> +def main():\n> +    parser = argparse.ArgumentParser(\n> +        description='Parse Intel AIQB binary for libcamera Simple IPA YAML',\n> +        epilog='Tested on OV2740_CJFLE23_ADL.aiqb only. Other sensors may '\n> +               'require adjustments.')\n> +    parser.add_argument('aiqb', help='Path to .aiqb file')\n> +    parser.add_argument('--sensor-name',\n> +                        help='Sensor name for YAML output (default: derived '\n> +                             'from filename)')\n> +    parser.add_argument('--ccm-scale', type=int, default=0,\n> +                        help='Integer CCM scale for record id=18 (0=autodetect)')\n\nIs that actually needed? How is a user able to guess a proper scaling\nfactor?\n\n> +    args = parser.parse_args()\n> +\n> +    sensor_name = args.sensor_name or os.path.basename(args.aiqb).split('_')[0].lower()\n> +    output_path = f'{sensor_name}.yaml'\n> +\n> +    with open(args.aiqb, 'rb') as f:\n> +        data = f.read()\n> +\n> +    print(f\"File: {args.aiqb} ({len(data)} bytes)\")\n> +\n> +    records = walk_records(data)\n> +    print(f\"Records found: {sorted(records.keys())}\\n\")\n> +\n> +    if CMC_GENERAL_DATA in records:\n> +        gd = extract_general_data(data, records[CMC_GENERAL_DATA][0])\n> +        print(f\"Sensor: {gd['width']}x{gd['height']}, {gd['bit_depth']}-bit, \"\n> +              f\"color_order={gd['color_order']}\")\n> +\n> +    if CMC_SENSITIVITY in records:\n> +        iso = extract_sensitivity(data, records[CMC_SENSITIVITY][0])\n> +        print(f\"Base ISO: {iso}\")\n> +\n> +    adv_mode = False\n> +    if CMC_ADV_COLOR_MATRICES in records:\n> +        matrices = extract_advanced_color_matrices(data, *records[CMC_ADV_COLOR_MATRICES])\n> +        adv_mode = True\n> +        print(f\"\\nAdvanced color matrices (id=25, {len(matrices)} entries, float CCMs):\")\n> +    elif CMC_COLOR_MATRICES in records:\n> +        matrices = extract_color_matrices(data, *records[CMC_COLOR_MATRICES])\n> +        print(f\"\\nColor matrices (id=18, {len(matrices)} entries):\")\n> +    else:\n> +        print(\"ERROR: no color_matrices record found (id=18 or id=25)\")\n> +        sys.exit(1)\n> +\n> +    ccm_scale = args.ccm_scale or (1 if adv_mode else guess_ccm_scale(matrices))\n\nccm_scale seems only to be used in that else branch below. Would it make\nsense to move it there?\n\n> +\n> +    valid_matrices = []\n> +    for m in matrices:\n> +        cct = m['cct']\n> +        if not cct:\n> +            continue\n> +        if adv_mode:\n> +            vals = list(m['matrix_float'])\n> +            rg = m['r_per_g']\n> +            bg = m['b_per_g']\n> +        else:\n> +            vals = [v / ccm_scale for v in m['matrix_raw']]\n> +            rg = m['r_per_g_raw'] / 256.0\n> +            bg = m['b_per_g_raw'] / 256.0\n> +        row_sums = [sum(vals[r * 3:(r + 1) * 3]) for r in range(3)]\n> +        if max(abs(s - 1.0) for s in row_sums) > 0.05:\n> +            # Row sums deviating from 1.0 indicate a bad entry.\n> +            # For id=18: the scale factor is wrong (e.g. sums near 2.0 means\n> +            # ccm_scale is half the true value); use --ccm-scale to override.\n> +            # For id=25: floats are stored directly; deviation means the layout\n> +            # does not match ia_cmc_types.h or the entry is corrupt.\n> +            print(f\"  WARNING: CCT={cct}K row sums {[round(s, 4) for s in row_sums]} - skipping\")\n> +            continue\n> +        print(f\"  CCT={cct}K  R/G={rg:.4f}  B/G={bg:.4f}\")\n> +        valid_matrices.append((cct, vals, rg, bg))\n\nIt took me a while to understand the logic with the set. Would it make\nsense to replace that with a dataclass or a map? I think that might make\nthe following generators easier to read.\n\n> +\n> +    if not valid_matrices:\n> +        print(\"ERROR: no valid colour matrices extracted\")\n> +        sys.exit(1)\n> +\n> +    min_rg = min(m[2] for m in valid_matrices)\n> +    min_bg = min(m[3] for m in valid_matrices)\n\nThis could then be m.rg of m['rg']\n\n> +    max_gain_r = round((1.0 / min_rg) * 1.1, 2) if min_rg > 0 else 2.5\n> +    max_gain_b = round((1.0 / min_bg) * 1.1, 2) if min_bg > 0 else 3.2\n\nWhere do these constantsi (2.5, 3.2)  come from? A comment would be nice here.\n\n> +    print(f\"\\nSuggested AWB maxGainR={max_gain_r}, maxGainB={max_gain_b} \"\n> +          f\"(from min R/G={min_rg:.4f}, min B/G={min_bg:.4f})\")\n> +\n> +    sorted_matrices = sorted(valid_matrices)\n> +\n> +    colour_gains = [\n> +        {'ct': cct, 'gains': _FlowList([round(1.0 / rg, 4), round(1.0 / bg, 4)])}\n> +        for cct, vals, rg, bg in sorted_matrices\n> +    ]\n\nBasically the same, but IMHO easier to read:\n\ncolour_gains = [\n\t{'ct': m.cct, 'gains': _FlowList([round(1.0 / m.rg, 4), round(1.0 / m.bg, 4)])}\n\tfor m in sorted_matrices\n] \n\n> +\n> +    ccms = [\n> +        {'ct': cct, 'ccm': _FlowList([round(v, 4) for v in vals])}\n> +        for cct, vals, rg, bg in sorted_matrices\n> +    ]\n> +\n> +    aiqb_name = os.path.basename(args.aiqb)\n> +    doc = {\n> +        'version': 1,\n> +        'algorithms': [\n> +            {'Awb': {\n> +                'algorithm': 'grey',\n> +                'maxGainR': max_gain_r,\n> +                'maxGainB': max_gain_b,\n> +                'speed': 0.25,\n> +                'colourGains': colour_gains,\n> +            }},\n> +            {'Ccm': {'ccms': ccms}},\n> +            {'Adjust': {'gamma': 2.2, 'contrast': 1.0, 'saturation': 1.0}},\n> +            {'Agc': {}},\n> +        ],\n> +    }\n> +\n> +    with open(output_path, 'w') as f:\n> +        f.write('# SPDX-License-Identifier: CC0-1.0\\n')\n> +        f.write(f'# Calibrated from {aiqb_name}\\n')\n> +        yaml.dump(doc, f, Dumper=_Dumper, default_flow_style=False,\n> +                  allow_unicode=True, sort_keys=False, version=(1, 1),\n> +                  explicit_start=True, explicit_end=True)\n> +\n> +    print(f\"\\nWrote {output_path}\")\n> +    print(\"NOTE: Add a BlackLevel entry to the YAML with the sensor's black level.\")\n\nShould this be replaced by a static BlackLevel entry, as the actual\nblack levels are coming from the sensor helper?\n\nThis looks all quite nice and you'll make quite some people happy out\nthere :-)\n\nBest regards,\nStefan\n\n> +\n> +\n> +if __name__ == '__main__':\n> +    main()\n> \n> -- \n> 2.54.0\n>","headers":{"Return-Path":"<libcamera-devel-bounces@lists.libcamera.org>","X-Original-To":"parsemail@patchwork.libcamera.org","Delivered-To":"parsemail@patchwork.libcamera.org","Received":["from lancelot.ideasonboard.com (lancelot.ideasonboard.com\n\t[92.243.16.209])\n\tby patchwork.libcamera.org (Postfix) with ESMTPS id 1E84CC3261\n\tfor <parsemail@patchwork.libcamera.org>;\n\tMon, 29 Jun 2026 10:34:47 +0000 (UTC)","from lancelot.ideasonboard.com (localhost [IPv6:::1])\n\tby lancelot.ideasonboard.com (Postfix) with ESMTP id 63C4765F1A;\n\tMon, 29 Jun 2026 12:34:46 +0200 (CEST)","from perceval.ideasonboard.com (perceval.ideasonboard.com\n\t[213.167.242.64])\n\tby lancelot.ideasonboard.com (Postfix) with ESMTPS id 7D7DD65F04\n\tfor <libcamera-devel@lists.libcamera.org>;\n\tMon, 29 Jun 2026 12:34:44 +0200 (CEST)","from ideasonboard.com (unknown\n\t[IPv6:2a00:6020:448c:6c00:8f6f:e620:caae:2893])\n\tby perceval.ideasonboard.com (Postfix) with ESMTPSA id 5A5D012F;\n\tMon, 29 Jun 2026 12:34:01 +0200 (CEST)"],"Authentication-Results":"lancelot.ideasonboard.com; dkim=pass (1024-bit key;\n\tunprotected) header.d=ideasonboard.com header.i=@ideasonboard.com\n\theader.b=\"uPeaxzHb\"; dkim-atps=neutral","DKIM-Signature":"v=1; a=rsa-sha256; c=relaxed/simple; d=ideasonboard.com;\n\ts=mail; t=1782729241;\n\tbh=YGEOBF1gvlUGMAli0KkVGGwUNHtYRWnvJ3Jex9Otc2g=;\n\th=In-Reply-To:References:Subject:From:To:Date:From;\n\tb=uPeaxzHb9zLs8tNApgTG7zBSc8dMYvpB6+YaAIEuIfhiGDlGLHKllIZlU+MrrTuex\n\tDUGGy+J3DEMz0cw7czR/Jpfd6ydK7r1hyxrNdbTNnnfbgAHGY62SMLdZC90W8UApfS\n\tHQQeCsUSbBiHqx5y8dXSGwA/kp2+f9W8LHszChaU=","Content-Type":"text/plain; charset=\"utf-8\"","MIME-Version":"1.0","Content-Transfer-Encoding":"quoted-printable","In-Reply-To":"<20260514-ov2740-tuning-v4-2-5d59b40abfef@jetm.me>","References":"<20260514-ov2740-tuning-v4-0-5d59b40abfef@jetm.me>\n\t<20260514-ov2740-tuning-v4-2-5d59b40abfef@jetm.me>","Subject":"Re: [PATCH v4 2/2] utils: tuning: Add AIQB parser for Intel IPU6\n\tsensors","From":"Stefan Klug <stefan.klug@ideasonboard.com>","To":"Javier Tia <floss@jetm.me>, libcamera-devel@lists.libcamera.org","Date":"Mon, 29 Jun 2026 12:34:42 +0200","Message-ID":"<178272928232.14893.17704972927852843604@localhost>","User-Agent":"alot/0.12.dev43+g2cacc0d03","X-BeenThere":"libcamera-devel@lists.libcamera.org","X-Mailman-Version":"2.1.29","Precedence":"list","List-Id":"<libcamera-devel.lists.libcamera.org>","List-Unsubscribe":"<https://lists.libcamera.org/options/libcamera-devel>,\n\t<mailto:libcamera-devel-request@lists.libcamera.org?subject=unsubscribe>","List-Archive":"<https://lists.libcamera.org/pipermail/libcamera-devel/>","List-Post":"<mailto:libcamera-devel@lists.libcamera.org>","List-Help":"<mailto:libcamera-devel-request@lists.libcamera.org?subject=help>","List-Subscribe":"<https://lists.libcamera.org/listinfo/libcamera-devel>,\n\t<mailto:libcamera-devel-request@lists.libcamera.org?subject=subscribe>","Errors-To":"libcamera-devel-bounces@lists.libcamera.org","Sender":"\"libcamera-devel\" <libcamera-devel-bounces@lists.libcamera.org>"}}]