简介:针对ZYBO Z7开发板打造的一份FPGA图像处理实验资源,定位为HDMI接口下图片Sobel滤波显示的完整工程,适合正在学习Zynq SoC和数字图像处理的开发者。压缩包共收录470个文件,整体大小28MB,包含Vivado工程与约束文件、Verilog和VHDL等多种源码、仿真激励与自动化脚本,以及位流和存储器初始化文件,能够支撑从RTL设计、综合实现到板级验证的完整流程。实验覆盖图像采集、数据预处理、Sobel边缘检测、图像后处理与HDMI信号输出等关键环节,文件包内含大量综合日志、仿真报告和工程记录,便于对照排查时序或功能问题,也适合在此基础上扩展其他实时图像算法。目前已有148人学习浏览,对希望掌握FPGA并行计算和视频接口应用的工程师与高校学生具有较高的参考价值。
1. FPGA-HDMI-图片Sobel滤波显示实验(ZYBO Z7)在做一件什么事
很多 FPGA 初学者把 Sobel 当成“写一个卷积核”,但放到 ZYBO Z7 上跑通“图片 → DDR → HDMI 显示器”这条链路后,你会发现真正的复杂度不在那 9 个乘法,而在行缓存的深度、像素时钟的对齐和 ADV7513 的输出时序。ZYBO Z7 的特别之处在于板载了 HDMI 发送芯片,PL 侧只需要送并行 RGB 和同步信号,不需要自己写 TMDS 串行器,这让整个实验的难点收敛到了“图像数据通路怎么组织,Sobel 流水线怎么插进去”这两个问题上。
这个实验适合两类人:一类是想在 Zynq 上验证图像处理 IP 的工程师,另一类是准备秋招或竞赛、需要把“FPGA 图像处理”写在简历上的学生。它能让你在一晚上之内把 BRAM/FIFO 行缓存、AXI-Lite 寄存器、像素级流水线这几个高频考点全部串起来,而且每个环节都能用 ILA 直接抓到波形,不像纯仿真那样心里没底。
2. 从图片到 HDMI:硬件链路与时序预算
2.1 ZYBO Z7 的 HDMI 输出链路:FPGA 并口到 ADV7513
ZYBO Z7 板载的 HDMI TX 端接的是 ADI 公司的 ADV7513 发送芯片。FPGA 不需要产生 TMDS 差分信号,只需要给 ADV7513 提供并行 RGB 数据、时钟和同步信号,芯片内部会完成编码和串行化。这个设计对做实验非常友好,但也带来一个前提:你必须先通过 I2C 对 ADV7513 做初始化,否则屏幕上只会出现花屏或完全无信号。初始化序列可以直接从 ADI 官方驱动里抄,重点配置寄存器组是输出色彩空间(RGB 4:4:4)、像素时钟频率和是否使能 HDMI 模式。通常用 24-bit RGB,DE 信号作为数据有效标志,HSYNC/VSYNC 信号可以交给 ADV7513 内部做同步。
常见的误区的是一上来就盯着 ADV7513 的寄存器手册,把大量时间花在 I2C 初始化上。我的建议是先把自己写的并行视频信号挂在 FPGA 逻辑里,用板上测试图(比如彩条)点亮屏幕,确认基础通路没问题,再引入图片和 Sobel。这样后续调试 Sobel 时,画面异常能快速分辨是图像数据的问题还是算法的问题。
2.2 像素时钟与有效像素的预算
HDMI 720p 60Hz 的像素时钟是 74.25 MHz,一帧包含 1280x720 有效像素,但加上消隐区后是 1650x750。FPGA 里要关心的不是整个帧的大小,而是 DE(Data Enable)信号覆盖的那段有效区域。DE 为高的每个周期对应屏幕上一个像素,Sobel 模块必须在这段时间内完成一次完整的 3x3 卷积。
ZYBO Z7 做 720p 输出时,AXI4-Stream 数据通路至少要能每时钟接收一个像素,否则 FPGA 内部 FIFO 会被读空,画面上会出现横向随机条纹。我这里给出 720p 的一组标准时序参数,来源是 CEA-861 规范,很多博客会把 h_pulse 和 h_bp 弄反,抄的时候注意对照 Datasheet:
| 参数 | 值 | 说明 |
|---|---|---|
| h_active | 1280 | 水平有效像素数 |
| h_front | 110 | 水平前沿消隐 |
| h_sync | 40 | 水平同步脉冲 |
| h_back | 220 | 水平后沿消隐 |
| v_active | 720 | 垂直有效行数 |
| v_front | 5 | 垂直前沿消隐 |
| v_sync | 5 | 垂直同步脉冲 |
| v_back | 20 | 垂直后沿消隐 |
| pixel_clock | 74.25 MHz | 像素时钟 |
Sobel 模块在 DE 有效期间工作,消隐期要停止移位寄存器的写入,否则会把边沿噪声当成图像内容。行计数器 x_cnt 和场计数器 y_cnt 必须从 DE 拉高的第一个像素开始计数,这样 Sobel 的窗口位置和屏幕像素才是严格对齐的。
2.3 用 create_clock / set_output_delay 约束 HDMI 时钟域
如果只在仿真里看波形,不写约束也能跑通,但上板后可能遇到隔几分钟闪一次画面的“玄学”问题。ADV7513 的并行输入接口有建立保持时间要求,像素时钟由 FPGA 输出,数据也必须和它对齐。我一般会先对像素时钟做约束:
create_clock -name hdmi_pix_clk -period 13.468 [get_ports hdmi_pixel_clk] set_output_delay -clock hdmi_pix_clk -min -1.0 [get_ports {hdmi_rgb[*] hdmi_de hdmi_hsync hdmi_vsync}] set_output_delay -clock hdmi_pix_clk -max 1.0 [get_ports {hdmi_rgb[*] hdmi_de hdmi_hsync hdmi_vsync}]set_output_delay 的 min/max 对应 ADV7513 数据手册里的 hold time 和 setup time,不同的板卡走线长度不同,不能照抄数值。约束做完后在 Vivado 里看时序报告,如果像素时钟路径的 setup slack 为负数,优先检查输出寄存器有没有直接用 ODDR 原语,而不是在组合逻辑上直接输出。总线的位宽选择决定了数据通路宽度:灰度图用 8bit 每像素,RGB888 用 24bit 每像素,后者会让 AXI-Stream 总线位宽从 32bit 变成 64bit,DMA 配置也随之变化。
3. Sobel 算子的 FPGA 实现:行缓存、3x3 窗口与梯度计算
3.1 图片数据从 BRAM/DDR 到像素流的两种读法
图片在 FPGA 里要么放在 BRAM 里,要么放在 DDR 里。BRAM 方案适合做最小系统验证,用 Python 把图片转成 COE 或 MIF 文件,Vivado 综合时直接初始化进去,但图片不能太大,超过几万像素就占用过多 Block RAM。DDR 方案更接近实际项目,通过 VDMA 从 DDR 中读取图片数据,以 AXI4-Stream 形式送给图像处理模块,处理完再进 HDMI 控制器。我推荐在 ZYBO Z7 上直接用后者,因为芯片本身有 DDR3,而且 Zynq 的 PS 侧可以跑裸机程序,把图片数据从 SD 卡搬到 DDR 中,非常方便。
如果你只是想把算法先跑通,避免调试 DMA 的复杂度,也可以把测试图缩小到 320x240,用 BRAM 存储,整个实验能省一个下午的时间。但这篇文章后续内容都以“VDMA + DDR 读图”为默认方案,因为它才是实际工程里会遇到的形态。
3.2 3x3 窗口生成器的 Verilog 实现
Sobel 卷积核是 3x3 的,意味着处理当前像素时需要知道它上面一行、下面一行的相邻像素,因此数据通路里必须有行缓存。FPGA 里最常见的做法是用两个同步 FIFO 做两行延迟,第三个行的数据直接从输入端来,然后在每个时钟周期内把三行数据都打进寄存器阵列。下面是轻量级的实现框架:
module sobel_3x3_window #( parameter WIDTH = 1280, parameter DATA_WIDTH = 8 )( input wire clk, input wire de, input wire [DATA_WIDTH-1:0] pix_in, output wire [DATA_WIDTH-1:0] p00, p01, p02, output wire [DATA_WIDTH-1:0] p10, p11, p12, output wire [DATA_WIDTH-1:0] p20, p21, p22 ); // 两个行延迟 FIFO,深度稍大于一行有效像素 wire [DATA_WIDTH-1:0] row0_out, row1_out; wire rd_row0 = de && (row_cnt >= 1); wire rd_row1 = de && (row_cnt >= 2); xpm_fifo_sync #( .FIFO_MEMORY_TYPE("block"), .FIFO_WRITE_DEPTH(WIDTH + 16), .WRITE_DATA_WIDTH(DATA_WIDTH), .READ_DATA_WIDTH(DATA_WIDTH), .READ_MODE("fwft") ) u_row0 ( .rst(1'b0), .wr_clk(clk), .wr_en(de), .din(pix_in), .rd_clk(clk), .rd_en(rd_row0), .dout(row0_out) ); // u_row1 结构与 u_row0 完全相同,只是端口连到 row1_out // 三行数据打入窗口寄存器,pix_in 是当前行,row1_out 是上一行,row0_out 是上上行 always @(posedge clk) begin if (de) begin {p22, p21, p20} <= {p21, p20, row0_out}; {p12, p11, p10} <= {p11, p10, row1_out}; {p02, p01, p00} <= {p01, p00, pix_in}; end end endmoduleFIFO 设置为 First-Word Fall-Through 模式后,读使能拉高的同一拍就能拿到数据,寄存器阵列的时序容易对齐。如果使用标准 FIFO 模式,读数据会有 1 拍延迟,p00 到 p22 的赋值必须配合打拍来修正。行缓存的深度要覆盖“一行完整有效像素 + 消隐期”,这里写了 WIDTH + 16,这是因为 DE 在行消隐期间为低,FIFO 不会被写入额外数据,但如果 VDMA 送来的流有气泡,深度太小会把上一行的末尾数据挤掉。边界像素的处理在这个代码里是直接补零,也就是图片最左和最右列会出现梯度响应,这部分在最后一章再展开。
3.3 Gx / Gy 计算与幅值近似
拿到 3x3 窗口后,Sobel 算子的两个卷积核分别检测水平和垂直边缘:
Gx = (p02 + 2*p12 + p22) - (p00 + 2*p10 + p20) Gy = (p20 + 2*p21 + p22) - (p00 + 2*p01 + p02)FPGA 里做平方和开方时要消耗 DSP,但实验场景完全可以做近似。我用的是 abs(Gx) + abs(Gy),如果追求边缘更细,可以改成 max(abs(Gx), abs(Gy))。下面给出一段可直接用的梯度计算逻辑,实现了无乘法器的版本,乘 2 直接用左移替代:
module sobel_gradient #( parameter DATA_WIDTH = 8 )( input wire [DATA_WIDTH-1:0] p00, p01, p02, input wire [DATA_WIDTH-1:0] p10, p11, p12, input wire [DATA_WIDTH-1:0] p20, p21, p22, output wire [10:0] grad_out ); reg [10:0] gx_sum_pos, gx_sum_neg; reg [10:0] gy_sum_pos, gy_sum_neg; reg [10:0] gx_abs, gy_abs; // 左侧权重和右侧权重分开累加,便于求绝对值 always @(*) begin gx_sum_neg = {3'd0, p00} + {2'd0, p10, 1'b0} + {3'd0, p20}; gx_sum_pos = {3'd0, p02} + {2'd0, p12, 1'b0} + {3'd0, p22}; gy_sum_neg = {3'd0, p00} + {2'd0, p01, 1'b0} + {3'd0, p02}; gy_sum_pos = {3'd0, p20} + {2'd0, p21, 1'b0} + {3'd0, p22}; end always @(*) begin gx_abs = (gx_sum_pos >= gx_sum_neg) ? (gx_sum_pos - gx_sum_neg) : (gx_sum_neg - gx_sum_pos); gy_abs = (gy_sum_pos >= gy_sum_neg) ? (gy_sum_pos - gy_sum_neg) : (gy_sum_neg - gy_sum_pos); end assign grad_out = gx_abs + gy_abs; endmodule梯度结果最大值的估算:8bit 像素的最大值是 255,Gx 绝对值为正负两侧都各自不超过 4 倍的 255,约 1020,abs 相加后不超过 2040,因此 11bit 的 grad_out 足够。把 result 位宽设计成 11bit 是必要的,如果直接使用 8bit 会截断高幅值边缘。后续阈值比较就是拿 grad_out 和外部寄存器里的阈值比较,大于阈值输出白色 255,小于则输出黑色 0,这一步相当直观。
3.4 阈值比较放数据链路末端的原因
阈值的比较一定要放在流水线最后一级,而不是在每个窗口寄存器的数据输入端做,原因有两个。第一,Sobel 的梯度需要同时看到 9 个像素,如果在像素输入侧做判断,等于把输入图像二值化,边缘检测就退化成了固定阈值分割,丢失了梯度方向信息。第二,全流水结构的每一级输出都需要保持和 DE 严格同步,提前做判断会让后续行缓存的写使能条件变得复杂。阈值寄存器值由 AXI-Lite 异步写入,但比较只在像素同步时钟域内使用,因此需要先在寄存器模块里做一拍同步,防止亚稳态进入算法逻辑。实际工程里,我会把阈值默认设为 60,观察效果后再通过 PS 端修改,这个设计在第四章会再次提到。
4. 工程集成:ZYBO Z7 上的 Zynq PS 与 PL 协同
4.1 Block Design 里加什么 IP,Stream 位宽怎么选
在 Vivado 中创建一个 Block Design 后,PL 侧需要例化一个 AXI4-Stream Data FIFO 和自定义的 Sobel IP,加上 Xilinx 的 Video Timing Controller 和 VDMA。我一般把自定义 Sobel IP 的接口封装成 AXI4-Stream Slave,数据位宽 8bit,只处理灰度图。这样 VDMA 的 MM2S 通道配置成 8bit、一帧 1280x720,Sobel 输出同样 8bit AXI4-Stream,再送到 HDMI 输出模块。
VDMA 和 Sobel IP 之间强烈建议插入一个 32bit 位宽的数据 FIFO,用于吸收 DDR 读带宽抖动。VDMA 的突发读一次可能吐出多个像素,而 Sobel 模块每个时钟只消费一个像素,FIFO 起到弹性缓冲作用。大部分实际项目里这个 FIFO 深度设为 1024 即可,如果抓 ILA 发现 read data 侧存在气泡,才需要加大深度。
4.2 把图片转成 DDR 可读数据的小脚本
图片进入 DDR 的方式有两种:裸机程序通过 UART 或 SD 卡读取,或者在 Linux 环境下用 Python 脚本写入。这里给出一个最通用的 Python 方案,它把灰度图转成 RAW 二进制文件,直接放在 SD 卡里,然后由 PS 端用 f_open/f_read 读入 VDMA 的输入地址,最后用 Xil_DCacheFlush 保证 DDR 数据一致性:
from PIL import Image # 打开图片并转为灰度,缩放到 1280x720 方便直接输出 img = Image.open("lena.png").convert("L").resize((1280, 720)) with open("lena_gray_1280x720.raw", "wb") as f: f.write(img.tobytes()) print("done")RAW 文件没有头部信息,SD 卡读取后直接命中 VDMA 的起始地址,VDMA 只需要知道基地址、宽度和高度三个参数即可。注意 Zynq 的 DDR 地址空间通常从 0x01000000 开始可用,VDMA 参数配置时要避开 PS 自身运行内存区域,否则会踩掉程序堆栈。如果图片不缩放到满屏,而是保持在左上角区域,可以按照实验需要改 VDMA 的 HSIZE 和 STRIDE,Sobel 行缓存的深度也要相应地改为较小的数值,避免把无意义的空白行也送去卷积。
4.3 FPGA 图像处理边界时序与 ILA 调试
上板后最常见的两个问题都出在边界时序上。第一个是行缓存深度不够导致的图像右边缘出现一条竖向亮线,这根线其实是上一行末尾的数据被当作当前行开头参与卷积。检查方法是把 ILA 的第一个采样点放在 FIFO 的读使能和 x_cnt 上,看 FIFO 读出的第一个像素是否是图像最左列,如果不是,说明读使能提前了若干个周期,需要把 rd_en 条件改为 de 且 x_cnt 大于某个值。第二个问题更隐蔽,VDMA 的 AXI-Stream 数据位 8bit 但 tkeep 信号无效时,高字节会是垃圾值,直接导致画面出现随机噪点。对于 8bit 位宽,tkeep 必须强制拉高,否则需要在 FIFO 内部把未对齐字节过滤掉。只要这两个问题解决了,Sobel 滤波结果基本不会出错。
5. 边缘增强显示、动态阈值与验证技巧
Sobel 滤波结果直接替换原图输出,看起来往往是一片黑底白线,适合观察算法效果,但缺少原图的参照。工程上更常见的是把边缘叠加到原图上,得到一个轮廓增强的画面。叠加公式为:
output = (gray * (256 - strength) + sobel * strength) >> 8strength 是 0 到 255 的增强系数,由 AXI-Lite 寄存器控制,默认取 128 时原图和边缘各占一半。这样在 HDMI 显示器上能看到原始图像的基础上,边缘部分被明显加亮,视觉上更直观,也更适合向前来参观的人演示。叠加模块的代码只有两行乘加,但因为 grayscale 和 sobel 结果都是 8bit,乘积需要用 16bit 中间寄存器暂存再移位,避免组合逻辑直接连到 24bit RGB 输出导致 Fmax 下降。
动态阈值的调试方式值得单独提一下。用固定阈值 60 时,如果测试图是低照度场景,许多边缘幅度只有 40 左右,会大量丢失;用阈值 80 时,正常光照图片的噪声又会全部穿过滤波器产生类似雪花的效果。所以我在设计里给 AXI-Lite 寄存器一个 11bit 字段,PS 端运行一个简单的循环,每隔 500ms 递减一次阈值,同时通过串口打印当前值,让演示者实时观察到阈值变化对边缘密度的影响。实际项目里这叫“在线调参”,比一遍遍重新综合要高效得多。
最后一个技巧是“镜像素”边界填充。默认补零会让图像最外圈像素的梯度值被人为拉高,尤其是斜线边缘在图片四角会出现明显的 45 度伪影。我在窗口生成器里做了如下处理:当 x_cnt 等于 0 时,把 p00、p10、p20 三个左列寄存器的输入复制自 x_cnt 等于 1 的像素,而不是直接给 0;右边界同理。这个操作只需要在赋值逻辑上加一个判断:
always @(posedge clk) begin if (de && (x_cnt == 0)) begin {p22, p21, p20} <= {p21, p20, row0_out}; {p12, p11, p10} <= {p11, p10, row1_out}; {p02, p01, p00} <= {p01, p00, pix_in}; // 实际 pix_in 就是左侧第一个像素 end end真实验证时,我会用一条斜率为 1 的水平渐变带作为测试图,如果 Gx 方向和 Gy 方向输出正确,渐变带的水平段会产生水平边缘、垂直段产生垂直边缘,两者交界处能看到清晰的 90 度转角。任何和这个预期不符的图案,都说明行缓存或者寄存器阵列的列映射需要回头检查。
本文还有配套的精品资源,点击获取