资讯动态

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

发布时间:2026/10/3 8:08:04 来源:尧图企业网站定制
做FPGA图像处理好几年了每年都会被问“怎么入门”。说实话图像处理在FPGA里是一个特别好的练手方向它不像高速接口那样依赖板卡和示波器也不像复杂算法那样一上来就要啃数学。它需要的核心能力就两样懂一点图像算法的基本原理能把它翻译成“像素流 行缓存 流水线”的硬件思维。这篇文章就基于我在ZYNQ7020上做的一个完整工程——图像中值滤波加膨胀腐蚀把从算法到RTL实现再到上板调试的整套思路原原本本讲一遍。这个项目适合两类人一类是学过Verilog语法但还没做过完整图像工程的FPGA新手另一类是在MATLAB或者OpenCV里写过图像算法、想看看硬件上怎么落地的人。看完之后你至少能掌握三件事中值滤波和膨胀腐蚀在硬件上怎么实现、3x3窗口和行缓存怎么设计、以及图像在FPGA里到底是怎么“流”起来的。整个过程用到的器件就是一块ZYNQ7020开发板属于Xilinx Zynq-7000系列PS端是双核ARM Cortex-A9PL端是Artix-7架构的可编程逻辑正好适合软硬协同的玩法。1. 项目整体设计与思路拆解1.1 为什么选ZYNQ7020做图像处理入门先聊一下平台。ZYNQ7020这个芯片在FPGA图像处理入门圈子里几乎成了“标配”原因很现实它便宜、资料多、而且架构对新手非常友好。ZYNQ的特别之处在于它是异构SoC芯片内部同时集成了PSProcessing System双核ARM Cortex-A9和PLProgrammable Logic等价于一片Artix-7 FPGA。这样一个芯片能干两件事ARM上可以跑Linux、跑OpenCV、跑网络协议栈FPGA逻辑里可以跑实时图像处理算法。对图像处理来说这个组合价值很高——你完全可以把视频采集驱动、显示控制放在PS端用软件解决把像素级的算法中值滤波、膨胀腐蚀、边缘检测这类放到PL端用硬件流水线处理。对比一下其他方案就更清楚。如果用纯FPGA比如 Spartan-6或者Artix-7那视频采集、DDR读写、图像缩放全部要用Verilog写对新手来说工作量直接翻倍。用单片机或者ARM做图像处理虽然软件好写但遇到720P60fps这种实时处理需求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:i1, j-1:j1)这种取邻域操作非常自然。但FPGA不存整幅图像——至少入门阶段不这么干。在FPGA里图像是按照像素时钟一个一个流进来的同一时刻你只能看到当前像素和它之前的几个像素。所以你得接受一个事实你不能“跳到”图像的任意位置只能用行缓存和窗口移位寄存器把当前需要的邻域数据组织起来。第二个转变是从“循环逻辑”到“流水线逻辑”。软件里写一个双重for循环遍历所有像素这很自然。但在FPGA里没有“循环”这个概念你要做的是把算法展开成并行流水线每个像素进入模块后经过固定周期的延迟在出口处输出对应的处理结果。中值滤波的排序不是“对9个像素排序一次”而是“每一拍都对9个像素重新排序一次”因为你面对的是一条永不停歇的数据流。这种数据流思维需要适应但一旦掌握了会非常顺手。第三个转变是从“调参验证”到“仿真打表”。软件里改一个参数然后跑一遍立刻能看到结果。FPGA上板调试成本太高正确的做法是先在Testbench里用激励数据验证算法逻辑用波形比对输入输出延迟是否对齐然后再上板。这个转变越早完成调试效率越高。这三个思维转换在后面的实现章节会反复出现我尽量在每个代码细节上都标注对应的硬件思维方便对照理解。2. 算法原理与FPGA硬件实现思路2.1 中值滤波排序网络的硬件实现方案中值滤波的原理一句话就能说清楚取当前像素3x3邻域内9个灰度值的中位数替代当前像素值。但“取中位数”在硬件上有讲究不能直接调用软件里的sort函数得考虑资源开销和时序延迟。最简单的思路是做一个9输入的全排序网络排完之后取第5个值。但9个数的全排序网络在硬件上需要大量比较器和多路选择器资源可观且时序路径长。实际工程中用得更多的是“列排序行比较”优化方案网上的经典案例是采用5个排序模块的55比较器方法通常叫“列排序法”步骤是对3x3窗口的三列分别做3输入排序得到每列的最小值、中值、最大值。取三列的最小值中的最小值记为min_of_min。取三列的最大值中的最大值记为max_of_max。取三列中值中的中值记为mid_of_mid。最后对这三个值再排序中值就是刚才的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 图像通路整体时序设计有了行缓存和窗口生成模块整个图像通路就可以搭建了。以720P1280x72060fps为例像素时钟大约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;这段综合后逻辑会稍微复杂一点更好的是用局部排序网络大家可以参考Lees 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.5MHz1080P60fps甚至更高就必须在每级之间插入寄存器形成流水线结构。插入寄存器时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的资源对于640x48060fps的灰度图处理来说绰绰有余全套工程综合下来LUT消耗不到5000FF不到8000BRAM也就用掉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做帧缓存这些扩展都基于这篇文章打下的基础。如果你也在做类似的项目卡在某个环节过不去欢迎在评论区交流我尽量把能分享的经验都分享出来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑