044、坏点校正的漏检与误杀——静态表+动态检测的互补策略——从单像素坏点到cluster坏点的检测算法设计与极限case处理
昨晚在实验室盯着一块Sensor的RAW图,越看越觉得不对劲。暗场下明明做了坏点校正,画面里却还是星星点点地闪着白点,不是那种均匀的噪点,是那种固定位置、固定亮度的“钉子户”。更头疼的是,同一颗Sensor在另一台样机上表现正常,换到这台就冒出来十几个新坏点。客户那边的反馈更直接:你们这个坏点校正是不是摆设?
这种问题我遇到过太多次了。坏点校正(DPC,Defect Pixel Correction)在ISP pipeline里是个不起眼的模块,但恰恰是它最容易在量产阶段翻车。翻车的模式就两种:漏检——该修的没修,画面里残留固定亮点;误杀——不该修的修了,把正常的像素点给抹掉了,画面出现“死灰”或者细节丢失。而这两者往往是矛盾的,你为了减少漏检,把检测阈值调低,误杀率立刻飙升;反过来,阈值调高,漏检又压不住。
先说漏检。静态坏点表(Static Defect Table)是出厂前烧录的,基于Sensor厂商提供的测试数据。但问题在于,Sensor的坏点不是一成不变的。温度漂移、电压波动、长时间使用后的老化,都会产生新的坏点。尤其是暗电流随温度升高而增大,某些像素在常温下正常,到了高温环境就变成“热像素”,亮度值明显高于周围。这种动态坏点,静态表根本管不到。
再说误杀。动态检测算法通常基于邻域比较,比如当前像素与周围8邻域的差值超过阈值就判为坏点。但遇到真实图像中的高对比度边缘、细纹理、或者高光区域,这种检测很容易误判。比如一根白色细线穿过画面,线上的像素点与两侧暗区的差值极大,算法一看,哎哟,全是坏点,全给抹了。结果就是画面里的细线变成了虚线,细节全丢。
我见过最极端的case,是某款车载摄像头在夜间行车时,路灯的高光区域被动态检测误杀,导致画面出现一圈一圈的“黑晕”,客户直接投诉说你们这个摄像头是不是坏了。后来排查发现,问题出在检测算法只用了单帧的邻域差值,没有考虑时间域的一致性。真正的坏点是固定的,每帧都在同一位置异常;而高光边缘是随场景移动的,不会固定在某个像素上。
所以,正确的思路是静态表+动态检测互补。静态表负责处理出厂已知的坏点,动态检测负责捕捉运行过程中新出现的坏点。但动态检测不能只看单帧,必须结合多帧时间域信息。这里有个关键设计:动态坏点的判定需要连续N帧在同一位置都出现异常,才确认为坏点。N的取值很讲究,太小了误杀率高,太大了漏检率高。我一般建议N取3到5帧,具体要看帧率和场景运动速度。车载场景帧率30fps,N取3比较合适;安防场景帧率可能只有15fps,N取2到3。
但多帧检测也有坑。如果场景本身是静止的,比如安防监控对着一个固定区域,那么画面中的噪点也会在固定位置出现多帧,容易被误判为坏点。这时候需要引入一个“噪声容忍度”的概念——不是看绝对差值,而是看相对信噪比。具体做法是,先计算当前像素与邻域均值的差值,再除以邻域标准差,得到一个归一化的偏离度。只有偏离度超过某个阈值,并且连续多帧都超过,才判定为坏点。这个阈值我通常设在5到6个标准差,太低会误杀,太高会漏检。
再说cluster坏点。单像素坏点好处理,但Sensor制造过程中可能出现一片区域的多个像素同时损坏,形成2x2、3x3甚至更大的坏点簇。这种cluster坏点如果按单像素处理,会出现一个严重问题:坏点簇内部的像素互相作为邻域参考,导致检测不到。比如一个3x3的坏点簇,中心像素的8邻域全是坏点,邻域均值本身就被污染了,差值计算出来反而不大,漏检了。
处理cluster坏点,我常用的方法是分级检测。第一级,用大窗口(比如5x5或7x7)计算邻域统计值,找出疑似坏点区域;第二级,在疑似区域内做细粒度检测,区分单点坏和cluster坏。大窗口的好处是,即使坏点簇内部有污染,窗口边缘的正常像素仍然能提供参考。但大窗口也有问题——如果坏点簇太大,比如超过5x5,大窗口也被污染了。这时候需要引入“方向性检测”,分别计算水平、垂直、两个对角线方向的梯度,取最小值作为参考。因为坏点簇通常是各向同性的,而真实图像中的边缘是各向异性的,方向性检测可以区分这两者。
这里踩过一个大坑。某次在工业视觉项目里,客户的产品表面有规则的网格纹理,网格交叉点的像素亮度极高。我们的动态检测算法把这些交叉点全判成了坏点,导致校正后网格纹理变得模糊。后来分析发现,网格交叉点的亮度虽然高,但在时间域上是稳定的,而且方向性梯度在水平和垂直方向都很大,但在对角线方向很小。真正的坏点应该是所有方向梯度都异常大。于是我们加了一个条件:只有四个方向梯度都超过阈值,才判定为坏点。这样网格交叉点就被排除了。
极限case处理,我总结了几类。第一类是Sensor输出饱和——当场景过曝时,所有像素都达到最大值,这时候任何检测算法都会失效。处理办法是检测到饱和区域后,跳过该区域的坏点检测,只做静态表校正。第二类是Sensor输出全黑——暗场下所有像素值都很低,动态检测的阈值需要动态调整,不能用一个固定值。我通常的做法是,根据当前帧的全局统计值(均值、方差)自适应调整检测阈值。第三类是坏点恰好位于图像边缘——邻域像素不足,无法计算统计值。处理办法是边缘像素只做静态表校正,不做动态检测,或者用镜像填充的方式扩展邻域。
还有一个容易被忽略的点:坏点校正的顺序。在ISP pipeline里,坏点校正通常放在黑电平校正之后、去噪之前。如果放在去噪之后,去噪算法会把坏点的能量扩散到周围像素,导致坏点特征被掩盖,检测难度大增。如果放在黑电平之前,暗电流的影响还没被去除,检测阈值不好设。所以顺序很重要,别乱调。
代码实现上,动态检测的核心逻辑大概是这样的:
// 这里踩过坑:别用int存像素差值,会溢出// 用int32_t,特别是处理12bit RAW时int32_tdiff[4];// 四个方向的梯度int32_tmin_grad=INT32_MAX;// 计算四个方向的梯度,取最小值// 方向1:水平diff[0]=abs(pixel-left)+abs(pixel-right);// 方向2:垂直diff[1]=abs(pixel-up)+abs(pixel-down);// 方向3:主对角线diff[2]=abs(pixel-up_left)+abs(pixel-down_right);// 方向4:副对角线diff[3]=abs(pixel-up_right)+abs(pixel-down_left);for(inti=0;i<4;i++){if(diff[i]<min_grad){min_grad=diff[i];}}// 归一化:除以邻域标准差// 别用全局标准差,要用局部窗口的floatnorm_grad=(float)min_grad/local_std;// 多帧一致性判断// 这里用环形缓冲区存历史结果,别用数组拷贝,浪费带宽if(norm_grad>threshold&&history[frame_idx%N]==1){// 连续N帧都异常,确认为坏点pixel=median_filter(pixel,window);}最后说点个人经验。坏点校正这个模块,看起来简单,但真正做好很难。我见过太多团队在这个模块上栽跟头,原因都是只盯着单帧的检测算法,忽略了时间域和空间域的联合分析。我的建议是:第一,静态表一定要做,而且要定期更新——量产后的老化测试数据要回流到静态表里;第二,动态检测一定要结合多帧,单帧检测就是耍流氓;第三,阈值参数不要拍脑袋定,要基于大量实拍数据做统计分析,找到合理的分布区间;第四,一定要留调试接口,把检测到的坏点位置和置信度输出到日志里,方便现场排查。
坏点校正不是一锤子买卖,它是Sensor生命周期里持续演进的过程。你今天调好的参数,明天换个温度环境可能就失效了。所以,设计算法的时候就要想着怎么自适应、怎么自学习,而不是靠一堆硬编码的阈值撑场面。