简介:偏振图像去噪方向的 Python 项目实践资料,面向计算机视觉、图像分析与深度学习方向的学习者和研究者,围绕偏振图像增强与噪声抑制展开,可用于复现去噪流程、对比算法效果,或作为课题练手基础。包内共 38 个文件、约 906KB,以 15 个 py 脚本为核心,覆盖模型定义(PCN、SNA、RGBRN 等网络结构)、数据加载与变换、评价指标与损失函数、可视化工具等模块;14 个 pyc 为编译缓存,5 张 jpg 给出合成数据集、真实场景数据集与传感器阵列等效果示意,另含 txt 说明、Dockerfile 环境配置及 license 授权文件,便于快速搭建运行环境。已有 400 人学习下载。读者可据此了解偏振图像从数据读取、预处理到模型训练与指标评估的完整链路,借助内置数据集在不同噪声与光照条件下测试去噪方法,并参考 Dockerfile 与目录组织复现实验,适合需要一份可运行、可改动代码基线的人使用。
1. 偏振图像去噪的反直觉结论:先滤波再解算,DoLP 反而更差
把一张偏振原始图先转成灰度、用现成的去噪算子滤一遍、再解算偏振度,结果常常比不滤还糟——平滑区域的 DoLP 误差不降反升。原因不复杂:偏振图像去噪真正在处理四个偏振通道之间的差分关系,并非只处理一张图。噪声一旦进入差分项就被放大,后续再强的滤波器也只能在已经被污染的数值上做平滑。用 Python 搭这套链路之所以值得拆开讲,是因为它同时撞上两堵墙:物理层的光强采样约束,和信号层 Stokes 参数的非线性放大。搞清这条链路,你才知道该在哪一层处理噪声、哪一层必须靠标定,而不是盲目换一个更大的网络。
2. 偏振成像链路:一条光子噪声如何被复制成四份
2.1 微偏振阵列与 2×2 超像素的采样几何
分焦平面结构的做法是把一层线栅偏振片直接做在传感器靶面上,每四个相邻像素编成一组,方向依次是 0°、45°、90°、135°。好处很直接:单帧成像,不需要旋转偏振片,也不需要液晶调制,动态场景可以拍。代价全部体现在几何上——四个方向采样的是四个不同的空间位置。
这带来两个后果。第一个是空间分辨率降到四分之一,重建全分辨率的 DoLP 必须插值,插值本身就会把边缘糊掉一截。第二个更麻烦,叫瞬时视场误差:在物体边缘、纹理密集的地方,I0 和 I90 采自不同点,两者相减得到的 S1 里混进的是真实的光强梯度,并非偏振信息。这类误差看上去很像噪声,性质却是确定性的,跟场景结构死死绑定,任何滤波器都分不开。
所以“偏振图像去噪”这个词本身有歧义。真正要对付的是三类东西:随机噪声(光子散粒、读出)、固定模式噪声(各方向透过率不一致)、结构性误差(瞬时视场、插值)。只有第一类适合套 BM3D 那一套。
2.2 Stokes 解算的差分结构:噪声为什么会被放大 √2 倍
标准解算只有三行:
import numpy as np def stokes_from_subimages(i0, i45, i90, i135): """四个偏振方向的子图 -> Stokes 矢量。 输入必须已完成暗场/平场校正,且为 float32 或更高精度,避免 uint8 溢出。 """ s0 = (i0 + i45 + i90 + i135) * 0.5 # 总光强,系数 1/2 来自理想偏振片只过半光 s1 = i0 - i90 # 0/90 差分 s2 = i45 - i135 # 45/135 差分 return s0, s1, s2 def dolp_aolp(s0, s1, s2, eps=1e-6): s0_safe = np.maximum(s0, eps) # 暗区保护,否则 DoLP 直接炸成天文数字 dolp = np.sqrt(s1 ** 2 + s2 ** 2) / s0_safe aolp = 0.5 * np.arctan2(s2, s1) # 值域 (-pi/2, pi/2] return np.clip(dolp, 0.0, 1.0), aolp0.5这个系数在不同文献里约定不一致,有人写成S0 = I0 + I90(等价形式)。只要 S0 与 S1、S2 用同一套约定,DoLP 就一致;混用两套约定是最常见的低级错误,表现是 DoLP 整体偏大一倍或偏小一半。
真正的问题是方差怎么传。假设四个方向的光强独立、方差同为 σ²(散粒噪声限制下 σ² 近似等于光子数),那么 Var(S0) = 4σ²·(1/2)² = σ²,而 Var(S1) = 2σ²。也就是说 S1、S2 的信噪比从起点就比 S0 低 3 dB,这不是算法问题,是差分运算的固有代价。
再往后是非线性放大。DoLP = √(S1²+S2²)/S0,它是 S1、S2 的模长除以 S0,在 S0 小的暗区相对误差急剧变大;AoLP = 0.5·atan2(S2, S1),在 DoLP 趋近 0 的地方根本没有定义,任何一点噪声都会让角度随机跳变。举个例子:某像素 S0 = 1000 个光电子,DoLP ≈ 1,则 |S1| ≈ 1000,σ(S1) ≈ 44.7,DoLP 的相对误差大约 4.5%。同一套噪声下把 DoLP 换成 0.05,绝对误差不变,相对误差直接变成 90%。天空散射、近 Brewster 角的水面反射看上去“噪声特别大”,真正发生的是信号变小了,并非噪声变大。
2.3 噪声预算:散粒噪声、读出噪声与非理想偏振片的消光比
把噪声拆成三层看会清楚很多。散粒噪声服从泊松分布,方差随光强线性增长,亮区主导;读出噪声近似高斯,与光强无关,暗区主导;固定模式噪声来自线栅加工误差和像素响应非均匀性,跟场景无关,可以标定掉。三者的处理方式完全不同,混在一起调参只会互相打架。
| 噪声源 | 统计特性 | 在 Stokes 域的表现 | 能否靠滤波解决 |
|---|---|---|---|
| 光子散粒噪声 | 泊松,方差∝光强 | S1/S2 方差是 S0 的 2 倍 | 可以,但会牺牲分辨率 |
| 读出噪声 | 近似高斯,与光强无关 | 暗区 DoLP 出现随机高值 | 可以,配合多帧更有效 |
| 固定模式噪声 | 确定性,与场景无关 | 全图出现与内容无关的纹理 | 不能,必须平场标定 |
| 消光比不足 | 系统性偏差 | DoLP 被整体低估,高偏振区压得最狠 | 不能,只能建模补偿 |
最后一行的性质要单独强调:非理想线栅对“不该通过”的偏振方向存在泄漏,等效于给差分信号乘上一个小于 1 的对比度因子。这是偏差,并非方差。方差能靠滤波压下去,偏差只能靠标定和物理模型修回来。分不清这两者,是偏振去噪里最烧时间的一类弯路。
3. Python 去噪流水线:从 mosaic 解包到 Stokes 重建
3.1 数据集的组织方式与 2×2 超像素解包
资源里附带的数据集按“一次曝光一条样本”来组织,原始数据是单通道 mosaic,不预先拆成四个文件。这样存体积小、保留像素间的物理邻接关系;代价是所有预处理都得自己写,解包顺序一旦搞反,后面的标定和训练全部白做。
关键约定只有一条:mosaic 里坐标 (r, c) 的像素属于哪个偏振方向。常见排布是(偶行偶列)= 0°,(偶行奇列)= 45°,(奇行奇列)= 90°,(奇行偶列)= 135°。注意 90° 落在右下角而不是左下角,这个反直觉的排布被不少开源实现当成默认,也正是错位的高发点。
import numpy as np def unpack_mosaic(mosaic): """单通道 mosaic -> 四张 (H/2, W/2) 子图。 排布约定:(0,0)->0deg (0,1)->45deg (1,1)->90deg (1,0)->135deg """ assert mosaic.ndim == 2, "mosaic 必须是单通道" h, w = mosaic.shape assert h % 2 == 0 and w % 2 == 0, "先裁成偶数尺寸,否则整幅图像错位一格" sub = { 0: mosaic[0::2, 0::2], # 偶行偶列 45: mosaic[0::2, 1::2], # 偶行奇列 90: mosaic[1::2, 1::2], # 奇行奇列 135: mosaic[1::2, 0::2], # 奇行偶列 } return sub用步长为 2 的切片而不是双重循环,是 numpy 里唯一值得坚持的写法:切片返回视图,不复制内存,后面接浮点运算时再astype(np.float32),峰值内存能省一大截。断言偶数尺寸并非形式主义——奇数尺寸下最后一行凑不成完整超像素,强行解包会让之后的每一次索引都偏一格,症状是整幅 DoLP 图出现周期性条纹,而你会以为是去噪算法的问题。
注意:如果数据集里同时给了全分辨率的强度参考图(去掉偏振片拍摄),不要拿它当 mosaic 解包,两者尺寸对不上,直接 reshape 会得到一张被切碎的画面。
3.2 暗场、平场与四通道配准:一次性标定省掉一半误差
暗场减去偏置,平场去除各方向透过率差异,标准写法是(raw - dark) / (flat - dark)。这一步在普通成像里属于可选项,在偏振成像里是必选项:四个偏振方向之间的增益不一致,会直接变成虚假偏振,在均匀白板上都能测出几个百分点的 DoLP。
import numpy as np def calibrate(raw, dark, flat, eps=1e-6): """raw/dark/flat 均为同尺寸 float32 mosaic。 平场必须用与样本同偏振态的光源采集(通常是积分球或均匀漫射板)。 """ raw = raw.astype(np.float32) dark = dark.astype(np.float32) flat = flat.astype(np.float32) gain = np.maximum(flat - dark, eps) # 分母保护,避免纯黑像素产生 inf return (raw - dark) / gain配准处理的是另一个问题:四个子图之间存在固定的亚像素位移,来源是阵列加工时的对准误差。由于是固定偏移,只需要用一组标定图像估一次,之后所有帧复用同一个位移量,不需要逐帧配准。估计方法用相位相关,采样区域要挑平滑区,边缘会污染相关峰。
import cv2 import numpy as np def estimate_shift(ref, mov, min_response=0.2): """估计两张子图之间的亚像素位移,返回 (dx, dy, 可信度)。""" ref = ref.astype(np.float32) mov = mov.astype(np.float32) (dx, dy), resp = cv2.phaseCorrelate(ref, mov) if resp < min_response: # 相关峰太弱,通常是运动模糊、曝光不足或场景过于平坦 return None return dx, dy, resp返回的resp是相关峰值,低于 0.2 基本说明这一帧不可信,应该整帧丢弃而不是硬配。这个阈值不是玄学,取决于场景纹理丰富度,实拍数据上我一般先用一批标定帧统计一下分布再定。
3.3 去噪该插在哪一层:mosaic 域、子图域还是 Stokes 域
三个位置各有代价。mosaic 域:输入是一张图,任何单通道去噪器都能直接用,但它对“相邻像素属于不同偏振方向”这件事一无所知,会把差分结构当成噪声抹掉。子图域:先解包再分别去噪,缺点是四个通道的抑制程度不一致,重建出的 DoLP 会出现块状偏差。Stokes 域:先解算再去噪,物理意义最清晰,但 S1、S2 已经被放大过一轮,去噪器面对的是更差的输入。
| 处理位置 | 噪声抑制 | 伪影风险 | 计算量 | 适用场景 |
|---|---|---|---|---|
| mosaic 域 | 中 | 高,破坏差分结构 | 最低 | 只关心 S0 的场合 |
| 子图域 | 中高 | 中,通道间不一致 | 中 | 散粒噪声主导的暗场景 |
| Stokes 域 | 高 | 低,可加物理约束 | 高 | 需要精确 DoLP/AoLP 的场合 |
| 两级串联 | 最高 | 低 | 最高 | 高质量离线重建 |
我的偏好是两级串联:先用保守的小窗口滤波器压掉子图里最猛的散粒噪声,解算后再在 Stokes 域用 S0 引导精修 S1、S2。这样既不会在差分之前破坏结构,又能在信噪比最低的位置补上第二遍。
def pipeline(mosaic, denoise_sub, denoise_stokes, mode="stokes", eps=1e-6): sub = unpack_mosaic(mosaic) # 第一级:子图域轻去噪,窗口别开大,3x3 到 5x5 足够 if mode in ("subimage", "stokes"): sub = {k: denoise_sub(v) for k, v in sub.items()} s0, s1, s2 = stokes_from_subimages(sub[0], sub[45], sub[90], sub[135]) # 第二级:Stokes 域精修,S0 作为引导通道传入 if mode == "stokes": s0, s1, s2 = denoise_stokes(s0, s1, s2) return dolp_aolp(s0, s1, s2, eps)mode的三种取值对应上表的三条路线,denoise_stokes需要接受(s0, s1, s2)三元组并按通道分别或联合处理。第一次跑通建议先用mode="subimage"加一个保守的维纳滤波,拿到一版基准 DoLP 图,再去对比更复杂的方案,否则很难判断提升来自网络还是来自标定。
4. 偏振感知去噪的机制:为什么损失函数比网络结构更决定成败
4.1 强度引导的残差学习:让 S0 当 S1/S2 的辅助引导
S0 是四通道之和,信噪比最高;S1、S2 是差分,信噪比最低。三者来自同一场景、同一次曝光,边界位置高度一致。让 S0 当引导图,把结构信息传给 S1、S2,有物理依据,并非随手加一个 trick。
但直接拿 S0 的梯度去做联合滤波会失败:金属、水面、塑料这些材料的偏振特性有自己的纹理,比如应力条纹、Brewster 角附近的分布,这些结构在 S0 上根本看不见。纯引导会把它们当噪声抹平,重建出的 DoLP 图过于光滑,好看但错。
常见的实现思路是残差学习:网络回归“含噪 S1 减去干净 S1”这个残差,同时把 S0 作为额外输入通道拼进去。残差的能量集中在噪声和边缘两处,网络更容易学到“哪里必须保留、哪里可以削弱”。我一般会这么理解:引导告诉网络别在哪里随意修改,残差告诉它需要修改多少。直接回归干净图像等于让网络自己摸索这两件事,收敛更慢,也更容易过平滑。
4.2 角度项的 2θ 变换:避开 atan2 的相位跳变
偏振角有 π 周期性,θ 和 θ+180° 描述同一个偏振态。直接对角度做 L1 回归,会在 ±90° 的跳变处产生巨大梯度,训练直接发散,loss 曲线表现为周期性尖刺。
两种等价处理:一是把角度编码成 (cos2θ, sin2θ) 双通道,回归在单位圆上做;二是保留 S1、S2 作为输出,在损失里用1 - cos(2Δθ),它天然处理周期性且处处可导。第二种更省事,因为 S1、S2 本来就是线性量,网络不必额外学一层三角函数。
import torch import torch.nn.functional as F def polarization_loss(pred, target, w_int=1.0, w_pol=1.0, w_ang=0.5): """pred/target: (B, 3, H, W),通道顺序 [S0, S1, S2]""" l_int = F.l1_loss(pred[:, 0], target[:, 0]) # 强度项,占主导 l_pol = F.l1_loss(pred[:, 1:], target[:, 1:]) # 偏振分量项 a_p = torch.atan2(pred[:, 2], pred[:, 1]) a_t = torch.atan2(target[:, 2], target[:, 1]) l_ang = (1.0 - torch.cos(2.0 * (a_p - a_t))).mean() # 对 pi 周期免疫 return w_int * l_int + w_pol * l_pol + w_ang * l_ang三个权重里w_ang最需要克制,超过 0.5 之后网络会为了角度一致性牺牲 DoLP 幅值,典型症状是重建结果的 DoLP 整体偏小、高偏振区被压平。w_int也不宜过大,S0 的能量占比太高,稍微加重就会让网络退化成普通灰度去噪器,偏振分量学不到东西。
4.3 评价指标:PSNR 会骗人,DoLP 绝对误差与 AoLP 角误差才是真信号
在 S0 上报 PSNR 是最没信息量的做法。S0 占总能量九成以上,PSNR 基本由它决定,S1、S2 的好坏被完全掩盖。见过不少方案 PSNR 涨了 2 dB,实际 DoLP 误差反而变大,原因是网络把偏振细节当噪声抹了。
有效的指标要分层。DoLP 的平均绝对误差看整体幅值;AoLP 的角误差只在 DoLP 大于阈值的像素上统计,因为低偏振区的角度本来就没有物理意义;再加一个“伪偏振率”,统计真值 DoLP 接近 0 的区域里预测值超过 0.1 的像素占比,它直接对应下游反演会不会凭空造出结构。
import numpy as np def eval_metrics(dolp_p, aolp_p, dolp_t, aolp_t, mask_thr=0.1): mae = np.abs(dolp_p - dolp_t).mean() mask = dolp_t > mask_thr # 只在有偏振信号的区域算角度误差 d = 2.0 * (aolp_p[mask] - aolp_t[mask]) ang_err = np.degrees(np.abs(np.arctan2(np.sin(d), np.cos(d)))) / 2.0 fake = ((dolp_t < 0.02) & (dolp_p > 0.1)).mean() # 伪偏振率 out_of_range = ((dolp_p < 0) | (dolp_p > 1)).mean() return mae, ang_err.mean(), fake, out_of_range角度误差先乘 2、取完环绕再除 2,是处理 π 周期量的标准手法,直接算差值平均会在 ±90° 附近得到虚假的巨大误差。越界率常被忽略但很关键:物理上 DoLP ∈ [0,1],任何预测出负值或大于 1 的结果都说明模型没学到物理约束,这种模型换个场景必然失效。
5. 避坑与排查:偏振去噪里最容易翻车的五件事
5.1 数据与标定环节的三个隐蔽错误
第一件,超像素相位错一格。现象是 DoLP 图上出现规则的棋盘格或斜向条纹,去噪之后条纹依然清晰。原因是解包时把 90° 和 135° 的位置搞反,或者图像被裁成奇数尺寸后整体错位。解决办法是拿一片均匀偏振参考片(或者一块白板加线偏振片)拍一组旋转序列,检查四个子图的均值是否随旋转角呈 cos2θ 变化,相位不对会立刻暴露。
第二件,拿 8-bit sRGB 图直接算 Stokes。现象是暗区 DoLP 系统性偏高,噪声看着比实际更猛。原因是 gamma 压缩是非线性的,差分运算在非线性空间里不成立,暗部的微小差异被放大成显著的偏振信号。解决办法是在线性域做全部运算,或者干脆保留 raw 的 16-bit 数据,只在最终可视化时才转 sRGB。
第三件,跳过平场直接去噪。现象是图像上有一层跟内容无关的固定纹路,滤波器怎么调都去不掉。原因是微偏振阵列各方向的透过率和消光比不一致,这是确定性偏差,不是随机噪声。解决办法是用均匀光源采一组平场,并用已知 DoLP 的参考片反推消光比,做一次增益补偿。
5.2 训练与评估环节的两个陷阱
第四件,同一场景裁块后随机划分训练集和验证集。现象是验证集 PSNR 高得离谱,换一个场景精度断崖式下跌。原因是数据泄漏,同一场景的纹理、光照、材质被网络记住了,验证集实际上是训练集的近邻。解决办法是按场景分组划分,用 GroupKFold 之类的策略,确保同一场景的所有裁块只出现在一边。
第五件,只在高 DoLP 区域做评估。现象是各项指标都漂亮,实拍天空或玻璃时 DoLP 出现负值或大于 1。原因是低偏振区是病态区,误差绝对值小但相对误差极大,被平均指标稀释掉了。解决办法是分层评估,把 DoLP 分成 0~0.05、0.05~0.2、0.2 以上三档分别统计,再叠加前面提到的越界率,问题立刻现形。
提示:这五条里有三条属于标定问题、两条属于数据划分问题,没有一条能靠换网络结构解决。偏振去噪的调参顺序应该是先标定、再划分、最后才是模型。
6. 设计取舍:算力、保真与可复现之间怎么选
偏振去噪的取舍跟普通图像去噪不太一样,最大的分歧点在于“编造”的代价。普通去噪里,网络脑补出一点纹理通常无伤大雅;偏振测量里,凭空生成的 DoLP 结构会被下游当成真实的物理量,拿去做应力分析、材质分类或者折射率反演,错得很安静。所以我评估任何方案时,第一道关卡是伪偏振率,第二道才是 PSNR。
第二个取舍是算力与精度的配比。Stokes 域的三通道网络比 mosaic 域的单通道网络参数量多出接近三倍,但实测提升往往集中在低偏振区那一小片面积上。如果下游只关心高偏振目标,一步到位的复杂网络性价比并不高;反过来,如果要做全图的偏振态分类,低偏振区恰恰是分类边界最容易出错的地方,这时候多花的算力是值的。
| 路线 | 保真能力 | 计算开销 | 复现难度 | 适合的下游用途 |
|---|---|---|---|---|
| 子图域滤波 + 解算 | 中,通道间易不一致 | 低 | 低 | 快速预览、粗筛 |
| Stokes 域引导滤波 | 中高,边缘保护好 | 中 | 低 | 常规定量分析 |
| 残差网络 + 角度损失 | 高,需防伪偏振 | 高 | 中,依赖标定质量 | 高精度反演 |
| 多帧平均 + 单帧去噪 | 最高,接近物理极限 | 取决于帧数 | 高,需静止场景 | 离线标定与验证 |
第三个取舍是复现性。偏振数据的价值有一半在标定参数里,暗场、平场、消光比、通道位移这四项如果不跟数据一起走,换台机器重新采一遍就完全是另一套数值,别人拿代码也跑不出你的结果。资源里附带的数据集把原始 mosaic 和采集参数一起打包,这一点比多给几百张样本更有用——它让结果可以被别人复算,而不是只能被相信。
最后给一个我自己常用的验证习惯:不要只拿真实数据看指标,做一组合成噪声注入。选一张高信噪比的均值图当无噪真值,按泊松加重高斯注入已知强度的噪声,跑一遍去噪流水线,看输出的 DoLP 偏差随噪声强度怎么变化。这条闭环能区分“网络真的在降噪”和“网络在适应这批数据的统计特性”,后者换场景必崩。从那以后我每次拿到新的偏振数据集,都强制先走一遍这个注入验证再谈模型,省下的返工时间远比写脚本的时间多。希望帮到你。
本文还有配套的精品资源,点击获取