资讯动态

ISP图像处理Verilog仿真框架:从RAW载入到自动比对的工程实践

发布时间:2026/10/3 1:13:11 来源:尧图企业网站定制
做ISP的硬件验证难的不是某个算法模块本身而是怎么把一张真实的RAW图片送进仿真环境让模块像在真实sensor时序下工作最后还能准确判断输出对不对。你可能已经写过不少testbench也在波形上看过几次正确的过采样但一换图、一换分辨率就手忙脚乱——这个状态我经历过也是我想聊仿真框架的原因。这篇文章不是给一个孤立模块的激励示例而是给一套可以反复使用的ISP图像处理Verilog仿真框架。围绕RAW图像载入、sensor时序模拟、滑动窗口与行缓冲、DPC坏点矫正实现以及像素级自动比对我会把能直接复用的代码骨架和踩过的坑都放出来。适合刚入门FPGA图像处理、或者正准备用Verilog实现ISP pipeline的朋友参考。1. 仿真框架为什么比手写testbench更值得搭1.1 一个让我重写三遍testbench的教训最早我做坏点矫正DPC模块的时候图省事直接在testbench里按行按列手敲像素激励。第一版测下来功能是好的波形也漂亮。但是等我把算法阈值调大、准备换第二张测试图的时候发现所有激励都要重写几十行的像素赋值代码要重新生成当时就想把电脑合上。后来我学乖了改成用$readmemh从文件读图像确实解决了换图就要重写testbench的问题。但第二个问题又冒出来我只看波形不看数值。模块输出对不对全靠眼睛盯dpc_data那根信号。640x480的图一帧几十万个像素靠肉眼根本盯不过来。第三个问题是网表仿真阶段才暴露的模块内部有valid/ready握手但testbench里没有模拟流水线反压的情况结果联调时一出现ready拉低模块就漏数据又得重新搭环境。三次重写testbench之后我才意识到问题不是我不会写仿真而是缺少一个框架。框架要解决的不只是一次仿真的输入输出而是四件复用性很强的事从文件批量灌入图像、模拟sensor时序、捕捉模块输出、自动做像素级比对。这四件事固定下来后验证新模块就变成换一张图、换一套参数、加一个比对模型的事情而不是从零开始搭平台。1.2 ISP仿真要拆成哪几件事ISP pipeline大家应该不陌生从sensor出来的是RAW图通常先做黑电平校正BLC再是坏点矫正DPC、镜头阴影校正LSC、去马赛克Demosaic、白平衡AWB、色彩校正CCM、Gamma最后输出YUV或RGB。这条链路里每个模块的算法特性差别很大但仿真验证要解决的共性问题是一致的数据从哪里来。大部分ISP模块吃的是RAW图像或中间格式数据仿真环境必须能按帧、按行地把数据送进去。时钟和有效信号怎么打。ISP模块几乎都是流水线结构输入输出的valid信号怎么拉高、有没有背压、行列同步信号怎么对齐直接决定模块外部时序是否正常。结果怎么看。模块输出如果是图像就应该能被还原成图片如果是统计量就应该能和参考模型比对。所以框架的价值不在于替你把某个算法写出来而在于提供一套统一的数据搬运和结果检查机制。你拿到一个新模块只需要把模块插进框架把参考模型写好剩下的喂数据和比对都是现成的。1.3 框架型仿真的整体结构我常用的仿真框架分四层顶层testbench。负责例化sensor模型、DUT和监视器同时管理全局时钟和复位。sensor模型。模拟sensor输出时序包括pclk、行同步、场同步、数据有效信号以及像素数据。DUT与参考模型。DUT是被验证的Verilog模块参考模型用C或Matlab实现同样的算法仿真时生成一份黄金数据。监视器与自动比对。监视DUT接口上的有效输出和参考数据做逐像素比对发现不一致就打印坐标和期望值。下面章节会按这个结构展开。代码我在Icarus Verilog和Vivado Simulator上都跑过基本的$readmemh、$fwrite这些系统函数都支持没有依赖特殊仿真器特性。2. 图像数据从哪来让仿真环境读懂RAW图2.1 用$readmemh把RAW数据载入仿真仿真环境要能处理图像第一步就是想办法把图像数据读进Verilog的数组里。最直接的做法是$readmemh读取十六进制文本文件。假设sensor输出的是10bit RAW图分辨率为640x480那么先用Matlab或者Python把RAW文件转换成hex文本每行一个像素值raw imread(test.raw); % 假设已经读进来 % 量化到10bit raw_q uint16(round(double(raw) * 1023 / 255)); fid fopen(sensor_raw.hex, w); fprintf(fid, %03x\n, raw_q); fclose(fid);注意fprintf转置成列向量这样写入的顺序是按行从左到右。然后在testbench里声明一个图像内存用$readmemh一次性载入define IMG_WIDTH 640 define IMG_HEIGHT 480 define PIX_BITS 10 reg [PIX_BITS-1:0] frame_mem [0:IMG_WIDTH*IMG_HEIGHT-1]; initial begin $readmemh(sensor_raw.hex, frame_mem); end读进来之后sensor模型再按像素时钟逐个输出。如果用$readmemb也可以对应二进制文本。值得提醒一点$readmemh支持任意空白字符作为分隔符所以生成的hex文件每行一个数、还是多个空格分隔都没问题。2.2 sensor时序模型怎么搭ISP模块必须要在sensor时序下工作才真实。一个简化但不失真的sensor模型主要产生pclk、frame_valid、line_valid以及像素数据。我的习惯是把行列计数器和图像内存的下标绑定reg [9:0] pixel_x; // 列坐标 reg [9:0] pixel_y; // 行坐标 reg [9:0] pixel_data; reg frame_valid; always (posedge pclk or negedge rst_n) begin if (!rst_n) begin pixel_x 0; pixel_y 0; frame_valid 0; end else if (pixel_y IMG_HEIGHT) begin frame_valid 1; pixel_data frame_mem[pixel_y * IMG_WIDTH pixel_x]; if (pixel_x IMG_WIDTH - 1) begin pixel_x pixel_x 1; end else begin pixel_x 0; pixel_y pixel_y 1; end end else begin frame_valid 0; end end这个模型里pixel_x和pixel_y就是当前像素在图像中的坐标pixel_data是从内存取出的像素值。仿真时通过frame_valid让模块知道当前时刻有有效数据模块自己再根据内部状态决定何时拉data_valid。如果你的DUT处理的是连续视频流可以把外层再加一层帧循环让pixel_y归零后继续重复输出模拟连续帧。2.3 仿真结果怎么保存成图片仿真结束如果只有一堆数值打印看得眼花不说还不直观。我会把结果写成PPM格式图片。PPM格式非常简单P3是ASCII版本P6是二进制版本。ASCII版写起来最容易测试小图和调试都用它integer out_file; initial begin out_file $fopen(dpc_out.ppm, w); $fwrite(out_file, P3\n%d %d\n1023\n, IMG_WIDTH, IMG_HEIGHT); end // 在监视器里每采到一个有效输出像素就写入 always (posedge pclk) begin if (dpc_valid) begin $fwrite(out_file, %d %d %d\n, dpc_data, dpc_data, dpc_data); end end对于RAW灰度图R、G、B三通道写同一个值就行。如果是RGB图像按顺序写三个通道的值。PPM可以直接用ImageMagick、GIMP或者Python的PIL打开方便快速目检。等调试稳定了再在Matlab或者Python脚本里把像素数据读出来和参考模型的输出做离线分析。这里有个细节$fopen默认路径是当前运行仿真的目录跟RTL源码路径不一定相同。跑Icarus Verilog时建议先cd到testbench所在目录再运行命令否则很容易出现文件打开失败或者生成了文件却找不到的情况。3. 3x3窗口和行缓冲是ISP算法仿真的地基3.1 行缓冲的Verilog实现ISP里大量算法都要用到邻域窗口比如中值滤波、边缘检测、坏点检测。硬件上不可能等整帧数据存下来再处理那样会引入一帧以上的延迟和巨大的存储开销。工程上的标准做法是用行缓冲line buffer只缓存两到三行数据配合移位操作生成窗口。行缓冲最简单的实现方式是用二维寄存器数组做循环写入。假设图像宽度为640数据位宽10bitreg [9:0] line0 [0:639]; reg [9:0] line1 [0:639]; reg [9:0] line2 [0:639]; always (posedge pclk) begin if (frame_valid) begin line2[pixel_x] line1[pixel_x]; line1[pixel_x] line0[pixel_x]; line0[pixel_x] pixel_data; end end这样每来一个像素三行缓冲里对应位置的值就整体向下一行移动line2拿到原来line1的数据line1拿到原来line0的数据line0写入当前输入。当pixel_x从0走到639整个帧不断流经这个结构任意时刻和当前像素相邻的上两行数据都缓存在line1和line2中。3.2 窗口像素对齐与valid生成有了行缓冲接着就要把窗口的9个像素组合出来。当前像素坐标是(pixel_x, pixel_y)窗口中心就是pixel_data而line0[pixel_x]是当前行当前列等等。需要的是窗口中心左一列、当前列、右一列以及上一行、上两行的对应位置。由于数据是边写入边读取的采集窗口像素的时序要和行缓冲写入逻辑严格对齐否则拿到的像素位置会差一列。工程上我习惯先移位生成三个当前窗口再锁定输出reg [9:0] win [0:2][0:2]; // [行][列] [y][x] always (posedge pclk) begin if (frame_valid) begin // 每拍把窗口向右平移一个像素 win[0][0] line2[pixel_x - 1]; win[0][1] line2[pixel_x]; win[0][2] line2[pixel_x 1]; win[1][0] line1[pixel_x - 1]; win[1][1] line1[pixel_x]; win[1][2] line1[pixel_x 1]; win[2][0] line0[pixel_x - 1]; win[2][1] line0[pixel_x]; win[2][2] line0[pixel_x 1]; end end更稳妥的办法是再加几级寄存器先把line0[pixel_x-1]、line0[pixel_x1]等打拍对齐避免组合逻辑直接从数组异步取值。实际综合时数组异步读会综合出多路选择器位宽一大就容易变成时序瓶颈。建议在框架里就把窗口像素都打一拍后面模块的时序会好处理很多。data_valid的生成也要注意不是每一拍窗口数据都有效。在图像左边界和右边界pixel_x-1和pixel_x1会越界需要额外处理。简单做法是用frame_valid加上pixel_x1 pixel_xIMG_WIDTH-2 pixel_y1 pixel_yIMG_HEIGHT-2作为窗口有效标志也就是等边界都满足时再拉高win_valid驱动下游模块。3.3 边界像素策略边界像素的缺失几乎是所有图像算法绕不开的问题。工业上一般有三种策略补零。最简单但会在边界造成明显的黑边对中值滤波这类算法影响较大。复制边界。用最边缘的有效像素重复填充缺失位置实现容易效果比补零好。直接不处理边界。输出时把边界像素原样透传或者干脆在边界保持上一步的结果。很多ISP芯片就是这么干的坏点和去马赛克只在有效区域内执行边界不做处理。我通常在后端参考模型和testbench里都用不处理边界的策略这样比对时逻辑最简单。等算法在有效区域验证正确之后再根据产品需求决定边界行为。4. DPC坏点矫正模块仿真完整代码与验证流程4.1 DPC的算法选型与判定逻辑坏点dead pixel是sensor生产和使用中常见的问题表现形式是某个像素点始终偏暗、始终偏亮或者响应异常。DPC模块要做的就是在RAW域把这些异常点找出来用周围正常像素替换掉。坏点检测有很多流派有些依赖标定表有些靠实时检测。仿真框架里我用的是一种常见的实时检测思路通过水平和垂直方向的梯度判断中心像素是否显著偏离周围。判定逻辑简单归纳计算水平方向梯度grad_h取中心像素与左右像素差值的较大者。计算垂直方向梯度grad_v取中心像素与上下像素差值的较大者。如果水平方向超过阈值并且垂直方向也超过阈值那就认为中心像素是坏点用邻域中值替换。这个判据的核心思想是真实图像边缘虽然也有梯度但边缘通常在单方向具有连续性坏点则是孤立的会在多个方向同时表现异常。同时超过两个方向的阈值能比较好地把坏点和边缘区分开。4.2 可综合的Verilog实现附代码下面给一个简化但可综合的DPC模块。输入是3x3窗口像素输出是处理后的中心像素module isp_dpc #( parameter PIX_W 10, parameter THRESH 40 )( input wire clk, input wire rst_n, input wire win_valid, input wire [PIX_W-1:0] p00, p01, p02, input wire [PIX_W-1:0] p10, p11, p12, input wire [PIX_W-1:0] p20, p21, p22, output reg [PIX_W-1:0] dpc_data, output reg dpc_valid ); wire [PIX_W:0] grad_h, grad_v; reg h_bad, v_bad, bad; reg [PIX_W-1:0] median_out; // 水平/垂直梯度计算 assign grad_h (p11 p10) ? (p11 - p10) : (p10 - p11); assign grad_v (p11 p01) ? (p11 - p01) : (p01 - p11); // 只用左右/上下与中心比较取最大梯度 wire [PIX_W:0] grad_h2, grad_v2; assign grad_h2 (p11 p12) ? (p11 - p12) : (p12 - p11); assign grad_v2 (p11 p21) ? (p11 - p21) : (p21 - p11); reg [PIX_W:0] grad_h_max, grad_v_max; always (*) begin grad_h_max (grad_h grad_h2) ? grad_h : grad_h2; grad_v_max (grad_v grad_v2) ? grad_v : grad_v2; end // 判定坏点 always (*) begin h_bad grad_h_max THRESH; v_bad grad_v_max THRESH; bad h_bad v_bad; end // 邻域中值用三组三数中值再取中值工程近似 function [PIX_W-1:0] median3; input [PIX_W-1:0] a, b, c; reg [PIX_W-1:0] max_ab, min_ab; begin max_ab (a b) ? a : b; min_ab (a b) ? a : b; median3 (c max_ab) ? max_ab : ((c min_ab) ? min_ab : c); end endfunction always (*) begin median_out median3( median3(p00, p01, p02), median3(p10, p11, p12), median3(p20, p21, p22) ); end always (posedge clk or negedge rst_n) begin if (!rst_n) begin dpc_data 0; dpc_valid 0; end else begin dpc_valid win_valid; if (win_valid) begin if (bad) dpc_data median_out; else dpc_data p11; end end end endmodule注意两点一是grad位宽定义成PIX_W1否则10bit数做减法若结果还是10bit负方向的值会截断出错。二是median3函数里的表达式写法我用嵌套三元表达式让代码简短实际工程中更清晰的写法是用begin/end块加中间变量。4.3 testbench如何自动判定结果有了DUT和参考模型testbench的自动比对是关键。做法是在参考模型里用同样的算法对输入图像做一遍离线处理把结果存成一个expected.hex文件。然后在testbench里把这组数据读入内存reg [PIX_BITS-1:0] ref_mem [0:IMG_WIDTH*IMG_HEIGHT-1]; initial begin $readmemh(expected.hex, ref_mem); end随后用监视器实时比对integer err_cnt; reg [9:0] ref_x, ref_y; always (posedge pclk) begin if (dpc_valid) begin if (dpc_data ! ref_mem[ref_y * IMG_WIDTH ref_x]) begin $display(ERROR: pos(%0d,%0d), expect%0d, get%0d, ref_x, ref_y, ref_mem[ref_y * IMG_WIDTH ref_x], dpc_data); err_cnt err_cnt 1; end if (ref_x IMG_WIDTH - 1) ref_x ref_x 1; else begin ref_x 0; ref_y ref_y 1; end end end always (posedge pclk) begin if (frame_end) begin if (err_cnt 0) $display(PASS: all pixels match.); else $display(FAIL: %0d mismatches., err_cnt); end end注意max(1) 这里ref_x、ref_y要等dpc_valid拉高后再递增保证索引和DUT输出像素一一对应。如果DUT有流水线延迟还要把参考数据的索引也延迟同样拍数否则会错位。这个错位问题是仿真框架里最常见的假错误来源我后面还会再讲。5. 仿真提速与新模块接入的实操经验5.1 Icarus Verilog的文件读取与格式限制用Icarus Verilogiverilog跑这套框架基本语法都没问题但有几个细节值得注意。$readmemh能正确识别十六进制数也支持类似地址指令的形式不过实际使用中还是推荐纯数据文件简单可靠。$fwrite目前不支持二进制格式的%b直接写文件头以外的部分所以我写PPM时用ASCII的P3格式避免踩格式坑。另外iverilog对initial块里大规模for循环的仿真速度还行但不要写类似for (i0;i100000;ii1)在同一个时间步里完成大量算术的代码会导致编译很慢。真要生成参考数据建议在Matlab/Python里做Verilog只负责比对。5.2 大图仿真太慢降采样与局部窗640x480的RAW图仿真一帧往往要跑几十秒到几分钟分辨率再上去甚至要十几分钟。如果只是验证算法逻辑没必要跑全图。我常用的做法是裁剪一块64x64或者128x128的区域做仿真测试图要包含边缘、纹理、平坦区域和人为注入的坏点。这样既覆盖算法分支又大幅缩短仿真时间。举例来说我会在Matlab里先取图像的一块区域比如坐标从(100,100)开始的128x128块再人为随机撒上几十个坏点block raw(100:227, 100:227); bad randperm(128*128, 30); block(bad) 1023; % 撒白点亮坏点把这小块转成hex和参考结果框架代码完全不用改只改宏定义的宽高就能跑。逻辑验完后再用全图回归一次效率会高很多。5.3 模块接入后的握手信号扩展基础框架里我用的是一拍valid加数据的方式实际模块几乎都要加ready反压。这时testbench的sensor模型也要跟着支持ready信号否则模块一旦拉低readysensor还在不断输出数据就丢了。推荐用最经典的valid-ready握手当valid和ready同时为高时数据被消费否则数据保持不变。写成状态机就是三段式写法最舒服localparam S_IDLE 2d0; localparam S_RUN 2d1; localparam S_DONE 2d2; reg [1:0] state, next_state; always (posedge clk or negedge rst_n) begin if (!rst_n) state S_IDLE; else state next_state; end always (*) begin next_state state; case (state) S_IDLE: if (in_valid) next_state S_RUN; S_RUN: if (done) next_state S_DONE; S_DONE: if (!in_valid) next_state S_IDLE; endcase end把握手逻辑封装成独立的小模块测DPC时候用测下一级去马赛克模块时也能复用。框架的价值在这里最明显——它不是为一个算法定制的而是为整个ISP pipeline定制的。尾声框架的下一步扩展方向搭好这套仿真框架之后后续往里面加模块是比较顺畅的。DPC测完了可以继续加BLC黑电平校正输入还是RAW图参考模型也还是同一份RAW数据只是在模块里做一次减黑电平的运算。再往后是Demosaic去马赛克那一模块的参考模型要输出RGB三通道比对数据结构会从单个值变成三元组PPM输出从灰度也要改成彩色。框架整体的读图像-送时序-带握手-写结果-自动比对主流程不需要动只需在监视器里扩展通道数即可。我个人在实际使用中的一个体会是仿真框架别等模块写完了再搭最好的时机是第一个模块动工之前。哪怕一开始框架很糙、只支持固定分辨率、只做灰度图只要数据流是通的后面所有模块都能跟着受益。反过来如果等到第三个模块才开始重构环境多半又要面对激励重写一遍、比对逻辑全换的老问题那就跟我在文章开头踩的坑一样了。最后分享一个调试小技巧第一次跑通DPC仿真时别急着看全图比对结果先在testbench里把窗口内9个像素和判定结果、阈值比较结果全部$display出来跑一个很小的5x5图人工核对几组数据确认框架的窗口对齐和参考模型索引完全一致后再放开跑大图和自动比对。这个小图人工核验、大图自动比对两步走能帮你避开很多因为索引错位导致的假报错省下的时间远比初始化一个复杂环境要多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑