简介面向FPGA开发者的图像处理系列第5份资料聚焦基础功能中的双线性插值原理与FPGA实现。文档从算法公式出发结合四邻域像素加权计算目标像素的典型场景说明了双线性插值在图像缩放、旋转、平移等操作中的意义并重点讲解在Sysgen环境中通过Slice模块截去低位代替除法、将插值系数设计为2的指数等工程化技巧避免FPGA内部出现复杂除法。压缩包内为1个PDF文件约71KB内容精炼而充实配有示意图与步骤分解适合硬件工程师和图像算法入门者按需查阅与快速上手。已有1342人学习下载读者可参照图文推导理清系数计算、邻域坐标定位与位宽对齐等关键细节可直接用于图像预处理模块的方案设计与功能验证并为后续更高阶算法提供基础支撑。1. 用双线性插值打开 FPGA 图像处理的第一道门做视频采集或显示驱动的 FPGA 工程师早晚会撞上图像缩放摄像头输出 1280x720显示屏是 1920x1080点对点显示不了。直接丢行列会闪把源像素复制成四个锯齿又太重。双线性插值是画质与资源之间最常见的折中只读目标像素周围四个源像素做加权平均就能把缩放结果拉回可接受水平。这篇按 FPGA 图像处理基础功能的视角讲清双线性插值原理与落点先解释四邻域加权为什么有效再给出能在 Vivado/Quartus 直接综合的坐标与加权求和的 Verilog 代码最后把行缓存、流水线和仿真对拍的做法讲透。适合正在做 ISP、视频缩放或显示控制器有 Verilog 基础但没手写过插值核的开发者。2. 双线性插值原理四邻域加权平均是图像缩放的最小可用模型2.1 反向坐标映射硬件缩放的第一步不是插值图像缩放本质是重采样。放大一张 640x480 的图到 960x720直接做法是让每个输出像素去源图像里找自己的“前世”。输出像素坐标 (dst_x, dst_y) 映射回源坐标 (src_x, src_y)映射公式是src_x dst_x × (SRC_W / DST_W) src_y dst_y × (SRC_H / DST_H)这个“反向映射”决定了插值算法能不能落地。如果反过来做正向映射把源像素直接复制到输出位置缩放倍数大于 2 时目标图上会出现大量空洞还得靠后处理填补。FPGA 做实时视频缩放数据是逐行推进的流反向映射天然适合流水线每个输出时钟周期只要算出一个源坐标再从缓存里抓四个像素即可。硬件里这个映射不是每周期做一次除法而是用定点累加器按步长推进后面第 3 章会给出具体写法。这里容易出现第一个错误映射用的是 SRC_W / DST_W 还是 (SRC_W-1) / (DST_W-1)。前者会让人物在缩放后向中心收缩后者能保证图片左右边缘贴齐。工程上我默认用 (SRC_W-1)/(DST_W-1)原因是缩放前后像素中心要对齐源图像最后一个像素必须能映射到目标图像最后一个像素。2.2 从最近邻到双线性为什么只取四个点就够最近邻插值在 FPGA 上实现最便宜取坐标四舍五入后直接读一个像素。它的缺陷是放大时出现明显块状效应缩小高频区域会产生闪烁感。双线性插值把目标像素映射到源坐标后取这个坐标周围最近的 2x2 像素窗口按距离加权求和。假设源坐标为 (x0dx, y0dy)其中整数部分 x0、y0 从 floor 得到小数部分 dx、dy 都在 [0,1) 区间。四个相邻像素记为 p00、p10、p01、p11取值分别为 (x0,y0)、(x01,y0)、(x0,y01)、(x01,y01)。插值公式为out (1-dx)(1-dy)·p00 dx(1-dy)·p10 (1-dx)dy·p01 dx·dy·p11四个权重之和恒等于 1这意味着输出不会发生整体亮度偏移。这个约束在硬件定点化时很有价值后面会用它省乘法器。双线性插值本质上是个低通滤波器对高频细节有抑制作用。放大照片时边缘会偏软缩小文字行时会丢失对比度。但实时视频流里人眼对软边缘的容忍度远高于马赛克和闪烁所以它是 ISP 管线里性价比最高的选择。2.3 可分离性把二维插值拆成两次一维插值上述四邻域公式可以改写为两次一维插值。先对当前行的两个像素做水平插值tmp0 (1-dx)·p00 dx·p10 tmp1 (1-dx)·p01 dx·p11再对两行结果做垂直插值out (1-dy)·tmp0 dy·tmp1二维双线性插值是可分离的这个数学性质在实际工程里用来调整流水线节奏。当权重位宽较大时拆分结构还方便插入寄存器打拍水平插值一级流水垂直插值一级流水关键路径长度能明显缩短。如果缩放比例固定两次一维插值还可以共用同一套权重计算模块。算法MUX/DSP消耗画质FPGA典型延迟适用场景最近邻0 个 DSP边缘锯齿明显高频闪烁1 周期缩略图、OSD、对延迟极敏感的通道双线性4~8 个 DSP48边缘柔和亮度连续3~5 周期视频缩放、ISP、显示控制双三次16~32 个 DSP48细节较好过冲振荡8~16 周期静态图像离线处理实时性难保证双三次在实时 4K60 场景下资源代价过高而且人眼在运动画面上并不总能感受到它的提升。工程实践中绝大多数 FPGA 视频通路最终都收敛到双线性再叠加锐化滤波补偿细节损失。2.4 边界策略越界像素的三种处理方式当源坐标落在图像右边缘或下边缘时x01 会越过列号范围。常见做法有三种把像素值钳位在边界复制、回绕到对侧边缘、用最近的有效像素替代。硬件里最常选第一种因为边界复制不需要额外存储器只把 x1 限制为 min(x01, W-1) 即可。具体到 Verilog边界的处理是在读像素之前完成的。整数坐标经过饱和裁剪后再送给行缓存读地址而不是等读到越界地址再纠错。实现时用一个比较器加选择器wire [11:0] x0_clip (x0 SRC_W - 1) ? SRC_W - 2 : x0; wire [11:0] x1_clip x0_clip 1;x0_clip 限制到 SRC_W-2保证 x1 最大不超过 SRC_W-1。注意不要只裁剪 x1 不裁剪 x0否则窗口整体偏移会造成图像右边缘收缩。亮度与色度分开缩放的项目里色度平面边界常会额外多采一个像素做 Chroma Reuse这一步会放到后面通道设计时单独讨论。3. 把插值公式落成 Verilog坐标定点化与加权求和3.1 为什么 FPGA 上不直接写浮点定点数才是常态浮点数的双线性公式在 C 语言里很直观放到 RTL 里就变味了。FPGA 做浮点乘加要么调用 Floating-Point IP要么让综合器把浮点表达式推断成 DSP 阵列两种情况资源都会翻倍延迟增加 3~5 个周期。另外浮点加法器不满足交换律位级仿真结果和 MATLAB 模型存在漂移对拍时很难收敛。业界约定俗成的是定点小数坐标用 Q16.16权重用 Q8.8。QP 格式的含义是整数部分 P 位小数部分 16-P 位。坐标步长只需要保证两方面的精度单行累积误差不能超过 0.5 个像素连续 4096 列累加后不能出现漂移。16 位小数位宽下步长量化误差是 1/65536 像素即使 4K 宽度 3840 列累积最大误差也只有 6% 像素肉眼完全不可见。权重用 8 位小数是因为权重本身只在 [0,1] 区间256 级分辨率已经足够表达亮度过渡的连续性。3.2 坐标计算模块累加器代替除法器固定缩放比场景下反向映射的除法在编译期就能算成常量运行时只做加法。下面这段 Verilog 是生成源坐标的核心模块module coord_step #( parameter SRC_W 640, parameter DST_W 1280, parameter FBITS 16, // 定点小数位宽 parameter CORD_W 28 // 整数部分 12bit 小数 16bit )( input wire clk, input wire rst_n, input wire en, // 每时钟有效表示一个输出像素 input wire line_last, // 行结束信号 output wire [CORD_W-1:0] src_pos // 定点源坐标 ); // 编译期常量除法不消耗任何逻辑 localparam integer STEP_X ((SRC_W - 1) FBITS) / (DST_W - 1); reg [CORD_W-1:0] pos_r; always (posedge clk or negedge rst_n) begin if (!rst_n) pos_r d0; else if (en) begin if (line_last) pos_r d0; else pos_r pos_r STEP_X; end end assign src_pos pos_r; endmodule代码里 STEP_X 是在 Elaboration 阶段算出的常量综合后只是一个加法器的常数输入不会生成除法器。每次 en 有效坐标累加一次一行结束后重置为 0避免行间误差累积。这里行内连续累加的前提是输出像素的使能信号均匀如果插值链路中插入了 FIFO 反压en 信号不能简单用 valid 代替。CORD_W位宽计算方法整数部分需要容纳 SRC_W 的最大值 640 对应 10 位留 2 位冗余做中途临时值取 12 位加 16 位小数共 28 位。换分辨率时整数位宽按clog2(SRC_W)2计算不要把整数位设成固定 16 位否则极端缩放比下坐标溢出后插值窗口会串行。x 坐标和 y 坐标可以用同一个模块实例化两份。y 方向的 STEP_Y 按行推进每来一行line_last时累加一次。注意 x 方向每行都重置为 0y 方向整个帧都不重置只在帧同步信号到来时清 0。3.3 加权求和四路乘累加加一点截断技巧拿到源坐标后取整数部分和小数部分。整数位进行缓存读地址小数位作为权重进入乘法器。下面这段代码覆盖了双线性公式的核心计算module bilinear_core #( parameter DATA_W 8, parameter WF_W 8 // 权重小数位宽 )( input wire clk, input wire rst_n, input wire en, // 输入使能 input wire [DATA_W-1:0] p00, p01, p10, p11, input wire [WF_W-1:0] dx, dy, // 定点小数权重 output reg [DATA_W-1:0] dout, output reg dout_vld ); // 1 用常量表示权重总数便于后续归一化 localparam integer WF (1 WF_W); // 256 // 2 计算 2x2 窗口权重全部定点乘法 wire [WF_W*2-1:0] w00 (WF - dx) * (WF - dy); wire [WF_W*2-1:0] w01 dx * (WF - dy); wire [WF_W*2-1:0] w10 (WF - dx) * dy; wire [WF_W*2-1:0] w11 dx * dy; // 3 像素与权重相乘后累加乘积按 32bit 处理 wire [31:0] mul00 p00 * w00; wire [31:0] mul01 p01 * w01; wire [31:0] mul10 p10 * w10; wire [31:0] mul11 p11 * w11; reg [31:0] sum_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sum_r d0; dout d0; dout_vld 1b0; end else if (en) begin sum_r mul00 mul01 mul10 mul11; dout sum_r[(WF_W*2) : DATA_W]; // 从第 16 位开始取 8 位 dout_vld en; end end endmodule权重 w00 到 w11 是 16 位定点数最大值是 65535 正好对应整数 1。四个权重和必然等于 65536这个性质让输出不会整体偏亮或偏暗。像素值 8 位乘以权重 16 位得到 24 位乘积四次乘积累加扩展到 32 位是为了防止进位溢出。输出截断从 bit16 开始取 8 位等价于除以 65536 后四舍五入到最接近整数。不要直接取高 8 位那样对小像素值会有 -1 的固定偏差连续视频流里看起来是暗部发灰。注意到这里 dout 的输出比起 sum 多打了一拍所以 dout_vld 也要对齐到同一拍。忘记对齐使能信号是自写代码里最常见的时序错误仿真时数据看起来错半拍上板后表现为图像右移一两个像素。3.4 接口与参数设计一份可复用的插值子模块把坐标模块和核心计算模块封装起来之前需要先定好接口。下面是实用端口定义信号方向位宽说明clk / rst_ninput1时钟建议 150MHz 以上s_axis_tvalidinput1输入像素有效s_axis_treadyoutput1反压信号s_axis_tdatainput8/16/24灰度或 RGB 像素s_axis_tuserinput1帧同步tlastinput1行同步m_axis_tvalidoutput1输出数据有效width / heightinput16运行时分辨率可动态切换参数用SRC_W/DST_W/FBITS/WF_W四个即可覆盖绝大多数项目。分辨率固定时把参数写死能省资源但做多分辨率适配一定要把宽高信号做成寄存器主机端通过 AXI-Lite 配置。缩放的边界情况也在这里处理当 DST_W SRC_W 且 DST_H SRC_H 时坐标模块输出 0 步长插值核变成纯通路这时可以直接把旁路开关打开省掉全部乘法器功耗。4. 行缓存与流水线让缩放过程不打断视频数据流4.1 为什么必须有行缓存一次输出需要读两行两列输入视频流是逐行来的但双线性插值一次输出要同时访问 (x0,y0)、(x01,y0)、(x0,y01)、(x01,y01) 四个像素。这四个像素分布在相邻两行里如果没有缓存等第二行数据到达时第一行早就过去了。行缓存的任务是把当前行延迟一拍让插值器随时能从“上一行”和“当前行”各取相邻两个像素。实现上最常用双口 RAM写侧接收新像素读侧滞后一拍输出上一行数据。设计行缓存时一个反复出现的错误是缓存深度只看行宽本身。行缓存深度至少等于 SRC_W但读侧要支持同时访问 y0 和 y01 两行的同列像素所以实际是两个深度为 SRC_W 的银行。对 1080p 灰度图2 行 x 1920 像素 x 8bit 共 30Kbit约等于 1 个块 RAM (BRAM36K)RGB888 则是 90Kbit需要 3 个 BRAM36K。4.2 双行缓存读写控制写侧顺序写读侧跟行延迟下面代码展示双行缓存的控制逻辑。这里用寄存器数组便于理解综合工具会自动把它映射成块 RAMmodule line_buffer #( parameter DATA_W 8, parameter LINE_W 1920 )( input wire clk, input wire rst_n, input wire wr_en, input wire [DATA_W-1:0] din, input wire [$clog2(LINE_W)-1:0] wr_addr, input wire [$clog2(LINE_W)-1:0] rd_addr, output reg [DATA_W-1:0] dout_prev, // 上一行像素 output wire [DATA_W-1:0] dout_curr // 当前行像素 ); reg [DATA_W-1:0] mem_bank [0:LINE_W-1]; always (posedge clk) begin if (wr_en) mem_bank[wr_addr] din; dout_prev mem_bank[rd_addr]; end assign dout_curr din; // 当前行直接从输入取 endmodule写法上把当前行数据直接接到 din上一行数据延迟一拍读出来。这样插值器每个时钟周期都能拿到同一列的上下两个像素。rd_addr 通常比 wr_addr 少 1保证读出来的上一行像素和当前输入像素在空间上是对齐的。注意 mem_bank 读使能不要额外加条件否则读出数据会多延迟一拍打乱插值窗口对齐关系。行缓存写满一帧后 RAM 里的数据就是残帧下一个帧同步到来时不能只清零读写指针还要把 mem_bank 的存储内容保持原样。双缓存或乒乓结构是更稳妥的做法两个 bank 交替服务奇偶行插值器永远从“正在写的 bank”和“刚写完的 bank”读数据。代价是多 1 倍 BRAM换取的是不需要在行切换时等 RAM 清空。4.3 流水线拆解分几级、每级做什么双线性插值的完整数据通分段位流水级处理内容操作类型延迟P0计算定点源坐标 src_pos加法器累加1clkP1从行缓存读 p00/p10/p01/p11BRAM 读延迟1~2clkP2权重 w00~w11 计算4 个乘法器1clkP3像素 x 权重乘累加4 个 DSP 加法树1~2clkP4位宽截断与输出对齐寄存器打拍1clk总共 5~7 个时钟周期延迟对视频链路来说完全可以忽略。关键是每级之间的寄存器要打拍对齐使能信号 en 要跟着数据一起走。如果把 tready 反压恢复后数据重新使能但使能信号没有穿过 BRAM 读延迟对齐就会出现输出花屏。P2 和 P3 在资源充足时可以合并成一级4 个权重乘法加 4 个像素乘法共 8 个 DSP48E11080p60 下频率仍能跑到 200MHz。如果 DSP 数量吃紧把权重用 LUT 实现WF_W8 时 w00 只是两张 256x16 的查找表DSP 可以压到 4 个。反过来要省 LUT 就保留 DSP 权重两种方案综合结果差 100 个 LUT影响不大。4.4 AXI-Stream 时序适配握手信号不能省视频缩放核通常嵌在 AXI-Stream 链路里输入输出都有 tvalid/tready 握手。插值器内部需要连续两个行缓存数据才能出第一个有效像素所以输入侧 tready 不能一直拉高。简化处理是在输入侧做一个深度 64 的 FIFOFIFO 已有两个像素时把 tready 拉高插值器从 FIFO 取数保证行缓存写侧始终连续。输出侧问题在于双线性插值的输出速率不稳定。当缩放比不是整数时每个输出像素消耗的源像素数量呈周期变化输出 tvalid 会周期性地拉低几个周期。接收端如果是 HDMI TX 这类要求连续流的接口必须插一个输出 FIFO 吸收抖动。FIFO 深度按最大抖动量估算一般 256 足够。两个容易踩的手握时序细节其一tready 拉低前必须保持 tvalid 不变否则上游可能丢像素其二tlast 要与对应行的最后一个像素同步不能滞后一拍否则接收端行计数错乱。仿真时用 AXI VIP 或手写 monitor 脚本检查这两条规则比盲目抓波形效率高得多。4.5 资源估算一句话决定用不用这个方案目标分辨率位宽块 RAMDSP48E1LUT 估值最大频率参考1280x720 灰度8bit1 个 BRAM36K4350~450250MHz1920x1080 灰度8bit1 个 BRAM36K4400~500200MHz1920x1080 RGB24bit3 个 BRAM36K121200~1500200MHz3840x2160 RGB24bit7 个 BRAM36K121500~1800150MHzRGB 三个通道各自独立做插值时权重模块可以共享但行缓存和乘法器必须复制三份。如果项目对 BRAM 极敏感可以先把 RGB 转成 YUV422对 Y 全分辨率插值UV 各半分辨率插值再合并BRAM 占用能降 40%。代价是色度细节垂直方向略软多数显示场景可接受。5. 仿真验证与上板排错从 Testbench 到 ILA 抓波形的完整闭环5.1 用 $readmemh 喂图先验证单像素级计算写 Testbench 的第一步是验证插值核算术是否正确。手工构造一个 2x2 窗口给定 dx/dy手算出期望值。以 p00100、p01200、p10150、p11250dx0.25、dy0.5 为例期望输出 0.375×100 0.125×200 0.375×150 0.125×250 150Testbench 代码核心片段module tb_bilinear; reg clk 0; reg en 0; reg [7:0] p00 100, p01 200, p10 150, p11 250; reg [7:0] dx 64, dy 128; // 对应 0.25 / 0.5 wire [7:0] dout; wire dout_vld; bilinear_core dut ( .clk(clk), .rst_n(1b1), .en(en), .p00(p00), .p01(p01), .p10(p10), .p11(p11), .dx(dx), .dy(dy), .dout(dout), .dout_vld(dout_vld) ); always #5 clk ~clk; initial begin repeat (10) (posedge clk); en 1; repeat (2) (posedge clk); en 0; repeat (5) (posedge clk); $display(dout%0d expect150, dout); if (dout 150) $display(PASS); else $error(FAIL); $finish; end endmodule这段验证的是纯组合逻辑和输出打拍能立刻发现位宽截断或权重公式写反的问题。dout 输出晚于 en 两拍因此用repeat(2)拉低 en 后等 5 拍再看结果。工程里我会把 256 组随机 dx/dy 放进循环生成 Testbench跑完统计 err 是否都在 1 LSB 内。5.2 与 Python 参考模型对拍允许 1 个 LSB 误差单像素手算只能验证核心公式整帧验证要靠参考模型。Python 里写一个同样使用定点算法的模型喂同一张测试图输出和 RTL 仿真相减import numpy as np from PIL import Image def bilinear_fp(src, dst_w, dst_h, fbits16, wbits8): h, w src.shape out np.zeros((dst_h, dst_w), dtypenp.uint8) for j in range(dst_h): for i in range(dst_w): sx (i * (w - 1) * (1 fbits)) // (dst_w - 1) sy (j * (h - 1) * (1 fbits)) // (dst_h - 1) x0 min(sx fbits, w - 2) y0 min(sy fbits, h - 2) dx (sx (fbits - wbits)) ((1 wbits) - 1) dy (sy (fbits - wbits)) ((1 wbits) - 1) w00 (256 - dx) * (256 - dy) w01 dx * (256 - dy) w10 (256 - dx) * dy w11 dx * dy val (src[y0, x0] * w00 src[y0, x01] * w01 src[y01, x0] * w10 src[y01, x01] * w11) 16 out[j, i] val return out src np.arange(64 * 64, dtypenp.uint8).reshape(64, 64) ref bilinear_fp(src, 128, 128)Python 模型里必须保持和 Verilog 相同的位宽、相同的截断时机连舍入方式都要一致。权重用乘法器算完乘积累加后右移 16 位这在 Python 用 16模拟。如果两边结果差超过 1先把坐标定点部分拉出来单测大概率是 SRC_W-1 和 DST_W-1 那两条减 1 到底是减在哪个变量上没对齐。5.3 整帧仿真提效用灰度渐变图做空间连续性检查全 64x64 逐点循环的 Python 模型能验证正确性但速度慢。回归仿真是个更好的折衷方案生成一张 1920x1 的横向渐变图作为单行数据送入 Testbench观察输出行像素值是否单调递增。双线性插值对线性渐变图理论上输出仍是线性任何非线性跳变都说明坐标累加或权重截断有 bug。仿真跑一行 1920 个时钟只要几微秒比 50 帧仿真快三个数量级。用 Modelsim 或 Vivado Simulator 跑完把结果导出成 CSV与参考模型同一行数据做差分统计。重点看两个指标最大绝对误差不超过 1误差位置是否固定在某些坐标。误差固定出现在行尾说明 STEP_X 位宽不足误差分散出现则要检查权重截断方式。5.4 上板排错先抓信号再调阈值仿真过了不等于上板没问题板级系统里的行缓存、FIFO 反压、帧同步都会引入仿真里没有的情况。ILA 调试时的优先级先抓输入侧 tready/tvalid 是否因为 FIFO 满而周期拉低这决定坐标累加节奏是否被打乱再抓一行中的 tlast 是否与最后一个像素对齐这是行缓存切行是否正确的直接依据最后抓输出侧 tvalid 与 dout 的对齐关系dout 不能比 tvalid 早或晚出现花屏常见原因集中在两个地方行缓存读地址没跟写地址错一拍以及帧首行插值窗口里混入了上一帧数据。前者把 dout_prev 打拍与 din 对齐能解决后者在帧同步后延迟两个时钟再使能行缓存写入确保插值器前两行不读垃圾数据。6. 进阶技巧资源压缩和动态缩放的落地细节DSP 数量敏感的项目可以让 w11 不显式计算利用四个权重和为 65536 这一点直接做减法w11 65536 - w00 - w01 - w10这样权重计算从 4 个乘法器降到 3 个代价是增加两级减法链。减法和加法级数交换后对时序影响很小17x17 乘法器本来就是 DSP48 的单个原生操作省一个 DSP 对整片资源布局意义不小。注意 w11 一定要钳位到非负值定点舍入导致的极端情况下它可能算出 -1。动态分辨率切换项目里把 STEP_X/STEP_Y 做成寄存器由主机下发避免重新综合。定点位宽取 20 位小数整数部分 12 位这样既能覆盖 4K 宽度切换分辨率时也不需要改 RTL。主机计算步长的公式用(src-1)/(dst-1)20传下来后 RTL 只做累加和截断。RGB 三通道实现里有一个容易被忽略的优化三分量共用同一套 dx/dy 权重和坐标。真正的计算差异只有行缓存数据和乘法加法权重模块只需要一个副本再配以每周期三路并行读像素整核只消耗 3 个 DSP 权重乘法器加 9 个像素乘法器就把 1080p RGB 缩放做完了比直接复制三路完整模块省一半资源。把这段逻辑整理成参数化 IP 后配 AXI-Lite 配置步长和开关就可以直接复用到多路视频拼接、画中画、多屏显示项目里。本文还有配套的精品资源点击获取
