第3章 RAW域预处理:黑电平校正、镜头阴影校正、坏点修复与去噪

好,咱们进入RAW域预处理。这是整个夜景管线的第一道关卡,也是最容易被忽视的环节。我见过不少团队,算法模型调得花里胡哨,结果RAW域预处理没做好,后面再怎么折腾也是白费力气。

说白了,RAW域预处理就干四件事:黑电平校正、镜头阴影校正、坏点修复、去噪。顺序不能乱,每一步都有它的道理。

核心观点:RAW域预处理的质量,直接决定了后续ISP管线能发挥的上限。夜景场景下,信号本来就弱,预处理阶段每多一分噪声,后面就要花十分力气去擦屁股。

RAW域预处理四步流程 RAW 原始数据 ① 黑电平校正 (BLC) ② 镜头阴影校正 (LSC) ③ 坏点修复 (DPC) ④ 去噪 (Denoise) 注意:坏点修复和去噪在部分架构中可并行处理,但逻辑上坏点修复优先

3.1 黑电平校正(BLC)

黑电平是什么?传感器在没有光照的情况下,理论上输出应该是0。但实际不是——由于电路暗电流、ADC偏移等因素,传感器会有一个固定的底噪偏移。这个偏移值就是黑电平。

我遇到过最离谱的一次,某款传感器在长曝光下黑电平漂了将近20个DN(数字码值)。如果不校正,整个画面会偏灰,暗部细节全被淹没了。

校正公式很简单:

// 黑电平校正 - 基础实现
// ob_value: 光学黑区统计得到的黑电平值
// raw_data: 输入RAW像素值
// bit_depth: 传感器位深(如10bit、12bit)

uint16_t blc_correction(uint16_t raw_data, uint16_t ob_value, uint8_t bit_depth) {
    int16_t corrected = (int16_t)raw_data - (int16_t)ob_value;
    // 钳位到有效范围
    if (corrected < 0) corrected = 0;
    if (corrected > (1 << bit_depth) - 1) corrected = (1 << bit_depth) - 1;
    return (uint16_t)corrected;
}

实战技巧:黑电平值不是一成不变的。温度升高、曝光时间变长,黑电平都会漂移。我建议在每帧的消隐区(OB区域)实时统计黑电平,而不是用固定值。夜景长曝光时尤其重要。

3.2 镜头阴影校正(LSC)

镜头阴影,也叫亮度渐晕。说白了就是镜头中心进光多、边缘进光少。你想想看,一个均匀的灰卡,拍出来中心亮、四角暗,这就是镜头阴影。

校正方法是用一个增益矩阵去补偿边缘像素。这个增益矩阵通常在产线标定时生成,存储在OTP(一次性可编程存储器)里。

区域 相对中心亮度 典型增益值
中心(0~20%半径) 100% 1.0x
中间(20%~60%半径) 80%~95% 1.05x~1.25x
边缘(60%~100%半径) 50%~75% 1.33x~2.0x

注意,增益不能无限放大。边缘增益过大,会把传感器本身的噪声也放大。我见过有人把边缘增益拉到3.0x,结果画面边缘全是噪点,比不校正还难看。

避坑指南:我曾经在某个项目里直接用了标定好的LSC增益表,结果发现夜景模式下边缘偏色严重。后来排查发现,标定时用的是D65光源(色温6500K),而夜景路灯是暖色温(约3000K)。不同色温下镜头阴影特性不一样,最好做色温相关的LSC校正。

3.3 坏点修复(DPC)

传感器上总有那么几个像素是坏的——要么一直亮(白点),要么一直暗(黑点),要么随机闪烁(闪点)。夜景模式下曝光时间长,坏点更容易被看到。

坏点修复分两步:检测和替换。

检测方法有很多,我常用的是基于邻域统计的阈值法:

// 坏点检测 - 邻域比较法
// 以当前像素为中心,取3x3邻域
// 如果当前像素与邻域中值的差异超过阈值,判定为坏点

int is_dead_pixel(uint16_t center, uint16_t neighbors[8], uint16_t threshold) {
    // 排序取中值
    uint16_t sorted[8];
    memcpy(sorted, neighbors, 8 * sizeof(uint16_t));
    // 简单冒泡排序(实际用快速选择)
    for (int i = 0; i < 7; i++) {
        for (int j = 0; j < 7 - i; j++) {
            if (sorted[j] > sorted[j+1]) {
                uint16_t tmp = sorted[j];
                sorted[j] = sorted[j+1];
                sorted[j+1] = tmp;
            }
        }
    }
    uint16_t median = (sorted[3] + sorted[4]) / 2;
    
    // 判断差异
    int diff = abs((int)center - (int)median);
    return (diff > threshold) ? 1 : 0;
}

替换策略我推荐用中值替换,而不是均值替换。为什么?因为均值会把邻域的细节模糊掉,而中值能更好地保留边缘。夜景下细节本来就少,经不起均值滤波的折腾。

经验之谈:坏点阈值怎么设?我一般取传感器噪声标准差的3~5倍。太严了会把正常像素误判为坏点,太松了又漏检。夜景下噪声大,阈值可以适当放宽,但别超过8倍标准差,否则坏点就藏不住了。

3.4 去噪(Denoise)

终于到了重头戏。夜景去噪,是所有ISP工程师的噩梦,也是展现功力的地方。

RAW域去噪和RGB域去噪最大的区别是什么?RAW域是Bayer格式,每个像素只有一种颜色分量。你不能直接套用RGB域的滤波算法,否则会引入颜色伪影。

我常用的RAW域去噪方法有几种:

  • 双边滤波(Bilateral Filter):保边去噪效果好,但计算量大。适合高端平台。
  • 非局部均值(NLM):去噪效果顶级,但速度慢到令人发指。我一般只在拍照模式用。
  • 小波阈值去噪:在频域操作,对高斯噪声效果好。夜景下传感器噪声主要是高斯+泊松混合,小波方法挺合适。
  • BM3D:目前公认的去噪天花板,但实时性差。移动端基本别想。

对于实时夜景,我推荐改进型双边滤波。为什么?因为它可以在FPGA或DSP上流水线实现,延迟可控。

// 改进型双边滤波 - RAW域实现
// 只对同色通道的像素进行滤波,避免颜色串扰
// sigma_s: 空间域标准差(控制模糊半径)
// sigma_r: 值域标准差(控制边缘保留程度)

void bilateral_filter_raw(uint16_t *raw, uint16_t *output, 
                          int width, int height, 
                          float sigma_s, float sigma_r) {
    int radius = (int)(3 * sigma_s + 0.5);
    float sigma_s_inv = 1.0f / (2 * sigma_s * sigma_s);
    float sigma_r_inv = 1.0f / (2 * sigma_r * sigma_r);
    
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            // 只处理当前像素同色的Bayer位置
            // 实际实现需要根据Bayer模式(RGGB/GRBG等)做通道分离
            
            float sum_w = 0.0f;
            float sum_v = 0.0f;
            uint16_t center = raw[y * width + x];
            
            for (int dy = -radius; dy <= radius; dy++) {
                for (int dx = -radius; dx <= radius; dx++) {
                    int nx = x + dx;
                    int ny = y + dy;
                    if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
                    
                    // 只对同色通道滤波
                    if (((x & 1) != (nx & 1)) || ((y & 1) != (ny & 1))) continue;
                    
                    uint16_t neighbor = raw[ny * width + nx];
                    float dist_s = dx*dx + dy*dy;
                    float dist_r = (float)((int)center - (int)neighbor);
                    dist_r *= dist_r;
                    
                    float w = expf(-dist_s * sigma_s_inv) * expf(-dist_r * sigma_r_inv);
                    sum_w += w;
                    sum_v += w * neighbor;
                }
            }
            output[y * width + x] = (uint16_t)(sum_v / sum_w + 0.5f);
        }
    }
}

优化建议:上面的代码是教学版,实际工程中千万别这么写。expf()函数太慢了。我一般用LUT(查找表)或者多项式近似来加速。另外,半径可以限制在3~5像素,再大收益有限,计算量翻倍。

3.5 四步串联的工程考量

这四个步骤在硬件流水线上是串行的。但有个细节要注意:坏点修复应该在去噪之前做。为什么?因为坏点如果不先修掉,去噪的时候会把坏点的异常值扩散到邻域,造成更大面积的污染。

另外,黑电平校正和镜头阴影校正的顺序也有讲究。先做BLC再做LSC,因为LSC的增益是基于真实信号值的。如果黑电平没减掉,增益会把黑电平也放大,导致暗部偏色。

步骤 输入 输出 关键参数
BLC RAW原始值 减掉黑电平后的RAW OB区域统计值
LSC BLC后的RAW 亮度均匀的RAW 增益矩阵(色温相关)
DPC LSC后的RAW 坏点修复后的RAW 检测阈值(3~5σ)
Denoise DPC后的RAW 去噪后的RAW sigma_s, sigma_r

重要提醒:夜景模式下,曝光时间变长,传感器温度升高,黑电平会漂移。我建议每帧都重新统计OB区域的黑电平值,而不是用标定值。另外,长曝光下坏点会更明显,检测阈值可以适当降低,宁可误判几个好点,也别漏掉一个坏点。

嗯,RAW域预处理就这些。看起来简单,但每个环节都有坑。我当年第一次做夜景ISP,就是在LSC和DPC的顺序上栽了跟头——先做了DPC再LSC,结果边缘区域的坏点因为增益放大变得特别明显,调试了整整一周才发现问题。

记住一句话:RAW域预处理是ISP管线的地基,地基不稳,楼盖得再高也是危楼。