ISP Tuning 完全指南:从灯箱色卡到 JSON 参数的全流程

概述

ISP Tuning 的最终产出是一个 tuning JSON 文件(如 imx500.json),包含所有 ISP 模块的参数。每换一颗传感器或一个镜头,就需要重新做一次 tuning。

灯箱 + 色卡 + 拍摄样本 → 分析计算 → tuning JSON → ISP 硬件按参数处理图像

设备准备

设备 用途
灯箱(多色温) 提供标准、均匀、可重复的光源
X-Rite ColorChecker 24 色卡 色彩校正矩阵标定
18% 灰卡 白平衡标定
均匀白/灰板 镜头阴影校正、噪声测量
灰阶卡(Kodak Q13) Gamma/对比度校准
ISO 12233 分辨率卡 锐化效果评估
镜头盖 黑电平、坏点测量

完整流程图

准备设备(灯箱+色卡+灰板)
        │
        ▼
拍摄标定图片(RAW)
  ├── 暗场 → 黑电平
  ├── 灰板×多色温 → AWB + LSC
  ├── 24色卡×多色温 → CCM
  ├── 灰板×多增益 → 噪声模型
  ├── 灰阶卡 → Gamma
  └── 分辨率卡 → 锐化
        │
        ▼
计算参数(ctt 工具或手动)
        │
        ▼
生成 tuning JSON
        │
        ▼
实景验证 + 微调
        │
        ▼
最终 JSON 交付

第 1 步:黑电平标定(Black Level)

原理

传感器厂商在芯片设计时故意加入一个固定偏移,让”完全无光”时的输出不是 0,而是一个正值。原因是 ADC 在 0 附近有非线性和噪声截断问题。

设备

镜头盖(完全遮光)

操作

  1. 盖住镜头
  2. 拍 RAW:rpicam-still -r -o /tmp/dark.dng --shutter 10000 --gain 1
  3. 用 Python 读出像素值

计算

import struct

with open("/tmp/dark.dng", "rb") as f:
    data = f.read()

# 读取 RAW 像素(16bit)
pixels = struct.unpack_from("<10000H", data, strip_offset)
print(f"Min={min(pixels)}")  # → 4032

结果:

  • 像素 Min = 4032(16bit 表示)
  • IMX500 是 10bit 传感器,左移 6 位到 16bit:4032 ÷ 64 ≈ 63 ≈ 64
  • 黑电平 = 64(10bit)= 4096(16bit)

为什么是 16bit

PiSP BE(ISP 硬件)内部统一用 16bit 处理。不同传感器位深不同(8/10/12/14bit),统一左移到 16bit 后送入 ISP。

常见传感器黑电平

传感器 位深 黑电平(原始) 16bit 表示
IMX500 10bit 64 4096
IMX708 10bit 64 4096
IMX477 12bit 256 4096
OV5647 10bit 16 1024

写入 JSON

"rpi.black_level": {
    "black_level": 4096
}

第 2 步:镜头阴影校正(Lens Shading / ALSC)

原理

镜头有光学缺陷,中心进光多、边角进光少。拍均匀白板出来中心亮边角暗。

设备

灯箱 + 均匀白色/灰色板

操作

在多种色温(D65、TL84、A 光)下拍摄均匀灰板的 RAW。

计算

将图像分成 N×N 网格(如 32×32),每格取平均像素值:

70   80   82   72
82   95   96   84
84   96  100   86
72   82   84   74

中心最亮 = 100,计算补偿系数:

补偿系数 = 中心值 / 该区域值

1.43  1.25  1.22  1.39
1.22  1.05  1.04  1.19
1.19  1.04  1.00  1.16
1.39  1.22  1.19  1.35

ISP 拍照时对每个区域的像素乘以对应系数:边角像素 70 × 1.43 = 100

为什么要多种色温

不同波长的光衰减程度不同,边角可能 R 通道衰减多、B 通道衰减少,导致边角偏色。所以要在不同色温下分别测 R/G/B 三个通道的衰减。

写入 JSON

"rpi.alsc": {
    "luminance_strength": 1.0,
    "calibrations_Cr": [
        {"ct": 3000, "table": [1.43, 1.25, 1.22, ...]},
        {"ct": 6500, "table": [1.38, 1.20, 1.18, ...]}
    ],
    "calibrations_Cb": [
        {"ct": 3000, "table": [1.30, 1.15, 1.12, ...]},
        {"ct": 6500, "table": [1.25, 1.12, 1.10, ...]}
    ],
    "luminance_lut": [1.43, 1.25, 1.22, ...]
}

第 3 步:白平衡标定(AWB)

原理

灰卡在任何光源下人眼看都是灰色(R=G=B)。但传感器对不同颜色的光敏感度不同,拍出来 R≠G≠B。白平衡就是算出乘以多少能让 R=G=B。

设备

灯箱(多种色温)+ 18% 灰卡

操作

分别在 2800K、4000K、5000K、6500K 等色温下拍灰卡 RAW,提取 R/G/B 通道均值。

计算

以 2800K(暖光)为例,拍灰卡得到:

R 通道均值 = 3000
G 通道均值 = 4500
B 通道均值 = 2000

以 G 为基准,计算增益:

R_gain = G_mean / R_mean = 4500 / 3000 = 1.5
G_gain = 1.0(基准)
B_gain = G_mean / B_mean = 4500 / 2000 = 2.25

验证:校正后 R=3000×1.5=4500, G=4500, B=2000×2.25=4500,R=G=B ✓

多色温重复

色温 R_mean G_mean B_mean R_gain B_gain
2800K 3000 4500 2000 1.50 2.25
4000K 3500 4500 2800 1.29 1.61
5000K 4000 4500 3500 1.13 1.29
6500K 4500 4500 4000 1.00 1.13

运行时

AWB 算法估计当前色温 → 在曲线上插值得到增益 → ISP 对每个像素乘以对应增益。

为什么用 18% 灰卡

  • 白卡容易过曝(像素饱和后值不准)
  • 黑卡信号太弱(噪声影响大)
  • 18% 灰卡亮度适中,测量最准确

写入 JSON

"rpi.awb": {
    "ct_curve": [
        2800, 1.50, 1.0, 2.25,
        4000, 1.29, 1.0, 1.61,
        5000, 1.13, 1.0, 1.29,
        6500, 1.00, 1.0, 1.13
    ]
}

第 4 步:色彩校正矩阵(CCM)

原理

白平衡只调了 R/G/B 的整体增益,但传感器对每种颜色的响应和人眼不同。比如传感器看到”红色”可能混了点绿,需要用 3×3 矩阵做精细纠正。

设备

灯箱 + X-Rite ColorChecker 24 色卡

操作

  1. 在多种色温下拍 24 色卡 RAW
  2. 提取每个色块的 R/G/B 值
  3. 与色卡标准 sRGB 值对比

计算

已知:

  • 传感器测量值(错误输出)
  • 色卡标准值(正确答案)

求: 纠错矩阵 CCM

以 R 通道为例,对 24 个色块列方程:

R_target = m11×R_sensor + m12×G_sensor + m13×B_sensor

用最小二乘法求解:

import numpy as np

# A = 24个色块的传感器 [R, G, B] 值
# b_r = 24个色块的标准 R 值
A = np.array([[180,50,30], [40,160,50], ...])  # 24行
b_r = np.array([200, 30, 20, ...])             # 24个标准R值

# 求解
m_r = np.linalg.lstsq(A, b_r, rcond=None)[0]
# 结果如: [1.15, -0.10, -0.05]

对 G、B 通道同样求解,得到完整 3×3 矩阵。

矩阵含义

CCM = [1.15, -0.10, -0.05,    ← R_out 的计算系数
      -0.15,  1.20, -0.05,    ← G_out 的计算系数
      -0.05, -0.10,  1.15]    ← B_out 的计算系数
  • 对角线 > 1:增强本通道
  • 非对角线为负:减去其他通道的串扰

验证

红色块:R_out = 1.15×180 + (-0.10)×50 + (-0.05)×30 = 200.5 ≈ 200 ✓

写入 JSON

"rpi.ccm": {
    "ccms": [
        {"ct": 2800, "ccm": [1.8, -0.5, -0.3, -0.4, 1.6, -0.2, 0.0, -0.6, 1.6]},
        {"ct": 4000, "ccm": [1.5, -0.3, -0.2, -0.3, 1.5, -0.2, 0.0, -0.5, 1.5]},
        {"ct": 6500, "ccm": [1.15, -0.10, -0.05, -0.15, 1.20, -0.05, -0.05, -0.10, 1.15]}
    ]
}

运行时根据 AWB 估计的色温,在相邻两个 CCM 之间线性插值。


第 5 步:噪声模型标定(Noise Profile)

原理

降噪算法需要知道”多大的波动是噪声,多大的波动是真实细节”。噪声有两个来源:

  • 读出噪声(A):电路固有噪声,常数
  • 散粒噪声(B × signal):光子量子噪声,越亮越大

模型:noise_variance = A + B × signal_level

设备

灯箱 + 均匀灰板

操作

固定光照,用不同 analog gain(1x, 2x, 4x, 8x)拍灰板 RAW。

计算

拍均匀灰板,画面应该每个像素都一样,实际的波动就是噪声:

Gain=1x: 像素值 100, 102, 99, 101... → 均值=100, 方差=2.5
Gain=8x: 像素值 800, 815, 785, 820... → 均值=800, 方差=400

在同一 gain 下,不同亮度区域测量方差:

区域亮度(signal) 测量的方差(noise)
100 5
200 7.5
400 12.5
800 22.5

线性拟合:

# variance = A + B × signal
B = (22.5 - 5) / (800 - 100) = 0.025
A = 5 - 0.025 × 100 = 2.5

ISP 怎么用

降噪时,对每个像素:

  • 波动 < 预期噪声 → 是噪声,平滑掉
  • 波动 > 预期噪声 → 是真实边缘/细节,保留

写入 JSON

"rpi.noise": {
    "reference_constant": 2.5,
    "reference_slope": 0.025
}

第 6 步:Gamma / 对比度曲线

原理

传感器输出是线性的(亮度翻倍,像素值翻倍),但人眼感知是非线性的(对暗部变化敏感,对亮部不敏感)。Gamma 校正把线性数据转换为符合人眼感知的非线性曲线。

设备

灯箱 + 灰阶卡(Kodak Q13,20 级灰阶)

计算

gamma_curve 是一个查找表,用标准 sRGB gamma 公式生成:

import math

curve = []
for i in range(0, 65536, 1024):
    x = i / 65535.0  # 归一化到 0~1
    
    # sRGB gamma
    if x <= 0.0031308:
        y = 12.92 * x
    else:
        y = 1.055 * (x ** (1.0/2.4)) - 0.055
    
    output = int(y * 65535)
    curve.append(i)       # 输入
    curve.append(output)  # 输出

效果

输入(线性) 输出(Gamma后) 效果
0 (0%) 0 (0%) 纯黑不变
1024 (1.6%) 5040 (7.7%) 暗部大幅提亮
16384 (25%) 28000 (43%) 中间调提亮
65535 (100%) 65535 (100%) 纯白不变

灰阶卡的作用

用来验证曲线是否正确:拍灰阶卡 → 应用 gamma → 检查相邻灰阶间距是否符合人眼感知的”均匀过渡”。

写入 JSON

"rpi.contrast": {
    "ce_enable": 1,
    "gamma_curve": [
        0, 0,
        1024, 5040,
        2048, 9338,
        4096, 15006,
        8192, 23920,
        16384, 36000,
        32768, 50000,
        65535, 65535
    ]
}

第 7 步:锐化调节

原理

锐化 = 增强边缘。检测像素和周围的差异,把差异放大:

原始边缘:  100  100  100  150  150  150
锐化后:    100  100   90  160  150  150
                     ↓    ↑
                   压暗  提亮  → 边缘更明显

设备

灯箱 + ISO 12233 分辨率测试卡

与前面步骤的区别

前面的步骤(黑电平、AWB、CCM)是算出来的——有公式、有标准答案。锐化参数是调出来的——没有唯一正确答案,靠人眼主观判断。

三个参数

参数 含义
threshold 多大的差异才算”边缘”需要锐化
strength 锐化强度(放大多少倍)
limit 最大锐化量(防止白边/振铃)

调节过程

调 strength: 从 0 逐步增大,观察分辨率卡细条纹的清晰度

strength 观感
0 模糊
1.0 清晰自然 ✓
2.0 边缘有白边 ✗

调 threshold: 对着均匀灰板

threshold 表现
0 噪声被放大 ✗
0.25 平坦区域干净,边缘仍锐利 ✓

调 limit: 对着高对比度边缘

limit 表现
0.5 锐利且无白边 ✓
1.0 出现光晕 ✗

写入 JSON

"rpi.sharpen": {
    "threshold": 0.25,
    "strength": 1.0,
    "limit": 0.5
}

第 8 步:坏点校正(DPC)

原理

传感器上有些像素天生有缺陷,在暗场下也会输出异常高的值。

设备

镜头盖

操作

盖住镜头,长曝光拍 RAW:

正常像素:64, 65, 63, 64, 65...
坏点像素:64, 65, 800, 64, 65...
                  ↑ 异常亮

计算

DPC 不需要复杂计算。ISP 硬件自动判断:如果一个像素和周围 8 个邻居的差异超过阈值,就用邻居的平均值替换。

strength 参数控制检测灵敏度:

  • 0 = 不校正
  • 1 = 标准校正

写入 JSON

"rpi.dpc": {
    "strength": 1
}

工具链

树莓派官方提供了 Camera Tuning Tool(ctt):

git clone https://github.com/raspberrypi/libcamera
cd utils/raspberrypi/ctt

# 输入:一组标定 RAW 图片
# 输出:tuning JSON
python3 ctt.py -i /path/to/calibration_images/ -o imx500_tuned.json

自动完成黑电平、AWB、CCM、ALSC、噪声模型等计算。


调试命令参考(树莓派)

# 使用自定义 tuning file
rpicam-still -r -o test.dng --tuning-file /path/to/custom.json

# 拍 RAW(带 DNG)
rpicam-still -r -o output.dng --shutter 10000 --gain 1

# 指定曝光和增益(用于噪声标定)
rpicam-still -r -o noise_gain8.dng --shutter 10000 --gain 8

总结

步骤 方法 输入 输出
黑电平 测量 暗场 RAW black_level 值
镜头阴影 计算 均匀灰板 RAW 补偿系数表
白平衡 计算 多色温灰卡 RAW 色温→增益曲线
CCM 最小二乘法 24色卡 RAW + 标准值 3×3 矩阵
噪声模型 线性拟合 多增益灰板 RAW A, B 系数
Gamma 公式生成 sRGB 标准 查找表
锐化 人工调节 分辨率卡 threshold/strength/limit
坏点 开关 暗场 RAW strength