偏振图像去噪实战:从Stokes参数到Python全流程解析
简介面向偏振图像处理与去噪研究的Python实现资源适合计算机视觉、图像分析与机器学习方向的开发者和研究人员。资源围绕偏振图像增强与噪声抑制展开既提供了可复用的模型代码与数据集也包含数据加载、训练、评估与可视化等完整流程便于调研算法性能、复现实验或作为课题脚手架。压缩包共38个文件约906KB其中15个py源码覆盖数据读取、模型定义、指标计算和辅助工具等关键模块14个pyc为本地编译产物5张jpg为合成与真实场景样例图另有txt、Dockerfile和LICENSE帮助配置运行环境与明确使用边界。目前已有396人浏览学习。通过该资源读者可以搭建偏振图像处理管线在多种网络结构如渐进式修复、自注意力类去噪模型上比较去噪效果并可基于容器化环境快速开展实验进而针对不同噪声条件调整网络或后处理策略。1. 偏振图像去噪为什么普通去噪套路在这里频频失灵偏振图像去噪这个需求最初不是从论文里冒出来的而是偏振相机装到机器视觉工位之后大家发现普通去噪方法处理完的图“看起来很干净”但下游分割、检测照样翻车。偏振图像的信息密度藏在差值里S1 I0 - I90S2 I45 - I135差分一算传感器读出噪声和光子噪声被成倍放大想要保留有用的偏振信息就不能按普通灰度图那套思路去噪。这篇笔记围绕如何用 Python 跑通偏振图像去噪的全链条展开偏振数据怎么读、去噪算法怎么选、数据集怎么准备、哪些参数必须单独调以及几个会让人重跑三遍实验的坑。2. 偏振数据怎么读Stokes参数、噪声模型与Python打开方式2.1 Stokes是一个4维向量去噪的主要对象是谁偏振图像的底层数据不是常见的 RGB 三通道而是光强在不同偏振方向上的测量值。分焦平面DoFP偏振相机拍一帧马赛克排列的微偏振片会把光线拆成 0°、45°、90°、135° 四个方向这四张子图才是原始观测。绝大多数偏振算法并不直接处理这四个方向而是转成 Stokes 矢量S0 代表总光强S1 和 S2 代表线偏振分量S3 代表圆偏振普通场景里非常弱一般直接忽略。后续计算线偏振度 DoLP 和偏振角 AoP都需要先拿到 S0、S1、S2。这里产生第一个关键认知去噪对象不是单独一张灰度图而是一组三个相互关联的通道。S0 相当于普通光强图噪声形态和我们熟悉的图像噪声接近但 S1、S2 是两个差分通道原始信号经过相减之后传感器读出噪声和暗电流噪声没有被抵消反而因为除以了较小的偏振差值而显得刺眼。在室内弱光场景S1、S2 上的噪声甚至会呈现出“颗粒状条纹”这是普通 RGB 去噪任务里根本见不到的形态。我一般不建议直接把四方向图丢给去噪网络理由稍后展开。先记住结论偏振去噪的主战场是 Stokes 域重点是 S1 和 S2。下文所有代码和参数讨论都以这个为前提。2.2 用Python把偏振RAW还原成S0/S1/S2拿到偏振相机的 RAW 或四张方向图之后第一步是把它们组合成 Stokes 通道。以下是我常用的一组函数输入可以是相机输出的 2x2 马赛克排布也可以是已经拆好的四方向子图。import numpy as np import cv2 def demosaic_polar(raw_2x2, width, height): 入参 raw_2x2: (H/2, W/2, 4) 的四方向强度图通道顺序为 [0, 45, 90, 135]。 相机的2x2微偏振阵列把图像切成四份每份是半分辨率的强度图。 这里用双线性插值把四个方向都恢复到原始分辨率避免后续Stokes计算时尺寸不齐。 h, w raw_2x2.shape[0] * 2, raw_2x2.shape[1] * 2 dirs [] for i in range(4): up cv2.resize(raw_2x2[..., i], (w, h), interpolationcv2.INTER_LINEAR) dirs.append(up) return np.stack(dirs, axis-1) # 返回 (H, W, 4) def stokes_from_dirs(imgs): imgs: (H, W, 4) 依次为 I0, I45, I90, I135 返回 S0, S1, S2, DoLP, AoP I0, I45, I90, I135 imgs[..., 0], imgs[..., 1], imgs[..., 2], imgs[..., 3] S0 0.5 * (I0 I45 I90 I135) S1 I0 - I90 S2 I45 - I135 DoLP np.sqrt(S1 ** 2 S2 ** 2) / (S0 1e-6) AoP 0.5 * np.arctan2(S2, S1) * 180.0 / np.pi return S0, S1, S2, DoLP, AoP这里有两个容易出错的地方。第一S0 的系数是 1/2不是 1因为四方向平均光强才是总光强写错系数会让后续 DoLP 整体偏小。第二插值一定要在计算 S1、S2 之前做如果先拿半分辨率算差分再上采样偏振角会多出一圈锯齿伪影。工程上如果相机已经给出了配准后的四方向图这一步可以直接跳过但许多公开偏振数据集给的还是马赛克原图这个函数就成了绕不开的前处理。2.3 先学会合成噪声才能定量调参很多场景没有现成的成对去噪数据调滤波器参数时只能靠肉眼。更靠谱的做法是先给干净图像叠加已知噪声这样每个参数改动都能算出精确的降噪增益。偏振图像的噪声模型比普通图像多一层光子散粒噪声服从泊松分布读出噪声和热噪声近似高斯分布叠加之后就是“泊松高斯”混合噪声。def synthesize_noise(clean, shot_gain10.0, read_sigma2.0): clean: 干净的S0/S1/S2单通道图像 shot_gain: 散粒噪声强度系数值越小光子噪声越明显 read_sigma: 读出噪声标准差单位与图像灰度一致 noisy np.random.poisson(clean / shot_gain) * shot_gain noisy noisy np.random.normal(0, read_sigma, sizeclean.shape) return np.clip(noisy, 0, None)合成噪声时建议三个通道分开调参。S0 的光子噪声占主导shot_gain 往小设S1、S2 因为做过差分读出噪声被放大read_sigma 要比 S0 明显更大。很多新手用同一组噪声参数生成三通道训练数据模型学到的是错误的通道相关性到了真实相机上立刻失灵。这一步虽然简单却是后面所有定量评估的地基值得多花半小时把参数和真实数据对齐。3. 从OpenCV到U-Net偏振去噪的算法选型与落地代码3.1 先跑通基线OpenCV三种空域滤波任何去噪任务都应该先跑基线偏振图像也不例外。基线的作用不是拿高分而是确认“S1/S2 通道上的噪声到底是高频斑点还是结构性伪影”。OpenCV 里的高斯、中值、双边滤波各用一个函数就能跑参数也直观。import cv2 def run_baseline_filters(im, filter_typebilateral, d7, sigma_color30, sigma_space30): if filter_type gaussian: return cv2.GaussianBlur(im, ksize(7, 7), sigmaX1.5) elif filter_type median: return cv2.medianBlur(im, d5) elif filter_type bilateral: return cv2.bilateralFilter(im, dd, sigmaColorsigma_color, sigmaSpacesigma_space) return im跑基线时要分通道测试不要一次性把 S0/S1/S2 叠成三通道再滤波。实测下来S0 通道用高斯滤波就能获得不错的平滑效果但 S1 通道对边缘敏感高斯滤波会把偏振边界的梯度抹掉表现为 DoLP 结果上出现一圈黑边。中值滤波对 S1 的椒盐状噪声更友好代价是分辨率损失。双边滤波虽然能保边它的两个 sigma 参数对 S1/S2 特别敏感sigma_color 设置过小等于没滤波设置过大则产生“油画”伪影。基线完成后把三张结果图并排看一眼你基本能判断噪声构成再决定上不上重型算法。3.2 BM3D处理Stokes单通道先估计sigma再滤波BM3D 到现在仍然是传统去噪的上限OpenCV contrib 版本里带了官方移植。它的特点是把相似块叠成三维数组做联合滤波对高斯噪声非常有效但前提是 sigma 要给得准。偏振图上直接用默认值会得到两个极端sigma 设小噪声纹丝不动设大S1 和 S2 的细节被磨成塑料。import cv2 def estimate_noise_sigma(layer): 中位绝对偏差法估计高斯噪声标准差对S0/S1/S2都适用 residual layer - cv2.medianBlur(layer, ksize3) median np.median(residual) sigma np.median(np.abs(residual - median)) / 0.6745 return sigma def bm3d_denoise_channel(channel): sigma estimate_noise_sigma(channel) # sigma 传入的是噪声标准差值域需与图像一致 denoised cv2.xphoto.bm3dDenoising( channel.astype(np.float32), sigmasigma, window_size4, block_size8, search_window16 ) return denoised这里的四个参数值得逐一说明。sigma 不是滤波强度而是你对噪声标准差的估计估偏 20% 对结果影响就很大。window_size 是金字塔层级的窗口尺寸通常 4~8 之间越大越适合低频噪声。block_size 是块匹配时的基本块边长8 是兼顾细节与速度的常见值。search_window 是搜索邻域半径设 16 表示在 32x32 区域内找相似块。偏振场景中我建议把 S1/S2 的 search_window 设到 32因为差分通道上的相似块分布更稀疏搜索范围太小找不到足够的相似块去噪效果直接腰斩。BM3D 的代价是慢我之前在 1280x1024 的 S1 通道上跑一次大概要 5 到 8 秒所以它更适合离线处理而不是实时管线。3.3 U-Net微调输入三通道还是四通道深度学习的优势在于能够同时建模 S0、S1、S2 之间的关系。网络输入这里有个关键选择给四方向图I0/I45/I90/I135还是给 Stokes 三通道S0/S1/S2我两种都测过结论是Stokes 域输入更稳。原因是网络学的是“噪声到干净”的映射Stokes 通道之间相关性更低模型更容易找到确定性映射而四方向图之间共享大量光强信息网络要额外学一个隐式 Stokes 变换训练难度增加且容易在小样本上过拟合。下面是一个可跑的 U-Net 精简版输入输出都是 3 通道。import torch import torch.nn as nn def conv_block(in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) class PolarUNet(nn.Module): 输入S0/S1/S2三通道输出三通道去噪结果 def __init__(self, in_ch3, out_ch3): super().__init__() self.enc1 conv_block(in_ch, 64) self.enc2 conv_block(64, 128) self.pool nn.MaxPool2d(2) self.up2 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec1 conv_block(128, 64) self.head nn.Conv2d(64, out_ch, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) d self.dec1(torch.cat([self.up2(e2), e1], dim1)) return self.head(d)模型本身没有任何玄学就是一个带跳跃连接的小 U-Net。值得关注的是训练目标。我直接让网络预测干净图而不是预测残差。原因是偏振图像的噪声强度与信号强度相关S1 通道某些区域接近 0残差学习很容易把模型带到“输出恒为零”的局部最优。训练损失用 L1 而不是 L2L1 对边缘的惩罚更小偏振图像里保边缘直接关系到 DoLP 的准确性。训练循环的关键片段如下def train_one_epoch(model, loader, opt, criterion, device): model.train() total_loss 0.0 for clean, noisy in loader: clean, noisy clean.to(device), noisy.to(device) opt.zero_grad() pred model(noisy) loss criterion(pred, clean) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) opt.step() total_loss loss.item() return total_loss / len(loader)梯度裁剪虽然只加了一行但对偏振任务不是可选项。S1/S2 通道偶尔会出现极端离群值一次反传就可能把编码器权重推飞clip 到 1.0 能把这些异常样本的影响限制在单步内。学习率我习惯用 1e-4 配 Adampatch size 用 128batch size 在 12GB 显存上设为 8训练 50 epoch 左右开始收敛。如果验证集的 DoLP 误差还在下降就继续跑不要硬套固定 epoch 数。4. 把数据集用起来公开偏振资源、目录组织与训练管线4.1 可用偏振数据集公开集、合成集与自采标题里强调“含所使用数据集”说明数据准备往往是偏振去噪落地时最卡脖子的一环。公开能检索到的偏振数据集大致分三类。第一类是真实场景偏振图像集典型代表是学术机构发布的 DOLP 相关数据集内容多为室外自然场景、水面、塑料制品等包含多方向偏振原始图或预处理后的 Stokes 通道。这类数据最接近真实相机分布但数量有限且标注不一定齐全。第二类是合成偏振数据集像 Anisido 这类以物理渲染为核心的合成资源用 3D 模型加偏振渲染引擎批量生成量大且带有精确真值适合预训练问题是渲染出来的噪声特性和真实传感器完全不同只适合做Domain Pretrain。第三类是自己用偏振相机采集成本最高但只需要一台 DoFP 相机加一块漫反射白板、几个不同材质物体半小时就能拍出几百组样本足够在公开集基础上做微调。我处理一个去噪项目时通常的做法是先在合成集上训练一个初版再用真实数据公开集 自采混合做第二阶段的微调。这个两阶段策略能把真实数据的需求量压缩一半以上同时收敛速度明显更快。4.2 训练数据管线目录组织与随机裁剪数据集组织规范直接决定后面调参是否顺利我强烈建议从一开始就按通道分目录存放不要堆在一个文件夹里。data/polar/ 01_stokes/ s0/ # S0通道 tif文件名如 scene001_s0.tif s1/ # S1通道 tif s2/ # S2通道 tif 02_clean/ # 去噪后的干净版本仅在合成数据中提供 sets/ train.txt # 每行是一个样本ID例如 scene001 val.txt test.txt下面这个 Python 脚本完成样本划分和三通道拼装import os import random from glob import glob s0_files sorted(glob(data/polar/01_stokes/s0/*.tif)) s0_ids [os.path.basename(p).replace(_s0.tif, ) for p in s0_files] random.seed(42) random.shuffle(s0_ids) n_train int(len(s0_ids) * 0.7) n_val int(len(s0_ids) * 0.15) train_ids s0_ids[:n_train] val_ids s0_ids[n_train:n_train n_val] test_ids s0_ids[n_train n_val:] for split, ids in [(train, train_ids), (val, val_ids), (test, test_ids)]: with open(fdata/polar/sets/{split}.txt, w) as f: f.write(\n.join(ids))划分时务必使用随机种子否则每次运行划分结果都不一样不同轮次的实验结果没法横向对比。训练时每一轮都从目录中重新按 ID 找三张同名通道图拼成 3 通道张量。class PolarDataset(torch.utils.data.Dataset): def __init__(self, split_path, stokes_root, patch_size128, trainTrue): self.ids open(split_path).read().strip().split(\n) self.root stokes_root self.patch_size patch_size self.train train def __len__(self): return len(self.ids) * 20 # 每张图抽20个patch def __getitem__(self, idx): sample_id self.ids[idx % len(self.ids)] s0 cv2.imread(f{self.root}/s0/{sample_id}_s0.tif, cv2.IMREAD_UNCHANGED) s1 cv2.imread(f{self.root}/s1/{sample_id}_s1.tif, cv2.IMREAD_UNCHANGED) s2 cv2.imread(f{self.root}/s2/{sample_id}_s2.tif, cv2.IMREAD_UNCHANGED) img np.stack([s0, s1, s2], axis-1).astype(np.float32) # 随机裁剪到 patch_size x patch_size h, w img.shape[:2] y np.random.randint(0, h - self.patch_size) x np.random.randint(0, w - self.patch_size) img img[y:y self.patch_size, x:x self.patch_size] # 由干净图合成噪声作为训练对 clean torch.from_numpy(img).permute(2, 0, 1) noisy torch.from_numpy(synthesize_noise(img)).permute(2, 0, 1) return clean, noisy代码里有几个设计是为了偏振任务特意选的。每张图抽 20 个 patch 而不是全部用整图因为偏振数据的有效区域经常只占画面一部分随机裁剪能让网络看到更多不同的纹理组合。每次取 patch 时实时合成噪声等于变相做了无限数据增强模型不容易背下某一张图的固定噪声模式。4.3 数据增强要连S1/S2符号一起增强普通图像增强里随手用的水平翻转在偏振域会引入一个隐蔽错误图像镜像之后线偏振方向也会被镜像S1 通道的符号会反转。如果你的训练代码只翻转了图像而没处理 S1模型等于同时看到两套相互矛盾的标签。def augment_polar(img): img: (H, W, 3) 三通道输入通道顺序S0, S1, S2 水平翻转后 S1 取反垂直翻转后 S2 取反与AoP定义相关。 这个处理跟相机坐标系定义有关以实际标定为准。 img img.copy() if np.random.rand() 0.5: img img[:, ::-1, :] img[..., 1] -img[..., 1] # S1反号 if np.random.rand() 0.5: img img[::-1, :, :] img[..., 2] -img[..., 2] # S2反号 return img很多人觉得数据增强是细节问题但在偏振去噪里它直接影响模型能不能学到正确的 Stokes 物理约束。如果嫌符号变换处理起来太绕最保守的方案是只做随机裁剪和 90° 的倍数旋转不做任意角翻转。90° 旋转对 S1/S2 的影响可以通过通道重排精确求出不会出现符号方向错乱。5. 偏振去噪避坑指南5个实测翻车点与对应修复5.1 同一个sigma滤波S0/S1/S2DoLP直接失真现象三通道都用 sigma10 做 BM3D去噪后 S0 很干净S1/S2 也平滑了但算出来的 DoLP 出现大量异常高亮原来平整的区域变得斑驳。原因S1/S2 是差分通道噪声标准差远大于 S0。用 S0 的 sigma 去滤 S1等效于滤波强度远低于需求噪声没有被压下去却在计算 DoLP 时被平方项进一步放大最终表现为偏振度虚高。解决每个通道先用 estimate_noise_sigma 单独估计然后分别滤波。注意 S1/S2 的估计值通常比 S0 大 2 到 4 倍这是正常现象不需要手工干预。5.2 把偏振马赛克直接喂进预训练去噪模型现象用过预训练的去噪网络处理偏振相机 RAW效果图看起来没有马赛克但 S1/S2 通道出现规律性网格纹理。原因DoFP 相机的 2x2 偏振马赛克在空间上呈现固定周期纹理预训练模型在普通 RGB 数据上训练的把它识别成真实细节尝试去“恢复”或“平滑”结果把偏振相位信息破坏了。解决任何深度学习去噪之前必须先完成偏振马赛克解算得到 S0/S1/S2 后再进网络。这条没有捷径跳过解算等于让模型在错误的数据空间里学习。5.3 用RGB模型的mean/std归一化偏振图现象按 ImageNet 的 mean[0.485, 0.456, 0.406] 和 std[0.229, 0.224, 0.225] 归一化训练数据loss 降不下去验证误差震荡。原因偏振 S1/S2 通道包含负值且物理含义是差值强度ImageNet 均值和方差域完全不对负值被归一化后直接扭曲了符号信息S1/S2 上最关键的方向信息被破坏。解决统计自己的数据集的通道均值和标准差再归一化。偏振数据 S0 通常很大可能到几千S1/S2 则分布在 0 附近统计一遍会看到三个通道的量级差异用这个统计量归一化S1/S2 的符号才能保留。5.4 数据增强旋转翻转后AoP符号全乱现象训练时做了水平翻转和任意角度旋转模型去噪结果在边缘处 AoP 出现镜像错误的伪影整体偏转约 90°。原因AoP 0.5 * arctan(S2/S1)对图像做镜像时 S1/S2 的符号变化不同步AoP 的计算基准被破坏任意角度旋转则完全改变了偏振方向的几何定义。解决要么只做随机裁剪和 90° 倍数旋转要么按 4.3 中的公式同步变换 S1/S2 符号。开展实验前先在单张图上验证增强后的 DoLP/AoP 与增强前的一致性再放进训练流水线。5.5 只用PSNR验收偏振伪影看不到现象模型 PSNR 比基线高 3dB主观看起来也干净但把去噪后的 S1/S2 转为伪彩色 DoLP 图发现物体边缘出现了一道道不自然的亮带。原因PSNR 是逐像素强度误差对 S1/S2 通道中的符号翻转和边缘振荡不敏感。这些错误对光强影响很小却会显著污染偏振物理量。解决验收阶段必须加入 DoLP 误差和 AoP 误差指标具体计算方式放在下一章。哪怕只是观察伪彩色 DoLP 图也能快速发现这类只在偏振域暴露的问题。提示以上第 5 条表面上是验收问题实际是设计问题。模型训练时就该在损失函数里加入 DoLP 约束项例如 L1 损失多输出一个 DoLP 分支能显著减少这类伪影。6. 验证的最后一公里DOLP/AoP误差比PSNR更值得盯6.1 用已知噪声做定量验证调参时手里要有一组加了已知噪声的测试图。做法是取干净数据用 synthesize_noise 分别生成噪声强度不同的三组样本然后跑去噪算法计算恢复图和干净图之间的误差。这样可以同时得到两个信息算法在多大噪声强度下开始失效以及每个通道的剩余噪声水平。def evaluate_denoiser(denoise_fn, clean, noise_fn): noisy noise_fn(clean) # 制造带噪输入 pred denoise_fn(noisy) # 推理去噪 psnr 10 * np.log10(np.max(clean) ** 2 / np.mean((pred - clean) ** 2)) return psnr在偏振任务里单看这个指标不够至少还要看 DoLP 和 AoP 的误差见下节。6.2 把DOLP和AoP误差加进验收清单DoLP 是 0~1 的比值不用 PSNR用平均绝对误差更直观AoP 是角度量不能用普通减法算差值必须处理角度回卷。def dolp_mae(pred_s0, pred_s1, pred_s2, gt_s0, gt_s1, gt_s2): pred_dolp np.sqrt(pred_s1**2 pred_s2**2) / (pred_s0 1e-6) gt_dolp np.sqrt(gt_s1**2 gt_s2**2) / (gt_s0 1e-6) return np.mean(np.abs(pred_dolp - gt_dolp)) def aop_median_error(pred_s1, pred_s2, gt_s1, gt_s2): pred_aop 0.5 * np.arctan2(pred_s2, pred_s1) * 180.0 / np.pi gt_aop 0.5 * np.arctan2(gt_s2, gt_s1) * 180.0 / np.pi diff (pred_aop - gt_aop 180.0) % 360.0 - 180.0 # 处理角度回卷 return np.median(np.abs(diff))这两个指标的合理范围因场景而异但我自己的判断标准是DoLP MAE 低于 0.02、AoP 中位误差低于 3° 才算可上线。如果只盯着 PSNR你会以为自己已经做完了实际上偏振信息可能已经废了。6.3 天空与暗背景检测“伪偏光”最快的方法最后一个技巧来自我的血泪经验去噪算法有没有悄悄制造虚假偏振信息只要找一块 DoLP 理论上接近 0 的均匀区域比如阴天天空、均匀白墙看算法处理前后 DoLP 均值变化。如果去噪后这块区域的 DoLP 从 0.01 涨到了 0.05说明算法在 S1/S2 上制造了纹理伪影哪怕整体 PSNR 是涨的这个模型也不能用。我现在每次跑完去噪实验第一件事永远不是看 PSNR而是切一块天空或白墙看 DoLP 有没有“无中生有”。这个习惯帮我挡掉了至少三次看起来很强的模型。偏振去噪的终点不是让图像变平滑而是让 S1/S2 在去噪后仍然忠实于物理偏振状态。希望帮到你。本文还有配套的精品资源点击获取