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 附近有非线性和噪声截断问题。
设备
镜头盖(完全遮光)
操作
- 盖住镜头
- 拍 RAW:
rpicam-still -r -o /tmp/dark.dng --shutter 10000 --gain 1 - 用 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 色卡
操作
- 在多种色温下拍 24 色卡 RAW
- 提取每个色块的 R/G/B 值
- 与色卡标准 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 |