Skip to content

Repository files navigation

3A ISP Lab —— AE / AWB / AF 算法与调优实验平台

CI License: MIT Python

一个从物理建模 → ISP 链路 → 3A 闭环控制 → 量化评价 → 实验报告全流程跑通的 相机 3A 算法实验平台。所有结论都有可复现的数字,而不是"看起来很对"。

  • 仿真相机:光学(离焦 PSF / 镜头阴影 / 横向色差)、传感器(满阱、光子散粒噪声、 读出噪声、CFA 串扰、ADC 量化)、光源(黑体色温、交流纹波)
  • 完整 ISP:BLC → LSC → 去马赛克(双线性 / 色差插值)→ AWB → CCM → 色调映射 → 降噪锐化
  • 3A 算法:5 种测光方式 + 变步长闭环 + 标定查表;5 种 AWB 算法 + 置信度融合 + 色温先验; 5 种对焦评价函数 + 4 种搜索策略
  • 时域:抖动源建模(光源波动 / 闪烁相位 / 执行器量化)、对数域 EMA 时域滤波、 三信号投票的场景切换检测(含误触发率与分离度标定)
  • 评价指标:光源角度误差、ΔE00、中性区残余色度、CCT/Duv、收敛帧数、半高宽、动态范围……
pip install -e ".[dev]"      # 可编辑安装(提供 aaa-isp-lab 命令)
aaa-isp-lab                  # 跑完全部实验并生成报告(约 4 分钟,约 3200 次成像)
aaa-isp-lab --fast           # 快速模式(小图、少重复,约 45s)
pytest                       # 51 个单元测试(38 个纯 Python + 编译后 13 个 C 路径)

不想安装也可以直接跑:python run_all.py --fast(等价于 python -m aaa_isp_lab)。

产出:report.html(自包含,含全部图表与结论)、report.pdf、report.md、results.json。 仓库里的 docs/ 是已发布的报告快照(随代码一起提交,README 直接引用); 默认输出到 out/,用 --out docs 刷新快照。

ISP 各阶段


为什么用仿真而不是实拍

实拍照片没有真值:你不知道拍摄时的真实光源色温,也不知道镜头的理想合焦位置。 于是"AWB 准不准""对焦准不准"只能靠肉眼判断,无法量化。

仿真里光源色温、反射率、理想合焦位置都是自己设定的,因此每个指标都能给出数字:

要评的东西 评价指标 真值来源
AWB 精度 光源角度误差(度)、CCT/Duv 偏差 设定的光源色温
颜色精度 色卡逐块 ΔE00 场景反射率 × 理想白平衡增益
AE 精度 目标码值误差、收敛帧数、过曝比例 18% 中灰目标
AF 精度 峰位误差、半高宽、动态范围 设定的真合焦位置

理想成像结果的推导(颜色评价的无争议基准): 反射率 r × 光源 L × 理想白平衡增益 (1/L,G 归一化) = r × L_G, 即白平衡正确时,理想图像就是反射率本身乘一个常数。

仿真的边界(必须说清楚)

  • 传感器 RGB 被近似当作 sRGB 原色处理。真实 ISP 在 sensor RGB 标定矩阵上做 AWB, 本近似会引入固定的模型误差 —— 所以所有 ΔE 只用于相对比较,不作为绝对精度结论。
  • 场景是程序合成的,不含真实的材质光谱、镜头像差、传感器非线性。
  • 结论中"哪一类算法更好"是可迁移的;具体数值不能直接搬到真实产品上。

目录结构

aaa_isp_lab/
├── src/aaa_isp_lab/
│   ├── config.py           所有可调参数集中在这里(模拟真实 ISP 的 tuning 表)
│   ├── color_science.py    色温/色度、sRGB/XYZ/Lab、普朗克轨迹、Duv
│   ├── sim/                物理建模:世界怎么成像
│   │   ├── scene.py        合成场景(色卡 / 对焦标板 / 逆光人像 / 单色 / 匀光板)
│   │   ├── optics.py       抗锯齿离焦 PSF、运动模糊、镜头阴影、横向色差
│   │   ├── sensor.py       光电转换、散粒/读出噪声、CFA 串扰、量化
│   │   └── camera.py       曝光分配、闪烁带纹、EV 范围约束、成像入口
│   ├── isp/                处理链路:RAW 怎么变成图
│   │   ├── modules.py      BLC / LSC / 去马赛克 / WB / CCM / 色调映射 / 降噪锐化
│   │   └── pipeline.py     管线编排,同时输出多个"域"供 3A 各自使用
│   ├── aaa/                控制算法:怎么决策
│   │   ├── ae.py           测光 + 变步长闭环 + 标定查表 + 抗闪烁 + 逐帧流式
│   │   ├── awb.py          5 种估计器 + 置信度融合 + CCT/Duv 约束 + 时域稳定
│   │   ├── af.py           5 种评价函数 + 4 种搜索策略 + 四性分析
│   │   └── temporal.py     对数域 EMA 滤波 + 三信号场景切换检测
│   ├── eval/               评价与呈现
│   │   ├── metrics.py      PSNR/SSIM/ΔE00/中性色度 + 时域抖动统计
│   │   ├── image_quality.py 画质指标:斜边 MTF / 光子转换曲线 / 动态范围 / 阴影
│   │   └── report.py       图表与报告生成
│   ├── experiments.py      17 组实验的定义(只产出数据,不画图)
│   └── cli.py              命令行入口与结果落盘
├── tests/test_aaa.py       38 个纯 Python 单元测试(含 CIEDE2000 官方测试数据)
├── tests/test_native.py    13 个 C 路径用例(按能否加载共享库条件注册)
├── native/                 C++ 统计通路(可选组件,见 native/README.md)
├── docs/                   已发布的报告快照与图表
└── pyproject.toml

分层是刻意的:sim 只管成像、isp 只管处理、aaa 只管决策、eval 只管评价, 任何一层都不反向依赖上层 —— 所以每个实验都能单独调用、单独验证, 而不是一堆只能整体跑的脚本。


实验结果摘要

完整结论与图表见 out/report.html。以下是关键数字:

AE 测光(逆光:大面积亮背景 + 正中主体)

测光方式 帧数 最终 EV 主体码值 过曝比例
全画面平均 7 -2.72 83 0.00%
中心加权 6 -2.55 87 0.03%
点测光 3 -1.63 118 0.48%
分区评价 6 -2.68 83 0.00%
高光优先(99分位) 7 -1.75 114 0.43%

五种方式全部正常收敛,主体码值却相差 35 —— "AE 准不准"这个问法本身是错的, 先要问测的是什么。

逆光场景五种测光方式对比

AE 收敛过程与误差下降曲线

AE 执行器分配(低照度)

策略 曝光时间 增益 SNR 清晰度
优先快门 33.3 ms 1.89× 17.44 dB 0.042
优先增益 16.7 ms 3.79× 17.43 dB 0.058

在光子噪声主导的亮度区间,增益放大的是同一份光子噪声,SNR 几乎不变; 换来的只是更短的曝光和更少的运动模糊。

⚠️ 这个结论有边界:它成立的前提是读出噪声折算在传感器输入端(ISO 无关)。 真实传感器的读出噪声主要来自源增益之后的电路,高增益会把暗部的噪声底压低 —— 见下面「画质指标」一节的实测对比。

曝光策略对比与抗闪烁

交流光源带纹:曝光时间是否落在 10ms 整数倍上

AWB(光源角度误差,度)

场景 无白平衡 灰世界 白块 灰边 SoG 融合
色卡 5000K 10.61 7.90 1.56 5.88 1.09 0.52
逆光 6500K 1.45 4.86 2.87 4.02 3.56 3.75
单色 3000K 32.50 12.50 4.34 32.50 9.40 2.42

同一套算法在不同场景下的排名完全颠倒,没有一种算法在所有场景都赢。

AWB 各算法在不同场景下的输出与光源误差

各算法光源估计误差对比

色温先验:只治色温,不治 Duv

场景 真值 约束前 Duv 误差 约束后 误差
红色主导 3000K 1688K -0.0130 17.16° 1986K 12.50°
蓝色主导 3000K 7172K +0.0077 36.59° 7172K 36.59°
标准色卡 5000K 4194K -0.0005 7.89° 4194K 7.89°

三个场景只有一个触发了约束;误差最大的那一个(36.6°)估计色温"看着很正常", 错误全在 Duv 方向上 —— 把色温范围钳位当作保护措施,实际几乎不起作用。

色温先验约束的效果

对焦评价函数

函数 峰位误差 半高宽 动态范围 峰位抖动σ
Brenner 0.025 0.20 18.5 dB 0.030
Tenengrad 0.050 0.15 15.0 dB 0.041
拉普拉斯方差 0.050 0.15 31.6 dB 0.037
SML 0.025 0.20 15.3 dB 0.019
FFT 高频占比 0.050 0.45 2.6 dB 0.032

FFT 高频占比看起来最"优雅",动态范围只有 2.6 dB —— 几乎没有分辨能力。

五种对焦评价函数曲线对比

搜索策略

策略 帧数 定位误差
全扫描 21 0.050
爬山 6 0.070
粗到细 15 0.000
黄金分割 14 0.032

黄金分割在严格单峰函数上理论最优,实测输给粗到细 —— 它一旦被假峰误导, 就会被永久关在错误的区间里(区间收缩不可逆)。理论最优 ≠ 工程可用。

搜索策略:帧数与定位精度的取舍

3A 耦合:三者不是独立的三个模块

欠曝到 -3 EV 以下 AWB 直接完全失效(误差 0.69° → 10.61°);过曝时对焦评价函数的 动态范围从 15.0 dB 塌到 7.5 dB。欠曝主要伤 AWB,过曝主要伤 AF。

3A 耦合:曝光对 AWB 与 AF 的影响

时域:抖动从哪来,以及为什么滤波不是免费午餐

先说一个负结论:测光量是整帧空间平均,480×360 下光子散粒噪声被平均掉, 实测噪声地板只有 1.2e-4 EV —— 比收敛阈值低两个数量级。也就是说 静止场景下 AE 的抖动恒等于浮点噪声,「时域滤波把抖动降低 90%」在这个分辨率下 是伪结论。要让这件事有意义,扰动必须作为显式的物理源注入:光源强度波动、 闪烁相位漂移、执行器量化。

其中执行器量化最关键,它是确定性的 —— 不需要任何噪声,抖动来自控制结构本身。 而且严格分域:亮场景增益钳在 1.0,抖动由曝光时间量化主导;暗场景曝光时间顶在上限, 抖动全部来自增益档位(1/6 EV 档位下实测是噪声地板的 373 倍,台阶越大抖动越大)。

然后是三个和直觉相反的实测结果:

  1. 时域滤波在 AE 闭环里不降抖动,开太狠反而失稳。 固定 α 的曝光抖动全都比 不滤波更差,α=0.2 涨到 0.21 EV、α=0.1 直接不收敛(1.9 EV)。根因是 AE 是 变步长的(大误差时 d=0.9,过曝补偿还能推到 1.0),慢滤波的相位滞后叠加这个 大环路增益把闭环推向振荡。滤波强度存在稳定边界。
  2. 真正有价值的是场景切换检测器。 同一滤波强度下成对比较:α=0.4 时重收敛从 17 帧降到 6 帧,而抖动分毫不变。它买的是「切换瞬间立刻松手」,不牺牲稳态平滑。
  3. AWB 侧结论相反,因为它结构不同。 AWB 是开环估计器(逐帧独立、无反馈), 滤波只做平均:增益跳动降 5.8 倍,而光源角度误差均值几乎不变(不引入偏差)。 同一种滤波,放在开环里是纯收益,放进闭环里就得先算稳定边界。

检测器的一个必须讲清楚的设计点:AE 还没收敛时,「场景变了」和「AE 还没调好」 在观测上是不可分的 —— 过曝时像素饱和会同时破坏曝光归一化和结构信号的标度不变性。 所以检测器只在 AE 连续稳定若干帧后才武装,且刚武装时重置参考量。 这条护栏实测让「AE 从 +3 EV 收敛」这条最要命的假阳性变成零触发。

已知边界:三个信号测的是亮度与内容变化。等亮度色温切换(5000K→3000K)实测 全部低于门限,检出 0/2 —— 要覆盖它得另加色度信号。如实记录,不藏。

C++ 统计通路与定点化(可选组件)

AE 测光与 AWB 统计的逐像素通路另有一份 C++ 实现,编成 extern "C" 共享库、 用 ctypes 加载。不编译也能跑(默认后端是纯 Python,路径与结论都不变)。

python tools/build_native.py --with-bench    # 需要 g++/clang++,不需要 MSVC
./native/build/aaa_native --self-test        # 23 项不依赖 numpy 的解析自检

为什么不用 setuptools Extension / pybind11:Windows 上的 CPython 是 MSVC 构建的, 而开发机只有 MinGW g++,MinGW 编译的扩展模块无法可靠链接。改成纯 C ABI 的共享库后, 两个工具链都能编、不引入任何 pip 依赖,而且这个库脱离 Python 也能编能跑。 (顺带踩到:-static-libgcc -static-libstdc++ 不够,这个 MinGW 构建下 std::string/std::vector/异常处理还会拉进 libwinpthread-1.dll,必须用 -static。)

结论一:加速来自算法,不是语言。

倍数
算法改进(Python→Python,只改算法) 1.45×
实现改进(算法不变,换 C++) 11.1×
总加速 16.1×
纯语言差异(全画面平均,两边都没有算法可改) 0.89×

最后一格是关键:在没有算法差异的地方,C 反而比 numpy 略慢(numpy 的 mean() 是 SIMD 归约)。那 16 倍来自重写时顺手消除了结构性的浪费 —— 现状每帧做 5 次全帧 布尔 gather、4 次 Sobel(其中 2 次完全重复)、3 次全帧 luma、2 次全帧饱和度、 4 次全帧开方,且无条件计算 4 个估计器(其中 shades_of_gray 的结果根本没进融合)。 "C++ 比 numpy 快 16 倍"是错的说法。

结论二:等价性可以做到逐位(但有版本边界)。

white_patch 的 99.5 分位与开发环境验证过的 numpy 1.26.4 逐位相等(偏差 0.00e+00), 做法是照抄 numpy 的 virtual_index 运算顺序、_lerp 的两分支写法、以及 (b-a) 必须在 float32 里减。掩码计数 n_valid 同样逐位相等。

⚠️ 这个「逐位」是有版本依赖的:CI 装的是 numpy 2.x,而 numpy 在 1.26 → 2.x 之间 改过 quantile 的实现,同一份输入下 p99 相差约 1 个 float32 ulp(~4e-8 相对)。 C 侧的结果跨平台完全一致,变的是 numpy。 准确说法是「复刻了开发时验证过的那版语义」。

均值类的 ~2.8e-6 相对差不是我们算错了 —— numpy 用 float32 pairwise、C 用 double 顺序累加,差的是 numpy 自身的误差; 测试里还额外断言了"C 更接近 float64 精确值"。

结论三:定点化误差有推导上界,而且位宽 10 位以上就没收益了。

类型 折算 EV 上界 余量
均值类 ≤ 6.8e-06 1.8e-5 80×
分位数 ≤ 4.95e-04 一个 bin 宽 20×

对比收敛阈值 0.02 EV 低两到三个数量级。位宽扫描显示 10 位以上没有收益 —— 瓶颈从"输入量化"转移到了"直方图 bin 宽",继续加位宽是在优化已经不是瓶颈的环节。

定点化的边界:只定点化逐像素统计通路,融合权重、对数域几何平均、CCT 约束、 AE 控制律仍在 double(每帧只作用在 3~5 个数上,没有吞吐论证)。


实现过程中踩到并修掉的坑

这一节是这份工程里最值钱的部分。每一个坑的共同点都是"程序不报错、结果看起来合理", 只有对物理量和算法性质有预期,才能发现。

  1. 曝光归一化搞错:电子数 = 辐射亮度 × 曝光时间(秒) × 满阱 混用了绝对时间与归一化 亮度,整条链路暗了 60 倍,AE / AWB / AF 全部崩溃(表现为"AWB 各种算法结果完全一样", 因为有效像素掩码被全部排除,都走了兜底分支)。正确写法是显式的相对曝光因子, 使得 exposure_factor = 1 时线性域数值等于物理辐射亮度。

  2. AE 的 EV 范围必须与硬件一致:控制器输出范围写成 ±8 EV,而该传感器最多给到 +5 EV —— 控制器一路推到 +8 EV 却永远到不了目标,表现为"不收敛"。现在 ev_limits() 从传感器参数反算范围,AE 直接用。

  3. 离焦 PSF 的亚像素量化:按"像素中心在圆内"生成圆盘核,半径 < 1 px 时核退化成 delta,图像完全不变。结果是评价函数在一大段镜头位置上水平、峰位随机落在 行程中点 0.50(真值 0.35)。改成按覆盖面积做抗锯齿(子采样)后峰位才正确。

  4. 饱和时 AE 的误差被压缩到接近 0:像素顶到 1.0 后亮度不再随曝光增加, 哪怕过曝 2 档,99 分位也只给 1.0,算出的误差被压到 -0.15 EV —— 越曝越看不出来。 实测 12 帧仍未收敛、55% 像素过曝。解法是改用与亮度无关的"过曝像素比例"给出步长下限。

  5. 带纹指标被 Bayer 行交替污染:直接对 RAW 求行均值,奇偶行颜色组合不同 (R+G 与 G+B),凭空多出一个巨大且恒定的"带纹"。必须在去马赛克后的亮度图上测。

  6. 带纹指标的去趋势窗口太小:纹波在画面上只有 2~3 个周期,用 rows/8 的中值窗 去趋势会把真实带纹一起滤掉,测出来只剩真实值的百分之几。改用二次多项式去趋势 + 只在已知纹波频点上取幅度,实测"关抗闪烁 0.147 / 开抗闪烁 0.0001",相差千倍。

  7. 测试场景选错会得出伪结论:用色卡测带纹 → 场景自身的行结构被当成带纹 → 得出"开抗闪烁反而更差"的错误结论。测试场景必须行轮廓平坦(匀光板), 这也是产线上的实际做法。同理,均匀度必须在匀光板上测色卡上测的是画面内容。

  8. 测试场景的构图决定能不能测出差异:测光方式的对比里,主体原来占画面 30%, 结果五种测光方式几乎没差别(都被"顺便"保住了)。改成主体占 8%、亮背景占 55% 之后差异才显现。

  9. 光照充足时看不出快门/增益策略的差异:EV<0 时增益被钳在 1.0,两种策略结果完全一样。 必须把场景调暗 12 倍(约 3.6 档)让 EV>0 才测得出。

  10. 仿真里不加光谱串扰,颜色题就是假题:传感器通道恰好等于场景反射率时, CCM 退化成单位阵,"白平衡 + CCM 的贡献拆解""AWB/CCM 耦合"全都变成自欺欺人。 加上真实存在的 CFA 串扰后,标定出的 CCM 才带负交叉项。

  11. _to_u8 的 dtype 陷阱:np.asarray(img, np.float32) 之后再判 dtype == uint8 永远为假,uint8 图被乘两次 255 → 整幅图全白。图"看起来是空的",程序不报错。

  12. 结论必须由数据支持,不能反过来:最初想用"AF 帧数随曝光变化"讲 3A 耦合, 实测帧数由搜索策略决定、与曝光无关 —— 于是改成评"评价函数动态范围"。 报告里每一条结论都对应一组实测数字,对不上的地方就改结论,不改数据。


已知不足与可以继续做的方向

  • 没有真实相机验证:仿真结论需要一条真机链路做交叉验证 (用 OpenCV 手动控制真实摄像头的曝光/增益/白平衡,复现几个关键结论)
  • 时域滤波只做了最小闭环:标量 EMA(对数域)+ 场景切换检测。双速率以上的 自适应 τ、运动检测(区分"场景切换"与"相机运动")、按场景分类的分支策略都没做。 另外检测器测的是亮度与内容变化,不测色度变化 —— 等亮度色温切换(5000K→3000K) 实测三个信号全部低于门限,要覆盖它得另加色度信号。 抖动源是仿真注入的(光源强度波动 / 闪烁相位漂移 / 执行器量化),幅度是设定的 而非真机实测谱 —— 真机抖动谱需要在真机上测。
  • 更完整的 AWB:色域(gamut)约束、按场景分类分支、时序先验
  • AF 只做了 CDAF:相位检测 AF(PDAF)的双像素模型、混合对焦策略
  • 色调映射过于简单:只有全局曲线 + 肩部压缩,没有局部色调映射与 HDR 融合
  • 端侧量化/性能只做了统计通路的一个 C++ 原型:AE 测光与 AWB 统计已重写为 extern "C" 共享库(native/),并给出有推导上界的定点化误差预算与性能对照。 未做:整条 ISP 的定点化(去马赛克、CCM 仍是浮点)、控制律的定点化、 面积/功耗/时序估算、RTL 与真机验证。性能数字依赖机器与编译器,不是可复现量; 等价性与误差数字是可复现的。

环境

Python 3.8+,依赖 numpy、opencv-python、scipy、matplotlib。 (开发环境为 Python 3.10 + numpy 1.26 + opencv 4.9 + scipy 1.13 + matplotlib 3.8)

pip install -e ".[dev]"      # 依赖以 pyproject.toml 为唯一来源

提示:需要中文字体(Windows 自带 Microsoft YaHei,Linux 需安装 SimHei 或 Noto Sans CJK),否则图表里的中文会显示成方块。

About

3A(AE/AWB/AF)算法与 ISP 画质调优实验平台:从物理建模到量化评价的完整仿真链路

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages