029、多帧降噪与VST方差稳定变换低照度场景下的信噪比提升策略一、一个让我熬夜三天的调试问题去年做某款旗舰手机的前置摄像头调试客户要求在1lux照度下输出“可用的”预览画面。你懂的这种需求在项目初期听起来像天方夜谭——1lux什么概念差不多就是月光下看报纸的亮度。Sensor那边给到的原始信号噪声已经和信号差不多大了直方图拉出来就是一片“雪花”。我一开始的想法很简单多帧降噪嘛帧数堆上去不就完了于是写了8帧的均值降噪效果确实有改善但问题来了——暗部区域出现了明显的“块状伪影”而且运动物体的拖影让人头晕。更糟糕的是在画面从暗到亮的过渡区噪声反而被放大了。这就是典型的“方差非均匀性”问题低照度下噪声的方差和信号强度相关简单平均只会让暗部噪声被“压扁”亮部噪声被“拉伸”。二、为什么直接多帧平均会翻车先说说噪声模型。在CMOS sensor里光子散粒噪声服从泊松分布它的方差等于均值。加上读出噪声、量化噪声这些高斯成分整体噪声可以近似为总方差 k * 信号 常数这个k是增益相关的系数常数项是读出噪声基底。你看噪声方差和信号强度是线性关系——亮的地方噪声方差大暗的地方方差小。直接做多帧平均相当于给所有像素的噪声方差“一视同仁”地除以帧数N。但问题在于暗部区域的噪声方差本来就小平均后噪声水平确实降低了可信号也弱信噪比提升有限亮部区域噪声方差大平均后残留的噪声在视觉上反而更明显。更坑的是当场景中有高动态范围时比如夜景里的霓虹灯和阴影亮部和暗部的噪声特性差异巨大直接平均会导致画面出现“噪声纹理不一致”的割裂感。我那次调试翻车就是因为没处理这个方差非均匀性。三、VST把噪声“拉平”再处理Variance Stabilizing Transform方差稳定变换核心思想很简单找一个数学变换让变换后的数据方差变成常数不再依赖信号强度。这样后续的多帧平均就能“公平”地处理所有像素。对于泊松-高斯混合噪声最常用的VST是Anscombe变换。原始形式是z 2 * sqrt(x 3/8)这里x是原始像素值假设已经去除了黑电平。变换后的z其方差近似为1在信号强度不太低的情况下。但实际sensor数据里还有读出噪声所以更实用的版本是广义Anscombe变换z 2 * sqrt(max(0, x 3/8 sigma_read^2))sigma_read是读出噪声的标准差需要从sensor的暗帧数据里标定出来。别问我怎么标定——拿个镜头盖拍100帧暗帧算每个像素的方差取中位数开方。简单粗暴但管用。四、实战中的VST多帧降噪流程我现在的标准做法分四步每一步都有坑第一步噪声参数标定这里踩过坑。不要直接用sensor datasheet里的值那是在实验室理想条件下测的。实际模组组装后读出噪声会受电源纹波、温度、甚至PCB走线影响。我习惯在产线校准阶段用黑体炉或者干脆用暗箱拍一组不同增益下的暗帧拟合出噪声模型参数。代码里大概这样# 别这样写直接硬编码噪声参数# sigma_read 2.5 # 这是datasheet里的值实际可能差一倍# 正确做法从暗帧数据拟合defcalibrate_noise(dark_frames,gains):# dark_frames: shape [N, H, W]N帧暗帧# gains: 对应的增益值列表sigma_read_list[]fori,gaininenumerate(gains):frame_varnp.var(dark_frames[i],axis0)# 逐像素方差# 取中位数避免坏点干扰sigma_read_list.append(np.sqrt(np.median(frame_var)))# 拟合sigma_read与gain的关系通常是线性# 这里踩过坑不要直接用均值中位数更鲁棒returnnp.polyfit(gains,sigma_read_list,1)第二步VST变换对每一帧原始数据做变换。注意边界处理——像素值可能为0或负值经过黑电平校正后sqrt里要加max保护。defanscombe_transform(frame,sigma_read):# 这里踩过坑直接sqrt会导致负值变NaNxnp.maximum(frame3/8sigma_read**2,0)return2*np.sqrt(x)第三步多帧平均在变换域里做平均。这里有个细节帧数N的选择。N越大降噪效果越好但运动模糊也越严重。我一般根据场景亮度动态调整亮的地方用少帧2-4帧暗的地方用多帧8-16帧。但别搞得太复杂简单的帧数查表就行。defmulti_frame_average(frames_transformed,motion_maskNone):# frames_transformed: [N, H, W] 已经VST变换后的帧# motion_mask: 运动区域标记1表示运动0表示静止ifmotion_maskisNone:returnnp.mean(frames_transformed,axis0)else:# 运动区域用更少的帧避免拖影# 别这样写直接对所有帧平均static_meannp.mean(frames_transformed[~motion_mask],axis0)# 运动区域用当前帧current_frameframes_transformed[-1]returnnp.where(motion_mask,current_frame,static_mean)第四步逆VST变换平均后的数据需要变换回原始域。逆Anscombe变换没有精确的解析解但有个近似公式definverse_anscombe(z):# 这里踩过坑直接平方再减回去会有偏差# 使用精确无偏估计Asymptotic unbiased estimatory(z/2)**2-3/8# 对于低信号区域需要修正# 别这样写直接返回y# 正确做法根据z值做分段处理maskz2.5# 低信号区域y[mask](z[mask]/2)**2-3/80.5/(z[mask]/2)# 修正项returnnp.maximum(y,0)五、实际效果与调优经验这套流程跑通后1lux下的信噪比从原来的18dB提升到了28dB左右暗部块状伪影基本消失。但有几个坑必须提坑1运动检测的阈值VST变换后的噪声方差是常数所以运动检测的阈值可以统一设置。我一般用3倍sigma变换域里sigma≈1超过3的认为是运动。但注意如果场景里有闪烁光源比如LED灯帧间的亮度变化会被误判为运动。解决办法是加一个频率滤波器或者干脆在闪烁光源区域降低降噪强度。坑2计算量VST变换涉及sqrt运算在移动端DSP上很费。我后来改用查表法——把0-1023的像素值对应的变换结果预先算好存成LUT运行时直接查表。1024个entry每个entry 16bit才2KB性价比极高。坑3与HDR的配合如果同时开启HDR比如多帧合成HDRVST要放在HDR合成之前还是之后我的经验是先做VST再做HDR合成。因为HDR合成本身也是一种多帧处理在变换域里做可以保证噪声特性一致。但注意HDR的帧曝光时间不同噪声参数也不同需要分别标定。六、个人经验性建议别迷信理论公式Anscombe变换的精确形式在信号极低时1DN会失效。我实测发现对于12bit sensor当原始像素值小于10时直接做VST反而引入偏差。这时候我改用“局部均值局部方差”的自适应方法——对每个像素用周围3x3窗口估计信号强度再决定是否做VST。帧数不是越多越好超过16帧后信噪比提升开始饱和但运动模糊和存储开销线性增长。我一般设上限12帧配合运动补偿光流法可以再提2-3帧但代价是功耗。调试时用“分屏对比”把画面分成左右两半左边用传统多帧平均右边用VST多帧平均。这样能直观看到暗部细节和噪声纹理的差异。我那次熬夜三天最后就是用这个办法说服了客户——他们一眼就看出了VST版本在暗部区域的“干净”。别忘了温度补偿sensor温度升高时读出噪声会增大。我见过最夸张的情况手机连续录像30分钟后噪声水平翻了一倍。所以噪声参数要随温度动态调整或者干脆用更保守的初始值。最后一条也是最重要的多帧降噪不是万能的。如果场景里物体在运动或者有手抖再好的VST也救不了。这时候要考虑和OIS/EIS的配合——先做电子防抖对齐帧再做VST降噪。顺序不能反否则对齐误差会被VST放大。这套方案后来被用在了三款旗舰机上效果都还不错。但每次调试新平台我都会重新标定噪声参数——sensor工艺变了读出噪声特性就变了VST的参数也得跟着变。这就是调试的常态没有一劳永逸的公式只有不断踩坑、填坑的过程。