ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于直方图的图像曝光量判决算法FPGA实现及仿真测试

基于直方图的图像曝光量判决算法FPGA实现及仿真测试 做图像处理的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或者YUVY分量就是亮度灰度图可以直接认为等价于Y分量。如果输入是RGB需要先做RGB转YCbCr或RGB转Gray这部分不在本文范围内但我建议把转换和直方图统计分成两个模块方便单独仿真。输出方面我定义了一个2bit的曝光状态judge_result再加一个judge_valid指示信号2b00 曝光正常2b01 整体偏暗建议增加曝光2b10 整体偏亮建议减少曝光2b11 对比度过大画面同时存在大量暗区和大量亮区边界条件是这个模块最容易出问题的地方。全黑帧、全白帧、纯色帧、只有局部亮的帧都要单独验证。仿真测试阶段如果不把这些边界帧构造出来上板之后很容易被真实场景打脸。这也是我这次仿真测试里最看重的部分。2. 硬件架构与核心逻辑拆解2.1 顶层模块划分与数据流整个设计我分成三个子模块hist_stat直方图统计、expo_judge曝光判决、top_reg寄存器接口。数据流是这样的像素流和de信号进入hist_stathist_stat在帧有效期间不断更新片内RAM里的256个灰度统计值帧结束之后hist_stat把256个bin的计数值串行读出送给expo_judgeexpo_judge边读边累加暗区计数、亮区计数和加权灰度总和读完所有bin后expo_judge根据阈值寄存器输出判决结果并拉高judge_validtop_reg负责存放阈值配置比如暗区阈值、亮区阈值、暗占比门限、亮占比门限也负责把判决结果寄存到输出总线。三个模块的接口信号不多但一定要在顶层框图里先画清楚尤其是hist_stat和expo_judge之间的握手。我习惯用ready/valid握手而不是固定等几个周期hist_stat读完256个bin后拉高read_doneexpo_judge收到后开始接收数据。仿真时这个握手信号一旦有毛刺后续结果全乱必须在波形里重点观察。2.2 直方图统计RAM的参数计算直方图统计本质上是一块双端口RAM深度256每个地址对应一个灰度值存储单元里存的是该灰度值出现的次数。位宽怎么定有个很常见的错误是拍脑袋取16bit结果统计到一半溢出回绕数据全错。正确做法按最大分辨率算如果做1080p1920×10802073600像素需要至少21bit因为2^201048576不够2^212097152才覆盖2073600如果只做720p1280×720921600像素20bit够用实验用64×64图4096像素12bit就够。我在设计里把位宽做成参数化仿真用小位宽上板前按目标分辨率调大。这样仿真速度快逻辑也不用改。给几个常用配置参考分辨率总像素数每个bin最小位宽预留余量推荐位宽64×64409612bit16bit640×48030720019bit20bit1280×72092160020bit21bit1920×1080207360021bit22bit这个位宽参数会影响整个RAM的资源占用。深度256、宽度21bit的BRAM在Xilinx 7系列上大概占一个18Kb BRAM的一部分资源开销很小。真正要小心的是统计过程中位数不够导致的溢出波形上看是一个纯色帧的hist值突然掉回0这个我在后面踩坑部分会展开。2.3 读改写状态机的设计思路直方图统计的核心操作是“读改写”每来一个有效像素把RAM里对应灰度地址的值读出来加1再写回原地址。听起来简单做起来有个吞吐率问题读和写毕竟不是同一个端口能瞬间完成的如果像素每个时钟周期都有效单个读改写操作根本来不及完成。我的做法是做一个简单的两拍状态机分两个周期处理一个有效样本。第一拍发出读地址第二拍把读出的旧值加1写回。这对实时性有影响吗确实有。如果全帧逐像素统计720p60的像素率约74MHz两拍处理一个像素就需要148MHz以上逻辑频率有点紧1080p就更紧张了。所以我通常加一道抽样逻辑每4个像素只统计1个。曝光量判决要的是图像整体亮度分布抽样四分之一对直方图形状影响很小但统计吞吐率需求直接降到1/4两拍方案就能轻松覆盖1080p。这正是很多工程教材不讲的点直方图统计不需要逐像素全量完成抽样统计既能保证判决质量又让时序收敛变得容易。仿真阶段我强烈建议先用64×64的测试图跑通全流程再考虑分辨率扩展。简化的读改写核心代码逻辑如下// 每2个时钟周期处理一个有效样本 localparam S_IDLE 2d0; localparam S_READ 2d1; localparam S_WRIT 2d2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin stat_state S_IDLE; hist_addr 8d0; hist_wr_en 1b0; end else if (frame_active sample_valid) begin case (stat_state) S_IDLE, S_READ: begin // 发出读地址等待BRAM输出旧计数 hist_addr pixel_gray; hist_wr_en 1b0; stat_state S_WRIT; end S_WRIT: begin // 读数据已在hist_rdata上加1写回 hist_wr_en 1b1; hist_addr pixel_gray; hist_wdata hist_rdata 1b1; stat_state S_READ; end endcase end else begin hist_wr_en 1b0; stat_state S_IDLE; end end这里有个关键点state从S_WRIT跳回S_READ时如果此时恰好下一个有效样本到来RAM访问窗口必须错开。我的做法是把sample_valid打一拍再作为状态机采样条件确保每次“读”都发生在“写”完成之后的第二个时钟周期。这个细节在波形上看得非常清楚如果写使能和下一次读地址重叠立刻会出现计数丢失。3. 仿真测试平台的搭建方法3.1 用Python快速生成四类测试图仿真不能只喂一种图。我每次都会用脚本先生成四类标志性测试图整体偏暗图、正常亮度图、整体偏亮图、半暗半亮图。这些图直接对应判决模块必须正确区分的四类结果比随机生成几张图更有针对性。用Python生成非常快我这里用PIL和numpyimport 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, dtypenp.uint8) save_hex(img_dark, img_dark) # 2. 正常亮度均匀灰度128 img_normal np.full((H, W), 128, dtypenp.uint8) save_hex(img_normal, img_normal) # 3. 整体偏亮均匀灰度240 img_bright np.full((H, W), 240, dtypenp.uint8) save_hex(img_bright, img_bright) # 4. 半暗半亮左半20右半240 img_contrast np.zeros((H, W), dtypenp.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 1b1; #80; vsync 1b0; de 1b1; for (i 0; i 64*64; i i 1) begin pixel_data img_mem[i]; (posedge clk); end de 1b0; #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期望2b01img_normal期望2b00img_bright期望2b10img_contrast期望2b11。自动比对的意义在于修改主逻辑后重新仿真只要跑一遍回归就能知道有没有破坏原有功能不用肉眼重新核对一遍波形。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应该是4096bright_cnt应该是0total_acc也应该是4096。任何一个对不上直接往统计状态机找问题不用去怀疑判决逻辑。4.2 典型场景的判决输出四类测试图在仿真里的表现应该和下面的表一致。这里我用64×64图、暗区阈值dark_th32、亮区阈值bright_th224、暗占比门限30%、亮占比门限30%做配置测试图dark_cntbright_cntdark占比bright占比judge_resultimg_dark灰度2040960100%0%2b01 偏暗img_normal灰度128000%0%2b00 正常img_bright灰度240040960%100%2b10 偏亮img_contrast半暗半亮2048204850%50%2b11 对比度大这个表值得逐行核对。img_contrast这类图很多人会忽略但它恰恰能验证“同时出现大量暗区和亮区”时的判决分支。如果只用暗、亮、正常三种图2b11这条分支在仿真里永远跑不到上板才发现逻辑问题那就太晚了。细节上要注意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、102464×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_cnt1024dark占比100%判决正确但如果把分母写死成4096dark占比就变成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附近少量区域灰度30dark_th刚好等于32时某些帧判暗、某些帧判正常输出不稳定。直接把这个判决结果丢给后级曝光控制曝光参数会在两个值之间来回跳画面会出现闪烁。解决办法是给判决加迟滞或帧间滤波。比如暗占比超过35%才判偏暗低于25%才恢复正常中间区域保持上一帧判决不变或者在判决结果输出前做帧间中值滤波连续三帧至少两帧一致才更新。这些逻辑在仿真阶段可能看不出必要性但上板后几乎必现。我在仿真里把这层逻辑一起加进去并用带噪声的测试图验证了迟滞阈值实际效果很稳定。写在最后的一点体会这次仿真测试做下来我最大的感受是曝光判决算法本身不复杂难的是让每一帧图像到判决结果的整条路径都可观测、可比对。单独跑一个直方图统计模块和把统计、读出、判决串起来跑完全不是一回事。仿真时把参考模型、自动断言、四类边界图全部配齐后面改任何一行RTL都能在几分钟内知道有没有引入回归这是纯看波形完全比不上的效率。后续如果做双向曝光控制我打算在这个判决模块后面再接一个PID或者简单的积分控制器直方图判决结果作为误差信号控制sensor曝光行数到时候仿真平台还可以沿用这套结构只是把后级模型换掉。对正在学FPGA图像处理的朋友建议你也试试先搭一套自动化仿真框架再开始写核心逻辑这比写一堆代码后再补仿真要顺手得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进