做图像处理的FPGA工程师,大概率都遇到过类似场景:sensor输出的画面整体发暗,或者一到室外就整帧过曝,自动曝光(AE)环路调半天拉不回来。图像曝光量判决算法要解决的就是这个问题——从一帧图像里统计灰度直方图,根据暗区、亮区的像素占比,判断当前曝光状态是偏暗、偏亮还是正常,再把判决结果送给sensor配置逻辑或者ISP里的数字增益模块做闭环调节。这篇文章记录的是我调试“基于直方图的图像曝光量判决算法FPGA实现”时的仿真测试部分,算是这套模块里最依赖细心的一环。仿真测试不是为了交差,而是让从一帧图像输入到判决结果产生的整条数据流,在波形上变得可观测、可核对。如果你正在做FPGA图像处理、ISP pipeline,或者刚写完第一个想认真验证的testbench,这篇应该能帮你少走几条弯路。
1. 曝光判决模块在ISP里的定位
1.1 自动曝光闭环中,判决模块到底负责什么
先把这个模块放在完整链路里看。相机成像时,sensor把光信号转成电信号,数字化的原始RAW数据进入ISP(图像信号处理)流水线。自动曝光(AE)是一个反馈环路:当前帧先做统计,统计结果经过判决,得出“这帧画面亮度是否合适”的结论,再根据结论去调整sensor的曝光时间、模拟增益或ISP的数字增益。曝光量判决模块就处在“统计”和“控制”中间,它的输入是直方图统计结果,输出是一个曝光状态的判定结果。
如果把这个链路拆开,大致是:sensor输出灰度图或Y分量 → 直方图统计模块逐像素更新直方图 → 帧结束把直方图读出 → 曝光判决模块计算暗区占比、亮区占比、均值等特征 → 输出曝光状态(偏暗/偏亮/正常/对比异常) → 后续曝光控制模块按状态调整参数。
我见过不少初学者直接拿均值亮度去判断曝光,比如算一个整帧平均灰度,小于某个阈值就认为欠曝。这样做在大部分均匀光照场景下没问题,但遇到逆光、夜景灯光、半暗半亮的场景就容易翻车。比如一个画面有一小块很亮的灯,其余大面积都是暗的,平均灰度可能正好落在中间,均值法会误判成“曝光正常”,而直方图法能看到暗区占比极大,从而给出“欠曝、需加曝光”的判决。直方图的价值就在这:它保留了像素亮度的分布信息,而不是压缩成一个点。
1.2 这个模块的输入输出与边界条件
输入方面,本设计接收的是8bit灰度数据流,附带像素有效信号de和帧同步信号vsync。实际sensor输出多为RAW或者YUV,Y分量就是亮度,灰度图可以直接认为等价于Y分量。如果输入是RGB,需要先做RGB转YCbCr或RGB转Gray,这部分不在本文范围内,但我建议把转换和直方图统计分成两个模块,方便单独仿真。
输出方面,我定义了一个2bit的曝光状态judge_result,再加一个judge_valid指示信号:
- 2'b00 曝光正常
- 2'b01 整体偏暗,建议增加曝光
- 2'b10 整体偏亮,建议减少曝光
- 2'b11 对比度过大,画面同时存在大量暗区和大量亮区
边界条件是这个模块最容易出问题的地方。全黑帧、全白帧、纯色帧、只有局部亮的帧,都要单独验证。仿真测试阶段如果不把这些边界帧构造出来,上板之后很容易被真实场景打脸。这也是我这次仿真测试里最看重的部分。
2. 硬件架构与核心逻辑拆解
2.1 顶层模块划分与数据流
整个设计我分成三个子模块:hist_stat(直方图统计)、expo_judge(曝光判决)、top_reg(寄存器接口)。数据流是这样的:像素流和de信号进入hist_stat,hist_stat在帧有效期间不断更新片内RAM里的256个灰度统计值;帧结束之后,hist_stat把256个bin的计数值串行读出,送给expo_judge;expo_judge边读边累加暗区计数、亮区计数和加权灰度总和;读完所有bin后,expo_judge根据阈值寄存器输出判决结果,并拉高judge_valid;top_reg负责存放阈值配置,比如暗区阈值、亮区阈值、暗占比门限、亮占比门限,也负责把判决结果寄存到输出总线。
三个模块的接口信号不多,但一定要在顶层框图里先画清楚,尤其是hist_stat和expo_judge之间的握手。我习惯用ready/valid握手,而不是固定等几个周期:hist_stat读完256个bin后拉高read_done,expo_judge收到后开始接收数据。仿真时这个握手信号一旦有毛刺,后续结果全乱,必须在波形里重点观察。
2.2 直方图统计RAM的参数计算
直方图统计本质上是一块双端口RAM,深度256,每个地址对应一个灰度值,存储单元里存的是该灰度值出现的次数。位宽怎么定?有个很常见的错误是拍脑袋取16bit,结果统计到一半溢出回绕,数据全错。正确做法按最大分辨率算:如果做1080p(1920×1080=2073600像素),需要至少21bit,因为2^20=1048576不够,2^21=2097152才覆盖2073600;如果只做720p(1280×720=921600像素),20bit够用;实验用64×64图,4096像素,12bit就够。
我在设计里把位宽做成参数化,仿真用小位宽,上板前按目标分辨率调大。这样仿真速度快,逻辑也不用改。给几个常用配置参考:
| 分辨率 | 总像素数 | 每个bin最小位宽 | 预留余量推荐位宽 |
|---|---|---|---|
| 64×64 | 4096 | 12bit | 16bit |
| 640×480 | 307200 | 19bit | 20bit |
| 1280×720 | 921600 | 20bit | 21bit |
| 1920×1080 | 2073600 | 21bit | 22bit |
这个位宽参数会影响整个RAM的资源占用。深度256、宽度21bit的BRAM,在Xilinx 7系列上大概占一个18Kb BRAM的一部分,资源开销很小。真正要小心的是统计过程中位数不够导致的溢出,波形上看是一个纯色帧的hist值突然掉回0,这个我在后面踩坑部分会展开。
2.3 读改写状态机的设计思路
直方图统计的核心操作是“读改写”:每来一个有效像素,把RAM里对应灰度地址的值读出来,加1,再写回原地址。听起来简单,做起来有个吞吐率问题:读和写毕竟不是同一个端口能瞬间完成的,如果像素每个时钟周期都有效,单个读改写操作根本来不及完成。
我的做法是做一个简单的两拍状态机,分两个周期处理一个有效样本。第一拍发出读地址,第二拍把读出的旧值加1写回。这对实时性有影响吗?确实有。如果全帧逐像素统计,720p@60的像素率约74MHz,两拍处理一个像素就需要148MHz以上逻辑频率,有点紧;1080p就更紧张了。所以我通常加一道抽样逻辑:每4个像素只统计1个。曝光量判决要的是图像整体亮度分布,抽样四分之一对直方图形状影响很小,但统计吞吐率需求直接降到1/4,两拍方案就能轻松覆盖1080p。
这正是很多工程教材不讲的点:直方图统计不需要逐像素全量完成,抽样统计既能保证判决质量,又让时序收敛变得容易。仿真阶段我强烈建议先用64×64的测试图跑通全流程,再考虑分辨率扩展。
简化的读改写核心代码逻辑如下:
// 每2个时钟周期处理一个有效样本 localparam S_IDLE = 2'd0; localparam S_READ = 2'd1; localparam S_WRIT = 2'd2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin stat_state <= S_IDLE; hist_addr <= 8'd0; hist_wr_en <= 1'b0; end else if (frame_active && sample_valid) begin case (stat_state) S_IDLE, S_READ: begin // 发出读地址,等待BRAM输出旧计数 hist_addr <= pixel_gray; hist_wr_en <= 1'b0; stat_state <= S_WRIT; end S_WRIT: begin // 读数据已在hist_rdata上,加1写回 hist_wr_en <= 1'b1; hist_addr <= pixel_gray; hist_wdata <= hist_rdata + 1'b1; stat_state <= S_READ; end endcase end else begin hist_wr_en <= 1'b0; stat_state <= S_IDLE; end end这里有个关键点:state从S_WRIT跳回S_READ时,如果此时恰好下一个有效样本到来,RAM访问窗口必须错开。我的做法是把sample_valid打一拍再作为状态机采样条件,确保每次“读”都发生在“写”完成之后的第二个时钟周期。这个细节在波形上看得非常清楚:如果写使能和下一次读地址重叠,立刻会出现计数丢失。
3. 仿真测试平台的搭建方法
3.1 用Python快速生成四类测试图
仿真不能只喂一种图。我每次都会用脚本先生成四类标志性测试图:整体偏暗图、正常亮度图、整体偏亮图、半暗半亮图。这些图直接对应判决模块必须正确区分的四类结果,比随机生成几张图更有针对性。
用Python生成非常快,我这里用PIL和numpy:
import numpy as np from PIL import Image # 64x64,便于仿真快速跑完 H, W = 64, 64 def save_hex(img, name): # 生成$readmemh可读的hex文件 with open(f"{name}.hex", "w") as f: for row in img: f.write(" ".join(f"{p:02x}" for p in row) + "\n") Image.fromarray(img).save(f"{name}.png") # 1. 整体偏暗:均匀灰度20 img_dark = np.full((H, W), 20, dtype=np.uint8) save_hex(img_dark, "img_dark") # 2. 正常亮度:均匀灰度128 img_normal = np.full((H, W), 128, dtype=np.uint8) save_hex(img_normal, "img_normal") # 3. 整体偏亮:均匀灰度240 img_bright = np.full((H, W), 240, dtype=np.uint8) save_hex(img_bright, "img_bright") # 4. 半暗半亮:左半20,右半240 img_contrast = np.zeros((H, W), dtype=np.uint8) img_contrast[:, :W//2] = 20 img_contrast[:, W//2:] = 240 save_hex(img_contrast, "img_contrast")PIL读图、numpy生成灰度矩阵,再导出成hex,三行代码就能搞定。如果你希望更接近真实sensor场景,可以在这些图的基础上叠加高斯噪声,或者生成带亮度梯度的图,比如从左到右从0渐变到255,这种图可以用来观察直方图分布是否连续完整。
导出hex文件时需要注意格式:$readmemh是按行读的,每个数值用空白分隔即可。我上面的save_hex函数就是按行把每个像素写成一个8bit十六进制数,testbench里直接按行索引读取。注意一行64个像素,一帧64行,索引顺序和图像逐行扫描顺序一致,这点必须保证。
3.2 Testbench的关键骨架
testbench的核心目标是把一帧图像按sensor时序送进RTL,同时自己维护一个参考直方图,在帧结束后自动比对。时钟和复位谁都会写,真正有讲究的是帧时序的生成和参考模型的同步。
我习惯用task来发送一整帧数据。task内部循环遍历图像数组,每个时钟周期输出一个像素,同时拉高de;整个数组读完,拉低de,再拉高vsync表示一帧结束。这样在testbench里可以连续发送多帧不同的图,验证多帧统计之间互不污染。
reg clk = 0; always #5 clk = ~clk; // 100MHz reg [7:0] img_mem [0:64*64-1]; integer img_fp; task send_frame(input [7:0] frame_id); integer i; begin vsync = 1'b1; #80; vsync = 1'b0; de = 1'b1; for (i = 0; i < 64*64; i = i + 1) begin pixel_data = img_mem[i]; @(posedge clk); end de = 1'b0; #80; end endtask这里有个小坑:$readmemh读hex文件时,如果文件行数少于数组大小,剩余位置会填x。所以一定要保证hex文件生成的行列数和testbench里声明的数组大小一致。我在脚本里生成64行,每行64个值,testbench就用[0:64*64-1]的数组,按行拼接索引。
参考直方图模型我直接在testbench里用system verilog的开放数组或verilog二维数组维护:
integer ref_hist [0:255]; always @(posedge clk) begin if (de) begin ref_hist[pixel_data] = ref_hist[pixel_data] + 1; end end注意参考模型统计的是送入RTL的像素流,如果RTL内部做了抽样,那参考模型也要在同一个抽样使能条件累加,否则两边统计量永远对不上。我建议把抽样使能信号从RTL里拉出来给testbench观察,参考模型用同样的使能条件累加,比对才公平。
3.3 自动化比对与结果打印
手工看波形容易漏,所以我通常在testbench里写自动比对。帧结束且judge_valid拉高后,把dark_cnt、bright_cnt、mean等中间量和参考模型计算值比较,只要不一致就打印$error,并把当前帧号和期望值一起输出。
always @(posedge clk) begin if (judge_valid) begin if (hist_total_acc != WIDTH*HEIGHT) begin $error("[%0t] hist total mismatch: rtl=%0d expect=%0d", $time, hist_total_acc, WIDTH*HEIGHT); end if (judge_result !== expected_judge[frame_cnt]) begin $error("[%0t] judge mismatch: rtl=%02b expect=%02b", $time, judge_result, expected_judge[frame_cnt]); end else begin $display("[%0t] frame %0d judge OK, result=%02b", $time, frame_cnt, judge_result); end end end期望结果数组expected_judge在testbench初始化里按测试图顺序填好,例如img_dark期望2'b01,img_normal期望2'b00,img_bright期望2'b10,img_contrast期望2'b11。自动比对的意义在于:修改主逻辑后重新仿真,只要跑一遍回归就能知道有没有破坏原有功能,不用肉眼重新核对一遍波形。
4. 仿真结果解读与验收要点
4.1 直方图统计阶段的波形怎么看
打开波形,时序上分两段看。第一段是de有效期间,hist_stat状态机在两拍周期内对样本做读改写。此时观察hist_addr和hist_wr_en:如果输入是纯灰度20的图,hist_addr应该基本恒定在20附近,hist_wr_en每两拍出现一次,hist_wdata在第一次写回时为1,第二次为2,逐渐递增。如果输入是正常灰度128的图,hist_addr恒定在128附近。如果输入是暗亮各半的图,hist_addr会在20和240之间交替,每次写回的目标地址和像素灰度严格对应。
第二段是帧结束后的读出阶段。de拉低后,hist_stat开始逐个地址读出256个bin,此时hist_addr从0循环到255,每个地址保持一个时钟周期,输出端hist_rdata上会依次出现这些灰度值对应的统计量。expo_judge模块在这个阶段启动累加,所以你会看到dark_acc、bright_acc、sum_acc这三个累加器在逐渐变化。这是在判断“读出逻辑是否正常”的最直观窗口:地址必须从0到255连续,不能漏地址、不能跳变。
我常常会把累加器的最终值和手动算的期望值对比。比如64×64全暗图,dark_cnt应该是4096,bright_cnt应该是0,total_acc也应该是4096。任何一个对不上,直接往统计状态机找问题,不用去怀疑判决逻辑。
4.2 典型场景的判决输出
四类测试图在仿真里的表现应该和下面的表一致。这里我用64×64图、暗区阈值dark_th=32、亮区阈值bright_th=224、暗占比门限30%、亮占比门限30%做配置:
| 测试图 | dark_cnt | bright_cnt | dark占比 | bright占比 | judge_result |
|---|---|---|---|---|---|
| img_dark(灰度20) | 4096 | 0 | 100% | 0% | 2'b01 偏暗 |
| img_normal(灰度128) | 0 | 0 | 0% | 0% | 2'b00 正常 |
| img_bright(灰度240) | 0 | 4096 | 0% | 100% | 2'b10 偏亮 |
| img_contrast(半暗半亮) | 2048 | 2048 | 50% | 50% | 2'b11 对比度大 |
这个表值得逐行核对。img_contrast这类图很多人会忽略,但它恰恰能验证“同时出现大量暗区和亮区”时的判决分支。如果只用暗、亮、正常三种图,2'b11这条分支在仿真里永远跑不到,上板才发现逻辑问题,那就太晚了。
细节上要注意dark占比是用dark_cnt除以total_samples算出来的。因为用了抽样统计,total_samples不是4096而是样本数,但占比是比值,不受抽样率影响。仿真里如果我在判决模块误用了全帧像素数4096作为分母,img_dark的dark占比会变成25%,刚好低于30%门限,判决就误判成正常。这个错误很隐蔽,我在3.3节提到的total_acc比对就是为了抓这种bug。
4.3 边界与回归:连续多帧输入的验证
单帧仿真通过不算数,我还会在同一个testbench里连续送四帧,中间用vsync隔开,重点验证帧间清零。直方图RAM如果不清空,第二帧的统计结果等于第一帧加第二帧的叠加。帧间清零的做法我放在hist_stat状态机里:readout读出bin的同时,往该地址写0。这样读一遍顺便清零,省掉单独的清空周期。
连续四帧仿真的波形上,每一帧的total_acc都应该等于本帧样本数,而不是递增累计。我在testbench里对每一帧单独打印total_acc,连续四帧应该分别是1024、1024、1024、1024(64×64按1/4抽样后的样本数)。如果出现了1024、2048、3072这样的递增序列,那就是清零逻辑失效,直接回去查readout路径的写0使能是否拉对了。
回归测试也要覆盖阈值配置的改动。比如把dark_th从32改成64后,原来的img_normal(灰度128)仍然不判暗,但如果再用一张灰度48的图仿真,它应该判暗。这验证了寄存器配置的动态修改在全流程中真正生效,而不是写进了寄存器却没人用。
5. 踩坑实录:从仿真波形到逻辑修正
5.1 RAM的写优先和读优先配置导致的计数翻倍
这是我自己第一次做直方图统计时踩的坑,也是最经典的坑。直方图RAM用的是Xilinx BRAM,生成IP核时可以选READ_FIRST、WRITE_FIRST或NO_CHANGE。我在读改写逻辑里假设“同一时钟周期写回后,下一拍读同地址读到的是新值”,但BRAM的READ_FIRST模式下,同一时刻同地址读写,读端口拿到的是旧值而不是新写入的值。这个行为差异直接导致连续两个像素灰度相同时,第二个像素统计到的是没有累加第一个像素的旧计数,最终hist值偏小;反过来,如果配置理解反了,会出现计数翻倍的情况。
解决方案不是去猜BRAM行为,而是先打开BRAM IP核的仿真模型文档,明确当前配置的读写优先级,再回去检查RTL时序。我的两拍状态机天然避开了这个问题:读发生在第一拍,写发生在第二拍,两者不在同一个时钟沿竞争同地址,所以无论BRAM配成什么优先级都不会错。如果追求每拍一个样本的高吞吐实现,这个问题就必须正面解决,通常会用双bank交替直方图,把连续同灰度像素分散到两个bank,最后合并统计。
5.2 抽样统计与全帧像素的换算陷阱
抽样统计节省了不少带宽,但也带来了一个容易算错的点。判决模块计算占比时,分子是dark_cnt或bright_cnt,分母应该是total_samples(实际统计到的样本总数),而不是widthheight。我在4.2节已经提过这个陷阱,这里再展开一次:假设64×64图,1/4抽样,样本总数是1024,全暗时dark_cnt=1024,dark占比100%,判决正确;但如果把分母写死成4096,dark占比就变成25%,低于30%门限,判决错误。这个bug在仿真波形上表现并不明显,因为dark_cnt看起来是对的,只有total_acc对不上。所以我总在testbench里加一条断言:total_acc必须等于widthheight/抽样率(整除情况下)。这个断言帮我在一次改动中直接定位了问题,省了不少排查时间。
5.3 仿真效率:全高清帧别硬跑
一开始我图省事,直接用1080p的图跑全帧仿真,结果跑一次要等几个小时,基本没法迭代。后来把测试图切成64×64,整个仿真只要几百毫秒,调参数、改逻辑、重新跑,效率完全不一样。很多同学觉得用大分辨率测试更“真实”,但其实对于曝光判决算法验证,64×64和1080p在直方图分布特性上没有本质区别。你可以先用64×64把功能全部验证完,最后再用一张小尺寸的复杂图做回归,没有必要让仿真反复跑长帧。
如果确实需要看高清大图的直方图,正确做法是用C模型或Python脚本离线算一遍期望值,再抽样出几个关键bin做比对,而不是让仿真把几百万个像素都流一遍。这个思路和上板调试时用ILA抓一小段窗口的逻辑是相通的。
5.4 判决输出抖动与迟滞思路
仿真到了后期,我开始往测试图里加噪声,模拟真实sensor的亮度波动。结果发现一个现象:裸的直方图判决在临界场景下会抖。比如一张图大部分区域灰度32附近,少量区域灰度30,dark_th刚好等于32时,某些帧判暗、某些帧判正常,输出不稳定。直接把这个判决结果丢给后级曝光控制,曝光参数会在两个值之间来回跳,画面会出现闪烁。
解决办法是给判决加迟滞或帧间滤波。比如暗占比超过35%才判偏暗,低于25%才恢复正常,中间区域保持上一帧判决不变;或者在判决结果输出前做帧间中值滤波,连续三帧至少两帧一致才更新。这些逻辑在仿真阶段可能看不出必要性,但上板后几乎必现。我在仿真里把这层逻辑一起加进去,并用带噪声的测试图验证了迟滞阈值,实际效果很稳定。
写在最后的一点体会
这次仿真测试做下来,我最大的感受是:曝光判决算法本身不复杂,难的是让每一帧图像到判决结果的整条路径都可观测、可比对。单独跑一个直方图统计模块,和把统计、读出、判决串起来跑,完全不是一回事。仿真时把参考模型、自动断言、四类边界图全部配齐,后面改任何一行RTL,都能在几分钟内知道有没有引入回归,这是纯看波形完全比不上的效率。后续如果做双向曝光控制,我打算在这个判决模块后面再接一个PID或者简单的积分控制器,直方图判决结果作为误差信号,控制sensor曝光行数,到时候仿真平台还可以沿用这套结构,只是把后级模型换掉。对正在学FPGA图像处理的朋友,建议你也试试先搭一套自动化仿真框架,再开始写核心逻辑,这比写一堆代码后再补仿真要顺手得多。