Show a patch.

GET /api/patches/26761/?format=api
HTTP 200 OK
Allow: GET, PUT, PATCH, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "id": 26761,
    "url": "https://patchwork.libcamera.org/api/patches/26761/?format=api",
    "web_url": "https://patchwork.libcamera.org/patch/26761/",
    "project": {
        "id": 1,
        "url": "https://patchwork.libcamera.org/api/projects/1/?format=api",
        "name": "libcamera",
        "link_name": "libcamera",
        "list_id": "libcamera_core",
        "list_email": "libcamera-devel@lists.libcamera.org",
        "web_url": "",
        "scm_url": "",
        "webscm_url": ""
    },
    "msgid": "<20260514-ov2740-tuning-v4-2-5d59b40abfef@jetm.me>",
    "date": "2026-05-14T20:01:49",
    "name": "[v4,2/2] utils: tuning: Add AIQB parser for Intel IPU6 sensors",
    "commit_ref": null,
    "pull_url": null,
    "state": "new",
    "archived": false,
    "hash": "356a6ae85d76de01d5724b44bb9bb08efb64f79b",
    "submitter": {
        "id": 261,
        "url": "https://patchwork.libcamera.org/api/people/261/?format=api",
        "name": "Javier Tia",
        "email": "floss@jetm.me"
    },
    "delegate": null,
    "mbox": "https://patchwork.libcamera.org/patch/26761/mbox/",
    "series": [
        {
            "id": 5948,
            "url": "https://patchwork.libcamera.org/api/series/5948/?format=api",
            "web_url": "https://patchwork.libcamera.org/project/libcamera/list/?series=5948",
            "date": "2026-05-14T20:01:47",
            "name": "Add a calibrated tuning file for the OV2740 sensor based on the Intel",
            "version": 4,
            "mbox": "https://patchwork.libcamera.org/series/5948/mbox/"
        }
    ],
    "comments": "https://patchwork.libcamera.org/api/patches/26761/comments/",
    "check": "pending",
    "checks": "https://patchwork.libcamera.org/api/patches/26761/checks/",
    "tags": {},
    "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 6DA8CC32F7\n\tfor <parsemail@patchwork.libcamera.org>;\n\tThu, 14 May 2026 20:01:58 +0000 (UTC)",
            "from lancelot.ideasonboard.com (localhost [IPv6:::1])\n\tby lancelot.ideasonboard.com (Postfix) with ESMTP id 6550063022;\n\tThu, 14 May 2026 22:01:57 +0200 (CEST)",
            "from fhigh-b3-smtp.messagingengine.com\n\t(fhigh-b3-smtp.messagingengine.com [202.12.124.154])\n\tby lancelot.ideasonboard.com (Postfix) with ESMTPS id A110A63025\n\tfor <libcamera-devel@lists.libcamera.org>;\n\tThu, 14 May 2026 22:01:54 +0200 (CEST)",
            "from phl-compute-02.internal (phl-compute-02.internal\n\t[10.202.2.42])\n\tby mailfhigh.stl.internal (Postfix) with ESMTP id C275C7A0053\n\tfor <libcamera-devel@lists.libcamera.org>;\n\tThu, 14 May 2026 16:01:53 -0400 (EDT)",
            "from phl-imap-07 ([10.202.2.97])\n\tby phl-compute-02.internal (MEProxy); Thu, 14 May 2026 16:01:53 -0400",
            "by mailuser.phl.internal (Postfix, from userid 501)\n\tid 80EF41EA006B; Thu, 14 May 2026 16:01:53 -0400 (EDT)"
        ],
        "Authentication-Results": "lancelot.ideasonboard.com; dkim=pass (2048-bit key;\n\tunprotected) header.d=jetm.me header.i=@jetm.me header.b=\"r8QsDpEe\";\n\tdkim=pass (2048-bit key;\n\tunprotected) header.d=messagingengine.com\n\theader.i=@messagingengine.com header.b=\"lL/FSh/X\"; \n\tdkim-atps=neutral",
        "DKIM-Signature": [
            "v=1; a=rsa-sha256; c=relaxed/relaxed; d=jetm.me; h=cc\n\t:content-transfer-encoding:content-type:content-type:date:date\n\t:from:from:in-reply-to:in-reply-to:message-id:mime-version\n\t:references:reply-to:subject:subject:to:to; s=fm2; t=1778788913;\n\tx=1778875313; bh=fZq8EJTkljr6qchHJWWzJ+vDKc1flX5PrThPvbc//r0=; b=\n\tr8QsDpEeLBfyf7CBab/HmQti1N1jFHutfmtfnjMXInLktQwz41KCEmAMlWc+jJ2W\n\tZFW5OoFL3FGKirkgmkoiLi/cs1SDuwXK0O6yLuDTQGScP/A4cXjxA1BhSnaRw6g5\n\t27xQ9MhwXPOSj4jL1VznDdOzKxLHYWqLmd7XMeVkxC5KsAAISqNuanGdix/uwAUU\n\tG3sQA/nuLrow4k96VbRoiRcvRMqebublFPheFw2CcRp5T7+LtXgzZhrE03tzSYHS\n\tmi7/LMuFvqeuf3iYivDhMPaQ7eqdeZH4MsIldT/ZjgT35CLhjaRPxYkzGIKun0sD\n\t7WFirZQnGjNt0t85k33GoQ==",
            "v=1; a=rsa-sha256; c=relaxed/relaxed; d=\n\tmessagingengine.com; h=cc:content-transfer-encoding:content-type\n\t:content-type:date:date:feedback-id:feedback-id:from:from\n\t:in-reply-to:in-reply-to:message-id:mime-version:references\n\t:reply-to:subject:subject:to:to:x-me-proxy:x-me-sender\n\t:x-me-sender:x-sasl-enc; s=fm3; t=1778788913; x=1778875313; bh=f\n\tZq8EJTkljr6qchHJWWzJ+vDKc1flX5PrThPvbc//r0=; b=lL/FSh/XGjQFuJzMc\n\tJ/xeNuvy2de4TxpZY9SyIBGh0907LutU+upd/jCX4DSmsZs8FVeRt3LRmCXWM+lk\n\tcwYdhzYHXNUrxJduOLfbHr+jVgFae/NbhAUrCgoFZpQyILF0x66khflTESOXcPrr\n\tH8MHT44PgS3IxMG0jvPiwUBpjWCzzqAAfKA6S6HXjRYiWTdoSK3pWOFrJgp2YZOA\n\tZf1dLstmGMT4UsB8eNheuk0th/qjLK6HqItUIAiaHrHcByGrHn+3wDs04bFe+Gwv\n\tXxaSzCX8LQ7wKRkpJAwOHZzeNw0xZGmHQM7zQCei2u+hTmo+fJ4KM+OXw3oNgxUV\n\tnYaaQ=="
        ],
        "X-ME-Sender": "<xms:MSoGapCr28IxoH_F8r6aep2R991j1hJDSz74Yir8W0augQBF2wh_NQ>\n\t<xme:MSoGaiVA-jeiXLKtgt4S23cSDePTzViU_9J2Vgv2K013w3JdFbuxNIFm7aTu7iyhR\n\tUdn-E5akX1ggcM8JWBjf1QVvgicyKix2fdZOtq1LjGDxbOfxgdBsfs>",
        "X-ME-Proxy-Cause": "gggruggvucftvghtrhhoucdtuddrgeefhedrtddtgdduvdekgedvucetufdoteggodetrf\n\tdotffvucfrrhhofhhilhgvmecuhfgrshhtofgrihhlpdfurfetoffkrfgpnffqhgenuceu\n\trghilhhouhhtmecufedttdenucenucfjughrpefohfffufggtgfgkffvofgjfhesthejre\n\tdtredtjeenucfhrhhomheplfgrvhhivghrucfvihgruceofhhlohhsshesjhgvthhmrdhm\n\tvgeqnecuggftrfgrthhtvghrnhephedvudeuueethfdtteelheegfeehieehleefffetke\n\tehfffggfeiieevtdeugfeinecuvehluhhsthgvrhfuihiivgepudenucfrrghrrghmpehm\n\trghilhhfrhhomhepfhhlohhsshesjhgvthhmrdhmvgdpnhgspghrtghpthhtohepuddpmh\n\thouggvpehsmhhtphhouhhtpdhrtghpthhtoheplhhisggtrghmvghrrgdquggvvhgvlhes\n\tlhhishhtshdrlhhisggtrghmvghrrgdrohhrgh",
        "X-ME-Proxy": "<xmx:MSoGauTQTnI-BRJmm3FMFxe5-5-m-JONC_8InPm1L0qMjHEPXH3lkg>\n\t<xmx:MSoGajtK4S1LxY8mER5j6n_GZCfBVysBGaIrBieklxYQlxQCopniaA>\n\t<xmx:MSoGaouSJit1q8PZ3BezvdrcSItdzF8evFnJSM7LRjht87Jk4yDypw>\n\t<xmx:MSoGahw05Grs5qbHiOFIF6HcvNwNu7AAhLPW0tRrlGtUdOE3MUzzKw>\n\t<xmx:MSoGaqkwFbupEgjgQ2roeUFRMi6yyHAWbzvyMZY2YT3VMslhE53a5LSo>",
        "Feedback-ID": "i9dde48b3:Fastmail",
        "X-Mailer": [
            "MessagingEngine.com Webmail Interface",
            "b4 0.15.2"
        ],
        "From": "Javier Tia <floss@jetm.me>",
        "Date": "Thu, 14 May 2026 14:01:49 -0600",
        "Subject": "[PATCH v4 2/2] utils: tuning: Add AIQB parser for Intel IPU6 sensors",
        "MIME-Version": "1.0",
        "Content-Type": "text/plain; charset=\"utf-8\"",
        "Content-Transfer-Encoding": "7bit",
        "Message-Id": "<20260514-ov2740-tuning-v4-2-5d59b40abfef@jetm.me>",
        "To": "libcamera-devel@lists.libcamera.org",
        "X-Developer-Signature": "v=1; a=openpgp-sha256; l=13107; i=floss@jetm.me;\n\th=from:subject:message-id;\n\tbh=zLEZJnbL/f9IDk7PLYWHX+loEL0ZBAf+qBOeFcEUo3w=; \n\tb=owEB7QES/pANAwAKAbXuwwuoZ3cfAcsmYgBqBiosdbdstxLmGNi5AfVea0/5SqXkx9y8y264d\n\tMrg4yWnAXKJAbMEAAEKAB0WIQSbE7ILzw7eI0VKk8m17sMLqGd3HwUCagYqLAAKCRC17sMLqGd3\n\tHz7KC/9sC7qQGrfbaw/V7KQuah2P6822Cs/HrYCR4yoESHjXEbj3Bj1VrZkv6NPUcgObp0D2dUI\n\tHZ8yKj916z9u0sUXUAuTSAW/mp8wZxErXps2xibccVFKfSqBVmdJ1siqI0qZRlJNwPZ9MwXFE1G\n\tzytYLzZ/Fa9JMLyqHKl3zdkQIZ+EhtHZPoKDKW820b0tahyjOOEY5IE4UrdCIsfddy5Px9P5Qr6\n\thh3ibPjT3QDcb9C7EV5+2GMpxUPaONk4jmkGNswuN0v2pishKoYTQruLsaMGzr7rNDFwhUVLBON\n\tJGWIEJBDrx5Ts78lsoG7xIEe80Ly/Ho4mrHSNqKSuDHh1n+QmNGs8KWd9wbiAgM848civRAJlY4\n\t95wKXEZ4ND0JpDo4SJ+y5h3TwrYOMjbBw0hQiWo87MEwl8NsuD+RyE9D4HFgczMZV2JzLo1vwYh\n\te0peoa0RcBhkprXsNJum/0e1oSLNXMzadDpgcZY+i6Rv3QP+iAJoernA7MWu+Ln7CtrTI=",
        "X-Developer-Key": "i=floss@jetm.me; a=openpgp;\n\tfpr=9B13B20BCF0EDE23454A93C9B5EEC30BA867771F",
        "In-Reply-To": "<20260514-ov2740-tuning-v4-0-5d59b40abfef@jetm.me>",
        "References": "<20260514-ov2740-tuning-v4-0-5d59b40abfef@jetm.me>",
        "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>"
    },
    "content": "Add a Python script to extract CCMs and AWB chromaticity limits from\nIntel AIQB binary calibration files, producing a ready-to-use\nlibcamera Simple IPA tuning YAML.\n\nAIQB is Intel's proprietary calibration format shipped with Windows\ncamera drivers for Intel IPU6 sensors. Files for Alder Lake and Tiger\nLake sensors are available in the ipu6-camera-hal repository under\nconfig/linux/ipu6ep/, or can be extracted from OEM Windows driver\ninstallers using p7zip and innoextract.\n\nThe script supports record id=25 (advanced color matrices, float\nformat with CCT in Kelvin directly) and falls back to record id=18\n(integer matrices with autodetected scale). Record id=25 is preferred\nand present in all Alder Lake AIQB files examined.\n\nTested against OV2740_CJFLE23_ADL.aiqb (Lenovo ThinkPad X1 Carbon\nGen 10, extracted from n3ace31w.exe). Other AIQB files may require\nadjustments if the record layout differs.\n\nSigned-off-by: Javier Tia <floss@jetm.me>\n---\n utils/tuning/parse_aiqb.py | 335 +++++++++++++++++++++++++++++++++++++++++++++\n 1 file changed, 335 insertions(+)",
    "diff": "diff --git a/utils/tuning/parse_aiqb.py b/utils/tuning/parse_aiqb.py\nnew file mode 100644\nindex 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+# 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+\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+\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+    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+\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+    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+\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+\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+    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+    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+\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+\n+\n+if __name__ == '__main__':\n+    main()\n",
    "prefixes": [
        "v4",
        "2/2"
    ]
}