这个系列做到第三篇,前面已经把"图像能显示出来"和"图像能通过SDRAM缓存再读出来"这两件事聊透了。这一篇换个角度:当缩放、移动同时出现在一条显示通路上,它们不是两个独立功能,而是一个坐标变换问题。我在实际做的时候踩了不少坑,最典型的就是单看缩放没问题、单看移动也没问题,一旦两个参数同时改,画面边缘就抖、切参数瞬间还会撕裂。这篇就把我从数学映射到Verilog实现、再到SignalTap实测的完整过程写出来,适合已经跑通VGA显示通路、正在做缩放功能、并且被动态切参和边界抖动折磨的读者。
1. 坐标变换引擎:缩放和移动为什么必须一起算
1.1 显示通路的基本盘:读侧与写侧解耦
先回顾一下通路的整体结构。写侧负责把图像数据写进缓存,读侧负责按显示时序把数据读出来送给屏幕。绝大多数情况下,写侧是固定节拍的,比如一行一行地从采集端或者测试图发生器收数据;读侧则是跟着VGA时序走的,hsync、vsync一拉,像素时钟一拍一个地址往外读。
缩放和移动做在读侧,原因很直接:写侧是"原图进",读侧才是"最终呈现给用户看什么"。只要读侧地址逻辑足够灵活,写侧完全可以不感知缩放和移动的存在,继续按原始分辨率往缓存里写。这样两边解耦之后,后续换摄像头、换分辨率,都不需要动缩放引擎。
但解耦也带来一个问题:读侧处理的是"屏幕坐标到缓存坐标"的映射,一旦这个映射变得复杂,读地址就不能再用简单的行列计数器递增了。
1.2 正向映射的坑与逆映射的好处
很多人第一次做缩放,脑子里的第一反应是"把源图像按比例放大,得到一张新图,再送到屏幕",这就是正向映射。如果只是软件逐像素处理一张静态图,正向映射完全没问题;但放在实时显示通路上,正向映射是个灾难,原因是输出像素和源像素不是一一对应:
- 放大时,源图像里一个像素要映射到输出图像的好几个位置,你得决定中间那些空缺填什么;
- 缩小时,源图像里好几个像素可能挤到输出图像的同一个位置,你得决定谁覆盖谁。
这两个问题在硬件实时流水线里都要用额外的缓冲和判断逻辑去解决,非常别扭。换一个思路,从输出像素反推"这个像素应该看源图像的哪个位置",一切就顺了。显示时序每来一个像素时钟,我们就知道当前屏幕坐标(dst_x, dst_y),通过逆映射函数算出源坐标(src_x, src_y),然后去缓存里读这个位置附近的数据。每个输出像素恰好对应一次读请求,不存在空洞和覆盖问题。
逆映射是这个系列第三篇的核心起点,后面所有代码都围绕它展开。缩放和移动的数学表达式统一写成:
src_x = (dst_x - center_x) / scale + center_x + offset_x src_y = (dst_y - center_y) / scale + center_y + offset_y这里scale是缩放系数,center_x和center_y是缩放锚点,offset_x和offset_y是平移量。注意这个形式里,缩放以锚点为中心,移动在缩放之后叠加,这样"镜头拉近拉远"和"画面平移"两个操作就解耦了。
1.3 移动不是简单加减:缩放下的平移量换算
移动听起来最简单,不就是给坐标加个偏移量吗?真做起来才发现,关键在于"偏移量的单位是什么"。
屏幕上的一个像素,对应源图像里多远,取决于当前的缩放系数。scale=1.0时,屏幕移动1个像素,源图像坐标也动1;scale=2.0时,屏幕移动1个像素,源图像坐标只动0.5。如果直接把用户输入的"屏幕像素偏移"当成"源图像坐标偏移"加进去,你会看到同一个平移按键,在1倍画面下移动速度正常,放到2倍画面下移动速度明显变慢,体验很割裂。
我在设计里把用户的平移量定义为"屏幕像素单位",在硬件里换算成源坐标偏移时需要除以scale。但这又有新问题:除法在FPGA里开销大,而且scale还是在线可变的。与其每次现算,不如把"偏移量/scale"这一步预先算好,再以定点数形式下发到读侧逻辑。后面讲寄存器设计时会详细说这个换算,这里先记住结论:移动量必须结合缩放系数换算,不能直接加。
2. 参数寄存器的定点化设计与跨时钟同步
2.1 缩放因子与锚点的定点表示
缩放系数是浮点数,但FPGA里做浮点运算很奢侈,尤其是这种每个像素都要参与的运算。工程上用定点数就够了。
我的做法是,把scale用Q8格式表示,也就是放大256倍存整数,例如scale=0.75存成192,scale=1.5存成384。每次输出像素更新时,源坐标累加器加的是1/scale,但1/scale不是整数,所以我避免直接做除法,改成预计算一个"步长"。显示一行内,锚点不变化,src_x的递推可以写成:
src_x_next = src_x + (1 / scale) * 2561/scale再乘以256仍然可能不是整数,所以步长本身也做定点近似。精度取到1/256像素,对VGA级别的显示完全够用,肉眼基本看不出量化误差。我把这个预计算放在参数更新模块里,乘除法只在参数变化瞬间各算一次,而不是每个像素算一次。
锚点我用两个正整数寄存器表示,一般就设成显示区域中心,比如640x480下是(320, 240)。不建议把锚点和scale塞进同一个寄存器再把运算拉长,调试起来会很难受。
2.2 参数更新的"帧同步锁存"机制
这是整个第三篇里最值得说的一环。如果你直接把按键变化后的新参数接到坐标变换逻辑上,立刻就能看到画面撕裂:上半屏还是旧的缩放倍数,下半屏突然跳到新的缩放倍数,中间一条明显的分界线。我当时看到这个现象,第一反应是缓存读出的数据错了,排查了半天才发现纯粹是参数更新时机的问题。
原因是VGA扫描是逐行进行的,参数在扫描到第300行时突变,第301行开始用新坐标映射,整个画面就变成新旧两截。解决思路是"帧同步锁存":
- 参数从按键模块进来后,不直接进入坐标变换逻辑,而是先存在一组"影子寄存器"里;
- 坐标变换逻辑使用另一组"工作寄存器";
- 每当vsync(场消隐)到来,把影子寄存器的内容一次性锁存到工作寄存器。
这样参数变化只会发生在两帧之间的消隐期,新的一整帧要么全部用旧参数,要么全部用新参数,不会出现帧内跳跃。实现上是一段很简单的代码:
always @(posedge clk or negedge rst_n) begin if (~rst_n) begin scale_work <= 16'd256; // 默认1.0 offset_x_work <= 16'd0; end else if (vsync_negedge) begin scale_work <= scale_shadow; offset_x_work <= offset_x_shadow; end end注意:这里的锁存条件我用的是vsync下降沿,也就是一帧扫描结束进入场消隐的时刻。有些显示控制器vsync是低有效,有些是高有效,具体取哪个沿要对着时序图确认,常见错误就是把极性搞反导致锁存时机刚好落在有效显示区内。
2.3 用In-System Sources and Probes在板上直接改参数
参数验证如果每次都重新编译烧录,效率太低。Quartus里有个现成的调试IP叫In-System Sources and Probes,可以在线读写FPGA内部寄存器,不需要重新编译。我在工程里挂了两个探针,一个写scale,一个写offset,跑起来之后对着屏幕一边改参数一边看效果,整个调试体验完全不一样。
用法很简单:在Platform Designer(或者老版本的Qsys)里例化In-System Sources and Probes IP,把source端口接在影子寄存器上,综合烧录后通过Quartus的Tools菜单打开In-System Sources and Probes Editor,就能在线输入十六进制值。source的位宽要提前设计好,比如scale那组给16位,offset那组给两个16位拼接。
这里有个小提醒:在线改的是影子寄存器,改完要等下一帧锁存才能生效。你看到的画面变化会比输入值晚一拍,这是正常的,不是卡死了。
3. 核心Verilog实现的三个关键段
3.1 源坐标累加器的写法
坐标变换虽然有两个维度,但VGA扫描天然是"先从左到右,再从上到下",所以我可以把src_x做成每像素累加,src_y做成每行累加。假设一行有效像素是H_ACTIVE,我的读侧核心逻辑大致长这样:
// 每像素时钟:x方向步进 always @(posedge pix_clk or negedge rst_n) begin if (~rst_n) begin src_x_acc <= 16'd0; end else if (line_start) begin src_x_acc <= src_x_start; // 行首装入经过锚点换算的起始坐标 end else if (pixel_valid) begin src_x_acc <= src_x_acc + x_step_q; // x_step_q = 1/scale的定点近似 end end // 每行结束:y方向步进 always @(posedge pix_clk or negedge rst_n) begin if (~rst_n) begin src_y_acc <= 16'd0; end else if (frame_start) begin src_y_acc <= src_y_start; end else if (line_end) begin src_y_acc <= src_y_acc + y_step_q; end end行首的src_x_start和帧首的src_y_start,就是在锚点基础上叠加平移量的结果:
src_x_start = center_x + offset_x_src src_y_start = center_y + offset_y_srcoffset_x_src是2.2节说的"平移量/scale"的定点结果。这样写的好处是,每个像素时钟只需要一次加法,完全不涉及乘法,时序很稳。
3.2 插值开销控制:最近邻与双线性的取舍
坐标变换算出来的src_x和src_y是定点数,小数部分不为0,意味着源图像的采样点落在像素之间。这时候就要决定取哪个像素的颜色。最省资源的是最近邻,直接把小数位截掉,读整数坐标对应的像素。缩放倍数不是整数时(比如0.75倍),最近邻会让画面产生轻微的像素跳动感,边缘会有锯齿,但静态画面下其实可以接受。
双线性插值质量好很多,代价是每个输出像素需要读四个相邻像素、做三次乘加。我最开始在720p时序下直接上双线性,结果时序报告一堆红色。后来优化成两个措施:
- 只在放大倍数超过1.5倍时才启用双线性,缩小场景下最近邻的视觉差异不明显;
- 复用两个行缓冲,把四像素读取变成两行流水读取,避免同一周期并发读四个SDRAM地址。
实际效果是:显示时钟从125MHz降一点就全线满足时序,画质在放大档位下明显变细腻。我建议如果嫌麻烦,可以先只做最近邻,把整套坐标变换和参数同步跑通,再回头加插值。插值是锦上添花,坐标变换才是骨架。
3.3 边界处理与填充色的选择
当画面移动超过源图像边界时,坐标变换引擎会在有效显示区内算出超出范围的src坐标,比如src_x变成-100,或者大于源图宽度。读缓存肯定读不到这种地址,所以必须在坐标变换模块里做钳位判断。
我试过两种方案。第一种是钳位到边界像素,画面到边缘时最后一行像素会被拉伸,观感像"贴边"。第二种是输出填充色,比如黑色或者灰色,观感像"画面移出屏幕边界"。两种都有道理,我最后选择的是:缩放时用钳位,因为放大画面边缘出现黑边会很突兀;移动时用填充色,因为用户移动图片时能看到边界更自然。
实现上就是在读取SDRAM前先判断:
wire out_of_bounds_x = (src_x_int < 0) || (src_x_int >= IMG_WIDTH); wire out_of_bounds_y = (src_y_int < 0) || (src_y_int >= IMG_HEIGHT); wire out_of_bounds = out_of_bounds_x || out_of_bounds_y; // 越界时输出填充色,而不是走SDRAM读通路这里有个容易忽略的点:越界标志必须在请求发出前生效,否则SDRAM已经收到非法地址,可能读回脏数据。我加了一级流水寄存,让地址有效信号和越界标志保持严格对齐,宁可晚一个周期出数据,也不能让非法地址真的发出去。
4. 上板调试:SignalTap实测与常见问题
4.1 改参数瞬间画面撕裂的排查链路
我前面提过帧同步锁存,但我不是一开始就做对的,这里把排查过程完整写一遍,希望能帮人少走弯路。
当时的现象是:按下放大按键,画面中间某一行突然出现跳变,上半屏旧参数、下半屏新参数。我第一反应是SDRAM读地址跨时钟域有问题,于是在SignalTap里挂了读地址和读数据有效信号,发现读侧数据根本没有断流,问题不在缓存通路。
然后又怀疑坐标变换模块里有寄存器没复位干净,挂src_x_acc看了很久也没看出毛病——它确实在每个像素时钟都在累加。最后实在没法子,把锁存的vsync信号和参数工作寄存器一起抓到SignalTap里,才看到参数工作寄存器在显示有效期内某个任意时刻发生了跳变。到这一步答案已经很清楚:参数没有等到vsync再更新。
修复方式就是2.2节那段帧同步锁存代码。修完再用SignalTap验证,可以看到vsync下降沿之后、下一帧有效显示开始之前,scale_work才变化。这样观察一次波形,比看十遍代码都管用。
排错经验:遇到显示异常,先别急着怀疑SDRAM和像素通路,优先抓"哪个信号在行有效期间发生了变化"。显示问题大部分是同步或者时序问题,数据本身往往是好的。
4.2 非整数缩放下的行缓冲与带宽观察
做0.75倍缩放时,源图像的1.333个像素映射到1个屏幕像素,意味着屏幕每3个像素要读4个源像素,读频率是变化的,不是均匀的。在SignalTap里观察SDRAM读请求信号,你会发现请求密度一帧内有波动,这会让缓存仲裁的压力忽高忽低。
如果只是缩放1.0或者2.0这种整数倍,带宽是均匀的,SDRAM基本上没什么压力;非整数倍才是考验。我实测在1280x720@60Hz、0.75倍缩放场景下,SDRAM读带宽峰值明显上涨,但不至于溢出。如果分辨率再往上走,就要考虑把行缓冲做成两行甚至三行,给突发读留出余量。
4.3 一张投入实测的功能验证表
最后放一张我在板子上实际验证过的功能表,每条都是真实跑过的场景。数值基于640x480的输入图像、1280x720的显示时序:
| 测试场景 | 操作方式 | 预期结果 | 实测情况 |
|---|---|---|---|
| 1.0倍静态显示 | 按键切换回1.0倍 | 图像完整无裁剪 | 通过,边缘无锯齿 |
| 2.0倍放大 | 在线设scale=512 | 画面中心放大,四周溢出 | 中心锚点稳定,边界钳位正常 |
| 0.75倍缩小 | 在线设scale=192 | 全图显示,留黑边 | 通过,缩小画质轻微细节损失 |
| 移动至左边界 | 连续减小offset_x | 画面左移出屏幕 | 左边界出现黑色填充,无拖影 |
| 移动至下边界 | 连续增大offset_y | 画面下移出屏幕 | 通过,底部填充色正常 |
| 缩放时移动 | 2.0倍下左右移动 | 平移速度与1.0倍时一致 | 换算后速度一致,无跳跃 |
| 动态切参 | 2.0倍直接切0.75倍 | 整帧切换,不撕裂 | 帧同步锁存生效,无明显分界 |
这张表后来直接成了我的回归用例,每次改完代码都要跑一遍。尤其是"缩放时移动"这一项,最容易暴露平移量换算没除以scale的问题。
这个功能做完之后,我最大的体会是:图像缩放和移动这类需求,在软件里写一个双重循环就够了,但在FPGA上,真正的工作量全在参数同步、坐标量化、边界判断这些"周边设施"上。坐标变换本身反而不是难点。后面如果再往下做,可以考虑把缩放中心做成可控锚点,或者把插值升级成三次卷积,但骨架还是这套逆映射思路。