有雾视频监控画面常年泛白、对比度差,单纯靠调Gamma和对比度根本救不回来。做监控、车载、航拍这类图像处理项目时,“电子透雾”(ISP Dehaze)几乎是刚需功能。我去年在一套基于FPGA的ISP处理链里完整实现了暗通道先验透雾算法,1080P@30fps实时跑通,资源占用可控,今天把整个从算法到硬件落地的过程记录下来。
这篇东西适合两类人:一类是刚开始接触FPGA图像处理、想找一个不算简单但又有明确结果的练手项目的朋友;另一类是已经在做ISP相关的工程、正被“如何在实时链路里插入Dehaze”折磨的开发者。我会把方案选型、算法原理、模块拆分、定点化处理、资源估算和调试中踩过的坑都讲清楚,尽量做到你看完能自己复现一条最小可用的透雾流水线。
1. 方案选型:为什么Electron Dehaze非得用FPGA做
1.1 电子透雾与物理透雾的本质区别
透雾这条需求,行业里一直有两条路线。物理透雾是硬件层面的,用红外相机、多光谱相机,或者让镜头工作在近红外波段,穿透雾气能力确实强,但传感器贵、系统体积大,而且对色彩影响明显。电子透雾就纯靠ISP算法,从单帧彩色图像里估计大气光成分并反演恢复,成本几乎为零,集成在现有相机链路里也不用改光学和传感器。
去雾算法能不能跑在嵌入式实时链路上,关键不只看计算量,还要看数据流形态。图像是一行一行扫进来的,像素时钟是恒定的,算法必须在严格的帧周期内完成,一旦处理不过来就丢帧。这类“流式处理”的天然形态,恰好是FPGA最擅长的战场。DSP和ARM能算,但像素级操作的耗时和数据搬运开销很容易成为瓶颈。GPU虽然算力强,但是功耗、体积和启动时间在工业相机、车载前装场景里聊不动。
1.2 处理器对比:FPGA、DSP、ARM的透雾计算效率
在方案评估阶段,我把几种处理器都列过一遍。ARM(比如常见的Cortex-A系列)做浮点强,Linux生态丰富,但瓶颈在于内存带宽——一张1080P的RGB图是6MB左右,做一次全图级联操作(最小值滤波+多个盒式滤波)至少要反复读写好几遍外存,带宽和时间都吃力。DSP的MAC能力强,但窗口类运算仍然受限于L2/L3缓存,碰到7x7、15x15这种大窗口最小值滤波,需要把邻域数据反复折腾。
FPGA的逻辑不一样。它可以在片内BRAM里构建行缓冲(Line Buffer),同一时刻只缓存N行数据,配合滑动窗口把二维卷积、最小值滤波、盒式滤波全部流式完成。整个运算过程中,全图数据不需要反复进出DDR,进来的像素流经过流水线后直接出去,时钟速率等于像素速率。这种“数据进来多少、处理多少”的模式,天然满足实时视频的实时性要求。还有一点,透雾算法对延时有要求,FPGA能做到几个毫秒内输出,ARM或者GPU动辄几十毫秒延迟,在环视和辅助驾驶场景里没法接受。
1.3 这个项目的输入输出与整体场景定位
做这个项目前,我先定位了场景。输入是标准ISP Pipeline输出的RGB图像,Sensor端可以是MIPI CSI-2接口的CMOS传感器,也可以从DDR3/4里读RAW图先经过Demosaic再进来。输出是透雾增强后的RGB图,后续可以直接送显示、编码或者传给识别算法。
很多做图像的朋友日常用STM32H743这类MCU,其实也能做小分辨率透雾(比如QVGA),但上了720P、1080P之后就会明显吃力。FPGA与MCU之间经常通过FMC接口通信——MCU负责参数配置、视频流控制,FPGA负责行级像素处理,这个协作模式在工业相机里非常常见。我推荐小团队和个人开发者参考这个分工:复杂调度和上层协议交给MCU或ARM,重计算和低延时留给FPGA。这样调试方便,性能也有保障。
2. 透雾算法原理与硬件映射
2.1 从大气散射模型讲到暗通道先验
透雾算法里最经典、最适合硬件化的理论框架是大气散射模型。有雾图像可以看成两部分:一部分是目标物体本身的光线在传播过程中被衰减后的结果,另一部分是大气光散射进入相机的成分。数学表达为:
I(x) = J(x) * t(x) + A * (1 - t(x))
I是观测到的有雾图,J是待恢复的无雾图,A是全局大气光(天空区域的亮度),t是透射率,描述光线从物体传播到相机的存活比例。t值越小,说明雾气越浓。问题就转化为:已知I,估计A和t,反解J。
那么t怎么估?何恺明提出的暗通道先验给出一个非常实用的统计规律:在户外无雾图像的绝大多数局部区域内,至少有一个颜色通道的强度值趋近于零。换句话说,取RGB三通道中每个像素的最小值,再对局部区域做一次最小值滤波,得到的“暗通道”图像应当是很黑的。而有雾区域因为大气光的叠加,暗通道会变亮。利用这个亮度的抬升量,就能反推透射率。
暗通道公式:
J_dark(x) = min_{c ∈ {R,G,B}} ( min_{y ∈ Ω(x)} J_c(y) ) ≈ 0
其中Ω(x)是以x为中心的局部窗口。透射率估计公式:
t(x) = 1 - ω * min_{c} ( min_{y ∈ Ω(x)} ( I_c(y) / A_c ) )
ω是保留因子,一般取0.9~0.95,目的是保留一点点雾感,让画面不至于显得不自然。最后恢复公式:
J(x) = (I(x) - A) / max(t(x), t0) + A
t0是透射率下限,一般是0.1,避免分母过小导致噪声放大。
这个算法流程里涉及的运算类型很少——逐像素求三通道最小值、窗口最小值滤波、全局统计大气光、逐像素除法减法。公式看着不难,难点在于“局部窗口最小值”和“全图统计”这两类操作,在CPU上属于典型的数据密集访问,在FPGA里却能做成非常漂亮的流水线。
2.2 从浮点算法到定点硬件:量化、归一化与除法近似
算法最初用Matlab验证时全是float,到了FPGA就不可能直接float跑,成本太高。我在工程里把整个计算链改成了纯定点(fixed-point)。图像数据是8bit灰度或者24bit RGB,这个不用动。大气光A也用8bit表示(0~255)。真正需要小心的是透射率t的表示。
t在浮点算法里是0~1之间的数,我映射成8bit定点数,数值范围0~255,对应浮点0.0~1.0。后面恢复公式里需要除以t,在硬件里直接做除法会消耗大量DSP资源。最实用的做法是做一张倒数查找表(LUT),256个地址,每个存 1/t 的定点结果,这样就把除法变成了查表+乘法。代价是一个很小的Block RAM,换来全链路无除法,时序特别好收敛。
还有一个归一化细节。透射率估计时,min_c(I_c / A_c)这个操作里的除法也是按通道的。我在硬件里没直接对每个像素做除法,而是先把输入像素预缩放,让A变成2的幂次近似,用移位代替除法。A等于127、128这种数值时,除以A就近似于乘以 1/A,我用厂商IP核里的定点除法器或者倒数表统一处理,设计上是“单像素单周期输出”的标准流程。
2.3 算法裁剪:导向滤波简化成盒式滤波,实时性和画质的平衡
暗通道透射率估计有个先天毛病:在局部窗口内假设透射率恒定,导致恢复后的透射率图有块状边缘(Block Effect),直接恢复图像会看到一圈一圈的光晕伪影。原论文用soft matting或者导向滤波来细化透射率,但导向滤波涉及大核、大量乘法和正则化项,在FPGA里全尺寸实现代价很高。
我的做法是:把导向滤波简化成两个盒式滤波(Box Filter)的组合,结合乘法做个近似引导。严格来说这不是完整导向滤波,但对透雾来说效果已经足够。具体路径是先把透射率图送入一个7x7盒式滤波得到mean_t,再利用原始灰度图I和t的乘积再做一次盒式滤波,最后按导向滤波的公式组合。这套操作全部可以用行缓冲和滑窗实现,一个像素进来,流水线一拍出去,不打破原有视频时钟。
盒式滤波最大的好处是“可分离”——横向先做累加求均值,纵向再做一次,二维盒式滤波就变成了两次一维滤波。每行的窗口值只需维护一个累加器,进入新像素就加,离开旧像素就减,消耗资源极少。我在实际测试中,简化版和完整导向滤波的主观画质差距很小,但硬件面积差不多省了60%~70%。做实时透雾,建议优先走这条简化路线。
3. 系统架构与模块划分
3.1 整体处理链路:Dehaze模块在ISP Pipeline里的位置
透雾模块不是单独存在的,要插入完整的ISP链路里才有效果。一个典型的FPGA ISP Pipeline顺序大概是:Sensor输入(MIPI/LVDS)→ RAW域处理(坏点校正、黑电平、Demosaic)→ RGB域处理(白平衡、去雾、色彩校正、Gamma)→ YUV输出(缩放、编码/显示)。
我在设计时把Dehaze插在Demosaic和白平衡之后、Gamma之前。原因有两点:第一,去雾算法依赖的是RGB三通道的物理统计关系,必须在RGB域做;第二,放在Gamma之前是为了在线性光或接近线性的空间里做大气估计,否则Gamma压缩后的图像会破坏暗通道先验的统计特性。
另一个工程上的关键点:Dehaze模块需要一帧图像的统计信息(大气光A),而像素流是逐行扫进来的。这意味着要么缓存整帧后再处理(增加一帧延迟),要么用上一帧的统计值处理当前帧。考虑到实时视频相邻帧的大气光变化很缓慢,我直接采用“上一帧估计、当前帧使用”的策略,帧延迟只增加1行,非常适合监控类场景。
3.2 行缓冲还是帧缓存?Line Buffer设计的关键拆解
窗口类运算在FPGA里的经典结构是“行缓冲+滑动窗口”。以暗通道计算里的最小值滤波为例,如果窗口大小是7x7,我需要缓存7行图像数据。每进来一个新像素,最老一行的数据就可以丢掉,7个行缓冲中的数据形成了一个H型窗口,再对当前窗口内的49个像素求RGB三通道最小值。
行缓冲的具体实现,我用的是Xilinx FPGA里的Shift Register(SRL16)或者Block RAM。BRAM配置成“行长度x行长字节”的环形buffer,由读地址和写地址管理。选择SRL还是BRAM取决于资源分布和行长度:小分辨率用SRL,行数短;1080P、7行、每行1920x3字节,BRAM更划算。行缓冲换成DDR存储是最差的选择,访问延迟和带宽都不好控制,尽量把窗口数据控制在片内。
滑窗内部怎么取49个像素?每个时钟周期BRAM只能输出一个数据,窗口的49个点在时序上不会同时到达。一个常用技巧是把滑窗通过移位寄存器阵列实现:7个行缓冲的输出端各接一条7深的移位寄存器,这样每拍能同时输出7x7=49个数据。最小值树(Min Tree)对这49个数据做两两比较,深度为log2(49)≈6级,路径延迟很小,不影响工作时钟。
3.3 核心子模块拆解:暗通道、大气光、透射率与恢复
我把Dehaze整体拆成四个子模块,每个模块的功能和资源特点如下。
暗通道模块输入RGB,输出三通道最小值、再做局部窗口最小值。求三通道最小值只需要2个比较器,窗口最小值滤波则依赖于行缓冲和比较树。窗口尺寸我用的是7x7,这跟分辨率有关。720P以下可以适当放大到11x11,能使暗通道估计更稳定;1080P下窗口太大容易丢失边缘细节,7x7比较平衡。
大气光估计模块不能只看暗通道最亮的那一个像素,这样容易被白色车、白色建筑物干扰。正确做法是:统计当前帧暗通道直方图,从最高亮度端往下取前0.1%(比如1080P约2000个像素)的亮度均值,再用这些位置的原始像素均值作为大气光A。硬件实现里直方图统计用片上RAM加累加器,帧结束计算阈值位置,不需要复杂排序。
透射率估计模块负责把暗通道图变换为透射率图,包含通道归一化、0.95保留因子和定点化。然后透射率图经过简化的盒式滤波得到平滑版本。盒式滤波的窗口我选的也是7x7,理由是透射率图的低频特性匹配7x7比较合适,窗口太大会把物体的透射率边缘糊掉,太小则起不到抑制块状伪影的作用。
恢复模块最后做减法、乘法和截断:输入原始RGB像素和透射率值,查表得到1/t,做乘加,还原出J。这个模块是标准的算术流水线,4级左右,不存在反压。整套链路从模块边界来看干净利落,每个模块都带一个AXI4-Stream信号(valid/ready/last/user),方便接线和调试。
3.4 时序控制与帧率分析:1080P30实时可行性
做FPGA图像处理,最后要落到时钟和帧率上。1080P@30fps的像素时钟是74.25MHz(含消隐),每帧总像素约2200x1125=247.5万像素,有效像素1920x1080。Dehaze全链路如果每个像素只处理一次,且内部无阻塞,那么74.25MHz的输入时钟足够跑满。我在Vivado里的实现结果是:时钟约束80MHz,所有模块都收敛,最差路径落在透射率恢复模块的乘法链上,通过插入寄存器流水级解决了。
如果要做720P@60fps,像素时钟同样是74.25MHz左右,对FPGA逻辑的要求基本一样。真正的挑战来自外部DDR缓冲:如果算法中需要缓存多帧做时间域去雾,就会有带宽压力。我的单帧流式方案里,DDR只在帧间放一帧透射率统计值,带宽占用非常小,这也是FPGA透雾比GPU更省带宽的优势。
4. 实操过程与关键实现
4.1 数据路径、位宽设计与资源估算表
下面是我用的位宽表和资源估算,可以作为复现参考。
计算路径上的位宽是这样的:原始RGB为8bit;暗通道输出为8bit(RGB三通道最小值);大气光A为8bit;透射率t为8bit(0对应0.0,255对应1.0);倒数表输出16bit定点(格式8Q8,整数8位小数8位);恢复J输出16bit中间值,最后截断/饱和到8bit。
资源估算以Xilinx Artix-7 XC7A35T为例(足够跑1080P30),大概是这样:
| 模块 | LUT | FF | BRAM | DSP |
|---|---|---|---|---|
| 暗通道最小值滤波 | 820 | 460 | 3(7行x1920x24bit,实际用到BRAM约4.5块) | 0 |
| 大气光直方图统计 | 240 | 180 | 1(256桶) | 0 |
| 透射率估计+盒式滤波 | 1030 | 720 | 2(透射率行缓冲) | 2 |
| 恢复模块 | 640 | 520 | 1(倒数LUT) | 4 |
| 总体(含控制逻辑) | 2800 | 2100 | 11 | 6 |
这个规模在XC7A35T里只占不到三成LUT,留着大量余量给ISP其他模块。上面是7x7窗口、1080P的配置。我把参数总结成一个通用经验公式供你估算:每增加一行行缓冲,需要BRAM约 行像素x字节/块容量 块;盒式滤波每增加一个DSP,可以处理2路乘法。
4.2 接口与存储:MIPI/LVDS输入、DDR交互、FMC配置通道
实际工程中,Dehaze模块的上游和下游接口很重要。我们这个项目的原始数据来自MIPI CSI-2接口的Sensor,FPGA需要先做MIPI RX的物理层和协议层解包。MIPI控制器可以买IP核,也可以用开源方案,但需要注意MIPI的byte clock和pixel clock的跨时钟域处理——所有像素进入Dehaze模块前,我统一通过异步FIFO同步到74.25MHz像素时钟域,避免亚稳态。
接着是DDR读写的问题。由于我的Dehaze模块是单帧流式的,外存只存大气光统计值和少量参数,所以DDR占用非常小。但如果是红外加可见光融合透雾,或者要做时域透雾,就需要在FPGA里挂DDR3/DDR4控制器,设计成AXI4接口,此时带宽规划就要重新算。1080P30时,RGB888一帧约6.2MB,DDR3能轻松支撑3~5帧缓存,但额外增加的延迟和功耗是需要在项目早期评估的。
还需要考虑和MCU的互动。很多相机平台以STM32H743或是Zynq ARM做系统控制,FPGA做像素处理。MCU与FPGA之间用FMC并行总线通信,MCU写入去雾强度、窗口大小等参数,FPGA返回统计信息和状态。Dehaze的强度可以做成参数可调:通过改变透射率估计公式里的ω值来实现,ω大、去雾强但可能偏暗,ω小、画面自然但雾气残留多。我推荐在寄存器里开放这个旋钮。
4.3 仿真验证与方法:用ILA和模型对比定位中间结果
仿真和调试在整个开发周期里占一半时间。我先在Matlab里把浮点算法和小型定点模型的结果导出来,作为FPGA仿真行为的golden model。然后用Vivado的仿真环境跑Dehaze模块,输入一张480p的有雾测试图,逐模块比对暗通道、透射率图和恢复图。
比对时不必逐像素比对一模一样,因为盒式滤波的边界处理方式可能不同。我给了一个_PASS标准:中间结果的PSNR大于35dB,边界区域的差异允许放大。跑出波形图之后,还要穿插“反向验证”——把板级调试的ILA抓取数据导回Matlab,确认硬件中间值和仿真一致。ILA(Integrated Logic Analyzer)是Vivado里最强大的片上逻辑分析仪,可以抓取内部信号,我通常在暗通道输出、透射率输出、恢复输出各挂一个ILA,触发条件设为行同步信号,抓几行数据就够了。
4.4 硬件验证:上板测试流程和主观画质评估
仿真通过后,我开始上板测试。测试流程分三步:
第一步是静态图测试。我准备了一批不同雾浓度的JPG图片,转成BMP后通过串口或TF卡灌入FPGA的DDR,然后由测试模块按像素时钟读出,走完整条ISP+Dehaze链路,再从HDMI输出到显示器。这里最关键的是观察画面中间亮度区域是否有色偏、高光区域是否过曝、天空是否存在色带。
第二步是动态实时测试。接上MIPI Sensor,对准室外有雾场景(或者人为制造雾气的场景),观察输出视频的流畅度、去雾效果和色彩自然度。一个常见问题是雾浓度随时间变化,大气光估计的更新速度如何,我通过调节“统计帧更新系数”解决——默认0.3,更新太快会有闪烁感,太慢则跟不上场景变化。
第三步是算法效果的定量对比。拍下同一场景透雾开/关的对比视频,用一些客观指标如对比度(RMS contrast)、暗通道均值、边缘强度(Sobel梯度均值)来量化提升幅度,顺便把透雾前后的图像分别送入识别算法(比如车牌识别)看准确率变化。透雾通常能让一些弱边缘变得清晰,识别准确率有明显提升。
5. 常见问题与排查技巧实录
5.1 恢复图像块状伪影严重,怎么处理
我自己第一次跑通硬件时,输出的恢复图像有明显的分块感,屏幕上一块一块的,效果像低精度量化。排查后发现三个原因:第一是透射率图的盒式滤波窗口太小(当时用了3x3),作用范围不足,块状边缘没压平;第二是暗通道窗口和盒式滤波窗口大小不匹配,透射率图的噪声被放大;第三是恢复模块的1/t查表精度不够,256点8bit表在t偏小时步进太大。
解决办法也很直接:把透射率平滑窗口从3x3提到9x9,暗通道窗口保持7x7,同时把倒数表从8bit精度换成了16bit(8Q8),块状伪影肉眼几乎不可见。这里给一个经验:盒式滤波窗口建议是暗通道窗口的1.2~1.5倍,太小压不住块,太大细节全糊。
5.2 大气光估计不准:偏色与整体发灰的根源
另一个高频问题是恢复出来的图像蒙了一层灰,或者大面积偏蓝偏黄。大气光A估计不准是第一嫌疑。取暗通道最大像素亮度的方法最容易翻车——场景里有纯白物体(比如白车、白色建筑)时,A直接被拉到255,恢复图的整体亮度被压暗。正确的实现是前面讲的取前0.1%像素的均值,我实际测试后还加了一个约束:A的初始值不低于图像最大亮度的80%,不高于99%,防止最终结果过曝或过暗。
还有一类问题是A估计本身没问题,但天的地方恢复偏色。这是因为天空区域不满足暗通道先验,透射率被严重低估。我的处理方法是增加一个“天空保护”逻辑:当像素亮度同时高于阈值(比如220/255)且饱和度低于阈值时,强制透射率t不低于0.4,避免过激恢复。这个逻辑只占用几十个LUT,但对天空场景的画质提升极大。
5.3 资源占用爆表,如何做减法优化
Dehaze模块如果按“教科书”方式全做下来(浮点、大窗口、完整导向滤波),资源很容易超。我的实际优化策略是这样的:第一,把所有浮点运算全定点化;第二,用可分离盒式滤波代替二维卷积;第三,行缓冲里只存灰度图或者Y通道,不存RGB三通道,暗通道的灰度版本对透射率估计影响很小,因为暗通道本身的统计特性主要在亮度变化上。
还有一个容易被忽略的点:如果同一块BRAM既当行缓冲又当倒数表,Vivado综合时资源会打架。最好在设计早期就做好物理规划,把行缓冲和查找表分开。另外,统计大气光的直方图RAM可以复用帧缓冲,但要注意不同时钟域的访问仲裁问题,我建议直接独立分配,避免后期调试复杂度上升。
5.4 调试建议:先把模块切开,再看整体
最后分享一个调试习惯。我不建议一口气把所有模块全部连线后直接上板,否则出了问题根本不知道是哪一块。我的做法是逐级验证:先单独验证暗通道模块,用测试图产生模型比对;再验证大气光统计,打印整帧统计结果跟Matlab比对;第三步才是透射率和恢复。每个模块验证完再接入主链路。
上板调试时,如果发现画面花、闪、错行,先检查同步信号和像素时钟域是否正确,再怀疑运算逻辑。我遇到过一整个下午都查不出画面对不齐的问题,最后发现是行有效信号的有效长度比实际行多了两个像素,导致行缓冲错位。这类同步问题通过ILA抓行场信号一眼就能看出来。
我个人在实际项目中的体会是:FPGA透雾项目的难度不在算法有多深,而在于“物理世界的一帧一帧图像如何精确映射到硬件数据流”这件事。暗通道先验算法本身并不复杂,真正考验人的是行缓冲、窗口、流水线、跨时钟域这些硬件细节的把控。如果你也想做类似的项目,建议从小分辨率(480p)开始,先验证算法效果,再逐级提高分辨率。把行缓冲结构和大气光统计做扎实,整个Dehaze模块就已经成功了一大半。最后再补一句,调试过程中一定要保存好每一版仿真波形和比对结果,很多看似奇怪的问题,回翻波形时往往一眼就能看出因果。