052、RAW域预处理BLC黑电平校正与DPC坏点校正实战去年夏天我在调试一款车载环视系统时遇到一个诡异现象——夜间倒车影像整体偏绿但只在画面左下角区域明显。折腾了两周换了三版ISP参数最后发现是BLC黑电平校正没做对Sensor暗电流补偿值在角落区域失效了。这种坑踩过一次就忘不掉。今天这篇笔记就聊聊RAW域预处理中最基础也最容易翻车的两个环节黑电平校正BLC和坏点校正DPC。它们位于ISP Pipeline的最前端Sensor原始数据进来后第一件事就是干这个。做不好后面所有模块都是白搭。一、黑电平校正别让“黑色”骗了你先讲个常识。CMOS Sensor在没有光照时理论上应该输出0。但实际不是这样——由于暗电流、像素漏电、读出电路偏置等因素即使完全遮光Sensor也会输出一个非零值这个值就是黑电平Black Level。不同Sensor的黑电平值不同有的在64左右10bit RAW有的在128左右12bit RAW甚至不同通道R/Gr/Gb/B的黑电平也不一样。BLC要干的事很简单把每个像素值减去黑电平让“纯黑”回到0附近。但简单不等于容易。1.1 静态BLC vs 动态BLC早期Sensor会在每帧数据中嵌入OBOptical Black行或OB列——这些像素被物理遮挡不感光它们的输出就是当前帧的黑电平参考。用这些OB像素的平均值做减法就是动态BLC。// 动态BLC取OB行均值做校正 uint16_t ob_mean 0; for (int i 0; i OB_LINE_PIXELS; i) { ob_mean ob_line_buffer[i]; } ob_mean / OB_LINE_PIXELS; // 这里踩过坑OB行可能有坏点直接求均值会翻车 // 别这样写加个中值滤波或剔除异常值 uint16_t ob_median get_median_filtered(ob_line_buffer, OB_LINE_PIXELS); for (int y 0; y height; y) { for (int x 0; x width; x) { raw_data[y][x] (raw_data[y][x] ob_median) ? (raw_data[y][x] - ob_median) : 0; } }现在很多Sensor不提供OB行或者OB行被用在其他用途。这时候只能用静态BLC——在产线标定时用均匀黑场图测出每个通道的黑电平值写进Sensor驱动或ISP参数里。1.2 通道独立的BLCBayer格式的RAW数据R、Gr、Gb、B四个通道的黑电平通常不一样。Gr和Gb因为位置不同读出路径有差异黑电平可能差1-2个LSB。如果统一用一个值减就会出现“绿色通道偏色”——这就是我开头说的那个车载案例的根因。// 通道独立BLC校正 // 注意Bayer排列是RGGB索引0R, 1Gr, 2Gb, 3B uint16_t blc_offset[4] {64, 66, 65, 68}; // 产线标定值 for (int y 0; y height; y) { for (int x 0; x width; x) { int ch get_bayer_channel(y, x); // 根据坐标获取通道索引 int32_t val (int32_t)raw_data[y][x] - blc_offset[ch]; raw_data[y][x] (val 0) ? (uint16_t)val : 0; } }1.3 温度补偿容易被忽略的细节黑电平随温度变化。Sensor温度升高10度暗电流可能翻倍黑电平漂移几个LSB很正常。手机拍照时连续录像10分钟Sensor温度能升到60度以上黑电平可能漂移5-10个LSB。我见过一个方案在Sensor模组里贴一个温度传感器查表补偿黑电平。更简单的做法是每N帧重新采集OB行做动态校正。如果既没有温度传感器也没有OB行那就只能靠ISP的自动白平衡去硬扛——效果嘛看运气。二、坏点校正别让“亮点”毁了画面坏点分两类死点Dead Pixel永远不感光输出固定值和亮点Hot Pixel输出值异常高随曝光时间增加而加剧。死点在暗场下表现为黑点亮点在暗场下表现为白点。坏点校正的核心思路检测到坏点后用周围像素插值替换。2.1 静态坏点表 vs 动态坏点检测产线标定时用均匀灰场图通常是18%灰拍一张找出偏离邻域均值超过阈值的像素记录坐标生成坏点表。这是最可靠的方式。// 静态坏点表校正 typedef struct { uint16_t x; uint16_t y; } bad_pixel_t; bad_pixel_t bad_table[MAX_BAD_PIXELS]; int bad_count load_bad_pixel_table(sensor_bad_pixel.bin, bad_table); for (int i 0; i bad_count; i) { int x bad_table[i].x; int y bad_table[i].y; // 用周围4个同通道像素均值替换 raw_data[y][x] interpolate_same_channel(raw_data, x, y); }但产线标定只能覆盖出厂时的坏点。Sensor在使用过程中会老化新坏点会不断出现。这时候就需要动态坏点检测。2.2 动态坏点检测算法动态检测的核心是坏点与邻域像素的差异远大于正常像素。但难点在于——画面本身可能有真实的高频细节比如星点、文字边缘不能误判。我常用的方法是基于梯度检测// 动态坏点检测基于梯度比较 // 别这样写只比较当前像素和邻域均值边缘会被误判 int detect_bad_pixel(uint16_t *raw, int x, int y, int width, int threshold) { // 取同通道的3x3邻域Bayer格式下同通道像素间隔2 uint16_t neighbors[8]; int count get_same_channel_neighbors(raw, x, y, width, neighbors); // 计算邻域中值 uint16_t median get_median(neighbors, count); // 计算邻域标准差自适应阈值 uint16_t std get_std(neighbors, count, median); int adaptive_th MAX(threshold, std * 3); // 这里踩过坑固定阈值在暗场下会漏检 int diff abs((int)raw[y * width x] - (int)median); return (diff adaptive_th) ? 1 : 0; }关键参数是阈值。阈值太小边缘细节被误杀阈值太大坏点漏检。我一般把阈值设为邻域标准差的3-5倍再根据ISO值做线性缩放——ISO越高噪声越大阈值也要相应提高。2.3 坏点校正的插值策略检测到坏点后怎么替换最简单的用邻域均值但效果一般。更好的做法死点用周围同通道像素的中值避免被其他坏点污染亮点用周围同通道像素的均值因为亮点通常只有一个均值更平滑边缘处的坏点沿边缘方向插值避免模糊// 坏点插值区分死点和亮点 uint16_t correct_bad_pixel(uint16_t *raw, int x, int y, int width) { uint16_t neighbors[8]; int count get_same_channel_neighbors(raw, x, y, width, neighbors); uint16_t median get_median(neighbors, count); uint16_t mean get_mean(neighbors, count); uint16_t current raw[y * width x]; // 死点值远低于邻域 if (current median / 4) { // 这里踩过坑阈值设成固定值不同亮度下失效 return median; // 死点用中值鲁棒性更好 } // 亮点值远高于邻域 else { return mean; // 亮点用均值更平滑 } }2.4 坏点校正的时序问题动态坏点检测是逐帧做的但坏点位置在短时间内不会变。一个优化技巧检测到坏点后把坐标缓存起来后续几帧直接用缓存表校正每隔N帧重新检测一次。这样能大幅降低计算量。// 坏点缓存机制 static bad_pixel_t cached_bad_pixels[MAX_BAD_PIXELS]; static int cached_count 0; static int frame_counter 0; void dpc_process(uint16_t *raw, int width, int height) { frame_counter; // 每30帧重新检测一次 if (frame_counter % 30 0) { cached_count dynamic_detect(raw, width, height, cached_bad_pixels); } // 用缓存表校正 for (int i 0; i cached_count; i) { int x cached_bad_pixels[i].x; int y cached_bad_pixels[i].y; raw[y * width x] interpolate_bad_pixel(raw, x, y, width); } }三、BLC与DPC的联动顺序很重要先做BLC还是先做DPC这个问题我见过有人搞反。正确顺序先BLC后DPC。原因很简单坏点检测依赖像素值的相对关系。如果黑电平没校正暗电流偏置会导致暗场下的像素值整体偏高坏点检测的阈值计算会出错。尤其是亮点检测——亮点在暗场下表现为高值但如果不减掉黑电平这个“高值”可能只是正常的暗电流输出。// 正确的RAW域预处理流程 void raw_preprocess(uint16_t *raw, int width, int height) { // Step 1: BLC黑电平校正 blc_correction(raw, width, height); // Step 2: DPC坏点校正 dpc_correction(raw, width, height); // 后续LSC镜头阴影校正、AWB白平衡等 }四、实战中的几个坑坑1BLC后出现负值减法操作后如果像素值小于黑电平结果会变成负数。uint16_t类型下会下溢成65535。一定要做饱和处理。// 别这样写uint16_t减法不检查会下溢 raw_data[y][x] raw_data[y][x] - blc_offset; // 正确做法 int32_t val (int32_t)raw_data[y][x] - blc_offset; raw_data[y][x] (val 0) ? (uint16_t)val : 0;坑2OB行包含坏点有些Sensor的OB行像素本身就有坏点。直接用OB行均值做动态BLC坏点会把均值拉偏。解决方案对OB行做中值滤波或者剔除上下5%的极端值。坑3坏点检测在纹理区域误判高对比度纹理比如毛衣的条纹、远处的树叶容易被误判为坏点。一个trick在检测前先做简单的边缘检测如果当前像素在边缘上放宽检测阈值。坑4产线标定的坏点表坐标偏移不同分辨率模式下Sensor的像素读出顺序可能不同。坏点表坐标必须与当前分辨率匹配。我见过一个案例4K模式下标定的坏点表切到1080p模式后坐标没做缩放结果校正到了错误位置。五、个人经验BLC参数不要只依赖产线标定。同一型号的Sensor不同批次、不同温度下的黑电平可能有差异。最好在驱动层留一个动态校准接口运行时根据OB行或温度传感器做微调。坏点检测的阈值要随增益变化。ISO越高噪声越大坏点检测阈值也要线性提高。我一般用公式threshold base_threshold * (current_gain / base_gain)。静态坏点表和动态检测要配合使用。静态表覆盖出厂坏点动态检测捕获老化产生的新坏点。两者不冲突。调试时一定要看暗场图。BLC和DPC的效果在暗场下最容易暴露问题。拿一张完全遮光的图检查是否有残留的偏色或亮点。性能优化用NEON指令集加速。BLC和DPC都是逐像素操作非常适合SIMD优化。在ARM平台上用NEON指令一次处理8个像素性能提升5-8倍。最后说一句BLC和DPC看起来简单但它们是ISP Pipeline的基石。基础不牢地动山摇。我见过太多“后面模块调不好回头发现是BLC没做对”的案例。花时间把这两个模块调稳了后面的工作会顺利很多。