如果你手头有一块ZYNQ7020开发板想入门FPGA图像处理大概率听到的第一个建议不是“先学Verilog”而是“先别急着上摄像头”。原因很简单图像处理里的中值滤波、膨胀、腐蚀这类算法本质都是局部邻域操作在MATLAB和OpenCV里几行代码就完事但搬到FPGA上你要面对的是像素流、行缓存、时序对齐这些在软件里完全不存在的问题。这篇文章我想从自己做ZYNQ7020图像处理项目时的实际路线出发把“图像中值膨胀腐蚀滤波”这条线完整走一遍从为什么选FPGA、图像数据怎么进来到中值滤波和形态学滤波的硬件实现再到仿真上板会遇到什么坑。适合刚入手ZYNQ7020、想用视频图像做算法验证的同学也适合想从流水灯和串口跳出来的Verilog初学者。看完你不会立刻成为ISP专家但至少能搭出一个能跑、能看效果、能继续扩展的最小图像处理链路。1. ZYNQ7020做图像处理为什么大家说它是“入门首选”1.1 像素级算法FPGA天然吃这碗饭先搞清楚一个问题为什么图像处理这件事FPGA能比ARM和GPU更有意思原因在于很多图像算法属于“像素级并行”操作。中值滤波、膨胀、腐蚀、Sobel边缘检测这些算法的共同点是输出图像的每个像素只取决于输入图像中对应位置周围的一个小邻域比如3x3窗口。没有跨图像的大范围依赖也没有复杂的循环跳转天然适合用硬件流水线逐像素并发处理。CPU做这件事本质是“取指令-解指令-执行”的串行循环即使有SIMD指令也受限于内存带宽和频率天花板。GPU做这件事并行度极高但调度开销和功耗都摆在那里。FPGA的方式则完全不同像素时钟一来数据从左边进计算结果从右边出一拍一个像素整条链路可以做到确定性延迟。以720p60为例像素时钟大约74.25MHz对FPGA来说这是个非常悠闲的频率甚至不需要太复杂的时序约束组合逻辑深度稍微控制一下就能收束。中值、膨胀、腐蚀这类算法还不需要乘法器只用比较器和选择器逻辑资源开销不大。这也是我推荐用它们入门FPGA图像处理的原因把“并行数据流 窗口 流水线”这三个最核心的硬件思维练熟后面再去做卷积、直方图、缩放这些更重的算子就是换汤不换药。1.2 PLPS双架构入门时不至于被调试劝退ZYNQ7020具体型号通常是XC7Z020是Xilinx ZYNQ-7000家族里最经典的一颗芯片PL端逻辑规模相当于Artix-7级别有大约5.3万LUT、10.6万FF、220个DSP和4.9Mb BRAM。这个量级跑720p甚至1080p的灰度图像处理绰绰有余。但ZYNQ比纯FPGA更吸引人的地方是它还有一个双核ARM Cortex-A9处理器也就是PS端。我见过不少人一拿到ZYNQ7020就纠结到底用纯PL做还是让PS也参与入门阶段我的建议是先把PS当“遥控器”用。比如通过AXI-Lite接口给PL端的处理模块配置阈值、切换滤波旁路、读取处理统计结果或者干脆只用PS跑一个串口打印方便看状态。等后面需要DDR缓存视频帧、通过VDMA搬运数据、跑Linux做OpenCV对照时PS的优势才会真正体现。如果完全没有PS参与纯PL也能完成图像处理只是调试手段比较痛苦全靠ILA抓波形。所以我给新手的推荐工程结构是PL部分完成图像输入先用测试图案生成器不急着接摄像头、灰度转换、行缓存、中值或形态学滤波、VGA/HDMI输出PS部分负责初始化、控制寄存器、串口打印。这样一个工程做下来你既练了Verilog又能体会到SoC架构里软硬件协同的乐趣。2. 图像进FPGA之后发生了什么行缓存与3x3窗口的搭建2.1 没有二维数组只有一条像素流很多从软件转过来的朋友第一次写FPGA图像处理都会懵明明是一张二维图片怎么在Verilog里看不到数组因为FPGA接收到的图像数据是一条按顺序流动的像素流。摄像头或测试图案生成器会在像素时钟pclk的驱动下逐行输出像素同时用hsync、vsync和de这类同步信号标出当前像素是不是有效。也就是说你没有办法像写C语言那样直接写img[row][col]你能做的只有“顺着像素流往下走”用硬件把曾经流过的像素缓存下来。要做3x3窗口意味着同一时刻你需要拿到三行数据当前行、上一行、上上一行并且三个像素在列方向上也对齐。这就要用到行缓存。行缓存的本质是延迟单元把当前像素延迟一整行的时间就得到上一行同一列的像素再延迟一整行就得到上上行的像素。这个概念想通了后面的窗口计算就顺了。2.2 用移位寄存器搭行缓存3行数据怎么凑齐实现一行延迟最常用的方式是使用BRAM构成的FIFO深度等于图像宽度W位宽等于像素位宽。以720p灰度图为例一行有1280个像素每个像素8bit那么一行数据就是1280x8 10240bit约10Kb。两个行缓存大约20KbXC7Z020有4.9Mb BRAM这点开销可以忽略。工程上我建议直接用Xilinx提供的FIFO IP核或者用“Shift Register RAM”IP核。前者带独立读写时钟和Almost Full/Empty信号后者适合做固定延迟。如果你想把原理吃透也可以自己用BRAM写一个环形FIFO但入门阶段完全没必要重复造轮子。关键点是写使能的控制。像素有效信号de拉高时数据写入FIFOFIFO输出端就相当于“延迟了一行的像素”。两个FIFO级联第一个的输出接到第二个的输入这样当前输入是第r行第一个FIFO输出是第r-1行第二个FIFO输出是第r-2行。三个信号同时存在时3x3窗口的“行方向”就凑齐了。// 三行对齐输出示意 wire [7:0] row0_data; // 上上行经过两级行缓存 wire [7:0] row1_data; // 上一行经过一级行缓存 wire [7:0] row2_data; // 当前行输入 wire line_wr_en de; // 像素有效时写入 // 这里省略FIFO例化核心逻辑是 // fifo0: dinrow2_data, doutrow1_data // fifo1: dinrow1_data, doutrow0_data // 三个输出在同一个像素时钟下对齐2.3 行列计数和边界处理决定了输出有没有黑边有了三行数据还需要确定“当前像素位于第几行第几列”这样才能构造出窗口的列方向。列计数在de拉高时自增到达图像宽度W-1时归零并让行计数自增。边界问题是FPGA图像处理里特别容易被忽略的坑。3x3窗口只有在中心像素位于第1行到H-2行、第1列到W-2列时才有完整的9个邻居。第一行、第一列、最后一行、最后一列都没有完整邻域。处理方式有三种边缘直接复制原像素、置零填充、或者丢弃边缘不做滤波。入门阶段我推荐最简单的方案窗口数据有效信号window_valid拉高后才开始输出滤波结果输出同步信号也同步延迟边缘区域干脆直接输出原像素。这样实现简单视觉上只是最外一圈没有滤波效果不影响主区域观察。比花大力气做镜像填充划算得多。我自己第一次就是没处理边界滤波后的图像四周出现一圈黑边排查半天才发现是窗口valid给早了。3. 中值滤波落地9个像素排序怎么在FPGA里又快又省3.1 中值滤波去椒盐噪声的原理与选型理由中值滤波的原理一句话就能说清把当前像素邻域内的所有像素按大小排序取中间那个值作为输出。比如3x3窗口9个像素排序后第5个值就是中值。它对椒盐噪声特别有效因为椒盐噪声通常表现为孤立的极亮或极暗点排序后被自然挤到两端中值不会受它影响。相比均值滤波中值滤波在去噪的同时能更好地保留边缘细节不会把图像整体模糊掉。从硬件实现角度中值滤波是绝佳的学习素材因为它不涉及乘法器、不涉及浮点、不涉及查找表只需要比较器和多路选择器。你只要把“排序”这件事在硬件里想明白后面做最大值最小值、做膨胀腐蚀甚至做更复杂的顺序统计滤波都是同一个套路。3.2 行列排序法3x3窗口的中值怎么用比较器算出来9个数据求中值最直观的做法是完整排序网络但比较器数量多、级数深。工程上更常用的是“行列排序法”它能用比较少的比较器求出严格中值。算法分三步第一步对每一行的3个像素排序得到每行的最小值min_i、中间值mid_i、最大值max_i。第二步取三行min_i中的最大值记为max_of_mins取三行mid_i中的中值记为mid_of_mids取三行max_i中的最小值记为min_of_maxs。第三步对max_of_mins、mid_of_mids、min_of_maxs这三个值再排序取中值结果就是整个3x3窗口的中值。我第一次看到这个算法时也觉得有点绕但它在硬件上非常漂亮每个sort3模块只需要3个比较器整个中值滤波模块可以规整地分成几级流水每一级逻辑深度都很浅。module sort3 #(parameter W 8) ( input wire [W-1:0] a, b, c, output reg [W-1:0] min_o, mid_o, max_o ); wire [W-1:0] ab_min (a b) ? a : b; wire [W-1:0] ab_max (a b) ? b : a; always (*) begin if (c ab_min) begin min_o c; mid_o ab_min; max_o ab_max; end else if (c ab_max) begin min_o ab_min; mid_o ab_max; max_o c; end else begin min_o ab_min; mid_o c; max_o ab_max; end end endmodule这个sort3模块可以综合行为也很直观。三个输入先比较前两个再把第三个插入到合适位置。要拼出完整中值滤波只需要例化多份sort3把行排序和列排序的结果按上述规则连接起来。为了让时序更干净建议在每级sort3输出后加一组寄存器打拍形成两级流水。3.3 流水线打拍与时序收束很多新手写Verilog做图像处理组合逻辑一路直连最后上板时序违例频率上不去。关于中值滤波的时序有几点经验你可以直接抄整个模块按三级流水设计第一级做行内排序第二级做列统计第三级做最终取中值。每一级之间用寄存器隔开这样组合逻辑深度就只取决于单级比较链不会太长。输出像素pixel_out需要和de、hsync、vsync严格对齐。做法很简单把输入同步信号用同样级数的D触发器打拍延迟级数必须和数据处理级数一致。否则画面会错位出现“图像内容和同步信号对不上”的鬼影。如果你一时懒得算延迟了几拍可以在仿真时把输入de和输出de的波形对齐数一下差几拍然后补上对应级数的寄存器。这是一个非常基础的调试技巧但能省下大量时间。资源方面8bit灰度图的3x3中值滤波整个模块大约也就几百个LUT对ZYNQ7020来说属于“洒洒水”的资源消耗。真正占资源的是行缓存但前面算过BRAM开销也不大。这也是我反复说这个项目适合入门的原因你可以把精力集中在理解数据流和控制逻辑上而不是和资源瓶颈搏斗。4. 膨胀与腐蚀把“取最大/最小”做成硬件流水线4.1 膨胀腐蚀的直观理解与灰度形态学膨胀和腐蚀来自数学形态学最经典的场景是处理二值图。二值图里每个像素不是0就是255膨胀的结果是让白色区域向外扩张一圈腐蚀的结果是让白色区域向内收缩一圈。你可以把膨胀理解成“只要邻域里有一个白点中心就变白”把腐蚀理解成“只有邻域里全是白点中心才保持白”。但膨胀和腐蚀不止能处理二值图。在灰度图上膨胀就是取邻域最大值腐蚀就是取邻域最小值。所以从FPGA实现角度形态学滤波比中值滤波还要简单同样是3x3窗口中值需要排序找中间值膨胀只需要找最大值腐蚀只需要找最小值。窗口逻辑完全复用只是后面的“计算单元”从排序网络换成了极值比较树。形态学最常见的用法是组合先腐蚀后膨胀叫开运算能去掉小的亮斑和噪点先膨胀后腐蚀叫闭运算能填补小的黑色空洞。这些组合在FPGA里就是串两个极值模块中间打拍对齐非常简单。4.2 比较树实现极值比中值滤波更简单求9个数的最大值最直接的方法是把9个数据两两分组逐级比较。比如8个数据先分成4对比较出4个较大值4个再分成2对比较出2个较大值2个再比较出1个最大值。剩下的第9个数据在最后一级参与比较。这样一共只需要8个比较器组合逻辑深度也只有4级左右。// 求9个8bit数据的最大值比较树 wire [7:0] w[0:8]; wire [7:0] t0 (w[0] w[1]) ? w[0] : w[1]; wire [7:0] t1 (w[2] w[3]) ? w[2] : w[3]; wire [7:0] t2 (w[4] w[5]) ? w[4] : w[5]; wire [7:0] t3 (w[6] w[7]) ? w[6] : w[7]; wire [7:0] u0 (t0 t1) ? t0 : t1; // w0~w3的最大值 wire [7:0] u1 (t2 t3) ? t2 : t3; // w4~w7的最大值 wire [7:0] u2 (u0 u1) ? u0 : u1; // w0~w7的最大值 wire [7:0] dilation_out (u2 w[8]) ? u2 : w[8];求最小值就是把所有换成都换成严格说是把比较条件反过来结构一模一样。这种树形结构比用一个for循环逐级累加好得多因为它的并行度更高组合逻辑也更均衡。综合工具大概率也会帮你优化成类似结构但手写树形能让时序更可控。4.3 和阈值二值化配合做出能用的形态学去噪链路实际工程里很少直接对原始灰度图做膨胀腐蚀更常见的做法是先把灰度图二值化再做形态学处理。二值化在FPGA里就是一个比较器bin (gray threshold) ? 8hff : 8h00。阈值可以预先通过PS端寄存器配置这样不用重新综合就能调整效果。入门阶段你可以做一个这样的完整链路摄像头或测试图案输入灰度图 → 二值化 → 3x3窗口 → 腐蚀模块取最小 → 膨胀模块取最大 → 输出。这个链路的视觉效果非常直观图像上的细小噪点经过“先腐蚀后膨胀”之后被清掉大块目标保持不变。把处理前后画面并排对比成就感很强。这里要提醒一点二值化阈值别拍脑袋定。先用MATLAB或Python的OpenCV离线处理几张真实图像看看什么阈值最合适再把这个阈值固化到寄存器配置里。FPGA里调参数虽然也方便但每改一次都要重新综合或写寄存器效率远远不如软件里先摸一遍规律。5. 从仿真到上板ZYNQ7020工程调试的完整链路与踩坑清单5.1 模块划分与推荐工程结构我建议第一次做这个项目工程结构尽量保持扁平不要一上来就搞AXI总线、DDR缓存那一套。下面这个模块划分是我用过之后觉得最适合入门的模块功能备注top顶层例化与时钟复位统一像素时钟域test_pattern_gen生成测试图像可带假椒盐噪声rgb2grayRGB转灰度或直接从灰度测试图开始line_buffer_3x3行缓存与窗口对齐输出9个排列好的像素median_filter中值滤波可由旁路开关切换morph_erode / morph_dilate腐蚀/膨胀可级联成开运算sync_delay同步信号打拍对齐输出de/hsync/vsyncvga_out / hdmi_out显示输出VGA调试最方便模块划分的原则是“一个模块只干一件事”。行缓存模块只负责输出9个对齐像素滤波模块只负责计算同步延迟模块只负责打拍。这样每个模块都能独立仿真出问题时定位也快。测试图案生成器是个容易被低估的好东西。它可以输出一个固定尺寸、带有模拟椒盐噪声的灰度图比如背景是渐变灰阶中间画一个白色方块再随机叠上黑白噪点。有了它你根本不需要一开始就接摄像头整个调试过程完全可控每次复位看到的图像都一样方便对比滤波前后效果。5.2 仿真先于硬件用test pattern和Python比对结果仿真这一步千万别跳过。很多人喜欢代码写完直接上板结果画面不对又没波形参考只能瞎猜。正确做法是先在Vivado Simulator或ModelSim里跑仿真验证逻辑正确性。具体做法是用$readmemh读入一张小尺寸测试图像比如64x64灰度图经过滤波模块后用$writememh把输出像素写出来。然后在Python里用OpenCV或scikit-image对同一张图做medianBlur或erode/dilate逐像素比对两边的结果。比对一致说明你的FPGA算法逻辑是对的不一致先别怀疑FPGA检查边界处理规则和输入数据格式是不是和软件一致。我踩过的例子软件里OpenCV对边缘像素做的是“边界复制”而FPGA这边我用的“直接丢弃边缘”两边差在边框一圈像素导致大量红绿灯报警。所以仿真比对时最好框定中间有效区域再比对或者干脆两边用同一种边界策略。仿真还应该做一件事把输入de和输出de拉出来对齐确认输出同步信号延迟和像素数据延迟一致。这一步能提前暴露大多数“图像错位”问题比上板后抓波形轻松太多了。5.3 上板调试必查的四个坑位上板之后你大概率会遇到下面这几个问题我把症状、原因和排查方法整理成表症状可能原因排查与解决图像出现斜条纹整行错位行缓存FIFO写使能没跟上de丢了一两个像素ILA抓de和FIFO write_en确认每行有效像素数量等于图像宽度画面滚动或者输出有竖条输出de/hsync/vsync没和像素数据对齐检查sync_delay延迟级数是否与滤波流水级数一致四周一圈黑边窗口valid信号给早了边界没有旁路原像素把滤波输出限定在有效窗口范围边缘直接输出原图复位后首帧花屏之后正常同步复位释放时序不一致全模块统一使用异步复位同步释放复位释放后空跑几帧再显示第四个坑特别隐蔽。如果PS端和PL端的复位不是同一个源或者复位释放时像素时钟还没稳定内部状态可能跑飞。我后来在顶层统一例化一个小型复位模块所有子模块用一个reset_gen输出的复位信号并且在复位释放后再拉一个“start”信号隔几帧才开始输出画面问题就再没出现过。5.4 性能分析与下一步扩展方向最后说一下性能。720p60的灰度图像像素时钟约74.25MHz中值滤波三级流水、膨胀腐蚀两级比较树都能轻松跑到这个频率。即使上1080p60像素时钟148.5MHz只要注意比较器级间打拍也问题不大。资源方面中值滤波加膨胀腐蚀模块本身可能只用了几百个LUT和FF主要的BRAM开销来自两级行缓存1080p灰度图大约30.7Kb在XC7Z020上完全可以接受。做完中值、膨胀、腐蚀你对FPGA图像处理的“窗口 流水线 同步对齐”这套方法论已经有手感了。接下来可以按兴趣扩展把3x3窗口参数化成5x5或7x7行缓存深度不变窗口寄存器变多中值滤波改成完整的排序树逻辑资源会增加但BRAM基本不变。做开闭运算的级联在前面腐蚀后面串一个膨胀就能去掉更多噪声。加一个Sobel边缘检测模块它的输入同样是3x3窗口只是计算单元变成乘加可以用DSP资源。试试接真实摄像头把测试图案换成OV5640或者MIPI接口的Sensor数据链路会更复杂但也更有意思。最后可以往VDMA方向走让PS端通过AXI VDMA把滤波结果搬到DDR再用HDMI输出这样一套流程下来你已经能驾驭ZYNQ7020的软硬件协同开发了。最后说一点我自己的体会。做FPGA图像处理最大的障碍往往不是Verilog写不出来而是调试心态。我第一次做中值滤波时仿真结果和OpenCV一模一样一上板就图像斜切折腾一整天才发现是行缓存的写使能多打了一拍。从此我养成了一个习惯仿真里除了比对像素值还会专门拉出de、hsync和vsync的波形观察它们和输出像素的对齐关系。如果你也能把“对齐”这两个字刻在脑子里ZYNQ7020上的中值膨胀腐蚀滤波入门基本就走通一半了。后面无论是继续做形态学、边缘检测还是更复杂的ISP链路这套行缓存加窗口计算加同步打拍的框架都不会变。