☰
FPGA双线性插值图像缩放Verilog实现与工程避坑指南
2026/10/27 19:03:41 网站建设 项目流程

简介:这份工程文件面向FPGA开发与图像处理方向的学习者与工程师,提供基于Verilog硬件描述语言实现的双线性插值(二次线性插值)图像实时缩放方案,可用于替代软件插值在高吞吐、低延迟场景下的性能瓶颈。压缩包共224个文件,约3.22MB,以tdf、cdb、hdb、ddb等Quartus工程数据库文件为主,配合v源码、bsf原理图符号、rpt综合报告、mif存储器初始化文件、vwf仿真波形及sof下载文件,完整覆盖设计、仿真、综合到下载的流程。资源中已包含插值计算、坐标映射、RAM地址控制等模块的Verilog实现与备份文件,读者可据此理解数据路径与控制逻辑的划分方式,掌握流水线设计与资源复用的优化思路,并借助仿真波形验证插值结果。目前已有2365人学习下载,适合具备一定数字逻辑基础、希望深入FPGA图像缩放实践的开发者参考。

1. 为什么一块 FPGA 做图像缩放,绕不开双线性插值

做视频采集和显示的同行大概率遇到过这个场景:摄像头输出 1280×720,但你的 LCD 屏是 1920×1080,或者反过来,传感器给的是 640×480,要铺满 1080P 的 HDMI 输出。最省事的做法是让上位机或者 SoC 里的 GPU 去缩放,但一旦上了产线,延迟、带宽、功耗三座大山压下来,很多人最后还是得回到 FPGA 里做实时缩放。而 FPGA 里做缩放,最经典、最平衡的算法就是双线性插值,也叫二次线性插值。

这份工程文件就是围绕这个需求来的:用 Verilog 在 FPGA 内部实现双线性插值算法,完成图像的实时缩放。它解决的不是"能不能显示"的问题,而是"在像素时钟节拍下,怎么用有限的片上 RAM 和 DSP 资源,把缩放后的每个像素算出来并稳定输出"。适合已经跑通 FPGA 图像直通(比如 OV5640 采集 + HDMI 显示),现在想加一级缩放模块的开发者;也适合正在做 FPGA 图像处理课程设计、创新设计大赛选题的人。下面我按"资源是什么、怎么落地、坑在哪"的顺序拆开讲。

2. 双线性插值的硬件账:从浮点公式到定点流水线

2.1 算法本身的四个权重从哪来

双线性插值的数学表达很简洁。目标图像上的一个点 $(x_d, y_d)$,映射回源图像坐标 $(x_s, y_s)$,通常写成:

$$x_s = x_d \cdot \frac{W_{src}}{W_{dst}}, \quad y_s = y_d \cdot \frac{H_{src}}{H_{dst}}$$

得到的 $x_s, y_s$ 一般不是整数,取整数部分 $x_0, y_0$,小数部分 $u, v$(范围 0 到 1)。然后取源图上 $(x_0,y_0)$、$(x_0+1,y_0)$、$(x_0,y_0+1)$、$(x_0+1,y_0+1)$ 四个点,按下面的权重合成:

$$P = (1-u)(1-v)P_{00} + u(1-v)P_{10} + (1-u)vP_{01} + uvP_{11}$$

在 PC 上用 OpenCV 一行resize就完事,但在 FPGA 里,浮点乘法、除法都是奢侈品。所以硬件实现的第一步,是把这套公式全部转成定点整数运算。常见做法是:缩放比例用定点数表示,比如把 $W_{src}/W_{dst}$ 放大 $2^{16}$ 倍存成整数,小数部分 $u, v$ 用 8 位或 10 位精度。这样权重 $(1-u)$、$u$ 都是 0 到 256 之间的整数,四个乘积项用 DSP 做乘法,最后右移截断。

2.2 为什么工程里通常用"逆向映射"而不是正向映射

正向映射是遍历源图像素,算它落到目标图哪里。问题是缩放时目标图上的某些像素可能没有任何源点落上去,出现空洞;放大时又会有多个源点落到同一个目标点,需要额外仲裁。逆向映射反过来:遍历目标图像素,反算它在源图的位置,每个目标像素都主动去"取"数据,天然没有空洞,也不需要写冲突仲裁。这份工程文件采用的就是逆向映射思路,逐目标像素推进。

代价是:逆向映射要求源图像能被随机访问。也就是说,你不能只缓存一行,得缓存至少两行源数据(当前行和下一行),才能同时拿到 $P_{00},P_{10},P_{01},P_{11}$ 四个点。这就是为什么双线性插值模块里一定会出现行缓存(line buffer)或者行缓冲 FIFO 的原因。

2.3 行缓存的深度怎么算

假设源图宽度最大 1920,像素位宽 16bit(RGB565),那么一行需要 1920×16bit = 30720bit。用 FPGA 的 Block RAM 实现,一块 36Kb 的 BRAM 刚好放得下一行多一点。要同时访问两行,常见做法是用两块 BRAM 做乒乓,或者用两个独立的行缓冲。

这里有个参数必须提前定死:最大支持源图宽度。它直接决定 BRAM 的例化数量和地址位宽。如果工程里写死了 1024,你拿 1280 的源图去跑,行缓存就会溢出,图像右边会出现撕裂或者错位。这是新手最容易翻车的地方之一。

2.4 定点精度的取舍

小数部分 $u, v$ 的位宽决定了插值精度。8 位精度(0~255)在大多数显示场景下肉眼已经看不出台阶,10 位更细腻但乘法器位宽和 BRAM 位宽都要跟着涨。工程里如果用的是 8 位,那么权重计算就是:

// 假设 scale_x_fixed 是 16bit 定点缩放系数 // x_s_fixed = x_d * scale_x_fixed // x_0 = x_s_fixed[31:16] // u = x_s_fixed[15:8] // 取高 8 位作为小数权重 wire [15:0] x_s_fixed = x_d * scale_x_fixed; wire [15:0] x_0 = x_s_fixed[31:16]; wire [7:0] u = x_s_fixed[15:8]; wire [7:0] u_inv = 8'd255 - u;

这段代码的逻辑是:把目标坐标乘以定点缩放系数,得到一个 32 位结果,高 16 位是整数坐标,紧接着的 8 位是小数权重。u_inv就是 $(1-u)$ 的定点表示。参数说明:scale_x_fixed的取值等于(W_src << 16) / W_dst,这个除法在综合时是常数除法,可以交给工具优化,也可以在配置寄存器里动态写入。

提示:如果缩放系数是运行时通过寄存器配置的,除法不能在综合时优化,需要自己写一个定点除法或者用查找表。很多工程为了省事,直接把缩放系数做成参数,上电前改代码重新综合。

3. 工程文件怎么落地:从模块划分到仿真验证

3.1 一份能跑的双线性插值工程通常包含哪些模块

拿到这份工程文件,先别急着上板。打开目录,按功能把文件分成几类,心里有数再动手。典型的模块划分是这样的:

模块作用关键接口
顶层 top例化各子模块,连接时钟复位clk, rst_n, 视频输入输出
采集/输入接口接收源图像像素流vsync, hsync, de, data_in
行缓存 line_buffer缓存两行源数据wr_en, rd_addr, data_out
坐标计算 coord_calc逆向映射,算 x_0,y_0,u,vx_d, y_d, scale, x_0, y_0, u, v
插值核心 interp_core四点多权重乘加p00,p10,p01,p11,u,v, pixel_out
输出时序 out_timing生成目标图 vsync/hsync/depixel_out, vsync_out…

这份工程文件的核心价值,就在于它把坐标计算和插值核心这两块最容易写乱的逻辑,拆成了独立模块,接口清晰。你替换采集接口或者输出接口,核心算法部分不用动。

3.2 行缓存的读写时序怎么对齐

行缓存是整条链路里最容易出时序问题的地方。写侧跟着源图像的像素时钟走,读侧跟着目标图像的像素时钟走。如果输入输出是同一个时钟域,问题简单;如果跨时钟域,就必须做异步 FIFO 或者双口 RAM 加握手。

一个常见的写法是:写地址用源图像的列计数,读地址用坐标计算模块算出来的 $x_0$ 和 $x_0+1$。注意,读地址会比写地址"超前"或"滞后",因为目标像素的推进速度和源像素不一样。工程里一般会用一个状态机控制:当目标坐标对应的源行数据已经写入缓存,才发起读操作。

// 简化的行缓存读逻辑 always @(posedge clk) begin if (rd_en) begin // 同时读出四个点需要的两个地址 rd_addr_a <= x_0; // P00 / P01 所在列 rd_addr_b <= x_0 + 1; // P10 / P11 所在列 end end // 两个 BRAM 分别存当前行和下一行 // data_a0 = line_buf_cur[rd_addr_a] // data_a1 = line_buf_cur[rd_addr_b] // data_b0 = line_buf_nxt[rd_addr_a] // data_b1 = line_buf_nxt[rd_addr_b]

逻辑说明:x_0和x_0+1是两个相邻列地址,分别从当前行缓存和下一行缓存读出,得到四个像素。参数说明:rd_en的使能时机必须保证读地址对应的数据已经写入,否则会读到旧数据或者未初始化数据。实际工程里通常会在行缓存写满一行后拉高一个line_ready标志,读侧等这个标志再启动。

3.3 插值核心的乘加流水线

四个点的加权求和,如果直接写成组合逻辑,路径太长,时序跑不高。工程里一般会插两级流水:第一级算两个水平方向的插值,第二级算垂直方向的插值。

// 第一级:水平方向插值 // top = P00*(1-u) + P10*u // bot = P01*(1-u) + P11*u wire [15:0] top = p00 * u_inv + p10 * u; wire [15:0] bot = p01 * u_inv + p11 * u; // 第二级:垂直方向插值 // out = top*(1-v) + bot*v wire [15:0] out = (top * v_inv + bot * v) >> 16;

逻辑说明:u_inv和v_inv是 $(1-u)$、$(1-v)$ 的定点表示。两次乘加之后右移 16 位,是因为权重是 8 位定点,两个 8 位权重相乘相当于放大了 $2^{16}$ 倍。参数说明:如果像素位宽是 16bit,p00*u_inv的结果是 24bit,两级加完之后位宽会到 32bit 左右,右移截断时要考虑舍入,直接截断会有轻微偏暗,加个进位补偿更稳。

3.4 仿真验证:先喂测试图,再上板

上板之前,强烈建议先用仿真跑一遍。构造一个简单的测试图案,比如 4×4 的灰度渐变,缩放到 8×8,看输出是否符合预期。工程文件里如果有 testbench,直接跑;没有的话,自己补一个也不难。

// 简化的 testbench 激励 initial begin // 初始化 rst_n = 0; #100 rst_n = 1; // 逐像素送入 4x4 源图 for (y = 0; y < 4; y = y + 1) begin for (x = 0; x < 4; x = x + 1) begin data_in = pattern[x][y]; de = 1; #10; end end de = 0; #1000 $finish; end

逻辑说明:这段激励模拟了源图像的像素流,de是数据有效信号。参数说明:#10是像素时钟周期,实际仿真时应该跟你的时钟频率对应。跑完仿真,把输出数据导出成文本,用 Python 或 MATLAB 画出来,跟 OpenCV 的resize结果对比,误差在几个灰度级以内就算正常。

注意:仿真时行缓存的初始化状态会影响前几个像素的输出,对比时要跳过前几行,或者给行缓存加复位初值。

4. 避坑与排查:双线性插值工程里最容易翻车的五件事

4.1 图像边缘出现黑边或彩色条纹

现象:缩放后的图像四周有一圈黑边,或者边缘出现红蓝彩色条纹。

原因:坐标计算时,$x_0+1$ 或 $y_0+1$ 超出了源图像的实际范围。比如源图宽度 640,目标像素反算出来的 $x_0$ 是 639,那么 $x_0+1$ 就是 640,越界了。行缓存读出来的是未定义数据,合成后就是黑边或杂色。

解决:在坐标计算模块里加边界钳位。当 $x_0 \ge W_{src}-1$ 时,强制 $x_0 = W_{src}-2$,$u = 255$;$y$ 方向同理。这样边缘像素会用最后一对有效像素做插值,不会越界。

4.2 缩放后图像整体偏移或拉伸比例不对

现象:图像内容对,但位置偏了,或者长宽比不对。

原因:逆向映射的坐标公式里,通常要加 0.5 的偏移量,即 $x_s = (x_d + 0.5) \cdot scale - 0.5$。很多工程为了省事直接省略了这 0.5,导致图像整体偏移半个像素。另外,缩放系数如果算错,比如把 $W_{src}/W_{dst}$ 写反了,图像就会被拉伸或压缩。

解决:检查坐标计算模块里的偏移量。定点实现时,0.5 对应的是1<<15(如果小数是 16 位)。缩放系数的计算建议在配置阶段用上位机算好,直接写入寄存器,避免 FPGA 内部做除法。

4.3 行缓存 BRAM 不够用,综合报错

现象:综合时提示 BRAM 资源不足,或者布局布线失败。

原因:行缓存的深度按最大源图宽度例化,如果设成 1920,两块 BRAM 就是 3840×16bit,接近 64Kb。低端 FPGA 的 BRAM 总量可能只有几百 Kb,加上其他模块就不够了。

解决:根据实际使用的最大分辨率裁剪行缓存深度。如果只做 720P 缩放,行缓存深度设 1280 就够。另外,可以用分布式 RAM 替代 BRAM 做小深度缓存,但深度超过 256 之后分布式 RAM 的 LUT 消耗会急剧上升,不划算。

4.4 输出图像有横向撕裂或错位

现象:图像中间有一条竖线,左右两边内容对不上,或者整幅图像横向错位。

原因:行缓存的读写地址没有对齐。写侧按源图像列计数,读侧按目标坐标反算的 $x_0$,两者如果不在同一个坐标系里,读出来的数据就是错位的。常见于跨时钟域场景,写时钟和读时钟不同步。

解决:在行缓存读写之间加一个同步 FIFO,或者用双口 RAM 加握手信号。确保读操作发生在对应数据写入之后。仿真时重点看行缓存读写地址的波形,确认读地址对应的数据已经稳定。

4.5 时序不收敛,跑不到目标频率

现象:综合实现后时序报告显示建立时间违例,频率上不去。

原因:插值核心的乘加逻辑太长,或者坐标计算里的乘法器没有打拍。双线性插值本身计算量不大,但如果全部写成组合逻辑,关键路径会很长。

解决:在乘法器后面插寄存器,把两级乘加拆成多级流水。坐标计算里的定点乘法也建议打一拍。代价是输出延迟增加几个时钟,但对视频流来说无所谓。工程文件里如果已经做了流水线设计,检查一下综合约束里的时钟频率是否设得过高。

5. 进阶技巧:把缩放系数做成运行时可配,以及一个验证习惯

5.1 从固定参数到动态配置

很多工程文件里的缩放系数是写死的参数,改分辨率就得重新综合。实际项目里更常见的做法是把缩放系数做成寄存器,通过 SPI 或 I2C 在上电后写入。这样同一份比特流可以支持多种分辨率。

实现上,坐标计算模块里的乘法器从常数乘法变成变量乘法,会多消耗一些 DSP。如果 DSP 紧张,可以用移位加代替乘法,但缩放系数不是 2 的幂时精度会下降。折中方案是:把常用的几组缩放系数做成查找表,运行时选择。

// 动态缩放系数配置示例 reg [15:0] scale_x_reg; reg [15:0] scale_y_reg; always @(posedge clk) begin if (cfg_wr) begin case (cfg_addr) 8'h00: scale_x_reg <= cfg_data; 8'h01: scale_y_reg <= cfg_data; endcase end end

逻辑说明:cfg_wr是配置写使能,cfg_addr选择寄存器,cfg_data是配置数据。参数说明:scale_x_reg的格式是 16 位定点,高 16 位是整数部分,低 16 位是小数部分。写入前在上位机算好,避免 FPGA 内部做除法。

5.2 用棋盘格测试图快速定位问题

调试图像缩放时,最有效的测试图不是照片,而是棋盘格。棋盘格的边缘锐利,缩放后如果出现模糊、锯齿、错位,一眼就能看出来。具体做法是:用 MATLAB 或 Python 生成一张 640×480 的棋盘格,每个格子 32×32 像素,存成 RGB565 的二进制文件,通过串口或者 SD 卡送入 FPGA。

对比时,把 FPGA 输出的缩放结果和 OpenCV 的resize结果并排显示。如果 FPGA 结果在格子边缘有明显的灰度过渡带,说明插值正常;如果边缘出现彩色噪点,说明某个通道的权重算错了;如果格子大小不均匀,说明缩放系数不对。

5.3 一个我踩过的坑:行缓存复位后的第一帧

最后说一个血泪经验。行缓存 BRAM 上电后内容是随机的,如果不做初始化,第一帧图像的前几行会读到随机数据,输出花屏。很多工程里给行缓存加了复位,但复位释放的时机和视频流的 vsync 没对齐,导致第一帧仍然有问题。

我现在的习惯是:行缓存不复位,但在每个 vsync 到来时清空写地址和读地址,并且用一个frame_valid标志控制输出。第一帧只写不读,从第二帧开始正常输出。这样虽然牺牲了一帧的延迟,但彻底避免了上电花屏。从那以后我每次做视频缓存,都强制走一遍这个流程:vsync 对齐、首帧丢弃、frame_valid 门控。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询