ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

FPGA直方图均衡化:像素统计、累加与除法器三大模块拆解

FPGA直方图均衡化:像素统计、累加与除法器三大模块拆解

1. 先搞清楚这三个模块在整条流水线里的位置

直方图均衡化在计算机图像处理工具里一条命令就能出结果,但放到FPGA上实时跑,事情就完全不一样了。输入是逐像素流动的灰度数据流,输出要在下一帧到来之前完成映射,中间那些统计、累加、归一化运算全都得拆成硬件模块,一拍一拍地算。这一节的三个主角——像素总个数统计模块、直方图累加模块、除法器模块——恰恰是直方图均衡化算法在FPGA实现里分工最明确、也最容易写错的三块电路。

1.1 从一行公式映射到三类硬件模块

直方图均衡化的核心公式可以写成:

g = round( cdf[I] × 255 / 总像素数 )

I代表输入的8bit灰度值,cdf[I]是从灰度0到灰度I的累积频数,总像素数就是整幅图像有效像素的个数,g是均衡化后的输出灰度。别看公式短,它在硬件里拆成了四个完全不同的动作:统计每个灰度级出现的次数hist[i]、把hist逐项累加成cdf[i]、把cdf归一化到0到255的范围、最后把结果存成查找表供像素查表输出。

这四件事分别对应了三个模块加一张RAM。像素总个数统计模块负责算出公式里的分母;直方图累加模块负责从hist数组得到cdf数组,公式里分子的主体;除法器模块负责把cdf值除以总像素数再乘以255,完成归一化。有人会问,直方图统计本身不也是一个模块吗?确实,但统计频数的那张RAM是"边统计边写入"的,和累加模块是前后级的关系,这节先把它当作背景,重点拆解的是统计之外的三块。

1.2 一张表看懂整条流水线的时间分工

真正理解这三个模块,不需要急着看代码,先看它们分别在什么时间段干活:

模块工作时段输入输出处理节奏
像素总个数统计有效像素期间pixel_validtotal_cnt每个像素一拍
直方图统计RAM有效像素期间像素灰度值hist[0..255]每个像素一拍
直方图累加垂直消隐期hist RAMcdf值256拍内完成
除法归一化垂直消隐期cdf值映射表LUT每灰度级一拍
LUT映射下一帧有效像素期间输入灰度输出灰度每个像素一拍

这张表背后有两个关键点。第一,前两个模块是实时赶工的,像素流不停,统计就不能停;后两个模块是"帧间补作业"的,只要在下一帧有效像素到来之前算完256个结果就行。第二,模块之间有严格的先后依赖:累加必须等直方图统计结束,除法必须等累加结果出来,像素计数要为除法提供分母。所以典型流程是第N帧统计、第N帧结束后的消隐期里累加和除法、第N+1帧查表映射,输出天然比输入晚一帧。

理解了这段时间轴,后面每一个模块的时序约束都好办了。下面直接进模块。

2. 像素总个数统计模块:计数器不是随便加几行代码就完事

2.1 为什么不直接用宽乘高

很多刚接触FPGA的读者第一反应是:分辨率是确定的,总像素数不就是width乘以height吗,干嘛还要单独写个计数器。如果只做一个固定分辨率的演示工程,这么想没错,直接把常数写进除法系数就行。但稍微往前一步,这个方案就站不住了。

第一个场景是IP复用。同一套均衡化模块,今天接640×480,明天接1280×720,后天可能要处理一个裁剪出来的ROI区域。分母一旦写死,换分辨率就得改代码重新综合,还容易漏改。漏改的后果是整帧亮度整体偏暗或偏亮,看起来像"没坏但不对劲",特别难排查。第二个场景是调试。如果前端时序出了问题,比如像素有效信号在边界多拉了一拍,总像素数会莫名多出几十个,用固定常数分母根本发现不了,用计数器却能立刻暴露。第三个场景是自适应算法,有些实现会根据实际统计结果决定是否做动态范围拉伸,总像素数本身就是参与计算的变量。

正规做法是让计数器统计实际有效的像素个数,同时在寄存器里存一份宽乘高的理论值,帧结束后两者比对,不一致就置错误标志。既保留了灵活性,又拿到了调试抓手。实现成本也就一个加法器加一组寄存器,资源开销几乎可以忽略。

2.2 位宽怎么定:一张表看清常见分辨率

计数器位宽按最大支持分辨率来定,宁多勿少。常见分辨率对应的像素总数和位宽如下:

分辨率像素总数最小二进制位宽
640×48030720019bit(2^19=524288)
1280×72092160020bit(2^20=1048576)
1920×1080207360021bit(2^21=2097152)
3840×2160829440023bit(2^23=8388608)

位宽如果给小了,计数值超过上限会回绕,后面除法算出来的映射表整体出错。这种错误在画面上表现为对比度异常,但不像坏点那样一眼能定位,排查起来很费劲。这里还有个小技巧:通用设计在算出最小位宽后可以再留一位余量,比如4K分辨率按24bit处理,多出来的一位几乎不费资源,但能省掉以后换更大分辨率的改版工作。

代码建议写成参数化形式,让工具自己算位宽:

localparam MAX_H = 1920; localparam MAX_V = 1080; localparam CNT_WIDTH = $clog2(MAX_H * MAX_V + 1); // 21

$clog2是Verilog-2005的系统函数,很多初学者不知道它。手算位宽本身不难,但用参数化写法能避免"改分辨率忘了改位宽"这类低级错误。括号里加1是为了把计数值恰好等于上限的情形包住,保证最大像素数也能被完整表示。

2.3 清零时机和使能的三个坑

计数器本体很简单:

always @(posedge clk or negedge rst_n) begin if (!rst_n) pixel_cnt <= 'd0; else if (frame_start) pixel_cnt <= 'd0; else if (pixel_valid) pixel_cnt <= pixel_cnt + 1'b1; end

优先级很明确:复位优先、帧起始清零、像素有效加一。工程里真正出问题的往往不在计数器本身,而在清零和使能信号的时序。

第一个坑:frame_start脉冲必须和第一行第一个有效像素对齐,不能直接拿场同步信号的上升沿清零。从场同步变化到有效像素出现之间,通常隔着几十个时钟的水平消隐,如果frame_start来得太早而pixel_valid已经拉高,计数器会把消隐期的假像素也数进去,总数偏大,映射表整体偏暗。稳妥的做法是从行场同步时序里单独产生一个"当前像素坐标到达(0,0)"的单周期脉冲来清零。

第二个坑:pixel_valid必须是单周期脉冲,不能是持续拉高的电平。如果前端给的valid是电平信号,计数器会在有效期的每个时钟都加一,把没有数据使能的时钟也算进去。遇到这种情况,要么在进入模块前做一次边沿检测,要么跟行场同步配合,强行限定valid只在一个数据窗口内为高。

第三个坑:使能信号不要和清零信号同拍竞争。假设某帧最后一拍恰好同时满足pixel_valid和frame_start,不同写法会出现"清零后再加一"和"加一后再清零"的差异,帧间总数差1。解决办法是约定优先级,帧结束那拍先停止计数,下一拍再清零。

3. 直方图累加模块:从统计频数到累积分布

3.1 直方图统计的读改写冲突与RAM配置

直方图统计的本质是维护一张256深度的频数表:每来一个像素,对应灰度值的频数加1。这在硬件上是经典的"读改写"操作——先读RAM里地址为灰度值的旧频数,加1再写回去。难点在于FPGA的Block RAM一个时钟周期要么读要么写,单口根本忙不过来,必须用真双口(True Dual Port)。

真双口BRAM的A口和B口可以独立访问,我建议A口专心做读改写,B口留给后面的累加模块去读,两个模块各管各的,不争端口。BRAM本身有多种写模式,其中有一种"先出旧值再写入新值"的模式,对读改写特别友好:写使能有效的那个时钟沿,数据输出端口给出的是该地址的旧内容,同时数据输入端口把新内容写入同一地址,读和写一步完成。

实现上有两种写法。第一种是直接利用这个特性,把读数据加1后接回写数据端口,单周期完成读改写:

assign hist_wea = pixel_valid; assign hist_addra = pixel_data; assign hist_dina = hist_dout + 1'b1;

这种写法能综合,但数据输出经过加法器再回到写数据端口,组合路径绕了一圈,时钟频率上到200MHz以后时序余量会越来越小。我更推荐把读写拆成两个周期,用流水寄存器把路径断开:

always @(posedge clk) begin if (pixel_valid) begin hist_rd_addr <= pixel_data; // 周期1:送读地址 hist_wen <= 1'b1; // 写使能延迟一拍 hist_wr_addr <= hist_rd_addr; // 写地址跟随读地址 hist_wr_data <= hist_rd_data + 1'b1; // 周期2:写回旧值+1 end else begin hist_wen <= 1'b0; end end

为什么我坚持用两拍?因为频数更新的路径上最怕的就是"读到的旧值还没稳定,新的写数据又变了"。两拍结构把限制放宽了许多,综合工具更容易收敛,尤其在低端芯片上差别很明显。两拍更新一次的代价是处理效率减半,但主流分辨率下像素时钟最高也就一百多兆赫兹,两拍完全来得及。真遇到超高分辨率高帧率,再考虑单周期读改写,同时把加法器搬进专用的乘法累加单元里做流水。

3.2 累加状态机:256次迭代的节奏控制

帧结束信号到来时,hist RAM里已经是完整的频数统计。累加模块的任务是逐项累加,算出cdf[i]=hist[0]+...+hist[i],再把每一个cdf值交给后面的归一化单元。最简单的实现是一个三段状态机:

localparam S_IDLE = 2'd0; localparam S_ACC = 2'd1; localparam S_END = 2'd2; reg [1:0] state; reg [7:0] acc_idx; reg [CNT_WIDTH-1:0] cdf_sum; always @(posedge clk) begin case (state) S_IDLE: begin if (frame_end_d) begin state <= S_ACC; acc_idx <= 8'd0; cdf_sum <= {CNT_WIDTH{1'b0}}; end end S_ACC: begin // 读 hist[acc_idx] // 下一拍 cdf_sum += hist[acc_idx],同时送归一化单元 if (acc_idx == 8'd255) begin state <= S_END; end else begin acc_idx <= acc_idx + 1'b1; end end S_END: begin state <= S_IDLE; end endcase end

这里要特别强调cdf_sum的清零位置。它必须在S_IDLE状态里、检测到frame_end_d的那一拍就被清零,不能拖到进入S_ACC之后再清。因为进入S_ACC的第一个时钟沿,累加逻辑立刻要读hist[0]并累加,如果清零动作和累加动作挤在同一拍,综合器对优先级和赋值顺序的处理稍有差异,就会出现第一帧正确、第二帧沿上一帧的尾巴继续算的间歇性错误。仿真帧数少看不出来,连续跑几百帧,图像亮度缓慢漂移,相当阴间。

3.3 为什么必须放在垂直消隐期:时间预算核算

有个问题值得认真回答:为什么不能边统计边累加?因为cdf是严格顺序依赖的,要等hist[0]到hist[i]全部统计完成,cdf[i]才成立。统计过程中任何一拍的hist值都是变动中的,算出来的cdf没有意义,硬件上只能把累加排到统计结束之后。

统计结束的时间点是帧的最后一个有效像素,再往后就是垂直消隐期。以常见的640×480@60Hz为例,垂直消隐大约45行,每行800个像素时钟,算下来有3.6万个时钟周期可用,而累加只要256拍,加上除法单元的流水延迟,占比不到百分之一。4K分辨率下消隐期相对短一些,但也还有四五百个时钟周期以上,256拍依然塞得进去。结论很明确:只要把累加和归一化做成"每灰度级一拍"的结构,任何常见分辨率的垂直消隐期都足够用。唯一要警惕的是别在归一化里用那种几十拍延迟的长流水除法器,那会让时间窗口变得紧张。

4. 除法器模块:三种实现方案,按需选型

4.1 先算一笔账:除法在硬件里的真实成本

硬件除法从来不是免费的。在FPGA里做一次除法,要么调用厂商的除法器IP核,要么被综合成一大片基于减法迭代的逻辑,延迟少则十几个、多则几十个时钟周期。如果对每一帧的每一个像素都做一次除法,1080p@60Hz意味着每秒要处理一亿多次除法,光流水线就会把资源吃穿。

直方图均衡化的巧妙之处在于:归一化只需要针对256个灰度级计算,而不是针对每个像素计算。映射表LUT一共就256个数值,每帧更新一次。真正的需求是"一帧256次除法",而且这256次除法全部发生在垂直消隐期,根本不占用像素实时通路。这是整个设计的命门:把除法从像素域搬到灰度级域,把实时计算变成帧间计算。明确了这个前提,下面三种实现方案选哪个都不会错。

4.2 方案一:除法IP核,最省心但最不灵活

最直接的做法是例化除法器IP核,或者直接在Verilog里写除号:

lut_value = (cdf_i * 255) / total_cnt;

综合工具会自动推断出除法结构。这个方案的优点是正确性有保障、设计周期短,适合先把功能调通,也适合逻辑资源本来就富裕的项目。缺点是延迟和资源不可控:除法器IP的输出延迟通常要二十多个周期,而且会占不少普通逻辑单元,在资源紧张的低端芯片上,这一块往往是压垮时序的最后一根稻草。还有可移植性问题,不同厂家的工具对除号运算符的处理策略不一样,今天这套代码在A家工具能综合,换到B家可能变成一片告警。

4.3 方案二:倒数乘法加移位,工程上最推荐

把公式做个变形:g = cdf × 255 / total = cdf × (255 / total)。括号里的255/total是一个与像素无关的常数,每一帧只需要计算一次。算完之后,256个灰度级各自乘一次这个常数就行。为了不用浮点,把这个常数扩大2^FRAC_BITS倍取整,最后右移回来,加一个四舍五入位:

localparam FRAC_BITS = 20; localparam ROUND = (1 << (FRAC_BITS - 1)); // 帧结束时计算一次定点系数 K_fix // K_fix = round(255 * 2^FRAC_BITS / total_cnt) wire [CNT_WIDTH + FRAC_BITS + 8:0] mul_tmp = cdf_i * K_fix + ROUND; reg [7:0] lut_out; // 饱和截断 always @(posedge clk) begin if (mul_tmp[CNT_WIDTH + FRAC_BITS + 8:FRAC_BITS] >= 8'd255) lut_out <= 8'd255; else lut_out <= mul_tmp[FRAC_BITS +: 8]; end

每个cdf_i的乘法只需要一个乘法单元,一拍出结果,比除法IP省资源、延迟也短得多。两个工程细节必须注意。第一,饱和逻辑一定要写:当cdf_i接近总像素数时,乘出来的中间值会超过8bit,直接截高位会让255被截成254甚至更离谱的数值,图像最亮处发暗。第二,FRAC_BITS建议从20起步。取18位时,渐变色块上会出现肉眼可见的阶梯感;取到20位,误差基本压在0.5个灰度级以内,人眼已经感知不到。位宽也不是越大越好,乘数位宽变大会牵动后续FIFO、总线的位宽一起涨,成本上升。

4.4 方案三:完全查表,固定场景下的最优解

如果分辨率固定、场景固定,其实连256次乘法都可以省。主机端可以预先算好一张完整的归一化映射表,直接固化到ROM里,运行时连乘法都省了。这个方案在演示项目、算法教学Demo里非常常见,资源占用和时序表现都最好。

但它有个致命的适用边界:一旦分辨率运行时可变,或者算法想做动态范围自适应——比如把直方图底部和顶部各截掉一段再拉伸——这张表就必须实时重算,静态ROM立刻失效。我的选择标准很简单:演示和教育用途用查表,产品化和参数化设计用倒数乘法,除非项目周期特别紧,否则不推荐把除法IP当成最终方案。

三种方案的对比:

方案资源占用延迟灵活性适用场景
除法IP高(逻辑为主)高(20+周期)低快速调通原型
倒数乘法低(1个乘法单元)低(1~2周期)高产品化、多分辨率
全查表ROM极低极低低固定分辨率演示

5. 三个模块联调时的验证方法与踩坑记录

5.1 用已知分布的小图像做断言验证

三个模块单独仿真都没问题时,联调才是最花时间的环节。我的经验是:不要用随机图像或摄像头实时图做仿真激励,而是构造一幅灰度分布完全已知的极小图像。比如做一幅4×4、一共16个像素的测试图,其中4个像素灰度32,4个像素灰度96,8个像素灰度160。手算一下期望输出:

  • 总像素数 = 16;
  • cdf[32] = 4,映射后lut[32] = round(4×255/16) = 64;
  • cdf[96] = 8,映射后lut[96] = round(8×255/16) = 128;
  • cdf[160] = 16,映射后lut[160] = 255。

把这些期望值直接写成仿真断言,跑一次就能确认像素计数、直方图累加、除法归一化三条链路全部正确。断言失败时,通过检查中间量可以快速定位:total_cnt错了查计数器,hist值和预期不符查读改写逻辑,lut错了查累加和除法。用真实图像验证的问题在于:故障往往表现为"画面发白""对比度奇怪"这类主观描述,你很难判断到底是统计错、累加错还是除法精度错,排查效率天差地别。

5.2 一个容易被忽略的坑:帧同步脉冲的生成

联调时最常翻车的不是算法本身,而是frame_end信号的来源。很多教程直接拿场同步信号的下降沿当帧结束,这在标准时序下勉强能用,但遇到带额外消隐的摄像头时序,场同步前沿和最后一个有效像素之间可能隔着几十上百个时钟。如果frame_end来得太晚,留给累加和除法的时间窗口被压缩,极端情况下下一帧第一行像素开始输出时,LUT还没更新完,画面上半部分用的是旧映射表。

我建议从行计数器来衍生帧结束脉冲:行计数到有效行数减1、同时水平坐标等于有效宽度减1的那一拍,就是真正的"最后一像素"。把它延迟一拍得到frame_end_d,作为累加状态机的启动信号。这个信号同一时刻还可以用来锁存total_cnt到total_reg,保证除法系数用的是本帧完整的统计数据。

5.3 资源和性能参考:到底花多少硬件

以640×480@60Hz为例,整个均衡化模块的实测资源大致如下:

资源项数量说明
块RAM1块256×19的直方图RAM,真双口
块RAM1块256×8的映射表RAM,简单双口
乘法单元1个倒数乘法的定点乘法
逻辑单元约200个计数器、状态机、饱和逻辑

在25MHz像素时钟下,设计跑到150MHz以上余量充足,瓶颈完全不在这个模块。换成1080p只是计数器位宽变21bit、定点位数可能需要提到22位,RAM结构和状态机基本不动。这也是为什么我一直跟人讲:直方图均衡化在FPGA里是"入门级但极其典型"的模块,做一遍能把读改写、状态机、定点数、帧间流水这些基础功全练齐,后面做ISP管线里的一大票模块都靠这套底子。

6. 上板调试后想补充的几点经验

6.1 第一帧黑屏要有心理预期和应对

因为第一帧用于统计、第二帧才开始映射,上电后的前两帧输出会有问题。如果这个模块直接接实时显示链路,用户会看到开机闪黑或者画面闪错。我调试时习惯加一个"映射表就绪"标志,LUT更新完成后置高;未就绪时输出通路直接旁路原始图像,就绪后再切到查表结果。这样上电过程对用户完全无感,调试时也方便定位LUT生效的帧边界。

6.2 想提升效果,可以加动态范围裁剪

标准公式g=cdf×255/T在图像灰度分布平均时表现不错,但遇到大面积纯黑或纯白区域,映射结果会整体偏灰,对比度提升不明显。想要更好效果,可以把公式改成从"最小非零频数"开始计算,相当于去掉直方图两端的无效区,再拉伸到全范围。硬件改动非常小:累加模块额外记录最小非零cdf值,归一化时做一次减法,映射表生成多两行组合逻辑,视觉效果却显著提升。这个改进很推荐动手试试。

6.3 把模块封装成参数化IP,后续复用价值高

三个模块调通验证完之后,建议认真整理成参数化模块:分辨率、定点位数、是否使能动态裁剪都做成参数,对外接口是像素流加帧同步信号,内部把计数、累加、除法、查表四段封装好。后面做灰度投影、自动曝光统计、Gamma曲线实时生成,这套"统计—累加—定点归一化"的骨架几乎可以原样复用,改改数据位宽和映射表生成公式就是一个新功能。我实际项目里这些基础模块被拆来借去用了很多次,每次都能省下大量重复调试时间。

RELATED READING

延伸阅读

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