ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FPGA视频字幕叠加实战:像素坐标、字模ROM与时序对齐全解析

FPGA视频字幕叠加实战:像素坐标、字模ROM与时序对齐全解析 前阵子做了一台视频采集盒客户要求在输出画面上叠加“REC”字样和一行静态日期信息。需求听起来非常简单无非是在HDMI或者SDI的输出层上盖几个白色字符。真正动手之后才发现从像素坐标到字模ROM从行场同步到时序收敛几乎每一步都有坑。这篇文章就把我从设计到仿真、最后上板调试的完整过程记录下来重点不是贴完整工程而是说清楚每个坑的成因、现象和解决方法希望能给正在做类似FPGA图像处理项目的朋友省几天时间。1. 先搞清楚叠加的位置像素、行场时序和“画布”坐标1.1 叠加的本质是逐像素二选一视频叠加静态字幕说白了就是一个像素选择器在每一行有效像素的扫描过程中判断当前像素坐标是否落在字幕区域如果落在区域内就输出字幕的颜色否则原样输出视频像素。这句话说起来轻巧但很多新手会把它理解成“往帧存里写字”。如果是带着操作系统的SoC方案那确实是往显存里刷一片区域但纯FPGA裸逻辑处理实时视频流时根本没有一帧完整图像可以被随意读写。视频数据是按像素时钟一个点一个点从接口进来的我们必须在这个持续流动的流水线上做替换。我犯过的第一个错误是试图用“整帧存储CPU修改”的思路实现结果发现纯逻辑需要外部DDR读写控制器、帧同步缓存、总线仲裁整个工程复杂度翻好几倍。后来老老实实改成逐像素实时判断才意识到静态字幕场景下根本不需要帧缓存只需要一块很小的字符ROM和一套精准的坐标计数器。1.2 用像素计数器和行计数器定位字幕区域做逐像素叠加第一步是把输入视频的行场同步信号转换成坐标。这里说的坐标不是内存地址而是当前像素在画面中的二维位置横向位置 x_pos 从行有效区的第一个像素开始递增纵向位置 y_pos 从场有效区的第一行开始递增。TPGTest Pattern Generator类IP通常会在数据握手信号之外额外输出 x 和 y 坐标但真正接入HDMI、SDI时我们手里往往只有 data_valid、hsync、vsync 三个信号。我当时的做法是写一个时序解码模块always (posedge clk) begin if (!vsync) begin y_cnt 0; x_cnt 0; end else if (de) begin if (hsync) x_cnt 0; else x_cnt x_cnt 1b1; if (hsync_pulse) y_cnt y_cnt 1b1; end end这里的“de”是数据有效标志hsync 在有些协议里是脉宽极低的同步信号有些则是每行的有效指示。务必先确认接口手册里 hsync 的真实极性我在这上面因为不区分“同步脉冲”和“行有效”而吃过亏后面章节会细说。有了 x_cnt 和 y_cnt判断是否落在某个字符区域就非常直白wire char_visible (x_cnt CHAR_X_START) (x_cnt CHAR_X_END) (y_cnt CHAR_Y_START) (y_cnt CHAR_Y_END);这里的 CHAR_X_START 等参数就是字幕框在屏幕上的位置我通常把它们做成模块参数或者寄存器方便上板后微调。1.3 不同分辨率下的时序参数对照不同视频分辨率的行场参数差异很大直接影响去抖和计数逻辑。这里列几个实际调试时用过的参数注意 blanking 部分是决定坐标计数器是否稳定的关键。分辨率像素时钟行总数有效行列总数有效列Sync脉冲位置1920x108060148.5 MHz1125108022001920H: 88-128, V: 0-41280x7206074.25 MHz75072016501280H: 88-128, V: 0-5640x4806025.175 MHz525480800640H: 96, V: 2同一块字幕叠加模块要跑多种分辨率时这些参数不能写死最好做成只读寄存器由软件配置。早期我在 1080p 上验证没问题切到 720p 之后字幕整体向右下偏移后来才发现是计数起点没有跟随 blanking 变化导致 x_cnt 没有从有效区起点开始计数。2. 字幕数据从哪来取模、字库ROM和像素时钟的对账2.1 从BMP到COE字模的生成与排布静态字幕第一件麻烦事是“字模怎么来”。我试过用Python PIL库直接提取BMP像素然后把每个字符截成8x16的位图逐行转成二进制。对于只显示“REC”这类简单字符也可以直接用字库工具生成。我推荐的做法是先用本机字体渲染出字符位图统一归一化成8x16或16x32等尺寸然后按行列扫描输出16进制宽度的数据流。比如一个8x16字符每行一个字节R 的示例字模8列 x 16行 0x00, // 第0行全透明 0x7E, // 0111 1110 0x81, // 1000 0001 0x81, // 1000 0001 ... 0x00,这里最关键的是“位序”和“颜色极性”。取值模工具时有的工具按MSB在左有的按LSB在左如果和ROM读取时移位方向不一致字符会呈现左右镜像甚至乱码。我踩过最狠的一次是取了MSB在左的字模但在RTL里按LSB读取导致字母“R”变成了一团完全无法识别的符号。解决方式很简单在testbench里用 $display 打印字模数据人工对照位图检查一遍不要省这一步。2.2 双端口BRAM和字符行缓存字符ROM在FPGA里通常放在Block RAM里。一个8x16字符用16个字节的存储按128个字符算只有一个2KB量级完全没必要用DDR。但要注意如果整屏字幕包含几十个字符而每个字符都要在像素时钟上连续读取ROM端口会不够用。我最终采用的是双端口BRAM端口A在空闲时由微控制器或逻辑写字模数据端口B在视频像素时钟读取。这样字模更新和字符显示互不干扰。读取地址由“当前显示字符索引”和“当前行内像素序号”组成// 像素时钟下读取ROM assign rom_addr {char_index, y_in_char[3:0], x_in_char[2:0]};一个字符像素扫描需要16个时钟8列x16行? 实际是每行8像素共16行但实际视频像素时钟永不停歇一行里如果连续显示N个字符就要在N*8个像素位置连续提供数据。BRAM读端口在时序上有一个延迟周期所以字幕像素流要比视频像素流晚一个周期我在流水线里人工对齐了两路数据。2.3 带宽估算为什么静态字幕不应该碰DDR很多人一听到“视频叠加”就想到DDR帧缓存。实际上对静态字幕而言每帧需要输出的字幕像素数据量非常小。以1920x108060为例像素时钟148.5MHz如果字幕区域占200x30像素单色1bit则每帧字幕数据量只有 200x30/8750字节即使加上半透明混合逻辑也远在BRAM带宽之内。真正需要的只是跟上视频像素流每个有效点做一个判断。反过来如果坚持用DDR缓存你需要处理帧同步、行缓存、写地址跨越等问题。我见过很多项目因为过度设计把简单叠加干成了复杂的存储系统工程最后调试周期至少多两周。所以我的原则是先问自己“字幕内容多久变一次”如果只变一次或者变化频率极低就别碰DDR。3. RTL实现里真正值得花时间的地方3.1 黑白叠加和Alpha混合先做1bit还是8bit有段时间我纠结要不要给字幕加半透明效果就是黑色竖条背景上透出底层视频那种。显然1bit单色只能做“全替换”不能做透明度渐变。静态字幕最常见的需求是白字黑底或者描边字这两种都能用1bit字模少量邻域判断实现。我第一版用1bit字模字符像素为1时输出白色为0时在原视频像素基础上压暗一点点视觉效果接近黑底。如果想做真正的alpha混合则字模需要带8bit灰度或每个字符独立透明参数输出逻辑变成always (posedge clk) begin if (char_visible) rgb_out (char_rgb * alpha) (video_rgb * (8d255 - alpha)); else rgb_out video_rgb; end这里立刻引出两个坑一是乘法器数量8bit alpha加三通道就是3个8x8乘法器在像素时钟150MHz下很容易成为时序瓶颈二是alpha运算后的最终像素位宽是否还是8bit需要做截断或饱和。我建议除非UI明确要求透明背景否则第一版只用全替换简单、稳出错容易查。3.2 坐标比较的时序路径一场关于打拍的战争前面说用 x_cnt CHAR_X_START 这种方式做判断听起来毫无难度但实际在148.5MHz下字幕区域判断逻辑是组合逻辑加上ROM地址、字模位提取、像素替换逻辑很容易形成一条很长的组合路径。综合一次后时序报告显示 slack 为负数时我是有点懵的一个“equal”判断怎么可能时序违例原因在于 x_cnt 和 y_cnt 是计数器CHAR_X_START 若又是寄存器的实时值那么比较器输入距离触发器输出非常近后面 ROM 地址和位选又加了不少逻辑整体路径就超了。解决办法有两个把坐标比较结果作为中间寄存器打一拍ROM读出再打一拍允许字模像素比视频像素晚一两个周期后用 FIFO 或延迟寄存器对齐。把字幕区域起始坐标做成固定参数用常量比较路径会短很多。如果不要求运行时动态调整字幕位置强烈建议用参数而不是寄存器。后来我干脆做了一个流水线把像素通道上所有处理环节统一延迟对齐// 第一拍比较 reg char_visible_r; always (posedge clk) char_visible_r char_visible; // 第二拍读ROM reg [7:0] pixel_code_r; always (posedge clk) pixel_code_r pixel_code; // 第三拍替换输出 reg [23:0] video_rgb_r; always (posedge clk) video_rgb_r video_rgb; always (posedge clk) rgb_out char_visible_r ? char_color : video_rgb_r;这样每一级路径都被截短时序问题直接消失。3.3 复位、消隐区和H/V Blank的边界处理这是仿真里很难暴露、上板却让你找半天的问题。HDMI/SDI这类接口的输入数据流中de有效区域内才是可见图像blank附近的数据可能是黑电平或者边带数据。如果 RTL 里的 x_cnt 和 y_cnt 没有在消隐期严格复位或清零叠加模块会把字幕图像错误地画到 blank 区域里结果在屏幕边缘出现流动的噪点。我的做法是在每行 hsync 脉冲出现后的第一个 de 有效像素处 x_cnt 清零在每场 vsync 脉冲后 y_cnt 清零同时计数只在 de 有效时递增。如果你接的信号源 hsync 极性是反的必须先在顶层取反。另外复位信号尽量不要直接异步清零整个像素计数器除非寄存器完全处于静止状态。我习惯用异步复位、同步释放的复位模块避免多路计数器复位不同拍导致图像位置偏移。4. Testbench不是随便写写仿真阶段踩出的四个坑4.1 造一个符合协议的视频源Task很多同学仿真时用一个很短的memory初始化来模拟视频流比如 8x8 像素的退化图像。这种环境对模块握手验证够用但对字幕叠加这种“坐标敏感型”模块远远不够。至少得按真实分辨率产生有完整 blanking 的行场时序否则你根本验证不了 x/y 计数的正确性。我写了一个testbench任务按1280x72060的参数循环生成像素流task send_line(input [23:0] pixel, input [31:0] line_pixels); repeat (H_FRONT H_SYNC H_BACK) begin de 0; hsync (i H_FRONT) (i H_FRONT H_SYNC); (posedge clk); end repeat (line_pixels) begin de 1; data_video pixel; (posedge clk); end repeat (H_FP) begin de 0; hsync 0; (posedge clk); end endtask这里“line_pixels”就是有效列数。每次调用前先判断当前是同步头还是普通行并把行号传给叠加模块。造时序的时候最忌讳拍脑袋用短分辨率因为计数器边界和溢出逻辑只有在真实分辨率下才会触发。4.2 用Assertion让字幕错误当场现形肉眼盯波形是最容易漏问题的。我吃过一次大亏字幕区域边缘的像素在交替两帧之间闪烁波形上只有几个信号在高频翻转人眼根本看不过来。后来我改成在testbench里加断言直接用系统函数检查输出像素是否符合预期property p_subtitle; (posedge clk) disable iff (!rst) (char_visible_r) |- (rgb_out CHAR_COLOR); endproperty assert property (p_subtitle);这个断言保证“当字幕像素可见那一拍RGB输出必须等于字幕颜色”。凡是字幕区域有漏像素仿真自动报错能迅速定位是哪一级流水线没有对齐。断言别等到出问题时再加写RTL的时候顺手放进去可以省去大量肉眼排查时间。4.3 波形里的X态到底是谁的锅Modelsim/Vivado仿真里看到红色波浪线或者X态时不要急着怀疑综合出问题绝大多数是testbench自己造成的。我在验证时遇到过一个经典案例BRAM没有初始化所有字模数据都是X于是字幕区域输出变成了X态仿真报错。解决办法是在testbench里用 $readmemh 把字模文件读入并在复位释放之前完成初始化。如果BRAM里的数据可能被使用者修改还需要专门做初始化复位逻辑。另外两级寄存器之间如果有一个异步信号没有同步器仿真波形也可能出现X态。凡是跨时钟域信号testbench里必须模拟真实同步器结构否则验证结果不具备参考价值。4.4 别小看后仿真延迟模型带来的新问题前仿真只验证功能逻辑时序路径延迟是看不到的。综合后或布局布线后仿真里寄存器输出会带有 clk-to-Q 延迟、组合逻辑延迟、BRAM读延迟等很多“功能正确但时序不对”的问题会在这里暴露。我在后仿真时发现过ROM读数据比预期晚了一个周期导致字幕和背景错位。原因是BRAM IP核有可选的输出寄存器前仿真隐藏了这个延迟后仿真才真实体现。解决办法是统一用寄存器输出并重新对齐流水线。所以建议做后仿时先查每个存储IP的延迟配置别想当然指望和前仿一致。5. 上板以后仿真和现实的“温差”在哪5.1 跨时钟域FIFO深度不够导致字幕抖动静态字幕虽然内容固定但视频源如果有两个时钟域比如HDMI接收端输出的像素时钟和显示端像素时钟不是同一个PLL锁出来的就需要异步FIFO把视频数据搬过去。字幕叠加模块如果放在显示端字模ROM数据却由接收端寄存器配置就可能出现一个跨时钟域的点。第一版我把字模ROM挂在接收端时钟域显示端直接读异步FIFO给视频数据预留了深度512x24bit。实测发现字幕偶尔出现整行错位抖动查了很久才意识到虽然字模ROM内容只在开机时更新但显示端读ROM的地址总线上存在亚稳态可能。更稳妥的做法是在字模ROM后加一个“地址同步到显示时钟域”的处理步骤或者干脆把ROM整个放进显示端时钟域更新时通过同步器写入。如果像视频流FIFO一样写入端时钟和读出端时钟长期不完全一致FIFO深度至少要能容纳一行有效像素的抖动而不是只看FIFO平均带宽。我后来把深度改成2048问题消失。5.2 ILA抓不到信号先检查触发条件上板调试时我习惯用ILA观察行场计数器和字模ROM输出。第一次触发时波形总是空的设置成更宽触发行号才抓到。原因很简单叠加模块坐标计数器在未锁定时比如HDMI未接入有效信号一直复位在待机状态根本不会命中触发条件所以看起来像是逻辑不工作。正确的调试顺序是先看配置寄存器读写是否OKILA中把配置状态加进去。再抓 vsync 和 hsync 的极性确认计数器确实在跳变。最后才抓字幕区域内部的叠加信号。5.3 屏幕上“花斑”可能来自未复位的BRAM我还遇到过一种现象字幕区域里偶尔出现随机亮斑位置和内容完全不是预期的字符。排查了很久最后发现是BRAM初始化的问题——字模ROM使用的是“默认初始化”而不是“显式初始化文件”部分BRAM里的数据在上电后是未知的字幕区域就把这些混乱数据当成了像素。这类问题仿真往往看不出来因为testbench里 $readmemh 已经把数据写好了。解决方法是把字模ROM配置成初始化COE文件并在复位逻辑里对关键存储模块做显式复位写入不要依赖FPGA上电默认值。调试到这一步字幕已经稳定出现在画面左上角和视频背景完全同步。回想整个过程真正麻烦的地方不是“叠加”本身而是像素坐标、字模位序、跨时钟域和流水线对齐这些细节。6. 模块化改造从REC字样到可换内容字幕6.1 预留字库索引和坐标参数如果产品后续要从“REC”扩展到日期、时间或其他文字字符ROM里肯定不能只放一两个字符。我建议从一开始就保留字符索引参数比如用8bit ASCII码作为字模ROM高地址显示逻辑根据ASCII索引查找。这样软件侧只要往一个寄存器文件里写入ASCII码和显示坐标字幕模块就能动态切换内容。寄存器接口用简单的AXI-Lite或者SPI都行关键是字模ROM要足够大至少要覆盖常用ASCII字符和数字。我当时预留了128个字符8x16字模只占两个BRAM块资源压力很小。6.2 把行缓存参数化如果未来还想做两行以上的字幕显示逻辑要能同时处理多个字符行。处理方式是在每行有效像素时循环访问多个字符行索引或者每个字符行对应一组坐标比较器。无论哪种顶层参数最好直接以字符行数、每行字符数、字号为参数方便综合时复用module subtitle_overlay #( parameter N_CHAR_LINES 2, parameter CHARS_PER_LINE 40, parameter CHAR_W 8, parameter CHAR_H 16 )( ... );6.3 后续扩展思路这套模块的滑动滚动字幕改造也很顺只要把显示区域内的字符索引每隔N帧移位一次就能实现简单滚动。更复杂一点的做法是加一个定时器触发异步FIFO读地址变化让字幕内容随时间更新。但无论怎么扩展设计时守住的底线是视频通路上的流水线节奏不能被字幕逻辑打断。所有坐标比较、ROM读取和像素替换都在同一像素时钟下推进绝不在视频路径上插入等待周期否则要么图像撕裂要么字幕错位。这是整个项目里最值得记住的经验。我个人的体会是FPGA图像处理项目里的大多数“难啃的骨头”不是算法有多高深而是每个模块之间的时序约定和字节序约定。字幕叠加看起来是个小功能却把像素坐标、存储读时序、跨时钟域和数据对齐全串了一遍。如果你也在做类似需求我建议第一天就把字模位序和行场计数的极性确认清楚至少能避开一半的坑。
返回列表