FPGA图像处理实战:如何用FIFO实现3x3卷积窗口(附Verilog代码)

📅 发布时间:2026/7/11 9:24:36 👁️ 浏览次数:
FPGA图像处理实战:如何用FIFO实现3x3卷积窗口(附Verilog代码)
FPGA图像处理实战从FIFO流水线到3x3卷积窗口的工程化实现在FPGA上做图像处理很多工程师的第一反应是“资源够不够”或者“时序能不能跑上去”。但真正开始动手时你会发现最让人头疼的往往不是算法本身而是如何高效、稳定地把图像数据“喂”给算法模块。尤其是像卷积这种需要局部窗口的操作如果数据流组织得不好整个系统的吞吐量和延迟都会出问题。我刚开始接触FPGA图像处理时也在这个环节踩过不少坑后来发现用FIFO构建一个流水线式的行缓存结构是解决这个问题的经典且可靠的方法。今天我就结合自己的项目经验详细拆解一下如何用Verilog实现一个灵活、可配置的卷积窗口生成模块重点会放在FIFO的精准控制、边界处理的技巧以及如何让代码具备良好的可扩展性。这篇文章主要面向已经有一定Verilog和FPGA开发基础正准备将图像算法落地到硬件上的朋友。我会假设你已经了解基本的图像数据流比如基于DE、HS、VS的信号和FIFO的基本操作。我们的目标不是复现一个教科书式的模块而是打造一个在真实项目中能“扛事”的工程化代码。1. 理解问题为什么卷积窗口需要行缓存在软件中处理一张图片的卷积比如3x3的Sobel算子我们通常用两层循环遍历像素然后根据坐标去访问相邻的像素值。但在FPGA的流式处理中情况完全不同。图像数据通常按光栅扫描顺序一个时钟周期来一个像素或几个像素。当处理到第(m, n)个像素时我们瞬间需要的是以其为中心的3x3窗口内的9个像素(m-1, n-1),(m-1, n),(m-1, n1),(m, n-1),(m, n),(m, n1),(m1, n-1),(m1, n),(m1, n1)。注意这里坐标m代表行n代表列且假设左上角为原点(0,0)。问题来了当第m行的第n个像素(m, n)到来时它上一行(m-1, n)的像素已经在几个时钟周期前流过去了下一行(m1, n)的像素还远未到来。为了能同时获取这9个像素我们必须把之前行的数据暂时存起来。解决方案的核心思想缓存整行的像素。对于一个K x K的卷积窗口我们需要缓存K-1行数据。当新的一行数据流进来时我们同时从缓存中读出对应的历史行数据将它们与新到来的像素对齐从而“拼”出一个完整的窗口。这个缓存机制用FPGA内部的Block RAM或Distributed RAM实现FIFOFirst In, First Out是最自然的选择。数据按行写入FIFO按行读出形成一个行延迟线。2. 架构设计构建FIFO菊花链最直观的实现方式是使用多个FIFO首尾相连形成一个“菊花链”Daisy Chain。每个FIFO缓存一行图像数据。数据流动的方向是新像素从链首的FIFO输入依次向后传递。2.1 数据流与FIFO角色假设我们要实现一个3x3窗口K3需要缓存2行历史数据因此需要2个FIFOFIFO1, FIFO2。数据流时序如下初始状态FIFO1和FIFO2为空。第一行图像输入像素逐个进入FIFO1。此时FIFO1未满一行不读出。FIFO2空闲。第二行图像输入新像素第二行开始进入FIFO1。关键动作当FIFO1中已存满一行数据即第一行时从第二个像素开始每写入一个新像素到FIFO1就同时从FIFO1读出一个旧像素第一行的并将其写入FIFO2。这样FIFO2中开始缓存第一行数据。第三行及后续图像输入新像素第三行进入FIFO1。FIFO1读出第二行像素送入FIFO2。FIFO2读出第一行像素丢弃或送出用于窗口拼接。此时我们同时拥有当前输入的新像素第三行、从FIFO1读出的像素第二行、从FIFO2读出的像素第一行。将这三行数据在列方向上进行适当的寄存器延迟对齐就能得到完整的3x3窗口。这个过程就像一个流水线新的数据不断涌入旧的数据被依次向后推。下表清晰地展示了在稳定状态下每个FIFO中存储的内容随时间的变化时钟周期 (处理第N行时)FIFO1 内容 (最新)FIFO2 内容 (次新)可用于窗口拼接的行N-2 行期间正在缓存 N-2 行空无N-1 行期间正在缓存 N-1 行已存满 N-2 行N-2 行 (从FIFO2读出)N 行期间 (稳定)正在缓存 N 行已存满 N-1 行N-1 行 (从FIFO1读出), N-2 行 (从FIFO2读出)2.2 精准计数模式流水线同步的生命线要让这个菊花链正常工作核心在于控制每个FIFO的读使能信号。读操作不能随意进行必须满足一个条件FIFO中已经缓存了完整的一行图像数据。这就是为什么在实例化FIFO IP核时必须选择精准计数模式Precise Data Count。这个模式会实时输出FIFO中当前存储的数据量rd_data_count。我们可以通过判断rd_data_count IMG_WIDTH来确认是否已存满一行。// 以Xilinx FIFO Generator为例关键配置 FIFO_GENERATOR_0 your_fifo_inst ( .wr_clk(clk), .rd_clk(clk), .din(wr_data), .wr_en(wr_en), .rd_en(rd_en), .dout(rd_data), .full(full), .empty(empty), .rd_data_count(rd_data_count) // 启用读端口数据计数 );有了精准计数控制逻辑就变得清晰写使能wr_en跟随输入像素的有效信号如data_valid。读使能rd_en当rd_data_count IMG_WIDTH且新的写操作发生时即wr_en 1才产生读使能。这保证了“存满一行后边写边读”的流水节拍。// 控制pop_en信号的简化逻辑 always (posedge clk) begin if (rst) begin pop_en 1b0; end else if (!frame_active) begin // 不在有效帧内 pop_en 1b0; end else if (rd_data_count (IMG_WIDTH PADDING)) begin // 已缓存一行含边界扩充 pop_en 1b1; end end // 读使能是pop_en和写使能的“与” assign rd_en pop_en wr_en;3. 关键实现细节边界扩充与参数化设计3.1 边界像素的处理策略卷积在图像边界会遇到问题因为窗口的一部分会落在图像外部。常见的处理方法是边界扩充。在我们的流式结构中扩充需要在数据流入的源头就完成。对于K x K的卷积核需要在图像的上下左右各扩充 (K-1)/2 行/列对于K3就是上下各1行左右各1列。扩充的像素值通常为0黑边或边缘复制。在我们的架构里上下扩充可以通过控制FIFO初始读出的时机来实现例如前K-1行不产生有效的窗口输出。左右扩充则需要在每行数据的前后插入虚拟像素。一个实用的技巧是在行缓存之前就对输入数据流进行列方向的延迟以模拟左侧填充在输出窗口时对右侧进行填充。这样内部FIFO缓存的已经是“加宽”后的行逻辑更统一。// 示例为3x3窗口实现左侧填充一列0 reg [7:0] pixel_delay [1:0]; // 两级延迟寄存器 reg [7:0] padded_pixel; always (posedge clk) begin if (data_valid) begin pixel_delay[0] (col_cnt 0) ? 8h00 : pixel_in; // 行首像素用0填充 pixel_delay[1] pixel_delay[0]; end end // 送入FIFO链的是经过填充处理后的像素 assign data_to_fifo pixel_delay[1];3.2 高度参数化的Verilog模块设计一个好的窗口生成模块应该能适应不同的图像分辨率、不同的像素位宽以及最重要的——不同的窗口尺寸。这要求我们使用parameter和generate语句来构建代码。module conv_window_buffer #( parameter KSZ 3, // 卷积核尺寸如3,5,7... parameter IMG_WIDTH 640, // 原始图像宽度不含填充 parameter IMG_HEIGHT 480, // 原始图像高度 parameter DATA_WIDTH 8 // 像素数据位宽 )( // 时钟、复位、输入流信号 input wire clk, input wire rst_n, input wire i_data_valid, input wire [DATA_WIDTH-1:0] i_pixel_data, // 输出流信号及窗口矩阵 output reg o_window_valid, output wire [(DATA_WIDTH*KSZ*KSZ)-1:0] o_window_matrix );KSZ (Kernel Size)这是核心参数。它决定了需要实例化多少个行缓存FIFOKSZ-1个也决定了输出矩阵o_window_matrix的位宽DATA_WIDTH * KSZ * KSZ。使用generate构建FIFO链根据KSZ的值动态生成对应数量的FIFO实例和它们之间的连接逻辑。// 定义连接FIFO所需的网络 wire [DATA_WIDTH-1:0] fifo_din [KSZ-2:0]; wire [DATA_WIDTH-1:0] fifo_dout [KSZ-2:0]; wire fifo_wr_en[KSZ-2:0]; wire fifo_rd_en[KSZ-2:0]; wire [11:0] fifo_rd_cnt[KSZ-2:0]; // 假设计数位宽12 genvar i; generate for (i0; iKSZ-1; ii1) begin: FIFO_CHAIN if (i 0) begin // 第一个FIFO数据来自预处理后的输入 assign fifo_din[i] padded_input_data; assign fifo_wr_en[i] padded_data_valid; end else begin // 后续FIFO数据来自前一个FIFO的输出 assign fifo_din[i] fifo_dout[i-1]; assign fifo_wr_en[i] fifo_rd_en[i-1]; end // 实例化FIFO IP核或自定义FIFO模块 async_fifo #( .DATA_WIDTH(DATA_WIDTH), .DEPTH(IMG_WIDTH KSZ - 1) // 深度需包含填充后的宽度 ) u_line_fifo ( .wr_clk(clk), .wr_en(fifo_wr_en[i]), .din(fifo_din[i]), .rd_clk(clk), .rd_en(fifo_rd_en[i]), .dout(fifo_dout[i]), .rd_data_count(fifo_rd_cnt[i]) ); // 读控制逻辑当存满一行且正在写入新数据时读出 always (posedge clk or negedge rst_n) begin if (!rst_n) begin pop_en[i] 1b0; end else if (fifo_rd_cnt[i] (IMG_WIDTH KSZ - 1)) begin pop_en[i] 1b1; end end assign fifo_rd_en[i] pop_en[i] fifo_wr_en[i]; end endgenerate4. 窗口矩阵的组装与输出时序从FIFO链中我们得到了KSZ-1行历史数据fifo_dout[0]到fifo_dout[KSZ-2]加上当前输入行current_line_data总共KSZ行数据。每一行数据都是一个按顺序流动的像素流。要得到KSZ x KSZ的窗口我们还需要在列方向上进行对齐。4.1 列方向的对齐移位寄存器组对于每一行数据流我们使用一组KSZ-1级的移位寄存器就可以得到该行上相邻的KSZ个像素。// 为每一行数据构建列移位寄存器 reg [DATA_WIDTH-1:0] line_regs [KSZ-1:0][KSZ-1:0]; // [行索引][列延迟索引] genvar row, col; generate for (row 0; row KSZ; row row 1) begin: ROW_SHIFT // 确定该行数据的来源当前行 or FIFO输出行 wire [DATA_WIDTH-1:0] row_input; if (row KSZ-1) begin assign row_input current_line_data; // 最新一行 end else begin assign row_input fifo_dout[(KSZ-2)-row]; // 历史行注意顺序 end // 列方向移位 always (posedge clk) begin if (window_assembly_en) begin // 窗口组装使能信号 line_regs[row][0] row_input; for (col 1; col KSZ; col col 1) begin line_regs[row][col] line_regs[row][col-1]; end end end end endgenerate经过这个结构在任意时刻line_regs[row][0]是该行最新的像素line_regs[row][1]是上一个时钟周期的像素以此类推。因此{line_regs[2][2], line_regs[2][1], line_regs[2][0]}就构成了窗口最下面一行的三个像素。4.2 输出打包与有效信号生成最后一步是将这个二维的窗口数据打包成一个一维的大位宽向量输出并生成与之同步的有效信号。// 将二维寄存器数组打包成一维输出向量 integer r, c; always (posedge clk) begin if (window_assembly_en) begin for (r 0; r KSZ; r r 1) begin for (c 0; c KSZ; c c 1) begin // 注意输出顺序通常按行优先或列优先需与后续卷积模块约定一致 // 例如o_window_matrix[ (r*KSZ c)*DATA_WIDTH : DATA_WIDTH] o_window_matrix[ ((KSZ-1-r)*KSZ (KSZ-1-c)) * DATA_WIDTH : DATA_WIDTH] line_regs[r][c]; end end end end // 窗口有效信号当所有行的移位寄存器都被有效数据填满时产生 reg [KSZ-1:0] row_valid_shift; always (posedge clk or negedge rst_n) begin if (!rst_n) begin row_valid_shift 0; o_window_valid 1b0; end else begin // 用一个移位寄存器跟踪数据流填充状态 row_valid_shift {row_valid_shift[KSZ-2:0], data_valid_delayed}; // 当移位寄存器全为1时表示首个完整窗口就绪 o_window_valid row_valid_shift; end end4.3 仿真验证与调试技巧编写完代码后必须进行充分的仿真。创建一个简单的测试平台输入已知模式的图像数据如渐变条纹、棋盘格观察输出窗口矩阵是否正确。测试数据生成可以用$readmemh从文件读取图像数据或者用Verilog代码实时生成有规律的像素值。关键检查点边界处理检查输出窗口的第一个和最后几个有效数据其边界填充值是否符合预期全0或边缘复制。窗口滑动连续输出几个窗口检查相邻窗口之间是否正确地滑动了一个像素。例如窗口W(m,n)和W(m, n1)应该有6个像素是重叠的。时序对齐确保o_window_valid信号与o_window_matrix的数据严格对齐并且没有漏窗或多窗。// 简单的测试激励示例 initial begin // 初始化 clk 0; rst_n 0; i_data_valid 0; i_pixel_data 0; #100 rst_n 1; // 模拟一帧 5x5 的图像输入用于观察3x3窗口 for (int row 0; row 5; row) begin for (int col 0; col 5; col) begin (posedge clk); i_data_valid 1; i_pixel_data row * 16 col; // 生成有规律的数据 end // 行消隐 (posedge clk); i_data_valid 0; repeat(10) (posedge clk); // 模拟行间隔 end $finish; end在仿真波形中你应该能看到在经历初始的填充延迟后o_window_valid开始周期性拉高同时o_window_matrix上出现正确的3x3像素矩阵。第一个有效窗口的中心应该对应原始图像中第(1,1)个像素假设从0开始索引且进行了1像素的边界填充。5. 性能优化与工程实践考量一个基础的窗口生成模块工作后我们可以从以下几个角度思考优化让它更适合真实的项目。5.1 资源与速度的权衡FIFO类型选择Xilinx的FIFO IP核有基于Block RAM和基于Distributed RAM查找表的选项。Block RAM容量大但数量有限Distributed RAM更灵活但深度和位宽较大时会消耗大量LUT。对于高清图像如1920x1080一行缓存需要约2000个单元通常选择Block RAM FIFO。对于小分辨率或行缓冲深度小的中间过程可以考虑Distributed RAM。位宽优化如果像素位宽不是8的整数倍如10位、12位图像尽量将FIFO和数据通路的位宽对齐到下一个字节边界如16位以匹配Block RAM的端口位宽避免资源浪费。时序优化o_window_matrix信号位宽很宽3x3x872位5x5x8200位。这可能会成为时序路径上的关键信号。可以考虑在输出端插入一级寄存器流水线提高系统最高运行频率。5.2 与下游卷积模块的接口窗口生成模块的输出通常直接连接到卷积计算模块。这里有几个设计约定数据顺序明确约定o_window_matrix中像素的排列顺序是行优先还是列优先是从左上角开始还是从中心开始。这必须与卷积核的系数顺序匹配。流控信号除了o_window_valid最好也实现一个o_window_ready或接收下游的i_ready信号形成简单的握手协议如AXI-Stream。这在下游处理较慢时可以反压上游防止数据丢失。窗口坐标信息对于某些高级算法可能还需要知道当前窗口在图像中的坐标。可以伴随窗口数据输出当前的行计数器和列计数器。5.3 扩展性从3x3到NxN我们设计的参数化模块已经具备了扩展能力。要支持5x5、7x7甚至更大的窗口只需要在实例化时修改KSZ参数。但需要注意逻辑资源更大的K意味着更多的FIFO实例和更宽的移位寄存器组消耗的BRAM和LUT/FF都会线性增长。延迟从像素输入到第一个完整窗口输出所需的时钟周期数即流水线深度会增加。延迟 (KSZ-1) * (IMG_WIDTH KSZ -1) (KSZ-1)。在需要低延迟的应用中要评估此影响。边界填充更大的K需要更多的边界填充像素。填充逻辑需要根据KSZ参数自适应调整。我在一个视频分析项目中就用同一个模块分别实例化了3x3的Sobel边缘检测和5x5的高斯模糊。只需要在顶层用不同的参数例化两次然后将图像流复制一份分别送入这两个模块非常方便。这种设计让我在算法迭代时能快速更换卷积核尺寸进行效果对比而无需重写底层硬件结构。最后记得把整个模块放在版本控制系统里并写好详细的注释和接口文档。当半年后你需要回头修改或者团队其他成员要调用这个模块时清晰的文档和经过验证的代码能省下无数沟通和调试的时间。硬件设计尤其是FPGA里的流水线就像搭积木每一块都必须坚固可靠接口明确才能堆砌出复杂而稳定的系统。这个窗口生成模块就是图像处理流水线中最基础、也最关键的那几块积木之一。