ARTICLE DETAIL

资讯详情

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

Verilog搭建ISP图像处理仿真框架:图像输入到自动比对全解析

Verilog搭建ISP图像处理仿真框架:图像输入到自动比对全解析 做ISP图像处理IP核开发的朋友应该都有这种体会RTL写起来并不算最难真正让人头大的是验证。尤其是在纯Verilog环境下没有专用的VIP没有现成的图像比对脚本想验证一个简单的坏点矫正模块都得靠肉眼盯着波形图一个个像素数格子。我刚接触ISP流水线的时候被这种低效折磨得够呛后来花了很长时间搭起了一套可复用的Verilog仿真框架才算把这块彻底理顺。这篇就把这套框架的搭建思路、关键代码和踩过的坑完整记录下来希望能给正在做FPGA图像处理或者准备入门ISP pipeline的朋友一点参考。这套框架的目标很明确让写Verilog的工程师也能像做软件一样用“数据进、数据出、自动比对”的方式验证ISP模块。它能解决三个核心问题第一怎么把真实的图片数据喂进Verilog仿真第二怎么把仿真输出的结果变成能看的图像第三怎么用参考模型自动判断RTL功能对不对。适配的人群也很清晰——已经在写Verilog、但苦于验证手段单一的人以及想在FPGA上实现ISP pipeline、但还没想清楚验证路线的入门者。1. 整体设计与思路拆解1.1 为什么非搭这个框架不可在聊具体代码之前先说说这套框架解决了什么痛点。ISPImage Signal Processor图像信号处理器的流水线通常包含坏点矫正、黑电平校正、镜头阴影矫正、去马赛克、白平衡、Gamma校正、色彩空间转换、降噪等一系列模块。每个模块的处理逻辑都不复杂但串联起来之后问题就变得非常棘手信号链太长中间任何一级出错到最后图像上表现出来的症状都可能是“画面偏绿”“有横条纹”这类模棱两可的现象。如果靠传统的仿真方法用$display打印几个像素值出来看在调试单模块时勉强够用。但ISP模块处理的是二维图像数据单看一两个像素根本看不出问题。比如坏点矫正一个像素被判定为坏点之后要拿周围邻域像素做插值你盯着波形图看半天也很难判断插值权重是不是算对了。这时候就必须有一种方法能把整幅图像跑完再把输出结果用肉眼直观地看到最好还能跟一个标准答案自动对比。这套仿真框架的核心价值就在于此把“图像文件读写”和“自动比对”这两个基础设施一次性建好之后每开发一个新ISP模块只需要专注写RTL本身把模块往框架里一插就能立刻得到可视化的输出和量化的误差报告。1.2 框架整体构成与工具链选型先看一下整套框架包含哪些东西。我用的是最典型的四层结构脚本层、数据层、仿真层、分析层。脚本层负责跑仿真、调用工具、生成报告。我习惯用Python写因为它处理图像数据太方便了。数据层存放测试图像。原始传感器数据一般是RAW格式经过ISP之后输出BMP或PNG。仿真层这是核心包含Verilog testbench、被测模块、还有为仿真专门写的辅助RTL比如FIFO模型、寄存器模型。分析层用Python加载仿真输出的图像数据执行参考模型运算计算PSNR、MAE等指标输出对比图。工具链方面主流的方案有几种。如果你用的是Vivado可以直接用自带的xsim优势是和工程集成度高能自动识别IP核。如果追求仿真速度和轻量级Icarus Verilogiverilog加GTKWave是个不错的选择安装包只有几十兆跑命令行脚本非常顺手。我用的是iverilog加Python的组合原因很简单这套框架要频繁地做自动化回归命令行工具比IDE更便于脚本控制而且作为开源工具版本迭代对我们的影响也小。还有一点值得说明这套框架不依赖任何商用VIP所有的图像数据搬运都是自己写RTL完成的所以无论是用Vivado、Quartus还是开源工具链框架的迁移成本都很低。2. 核心细节解析与实操要点2.1 图像数据是怎么喂进仿真的这是整套框架最关键的一环。Verilog本身不直接认识图片文件所以必须用$readmemh或$fscanf这类系统函数把图像数据变成仿真能处理的格式。我的做法是分两步走。第一步用Python脚本把图片转成RTL友好的文本格式。比如一张1920x1080的RAW图每个像素10bit那么我就按行组织数据每行存一个像素值用十六进制写进文本文件。这个文件就是给Verilog用的“原始数据源”。第二步在testbench里用$fopen和$fscanf把文件内容读进来按照行同步、帧同步的时序打进DUT。代码层面文件读取部分是这样的integer file_id; reg [9:0] image_data [0:2073599]; // 1920*1080 initial begin file_id $fopen(../../data/input_raw.txt, r); if (file_id 0) begin $display(ERROR: Failed to open input file.); $finish; end for (int i 0; i 2073600; i i 1) begin $fscanf(file_id, %h\n, image_data[i]); end $fclose(file_id); end这里有几个细节值得注意。第一$fscanf读取的文本必须用十六进制格式且不带前缀否则仿真器解析会出错。第二大数组的定义要放在模块顶层因为在testbench里数组的深度决定了你能处理的最大分辨率。我通常会把这个参数和图像宽高绑定在一起用localparam定义方便换不同尺寸的图。第三为了节省仿真内存跑全分辨率图的时候可以按行流式读取而不需要一次性把整帧全部装进内存。2.2 Testbench架构和像素时序模拟Testbench的架构直接决定了你调试的效率。很多新人写tb喜欢用#10这种固定延时来打数据这在验证简单组合逻辑时问题不大但ISP模块普遍是流水线结构带行缓冲、FIFO、甚至外部DDR读写接口再用固定延时打数据很快就乱套了。我推荐的架构是用独立的时钟生成块配合valid-ready握手协议来传输像素数据。这样最接近真实芯片的工作方式而且任何模块之间的接口都被统一成了AXI-Stream风格后续想复用模块做系统集成会省很多事。// 时钟生成 always #5 clk ~clk; // 100MHz // 握手信号驱动 initial begin s_axis_tvalid 1b0; (posedge clk); for (int i 0; i IMG_HEIGHT; i i 1) begin for (int j 0; j IMG_WIDTH; j j 1) begin s_axis_tdata image_data[i*IMG_WIDTH j]; s_axis_tvalid 1b1; (posedge clk); end // 行结束插入一个周期的无效模拟行消隐 s_axis_tvalid 1b0; (posedge clk); end s_axis_tvalid 1b0; end加入行消隐、帧消隐的模拟是非常重要的一步。一个很常见的坑是模块单独仿真时时序完全正常一接入真实ISP流水线就出问题。原因往往是模块假设数据是连续不间断输入的没有考虑行与行之间的空隙。我在框架里默认就带行消隐和帧消隐的模拟从一开始就强制模块适配真实的视频时序。2.3 参考模型与自动比对机制有了图像数据的输入和输出下一步就是“自动判断对错”。这需要参考模型。所谓参考模型就是用C、Python或者Matlab实现一个和RTL功能完全一致的算法模型作为“标准答案”。对于ISP这种算法密集型场景用Python写参考模型最合适。一方面Python的数值计算库很完善OpenCV、NumPy处理图像数据结构非常方便另一方面Python可以直接读取Verilog仿真输出的文本数据通过脚本完成比对不需要在仿真器里做复杂的断言。一个典型的比对流程是这样的import numpy as np import sys rtl_out np.loadtxt(sim_output.txt, dtypenp.uint16) ref_out np.loadtxt(ref_output.txt, dtypenp.uint16) mae np.mean(np.abs(rtl_out.astype(np.int32) - ref_out.astype(np.int32))) psnr 10 * np.log10((255 * 255) / (np.mean((rtl_out - ref_out) ** 2) 1e-10)) print(fMAE: {mae:.4f}) print(fPSNR: {psnr:.2f} dB)这里有一个非常重要的工程原则参考模型和RTL不要写同一个思路的实现。如果参考模型只是把RTL代码翻译成Python那么RTL里的逻辑错误在参考模型里大概率也会出现。我遇到过一个案例RTL里的滑动窗口滤波模块在计算均值时窗口内像素求和少累加了一个点我把同样的思路写进Python做参考模型两边算出来的结果完全一致但跟真实的理论值差了整整一个像素值最后用Matlab重算了一遍才发现问题。所以参考模型尽量从算法公式推导而不是从RTL翻译。2.4 关键辅助模块FIFO与状态机ISP模块离不开数据缓存和时序控制所以仿真框架里要准备几个常用辅助模块的模板。最常用的是同步FIFO和异步FIFO。虽然商用IP这样很容易获取但仿真框架里我建议自己写一个简化版好处是可控性好波形里每一步都能看得到而且不依赖厂商库。module sync_fifo #( parameter DATA_WIDTH 16, parameter ADDR_WIDTH 8 )( input wire clk, input wire rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] din, input wire rd_en, output reg [DATA_WIDTH-1:0] dout, output wire full, output wire empty ); localparam DEPTH 1 ADDR_WIDTH; reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; reg [ADDR_WIDTH:0] wr_ptr; reg [ADDR_WIDTH:0] rd_ptr; wire [ADDR_WIDTH-1:0] wr_addr wr_ptr[ADDR_WIDTH-1:0]; wire [ADDR_WIDTH-1:0] rd_addr rd_ptr[ADDR_WIDTH-1:0]; assign full (wr_ptr[ADDR_WIDTH] ! rd_ptr[ADDR_WIDTH]) (wr_addr rd_addr); assign empty (wr_ptr rd_ptr); always (posedge clk or negedge rst_n) begin if (!rst_n) begin wr_ptr 0; rd_ptr 0; end else begin if (wr_en !full) begin mem[wr_addr] din; wr_ptr wr_ptr 1; end if (rd_en !empty) begin dout mem[rd_addr]; rd_ptr rd_ptr 1; end end end endmodule状态机方面我重点提一下三段式状态机。ISP里很多模块都需要状态机来调度比如去马赛克模块需要判断当前像素是奇数行还是偶数行、奇数列还是偶数列从而决定插值方向。用三段式状态机的好处是输出用寄存器打拍时序收敛容易而且状态转移、状态判断、输出逻辑三者分离代码可读性比一段式好太多。我见过太多同事把状态机和输出逻辑混在一起写调试时根本分不清是状态转移错了还是输出逻辑错了。3. 实操过程与核心环节实现3.1 一个可落地的框架目录结构如果你看完前面的思路准备动手我建议先照着这个目录结构建工程isp_sim_framework/ ├── rtl/ # 被测RTL源码 │ ├── dpc_top.v # 坏点矫正模块 │ ├── line_buffer.v # 行缓存 │ └── sync_fifo.v # FIFO ├── sim/ │ ├── tb_isp_top.v # 顶层testbench │ ├── tb_dpc.v # 单模块testbench │ └── waves.do # 波形配置文件 ├── scripts/ │ ├── run_sim.py # 自动化仿真脚本 │ └── cal_metrics.py # 指标计算脚本 ├── tools/ │ └── img2txt.py # 图像转文本工具 ├── data/ │ ├── input/ # 原始测试图像 │ └── output/ # 仿真输出结果 └── ref_model/ └── dpc_ref.py # 参考模型这个结构的核心原则是RTL和脚本分离、输入和输出分离。这样每跑一轮回归只需要清空data/output就可以重来不会把上一轮的仿真结果混到下一轮里。3.2 最小可用的坏点矫正仿真用一个具体案例来演示整个流程。坏点矫正Dead Pixel Correction是ISP pipeline里最靠前的模块之一处理逻辑也不复杂如果当前像素值明显偏离邻域像素中值就把当前像素替换成邻域中值或均值。由于逻辑简单它非常适合作为框架验证的第一个模块。参考模型先写好Python的OpenCV中值滤波就可以当作一种参考基线import cv2 import numpy as np raw cv2.imread(data/input/test_raw.png, cv2.IMREAD_UNCHANGED).astype(np.uint16) median cv2.medianBlur(raw, 3) np.savetxt(ref_output.txt, median.flatten(), fmt%d)RTL部分我写了一个简单的3x3中值滤波模块。核心是9个像素的排序网络用Verilog实现一个排序网络有点繁琐可以通过组合逻辑三分组再合并来实现。调试过程中最容易出的问题就是中值滤波对图像边界像素的处理。3x3窗口在处理图像最外围一圈像素时窗口的一部分在图像边界之外最常见的做法是复制边界像素。如果你的参考模型和RTL对边界策略的定义不一致那么边界的误差会占整幅图像误差的很大部分导致PSNR看起来很差。我在实操中就把边界处理策略做成参数可配仿真时对比不同策略对PSNR的影响。3.3 用脚本跑通一键回归文件准备好之后自动化脚本是提升效率的关键。手动敲命令运行iverilog、再敲命令跑Python完全失去了搭框架的意义。我的run_sim.py脚本逻辑很简单先检查文件是否存在然后编译RTL和tb执行仿真调用cal_metrics.py计算指标最后生成一个HTML格式的报告。import os import subprocess import sys def run_cmd(cmd): ret subprocess.run(cmd, shellTrue) if ret.returncode ! 0: print(fFAILED: {cmd}) sys.exit(1) def main(): rtl_files [rtl/dpc_top.v, rtl/line_buffer.v, rtl/sync_fifo.v] tb_file sim/tb_dpc.v cmd fiverilog -o sim/sim.vvp {tb_file} { .join(rtl_files)} -I rtl run_cmd(cmd) run_cmd(vvp sim/sim.vvp) run_cmd(python3 scripts/cal_metrics.py) if __name__ __main__: main()这个脚本虽然简单但已经具备了一键回归的雏形。后面要加新模块只需要在rtl_files列表里加一个文件路径脚本里加一行指标计算就行。跑一轮仿真通常只需要几分钟比之前打开Vivado、launch仿真、翻波形图的流程快了十倍不止。3.4 时钟、复位和异步处理的仿真细节还有一个容易被忽略的细节是跨时钟域处理。在真实的ISP芯片里传感器输出像素时钟和ISP处理时钟往往不是同一个频率所以整个ISP pipeline的仿真需要模拟两个时钟域。我见过很多tb写法是所有模块共用一个时钟仿真跑不出异步FIFO的效果等到上板才发现亚稳态问题。在框架里我会同时生成pclk和aclk两个时钟中间用异步FIFO衔接。比如传感器数据在pclk域写入FIFOISP模块在aclk域读出。这样仿真环境就覆盖了跨时钟域场景。异步FIFO的满空信号判断在仿真中很容易出问题常见的坑是格雷码在RTL建模时写错导致满空信号异常提前或滞后。我在框架里默认对异步FIFO做多次断言校验比如“full信号拉高后至少两拍内wr_en不能继续拉高”这种约束能把这类问题提前暴露出来。4. 常见问题与排查技巧实录4.1 仿真启动就报错license和路径问题这类问题在实际工作中出现频率极高。一种典型报错是failure to obtain a verilog simulation license看到这句话第一反应不一定是license真的失效而可能是仿真工具的license环境变量没有配置好或者你用的仿真工具本身就不是商业工具。用开源工具链就不会有这个问题iverilog完全不需要license。如果你必须用商业工具排查思路是按照“环境变量有没有配、license文件路径对不对、hostid是否匹配”三步走。另一种高频问题是路径分隔符和斜杠方向。Windows下用iverilog经常会遇到路径带反斜杠导致编译失败尤其是用-I选项指定include目录时。我吃过亏Windows命令行下路径要用正斜杠或者用双反斜杠转义。脚本里最好统一用os.path.join生成路径不要手写字符串去拼。4.2 仿真结果一片黑或数据全零这种情况排查思路可以从三个方向展开文件读取、数据写入、时序对齐。文件读取方向先用$fopen的返回值判断文件是否打开成功再用一个计数器看实际读到了多少行。我遇到过一种情况是Python脚本生成文本时因为数据量太大缓冲没有刷盘导致仿真运行时文件还没写完整读取到的都是无效值。解决办法是在Python脚本里写完后显式flush。数据写入方向检查输出端的$fwrite是否使用了正确的文件句柄。一个很隐蔽的坑是在你打开输出文件之前$fwrite就已经执行了此时文件句柄是0数据就丢掉了。解决办法是在tb里确保$fopen在initial块的最前面执行完成后再开始驱动数据。时序对齐方向如果你用了valid-ready握手而输出模块的ready信号逻辑写错了比如默认拉低了那么整个链路永远不会握手数据自然全是零。最有效的排查方法是拉出波形看关键信号的跳变是否如预期。这也是我一直推荐GTKWave的原因它虽然界面朴素但加载大波形文件比很多商业工具都快。4.3 输出图像有规律条纹行列计数错误调试ISP模块时最让人抓狂的问题就是输出图像看着“差不多”但隐约有规律的条纹。最常见的根因是行计数和列计数错位。行消隐期间计数器是否复位、复位时机是上升沿还是下降沿这些细节都会影响图像的几何位置。比如行缓存line buffer的写地址和读地址如果差了一拍图像就会出现固定偏移。用肉眼在波形图里找这个偏移很吃力但用图像处理的办法就很容易把仿真输出的图跟参考图逐像素求差如果差值在某个固定区域出现跳变说明是行偏移如果在整幅图所有位置都出现小幅波动说明是像素值精度问题或滤波权重差异。这类问题的排查要养成一个习惯不要只看整幅图的PSNR一定要生成误差热力图。PSNR只能反映整体水平无法定位局部错误。误差热力图用Python的matplotlib画出来坐标轴直接标出行列位置哪里偏了、偏了多少一目了然。我搭的框架里cal_metrics.py脚本默认就会生成三张图输入原图、输出图、误差热力图每次仿真结束都会保留方便回归对比。4.4 仿真速度太慢怎么优化ISP全流程仿真遇到的最大硬约束其实是仿真速度。一幅1080P的RAW图有超过两百万个像素如果每个像素都要经过几十个模块的时序仿真就算用带编译优化选项的iverilog跑完一帧也可能需要几个小时。我采用的优化策略是“三级仿真”第一级是纯算法仿真用Python参考模型快速迭代算法第二级是模块级仿真每个模块单独跑小图、跑特征图验证功能正确第三级是系统级仿真只在最后集成阶段跑全分辨率图像。另外在模块级仿真阶段建议把测试图缩小到64x64或者128x128因为大多数ISP模块的流水线逻辑跟图片尺寸没有直接关系小图能暴露绝大部分逻辑问题跑一遍只要几秒钟。如果你用的是iverilog还可以加-g2012选项启用SystemVerilog支持的数组和接口特性代码写起来会更简洁。不过注意这个选项对旧版本波形查看器的兼容性有限如果你是配合Vivado使用最好统一用Verilog-2001的写法。我个人在实际操作中的体会是仿真框架这件事越早搭越划算。很多做FPGA图像处理的朋友一上来就闷头写RTL等到模块写完再回头补环境往往会因为测试激励和比对逻辑设计得不合理前前后后返工好几次。而先把框架的输入输出、参考模型比对、自动化跑批这几样基础设施准备好后面的每一次模块开发效率都能翻倍。最后再分享一个我一直在用的小技巧在框架脚本里强制加一条“差分打印”命令把仿真输出的第一个像素、中间像素和最后一个像素的行列信息一起打印到日志里。看起来只是一条简单的$display语句实际调试中却能帮你快速判断数据有没有整体错位省掉大量翻波形的笨功夫。
返回列表