简介:面向FPGA开发与图像处理方向的学习者,这份项目资料围绕图像拼接与融合的硬件实现展开,适合希望把SIFT/SURF特征匹配、透视校正、像素级对齐与加权融合等算法落到可综合逻辑中的读者,也可为高校课程设计以及无人机航拍、机器人视觉等实时处理场景提供参考。压缩包共121个文件,约31.59MB,以jpg与png示例图像、Verilog源码、PDF文档、VCD仿真波形和TXT/MD说明为主,另含Python辅助脚本、Makefile及YML配置文件,可支撑从算法验证到上板调试的完整链路。已有615人学习。项目内提供了高斯滤波与Sobel边缘检测相关素材、显示与输出数据文件,以及工程接口说明,便于读者对照仿真波形排查时序与数据通路问题,理解图像缓存、流水线设计和融合权重计算的实现思路,从而搭建自己的FPGA图像处理验证环境并积累可复用的模块设计经验。
1. 双路摄像头拼成一幅宽幅图,为什么我把融合算法压进了像素流水线
去年帮某实验室调一个模拟项目X的视觉前端,两路 640×480 的摄像头要拼成 1152×480 的宽幅画面,中间 128 像素的重叠区不能出现亮带。第一版把两帧先写进片外缓存,再整帧读出来做加权平均,跑到 30fps 就开始撕裂,查了三天才发现是读写带宽和帧同步在互相对抗。后来改成“边读边算”的流式结构,权重直接由输出列号移位生成,一张 BRAM 都没占,逻辑只多了不到 800 个 LUT,帧率稳在 60。这份资源的价值在于,它把 fpga开发 里最容易被跳过的一环——从摄像头时序到拼接坐标映射的完整数据通路——拆成了可以逐段上板验证的模块,适合已经能写 Verilog 状态机、但还没把图像流水线真正跑通过的人。
2. 拼接链路选型:融合放在像素流里做,还是整帧做完再写回
动手之前必须先定架构,因为架构一改,后面所有模块的接口都得跟着改。这一章把两种主流做法摆出来算账,再解释为什么这个场景选了读侧融合。
2.1 两种架构:双缓存读侧融合与单画布写侧融合
双缓存读侧融合的思路是:左右两路各占一块独立的帧缓存区域,写入时谁也不用管谁;读出时按输出坐标去查表,落在左图区间就读左缓存,落在重叠区就同时读两块再做加权,落在右图区间只读右缓存。好处是两路摄像头完全解耦,接缝位置、重叠宽度全都是读侧的地址参数,改一个localparam就能重新对齐,甚至可以在线调节。代价是重叠区每个输出像素要读两次,读带宽多出一截。
单画布写侧融合则反过来:片上先开一张 1152 宽的画布,左路写x∈[0,640),右路写x∈[512,1152),重叠区在写入前就用一个对齐 FIFO 把两路像素凑到一起,算完加权再写一次。读完是一路线性流,读带宽最省,输出时序也简单。但它要求两路摄像头的行场严格同步——同一行、同一列的两个像素必须在时间上对得上,一旦某一路丢了几行,整幅图就错位,而且错位当场写死,读的时候救不回来。
| 对比项 | 双缓存读侧融合 | 单画布写侧融合 |
|---|---|---|
| 缓存容量(RGB565 单帧) | 2 × 600KB | 1152×480×2B ≈ 1.08MB |
| 写带宽 | 两路各 18.4 Mpixel/s | 同左,重叠区需合并写 |
| 读带宽 | 输出量 + 重叠区额外一路 | 等于输出量 |
| 两路同步要求 | 松(可容忍数行偏差) | 严(必须逐行逐列对齐) |
| 接缝位置可调 | 改参数即可 | 需重新综合 |
| 调试难度 | 可分段旁路 | 出错后定位困难 |
选读侧融合的核心理由是可调试性。做图像拼接最容易翻车的不是算法,是“差一个像素”这类定位问题,读侧的地址逻辑可以一路旁路到直通模式,把左图或右图单独打到屏幕上,肉眼就能判断偏移方向;写侧融合一旦错了,你看到的是一张已经揉在一起、无从下手的图。
2.2 带宽怎么算:把 1152×480@60 拆成读写两笔账
带宽不算清楚,后面 DDR 仲裁一定出问题。按输出分辨率 1152×480、刷新 60Hz、像素格式 RGB565(16bit)来算:
- 输出像素率 = 1152 × 480 × 60 ≈ 33.2 Mpixel/s
- 写侧:两路摄像头各 640×480@60 = 18.4 Mpixel/s,合计 36.9 Mpixel/s
- 读侧:输出 33.2 Mpixel/s,重叠区(128/1152 ≈ 11.1%)再多读一路,约 3.7 Mpixel/s,合计 36.9 Mpixel/s
- 折算成字节:36.9M × 2B ≈ 73.7 MB/s,读写相加约 147 MB/s
一块 16bit 位宽、400MHz 时钟的 DDR3,理论峰值 1.6 GB/s,实际连续读写效率按 55%~65% 估,可用带宽约 900 MB/s~1 GB/s。看上去余量很足,但真正的瓶颈从来不是平均值,而是突发性:摄像头一行 640 像素在 640 个 pclk 内涌进来,如果 pclk 是 48MHz,一行只花 13.3µs;而显示侧要求每 74.25MHz 出一个像素,中间不能断流。所以设计上必须用 FIFO 做速率缓冲,并且给写通道比读通道更高的仲裁优先级——写侧断了会丢数据,读侧断了只是偶尔一帧闪一下。
提示:算带宽时别用“平均帧率”糊弄自己,要按最坏情况算——两路同时行有效、显示侧正好在回读重叠区,这时候如果 FIFO 深度不够,丢的就是整行。
2.3 时钟域划分与模块切分
整条链路有三个时钟域,跨界点必须明确标注,否则后期查问题时你会不知道自己在看哪个域的波形。
| 模块 | 所在时钟域 | 职责 |
|---|---|---|
| cam_capture ×2 | pclk(24~48MHz,两路可不同源) | DVP 时序解析,输出 16bit 像素 + de |
| pack_fifo ×2 | 写 pclk / 读 ui_clk | 16bit 拼 64bit,跨时钟域 |
| wr_arbiter | ui_clk | 两路写请求仲裁,转 AXI4 写 |
| ddr_ctrl | ui_clk | 存储控制器,例化硬核或 IP |
| rd_engine | ui_clk | 按输出坐标发起读请求,返回 64bit |
| blend | pix_clk | 加权融合、格式整理 |
| video_timing | pix_clk | 行场同步生成,驱动 HDMI |
两路摄像头如果是同一型号、共用同一个外部晶振,pclk 通常同源同相;但不要假设它们一定同步,模块设计上按异步处理,代价只是两个异步 FIFO,收益是换摄像头型号时不用重写。
2.4 先把参数集中到一个文件里
所有分辨率、重叠宽度、缓存基地址都抽到一个头文件,后面每个模块`include它。这一步看着啰嗦,但改分辨率时你会感谢自己。
// params.vh —— 全局参数,所有模块统一引用 `ifndef PARAMS_VH `define PARAMS_VH // 单路摄像头有效分辨率 `define CAM_W 640 `define CAM_H 480 // 重叠区宽度(像素),必须是 2 的幂,方便用移位生成权重 `define OVERLAP_W 128 // 输出分辨率 = 左 + 右 - 重叠 `define OUT_W (`CAM_W + `CAM_W - `OVERLAP_W) // 1152 `define OUT_H `CAM_H // 右图在输出坐标里的起始列 `define RIGHT_X0 (`CAM_W - `OVERLAP_W) // 512 // 帧缓存基地址,两块各留 1MB(640*480*2B = 600KB) `define BASE_LEFT 32'h0000_0000 `define BASE_RIGHT 32'h0010_0000 // 像素格式:RGB565 `define PIX_BYTES 2 `endifOVERLAP_W取 2 的幂不是强迫症,是因为权重alpha = (x - RIGHT_X0) * 256 / OVERLAP_W除以 128 等价于右移 7 位,综合出来是一根线,而除以 120 会给你例化一个除法器,时序立刻紧张。BASE_RIGHT留 1MB 间距是为了对齐 DDR 的 bank 边界,两路写请求落在不同 bank 上能减少行激活冲突,这一点在带宽吃紧时差别很明显。
3. 采集与写缓存:DVP 时序、异步 FIFO 和地址映射
摄像头这一侧是所有问题的源头。DVP 并口没有时钟随路,靠 pclk 采样,极性还经常和手册写的不一致,先把这一段做扎实,后面的拼接才有意义。
3.1 DVP 采集状态机与三个必须确认的极性
DVP 接口一共五根线:pclk、vsync(场同步)、href(行有效,有的叫 hsync)、d[7:0] 数据。常见的坑是极性:有的传感器 vsync 低有效、href 高有效,有的反过来,还有的用 href 当行同步、另外给一根 hsync 当消隐指示。不要照着数据手册的时序图硬写,先用示波器或者逻辑分析仪抓一段实际波形,确认三个极性再动手。
数据组织上,RGB565 一个像素 16bit,DVP 只有 8bit 位宽,所以要两个 pclk 拼一个像素。第一个字节是高字节还是低字节,不同传感器不一样,寄存器配置里通常有个 bit 可以翻转,配置错了画面会呈现规律性的偏色(红蓝互换、亮度错位)。
// cam_capture.v —— 单路 DVP 采集,输出 16bit RGB565 有效像素流 module cam_capture ( input wire pclk, // 摄像头像素时钟 input wire rst_n, // pclk 域异步复位,低有效 input wire vsync_n, // 场同步,低有效(按实测极性改) input wire href, // 行有效,高有效(按实测极性改) input wire [7:0] din, // DVP 8bit 并口 output reg [15:0] dout, // RGB565 像素 output reg de, // 像素有效,与 dout 同拍 output reg [10:0] x_cnt, // 列计数 0~639 output reg [9:0] y_cnt // 行计数 0~479 ); reg byte_sel; // 0=收高字节 1=收低字节 reg href_d; // href 打一拍,用于检测行结束 always @(posedge pclk or negedge rst_n) begin if (!rst_n) begin href_d <= 1'b0; de <= 1'b0; byte_sel <= 1'b0; dout <= 16'd0; x_cnt <= 11'd0; y_cnt <= 10'd0; end else begin href_d <= href; if (!vsync_n) begin // 场同步期间复位行列计数 x_cnt <= 11'd0; y_cnt <= 10'd0; byte_sel <= 1'b0; de <= 1'b0; end else if (href) begin if (byte_sel == 1'b0) begin dout[15:8] <= din; // 高字节先到 byte_sel <= 1'b1; de <= 1'b0; end else begin dout[7:0] <= din; byte_sel <= 1'b0; de <= 1'b1; // 低字节到齐,输出有效像素 x_cnt <= x_cnt + 11'd1; end end else begin de <= 1'b0; byte_sel <= 1'b0; if (href_d && !href) begin // href 下降沿 = 一行结束 x_cnt <= 11'd0; y_cnt <= y_cnt + 10'd1; end end end end endmodule这段状态机的关键在三个地方。第一,de只在第二个字节到达那一拍拉高,并且dout在同一拍完成拼接,两者严格对齐,后面的 FIFO 只认de,不需要再判断字节序。第二,行列计数在vsync有效期间清零,而不是靠检测vsync的边沿——用“有效期间保持清零”比“沿触发清一次”更抗干扰,摄像头偶尔输出的毛刺脉冲不会把计数打乱。第三,href_d打一拍是为了检测下降沿,行结束时清x_cnt并累加y_cnt,这里必须用打拍后的信号,直接用href会在行内数据还没结束时误触发。
3.2 跨时钟域:16bit 拼 64bit 的打包 FIFO
摄像头的 pclk 可能是 24MHz,而存储控制器工作在 100MHz 或 200MHz 的ui_clk,中间必须隔一个异步 FIFO。更关键的是位宽转换:AXI 总线数据位宽一般是 64bit 或 128bit,一次突发传输至少 64bit,如果每个像素单独发一次请求,突发效率只有 1/4,带宽直接掉一大半。所以在写侧就要把 4 个 16bit 像素拼成一个 64bit beat。
// 写侧打包:4 个 RGB565 像素 -> 1 个 64bit beat reg [63:0] pack_buf; reg [1:0] pack_cnt; always @(posedge pclk or negedge rst_n) begin if (!rst_n) begin pack_buf <= 64'd0; pack_cnt <= 2'd0; end else if (de) begin pack_buf <= {pack_buf[47:0], dout}; // 左移,先到的像素在高位 pack_cnt <= pack_cnt + 2'd1; end end wire fifo_wr_en = de && (pack_cnt == 2'd3); // 凑满 4 个才写 wire [63:0] fifo_wr_data = {pack_buf[47:0], dout}; // 第 4 个像素并入后的完整值 // 异步 FIFO:写侧 64bit,读侧 64bit,深度 512(可缓存 2048 像素 = 3.2 行) async_fifo_64b u_wr_fifo ( .wr_clk (pclk), .wr_rst_n (rst_n), .wr_en (fifo_wr_en), .wr_data (fifo_wr_data), .wr_full (wr_full), .wr_level (wr_level), // 写侧水位,用于反压判断 .rd_clk (ui_clk), .rd_rst_n (ui_rst_n), .rd_en (m_axis_tready), .rd_data (m_axis_tdata), .rd_empty (m_axis_tvalid_n) ); // 写满说明存储侧来不及搬,用反压把摄像头数据丢弃并记录错误计数 always @(posedge pclk or negedge rst_n) begin if (!rst_n) drop_cnt <= 16'd0; else if (fifo_wr_en && wr_full) drop_cnt <= drop_cnt + 16'd1; endpack_cnt用两位计数器,回绕到 0 的那一拍不写 FIFO,而是在下一次de到达时凑满第四个像素再写,这样数据顺序是{p3, p2, p1, p0},高位在前,符合 DDR 小端存储下“低地址存低字节”的习惯,读出来按同样顺序拆包就还原了。
FIFO 深度 512 是按行算的:一行 640 像素拼成 160 个 beat,512 深的 FIFO 能缓存 3.2 行,足够覆盖存储控制器在做刷新、行激活时的最长停顿。drop_cnt这个计数器是整个设计里最有用的东西之一,正常情况下它应该永远为 0,一旦非 0 就说明带宽不够或者仲裁饿死了写通道——不用抓波形就知道问题出在哪一层。
3.3 写 DDR 的地址规划与突发对齐
地址计算看着简单,错了就是整屏花。RGB565 每像素 2 字节,一行 640 像素 = 1280 字节 = 20 个 64 字节突发。1280 能被 64 整除这件事非常重要,意味着每一行的起始地址天然对齐突发边界,不会出现一次突发跨越两行的情况。如果换成 1152 宽的图,1152×2 = 2304 字节 = 36 个突发,也能整除;但要是分辨率选成 1024×600 之类的,就得手动 padding。
// 像素坐标 -> 字节地址(乘法全部拆成移位,不占 DSP48) // offset = (y * CAM_W + x) * 2 = y*1280 + x*2 // 1280 = 1024 + 256,所以 y*1280 = (y<<10) + (y<<8) wire [31:0] line_off = ({21'd0, y_cnt} << 10) + ({21'd0, y_cnt} << 8); wire [31:0] pix_off = {21'd0, x_cnt} << 1; wire [31:0] addr_l = `BASE_LEFT + line_off + pix_off; wire [31:0] addr_r = `BASE_RIGHT + line_off + pix_off; // 转成 AXI 突发首地址:一次突发 64 字节 = 8 个 64bit beat wire [31:0] burst_addr_l = addr_l & 32'hFFFF_FFC0; wire [10:0] burst_len_l = 11'd8; // AXI3 的 len 是 beats-1,AXI4 按 IP 配置这里用移位代替*640不是微优化。综合工具对常数乘法会推断出 DSP 或者移位加法链,*1280这种非 2 的幂常数,在组合逻辑里会生成一串加法器,放在 pclk 到 ui_clk 的地址通路上,很容易让建立时间余量见底。手动拆成(y<<10)+(y<<8)之后,就是两根线加一个加法器,时序立刻宽松。
burst_addr_l做按位与掩码,是强制把地址压到 64 字节边界上。写侧数据永远是 4 个像素一起凑满 64bit,天然对齐,所以这个掩码理论上不会改变值,加上去是为了在综合报告里能一眼看出对齐意图,也防止后面有人改动位宽后忘记。
再看两路写请求的仲裁。两路摄像头是独立时钟域,各自的行有效时段可能重叠,如果两路同时向同一个 AXI 写通道发请求,仲裁器必须保证一行数据不被拆断——拆断本身不错,但会让 DDR 的行激活次数翻倍。简单的做法是给每个通道一个 64bit 的 skid buffer,仲裁时按“整突发授权”切换,而不是逐 beat 轮转。
4. 拼接与融合:坐标重映射和权重表怎么落地
写侧把所有像素老老实实存进两块缓存,剩下的全在读侧。这一章是整条链路的算法核心,也是唯一需要想清楚“数学怎么变成加法”的地方。
4.1 输出坐标反查输入地址:三个区间,两路读请求
设左图宽 640,右图宽 640,重叠 128,输出宽 1152。三段划分非常清晰:
x ∈ [0, 512):只属于左图,右图不参与,读左缓存x列x ∈ [512, 640):重叠区,读左缓存x列和右缓存x-512列,加权混合x ∈ [640, 1152):只属于右图,读右缓存x-512列
// 输出坐标 -> 双路读地址 + 融合权重 wire [10:0] x_out = h_cnt; // 当前输出列 wire in_l = (x_out < `CAM_W); // 左图有效 wire in_r = (x_out >= `RIGHT_X0); // 右图有效 wire in_ov = in_l && in_r; // 重叠区 wire [10:0] xl = x_out; // 左图读列 wire [10:0] xr = x_out - `RIGHT_X0; // 右图读列 // 权重:重叠区内从左到右 0 -> 255,128 个像素步进 2 // (x_out - RIGHT_X0) << 1 正好覆盖 0~254,末端近似全取右图 wire [7:0] alpha = in_ov ? {1'b0, (x_out - `RIGHT_X0)} << 1 : 8'd0; // 读地址(字节地址,不含突发偏移) wire [31:0] rd_addr_l = `BASE_LEFT + ({21'd0, y_out} << 10) + ({21'd0, y_out} << 8) + ({21'd0, xl} << 1); wire [31:0] rd_addr_r = `BASE_RIGHT + ({21'd0, y_out} << 10) + ({21'd0, y_out} << 8) + ({21'd0, xr} << 1);这段代码里有两处刻意的近似,反过来看就是精度取舍。第一,权重用(x - RIGHT_X0) << 1生成,128 个像素映射到 0、2、4……254,步进固定为 2,省掉了一个除法器和一张 128×8 的 BRAM。代价是 α 永远取不到 256,右图最右侧的像素仍保留 1/256 的左图成分——在 RGB565 的 5/6/5 量化下,这个分量小到连最低位都影响不了,实测接缝处没有可察觉的暗线。
第二,读地址按字节算,每次突发读 64 字节即 32 个像素,意味着每发起一次读请求,后续 31 个像素可以用流式 FIFO 直接吐出来,不用每像素都发地址。这一点在读侧融合尤其重要:重叠区要同时维护两个读通道,如果每像素都独立发起请求,AXI 的地址通道会成为瓶颈,实测吞吐会掉到理论值的三成。
注意:
xr是 11 位无符号数,x_out < RIGHT_X0时会下溢成一个大数。所以右路读请求必须用in_r做门控,只在x_out >= 512时才有效,否则会在缓存里读到完全无关的区域,表现为画面右侧出现一段杂色。
4.2 渐入渐出的定点实现:一次乘加搞定三个通道
融合公式是标准的 alpha blending:out = L*(255-α) + R*α,再右移 8 位。RGB565 拆成 R(5bit)、G(6bit)、B(5bit)三个通道分别算,每通道两个 8bit×5bit 乘法,一共 6 个乘法器。5 万 LUT 级别的器件上这点 DSP 完全不是问题,但要注意流水线——组合逻辑里串一个乘法加一个加法,时钟跑到 74.25MHz 会非常紧张。
// 三通道加权融合,两级流水:第一级乘法,第二级加法与移位 reg [12:0] mul_r0, mul_r1, mul_g0, mul_g1, mul_b0, mul_b1; reg [12:0] mul_r0_d, mul_r1_d, mul_g0_d, mul_g1_d, mul_b0_d, mul_b1_d; reg [7:0] alpha_d1, alpha_d2; reg ov_d1, ov_d2; wire [7:0] inv = 8'd255 - alpha; always @(posedge pix_clk) begin // 第一级:左右像素同 α 相乘 mul_r0 <= dout_l[15:11] * inv; mul_r1 <= dout_r[15:11] * alpha; mul_g0 <= dout_l[10:5] * inv; mul_g1 <= dout_r[10:5] * alpha; mul_b0 <= dout_l[4:0] * inv; mul_b1 <= dout_r[4:0] * alpha; // 数据延迟对齐 mul_r0_d <= mul_r0; mul_r1_d <= mul_r1; mul_g0_d <= mul_g0; mul_g1_d <= mul_g1; mul_b0_d <= mul_b0; mul_b1_d <= mul_b1; alpha_d1 <= alpha; alpha_d2 <= alpha_d1; ov_d1 <= in_ov; ov_d2 <= ov_d1; end // 第二级:求和 + 右移 8 位,非重叠区直接选通单路 wire [4:0] blend_r = (mul_r0_d + mul_r1_d + 13'd128) >> 8; // +128 做四舍五入 wire [5:0] blend_g = (mul_g0_d + mul_g1_d + 13'd128) >> 8; wire [4:0] blend_b = (mul_b0_d + mul_b1_d + 13'd128) >> 8; wire [15:0] pix_blend = {blend_r, blend_g, blend_b}; wire [15:0] pix_out = ov_d2 ? pix_blend : (in_r_d2 ? dout_r_d2 : dout_l_d2);+13'd128是四舍五入,右移 8 位前加半个 256。不加也能用,但接缝处会整体偏暗半个 LSB,128 个像素连起来就是一条极淡的竖带,在大面积纯色背景上(比如白墙)能看出来。这个细节在很多参考设计里被省掉了,实际产品里会被挑出来。
权重和像素的延迟对齐是这段代码里最容易被忽略的部分。乘法器有一拍延迟,加法移位又是一拍,所以in_ov和in_r必须跟着打两拍,否则重叠区的判断会和数据错位一个像素——表现出来就是接缝左边一列用错了权重,颜色突兀地跳一下。
另外inv = 255 - alpha这个减法用的是 8bit,而alpha最大 254,所以inv最小是 1,永远不会出现“左权重为 0、右权重为 255”的完全切换。这在数学上是有偏的,但物理上恰恰是需要的:完全切换会让接缝处出现硬边,保留一点点交叉反而让过渡更连续。
4.3 输出时序与行缓存对齐:两个读通道怎么不打架
输出必须是标准视频时序,才能直接接 HDMI 发送芯片。1152×480 不是 VESA 标准分辨率,常见做法是套用 1280×720@60 的时序参数,把有效区限制在左上角 1152×480,剩余部分填黑。这样发送芯片的 PLL 配置、TMDS 时钟都不用改。
| 参数 | 数值 | 说明 |
|---|---|---|
| 像素时钟 | 74.25 MHz | 720p60 标准 |
| 行总周期 | 1650 | 有效 1152 + 消隐 498 |
| 行同步 | 40 | 负极性 |
| 行后沿 | 220 | 有效区起点 |
| 场总周期 | 750 | 有效 480 + 消隐 270 |
| 场同步 | 5 | 负极性 |
| 场后沿 | 20 |
真正麻烦的是重叠区的双通道对齐。左路读请求和右路读请求是分别发起的,DDR 控制器返回的延迟取决于当时的仲裁状态,两路数据到达时间可能差几十个时钟。如果直接送进融合模块,α 加到了错误配对的像素上,接缝区会产生重影。
我一般会在两路各放一个深度至少 64 的同步 FIFO,写入侧由各自的tvalid驱动,读出侧由pix_clk域的“双路都非空”信号同时拉高rd_en。这样两路数据被强制以同一个节奏吐出,延迟差被 FIFO 的水位吸收掉。FIFO 深度按最坏延迟差估:DDR 控制器在满载时,单次读延迟可能到 40~60 个ui_clk周期,按 100MHz 折算成pix_clk域的 74.25MHz 大约 55~80 拍,取 128 深比较稳妥,代价是两片 128×16 的分布式 RAM,可以接受。
还有一个容易忘的点:右图的读地址在重叠区是x_out - 512,这个值在x_out还没进入重叠区时是无效的,但由于 FIFO 的存在,读请求会提前发出。所以地址生成模块必须在x_out到达RIGHT_X0 - LOOKAHEAD时才启动右路预读,LOOKAHEAD等于 FIFO 深度对应的像素数。这个预读窗口没做对,表现是重叠区右半段的图像整体向左偏移,偏移量正好等于预读窗口大小。
5. 避坑与排查:接缝错位、条带、撕裂和溢出
这一章全是血泪经验。上面每个模块单独看都没问题,凑到一起上板就是各种诡异现象,下面五条是我实际遇到过、并且花了时间才定位的。
5.1 接缝稳定错开 1 个像素
现象:拼接完成,接缝处的物体轮廓明显错位一列,而且每次上电都错同样的量,不是随机的。
原因:右图读地址比左图少了 1。根源在 FIFO 的读延迟——左路 FIFO 是先写先读、右路也是,但两路的写起始时刻差了一个 pclk(de信号在拼接模块里的对齐方式不同),导致右路数据整体晚了 1 拍。因为延迟是固定的,所以错位也是固定的,看起来像“算法问题”,其实是时序问题。
解决:不要试图在读地址上加 1 去凑,那是治标。正确做法是给两路 FIFO 的写使能加同一个frame_start对齐信号,在每帧第一行第一个有效像素处同时复位两个 FIFO 的写指针。如果做不到帧级对齐,至少用href上升沿做行级对齐,让两路的行起点锁定在同一拍。
5.2 融合区出现等间距竖条纹
现象:重叠区能看到间距 4 像素左右的细竖纹,纯色背景(比如白纸)下特别明显,换成纹理丰富的画面就看不出来了。
原因:打包 FIFO 的位宽转换把 4 个像素合成一个 beat,读回来拆包时如果拆分顺序和打包顺序不一致,就会形成周期为 4 的图案。这个错误极其隐蔽,因为在有纹理的图像上,4 像素的周期和人眼不敏感的高频细节混在一起,很难察觉。
解决:把读写顺序写成对称的。写侧是{buf[47:0], dout}左移,读侧就必须是{beat[15:0], beat[31:16], beat[47:32], beat[63:48]}这样从低 16 位开始取。上板前先做一个纯色测试图(摄像头盖住镜头,让整幅图是均匀的灰色),竖纹会立刻暴露。
5.3 画面上下撕裂,像被横向切开
现象:画面中有一条水平线,线上下的内容来自不同帧,位置随机跳动,运动物体经过时特别明显。
原因:读写共用一块缓存,写指针跑到一半时读指针正好读到了这一帧的新数据。本质是缺少帧级双缓冲。单缓冲下,即使读总是“落后”写半帧,只要读写速率有任何微小差异,累计起来就会追尾。
解决:开两块缓存做 ping-pong。写侧在vsync到来时切换写块并翻转frame_id;读侧只在frame_id变化时切换读块,且切换点必须落在场消隐期。切块瞬间的那一帧画面会重复一次,但不会撕裂。代价是缓存翻倍(1.2MB → 2.4MB),DDR 容量通常不是瓶颈,值得。
5.4 跑几分钟后花屏,复位一次又好了
现象:刚上电一切正常,跑三五分钟后画面开始出现随机色块,断电重启恢复。
原因:九成是 FIFO 溢出。写侧丢数据不会立刻花屏,因为丢的是零星像素,但丢的像素会导致 beat 打包错位——少一个像素,后面所有像素的 16bit 边界全错,画面就变成一片色块。drop_cnt计数器如果在这时候非 0,基本可以确诊。
解决:先看drop_cnt。如果确实在涨,说明存储侧带宽不够,优先查三件事:AXI 突发长度是不是只有 1(说明地址没对齐,控制器拒绝了长突发)、写通道有没有被读通道长时间占住(加仲裁优先级)、DDR 刷新周期配置是否过密。另一个隐蔽原因是异步 FIFO 的格雷码指针同步级数不够,跨时钟域时出现亚稳态,这个只能靠加两级同步寄存器解决。
5.5 综合后时序不收敛,报告一片红
现象:功能仿真全过,上板也能跑,但时序报告显示 74.25MHz 下建立时间违例,换一块板子就黑屏。
原因:融合模块的组合路径太长。乘法、加法、移位、选通全串在一拍里,逻辑级数轻松超过 30 级,在低端器件上跑不到 74MHz。
解决:按 4.2 节那样切成两级流水,乘法一级、加法移位一级,中间插寄存器。另外把alpha的生成也打一拍,别让它直接进乘法器。如果还差一点,把选通逻辑(in_ov ? pix_blend : ...)单独放一级,代价是整体延迟多两拍,用 FIFO 的预读窗口补偿回来即可。
6. 上板验证:用棋盘格和残差统计把「看着对」变成「量出来对」
眼睛是最不可靠的验收工具。同样一幅拼接图,有人说接缝偏左一像素,有人说没有,争论一天没结果。我的办法是三步量化验证,每一步都输出一个数字。
第一步,旁路融合,把左右两路分别单独打到屏幕上,用一张棋盘格标定板同时进入两个摄像头的视野。第二步,抓一帧左图和右图的重叠区,在 PC 上用脚本搜索整像素偏移,把dx/dy写回参数文件重新综合——这一步能把机械安装的误差吃掉大半,剩下的交给像素级微调。第三步,算融合后的残差:重叠区|L - R|的均值应该随 α 平滑过渡,如果某个位置突然跳变,说明权重生成有断层。
import numpy as np def best_offset(left, right, search=8): """在 ±search 内搜索右图相对左图的整像素偏移,返回 (SAD, dx, dy)""" h, w = left.shape best = None for dy in range(-search, search + 1): for dx in range(-search, search + 1): # 避开边缘 search 像素,防止越界 a = left[search:h-search, search:w-search].astype(np.int16) b = right[search-dy:h-search-dy, search-dx:w-search-dx].astype(np.int16) sad = np.abs(a - b).mean() # 平均绝对差,越小越对齐 if best is None or sad < best[0]: best = (sad, dx, dy) return best def residual_curve(left, right, overlap=128): """沿重叠区逐列计算残差,用于检查权重过渡是否连续""" l = left[:, -overlap:].astype(np.int16) r = right[:, :overlap].astype(np.int16) return np.abs(l - r).mean(axis=0) # 长度 = overlap 的一维曲线best_offset用 SAD(平均绝对差)而不是均方误差,是因为 SAD 对个别坏点不敏感,标定板上偶尔有几个过曝像素也不会把结果带偏。搜索范围给到 ±8 就够了,机械安装误差超过 8 像素说明支架本身有问题,靠算法补不回来。residual_curve输出的那条曲线直接画出来看:理想情况下它应该是一条平滑的 U 形或者单调曲线,如果中间出现尖峰,就去查那一列的读地址是不是跨了突发边界。
从那以后我每次改完拼接参数,都会强制走一遍“关融合 → 抓图 → 算 SAD → 看残差曲线”这套流程,哪怕只改了一个localparam。数比眼睛诚实,尤其是接缝这种只差一两个像素的活。希望帮到你。
本文还有配套的精品资源,点击获取