做ISP的硬件工程师,基本都经历过这样的夜晚:传感器增益一拉高,预览画面里突然浮出密密麻麻的白点,截图放大一看,每个点死死钉在固定坐标上,不闪烁、不漂移。客户第一反应是Sensor坏了,但你把原厂坏点表重新烧一遍,画面依然不干净——因为新增的那批全是动态坏点。静态坏点表能覆盖出厂检测时留下的物理缺陷,可光电二极管的暗电流、温度漂移、长时间工作后的退化,都会让成品上原本健康的像素慢慢“变坏”,这些位置不在任何一张出厂表里,必须在ISP实时链路上用DPCC动态坏点校正拦下来。
我这次要分享的,就是在一块FPGA上落地商用级DPCC模块的完整思路:Bayer域滑窗怎么搭、多方向并行判决怎么做、误检和漏检怎么平衡、资源和时序怎么控。内容偏硬件向,但也适合做系统方案、做Sensor调试的朋友用来理解DPC为什么难。
1. 为什么会留下坏点活下来:从传感器物理缺陷到ISP的责任边界
1.1 静态坏点:出厂就带伤,靠表就能管
光电传感器做完封装测试,总会有一小部分像素的表现异常——可能永远输出纯黑、纯白,也可能输出值明显偏离正常范围。这类坏点在出厂阶段就会被定位到坐标,生成静态坏点表,随Sensor出货。ISP端做的事情很简单:读表、比对坐标、命中就把像素值替换掉。
静态坏点校正之所以能长期有效,是因为这些坏点的“坏法”相对稳定:亮度响应曲线偏了就是偏了,温度变化只是让偏差变大变小,性质不会反转成良品。所以一张测出来准的表,可以很久不更新。
但注意,“久”不等于“永远”。我遇到过Sensor工作两三万小时后,静态表里某个点失效,现场图像上重新冒出坏点的情况。这种情况靠出厂表已经无法兜底,正是因为物理不稳定性会持续制造新缺陷。
1.2 动态坏点:温度、增益与时间都在参与
动态坏点的来源可以按三个维度理解。
第一是暗电流。光电二极管在无光照时也存在热激发产生的漏电流,温度越高漏电流越大,输出就会亮。暗场长时间曝光时,这个值可能被拉得很高,个别管芯的漏电又天生偏大,就成了暗场亮点。
第二是模拟增益。ISP前端增益把微弱信号放大,坏点偏移量同样被放大。同一个像素,增益为1倍时偏移淹没在噪声里不可见,增益到8倍时就是一个非常显眼的亮斑或暗斑。
第三是持续退化。长时间工作、温度循环、光照老化的综合作用,会让部分像素发生不可逆改变,今天的良品可能变成明天的坏点。这部分根本无法提前入表。
从工程角度看,动态坏点与静态坏点没有本质差异,区别在于“未知出现时机”。因此ISP不能只做查表比对,必须在每个像素到达时实时评估它“像不像坏点”,这就是DPCC存在的意义。
1.3 DPCC在ISP流水线中的位置
DPCC通常放在RAW域、去马赛克之前。原因有两个:RAW域的像素值保留了传感器最原始的响应,坏点特征没有被后面插值/滤波抹平;而且Bayer域图像尚未经过颜色插值,此时校正坏点不会在插值过程中把坏点信息扩散给周围一圈像素。等到denoise、demosaic都跑完,坏点周围已经染上颜色,再校就晚了。
位置确定之后,DPCC模块要面对一个硬约束:像素是流式的,一个时钟来一个像素,整个决策必须在几个像素周期内完成,不能回头看几帧,也不能丢掉一行数据重建窗口。这也是为什么滑窗架构和并行判决几乎是DPCC的唯一务实解法。
这个位置还有一点容易被忽略:DPCC不是在YUV域做,也不是在RGB域做,而是在Bayer RAW域做。Bayer域给DPCC带来的麻烦,我在下一章专门展开。
2. 滑窗架构中的真实工作量:行缓存、同色窗口与像素流同步
2.1 Bayer域的坏点比较必须先拆通道
Bayer格式下,一帧图像RGGB四个相位交错排列,R像素和B像素相隔两个像素位置,直接取一个3x3窗口,中心是R、四邻居是G和B,比较上来就是“拿苹果跟橘子比亮度”,没有意义。
正确做法有两种。一种是把5x5窗口里R、G、B各自对应位置的亮度抽出来,形成针对每个相位的同色3x3邻域;另一种是硬件上把Bayer数据拆成RG、GB两条数据流,分别各自维护窗口。第一种比较通用,因为不同Sensor的Bayer排列可能不同,代码可配置性更好;第二种实现起来更省行缓存,但通道分离后需要额外处理两张图拼接回原图,时序上更麻烦。
我最终采用的是第一种:一路像素流进来,同时登记行/列计数,根据{row[0], col[0]}判断当前像素属于R、Gr、Gb、B中的哪个相位,再去记录窗口向量。5x5的窗口实际能拿到5x5共25个像素,但对中心像素做同色比较时,只用其中间距为2的那些邻居——也就是同相位的3x3。
换句话说,数据通路维护的是5x5窗口,判决逻辑只看中心相位对应的3x3同色矩阵。这样代码里所有比较器的输入,永远是同一色彩通道的值。
2.2 行缓存资源账:4条Line Buffer是怎么算出来的
要实现5x5窗口,FPGA里必须同时拿着当前行、前一行、前两行、前三行、前四行各一个像素点。像素是流式到达的,同一时刻只能看到当前像素,要“回头”看到前几行同一列的数据,只能靠行缓存。
行缓存的组织方式是一个移位链:新像素进来,压入第0行缓存,第0行缓存吐出的旧像素送入第1行缓存,依次类推。每个行缓存的深度等于图像水平像素数,位宽等于像素位宽。这样在每个时钟沿,5行缓存的输出端同时给出同一列上下间隔2行的5个数据,配合行内寄存器链,就能在下一拍把5x5窗口完整准备好。
资源账很好算。以1920x1080、10bit RAW为例,每条行缓存是1920*10=19200bit,4条就是76800bit。按FPGA片上BRAM的单位容量折算,通常2到3块BRAM就能覆盖;如果分辨率降到1080p以内且对时延敏感,也可以用移位寄存器阵列来搭,会占用大量FF/LUT,但能换来更低的延迟和更高的灵活性。
| 分辨率 | 像素位宽 | 4条行缓存总量(约) | 实现建议 |
|---|---|---|---|
| 640x480 | 8bit | 20Kb | 分布式RAM/移位寄存器即可 |
| 1280x720 | 10bit | 50Kb | 建议用BRAM,约2块(按36Kb/块估算) |
| 1920x1080 | 10bit | 75Kb | BRAM约2-3块 |
| 3840x2160 | 12bit | 180Kb | BRAM约5-6块 |
这里强调一点:行缓存深度不是按全分辨率行宽度算的,而是按“有效像素”算的。比如1920x1080但带消隐区的时序,一行总像素能达到2200甚至更高,如果行缓存按2200配,会白白多占BRAM;按有效宽度配,窗口生成时必须在有效区对齐,消隐区里的窗口数据要打断。
2.3 窗口生成时序与流水线延迟
窗口寄存器矩阵(5x5)的更新逻辑里,每一行都用移位寄存器推进,像素每拍进来一个,窗口整体向右滑动一格;行尾时窗口重置。要注意“行有效”与“帧有效”信号参与控制:行有效拉低期间,窗口不能吸收无效像素,否则窗口会被消隐数据污染,边界那几列会出现错位。
这个模块从像素进来到5x5窗口完整可用,会引入至少4个像素周期的行缓存延迟,再加上寄存器链的2拍,约6-7个像素时钟。对于1080p60约148.5MHz的像素时钟来说,不到50ns,整条ISP链路完全可以接受。4K@60的594MHz像素时钟下,窗口生成延迟更小,但时序收敛工作量更大,这个后面专门讲。
窗口生成的Verilog逻辑骨架大致长这样(省去行列判相位的完整过程):
// 5x5窗口寄存器矩阵(伪代码示意) wire [DATA_W-1:0] line_in; reg [DATA_W-1:0] row_buf [0:3]; // 4条行缓存读出端口 reg [DATA_W-1:0] win [0:4][0:4]; // 5x5窗口寄存器 always @(posedge clk) begin if (px_valid) begin // 行缓存链:当前像素进入line_buf0,line_buf0旧值进入line_buf1... // 窗口列链:每行窗口右侧左移一列,最右列填入对应行缓存输出 win[0][4] <= row_buf[3]; win[1][4] <= row_buf[2]; win[2][4] <= row_buf[1]; win[3][4] <= row_buf[0]; win[4][4] <= line_in; // 其余列向左移一位 for (int j = 0; j < 4; j++) begin win[0][j] <= win[0][j+1]; // ... 依次类推 end end end伪代码只是示意,真实工程里还要处理行有效、帧边界、相位切换时的窗口复位,否则边界几列会算出脏数据。
3. 并行判决:把一个“疑似坏点”在几个时钟内变成过滤结果
3.1 多方向梯度计算:一个时钟内完成的并行动作
窗口准备好之后,接下来要回答一个问题:中心像素C跟周围邻居比,到底异常到什么程度?
坏点检测最常用的不是“绝对亮度”,而是“相对邻居的偏差”。一个白点的绝对亮度可能在暗场里是5000,在亮场里是20000,直接拿绝对值设阈值没法同时适配两种场景;但不管场景多亮,坏点与同色邻居的差异都显著大于正常像素之间的差异。所以判决的第一步,是计算中心与各个同色邻居的绝对值差。
我采用4近邻加4对角线的方式:上、下、左、右是距离中心2个像素位置的同色邻居(5x5窗口里对应位置),另外考虑左上、右下、右上、左下两条对角线方向。每个方向组合做一个绝对值减法:
wire [DATA_W-1:0] d_up = abs(center - up); wire [DATA_W-1:0] d_down = abs(center - down); wire [DATA_W-1:0] d_left = abs(center - left); wire [DATA_W-1:0] d_right= abs(center - right); wire [DATA_W-1:0] d_diag1= abs(center - diag_lu); wire [DATA_W-1:0] d_diag2= abs(center - diag_rd); wire [DATA_W-1:0] d_diag3= abs(center - diag_ru); wire [DATA_W-1:0] d_diag4= abs(center - diag_ld);这八个减法器在硬件上完全是并行的,一个时钟周期内出结果。注意Bayer域同色采样时,这些邻居在5x5窗口中的坐标不是紧挨着中心,而是相隔一行/列,因此窗口矩阵里取出来时要按相位索引取对应位置。这一步如果做错,后面所有判断全乱。
3.2 阈值判决与投票逻辑:兼顾灵敏度和误检率
有了8个方向差值,坏点判决就需要两个阈值配合:
TH_LOW:低阈值,用来过滤噪声底线。比它小的差值一律视为正常波动。TH_HIGH:高阈值,用来判断“显著异常”。差值和它比较,决定该方向是否“强烈指认坏点”。
一个常用判决规则是:至少N个方向的方向差超过TH_HIGH,且M个方向超过TH_LOW,才判为坏点。比如要求8个方向里至少4个超过TH_LOW,且至少2个超过TH_HIGH。这个“投票”逻辑的好处是,单一方向的异常(比如真实边缘的一侧)不会触发坏点判决;只有中心像素与周围普遍差一大截,才足够可信。
投票逻辑在RTL里就是一组比较器和一个加法器/计数器再和阈值比较:
reg [3:0] cnt_low, cnt_high; always @(*) begin cnt_low = (d_up > TH_LOW) + (d_down > TH_LOW) + ... ; cnt_high = (d_up > TH_HIGH) + (d_down > TH_HIGH) + ... ; end assign defect = (cnt_low >= LOW_N) && (cnt_high >= HIGH_N);要注意的是计数器的位宽必须能覆盖方向总数,避免溢出翻转产生错误判断;同时 TH_LOW/TH_HIGH 寄存器建议按像素位宽扩展1到2位,防止差值刚好等于阈值时被“等于”错误排除。比较器用大于号,阈值本身应理解为“超过才算”。
这里还有一个细节:阈值并不是一个全局固定值,它通常要做成跟随增益的自适应参数。Sensor的模拟增益变化时,噪声底和坏点偏移量同步变化,阈值要按增益的查表关系调整,否则增益一拉高,全图都是“坏点”。
3.3 替换策略:中值滤波为什么是商用首选
判决通过后,要把坏点替换成合理值。三种常见策略各有取舍:
| 策略 | 实现成本 | 边缘保留 | 适用场景 |
|---|---|---|---|
| 邻域均值 | 加法器+寄存器,成本最低 | 差,边缘被抹平 | 快速原型、低端场景 |
| 邻域中值 | 排序网络,比较器数量在数十个量级 | 好,适合有结构图像 | 商用主流 |
| 方向导向替换 | 方向分类+选择器 | 最好,但判断逻辑复杂 | 高端ISP,对细节要求极高 |
我最终选了中值。原因很直接:坏点的本质是“这个像素不可信”,而中值是邻域排序后的中间值,天然丢弃极值,对边缘和平坦区域都比较稳;均值会把坏点偏向拉低边缘的对比度,在暗场里还会让亮点残影一圈。方向导向替换见效最好,但需要引入方向分类器,逻辑量和验证成本都上去了,除非你的产品里DPC后面紧跟着很激进的降噪,否则中值已经能解决九成问题。
中值实现上,4个方向邻居做排序网络大约需要6个比较器,扩展到8个邻居时通常在二三十个比较器左右,对FPGA来说这点逻辑完全可以接受。排序后取中间值或中间两个值的均值,作为替换结果。注意替换不是直接把结果写回原图,而是通过MUX在输出级选择:pixel_out = defect ? median_result : center。这样正常像素一条直线路径过去,坏点才走替换路径,逻辑延迟差异就控制住了。
4. 误判治理实录:边缘、纹理、高增益噪声到底怎么应对
4.1 真实边缘与坏点的边界在哪里
第一版DPC跑出来的误检比想象中多。黑白棋盘格、文字边缘这类高频区域,中心像素和一侧邻居的差值天然很大,如果不加约束,整条边缘都会被当成“坏点列”抹掉。
回头看判决条件能发现,误检之所以发生,是因为“4个方向超过TH_LOW、2个方向超过TH_HIGH”这个投票太粗粒度。在真实边缘上,中心与左侧邻居差巨大,但中心与右侧邻居差很小;只看“计数”不看“方向分布”,右侧邻居的小差值照样能满足TH_LOW,计数就被凑够了。
改进办法有两种,我在工程里都试过。
第一种是加上“方向一致性约束”:如果中心与左邻居差大、与右邻居差也大,但与垂直方向邻居差小,说明它更像一条横向边缘上的点而不是孤立缺陷,应降低坏点置信度。
第二种是观察“邻居之间是否也存在类似尺度差异”。坏点往往是单点孤立异常,而边缘上相邻像素之间有连续过渡;当左右邻居本身也存在大差异时,中心更可能是一段真实纹理的一部分。把这层关系编码进判决,误检能明显下降。代价是逻辑和验证都变得更复杂,需要权衡。
4.2 暗场高增益:噪声冒充坏点
暗场、长曝光、高增益是另一个大坑。像素的读出噪声随增益线性放大,单帧RAW里正常像素的随机抖动可能达到几百个LSB,比不少真实坏点的偏差还大。此时固定阈值会产生两条结果:阈值调高,真实坏点漏掉;阈值调低,整片噪声都被标记成坏点,校正后画面出现雪花状的块状闪烁。
针对这种情况,我建议做三件事。
第一,把DPC阈值做成随增益变化的查表层。增益越大,TH_LOW和TH_HIGH都往上调,但两个阈值的比值要保持合理范围,避免灵敏度和误检一起失效。这个表建议每个Sensor单独标定,不同传感器噪声分布差异很大。
第二,利用多帧一致性。真实坏点在每一帧的同一坐标都会异常,而随机噪声逐帧不同。因此在动态坏点检测结果后追加一个“连续帧确认”逻辑:同一坐标累计被判定M次(例如3次连续),才真正进入校正列表;单帧偶发判决不做替换。这样会牺牲一帧左右的时间延迟,但换来的稳定性非常高。
第三,把“像素级判决”和“统计级判决”分开。像素级做的是实时替换,统计级做的是候选坏点表的更新。实时替换路径用高阈值保平安,统计路径用低阈值捕手发现新坏点,两条路互不干扰。这是商用ISP里比较成熟的“快替换、慢学习”结构。
4.3 边界行/列与Bayer相位错位
边界处理最容易被忽略。图像第一行没有上一行数据,第0列没有左侧邻居,5x5窗口在四周边界处天然残缺。常见处理有三种:边界复制扩展、用部分窗口、直接bypass。
我推荐直接bypass,即图像前两行、后两行、左两列、右两列不做DPC校正。理由很现实:边界像素本来就少,坏点出现概率低,肉眼在画面边缘也不太敏感;复制像素和部分窗口的做法会引入固定的模式,反而可能在特定视频里呈现出一条隐约的边线。bypass实现也最简单,用行列计数器判断窗口是否完整即可。
Bayer相位错位则更隐蔽。RGGB、BGGR、GRBG、GBRG四种排列,窗口索引逻辑完全不同。当Sensor驱动配置错误或者输出格式切换时,同一套窗口索引取到的“同色邻居”实际是异色像素,判决立刻失真。我的办法是在模块入口做一个排列可配置的相位映射表,切换Sensor时只改寄存器,不改RTL。
4.4 动态坏点表学习:如何确认一个点是“死透了”
最后加一张动态坏点表。当某个坐标在多帧中反复被判定为坏点,置信度计数达到一定阈值(比如连续8帧),就把它写入片上维护的动态坏点表。之后同一坐标出现时,直接用表中预留的校正值或标志旁路判决,省去实时判决的算力,同时锁定该点不会因某一帧噪声翻转。
写表前一定要经过确认机制,这也是“学习”和“乱学”的分界线。噪声在单帧里偶发大值,但在连续多帧内不会一直钉在同一坐标;真实坏点则相反。计数器加“连续帧确认”能天然过滤噪声。另一个细节是坏点表要定期衰减:某些点可能只是表现为“临时故障”,长时间稳定后又会恢复正常,如果永远躺在表里,反而制造新错误。工程上可以设置观察计时器,超时未再命中则自动清除表项。
5. 从RTL到商用量产:资源、时序与参数化设计
5.1 资源预算:LUT、FF、BRAM大概要花多少
DPCC的硬件成本主要花在四块:行缓存(BRAM/分布式RAM)、窗口寄存器(FF)、判决逻辑(LUT+加法器/比较器)、替换排序网络(比较器+选择器)。
以1080p@10bit、5x5窗口、4近邻+4对角、中值替换的配置来估算,在典型中端FPGA上大约是:
| 资源 | 估算用量 | 说明 |
|---|---|---|
| BRAM | 2-3块 | 4条行缓存,按36Kb/块分摊 |
| FF | 约1500个 | 窗口寄存器矩阵+流水线寄存器 |
| LUT | 约1200-1800个 | 比较器、计数投票逻辑、排序网络 |
| DSP | 0 | 纯逻辑加减比较,不需要乘法器 |
这个量级放到一颗动辄几十万LUT的FPGA里,占比非常小,说明DPCC真正考验的从来不是面积,而是数据流组织和误判治理。
有一些优化技巧可以减少资源:行缓存按“块内一行”分配时,如果像素位宽不是BRAM位宽整数倍,可以借位宽打包省块;窗口寄存器矩阵里,每行不需要完整保存5个像素的情况,可以只在当前行保留寄存器链,其余行直接从行缓存读出打一拍;判决逻辑里计数器和比较器可以复用,用时分复用把8个方向分成两组,LUT能省一半,但会增加一拍延迟。是否值得优化,要结合整个ISP链路的时序余量和面积预算判断。
5.2 时序收敛与流水线打拍技巧
像素时钟到594MHz(4K@60)时,组合逻辑必须认真切流水。我的做法是把判决路径拆成四级流水:
第一级:生成窗口和中心像素,生成8个方向差。 第二级:方向差与TH_LOW、TH_HIGH比较,输出8组比较标志。 第三级:投票计数,得到defect标志。 第四级:MUX选择校正结果输出。
这样每级组合逻辑大约只有几个LUT的深度,再配合寄存器的单一时钟域约束,594MHz下依然能稳定收敛。有一处很容易踩坑:阈值寄存器从APB/AXI配置接口写入时可能跨越时钟域,如果直接进判决比较器,会产生亚稳态。配置信号要在模块内做两级同步寄存器打拍,并且只在链路空闲/帧间隙生效,否则画面会出现一整条或一整块的错误校正带。
另外,排序网络(中值计算)本身是一个比较金字塔,跨级比较器之间天然有关键路径。如果时序紧张,可以给排序网络内部也插入寄存器,把中值计算拆成两拍。中值结果晚一个像素到达没关系,反正MUX选择时对齐defect标志即可,关键是要让defect和median到位的是同一拍。
5.3 参数寄存器化与多Sensor配置切换
商用ISP必然要面对多Sensor、多分辨率、多场景的切换。DPCC模块不能是写死的硬件,所有决策参数都应该暴露成配置寄存器:
- 使能与旁路:
DPCC_EN、BYPASS_EN - 阈值四件套:
TH_LOW、TH_HIGH、MIN_LOW_N、MIN_HIGH_N - 增益自适应阈值表的基址和步进
- 坏点表基址、容量、验证计数阈值
- 排列/相位的映射寄存器
这些寄存器建议用双缓冲设计:后台写“影子寄存器”,调度器在帧间隙统一搬运到“生效寄存器”,避免级联配置在帧中间生效导致画面跳变。
多Sensor切换时,除了配置参数,坏点表也要能随Sensor一起切换。如果只有一片SRAM存坏点表,不同Sensor的表内容互相覆盖,切回原Sensor时坏点表已经失效。我建议给坏点表划分出独立分区,或做一套表切换的上下文保存机制。这个细节很多人到量产阶段才会发现,改起来比写DPC本身还费劲。
5.4 与静态坏点表的双通道融合
最终方案里,动态DPC和静态坏点表不是二选一,而是并行双通道。
静态表管“确诊”的坏点:坐标命中就直接替换,完全不参与动态判决,这样可以释放判决算力,也避免那些出厂表里的坏点再来干扰投票计数。动态判决管“疑似”的坏点:负责发现新缺陷,并经多帧确认写入动态表。两条通道在输出级用一个MUX统一选择最终值。
这个结构有个附带好处:出厂静态表可以做成只读区域,动态表做成可写区域,两者分开管理。做量产测试时,工厂可以重新烧录静态表,也方便产线把老化筛选出来的新坏点固化到表中,省去专门跑一遍ISP校准流程。
最后,我把这些年踩坑后的一点经验收个尾。第一版先不要做花哨的方向一致性约束、多帧确认、静态表融合,把最朴素的“5x5同色窗口+8方向差值+阈值投票+中值替换”跑通,找一张有明显坏点的图验证链路,再逐步加约束和自适应。坏点校正在静态图上验证几乎没有意义,多准备几组暗场、亮场、高增益、低增益的底片,把误检和漏检量化出来再做微调,比在RTL里反复试参数高效得多。这也是为什么我始终认为,DPCC模块的难点不在RTL本身,而在你怎么定义“坏”的标准。