简介:面向FPGA与数字图像处理开发者,这份资源提供中值滤波算法的Verilog实现,适用于去除椒盐噪声、保留边缘细节的实时图像预处理场景。内容以压缩包形式包含中值滤波器的Verilog工程代码与设计文件,另有一个MATLAB脚本,用于将图像数据转换为文本格式,方便后续仿真与FPGA调试交互;整体10KB、共2个文件,结构精简,适合初学者快速开展算法验证与硬件部署。描述中提到的方法路径较为清晰:先借助MATLAB完成中值滤波算法原型设计与仿真,再通过HDL Coder自动生成Verilog代码,配合FPGA并行流水线、存储器管理实现实时处理,有助于缩短从算法到硬件落地的迁移周期。目前已有227人学习,代码量不大但涉及数据流组织、边界处理、接口设计等FPGA开发要点,可作为入门数字图像处理硬件加速的参考。
1. 中值滤波的Verilog实现,难的不是滤波而是排序
做图像采集卡或者实时视频预处理的时候,脉冲噪声(椒盐噪声)是最让人头疼的干扰之一:一幅正常画面里突然冒出来几十个纯白或纯黑的像素点,均值滤波会把噪声“抹开”成一片灰斑,形态学滤波又对噪声形状敏感,唯独中值滤波能把这些孤立噪点直接摘掉,几乎不伤边缘。于是很多做FPGA图像链路的工程师都会在某个版本里加入一个“中值滤波Verilog实现”的需求,真正动手之后才发现,滤波本身连乘法器都用不到,难点全在“9个像素在一个时钟周期里挑出第5大的值”这件事上。
我之前接过一个实时视频预处理模块,客户要求三行三列窗口、每像素时钟出一个结果,不能丢帧。一开始以为用几个比较器就能糊弄过去,结果在时序收敛和数据对齐上连续翻车,最后才把全比较法、排序网络和行列缓存的配合彻底理清。这篇笔记就按我实际做过的方案来讲:先讲清楚硬件里中值滤波长什么样,再给可直接拿走的Verilog实现和仿真参数,最后把那些让综合工具“玄学报错”的坑一个一个说透。
2. 先搞清楚硬件里的中值滤波:从滑动窗口到排序操作
2.1 中值滤波到底在算什么:不是平均,是排序取中
中值滤波的数学定义很简单:对目标像素周围邻域内的所有像素值排序,取中间那个值作为输出。对应到3×3窗口,就是把9个灰度值排好序,取第5个。这个操作在软件里用一句“sort然后取中间”就完了,在FPGA里却不能这么写——综合工具不会给你生成一个通用的排序电路,它只会把你写的比较逻辑翻译成LUT和触发器。
换个角度看,中值滤波就是三个子问题的组合:第一,怎么在像素流里形成3×3窗口;第二,怎么对这9个值做并行排序;第三,怎么保证排序结果和输入像素流严格对齐,不丢一拍、不错一位。其中第二个问题是核心,前两个问题其实是标准的数据通路设计。
在实际硬件实现里,我习惯把中值滤波看成“滑动窗口内的排序网络”。排序网络的特点是:输入和输出不经过反馈,完全是组合逻辑展开,只要给定9个输入,输出就是排序后的序列。这意味着中值滤波的延迟可以做到固定拍数,非常适合视频流这种每时钟周期进一个像素的场景。
2.2 窗口怎么形成:三行缓存与列对齐是关键
先解决数据来源的问题。视频流通常是一个像素接一个像素串行进来,按行扫描。要拿到当前像素左上、正上、右上这些邻域值,必须先把整幅图像的前面两行缓存住。常见做法是用两个行缓存FIFO,每个FIFO深度等于图像宽度:
- 第1个FIFO存第N-1行,输入是当前像素流;
- 第2个FIFO存第N-2行,它的输入来自第1个FIFO的输出;
- 当前像素、第1个FIFO输出、第2个FIFO输出,三者按同一个节拍取数,就凑成了三行同一列的数据。
然后把这3个列数据分别和它前两个周期的列数据拼接,就得到3×3窗口的9个像素。用Verilog描述时,窗口里的9个寄存器我习惯命名为v0 ~ v8,其中v4是当前中心像素:
v0 v1 v2 v3 v4 v5 v6 v7 v8这9个信号必须来自同一个时钟节拍,否则排序网络算出来的中值对应的窗口位置就错了。每来一个有效像素,9个值整体向左上平移一次,这就是滑动窗口的硬件本质。
2.3 三种中值提取方案选型:全比较法、排序网络、近似算法
拿到9个像素之后,怎么取中值?我实际对比过三种方案:
| 方案 | 比较器数量 | 逻辑级数 | 资源占用 | 适用场景 |
|---|---|---|---|---|
| 全比较法(计数选通) | 36(9选2) | 2~3级 | 最低,适合9个输入 | 3×3窗口首选 |
| 冒泡排序网络 | 每轮9路交换,约4轮 | 4~5级 | 中等,寄存器多 | 需要同时输出多个排序值时 |
| 双调/奇偶归并排序网络 | 约19~24个比较交换器 | 3~4级 | 较高 | 窗口扩大到5×5之后更合适 |
| 近似中值(行排序取中) | 9个以内 | 2级 | 最低 | 对精度要求不高的降噪预处理 |
全比较法的思路非常直接:对9个值两两比较,统计每个值比其它几个值大的次数。因为9个值各不相同(或者有相等),次数为4的那个值就是中值。这个方案在9输入时最省资源,逻辑级数也浅,是3×3中值滤波里最成熟的工程做法。
排序网络则更适合“我需要的不只是中值,还有最大值、最小值”这种场景,比如后面接自适应阈值分割时。如果只滤波,排序网络属于杀鸡用牛刀,不仅寄存器多,时序收敛也更费劲。
近似算法就是网上常见的“三行分别取中值,再取中值”——我明确说,这不是严格3×3中值滤波,输出有概率和真实中值不一样。除非你的算法验证阶段就接受了这种近似,否则别用在医疗影像或工业检测这类对像素一致性要求高的地方。
3. 全比较法实现3×3中值滤波:模块代码与参数说明
3.1 模块接口与时序约定
下面这段代码是我在实际视频预处理模块里用过的简化版本,去掉了行缓存FIFO,只保留核心的窗口数据输入和中值输出。这样更容易看懂排序逻辑,接真实FIFO时只需要把三个行缓存的输出按节拍接进来即可。
module median_3x3_full_compare #( parameter DATA_W = 8, parameter IDX_W = 4 ) ( input wire clk, input wire rst_n, input wire in_valid, input wire [DATA_W-1:0] v0, v1, v2, v3, v4, v5, v6, v7, v8, output reg [DATA_W-1:0] median_out, output reg out_valid ); // 9个输入两两比较,生成36个比较结果 // cmp[i][j] = 1 表示 v[i] > v[j] reg cmp [0:8][0:8]; genvar i, j; generate for (i = 0; i < 9; i = i + 1) begin : cmp_i for (j = i + 1; j < 9; j = j + 1) begin : cmp_j always @(*) begin if (v[i] > v[j]) begin cmp[i][j] = 1'b1; cmp[j][i] = 1'b0; end else begin cmp[i][j] = 1'b0; cmp[j][i] = 1'b1; end end end end endgenerate这里比较结果用二维reg数组来表示。注意比较时v[i] > v[j]和v[j] > v[i]是互斥的,也就是说每一对像素比较结果必定有一个1、一个0,这样后面统计“比它大的个数”时,每个值的秩之和必然是8,不会出现漏计。
两个值相等时,上面代码会把cmp[i][j]置0、cmp[j][i]置1,等于把“大”的定义偏向了后一个输入。这并不影响中值选取的正确性——重复值在窗口里占多个坑位,秩为4的那个值依然存在,只是如果整个窗口全相等时会选到靠后的输入,值本身不变。
3.2 统计秩与选通输出:核心的5行RTL
比较结果生成之后,下一步就是统计每个输入“赢过”多少次。
// 统计每个值的秩:rank[k] = 它比多少个其它值大 reg [3:0] rank [0:8]; always @(*) begin for (int k = 0; k < 9; k = k + 1) begin rank[k] = 4'd0; for (int m = 0; m < 9; m = m + 1) begin if (m != k) rank[k] = rank[k] + cmp[k][m]; end end end // 选出秩等于4的那个值,就是中值 always @(*) begin median_out = v4; // 兜底赋值 for (int k = 0; k < 9; k = k + 1) begin if (rank[k] == 4'd4) median_out = v[k]; end end这个选通逻辑综合出来是一个多路选择器树,9选1大约用8个LUT级别,对时序压力很小。rank在0~8之间,4位二进制够用。
最后打一拍输出,保证输出端不和组合逻辑直接相连,方便外部时序约束:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin out_valid <= 1'b0; end else begin out_valid <= in_valid; end end endmodule整个中值计算部分是纯组合逻辑:第一级是比较器,第二级是秩统计加法树,第三级是选通。以8bit灰度为例,合成后在主流FPGA中大约消耗180~240个LUT,时序跑200MHz以上没问题。如果综合工具报告时序不收敛,优先检查是不是把输出寄存器去掉导致组合逻辑直连到很长的数据通路上。
3.3 参数怎么调:位宽、窗口尺寸与吞吐率的关系
上面模块里暴露的DATA_W是灰度位宽,常见8bit,如果是12bit或16bit的RAW图像,需要把位宽参数改大,比较器资源会线性增长,但秩统计、选通逻辑几乎不受影响。
更值得关注的是吞吐率参数:这个模块每时钟周期处理一个像素,输出也是每周期一个。换句话说,吞吐率等于输入像素时钟频率。处理1080p@60fps的视频,像素时钟大约148.5MHz,所以模块需要能跑到148.5MHz以上;处理4K@60则大约需要594MHz,这时候单模块很难收敛,可选的方案是把图像分成四路并行的条带,每个条带一个滤波模块。
行缓存FIFO的深度跟图像宽度相关。如果只是单独仿真这个核心模块,不接FIFO,输入可以把9个颜色值直接拼接成72bit总线,valid信号约束好就行。我在做行为仿真时通常会把输入写成任务函数,随机生成带脉冲噪声的图像,逐像素送入模块,再和Matlab参考模型对比输出。
4. 气泡排序网络与双调网络:什么时候值得换一种写法
4.1 气泡排序网络:适合同时输出最大、最小、中值的场景
有一种常见需求是:滤波之后紧接着要做阈值分割,不仅想要中值,还想知道窗口内的最大值和最小值,用来估算局部对比度。全比较法虽然也可以读出最大最小,但要多加两级树形比较器。这时候换成气泡排序网络反而更直接。
气泡排序网络的核心思想是“相邻比较交换”:一组数据通过多级比较交换器后,大的向一端移动,小的向另一端移动。在Verilog里可以全部展开成组合逻辑:
// 单级交换单元:交换 a、b,使 min_out 为较小值 function automatic void swap_minmax( input [7:0] a, b, output [7:0] min_out, max_out ); if (a > b) begin min_out = b; max_out = a; end else begin min_out = a; max_out = b; end endfunction然后按轮次把9个值两两配对:第一轮比较 (0,1)、(2,3)、(4,5)、(6,7),(8)等待;第二轮比较 (1,2)、(3,4)、(5,6)、(7,8),如此交错进行,经过4~5轮之后,9个值基本有序。最终sorted[4]就是中值,sorted[0]、sorted[8]就是最小和最大值。
这种写法比较器数量多,但每一级交换都很规整,适合FPGA布局布线,不容易出现莫名其妙的延迟过大的路径。代价是寄存器资源比全比较法多出两到三倍。我只在“确实要同时用最大最小中值”时选它。
4.2 双调排序网络:从3×3走向5×5的跳板
窗口从3×3扩大成5×5以后,输入变成25个值,全比较法需要300个比较器,综合出来逻辑级数很深,时序非常难看。这时候可以用双调排序网络,它把一个长度非2的幂的序列补零/复制到2的幂,然后分阶段比较交换。
双调排序网络里有两个操作:bitonicMerge和bitonicSort。在Verilog里通常用generate循环展开成固定级数。5×5窗口取中值,先对每行5个值做小规模全排序,得到每行的最小值、中间值、最大值;然后利用行与行之间的有序性,对9个“候选值”做第二轮排序取中。这不是精确的25点中值,但很多图像处理论文里把它当快速近似方案用,误差通常在1~2个灰度级以内,肉眼完全看不出区别。
我实际在资源紧张的工程里用过这个近似方案,效果能接受。但如果产品规格书里写的是“25点严格中值”,那就必须上完整双调网络或者更复杂的基于直方图的方案。直方图法在FPGA上是另一条路线:统计窗口内灰度值分布,从低到高累加直方图,累加到13个像素时对应的灰度就是中值。这个方法在8bit灰度下需要256级累加,资源比排序网络多,但灰度位宽变大时扩展性好。
4.3 排序网络的综合参数:级数、位宽、时序优化
给一组我常用综合参数参考:
| 参数 | 全比较法 | 冒泡网络 | 双调网络 |
|---|---|---|---|
| 比较器数量(9输入) | 36 | 约72 | 约40~50 |
| 组合逻辑级数(8bit) | 3级 | 5级 | 4级 |
| 寄存器数量 | 约100 | 约300 | 约260 |
| 最大时钟频率(7系列级别) | 280MHz+ | 220MHz+ | 240MHz+ |
| 代码可读性 | 最好 | 一般 | 较差 |
频率数据以8bit灰度、普通speed grade为参考,实际值取决于具体芯片和布局。一个经验是:如果综合后时序违例不到2ns,优先优化组合逻辑级数,把比较结果、相等标志用寄存器打一拍,不要试图让工具“硬挤”。
5. 中值滤波Verilog实现的避坑指南:5个常见翻车点
5.1 窗口数据没对齐,中值结果全是花的
现象:仿真波形里每个输出像素单独看都对,但和参考图像对比时,整幅图出现斜向条纹或边缘错位。
原因:3个行缓存FIFO的读延迟不一致,或者列拼接寄存器没有同步打拍。比如第一个FIFO输出的行数据和当前像素流的行数据本来就相差一行,如果第二个FIFO深度设错,窗口的9个寄存器根本不是同一行同一列的组合。
解决:严格按“当前像素从FIFO0读出→打入FIFO1”的顺序,两个FIFO都用FWFT模式,并且在进入窗口拼接模块之前,对三路数据各打一拍同步。仿真时用一张有明显的横竖线的测试图逐帧查,比对着波形图数丰富多。
5.2 FIFO深度设成了2的幂而不是图像宽度
现象:图像右侧出现一整条竖线错位,或者每行的最后几个像素被错误滤波。
原因:行缓存FIFO的深度必须至少等于一行有效像素数。如果图像宽度是1920,配置FIFO深度为2048,读指针清零时机不对,行尾数据就会残留到下一行开头。
解决:把FIFO深度写死为“图像有效宽度 + 4(消隐余量)”,或者在行同步有效信号到来时强制重同步读指针。更稳妥的做法是外部直接把行有效信号接入FIFO的读使能,用行同步清零计数器,完成行对齐。
5.3 相等像素处理出错,导致中值偏向异常值
现象:把滤波器接在字符叠加模块后面,字符区域和背景交界处出现一排错误的灰点。
原因:两个像素相等时,如果比较逻辑同时输出cmp[i][j]=0且cmp[j][i]=0,那么两个值的秩都会偏小,可能没有任何一个值的秩等于4,选通过程掉进兜底赋值,输出了固定的v4(中心像素),导致局部输出抖动。
解决:比较时保证每对输出互补,见3.1节代码。用if (v[i] >= v[j])代替if (v[i] > v[j]),让相等时结果偏向固定一侧,这样秩总和永远为8,选通不会落空。
5.4 只验证了功能没验证时序,上板后图像偶发闪线
现象:ModelSim仿真完全正确,上板跑一会儿出现单帧闪线,或者某个分辨率下固定位置有噪点。
原因:滤波模块组合逻辑延迟接近时钟周期,遇到温度升高或电压波动,时序余量不足,出现亚稳态,偶尔一拍采到错误比较结果。
解决:把输出寄存器补上,组合路径加(* keep = "true" *)保留中间信号,用综合工具的时序报告看最差路径落在哪个比较器上。如果还抖,把排序逻辑拆成两级流水:第一级算前18个比较结果,第二级算剩余比较和秩选择,中间插入寄存器,代价是多一拍延迟,换来稳定收敛。
5.5 综合工具把排序网络优化没了
现象:资源报告里LUT数量异常少,但仿真结果输出恒等于某个输入值。
原因:工具发现排序网络的输入输出存在“等价”关系,比如某些输入常年恒为0(灰度0),优化掉了整块逻辑。这属于优化过了头。
解决:检查激励里是否所有灰度值都有覆盖,别只喂全灰图。另外在综合属性里设置max_fanout或者preserve,保住关键节点的比较逻辑。这类问题本质上不算bug,但排查起来极其耗时间,所以测试向量一定要包含随机噪声图。
6. 从3×3走向5×5:一个你必须知道的中值陷阱与我的选择
最后聊一个常见的进阶需求:把窗口扩大到5×5,让降噪能力更强。很多人的第一反应是“把3×3的中值模块复制5份,每行一个,最后再取中值”——这个方案我明确告诉你,结果不是严格5×5中值滤波。
原因很直观:5×5窗口有25个值,按行分组得到5个行中值,这5个行中值的中值并不等于25个值的全局中值。举个例子,某行的中值可能整体偏高,它依然能挤进最终候选,但全局排序里它根本排不到第13位。这个近似在噪声密度低时问题不大,噪声密度高时滤波结果会偏亮或偏暗。
我实际处理5×5窗口时有两个选择:如果芯片资源够,就用26输入双调排序网络,补一个恒为0的输入凑成16?不,25个输入补到32成本太高,可以直接用非2的幂版本奇偶归并排序网络,比较器约160个,5级流水,跑150MHz没问题。如果资源紧张,我会退而求其次用“十字形5×5中值”——只取中心十字上的9个值做中值,虽不是完整25点滤波,但实时性和资源占用和3×3几乎一致,对脉冲噪声的抑制在视觉上也不输完整5×5多少。这块我用过不止一次,代价是竖条纹噪声会残留,适合对边缘保护要求高的场景,不适合强噪声环境。
如果做医疗或工业检测类项目,我建议严格25点中值,别省资源。验证方式是用Python生成一张带2%椒盐噪声的标准测试图,分别用完整25点中值和近似方案跑一遍,肉眼比对差值和结构相似度,你立刻就能看出差异。
还有一个工程习惯:我会在中值滤波模块前保留原始图像的一路延时对齐通路,方便后续做“差值检测”——滤波结果和原图相减,差值大的像素就是疑似噪声点,这个信息可以送给后面的坏像素校正或压缩预处理。中值滤波模块本身只花几十行代码,但它接出来的这条差分通路,往往才是整个预处理链路里价值最高的部分。希望这次讲的这些实现细节和踩坑经验,能帮你少走一段我当年走过的弯路。
本文还有配套的精品资源,点击获取