2. 基础调优流程:从RAW图到线性化数据的预处理管线

好,咱们正式开始动手。上一章聊了HDR合成的大框架,这一章咱们得把袖子撸起来,看看最底层的活儿——怎么把相机吐出来的RAW图,变成我们后续能用的线性化数据。

你可能会问:RAW图不就是原始数据吗?怎么还要预处理?

嗯,这里有个关键点。RAW图确实是原始感光数据,但它不是线性的。相机厂商为了讨好眼球、压缩存储空间,在RAW里偷偷做了很多非线性操作。如果你直接拿这些数据去做HDR合成,亮度一致性会一塌糊涂。我当年第一次做多帧HDR时,就踩过这个坑——合成出来的图像亮部发灰、暗部死黑,折腾了两天才发现是预处理没做干净。

2.1 RAW图的“非真实面目”

先看一张典型的RAW数据流水线:

RAW图到线性化数据的预处理管线 传感器 RAW Bayer 模式 黑电平校正 减去暗电流偏移 增益归一化 ISO 归一化 线性化数据 浮点/16bit ⚠ 去马赛克(Demosaic)放在 HDR 合成之后,避免插值引入亮度误差 预处理只做:黑电平 → 增益归一化 → 线性化

看到没?从传感器出来到线性化数据,中间只有三个核心步骤。别小看这三步,每一步都有讲究。

2.2 黑电平校正:把“零”找回来

先说说黑电平。CMOS传感器在完全没有光照时,仍然会输出一个非零的数值。这个值叫黑电平(Black Level)。

为什么会有这个偏移?

  • 暗电流:半导体热激发产生的电子,温度越高越明显
  • 电路噪声:读出电路本身的偏置电压
  • ADC偏移:模数转换器的零点漂移

校正方法其实很简单:

// 黑电平校正伪代码
uint16_t black_level = 512;  // 12-bit RAW 典型值
for each pixel in raw_image:
    corrected_pixel = max(0, raw_pixel - black_level);
💡 我的经验: 黑电平值不是固定的。不同ISO、不同曝光时间、甚至不同温度下,黑电平都会漂移。我习惯从RAW文件的元数据(Makernote)里读取每帧的黑电平值,而不是用固定值。有一次用固定值做长曝光帧的校正,结果暗部出现了负值截断,合成后出现了奇怪的紫色条纹。

2.3 增益归一化:让不同ISO的帧“平起平坐”

多帧HDR通常会用不同的ISO和曝光时间组合。比如短曝光帧用ISO 100,长曝光帧用ISO 800。如果不做增益归一化,这两帧的亮度响应曲线完全不同,合成时会出现亮度跳变。

增益归一化的核心公式:

// 增益归一化
float iso_100_gain = 1.0;  // 参考ISO
float current_iso_gain = iso_800_gain / iso_100_gain;  // = 8.0

for each pixel in frame:
    normalized_pixel = corrected_pixel / current_iso_gain;
帧类型 ISO 曝光时间 增益系数 归一化后
短曝光帧 100 1/1000s 1.0 不变
中曝光帧 400 1/250s 4.0 除以4
长曝光帧 800 1/60s 8.0 除以8
⚠ 注意: 增益归一化必须在黑电平校正之后做。如果顺序反了,黑电平偏移会被放大,导致暗部偏色。我曾经在项目里犯过这个错,结果长曝光帧的暗部出现了明显的绿色色罩,排查了整整一天。

2.4 线性化:打破相机的“小心思”

你以为做完黑电平和增益归一化就完事了?还早。相机厂商为了在暗部保留更多细节,会在RAW里嵌入一个非线性映射——通常是分段线性或对数曲线。这个映射在消费级相机上尤其常见。

怎么判断你的RAW是不是线性的?

  • 看元数据:查找Tag 0x115C(LinearizationTable)或0x0022(Gamma)
  • 做实验:拍一组不同曝光时间的灰卡,看像素值是否线性增长
  • 查文档:DNG规范里定义了线性化表,libraw可以帮你解析

线性化的典型实现:

// 基于查找表的线性化
float linearization_table[4096];  // 12-bit RAW 需要4096个条目
// 从DNG或相机厂商文档中加载

for each pixel in raw_image:
    uint16_t index = normalized_pixel;
    float linear_pixel = linearization_table[index];
🔑 关键判断: 如果线性化表是一条斜率为1的直线,说明RAW本身就是线性的。我遇到过一些工业相机,它们的RAW直接就是线性数据,省掉了这步。但手机和消费级相机,十有八九都有非线性映射。

2.5 预处理管线的完整代码骨架

把上面三步串起来,就是一个完整的预处理管线。我习惯用C++写底层,但这里用伪代码展示逻辑:

// 预处理管线:RAW → 线性化浮点数据
float* preprocess_raw(uint16_t* raw, int width, int height,
                      uint16_t black_level, float iso_gain,
                      float* linearization_table) {
    float* linear_buffer = new float[width * height];

    for (int i = 0; i < width * height; i++) {
        // Step 1: 黑电平校正
        float corrected = max(0.0f, (float)raw[i] - black_level);

        // Step 2: 增益归一化
        float normalized = corrected / iso_gain;

        // Step 3: 线性化
        uint16_t index = (uint16_t)roundf(normalized);
        index = min(index, 4095);  // 防止越界
        linear_buffer[i] = linearization_table[index];
    }

    return linear_buffer;
}
💡 性能优化建议: 如果线性化表是单调递增的,可以用二分查找代替查表。但更常见的做法是把三步合并成一个查找表——提前计算好所有可能的输入值对应的输出,运行时一次查表搞定。我在高通平台上就是这么做的,速度提升了3倍。

2.6 验证你的预处理结果

做完预处理,怎么知道对不对?我一般用两个方法验证:

  1. 灰卡测试:拍一组不同曝光时间的灰卡,预处理后像素值应该与曝光时间成正比。画一条拟合线,R²应该大于0.999。
  2. 直方图检查:线性化数据的直方图应该没有明显的断档或阶梯。如果看到锯齿状直方图,说明线性化表有问题。