FPGA图像处理实战:基于ZYBO Z7的HDMI Sobel边缘检测实现

发布时间:2026/9/11 9:36:13
FPGA图像处理实战:基于ZYBO Z7的HDMI Sobel边缘检测实现 简介针对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 并口到 ADV7513ZYBO Z7 板载的 HDMI TX 端接的是 ADI 公司的 ADV7513 发送芯片。FPGA 不需要产生 TMDS 差分信号只需要给 ADV7513 提供并行 RGB 数据、时钟和同步信号芯片内部会完成编码和串行化。这个设计对做实验非常友好但也带来一个前提你必须先通过 I2C 对 ADV7513 做初始化否则屏幕上只会出现花屏或完全无信号。初始化序列可以直接从 ADI 官方驱动里抄重点配置寄存器组是输出色彩空间RGB 4:4:4、像素时钟频率和是否使能 HDMI 模式。通常用 24-bit RGBDE 信号作为数据有效标志HSYNC/VSYNC 信号可以交给 ADV7513 内部做同步。常见的误区的是一上来就盯着 ADV7513 的寄存器手册把大量时间花在 I2C 初始化上。我的建议是先把自己写的并行视频信号挂在 FPGA 逻辑里用板上测试图比如彩条点亮屏幕确认基础通路没问题再引入图片和 Sobel。这样后续调试 Sobel 时画面异常能快速分辨是图像数据的问题还是算法的问题。2.2 像素时钟与有效像素的预算HDMI 720p 60Hz 的像素时钟是 74.25 MHz一帧包含 1280x720 有效像素但加上消隐区后是 1650x750。FPGA 里要关心的不是整个帧的大小而是 DEData Enable信号覆盖的那段有效区域。DE 为高的每个周期对应屏幕上一个像素Sobel 模块必须在这段时间内完成一次完整的 3x3 卷积。ZYBO Z7 做 720p 输出时AXI4-Stream 数据通路至少要能每时钟接收一个像素否则 FPGA 内部 FIFO 会被读空画面上会出现横向随机条纹。我这里给出 720p 的一组标准时序参数来源是 CEA-861 规范很多博客会把 h_pulse 和 h_bp 弄反抄的时候注意对照 Datasheet参数值说明h_active1280水平有效像素数h_front110水平前沿消隐h_sync40水平同步脉冲h_back220水平后沿消隐v_active720垂直有效行数v_front5垂直前沿消隐v_sync5垂直同步脉冲v_back20垂直后沿消隐pixel_clock74.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 变成 64bitDMA 配置也随之变化。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(1b0), .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 {3d0, p00} {2d0, p10, 1b0} {3d0, p20}; gx_sum_pos {3d0, p02} {2d0, p12, 1b0} {3d0, p22}; gy_sum_neg {3d0, p00} {2d0, p01, 1b0} {3d0, p02}; gy_sum_pos {3d0, p20} {2d0, p21, 1b0} {3d0, 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 像素的最大值是 255Gx 绝对值为正负两侧都各自不超过 4 倍的 255约 1020abs 相加后不超过 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 里加什么 IPStream 位宽怎么选在 Vivado 中创建一个 Block Design 后PL 侧需要例化一个 AXI4-Stream Data FIFO 和自定义的 Sobel IP加上 Xilinx 的 Video Timing Controller 和 VDMA。我一般把自定义 Sobel IP 的接口封装成 AXI4-Stream Slave数据位宽 8bit只处理灰度图。这样 VDMA 的 MM2S 通道配置成 8bit、一帧 1280x720Sobel 输出同样 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 和 STRIDESobel 行缓存的深度也要相应地改为较小的数值避免把无意义的空白行也送去卷积。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 度转角。任何和这个预期不符的图案都说明行缓存或者寄存器阵列的列映射需要回头检查。本文还有配套的精品资源点击获取