From patchwork Tue Aug 25 13:08:15 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: John Cronin X-Patchwork-Id: 28087 Return-Path: X-Original-To: parsemail@patchwork.libcamera.org Delivered-To: parsemail@patchwork.libcamera.org Received: from lancelot.ideasonboard.com (lancelot.ideasonboard.com [92.243.16.209]) by patchwork.libcamera.org (Postfix) with ESMTPS id E08A8C3306 for ; Tue, 25 Aug 2026 13:08:28 +0000 (UTC) Received: from lancelot.ideasonboard.com (localhost [IPv6:::1]) by lancelot.ideasonboard.com (Postfix) with ESMTP id 38E37683F6; Tue, 25 Aug 2026 15:08:28 +0200 (CEST) Authentication-Results: lancelot.ideasonboard.com; dkim=pass (2048-bit key; unprotected) header.d=stromback-com.20251104.gappssmtp.com header.i=@stromback-com.20251104.gappssmtp.com header.b="KsZJUb8g"; dkim-atps=neutral Received: from mail-vs2-x00.google.com (mail-vs2-x00.google.com [IPv6:2a00:1450:4864:3a::]) by lancelot.ideasonboard.com (Postfix) with ESMTPS id 4A9EB68407 for ; Tue, 25 Aug 2026 15:08:26 +0200 (CEST) Received: by mail-vs2-x00.google.com with SMTP id ada2fe7eead31-7475824539cso5177067137.1 for ; Tue, 25 Aug 2026 06:08:26 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=stromback-com.20251104.gappssmtp.com; s=20251104; t=1787663305; x=1788268105; darn=lists.libcamera.org; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:from:to:cc:subject:date :message-id:reply-to:content-type; bh=Duq3lX7OdJyci8nOSh6cvLuR5FSm82lWFc7QKb2dB9I=; b=KsZJUb8gwyyOZqhvCrvlTDoQ1vZMgFNUqAQhp6rm8XTjsgrgYauHlUUvTO9ncnUbm1 B8TI1QvbkMRwPb+PK1VJOZ1f/2+v5oaQZ2nNltaK1srRJG6NI5yRAxMz7e1jYPos9elV cIh+z2Gk71engaZKVjZvMFmyOioZwy8AhxsC+uMEUc3hZC2qyKOWWhhEEODP3WphHA4f ORL4xtKembodj7XbJ04HwogKhXeLB8P6Mk1hefHm5bEywYipWedsr3f4SfejEKlsVD9b RrSxRdsMKKpPER3sJQqgSPCbI2XeGvBdtfwTuq3atw/oWvGwt9n9wJtXaG8t9PvJIEXh 0hVw== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1787663305; x=1788268105; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:x-gm-gg:x-gm-message-state:from :to:cc:subject:date:message-id:reply-to:content-type; bh=Duq3lX7OdJyci8nOSh6cvLuR5FSm82lWFc7QKb2dB9I=; b=AL47aA4VS3cyWIkl+Ym5thUT9OSLZq4+9wj25VHJkoq91jeqKUM7wfKRX28AjtciVy KEAn90xkDfHyUVBtAbYM2nH6iISya8LjpOpLI5nyYOSjAArj8/17EMILAgnF7quP9gMF 3SS2r3yRbo5vJRNqRcdBhoLIcIgOPlRteYKM3GTS5F60BVAVgJBThrUFoU3Bsp4WUAmu Ybcep5ax7yXmb/2lMfKNTuKwJUjtF7S8fw7OtSZT6f11/wW8P/Do1XMav1EiOpfCrOG+ LnLLntmyH2egQCfCKZ7ePgWTVIPFlDi+/jns8STVUxofqPbXMdQK1oxu+2qp4l4xJUzV 06tg== X-Gm-Message-State: AFuF++n54J6VFU/itah1AnWW7cM6O2Z+Tr3fNxa6ryfywIX9RQcv8VPw 5sTJwAIkl/RKIX/iGIbTQ3BshL8W16ohH7jgFkGth3QCp3mKhS7wmwFzv6mneLBMTBU5A1dq3zj +uCn2WDkuKlTxFsLMWQ== X-Gm-Gg: AR+sD12dxqZijXqXMw5uPJQA+kCPVHb2gZhvVkklkj7zm9f4vFdN/OuG0wmraRm5Y71 s2P+fkDhH4dkGVzBegOPF0HNZzGja7bSTE2Nq/8hqRVaRFdXSfCKUjMr6EhwGXFIQHErWjIm8vc w8IdWr0azNgj1Muo2Z0K9YpqQrEFJ5/I7T9u80MYR06/xWsFMMxXvZu49D1oBEf+o2m0s5DcJ56 /wgcQgNSmJiE997vdZ/x6AVx5FsHcUyfbWrwPbtcJjhGU+NQoLduuVVZMYIlvt/qYOZug/S4JmY NyBUJrmpN9c8VhMafHg3o4zgg1oHlAtRR36UVIMozwKaMAqXVqk5ZidNgijymWM7HDIx8u7eZBh yUWtv0sGGkhQQ/2omXDi3MUUsPReWXHPc1JNhYxh11EiSDvWCQJ37dhVp/ySYL9fo1NNrplSLQR ddDrRArQ09LwuqXnmz0ZX+R55EkDaO9et8YXAAJ+YdZ+80FXRm82/+7An8WlHsM1QzbUhk+07wi jSWhX3jc5l6igsRCCQPAuasTV/X8wQwe/W6IwR5mCe7ZDefDnWoYIA/qIq6wPD8LCIVS7CuKWzW 7tGsjLq/yu5vsGiGeBq6iAYk1nBfaEhpFoJb8Qc6Uh20FSK4mzPAMuL04u3aNBHAWMOLwv+ei90 hzjIgZN3VrqBnWKPugQwQoepvZELFvBwEnpDzojsav0vRZjJkgSZMK1fUq1IvDLs= X-Received: by 2002:a05:6122:d26:b0:5a0:5805:c88e with SMTP id 71dfb90a1353d-5c5ff3afbc4mr15993595e0c.2.1787663303682; Tue, 25 Aug 2026 06:08:23 -0700 (PDT) Received: from customer.mmmiflx1.isp.starlink.com ([66.9.164.90]) by smtp.gmail.com with ESMTPSA id 71dfb90a1353d-5c623f1c717sm5982652e0c.16.2026.08.25.06.08.22 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Tue, 25 Aug 2026 06:08:22 -0700 (PDT) From: John Cronin X-Google-Original-From: John Cronin To: libcamera-devel@lists.libcamera.org Cc: laurent.pinchart@ideasonboard.com, jacopo.mondi@ideasonboard.com, barnabas.pocze@ideasonboard.com, stefan.klug@ideasonboard.com, John Cronin Subject: [PATCH v3 1/2] utils: Add measure-analogue-gain helper Date: Tue, 25 Aug 2026 09:08:15 -0400 Message-ID: <20260825130816.2305405-2-john.cronin@opcenter.com> X-Mailer: git-send-email 2.55.0 In-Reply-To: <20260825130816.2305405-1-john.cronin@opcenter.com> References: <20260825130816.2305405-1-john.cronin@opcenter.com> MIME-Version: 1.0 X-BeenThere: libcamera-devel@lists.libcamera.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: libcamera-devel-bounces@lists.libcamera.org Sender: "libcamera-devel" Add a small Python utility to measure sensor analogue gain response and estimate black level for CameraSensorHelper development when a public datasheet is not available. The tool locks exposure (and digital gain) with v4l2-ctl, captures raw frames with cam, and fits brightness ratios to AnalogueGainLinear { m0=0, c0=k, m1=-1, c1=k } (G = k/(k-code)). Other linear or exponential families are out of scope. v4l2-ctl codes are required to bring up a helper that does not exist yet: cam AnalogueGain is a linear factor and is unavailable until a helper loads. After a helper exists, --emit-cam-script (and the example utils/measure-analogue-gain-cam.yaml) writes a cam --script YAML that replays the same points as linear AnalogueGain. Not installed; run from a libcamera source tree. Signed-off-by: John Cronin --- utils/measure-analogue-gain-cam.yaml | 28 ++ utils/measure-analogue-gain.py | 546 +++++++++++++++++++++++++++ 2 files changed, 574 insertions(+) create mode 100644 utils/measure-analogue-gain-cam.yaml create mode 100755 utils/measure-analogue-gain.py diff --git a/utils/measure-analogue-gain-cam.yaml b/utils/measure-analogue-gain-cam.yaml new file mode 100644 index 0000000..89a91fd --- /dev/null +++ b/utils/measure-analogue-gain-cam.yaml @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: CC0-1.0 +# +# Example cam --script for verifying a CameraSensorHelper after it exists. +# AnalogueGain is the *linear* factor libcamera applies (not a V4L2 code). +# These values are G = 1024/(1024-code) for codes 0, 200, 400, 800 +# (AnalogueGainLinear { m0=0, c0=1024, m1=-1, c1=1024 }). +# +# Bring-up of a *new* helper still uses v4l2-ctl codes: +# ./utils/measure-analogue-gain.py --sensor-name imx471 ... +# +# Replay / verify with cam once the helper is loaded: +# cam --camera 1 --stream role=raw,width=1928,height=1088 \ +# --script utils/measure-analogue-gain-cam.yaml \ +# --capture=12 --file=/tmp/gain-#.bin +# +# Generate a tailored copy: +# ./utils/measure-analogue-gain.py --emit-cam-script /tmp/cam.yaml \ +# --gains 0,200,400,800 --model-k 1024 --exposure-us 10000 + +frames: + - 0: + AnalogueGain: 1.000000 + - 3: + AnalogueGain: 1.242718 + - 6: + AnalogueGain: 1.641026 + - 9: + AnalogueGain: 4.571429 diff --git a/utils/measure-analogue-gain.py b/utils/measure-analogue-gain.py new file mode 100755 index 0000000..8367536 --- /dev/null +++ b/utils/measure-analogue-gain.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright (C) 2026, John Cronin +# +# Measure camera sensor analogue gain response and estimate black level +# for CameraSensorHelper development. +# +# Dependencies: libcamera "cam" tool, v4l2-ctl (v4l-utils), Python 3.10+. +# +# The sweep sets V4L2 analogue_gain *codes* via v4l2-ctl (needed to measure a +# helper that does not exist yet). After a helper exists, emit a cam --script +# YAML to replay the same points as linear AnalogueGain (see --emit-cam-script +# and utils/measure-analogue-gain-cam.yaml). +# +# The fitter only targets AnalogueGainLinear { m0=0, c0=k, m1=-1, c1=k } +# i.e. G = k / (k - code). Other linear or exponential families are out of scope. +# +# Example: +# ./utils/measure-analogue-gain.py \ +# --sensor-name imx471 \ +# --width 1928 --height 1088 --stride 3904 --bit-depth 10 \ +# --exposure 200 --digital-gain 256 \ +# --gains 0,50,100,150,200,300,400,500,600,700,800 \ +# --out /tmp/gain-measure +# ./utils/measure-analogue-gain.py --emit-cam-script /tmp/gain-measure/cam.yaml \ +# --gains 0,200,400,800 --model-k 1024 --exposure-us 10000 + +from __future__ import annotations + +import argparse +import csv +import json +import os +import statistics +import subprocess +import sys +from pathlib import Path + + +def resolve_cam_cmd() -> list[str]: + """Return argv prefix to invoke the libcamera cam utility. + + LIBCAMERA_CAM may be set to a command string (for example a path to + cam, or a wrapper that sets LD_LIBRARY_PATH for a local install). + """ + env = os.environ.get("LIBCAMERA_CAM") + if env: + return env.split() + return ["cam"] + + +def find_subdev(name_prefix: str) -> str: + for path in sorted(Path("/dev").glob("v4l-subdev*")): + name_file = Path("/sys/class/video4linux") / path.name / "name" + try: + name = name_file.read_text(encoding="utf-8").strip() + except OSError: + continue + if name.startswith(name_prefix): + return str(path) + raise SystemExit(f"No v4l-subdev with name prefix {name_prefix!r} found") + + +def v4l2_set(subdev: str, **ctrls: int) -> None: + arg = ",".join(f"{name}={value}" for name, value in ctrls.items()) + subprocess.run( + ["v4l2-ctl", "-d", subdev, f"--set-ctrl={arg}"], + check=False, + capture_output=True, + ) + + +def v4l2_get(subdev: str, *names: str) -> dict[str, int]: + result = subprocess.run( + ["v4l2-ctl", "-d", subdev, f"--get-ctrl={','.join(names)}"], + check=False, + capture_output=True, + text=True, + ) + out: dict[str, int] = {} + for line in result.stdout.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + out[key.strip()] = int(value.strip()) + return out + + +def frame_stats( + path: Path, + width: int, + height: int, + stride: int, + bit_depth: int, +) -> dict[str, float]: + """Compute basic stats for a packed raw frame. + + Expects little-endian 16-bit samples per pixel with the useful bits + in the low ``bit_depth`` bits (common for 10-bit Bayer delivered as + 16-bit words). ``stride`` is the bytes-per-line reported by the + capture pipeline (may include padding). + """ + frame_size = stride * height + data = path.read_bytes() + if len(data) < frame_size: + raise ValueError(f"{path}: got {len(data)} bytes, need {frame_size}") + + mask = (1 << bit_depth) - 1 + vals: list[int] = [] + # Subsample active area for speed; skip a small border. + for y in range(8, height - 8, 4): + row = data[y * stride : y * stride + width * 2] + for x in range(8, width - 8, 4): + sample = row[x * 2] | (row[x * 2 + 1] << 8) + vals.append(sample & mask) + + vals.sort() + count = len(vals) + return { + "n": count, + "min": vals[0], + "p1": vals[max(0, count // 100)], + "p5": vals[max(0, count // 20)], + "p50": vals[count // 2], + "p95": vals[min(count - 1, (count * 95) // 100)], + "max": vals[-1], + "mean": statistics.fmean(vals), + } + + +def capture_raw( + out_dir: Path, + tag: str, + camera: str, + width: int, + height: int, + frame_size: int, + count: int, +) -> list[Path]: + out_dir.mkdir(parents=True, exist_ok=True) + pattern = str(out_dir / f"{tag}-#.bin") + cmd = resolve_cam_cmd() + [ + "--camera", + camera, + "--stream", + f"role=raw,width={width},height={height}", + f"--capture={count}", + f"--file={pattern}", + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=90) + if result.returncode != 0: + sys.stderr.write(result.stderr or result.stdout or "cam failed\n") + + files = sorted(out_dir.glob(f"{tag}-*.bin")) + if not files: + files = sorted(out_dir.glob(f"{tag}*")) + return [path for path in files if path.stat().st_size >= frame_size] + + +def measure_at_gain( + subdev: str, + out_dir: Path, + gain: int, + exposure: int, + digital_gain: int | None, + camera: str, + width: int, + height: int, + stride: int, + bit_depth: int, + settle: int = 1, +) -> dict: + ctrls: dict[str, int] = {"exposure": exposure, "analogue_gain": gain} + if digital_gain is not None: + ctrls["digital_gain"] = digital_gain + v4l2_set(subdev, **ctrls) + + frame_size = stride * height + files = capture_raw( + out_dir, + f"g{gain:04d}", + camera=camera, + width=width, + height=height, + frame_size=frame_size, + count=settle + 2, + ) + readback_names = ["exposure", "analogue_gain"] + if digital_gain is not None: + readback_names.append("digital_gain") + readback = v4l2_get(subdev, *readback_names) + + use = files[settle:] if len(files) > settle else files + if not use: + raise RuntimeError(f"no frames captured for analogue_gain={gain}") + + stats_list = [ + frame_stats(path, width, height, stride, bit_depth) for path in use + ] + means = [item["mean"] for item in stats_list] + return { + "request_gain": gain, + "request_exposure": exposure, + "request_digital_gain": digital_gain, + "readback": readback, + "mean": statistics.fmean(means), + "mean_std": statistics.pstdev(means) if len(means) > 1 else 0.0, + "p1": statistics.fmean([item["p1"] for item in stats_list]), + "p50": statistics.fmean([item["p50"] for item in stats_list]), + "p95": statistics.fmean([item["p95"] for item in stats_list]), + "max": max(item["max"] for item in stats_list), + "frames": [str(path) for path in use], + "per_frame": stats_list, + } + + +def predicted_linear(code: int, k: int) -> float: + if code >= k: + return float("inf") + return k / (k - code) + + +def fit_linear_k(results: list[dict], black: float, k_min: int, k_max: int) -> dict: + sig0 = max(1e-6, results[0]["mean"] - black) + best: tuple[float, int] | None = None + for k in range(k_min, k_max + 1): + error = 0.0 + count = 0 + for row in results: + code = row["request_gain"] + if code >= k: + continue + ratio = max(0.0, row["mean"] - black) / sig0 + pred = predicted_linear(code, k) + error += (ratio - pred) ** 2 + count += 1 + if not count: + continue + mse = error / count + if best is None or mse < best[0]: + best = (mse, k) + if best is None: + return {} + return {"mse": best[0], "k": best[1]} + + +def parse_gains(text: str) -> list[int]: + return [int(part) for part in text.split(",") if part.strip() != ""] + + +def emit_cam_script( + path: Path, + gains: list[int], + model_k: int, + exposure_us: int | None, + frames_per_step: int = 3, +) -> None: + """Write a cam --script YAML that steps linear AnalogueGain. + + Use this *after* a CameraSensorHelper exists, to verify libcamera applies + the same G = k/(k-code) mapping. Bring-up of a new helper still needs + v4l2-ctl codes (AnalogueGain is not available until the helper loads). + """ + lines = [ + "# SPDX-License-Identifier: CC0-1.0", + "#", + "# Generated by utils/measure-analogue-gain.py --emit-cam-script", + f"# AnalogueGain = {model_k}/({model_k}-code) " + "(AnalogueGainLinear { m0=0, c0=k, m1=-1, c1=k })", + "#", + "# cam --camera 1 --stream role=raw,width=W,height=H \\", + f"# --script {path} --capture=N --file=out-#.bin", + "#", + "frames:", + ] + frame = 0 + for code in gains: + if code >= model_k: + continue + gain = predicted_linear(code, model_k) + lines.append(f" - {frame}:") + lines.append(f" AnalogueGain: {gain:.6f}") + if exposure_us is not None: + lines.append(f" ExposureTime: {exposure_us}") + frame += frames_per_step + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n") + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Measure analogue gain response and estimate black level for " + "CameraSensorHelper development." + ) + ) + parser.add_argument( + "--sensor-name", + default="imx471", + help="v4l-subdev sysfs name prefix (default: imx471)", + ) + parser.add_argument("--subdev", help="override sensor subdev path") + parser.add_argument( + "--camera", + default="1", + help="libcamera camera id or index for cam (default: 1)", + ) + parser.add_argument("--width", type=int, default=1928) + parser.add_argument("--height", type=int, default=1088) + parser.add_argument( + "--stride", + type=int, + default=3904, + help="bytes per line of raw frames (may include padding)", + ) + parser.add_argument( + "--bit-depth", + type=int, + default=10, + help="useful bits per sample in the 16-bit containers (default: 10)", + ) + parser.add_argument("--exposure", type=int, default=200) + parser.add_argument( + "--digital-gain", + type=int, + default=256, + help="digital gain code, or -1 to leave untouched (default: 256)", + ) + parser.add_argument( + "--gains", + default="0,50,100,150,200,300,400,500,600,700,800", + help="comma-separated analogue_gain codes to measure", + ) + parser.add_argument( + "--out", + type=Path, + default=Path("/tmp/libcamera-gain-measure"), + help="output directory for frames and reports", + ) + parser.add_argument( + "--dark", + action="store_true", + help="measure near-black at gain 0 with short exposures", + ) + parser.add_argument( + "--model-k", + type=int, + default=1024, + help="reference linear model constant for G=k/(k-code) (default: 1024)", + ) + parser.add_argument( + "--emit-cam-script", + type=Path, + metavar="FILE", + help=( + "write a cam --script YAML that steps AnalogueGain as " + "G=k/(k-code) and exit (no capture)" + ), + ) + parser.add_argument( + "--exposure-us", + type=int, + default=None, + help="optional ExposureTime (microseconds) for --emit-cam-script", + ) + args = parser.parse_args() + + if args.emit_cam_script: + emit_cam_script( + args.emit_cam_script, + parse_gains(args.gains), + args.model_k, + args.exposure_us, + ) + print(f"Wrote {args.emit_cam_script}", flush=True) + return 0 + + digital_gain = None if args.digital_gain < 0 else args.digital_gain + subdev = args.subdev or find_subdev(args.sensor_name) + out = args.out + out.mkdir(parents=True, exist_ok=True) + + print(f"cam cmd: {' '.join(resolve_cam_cmd())}", flush=True) + print( + f"subdev={subdev} camera={args.camera} " + f"{args.width}x{args.height} stride={args.stride} " + f"bit_depth={args.bit_depth}", + flush=True, + ) + + if args.dark: + rows = [] + for exposure in (1, 10, 50): + row = measure_at_gain( + subdev, + out / "dark", + gain=0, + exposure=exposure, + digital_gain=digital_gain, + camera=args.camera, + width=args.width, + height=args.height, + stride=args.stride, + bit_depth=args.bit_depth, + ) + rows.append(row) + print( + f"dark exp={exposure} mean={row['mean']:.2f} " + f"p1={row['p1']:.1f} p50={row['p50']:.1f} " + f"readback={row['readback']}", + flush=True, + ) + (out / "dark.json").write_text(json.dumps(rows, indent=2) + "\n") + print(f"Wrote {out / 'dark.json'}", flush=True) + return 0 + + gains = parse_gains(args.gains) + results: list[dict] = [] + print( + f"exposure={args.exposure} digital_gain={digital_gain}", + flush=True, + ) + print( + "gain\treadback\tmean\tp1\tp50\tp95\t" + f"pred_k{args.model_k}\tratio_vs_g0", + flush=True, + ) + + base_mean = None + base_p1 = None + for gain in gains: + row = measure_at_gain( + subdev, + out / "sweep", + gain=gain, + exposure=args.exposure, + digital_gain=digital_gain, + camera=args.camera, + width=args.width, + height=args.height, + stride=args.stride, + bit_depth=args.bit_depth, + ) + results.append(row) + if base_mean is None: + base_mean = row["mean"] + base_p1 = row["p1"] + + black = base_p1 if base_p1 is not None else 0.0 + signal = max(0.0, row["mean"] - black) + signal0 = max(1e-6, (base_mean or row["mean"]) - black) + ratio = signal / signal0 + pred = predicted_linear(gain, args.model_k) + readback_gain = row["readback"].get("analogue_gain", -1) + print( + f"{gain}\t{readback_gain}\t{row['mean']:.2f}\t{row['p1']:.1f}\t" + f"{row['p50']:.1f}\t{row['p95']:.1f}\t{pred:.4f}\t{ratio:.4f}", + flush=True, + ) + + black = results[0]["p1"] + signal0 = max(1e-6, results[0]["mean"] - black) + points = [] + for row in results: + code = row["request_gain"] + signal = max(0.0, row["mean"] - black) + ratio = signal / signal0 + pred = predicted_linear(code, args.model_k) + points.append( + { + "code": code, + "mean": row["mean"], + "signal": signal, + "ratio_measured": ratio, + f"ratio_pred_k{args.model_k}": pred, + "rel_error": (ratio - pred) / pred if pred else None, + "readback_gain": row["readback"].get("analogue_gain"), + } + ) + + best = fit_linear_k(results, black, k_min=max(args.model_k // 2, 2), k_max=args.model_k * 2) + analysis = { + "black_estimate_p1_at_g0": black, + "black_level_16bit": int(round(black)) << (16 - args.bit_depth), + "exposure": args.exposure, + "digital_gain": digital_gain, + "reference_model": f"G = {args.model_k}/({args.model_k}-code)", + "best_linear_k_mse": best, + "points": points, + } + summary = {"results": results, "analysis": analysis} + (out / "results.json").write_text(json.dumps(summary, indent=2) + "\n") + + with (out / "results.csv").open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "code", + "readback", + "mean", + "p1", + "p50", + "p95", + "signal", + "ratio", + f"pred_k{args.model_k}", + ] + ) + for point in points: + row = next(item for item in results if item["request_gain"] == point["code"]) + writer.writerow( + [ + point["code"], + point["readback_gain"], + f"{row['mean']:.4f}", + f"{row['p1']:.2f}", + f"{row['p50']:.2f}", + f"{row['p95']:.2f}", + f"{point['signal']:.4f}", + f"{point['ratio_measured']:.6f}", + f"{point[f'ratio_pred_k{args.model_k}']:.6f}", + ] + ) + + script_path = out / "cam-script.yaml" + emit_cam_script(script_path, gains, args.model_k, args.exposure_us) + print( + f"\nWrote {out / 'results.json'}, {out / 'results.csv'} " + f"and {script_path}", + flush=True, + ) + print( + f"Black estimate p1@g0={black:.2f} DN " + f"-> blackLevel_={analysis['black_level_16bit']} at 16-bit", + flush=True, + ) + if best: + print( + f"Best linear k for G=k/(k-code): k={best['k']} mse={best['mse']:.6f}", + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())