From patchwork Mon Aug 10 17:46:10 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: John Cronin X-Patchwork-Id: 27732 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 C5456BE080 for ; Mon, 10 Aug 2026 17:46:18 +0000 (UTC) Received: from lancelot.ideasonboard.com (localhost [IPv6:::1]) by lancelot.ideasonboard.com (Postfix) with ESMTP id 384B6681FC; Mon, 10 Aug 2026 19:46:18 +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="K8hxYj4m"; dkim-atps=neutral Received: from mail-wr2-x00.google.com (mail-wr2-x00.google.com [IPv6:2a00:1450:4864:30::]) by lancelot.ideasonboard.com (Postfix) with ESMTPS id B8F9C681F3 for ; Mon, 10 Aug 2026 19:46:15 +0200 (CEST) Received: by mail-wr2-x00.google.com with SMTP id ffacd0b85a97d-470713a9053so609534f8f.0 for ; Mon, 10 Aug 2026 10:46:15 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=stromback-com.20251104.gappssmtp.com; s=20251104; t=1786383975; x=1786988775; 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=hlk3bMjMohRi3h4f+Ydb6MYzBFSCw+U4akt6e65berY=; b=K8hxYj4mQCWXPZ6ijwvlJDvGTu+2YYPaaa+chGH+BKFSZT76rKRuCiW8w47l5oVdui wIXadc5LBX9IEjObDjBgmkfOXdrUW1rQMTdK6z1fdJM/KEy1dhfuxMyvQ4nXc/+jjr3c x89yF6lqVE+Z+SJaDPoSL6scbscNhxklhKaWasOxeyzPbjDBr+Zg8zn7MZmzYeUC/97I V0t1teO1adnUOZOjJApGeeu71at3rgnvka6uFvECvCVfm7N24PZbiwOEWQj/o5ljcbcI ySxx5tncYRmyUByskPgNmd/+prsKaoUzEtPw3yLouF7GNdWWCrmm7SOltwrqMYYtzHGU d3Pg== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1786383975; x=1786988775; 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=hlk3bMjMohRi3h4f+Ydb6MYzBFSCw+U4akt6e65berY=; b=r/F6JSWFnsuShjO6ymAElG/MH/69065kTXxPzjaJNdUJi2CXuYZRAIwhoYlJchCcEj 4SHBNGd7VwsAW+GX4aNzn4Bj7ZOsdp5iPhjTofqPUZHLfRDEhs3HUxx7SVccBRwhhfbV mmvYRJ7UTvyWsRGXVFTMWFa+GTc86izCqYAKZDGpzICWsqhMvwPYUSY60Ll6vVodAxv2 qu7vcQdW92y/kT9uoHCxx3JhvhtBY+6AFcjm1oF9j1PYd9IuZNeVKcnVMSXJE90wlV8u J93GdeG/t7b7IVXCfVOErdinKeWLv7ZCFZBqb8CnP/vAV8SjgQQlys2DYswLC6KnZYDK mfZw== X-Gm-Message-State: AOJu0Yyioe9EmlSDpoZesIn6P6D7QKya/lZVWQ5OU2x52vyHrIVGXBUg +GmImfMrMH0x9bULqRKrpMkOYDSjAbM918XvnTn4DWYUwflZ5Y/xIPjhLDjYgexqHH4apsvCUxM sp2pLSUk64oBCaS0= X-Gm-Gg: AR+sD12vYMSMoazCeOPE0AAESYWO5jWpTL3MdEcD7MhnvuG9eDF9GmS7F+3ii7RPsy3 ysh1GIwoyfv/Ro1+IzYJ13XThPdU8pN2O5AQlofGt1LHUyyB3Qge4b9moMuLNU4vCPPQigA9tlv D7AHGfpfkIOH/rGRwp/LKrZSSLoCfpTFZlrjHg30f8rgGKtF5dXmE9+34ulUQji6bB27jnx0H96 9tj7JWKT7lEKHvr2JjLZC7hyOe1h2G4VsrHNCxCc4IC64QLJvjwFBWyI2+xv/+W1S35RS33tfR2 Lkru3/RxdConfxd0FOKyKWkJJejiemEWqSU6b9fHZBa6WneqndQlyKrdF4zz2T33bXMsGnoOuf5 GdoTvhw/uxamareaX5yuISSnLmfMFoNbQNvvRcqMMmofLK/Gj4fLPEJXs8/dVQHjyzkCwg264hT k+12cBt7Lu6g7HAEmtgk+KLgzItBAh4I1pQPbvpQ342n8rXgEZ9WxVz05OmJa9vL12IO+my9fYO fryk4aclb22WzVMLyXxXk74YQo+EoqBMogvypGBIEdNvDzXTxP2qkrt9kyx5zCLrdoOXJ/uGbbT CZHjtcYRdfofJXmTqT3YAdPXaqT81FAQXjkvhh2hlrnSbf6uQ3keoc9+81/D/MT/XCriKEjrE10 xrubLUMu222OoMkgGh8g4gAyU/tvSWfbPOq/dalPbTNdrkz+suttynL22pV9ZAe1f9OiXIbczHB uWXUDM X-Received: by 2002:a05:6000:2997:10b0:481:4697:8808 with SMTP id ffacd0b85a97d-48146978ee3mr1758750f8f.22.1786383974690; Mon, 10 Aug 2026 10:46:14 -0700 (PDT) Received: from fedora.tail5f8cb3.ts.net (251-61.dsl.iskon.hr. [89.164.251.61]) by smtp.gmail.com with ESMTPSA id ffacd0b85a97d-480021506desm35040583f8f.10.2026.08.10.10.46.13 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Mon, 10 Aug 2026 10:46:14 -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 v2 1/2] utils: Add measure-analogue-gain helper Date: Mon, 10 Aug 2026 13:46:10 -0400 Message-ID: <20260810174611.2472046-2-john.cronin@opcenter.com> X-Mailer: git-send-email 2.55.0 In-Reply-To: <20260810174611.2472046-1-john.cronin@opcenter.com> References: <20260810174611.2472046-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 optional digital gain) via v4l2-ctl, steps V4L2_CID_ANALOGUE_GAIN, captures raw frames with the libcamera cam utility, and reports mean/percentile stats plus a fit of G = k/(k-code). Keep it as a developer script under utils/ only (not installed). Run it from the libcamera source tree. Signed-off-by: John Cronin --- utils/measure-analogue-gain.py | 466 +++++++++++++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100755 utils/measure-analogue-gain.py diff --git a/utils/measure-analogue-gain.py b/utils/measure-analogue-gain.py new file mode 100755 index 0000000..9072a55 --- /dev/null +++ b/utils/measure-analogue-gain.py @@ -0,0 +1,466 @@ +#!/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+. +# +# 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 + +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 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)", + ) + args = parser.parse_args() + + 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}", + ] + ) + + print(f"\nWrote {out / 'results.json'} and {out / 'results.csv'}", 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()) From patchwork Mon Aug 10 17:46:11 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: John Cronin X-Patchwork-Id: 27733 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 93015BE080 for ; Mon, 10 Aug 2026 17:46:21 +0000 (UTC) Received: from lancelot.ideasonboard.com (localhost [IPv6:::1]) by lancelot.ideasonboard.com (Postfix) with ESMTP id 55E0B68209; Mon, 10 Aug 2026 19:46:21 +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="XO0AIBUr"; dkim-atps=neutral Received: from mail-wr1-x431.google.com (mail-wr1-x431.google.com [IPv6:2a00:1450:4864:20::431]) by lancelot.ideasonboard.com (Postfix) with ESMTPS id 73FBF681FC for ; Mon, 10 Aug 2026 19:46:16 +0200 (CEST) Received: by mail-wr1-x431.google.com with SMTP id ffacd0b85a97d-4799b3f7c83so1384638f8f.2 for ; Mon, 10 Aug 2026 10:46:16 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=stromback-com.20251104.gappssmtp.com; s=20251104; t=1786383976; x=1786988776; darn=lists.libcamera.org; h=content-transfer-encoding:content-type: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=29flHvpEQFTHVL8xaiOE7lIwnai795l7bDKFZdaQ4d8=; b=XO0AIBUrnVQSyl2PrSGxVUEBe27s1Vakxg07OLUrKfdZfVW7HE31hHKtk42G3gIve2 OGTrxMhurMuOrBKcW0d9mdZdX/flveiuiJ/OPjpTjp1pNljQU1kTpEVt5tEn0EacWP+u LVAgScWxUfz4JRb+XqHizvlBFBZwokrlvRgd9yr+XtmWQe3/DiZpmQDveC3EiNJxEYMB UlLuY77e+DxAfWYH85rrIQtedSbnh6BCVHIChBpDrbgqrfVfjVx60ZbiQumweYJi6Izr AwXfFsCMyD5H4A0qg4G8F6mbGx0rlXS0SL9uyX8EYOOz0y9NmPu4Rd8AC283PiJIrxon cuOQ== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1786383976; x=1786988776; h=content-transfer-encoding:content-type: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=29flHvpEQFTHVL8xaiOE7lIwnai795l7bDKFZdaQ4d8=; b=l+25e0hyEddn7BgEifg+20sDf7aoWa6BdZL9aB1fYxIWqFQwAIWlnzxMI9xnGhC5qJ MwZNXD0uH8NTDE9/IlblknaaqPbVSBowmFbpqdViuSnmC1Zki+RxCAKyoB0Pyf1z7NjF XzrSrlY5JX+h2Wcue9vTgzKlrFWFsaZ7UJSck0STt6YcL89Mts0BMuYye4Lncbqup5iQ NIHED33LACHWRXIuDFb4h2p1s8mM3sCjNwEC5M7cVutPa8+4MYB7EurPN2pRvPg3MksT SBcuaAobw1j5+ZgIOnkjLfKge2RxVSs/oHkS+CgQdgJ4Oqo4b5lWlPwCmtEiX0ftR0Be K+Zg== X-Gm-Message-State: AOJu0YxgmEI5kgX2a62/fP9s62RuyBIBRTyjuAn2POTKPHs4j7snzUM+ jPxzLesXikSz+yUtlqWaGYK1GF/fEPUr7JKTHrlM4CMvkCYFijX+VsLtTIPlyPoOcoZesWLS9GW Ht3x0iZ/ym9IM X-Gm-Gg: AR+sD12I6eGmnFgu6/Kh0c62ovZg3vaNkK5LgmBq41tUCP1Hp2ru0rFyqMPKFn8E+ao yNChujjZBcG6RyH9gntUYRvnB7UaA34FLHZ2YCgsyuPBk7jIMft56irpJYLQMckSXNWXIhl/Y+n oOfmItALcHyWeAsi0zjc2YZtIOiGSupOfcSjsoBFhRiHYfbp3KwNtAFb93p8OIiSxqKv+24Ev52 Ka+L8xU+kQ5zCKN94t46cAqUIrUZeqM9RqUgXMcdFCjG9kunuVmJlHYgWzYXiZbvAZ0NnRH4QIW tYBucCPr98Ozp6iC79K50HL051YaDqkh9mfp7k4pnx6fgZiWlZoXCVARZC4h9Uy0FOf8zjcXa6A NauGI+8pHOa54Cv9xTHpzHVgQ+J1BMHnjDvdAaWAyb40gJje1+6bvbNGoITTd2rDf4mjeA5l5Sv UojOx5MAHY8iUuSry5ywf3MVdSxN5ReJGvtL51CJASQVu9EfZ6TdIVxtEA9iTAjk8lrbnKZuZNz fvDDtuD94fzawsDItzMRvgVzZcyX44vucYLcuKqrAfAUDd/GmAttgfAA9UKANSfYUlT7mrOfbpi CAL7CTqM8kSrwUZsPyTxlbqHZ/7N3IjsKHhBA4PBSD0ITWCqrgrcllsUjoCdWTyl0Rewn7nKvOz f7GXCnapFwhLIzSOy8muspCdErIDjmHraKtDhU+lQAmseULDZ/k/BjIienkvWXJD5nTU9UQ== X-Received: by 2002:a05:6000:2f89:b0:47f:c62e:9cca with SMTP id ffacd0b85a97d-4814564d767mr5833197f8f.22.1786383975994; Mon, 10 Aug 2026 10:46:15 -0700 (PDT) Received: from fedora.tail5f8cb3.ts.net (251-61.dsl.iskon.hr. [89.164.251.61]) by smtp.gmail.com with ESMTPSA id ffacd0b85a97d-480021506desm35040583f8f.10.2026.08.10.10.46.14 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Mon, 10 Aug 2026 10:46:15 -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 v2 2/2] Documentation: Add Camera Sensor Helper guide Date: Mon, 10 Aug 2026 13:46:11 -0400 Message-ID: <20260810174611.2472046-3-john.cronin@opcenter.com> X-Mailer: git-send-email 2.55.0 In-Reply-To: <20260810174611.2472046-1-john.cronin@opcenter.com> References: <20260810174611.2472046-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" Document how to add a CameraSensorHelper, measure analogue gain and black level with utils/measure-analogue-gain.py (run from the source tree), and what to put in camera_sensor_properties versus the helper. Signed-off-by: John Cronin --- Documentation/guides/camera-sensor-helper.rst | 148 ++++++++++++++++++ Documentation/index.rst | 1 + Documentation/meson.build | 1 + 3 files changed, 150 insertions(+) create mode 100644 Documentation/guides/camera-sensor-helper.rst diff --git a/Documentation/guides/camera-sensor-helper.rst b/Documentation/guides/camera-sensor-helper.rst new file mode 100644 index 0000000..6e62624 --- /dev/null +++ b/Documentation/guides/camera-sensor-helper.rst @@ -0,0 +1,148 @@ +.. SPDX-License-Identifier: CC-BY-SA-4.0 + +Camera Sensor Helper Guide +========================== + +This guide explains how to add a ``CameraSensorHelper`` for a new sensor and how +to measure the analogue gain model and black level when a public datasheet is +not available. + +Background +---------- + +The software ISP and other IPA modules need to know how the sensor maps +analogue gain control codes to linear gain factors, and which digital number +corresponds to optical black. That information lives in +``src/ipa/libipa/camera_sensor_helper.cpp`` as a small helper class registered +with ``REGISTER_CAMERA_SENSOR_HELPER``. + +Static metadata such as unit cell size and test pattern mode maps belong in +``src/libcamera/sensor/camera_sensor_properties.cpp``. Sensor control application +delays should only be listed there when they have been measured or documented; +leave ``sensorDelays`` empty to use libcamera defaults. + +Adding a helper +--------------- + +1. Confirm the kernel driver name (for example ``imx471``) and the V4L2 control + ranges for exposure and analogue gain (``v4l2-ctl -d /dev/v4l-subdevX + --list-ctrls``). +2. Prefer values from a datasheet when available. Document the source in the + commit message and, if short, a brief code comment (for example + ``/* From datasheet: 64 at 10bits. */``). +3. If no datasheet is available, measure gain response and black level as + described below. Put the measurement summary in the **commit message**; keep + in-code comments short. +4. Register the helper under the same string the kernel uses for the subdev + name (without bus address suffixes). + +Common Sony sensors program a linear code ``c`` such that: + +.. math:: + + G = \frac{k}{k - c} + +with ``k = 1024`` for many IMX models. The maximum V4L2 code may still be lower +than ``k - 1`` (for example codes ``0..800``), which yields a modest maximum +gain even when the model is correct. + +Measuring analogue gain +----------------------- + +The ``utils/measure-analogue-gain.py`` helper automates a fixed-exposure gain +sweep using the libcamera ``cam`` tool and ``v4l2-ctl``. It is a developer +tool intended to be run from a libcamera source tree (it is not installed +with the package). + +Dependencies: + +* ``cam`` (libcamera tools), or set ``LIBCAMERA_CAM`` to a wrapper/command +* ``v4l2-ctl`` from v4l-utils +* Python 3.10+ + +High-level procedure: + +1. Capture **raw** Bayer frames (``cam --stream role=raw,...``), not processed + RGB. Soft ISP AGC is avoided so the V4L2 codes you set stay put. +2. Lock exposure (and digital gain if present) on the sensor subdev. +3. Step ``V4L2_CID_ANALOGUE_GAIN`` across the driver range. +4. Compute the mean of active-area samples (useful bit depth, for example + 10-bit values carried in 16-bit words). +5. Black-subtract using a low percentile at minimum gain, or a dark frame. +6. Fit measured brightness ratios to ``G = k/(k-code)`` and compare with the + model you intend to hard-code (often ``k = 1024``). + +Terminology used by the tool: + +* **p1** — first percentile of the sample histogram (near-black floor) +* **p50** — median +* **signal** — ``mean - black`` after black subtraction +* **ratio** — ``signal(code) / signal(0)`` + +Example (Sony IMX471 on an IPU7 laptop, 1928×1088, stride 3904): + +.. code-block:: shell + + ./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 \ + --model-k 1024 \ + --out /tmp/gain-measure + +The tool writes ``results.csv`` and ``results.json`` including relative error +versus the reference model and a best-fit ``k`` for ``G=k/(k-code)``. + +Choose an exposure short enough that the highest gain code does not saturate +(watch the reported ``p95`` / ``max`` columns). If the scene is too dark, +increase exposure carefully and re-run. + +Measuring black level +--------------------- + +Prefer a **covered lens** dark frame at minimum analogue gain. If that is not +practical, a very short exposure at minimum gain is a useful approximation. + +.. code-block:: shell + + # Cover the lens if possible, then: + ./utils/measure-analogue-gain.py --sensor-name imx471 --dark \ + --width 1928 --height 1088 --stride 3904 --bit-depth 10 \ + --out /tmp/gain-measure + +For a 10-bit sensor pedestal of ``B`` DN, the 16-bit black level used by +helpers is typically ``B << 6`` (for example ``64`` → ``4096``). + +Do not invent a pedestal solely because another sensor in the same vendor +family uses that value; measure when the datasheet is missing. + +Sensor geometry and properties +------------------------------ + +``camera_sensor_properties.cpp`` entries should list: + +* ``unitCellSize`` in nanometres when known +* ``testPatternModes`` mapped to the modes the kernel driver registers +* ``sensorDelays`` only when verified + +Frame sizes and crop rectangles still come from the V4L2 subdev format and +selection API; properties do not replace a complete kernel driver. + +Submitting the result +--------------------- + +* Use ``git format-patch`` and ``git send-email`` so maintainers can ``git am`` + the series without MUA line wrapping (see :doc:`/contributing`). +* Split helper registration and static properties into separate commits when + both change. +* Put measurement methodology and tables in the commit message (and this guide + when adding or improving the tool), not large duplicated comments in the + helper constructor. + +Related reading +--------------- + +* :doc:`/sensor_driver_requirements` +* :doc:`/camera-sensor-model` +* :doc:`/guides/ipa` diff --git a/Documentation/index.rst b/Documentation/index.rst index e40cd0c..0712c27 100644 --- a/Documentation/index.rst +++ b/Documentation/index.rst @@ -22,6 +22,7 @@ Architecture Pipeline Handler Writer's Guide IPA Writer's guide + Camera Sensor Helper Guide Tracing guide Camera Sensor Model SoftwareISP Benchmarking diff --git a/Documentation/meson.build b/Documentation/meson.build index a156bd0..3e14498 100644 --- a/Documentation/meson.build +++ b/Documentation/meson.build @@ -158,6 +158,7 @@ if sphinx.found() 'design/ae.rst', 'feature_requirements.rst', 'guides/application-developer.rst', + 'guides/camera-sensor-helper.rst', 'guides/ipa.rst', 'guides/pipeline-handler.rst', 'guides/tracing.rst',