拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FPGA图像处理入门:中值滤波与膨胀腐蚀的ZYNQ实战

FPGA图像处理入门:中值滤波与膨胀腐蚀的ZYNQ实战

做FPGA图像处理好几年了,每年都会被问“怎么入门”。说实话,图像处理在FPGA里是一个特别好的练手方向,它不像高速接口那样依赖板卡和示波器,也不像复杂算法那样一上来就要啃数学。它需要的核心能力就两样:懂一点图像算法的基本原理,能把它翻译成“像素流 + 行缓存 + 流水线”的硬件思维。这篇文章就基于我在ZYNQ7020上做的一个完整工程——图像中值滤波加膨胀腐蚀,把从算法到RTL实现再到上板调试的整套思路原原本本讲一遍。

这个项目适合两类人:一类是学过Verilog语法但还没做过完整图像工程的FPGA新手,另一类是在MATLAB或者OpenCV里写过图像算法、想看看硬件上怎么落地的人。看完之后你至少能掌握三件事:中值滤波和膨胀腐蚀在硬件上怎么实现、3x3窗口和行缓存怎么设计、以及图像在FPGA里到底是怎么“流”起来的。整个过程用到的器件就是一块ZYNQ7020开发板,属于Xilinx Zynq-7000系列,PS端是双核ARM Cortex-A9,PL端是Artix-7架构的可编程逻辑,正好适合软硬协同的玩法。

1. 项目整体设计与思路拆解

1.1 为什么选ZYNQ7020做图像处理入门

先聊一下平台。ZYNQ7020这个芯片在FPGA图像处理入门圈子里几乎成了“标配”,原因很现实:它便宜、资料多、而且架构对新手非常友好。

ZYNQ的特别之处在于它是异构SoC,芯片内部同时集成了PS(Processing System,双核ARM Cortex-A9)和PL(Programmable Logic,等价于一片Artix-7 FPGA)。这样一个芯片能干两件事:ARM上可以跑Linux、跑OpenCV、跑网络协议栈,FPGA逻辑里可以跑实时图像处理算法。对图像处理来说这个组合价值很高——你完全可以把视频采集驱动、显示控制放在PS端用软件解决,把像素级的算法(中值滤波、膨胀腐蚀、边缘检测这类)放到PL端用硬件流水线处理。

对比一下其他方案就更清楚。如果用纯FPGA,比如 Spartan-6或者Artix-7,那视频采集、DDR读写、图像缩放全部要用Verilog写,对新手来说工作量直接翻倍。用单片机或者ARM做图像处理,虽然软件好写,但遇到720P@60fps这种实时处理需求,CPU串行处理很难跑满。ZYNQ7020正好站在中间:PS端管“控制”,PL端管“数据流”,等于给了你一条软硬协同的坡度,新手可以先用简单方案跑通链路,再把性能瓶颈一个个搬进PL端优化。

具体到开发板,入门阶段不需要多贵多高端。市面上几家主流开发板都有基于7020的型号,价格大概在千元左右。我的建议是选带HDMI输入输出接口的板子,因为视频采集和显示是图像处理最直观的验证方式。我用的是带OV5640摄像头接口加HDMI输出的板卡型号,调试起来非常方便。

1.2 中值滤波、膨胀腐蚀搭配起来能干什么

很多人会疑惑,为什么把这三个操作放在一个工程里?其实它们在图像处理链路中各自扮演不同角色,组合起来刚好构成一条完整的经典预处理流程。

先看中值滤波。它的核心作用是去除椒盐噪声——也就是图像上随机出现的黑白噪点。这种噪声在传感器采集或者传输过程中很常见,特点是单个像素点的灰度值和周围差异极大。中值滤波的做法是把每个像素点周围邻域的灰度值排序,用中间值替代当前像素。因为椒盐噪声在排序后大概率落在最大值或最小值的位置,中值取到正常像素值的可能性很高,所以去噪效果非常直接。相比均值滤波,它最大的优势是去噪的同时能保留边缘细节,不会把图像弄糊。

再看膨胀和腐蚀。这两个属于形态学处理,操作对象通常是二值图。腐蚀是取邻域内的最小值,结果是白色区域收缩,能去掉小的白色噪点和细小的连接;膨胀是取邻域内的最大值,结果是白色区域扩张,能填充空洞和断裂。两者组合能得到开运算(先腐蚀后膨胀,去孤立点)和闭运算(先膨胀后腐蚀,填补空洞)。

这三个算法在硬件实现上有很强的共性:都是基于3x3滑动窗口的邻域运算,区别只在最后的计算逻辑——中值滤波是排序取中,膨胀是取最大值,腐蚀是取最小值。正因如此,把它们放在一个工程里,你可以只做一套行缓存和窗口生成模块,然后复用在三个算法上,学习效率非常高。从工程角度讲,这也是一个很经典的“一鱼多吃”架构设计。

实际的图像流程我是这样设计的:摄像头采集到RAW RGB数据后,先经过一个简单的灰度转换模块变成8bit灰度图,然后进入三路并行分支——一路直通输出做对比,一路过中值滤波,一路在中值滤波之后再做二值化加膨胀腐蚀。最后通过一个MUX把三路图像切换到HDMI显示,方便直接肉眼对比效果。

1.3 从软件图像处理到FPGA图像处理的三个思维转换

学习FPGA图像处理最大的门槛不是语法,而是思维方式的转换。我从软件转硬件的时候踩了不少坑,总结下来有三个最核心的思维转变。

第一个转变是从“整图处理”到“像素流处理”。在MATLAB或者OpenCV里,图像是一个二维矩阵,你可以随便索引任意位置的像素,img(i-1:i+1, j-1:j+1)这种取邻域操作非常自然。但FPGA不存整幅图像——至少入门阶段不这么干。在FPGA里,图像是按照像素时钟一个一个流进来的,同一时刻你只能看到当前像素和它之前的几个像素。所以你得接受一个事实:你不能“跳到”图像的任意位置,只能用行缓存和窗口移位寄存器把当前需要的邻域数据组织起来。

第二个转变是从“循环逻辑”到“流水线逻辑”。软件里写一个双重for循环遍历所有像素,这很自然。但在FPGA里,没有“循环”这个概念,你要做的是把算法展开成并行流水线:每个像素进入模块后,经过固定周期的延迟,在出口处输出对应的处理结果。中值滤波的排序不是“对9个像素排序一次”,而是“每一拍都对9个像素重新排序一次”,因为你面对的是一条永不停歇的数据流。这种数据流思维需要适应,但一旦掌握了会非常顺手。

第三个转变是从“调参验证”到“仿真打表”。软件里改一个参数然后跑一遍,立刻能看到结果。FPGA上板调试成本太高,正确的做法是先在Testbench里用激励数据验证算法逻辑,用波形比对输入输出延迟是否对齐,然后再上板。这个转变越早完成,调试效率越高。

这三个思维转换在后面的实现章节会反复出现,我尽量在每个代码细节上都标注对应的硬件思维,方便对照理解。

2. 算法原理与FPGA硬件实现思路

2.1 中值滤波:排序网络的硬件实现方案

中值滤波的原理一句话就能说清楚:取当前像素3x3邻域内9个灰度值的中位数,替代当前像素值。但“取中位数”在硬件上有讲究,不能直接调用软件里的sort函数,得考虑资源开销和时序延迟。

最简单的思路是做一个9输入的全排序网络,排完之后取第5个值。但9个数的全排序网络在硬件上需要大量比较器和多路选择器,资源可观且时序路径长。实际工程中用得更多的是“列排序+行比较”优化方案,网上的经典案例是采用5个排序模块的55比较器方法(通常叫“列排序法”),步骤是:

  1. 对3x3窗口的三列分别做3输入排序,得到每列的最小值、中值、最大值。
  2. 取三列的最小值中的最小值,记为min_of_min。
  3. 取三列的最大值中的最大值,记为max_of_max。
  4. 取三列中值中的中值,记为mid_of_mid。
  5. 最后对这三个值再排序,中值就是刚才的min_of_min、max_of_max、mid_of_mid三个数中的中位数。

这里有个巧妙的数学性质:9个数的中位数,恰好等于“列最小值中的最大值、列中值中的中值、列最大值中的最小值”这三个数的中位数。相比全排序网络,这种方案只需要五组3输入比较器(每组3个比较器),资源大大减少,而且天然适合拆成三级流水线:第一级做列排序,第二级做行向比较,第三级做最终选择。每级只经过一级比较器,时序非常干净。

这里我把5组比较器的逻辑梳理一下。假设3x3窗口的9个像素命名为a00、a01、a02(第一行三个)、a10、a11、a12(第二行)、a20、a21、a22(第三行)。第一步对每一列分别排序:

  • 第0列排序:a00、a10、a20 → min0、mid0、max0
  • 第1列排序:a01、a11、a21 → min1、mid1、max1
  • 第2列排序:a02、a12、a22 → min2、mid2、max2

然后取三列结果分别求3输入排序:

  • 对min0、min1、min2排序 → 得到min_min, mid_min, max_min,其中真正用到的是max_min(三个最小值中的最大值)
  • 对mid0、mid1、mid2排序 → 得到min_mid, mid_mid, max_mid,真正用到的是mid_mid(三个中值中的中值)
  • 对max0、max1、max2排序 → 得到min_max, mid_max, max_max,真正用到的是min_max(三个最大值中的最小值)

最后把max_min、mid_mid、min_max做3输入排序,取中间值就是整个3x3窗口的中位数。这套方案总共用了3个3输入排序模块加1个3输入排序模块,每个3输入排序用3个比较器即可实现。

相比软件里用冒泡法对9个元素排序,这个硬件方案只用了更少的比较器,还因为结构规整非常容易被综合工具优化。我强烈建议新手直接实现这个优化版本,既练手又贴近工业级写法。

2.2 膨胀腐蚀:二值形态学的硬件落地

膨胀和腐蚀在软件形态学里是针对二值图像的,但在硬件实现上本质就是“3x3窗口内取最大值/最小值”的操作。取最大值的那个叫膨胀,取最小值的那个叫腐蚀。

等等,仔细推敲一下会发现,灰度图也能做膨胀腐蚀操作,只是效果比二值形态学更微妙。在实际工程中,我做的是先把灰度图二值化,再对二值图做膨胀腐蚀。二值化用的是一个简单的比较器:灰度值大于等于阈值就输出255,否则输出0。阈值可以通过按键实时调整,这样能在HDMI显示器上直接观察不同阈值下形态学处理的效果。

膨胀和腐蚀的硬件实现比中值滤波简单得多:对3x3窗口内的9个像素取最大值或者最小值,就得到了膨胀或腐蚀的结果。9个数的最大值可以通过两级比较树实现——第一级做4个比较器得到中间结果,第二级再比较。由于逻辑非常规整,综合后延迟很小,流水线只需要插一到两拍寄存器。

这里有个细节值得注意:严格来说,膨胀腐蚀是定义在结构元素上的。也就是说,不一定是3x3的全1结构,也可能用十字形、对角线等不同形状。但在FPGA入门阶段,3x3全1矩形结构元素是最常见的选择,因为硬件实现最简单。如果想扩展,只需要在取最大值/最小值的比较树前面加一个掩码逻辑:结构元素中为0的位置不参与比较即可。这个扩展留作后续进阶练习。

2.3 行缓存与3x3窗口生成模块

这是FPGA图像处理里最核心的架构设计,也是新手最困惑的地方。前面说过,FPGA的图像是像素流,一个时刻进来一个像素,但3x3窗口需要同时知道当前像素周围8个邻居的值。而这些邻居分布在图像的不同行——上一行的同行、上一行的左邻右舍、当前行的左邻右舍等等。

解决思路就是行缓存(Line Buffer)。行缓存的作用是把前面两行数据存下来,配合当前行一起输出三行数据,然后再用移位寄存器把三行的同一位置像素对齐,生成3x3窗口。

具体做法是这样的:

  • 输入是一路像素流,假设分辨率是640x480的灰度图,每行640个像素。
  • 用两个行缓存RAM,每个深度640,宽度8bit。
  • 第一个行缓存存“当前行的前一行”,第二个行缓存存“当前行的前两行”。
  • 当第N行像素输入时,从行缓存1读出第N-1行同一位置的像素,从行缓存2读出第N-2行同一位置的像素。这样就同时有了三行数据。

严格来说需要三个缓存。看数据流:当前行像素进来后,写入行缓存1;与此同时从行缓存1读出的第N-1行像素,写入行缓存2;然后从行缓存2读出第N-2行像素。这样一级一级“流水”下来,任何时刻三个行的数据都是对齐的。

再用三个8bit深的移位寄存器,分别缓存三行数据,每个移位寄存器包含当前列以及前两列的像素。经过两拍延迟后,三个移位寄存器的输出拼起来,就是一个完整的3x3窗口。这个模块叫“行缓存+窗口生成模块”,是整个图像处理工程的地基,所有后面的算法都建立在这个窗口之上。

很多新手在这个地方会犯一个错误:行缓存FIFO的读使能没跟上,导致三行数据错位。这个问题在后面的排错章节我专门讲。

2.4 图像通路整体时序设计

有了行缓存和窗口生成模块,整个图像通路就可以搭建了。以720P(1280x720@60fps)为例,像素时钟大约74.25MHz。每个像素时钟进来一个8bit灰度值,经过大约6到8个时钟周期的流水线延迟后输出一个处理结果。这个延迟是固定的,对我们来说只需要保证行场同步信号同步延迟相同的周期,图像就不会错位。

这是一个极简的同步设计思路:任何组合逻辑如果导致像素数据延迟了N拍,那行同步和场同步信号也必须延迟N拍。最简单的做法是用移位寄存器打拍,把hsync和vsync也延迟同样的拍数。虽然资源稍浪费,但对入门工程来说逻辑清晰、不容易出错。

更进一步的方案是使用AXI-Stream接口作为图像数据通路标准,在数据包中添加user信号作为行场标识。这样模块间接口统一,也方便后续往VDMA、AXI总线上迁移。不过我建议第一阶段先不用这套,因为牵扯的东西比较多,容易把人绕晕。先把核心通路跑通了,再逐步切换成标准接口,这是最平滑的学习路径。

3. 实操过程与核心环节实现

3.1 开发环境与工程目录结构

我用的是Xilinx Vivado 2019.1,虽然版本不算新,但稳定性和对ZYNQ7020系列的支持完全够用。如果你是新手,建议直接用Vivado,不要去折腾ISE了。Vivado自带仿真工具XSim,虽然比不了ModelSim专业,但对这个工程来说足够。

工程目录我习惯这样划分:

prj/ ├── rtl/ # 所有RTL源码 │ ├── top.v │ ├── rgb2gray.v │ ├── line_buffer.v │ ├── window_3x3.v │ ├── median_filter.v │ ├── binary_threshold.v │ ├── morph_dilate.v │ ├── morph_erode.v │ └── hdmi_top.v ├── sim/ # Testbench与仿真脚本 ├── ip/ # Xilinx IP核(FIFO、PLL等) ├── constr/ # 引脚约束和时序约束 └── script/ # Tcl脚本(可选)

这个结构最大的好处是把不同来源的文件分得清清楚楚,综合报错的时候能快速定位问题文件。对新手来说,一个好习惯是每写完一个模块就单独跑一次仿真,不要等整个工程写完再一起调,否则出问题根本定位不到是哪一行。

3.2 行缓存模块的Verilog实现

行缓存模块是整个图像处理流水线的地基。我用Xilinx的FIFO IP实现行缓存,也可以直接用BRAM原语或者分布式RAM手动搭,但IP核最省事并且时序有保障。

这里给出核心逻辑框架:

module line_buffer_two_rows #( parameter DATA_WIDTH = 8, parameter LINE_WIDTH = 640 )( input wire clk, input wire rst_n, input wire data_in_valid, input wire [DATA_WIDTH-1:0] data_in, output wire [DATA_WIDTH-1:0] row0_data, // 当前行数据 output wire [DATA_WIDTH-1:0] row1_data, // 上一行数据 output wire [DATA_WIDTH-1:0] row2_data // 上两行数据 );

实现思路是两级FIFO串联:

  • 第一级FIFO缓存第N行数据,读出时写入第二级FIFO并同时输出。
  • 第二级FIFO缓存第N-1行数据,读出时输出给row2。
  • row0直接来自输入数据。

这里要注意的是行与行切换时的“复位”操作:FIFO需要在一个行结束到下一行开始之间清空或者重置一次,确保每一行都是从行首开始读取。实际工程中我使用行有效信号(相当于hsync)的下降沿来产生一个短脉冲,把FIFO的读指针清零。

有一个更好的做法:用固定深度的普通RAM加读写地址自行管理,好处是可以在读取的同时写入新数据,节省一个周期。但如果用FIFO IP,只要控制好读写使能,也能达到同样的效果。我给初学者的建议是用FIFO IP,原因是你不用关心BRAM的地址竞争问题,调试成本低很多。

3.3 3x3窗口生成模块实现

行缓存模块输出三行数据后,窗口生成模块负责把每一行的三个连续像素对齐:

module window_3x3 #( parameter DATA_WIDTH = 8 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] row0_data, input wire [DATA_WIDTH-1:0] row1_data, input wire [DATA_WIDTH-1:0] row2_data, output reg [DATA_WIDTH-1:0] window [0:2][0:2] );

其内部就是三组移位寄存器:

reg [DATA_WIDTH-1:0] row0_shift [0:2]; reg [DATA_WIDTH-1:0] row1_shift [0:2]; reg [DATA_WIDTH-1:0] row2_shift [0:2]; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin row0_shift[0] <= 0; row0_shift[1] <= 0; row0_shift[2] <= 0; row1_shift[0] <= 0; row1_shift[1] <= 0; row1_shift[2] <= 0; row2_shift[0] <= 0; row2_shift[1] <= 0; row2_shift[2] <= 0; end else begin row0_shift[0] <= row0_data; row0_shift[1] <= row0_shift[0]; row0_shift[2] <= row0_shift[1]; // row1、row2同理 end end

然后窗口数据的映射为:

assign window[0][0] = row0_shift[2]; // 左上角 assign window[0][1] = row0_shift[1]; // 正上方 assign window[0][2] = row0_shift[0]; // 右上角 assign window[1][0] = row1_shift[2]; assign window[1][1] = row1_shift[1]; // 当前像素 assign window[1][2] = row1_shift[0]; assign window[2][0] = row2_shift[2]; assign window[2][1] = row2_shift[1]; assign window[2][2] = row2_shift[0];

很多参考代码会把这个窗口映射放在输出端做连线组合逻辑,我习惯放到一个独立的always块里做寄存器输出,这样可以避免组合逻辑出现在窗口输出的关键路径上,对改善时序有帮助。

3.4 中值滤波模块实现(含排序网络代码)

中值滤波模块接收3x3窗口,输出中值结果。我按之前提到的“列排序+行比较”方案实现。首先要写一个通用的3输入排序模块:

module sort3 #( parameter DATA_WIDTH = 8 )( input wire [DATA_WIDTH-1:0] a, input wire [DATA_WIDTH-1:0] b, input wire [DATA_WIDTH-1:0] c, output wire [DATA_WIDTH-1:0] min_out, output wire [DATA_WIDTH-1:0] mid_out, output wire [DATA_WIDTH-1:0] max_out ); wire [DATA_WIDTH-1:0] ab_min = (a < b) ? a : b; wire [DATA_WIDTH-1:0] ab_max = (a < b) ? b : a; wire [DATA_WIDTH-1:0] bc_min = (b < c) ? b : c; wire [DATA_WIDTH-1:0] bc_max = (b < c) ? c : b; assign min_out = (ab_min < bc_min) ? ab_min : bc_min; assign max_out = (ab_max > bc_max) ? ab_max : bc_max; assign mid_out = (ab_max < bc_min) ? ab_max : (ab_min > bc_max) ? ab_min : (ab_min + bc_min + bc_max - ab_max); // 需要修正 endmodule

等一下,上面这个mid_out的写法有问题。3输入排序求中值,更稳妥的写法是:

assign min_out = (a <= b && a <= c) ? a : (b <= a && b <= c) ? b : c; assign max_out = (a >= b && a >= c) ? a : (b >= a && b >= c) ? b : c; assign mid_out = (a >= b && a <= c) ? a : (a >= c && a <= b) ? a : (b >= a && b <= c) ? b : (b >= c && b <= a) ? b : c;

这段综合后逻辑会稍微复杂一点,更好的是用局部排序网络,大家可以参考Lee's Median Filter的经典实现,先用两级比较器取出三个排序对,然后再两两比较出极值和中值。我上面展示的sort3能工作但综合优化一般,实际项目我建议直接用两个比较器两两比较的组合逻辑,或者直接用“if-else链”让综合工具去优化。这里就不展开写最优版本了,因为综合工具对简单的if-else链优化效果非常好,手写优化反而可能画蛇添足。

列排序模块例化三份sort3,分别对三列排序:

sort3 u_sort_col0 (.a(window[0][0]), .b(window[1][0]), .c(window[2][0]), .min_out(min0), .mid_out(mid0), .max_out(max0)); sort3 u_sort_col1 (.a(window[0][1]), .b(window[1][1]), .c(window[2][1]), .min_out(min1), .mid_out(mid1), .max_out(max1)); sort3 u_sort_col2 (.a(window[0][2]), .b(window[1][2]), .c(window[2][2]), .min_out(min2), .mid_out(mid2), .max_out(max2));

再对min0/min1/min2、mid0/mid1/mid2、max0/max1/max2分别做3输入排序,然后取max_min、mid_mid、min_max做最后一个3输入排序的mid_out。这就是最终的中值结果。

这里提醒一下延迟问题:第一级列排序是组合逻辑,第二级行排序也是组合逻辑,第三级是组合逻辑。如果像素时钟在100MHz以下,三级组合逻辑一帧内完成通常没有问题。但到了148.5MHz(1080P@60fps)甚至更高,就必须在每级之间插入寄存器,形成流水线结构。插入寄存器时,hsync和vsync的延迟也要对应增加。这是FPGA图像处理里最经典的时序处理基本功。

我把这个工程在74.25MHz像素时钟下,不加流水线寄存器直接跑,时序报告显示WNS为负。加了三级流水线寄存器之后,WNS变成正数,整套系统稳定工作。这个对比非常典型,建议大家在仿真和上板时都验证一下。

3.5 膨胀腐蚀模块实现

膨胀腐蚀模块比中值滤波简单多了,核心就是9输入求最大值或者最小值。对于3x3窗口w[0][0]到w[2][2],求最大值可以用下面的方式:

wire [7:0] max_00 = (window[0][0] > window[0][1]) ? window[0][0] : window[0][1]; wire [7:0] max_02 = (window[0][2] > window[1][0]) ? window[0][2] : window[1][0]; // ... 以此类推构建两级比较树

但更优雅的方式是写一个参数化的max_min模块,用generate语句生成比较树。下面是个简化方案:

module morph_filter #( parameter DATA_WIDTH = 8, parameter IS_DILATE = 1 // 1代表膨胀,0代表腐蚀 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] window [0:2][0:2], output reg [DATA_WIDTH-1:0] result ); wire [DATA_WIDTH-1:0] temp [0:5]; genvar i; generate for (i = 0; i < 6; i = i + 1) begin : gen_cmp if (IS_DILATE) begin assign temp[i] = (window[i/3][i%3] > window[i/3][(i%3)+1]) ? window[i/3][i%3] : window[i/3][(i%3)+1]; end else begin assign temp[i] = (window[i/3][i%3] < window[i/3][(i%3)+1]) ? window[i/3][i%3] : window[i/3][(i%3)+1]; end end endgenerate // 第二级比较树继续... endmodule

嗯,上面这个generate循环的写法其实有个问题,因为我试图用i/3和i%3同时索引两维数组的不同元素,但C语言的整数运算在Verilog generate里是可综合的,只是这样的索引方式会导致窗口元素被重复比较。实际简洁的写法是直接把9个输入拼成一个3输入比较器级联的树状结构,手工写清晰又简洁,我这里贴一个可以直接用的比较树核心代码:

// 第一级:三个比较器并行 wire [7:0] m0 = window[0][0] > window[0][1] ? window[0][0] : window[0][1]; // 行内最大 wire [7:0] m1 = window[0][2] > window[1][0] ? window[0][2] : window[1][0]; wire [7:0] m2 = window[1][1] > window[1][2] ? window[1][1] : window[1][2]; wire [7:0] m3 = window[2][0] > window[2][1] ? window[2][0] : window[2][1]; wire [7:0] m4 = window[2][2]; // 可以直接参与比较 // 第二级 wire [7:0] n0 = m0 > m1 ? m0 : m1; wire [7:0] n1 = m2 > m3 ? m2 : m3; wire [7:0] n2 = m4; // 余数 // 第三级 wire [7:0] p0 = n0 > n1 ? n0 : n1; wire [7:0] p1 = p0 > n2 ? p0 : n2; assign result = p1;

这是个简单的比较树,不是严格意义上的9输入最大比较树,但思路类似。大家可以根据自己窗口元素的具体排列去构建比较树。我这里只是展示基本原理,实际用的时候可以借助综合工具进行优化,写成容易阅读的if-else链让综合工具自己生成比较树,效果也不错。

膨胀腐蚀在工程上和二值化模块做一个前后级联:灰度图 → 二值化 → 先腐蚀后膨胀(开运算),这样可以去掉二值图中的孤立噪点。这个开运算的效果在摄像头采集的画面中非常明显,特别适合用来演示形态学处理的实际作用。

3.6 工程测试与上板验证步骤

测试验证我有两套手段:仿真验证和上板验证。仿真阶段,我通常先用Python或者MATLAB生成一幅加了椒盐噪声的测试图像,转成文本格式的像素数据,Testbench里用$readmemh读进来作为输入。处理完的数据再用$fwrite导出回文本,最后在Python里重新拼成图像,对比输出效果。

这样做最大的好处是你可以定量对比:PSNR提升多少、噪声点有没有被消除、边缘有没有被模糊。而且比直接用仿真波形肉眼看要精确得多。

我这里分享一个Testbench的关键代码片段,模拟输入一行数据并送入模块:

initial begin // 读取图像数据 $readmemh("noise_image.txt", image_mem); // 等待复位释放 #100; rst_n = 1; // 模拟逐行输入 for (i = 0; i < 480; i = i + 1) begin for (j = 0; j < 640; j = j + 1) begin // 中间的打拍逻辑 pixel_data = image_mem[i*640 + j]; @(posedge clk); end end end

上板验证阶段,我建议用ILA(集成逻辑分析仪)抓内部信号。比如抓行缓存FIFO的读写指针、窗口模块三行输出是否对齐、中值滤波的结果是否符合预期。ILA用起来和示波器比较像,设置好触发条件、抓取深度就能观察内部信号。对图像处理来说,最常用的触发条件是hsync下降沿,抓取一整行数据,然后观察窗口输出是否正常。

上板调试还有一个经验:先用静态图像测试。把一张测试图存进BRAM作为图像源,这样输入完全可控,算法出问题容易定位。摄像头输入带了噪声和传感器特性,不利于定位问题。等静态图像测试通过后,再接摄像头实时数据,整个调试过程会顺畅很多。

4. 常见问题与排查技巧实录

4.1 图像错位、颜色异常怎么排查

这是FPGA图像处理最经典的问题,现象是图像看起来像被斜着切了一刀,或者画面整体偏移了若干列/若干行。绝大多数情况都是行缓存和窗口模块的时序没对齐。

我把排查思路整理成一张速查表:

现象可能原因排查方法
图像整体向右偏移若干像素窗口移位寄存器深度和期望不匹配检查窗口模块的输出延迟,确认是三拍还是四拍
图像整体向下偏移一行行缓存FIFO少算了一行检查行缓存FIFO深度和行结束标志是否对齐
画面撕裂、半幅错位行同步信号和像素数据没有同时延迟确认hsync/vsync是否和数据路径打了相同拍数
颜色异常,偏紫或偏绿灰度化或RGB转换的位宽没有对齐检查位宽和通道顺序是否正确
图像有规则竖条纹行缓存FIFO读写指针竞争检查FIFO的almost empty/almost full标志

最有效的排查手段还是那句老话:仿真相对于上板来说永远更快。在仿真里直接把三行窗口信号拖出来看,是不是每一行的数据都严格错开一个像素,一目了然。

4.2 资源不够和时序收敛问题

ZYNQ7020的资源,对于640x480@60fps的灰度图处理来说绰绰有余,全套工程综合下来LUT消耗不到5000,FF不到8000,BRAM也就用掉10个左右。但如果上到1080P彩色图,行缓存深度变大,BRAM消耗会明显上涨,这时候就要注意行缓存的实现策略。

关键优化点是:行缓存如果直接用FIFO IP,会增加一些控制逻辑。用裸BRAM加地址管理的方式,逻辑更精简但是需要自己处理地址竞争。对于入门项目,我建议先用FIFO IP,把功能跑通再说优化。

时序收敛是另一个高频问题。时序违例的典型场景是组合逻辑路径太长,比如中值滤波的三级排序网络全部是组合逻辑,在300MHz时钟下必然违例。解决办法是插入流水线寄存器,把排序网络拆成三级,每级之间用寄存器缓存中间结果。这个流水线改造我在前面已经提到过,具体实施时要注意hsync/vsync延迟对齐。

4.3 边缘像素怎么处理

3x3窗口有个先天问题:图像最边缘的一圈像素没有完整的3x3邻域。比如第一行像素的上方没有数据,第一列像素的左边没有数据。

通常有三种处理策略:

第一种是忽略边缘,直接输出原始像素值,或者标记为无效。简单粗暴,对视觉效果影响小,很多工业方案都这么做。

第二种是复制边缘,把边缘像素的值复制到缺失的位置。做法是在行缓存读不出数据时,用最近的有效像素值填充。

第三种是补零。对灰度图来说补零会引入黑边,视觉效果不好,一般不太推荐。

我实际工程中用的是第一种策略,在生成窗口有效信号时,当行号小于2(前两行)或者列号小于2(前两列)时,把输出置为原始像素值或置为无效。配合后续的显示模块做相应的屏蔽处理,显示效果比较干净。

实现窗口有效信号的时候要注意,行有效和列有效要分别判断。行有效通过hsync计数得到当前行号,列有效通过像素有效信号计数得到当前列号,只有当行号和列号都至少大于等于2时,窗口才是完整有效的。

4.4 调试工具和工作流建议

调试FPGA图像处理,我最推荐的组合是:仿真(ModelSim/XSim)+ ILA(集成逻辑分析仪)+ Python/MATLAB脚本。

仿真用于逻辑验证和算法正确性检查;ILA用于上板后的实时信号观察;Python/MATLAB用于生成测试图像和处理结果的离线路标。三者配合,基本能覆盖所有调试场景。

一个很实用的小技巧:在Testbench里加入自动比对功能,把FPGA输出的结果和MATLAB/Python算法的黄金结果做逐像素比对,一旦出现不一致就打印出像素坐标和期望值/实际值。这样能精确定位是第多少行、第多少列开始出错,大大提高排错效率。

5. 结尾还想说的几句实在话

做这个项目给我的最大体会是,FPGA图像处理入门并不需要一开始就追求复杂的算法和高速接口,反而是这三个经典的邻域运算——中值滤波、膨胀、腐蚀,能把FPGA图像处理的精髓全部串起来:像素流的概念、行缓存的架构、窗口生成的方法、流水线时序的处理、同步信号的对齐。这些基本功一旦掌握,后面的Sobel边缘检测、高斯滤波、双边滤波、甚至ISP里的去马赛克、自动白平衡,学起来都会快很多。

个人经验,如果你真的想把这个方向做扎实,我强烈建议把代码和MATLAB/Python的算法模型放在一起对比学习。每一段RTL代码都对应算法里的哪一步,延迟是几拍,为什么这么设计,都搞清楚之后,你的FPGA图像处理才算真正入了门。后续还可以往这个工程里加更多有意思的东西,比如接上一个简单的卷积加速器,或者把三路算法通过AXI总线挂到PS端由ARM动态切换,甚至用VDMA把图像搬运到DDR做帧缓存,这些扩展都基于这篇文章打下的基础。

如果你也在做类似的项目,卡在某个环节过不去,欢迎在评论区交流,我尽量把能分享的经验都分享出来。

返回列表