一个从物理建模 → 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 刷新快照。
实拍照片没有真值:你不知道拍摄时的真实光源色温,也不知道镜头的理想合焦位置。 于是"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。以下是关键数字:
| 测光方式 | 帧数 | 最终 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 准不准"这个问法本身是错的, 先要问测的是什么。
| 策略 | 曝光时间 | 增益 | SNR | 清晰度 |
|---|---|---|---|---|
| 优先快门 | 33.3 ms | 1.89× | 17.44 dB | 0.042 |
| 优先增益 | 16.7 ms | 3.79× | 17.43 dB | 0.058 |
在光子噪声主导的亮度区间,增益放大的是同一份光子噪声,SNR 几乎不变; 换来的只是更短的曝光和更少的运动模糊。
| 场景 | 无白平衡 | 灰世界 | 白块 | 灰边 | 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 |
同一套算法在不同场景下的排名完全颠倒,没有一种算法在所有场景都赢。
| 场景 | 真值 | 约束前 | 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 |
黄金分割在严格单峰函数上理论最优,实测输给粗到细 —— 它一旦被假峰误导, 就会被永久关在错误的区间里(区间收缩不可逆)。理论最优 ≠ 工程可用。
欠曝到 -3 EV 以下 AWB 直接完全失效(误差 0.69° → 10.61°);过曝时对焦评价函数的 动态范围从 15.0 dB 塌到 7.5 dB。欠曝主要伤 AWB,过曝主要伤 AF。
先说一个负结论:测光量是整帧空间平均,480×360 下光子散粒噪声被平均掉, 实测噪声地板只有 1.2e-4 EV —— 比收敛阈值低两个数量级。也就是说 静止场景下 AE 的抖动恒等于浮点噪声,「时域滤波把抖动降低 90%」在这个分辨率下 是伪结论。要让这件事有意义,扰动必须作为显式的物理源注入:光源强度波动、 闪烁相位漂移、执行器量化。
其中执行器量化最关键,它是确定性的 —— 不需要任何噪声,抖动来自控制结构本身。 而且严格分域:亮场景增益钳在 1.0,抖动由曝光时间量化主导;暗场景曝光时间顶在上限, 抖动全部来自增益档位(1/6 EV 档位下实测是噪声地板的 373 倍,台阶越大抖动越大)。
然后是三个和直觉相反的实测结果:
- 时域滤波在 AE 闭环里不降抖动,开太狠反而失稳。 固定 α 的曝光抖动全都比 不滤波更差,α=0.2 涨到 0.21 EV、α=0.1 直接不收敛(1.9 EV)。根因是 AE 是 变步长的(大误差时 d=0.9,过曝补偿还能推到 1.0),慢滤波的相位滞后叠加这个 大环路增益把闭环推向振荡。滤波强度存在稳定边界。
- 真正有价值的是场景切换检测器。 同一滤波强度下成对比较:α=0.4 时重收敛从 17 帧降到 6 帧,而抖动分毫不变。它买的是「切换瞬间立刻松手」,不牺牲稳态平滑。
- AWB 侧结论相反,因为它结构不同。 AWB 是开环估计器(逐帧独立、无反馈), 滤波只做平均:增益跳动降 5.8 倍,而光源角度误差均值几乎不变(不引入偏差)。 同一种滤波,放在开环里是纯收益,放进闭环里就得先算稳定边界。
检测器的一个必须讲清楚的设计点:AE 还没收敛时,「场景变了」和「AE 还没调好」 在观测上是不可分的 —— 过曝时像素饱和会同时破坏曝光归一化和结构信号的标度不变性。 所以检测器只在 AE 连续稳定若干帧后才武装,且刚武装时重置参考量。 这条护栏实测让「AE 从 +3 EV 收敛」这条最要命的假阳性变成零触发。
已知边界:三个信号测的是亮度与内容变化。等亮度色温切换(5000K→3000K)实测 全部低于门限,检出 0/2 —— 要覆盖它得另加色度信号。如实记录,不藏。
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 同样逐位相等。
均值类的 ~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 个数上,没有吞吐论证)。
这一节是这份工程里最值钱的部分。每一个坑的共同点都是"程序不报错、结果看起来合理", 只有对物理量和算法性质有预期,才能发现。
-
曝光归一化搞错:
电子数 = 辐射亮度 × 曝光时间(秒) × 满阱混用了绝对时间与归一化 亮度,整条链路暗了 60 倍,AE / AWB / AF 全部崩溃(表现为"AWB 各种算法结果完全一样", 因为有效像素掩码被全部排除,都走了兜底分支)。正确写法是显式的相对曝光因子, 使得exposure_factor = 1时线性域数值等于物理辐射亮度。 -
AE 的 EV 范围必须与硬件一致:控制器输出范围写成 ±8 EV,而该传感器最多给到 +5 EV —— 控制器一路推到 +8 EV 却永远到不了目标,表现为"不收敛"。现在
ev_limits()从传感器参数反算范围,AE 直接用。 -
离焦 PSF 的亚像素量化:按"像素中心在圆内"生成圆盘核,半径 < 1 px 时核退化成 delta,图像完全不变。结果是评价函数在一大段镜头位置上水平、峰位随机落在 行程中点 0.50(真值 0.35)。改成按覆盖面积做抗锯齿(子采样)后峰位才正确。
-
饱和时 AE 的误差被压缩到接近 0:像素顶到 1.0 后亮度不再随曝光增加, 哪怕过曝 2 档,99 分位也只给 1.0,算出的误差被压到 -0.15 EV —— 越曝越看不出来。 实测 12 帧仍未收敛、55% 像素过曝。解法是改用与亮度无关的"过曝像素比例"给出步长下限。
-
带纹指标被 Bayer 行交替污染:直接对 RAW 求行均值,奇偶行颜色组合不同 (R+G 与 G+B),凭空多出一个巨大且恒定的"带纹"。必须在去马赛克后的亮度图上测。
-
带纹指标的去趋势窗口太小:纹波在画面上只有 2~3 个周期,用
rows/8的中值窗 去趋势会把真实带纹一起滤掉,测出来只剩真实值的百分之几。改用二次多项式去趋势 + 只在已知纹波频点上取幅度,实测"关抗闪烁 0.147 / 开抗闪烁 0.0001",相差千倍。 -
测试场景选错会得出伪结论:用色卡测带纹 → 场景自身的行结构被当成带纹 → 得出"开抗闪烁反而更差"的错误结论。测试场景必须行轮廓平坦(匀光板), 这也是产线上的实际做法。同理,均匀度必须在匀光板上测色卡上测的是画面内容。
-
测试场景的构图决定能不能测出差异:测光方式的对比里,主体原来占画面 30%, 结果五种测光方式几乎没差别(都被"顺便"保住了)。改成主体占 8%、亮背景占 55% 之后差异才显现。
-
光照充足时看不出快门/增益策略的差异:EV<0 时增益被钳在 1.0,两种策略结果完全一样。 必须把场景调暗 12 倍(约 3.6 档)让 EV>0 才测得出。
-
仿真里不加光谱串扰,颜色题就是假题:传感器通道恰好等于场景反射率时, CCM 退化成单位阵,"白平衡 + CCM 的贡献拆解""AWB/CCM 耦合"全都变成自欺欺人。 加上真实存在的 CFA 串扰后,标定出的 CCM 才带负交叉项。
-
_to_u8的 dtype 陷阱:np.asarray(img, np.float32)之后再判dtype == uint8永远为假,uint8 图被乘两次 255 → 整幅图全白。图"看起来是空的",程序不报错。 -
结论必须由数据支持,不能反过来:最初想用"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),否则图表里的中文会显示成方块。










