4. RAW数据预处理与坏点校正:静态/动态坏点检测算法(DPC),基于FPGA的流水线实现,以及如何利用Block RAM做行缓存
各位同学,欢迎来到第四章。前面我们聊了RAW数据的格式和Bayer阵列,今天要讲的是ISP流水线上一个非常基础、但又极其关键的环节——坏点校正(DPC)。
你想想看,一块CMOS传感器,几百万甚至上千万个像素点,生产过程中难免会有几个“瑕疵品”。这些坏点如果不处理,拍出来的照片上就会出现固定位置的亮点或暗点,非常影响观感。我当年刚入行时,就遇到过一块Sensor,坏点密密麻麻,差点以为是镜头脏了……后来才发现是DPC没做好。
好,我们直接进入正题。
4.1 坏点类型与检测原理
坏点,说白了就是光电响应异常的像素。主要分两类:
| 类型 | 表现 | 成因 |
|---|---|---|
| 静态坏点 | 固定位置,始终过亮(白点)或过暗(黑点) | 制造缺陷,出厂即存在 |
| 动态坏点 | 特定条件下才出现,比如高温、长曝光 | 工艺波动、温度漂移 |
检测的核心思路是什么?很简单——比较。一个正常的像素,它的值应该和周围同颜色的邻居差不多。如果它比周围亮太多或暗太多,那它大概率就是坏点。
核心公式(简化版):
如果 |Pcenter - Pneighbor_avg| > Threshold,则判定为坏点。
这个Threshold怎么定?静态坏点可以用固定的阈值,动态坏点就需要根据图像局部亮度自适应调整。我个人习惯在项目中把静态和动态结合起来用:先用静态表把已知的坏点干掉,再用动态算法处理那些“漏网之鱼”。
4.2 静态坏点检测:查表法
静态坏点检测,说白了就是“记黑名单”。
Sensor出厂时,厂商会提供一个坏点列表(Defect Pixel List),里面记录了每个坏点的坐标。我们在FPGA里把这个列表存成一张查找表(LUT)。
流程很简单:
- 每个像素进来,查它的坐标是否在坏点列表中。
- 如果在,标记为坏点,进入校正流程。
- 如果不在,直接通过。
这里有个坑——坏点列表可能很大。我曾经处理过一个项目,坏点列表有几千个点。如果用纯LUT硬查,资源消耗非常大。我的做法是用Block RAM构建一个哈希表,把坐标映射到RAM地址,这样查一次只需要一个时钟周期。
小技巧: 如果坏点列表是固定的,可以在FPGA初始化时从外部Flash加载到BRAM里。这样既节省逻辑资源,又方便后期更换Sensor型号。
4.3 动态坏点检测:基于邻域统计
动态坏点检测不需要预存列表,它实时分析像素的邻域信息。常用的方法有:
- 中值法: 计算3x3邻域内同色像素的中值,如果中心像素与中值的差超过阈值,判为坏点。
- 均值法: 计算邻域均值,比较差值。
- 梯度法: 计算水平、垂直、两个对角线方向的梯度,如果所有方向的梯度都很大,说明这个点很“突兀”。
我个人比较推荐中值法。为什么?因为均值法容易被其他坏点“污染”,而中值法对异常值不敏感,鲁棒性更好。
来看一个3x3邻域的例子(以Bayer阵列的G通道为例):
// 假设3x3窗口,中心是G像素
// 周围四个G像素:G00, G02, G20, G22
// 中心:G11
// 计算四个邻居的中值
G_neighbors[4] = {G00, G02, G20, G22};
sort(G_neighbors, 4);
median = (G_neighbors[1] + G_neighbors[2]) / 2; // 偶数个取平均
// 判断
if (abs(G11 - median) > TH_DPC) {
is_bad = 1;
} else {
is_bad = 0;
}
注意: 阈值TH_DPC不能是固定值。在暗光区域,噪声本身就大,固定阈值容易误判。我建议用局部均值乘以一个比例系数作为自适应阈值。比如:TH_adaptive = K * local_mean,K一般在2~4之间。
4.4 坏点校正:插值替换
检测到坏点之后,怎么修?很简单——用邻居的值把它“填”上。
常用的校正方法:
- 邻居均值: 用周围同色像素的平均值替换坏点。
- 中值替换: 用中值替换,抗干扰能力更强。
- 梯度导向插值: 沿边缘方向插值,保留细节。
在FPGA里,我一般用中值替换。原因很简单——实现起来最省资源,效果也够用。毕竟DPC只是ISP流水线的第一步,后面还有去噪、Demosaic等更精细的处理,这里不需要做得太复杂。
4.5 基于FPGA的流水线实现
好,理论讲完了,我们来看看怎么在FPGA里把它跑起来。
整个DPC模块的流水线分为三级:
| 流水线级 | 功能 | 时钟周期 |
|---|---|---|
| Stage 1 | 行缓存与窗口生成 | 1 clk |
| Stage 2 | 坏点检测(静态+动态) | 1 clk |
| Stage 3 | 坏点校正与输出 | 1 clk |
每级流水线之间用寄存器打拍,保证数据同步。整个模块的延迟只有3个时钟周期,非常高效。
下面这张图展示了DPC模块的整体架构:
4.6 利用Block RAM做行缓存
行缓存是FPGA实现窗口运算的核心。要生成一个3x3的窗口,我们需要同时访问三行数据。但Sensor的数据是一行一行串行进来的,怎么办?
答案就是——用Block RAM做行缓存。
具体做法:
- 例化两个BRAM,每个深度等于图像一行像素数。
- 数据流进来时,依次写入BRAM0、BRAM1。
- 同时从BRAM0、BRAM1和当前输入中读取数据,拼成3行。
- 再用三个移位寄存器,从三行中提取出3x3的窗口。
来看一段简化的Verilog代码:
// 行缓存模块(简化版)
module line_buffer #(
parameter WIDTH = 12, // 数据位宽
parameter COLS = 1920 // 每行像素数
)(
input clk,
input rst_n,
input [WIDTH-1:0] din,
input valid_in,
output [WIDTH-1:0] dout_row0, // 上一行
output [WIDTH-1:0] dout_row1, // 当前行
output [WIDTH-1:0] dout_row2 // 下一行(实际是当前输入)
);
// 例化两个BRAM
reg [WIDTH-1:0] bram0 [0:COLS-1];
reg [WIDTH-1:0] bram1 [0:COLS-1];
reg [10:0] wr_addr;
reg [10:0] rd_addr;
always @(posedge clk) begin
if (!rst_n) begin
wr_addr <= 0;
end else if (valid_in) begin
bram0[wr_addr] <= din; // 写入BRAM0
bram1[wr_addr] <= bram0[rd_addr]; // BRAM0读出写入BRAM1
wr_addr <= wr_addr + 1;
rd_addr <= rd_addr + 1;
end
end
// 输出三行数据
assign dout_row0 = bram1[rd_addr]; // 上上行
assign dout_row1 = bram0[rd_addr]; // 上一行
assign dout_row2 = din; // 当前行
endmodule
经验之谈: 对于1080p的图像,一行1920个像素,每个像素12bit,一个BRAM只需要1920×12=23,040bit。Xilinx 7系列的一个BRAM是36Kb,完全够用。两个BRAM就能搞定3行缓存。
有了三行数据之后,再用三个移位寄存器(每个深度为3)就能提取出3x3的窗口了。这部分实现比较简单,我就不贴代码了,留给大家当练习。
4.7 避坑指南与工程经验
最后,分享几个我在项目中踩过的坑:
- 边界处理: 图像的第一行和最后一行,第一列和最后一列,没有完整的3x3邻域。我的做法是直接复制边界像素,或者干脆不做DPC,让它们原样通过。反正边界像素占比很小,不影响整体效果。
- 阈值调优: 动态坏点的阈值K值,我建议在仿真阶段用多张不同亮度的图片测试。K值太小会误判,K值太大会漏检。我一般取2.5~3.0之间。
- 流水线平衡: 如果静态表查表延迟比动态检测多一个周期,记得在动态检测路径上插入一级寄存器做对齐。否则数据会“打架”。
- 资源复用: 如果后面还有去噪模块(比如Bilateral Filter),它也需要行缓存。可以考虑把DPC和去噪的行缓存共用,节省BRAM资源。
曾经有个项目: 我为了省资源,把DPC的阈值设成了固定值。结果在暗光场景下,大量正常像素被误判为坏点,画面出现“麻子脸”。后来改成自适应阈值,问题才解决。所以,阈值自适应不是可选项,是必选项。
好了,这一章的内容就到这里。DPC虽然基础,但它是ISP流水线的第一道关卡。如果这里没做好,后面的去噪、Demosaic都会放大坏点的影响。希望大家动手把代码写一写,在FPGA上跑通一个简单的DPC模块,你会对流水线设计有更深的理解。