
项目标题本身已经把关键信息说得很明白了——FPGA、视频叠加、仿真、踩坑。这类工作说难不难说简单也不简单静态字幕叠加说白了就是在视频流上按位置覆盖一层字符位图但只要你拆开做一遍就会发现时序、存储、混合、跨时钟域、仿真验证几个环节都有各自的暗坑。我这次做的是一个把OSD字幕叠到摄像头输入视频流上的小项目从字符取模到上板调试踩了不少坑把设计到仿真的完整过程整理出来希望对正在做FPGA图像处理的同学有实际帮助。1. 方案选型为什么静态字幕也要认真对待1.1 静态字幕叠加的本质与两套实现路线视频叠加字幕行业里一般叫OSDOn-Screen Display本质就是把字符像素混合到视频像素流中。静态字幕和动态菜单的区别是字幕内容在整个显示期间基本不变不需要频繁刷新的OSD引擎也不需要有复杂的焦点管理和图层切换结构可以简化很多。实现路线有两条一种是在视频数据通路里直接做像素替换另一种是做Alpha混合。像素替换的逻辑最简单当扫描位置落在字幕区域内直接根据字模数据输出前景色或背景色其余像素原样透传。Alpha混合则是给字幕层加透明度output_color alpha * font_color (1 - alpha) * video_color静态字幕绝大多数情况下用像素替换就够了尤其是需要高可读性的监控叠加、工业显示场景。Alpha混合适合半透明悬浮字但会引入乘法器和额外的组合逻辑在时序紧张的项目里要谨慎使用。我这次做的是像素替换方案一是因为底层的视频流本来背景就偏暗硬切字符对比度高二是不想在像素时钟上再插流水级。为什么说静态字幕也要认真对待因为很多人觉得静态就等于简单实际上它还是要处理逐像素的扫描位置判断、字模地址计算、行场同步对齐、消隐保护等逻辑。任何一个环节出错屏幕上的表现就是字幕位置漂移、边缘花屏、或者整片区域花色。静态只是指内容不变时序任务一点没少。1.2 架构与模块划分模块划分是这种小项目的骨架。我建议至少拆成这几块字符取模工具或脚本负责把目标字符转成位图数组生成COE文件字符ROM用Block Memory Generator生成存放所有字模字幕控制寄存器决定字幕起始位置、颜色、使能扫描位置发生器在DE有效区域内产生X/Y坐标叠加混合器根据坐标和字模做像素替换时序同步模块把hsync/vsync/DE原样打拍输出这个架构的好处是各模块职责清晰仿真时可以单独验证ROM读取和混合逻辑。字符ROM用BRAM就够静态字幕一般不涉及大量字符几百个字节就能放完。如果项目里字幕很多且需要切换多页内容可以升级成DDR缓存或外部Flash但大多数场景下BRAM是成本最低的选择。1.3 关键参数分辨率、像素时钟与坐标计算做视频叠加之前先把手上的视频时序参数算清楚。我这次用720p的输入源但设计调试时先用1080p的时序模型仿真坐标逻辑一样。这里用一个通用例子说明假设按1920x108060Hz像素时钟约148.5MHz一行有效像素1920个一帧有效行1080行。字幕位置的计算公式很简单字幕起始X (图像宽度 - 字符总宽度) / 2 字符总宽度 字符数 * 字符点阵宽度 (字符数-1) * 字间距 字幕起始Y 指定的顶部偏移或垂直居中的计算值例如要在屏幕上居中显示Hello共5个字符字符点阵宽16像素、高32像素字间距4像素那么字符总宽度为516 44 96像素。起始X就是(1280 - 96)/2如果行宽按1280的话是592。这里的核心思想是把屏幕坐标映射到扫描计数器中每一行像素从0计数到1919每一帧行数从0计数到1079一旦当前X和Y落在字幕区域就使能地址生成逻辑从ROM中取出对应行的像素位。2. 核心模块设计与逻辑细节2.1 字符位图生成与COE文件格式字符取模我是先写Python脚本做的没有用现成的取模软件。因为脚本能直接生成出我要的COE格式中途调整字体、颜色、大小也方便。思路是先把字符渲染成二进制位图再按字模行写入文件from PIL import Image, ImageDraw, ImageFont def char_to_bitmap(char, font_size16, width8, height16): img Image.new(1, (width, height), 0) draw ImageDraw.Draw(img) font ImageFont.truetype(simhei.ttf, font_size) draw.text((0, 0), char, fontfont, fill1) rows [] for y in range(height): byte 0 for x in range(width): if img.getpixel((x, y)): byte | (1 (width - 1 - x)) rows.append(byte) return rows上面这段是8位宽字模每一行一个字节高位在左。生成后按顺序写入COE文件格式如下memory_initialization_radix16; memory_initialization_vector 00,00,10,28,28,44,44,82, fe,82,82,82,82,00,00,00;取模的关键坑是高位在左还是高位在右。如果你的字模数据按高位在左生成那么在Verilog里取像素位就要用data[7 - x]而高位在右则是data[x]。这个方向一旦搞反屏幕上的字就是镜像的而且你在仿真里扫描坐标根本看不出来必须拉实际画面才知道。2.2 字幕叠加逻辑与坐标同步视频扫描坐标发生器是叠加逻辑的地基。做法是在DE有效时用像素时钟驱动X计数器X计满一行后Y加一always (posedge pixel_clk or negedge rst_n) begin if (!rst_n) begin pixel_x 16d0; pixel_y 16d0; end else if (de_i) begin if (pixel_x H_ACTIVE - 1) begin pixel_x 16d0; if (pixel_y V_ACTIVE - 1) pixel_y 16d0; else pixel_y pixel_y 16d1; end else pixel_x pixel_x 16d1; end end注意这里用的是de_i作为门控信号不是hsync或vsync。原因是DE本身已经代表了有效视频区间直接用DE作为坐标基准能天然对齐数据省去了消隐期的复杂判断。每一次DE有效表示一行开始这样X和Y就跟实际画面严格对应。字幕叠加判断的核心逻辑是区域命中。假设字幕显示区域为X_START到X_START TOTAL_WIDTHY方向同理就在每个像素周期判断wire char_area_h (pixel_x X_START) (pixel_x X_START TOTAL_WIDTH); wire char_area_v (pixel_y Y_START) (pixel_y Y_START FONT_HEIGHT); wire char_area_en char_area_h char_area_v;命中后需要把坐标换算成字模行和字模列wire [15:0] char_index (pixel_x - X_START) / (FONT_WIDTH CHAR_SPACING); wire [3:0] font_row pixel_y - Y_START; wire [3:0] font_col (pixel_x - X_START) % (FONT_WIDTH CHAR_SPACING);其中char_index决定查哪个字符font_row决定读取ROM的第几行font_col决定取该行数据中的第几列像素位。ROM地址可以用char_index * FONT_HEIGHT font_row计算出来。这里有一个容易踩的细节如果字间距不等于点阵宽度font_col的值可能在字符点阵之外这一段应该直接输出背景色而不是让数据落到下一个字符。我在第一次实现时忽略了这一点导致字符间距区出现上一字符的尾部像素残留屏幕上看起来就像字符之间有杂色。2.3 混合输出与颜色格式像素替换逻辑本身很简单always (posedge pixel_clk) begin if (char_area_en) begin if (font_pixel) video_o FONT_COLOR; else video_o video_i; // 或输出背景色 end else video_o video_i; end但要注意这里的font_pixel不是ROM直接读出来的字节而是根据font_col从字节中提取的位值wire font_pixel char_area_en (rom_data[FONT_WIDTH - 1 - font_col]);如果RO M数据宽度是8或16这没问题。如果字模宽度不是恰好8的倍数比如7x13就得在取模脚本里补齐为16位生成COE时用16进制表示。我建议直接统一字模宽度为8的倍数省得提取位时还要做掩码。RGB数据的格式也要提前约定好RGB565还是RGB888通道顺序是RGB还是BGR不同开发板和不同视频源差异很大。我的设计里统一转为24位RGB888在输入层做转换在叠加层不管具体格式只做三层通道的并行替换。这样逻辑清晰且综合时不会出现跨字节的位操作错误。2.4 时钟域与复位的关键处理视频叠加里最常见的时钟域问题就是视频源的像素时钟和FPGA系统逻辑时钟不同频。比如摄像头输出30fps的720p像素时钟约74.25MHz而逻辑复位和寄存器配置可能是50MHz或100MHz时钟驱动的。我一开始图省事直接用系统时钟读视频数据和做叠加结果就是字符区域出现在错误位置而且行场抖动明显。后来改成标准的跨时钟域处理输入视频流先进异步FIFOFIFO的读时钟用像素时钟所有叠加逻辑全部挂在像素时钟域里。寄存器配置的信号通过两级同步器打拍到像素时钟域再使用。复位设计也要单独说。很多新手习惯把板级复位信号直接拿来做逻辑的异步复位我踩过坑之后统一改成同步复位处理reg rst_n_sync1, rst_n_sync2; always (posedge pixel_clk) begin rst_n_sync1 rst_n; rst_n_sync2 rst_n_sync1; end wire rst_n_sync rst_n_sync2;这样处理的好处是复位释放不会出现在时序敏感沿附近避免亚稳态同时代码里所有时序逻辑都使用同一个同步复位信号仿真时波形干净综合时也不会因为异步复位产生差异大的布局。对于上板调试来说复位释放多几个周期没关系但亚稳态可是会直接导致随机花屏的。3. 仿真环境搭建与Testbench设计3.1 Testbench需要模拟什么有人会说做个静态字幕叠加直接上板看效果多块但我要说没有仿真直接上板等于盲调。仿真能让你方便地看到每个像素周期里的X/Y坐标、ROM地址、字模数据、输出像素值。这点在波形里一眼就能看出来上板却只能靠眼盯屏。Testbench要生成的真实素材主要有四样像素时钟、行同步、场同步、DE。为了逼近真实视频源我写了一个简单的视频时序生成器localparam H_ACTIVE 1280; localparam H_FRONT 72; localparam H_SYNC 80; localparam H_BACK 216; localparam V_ACTIVE 720; localparam V_FRONT 3; localparam V_SYNC 5; localparam V_BACK 22;然后通过计数器产生hsync、vsync和de。这里的重点在于de必须在行同步和场同步的消隐期内保持低电平否则仿真波形不符合真实视频时序叠加逻辑的边界条件测不到。除了视频时序testbench还要生成图像数据。简单起见可以生成渐变或单色底图比如每个像素输出{pixel_x[7:0], pixel_y[7:0], 8h00}这样在波形图上能看到像素变化也方便判断字幕像素是否成功替换。3.2 Testbench结构性代码与检查逻辑下面是一段简化的视频源发生核心代码供参考wire clk pixel_clk; reg [11:0] h_cnt; reg [11:0] v_cnt; always (posedge pixel_clk or negedge rst_n) begin if (!rst_n) begin h_cnt 0; v_cnt 0; end else begin if (h_cnt H_TOTAL - 1) begin h_cnt 0; if (v_cnt V_TOTAL - 1) v_cnt 0; else v_cnt v_cnt 1; end else h_cnt h_cnt 1; end end assign hsync_i (h_cnt (H_ACTIVE H_FRONT)) (h_cnt (H_ACTIVE H_FRONT H_SYNC)); assign vsync_i (v_cnt (V_ACTIVE V_FRONT)) (v_cnt (V_ACTIVE V_FRONT V_SYNC)); assign de_i (h_cnt H_ACTIVE) (v_cnt V_ACTIVE);然后直接生成叠加输入像素流。启动时复位释放后先等几帧再用$display打印几个关键时间点的值。仿真中最有价值的是做自动检查在DE有效区里如果X/Y落入字幕区域且字模像素为1那么输出颜色必须等于前景色反之应等于背景或原像素。用$error或$warning打印出错位置always (posedge pixel_clk) begin if (char_area_en font_pixel (video_o ! FONT_COLOR)) $error(pixel mismatch at x%0d y%0d, pixel_x, pixel_y); end建议写完Testbench先仿真一帧确认每个模块输出符合预期再跑满多个分辨率对比坐标最后上板前再做一次formal确认。3.3 仿真波形里最容易忽略的信号Modelsim或Vivado Simulator里看不看得到问题关键在于你有没有捞对信号。我通常会把这些信号分组加到波形窗口视频时序组hsync_i, vsync_i, de_i, pixel_x, pixel_y字幕逻辑组char_area_en, font_row, font_col, char_index, rom_addr, rom_data输出组video_o, char_pixel, hsync_o, vsync_o, de_o看波形时第一件事是看de的高电平宽度是否等于H_ACTIVE是否在正确位置拉高。第二件事是看char_area_en在坐标落入字幕区域时是不是准确拉高。第三件事是看rom_data在地址变化后是否在一个时钟周期出正确数据因为BRAM读有延迟需要在取数时对齐一拍。前两点如果不对很可能不是叠加逻辑问题而是坐标基准用了hsync/vsync而不是DE。这个点我在仿真里花了半天才定位到。4. 从设计到仿真的踩坑实测记录4.1 字符镜像和取模方向不一致先说最隐蔽的坑——字模方向。第一次用PCtoLCD2002取模默认设置是逐行式低位在前生成的BIT数据直接放到ROM里结果屏幕上所有字母都是镜像的。因为在读取位时我用的是data[7 - font_col]但数据本身已经是反向排列的。正确做法是在仿真时就把ROM数据文件放到$readmemh或COE文件里然后随机抽几个像素沿X方向递增检查如果发现字符前景像素在X方向呈镜像分布就调整取模方向或读取位的顺序。千万不要指望上板看效果再改那样调试周期会翻倍。4.2 字符位置向右偏移了半个字符这个问题是在仿真里发现的坐标计算显示字幕起始X592但波形里命中位置却从640开始恰好偏了48像素。排查以后发现是RAM读延迟的问题BRAM在地址给出后要一拍才能出来数据而我的判断逻辑在同一个周期就把地址和RD_EN拉起来了导致实际读出的字模属于当前坐标之后的下一个时钟周期——于是所有字符都往前移了一个点阵的宽度。解决办法是在命中判断里把坐标打一拍让读取和判断对齐。相关代码要改成always (posedge pixel_clk) begin rom_addr_req rom_addr; char_area_en_d char_area_en; end这个问题的通用经验是BRAM读延迟最好在模块接口处固定读数据有效信号要滞后地址一拍。在设计阶段把这个约定写清楚后面所有模块引用就不会歪了。4.3 字符边缘出现残影和杂色仿真通过后上板发现字符边缘有很淡的杂色。一开始以为是像素时钟不够高导致的采样问题后来抓波形才发现问题在于字符区域外的第一个像素直接透传了原来的视频像素而原来的视频像素和前景色跳变太大人眼看过去就像有锯齿。处理方式是给整个字符区域加一个平滑过渡。静态字幕不像动态OSD需要复杂滤波只做边缘的2像素抗锯齿就够了。逻辑上可以做简单Alpha混合在字符区的上下左右各留1到2像素过渡过渡像素的前景色权重从1逐渐降到0。这个在仿真里也要造对应的边界测试只测规则区域测不出边缘问题。4.4 Modelsim波形全都是红色X态X态是FPGA仿真里最常见的灾难现场。我排查过三个原因按优先级说复位一直有效检查testbench的rst_n是否在initial块里正确释放很多X态就是复位没释放导致的。有信号没有驱动源比如模块输入端悬空波形不加激励直接显示X和Z。多个驱动源同时驱动同一条线常见于三态总线和多个always块对同一reg驱动冲突。尤其是多个always块驱动同一个变量这个问题综合工具会直接报错但仿真器不会。所以仿真时如果看到莫名的X态先去查是不是代码里有多个always块赋同一个reg再去查testbench端口连接。4.5 上板后字幕区域出现整块花屏这个问题在仿真里绝对不会出现因为仿真相对理想但上板就变成花屏通常是视频数据本身不是稳定的RGB字节序。比如有些摄像头输出的是YUV422或BT656格式直接按RGB格式做叠加当然花。这种问题必须先用ILA抓输入总线的数据确认输入格式后在叠加前先做颜色空间转换。我这次用的输入是标准RGB888没遇到这个大坑但之前做过BT656项目印象很深那种场景下必须做H/V同步提取和VB/DE恢复否则连叠加区域的坐标都没意义。5. 仿真到上板过程中的一些工程细节5.1 仿真通过不等于上板可靠这句话听起来像废话但我是真的吃过亏。仿真里标准视频时序和理想像素时钟上板后却被模拟器件、时钟抖动、时钟相位延迟打破规律。比较典型的例子是外部视频源的de信号和像素时钟之间可能有相位偏移仿真里波形都是对齐的实际却差个两三纳秒。所以设计里一定要对de打拍处理或单独用IDELAY校准。时序约束这块也要重视。叠加逻辑全部挂在像素时钟域那么生成时钟要设create_clock输入输出延时要设set_input_delay和set_output_delay。如果没有约束综合后的时序报告是乱的一堆路径一旦工作频率比较高时序违约是必然的。若你在Xilinx环境里直接用create_clock -name pixel_clk -period 13.468这种方式来约束74.25MHz的周期约13.468ns。5.2 用ILA抓关键信号对比仿真上板调试阶段ILA就是我眼睛的延申。我会把X坐标、Y坐标、char_area_en、rom_data和video_o这5组信号拉出来采样深度512触发条件设为char_area_en上升沿。这样一触发就能看到连续几百个像素周期里字幕区域的状态和仿真波形放在一起比对。一个很高效的排查思路是如果屏幕上字幕位置不对先比对同一时间点的pixel_x和pixel_y是否一致如果不一致则坐标产生逻辑与DE对齐有偏差如果一致则看char_area_en在坐标落区时是否拉高如果char_area_en正常再看rom_data和video_o。按这个链路从源头到输出逐级推进大多数问题半小时内就能定位。5.3 从静态字幕拓展到动态OSD的扩展路径静态字幕做完之后做动态OSD其实不难主要增加两块字符缓冲管理和图层叠加调度。字符缓冲需要支持随机写和随机读这时用简单BRAM直接引进来会够呛建议用AXI BRAM Controller或自带读端口的RAM然后用状态机按扫描坐标去读对应的字模。动态OSD的字符索引需要实时更新可以加一个CPU或状态机接口通过串口或SPI写寄存器。这一套逻辑做下来相当于是给视频通路加了一个小彩蛋层。如果后面还想加图片、图标本质上是把图标当作放大的特殊字符把ROM换成RAM大小超过BRAM范围就不再适合了得切到DDR等其他存储方案。我在实际做完这个项目后的体会是FPGA视频叠加这类任务的难度主要不在看懂Datasheet而在把像素坐标、字模读取、时序对齐这些细节的因果关系彻底理顺。不需要把每个模块都做得多花哨但必须保证每个环节的时序约定一致。若要用一句话总结就是先让仿真把坐标和字模的对应关系测透再上板折腾效率一定最高。