简介:偏振图像处理与去噪方向的 Python 实践项目,面向计算机视觉、图像分析与机器学习领域的学习者和研究者,也适合希望把偏振成像知识应用到自动驾驶、遥感、医学成像等场景的开发者。资源包共 38 个文件,约 906KB,包含 15 个 py 脚本与 14 个 pyc 编译文件,涵盖数据加载、图像变换、模型定义、评价指标等模块,另有 5 张 jpg 示意图直观展示合成数据集、真实场景数据集与传感器阵列,辅以 txt、gitignore、Dockerfile、license 等配置与许可文件,便于直接运行和二次开发。已有 400 人学习下载。项目将偏振图像去噪的流程拆分为接口参数、数据读取、模型网络、指标计算等环节,并提供配套数据集,读者可以据此复现中值滤波、高斯滤波、非局部均值、FFT 等基础去噪方法,也能结合预置模型评估去噪后细节保留效果,理解偏振特性与噪声抑制之间的权衡,从而为自己的图像增强与复原任务积累可迁移的工程经验。
1. 偏振图像去噪不是“四张灰度图各跑一遍”
偏振相机一次曝光吐出四张图:I0、I45、I90、I135。新手最常干的事,是把它们当成四张互不相干的灰度图,各跑一遍滤波再回头算斯托克斯参数,结果 DoLP 图边缘起毛、AoP 图整片乱跳。这个 Python 项目换了个思路——四个方向堆成一个四通道张量整体去噪,再去解算偏振特征,配套给了采集好的数据集、训练脚本和评估代码,改一条配置就能跑起来。适合正在做偏振成像、材质区分、水下与雾天视觉,想把偏振通路接进自己 pipeline 的开发者,也适合只想拿现成数据练去噪网络的人。往下看,数据怎么组织、四通道网络怎么写、哪些坑我踩过,都写清楚了。
2. 项目骨架与技术栈:先看清数据在目录里怎么流动
去噪项目能不能跑起来,八成取决于数据目录和配置对不对得上,模型代码反而是最不容易出问题的那部分。这个项目的目录组织偏“数据优先”:原始图、标定文件、切好的 patch、训练输出各占一个目录,脚本之间只靠路径和 yaml 配置耦合。我拿到手第一件事是先把目录树打印出来,对照每个目录的产出物确认一遍,再决定从哪个脚本开始跑。
2.1 目录树与各目录职责
polar-denoise/ ├── configs/ │ ├── base.yaml # 数据路径、patch 大小、噪声参数 │ └── train_res.yaml # 网络宽度、深度、优化器超参 ├── data/ │ ├── raw/ # 每个场景一个子目录,含 I0/I45/I90/I135.png │ ├── calib/ # dark.npy、flat.npy、配准偏移 │ ├── clean/ # 多帧平均后的干净参考,.npy 格式 │ └── patches/ # 切块产物,训练时直接读 ├── polar/ │ ├── io.py # 读图、位深归一化、暗场平场校正 │ ├── stokes.py # 斯托克斯参数、DoLP、AoP │ ├── dataset.py # Dataset 与带角度映射的增强 │ ├── noise.py # 散粒噪声 + 读出噪声合成 │ ├── model.py # 四通道残差去噪网络 │ └── metrics.py # PSNR、SSIM、DoLP/AoP 误差 ├── scripts/ │ ├── prepare_data.py # 标定校正 + 切块 │ ├── train.py │ └── eval.py └── requirements.txt| 目录 | 关键产出 | 什么时候会动它 |
|---|---|---|
data/raw | 原始 16bit 四方向图 | 换数据集时整目录替换 |
data/calib | 暗场、平场、通道配准偏移 | 换相机或换镜头必须重拍 |
data/clean | 干净参考,训练 target | 用多帧平均或长曝光生成 |
data/patches | 128×128 的 npy 切片 | prepare_data.py生成,可随时重建 |
polar/ | 全部核心逻辑 | 二次开发主要改这里 |
raw里每个场景是一个独立子目录,四张图文件名固定,这是整个项目最硬的一条约定。prepare_data.py靠这个命名规则遍历,模型训练又靠patches的目录结构反推场景名,所以换数据时文件名别自作主张改。
2.2 依赖清单与运行环境
requirements.txt里锁的东西不多,我一般把 torch 放在 2.x,配合torchvision只用它的 SSIM 实现,其余全是常规件。
python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt # 关键依赖:torch>=2.0 numpy opencv-python scipy scikit-image pyyaml tqdm推理和训练都不强依赖 GPU,但没有 CUDA 的话 128 patch、batch 32 跑一轮要十几分钟,调参周期会被拖得很长。scripts/train.py里用torch.cuda.is_available()自动选设备,没卡也能跑,只是慢。
2.3 三步跑通:预处理、切块、训练
整个流程只有三条命令,顺序不能颠倒。
# 1. 标定校正 + 切块,输出到 data/patches python scripts/prepare_data.py --config configs/base.yaml # 2. 训练,权重默认写到 runs/exp1/ python scripts/train.py --config configs/base.yaml --exp configs/train_res.yaml # 3. 在留出场景上评估 python scripts/eval.py --ckpt runs/exp1/best.pth --split test第一步跑完,data/patches下会按train/、val/、test/三个子目录存放.npy文件,每个文件是(128,128,4)的 float32 数组。第二步启动时会打印每个 split 的样本数,这个数字对不上就说明切块阶段有问题,先别急着看 loss 曲线。第三步评估除了 PSNR,还会单独输出 DoLP 平均绝对误差和 AoP 环绕误差,这两个指标才是偏振任务真正关心的。
提示:
prepare_data.py默认按场景划分 train/val/test,不是按 patch 随机划分。这条设定后面会单独讲,别随手改。
3. 数据管道:四方向图怎么变成可训练的张量
数据管道是这类项目里最容易埋雷的地方。四张图之间的物理关系很脆弱,任何一步归一化、配准、通道顺序出错,模型都能训出“看起来收敛”的结果,但偏振特征全是错的。这一章按读取、解算、增强三个环节拆开看。
3.1 读取、位深与通道对齐
原始图是 16bit PNG,直接读进来是uint16,必须先归一化到[0,1]再做任何运算。项目里统一除以 65535,不按每张图的最大值归一化——按最大值归一会破坏四通道之间的强度相对关系,DoLP 会直接算错。
import cv2 import numpy as np from pathlib import Path ANGLES = (0, 45, 90, 135) def load_scene(scene_dir, calib_dir=None): """读取一个场景的四方向偏振图,返回 (H, W, 4) 的 float32 张量。""" imgs = [] for a in ANGLES: path = Path(scene_dir) / f"I{a}.png" img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) if img is None: raise FileNotFoundError(f"缺少 {path}") # 固定位深归一化,不能用 img.max() scale = 65535.0 if img.dtype == np.uint16 else 255.0 imgs.append(img.astype(np.float32) / scale) stack = np.stack(imgs, axis=-1) # 通道顺序必须是 0/45/90/135 if calib_dir is not None: dark = np.load(Path(calib_dir) / "dark.npy") flat = np.load(Path(calib_dir) / "flat.npy") stack = (stack - dark) / np.maximum(flat - dark, 1e-6) stack = np.clip(stack, 0.0, 1.0) return stack.astype(np.float32)dark.npy和flat.npy是四通道同尺寸数组,拍摄方式很常规:盖镜头拍暗场,均匀光源拍平场。平场减暗场后除,是这套流程唯一能压住通道间响应不一致的手段。如果手头没有标定数据,calib_dir=None也能跑,但四个通道的增益差异会直接混进 DoLP,表现为整幅图有一个固定的偏振度偏置,后处理很难补回来。
cv2.imread加IMREAD_UNCHANGED是为了保住 16bit 位深。用默认参数读会降成 8bit,量化误差在暗部会被 DoLP 的分母放大,这个坑在后面的排查章里还会提。
3.2 斯托克斯参数与 DoLP/AoP 的解算
四个方向强度换成斯托克斯参数,公式本身不复杂,麻烦在约定和边界。
def stokes_from_stack(stack, eps=1e-6, valid_thr=0.02): i0, i45, i90, i135 = (stack[..., k] for k in range(4)) s0 = 0.5 * (i0 + i45 + i90 + i135) # 总光强,也等于 I0+I90 s1 = i0 - i90 # 0°/90° 方向的线偏振分量 s2 = i45 - i135 # 45°/135° 方向的线偏振分量 denom = np.maximum(s0, eps) dolp = np.sqrt(s1 ** 2 + s2 ** 2) / denom dolp = np.clip(dolp, 0.0, 1.0) # 物理上不会超过 1 aop = 0.5 * np.arctan2(s2, s1) # 范围 [-π/2, π/2] valid = s0 > valid_thr # 低光强区域不可信 return {"s0": s0, "s1": s1, "s2": s2, "dolp": dolp, "aop": aop, "valid": valid}几个参数值得单独说。s0前面的 0.5 是常见约定,也有实现直接取i0 + i90不乘系数,两种写法只差一个全局尺度,但训练时如果一处的 s0 和另一处的 s0 定义不一致,损失函数里算出来的 DoLP 就会有系统性偏移,排查起来非常隐蔽。valid_thr=0.02是有效光强阈值,低于它的像素 DoLP 基本是噪声除以噪声,评估时必须屏蔽。aop用arctan2而不是arctan,是为了保住象限信息,输出范围是[-π/2, π/2],周期为 π。
注意:DoLP 的
clip(0,1)不是为了好看,而是给后面计算加上限。传感器噪声可能让sqrt(s1²+s2²)略微超过s0,不截断会在损失里产生负值方向的梯度。
3.3 切块、归一化与增强:四通道不能各翻各的
切块逻辑本身很直白,滑窗加固定步长,关键是增强阶段。
def hflip_polar(p): # 水平镜像:0° 与 90° 方向不变,45° 与 135° 互换 return np.ascontiguousarray(p[:, ::-1, :][..., [0, 3, 2, 1]]) def rot90_polar(p): # 旋转 90°:每个通道的检偏角整体平移 90° return np.ascontiguousarray(np.rot90(p, k=1, axes=(0, 1))[..., [2, 3, 0, 1]]) def augment(p, rng): if rng.random() < 0.5: p = hflip_polar(p) k = rng.integers(0, 4) for _ in range(k): p = rot90_polar(p) return p翻转之后必须重排通道,这是四通道偏振数据与普通 RGB 最大的区别。水平镜像把偏振角 θ 映射成 -θ,0° 和 90° 这两条轴本身不变,45° 和 135° 互换,所以通道顺序要按[0,3,2,1]重排。旋转 90° 则让每个方向整体加 90°,通道顺序变成[2,3,0,1]。如果直接对四通道做cv2.flip而不管通道,等价于把物理方向打乱,模型学到的偏振关系全是假的。
噪声合成用散粒噪声加高斯读出噪声的混合模型,比单纯加高斯更接近实际传感器:
def add_mixed_noise(x, sigma=0.03, scale=200.0): """散粒噪声 + 读出噪声,模拟偏振相机的实际退化""" shot = np.random.poisson(np.clip(x, 0, 1) * scale) / scale read = np.random.randn(*x.shape).astype(np.float32) * sigma return np.clip(shot + read, 0.0, 1.0).astype(np.float32)scale控制散粒噪声强度,值越大噪声越弱,200 对应中等曝光下的量级。训练时每个 batch 随机抽一组(sigma, scale),让模型见过不同噪声水平,推理时对真实数据的泛化会稳一些。这套噪声模型是训练集构造的核心,换相机后必须重新标定这两个参数,照抄默认值大概率不匹配。
4. 去噪网络与训练链路:从四通道残差网络到偏振域损失
网络部分我倾向于保持简单。偏振去噪的难点不在网络结构,而在损失函数怎么把偏振物理约束塞进去。项目用的是一个中等宽度的残差网络,配上像素域加偏振域的组合损失,训练链路里每个环节都有明确职责。
4.1 网络结构:四通道进、四通道出
import torch import torch.nn as nn class ResBlock(nn.Module): def __init__(self, ch): super().__init__() self.body = nn.Sequential( nn.Conv2d(ch, ch, 3, 1, 1), nn.ReLU(inplace=True), nn.Conv2d(ch, ch, 3, 1, 1), ) def forward(self, x): return x + self.body(x) class PolarDenoiseNet(nn.Module): def __init__(self, in_ch=4, width=64, depth=6): super().__init__() self.head = nn.Conv2d(in_ch, width, 3, 1, 1) self.blocks = nn.Sequential(*[ResBlock(width) for _ in range(depth)]) self.tail = nn.Conv2d(width, in_ch, 3, 1, 1) def forward(self, x): feat = self.head(x) feat = self.blocks(feat) return x + self.tail(feat) # 残差学习:网络只预测噪声残差输入输出的通道数都是 4,这一点是整个项目能端到端跑通的前提。tail输出接一个全局残差,网络实际学的是“噪声图”而不是“干净图”,收敛更快,也不容易把低频的偏振度分布抹平。width=64、depth=6是我在一张 12G 卡上能放 batch 32 的配置,显存紧张就把 width 降到 32,深度加到 8,感受野更划算一些。
如果要做对比实验,换 backbone 只需要保证输入 4 通道、输出 4 通道、尺寸不变,train.py里按模块名实例化,接口对上就能直接替换。
4.2 损失函数:像素域加偏振域
import math import torch.nn.functional as F def stokes_torch(x, eps=1e-6): i0, i45, i90, i135 = x[:, 0], x[:, 1], x[:, 2], x[:, 3] s0 = 0.5 * (i0 + i45 + i90 + i135) s1 = i0 - i90 s2 = i45 - i135 return s0, s1, s2 def polar_loss(pred, gt, w_pix=1.0, w_dolp=0.5, w_aop=0.1, eps=1e-6): l_pix = F.mse_loss(pred, gt) s0p, s1p, s2p = stokes_torch(pred, eps) s0g, s1g, s2g = stokes_torch(gt, eps) dp = torch.sqrt(s1p ** 2 + s2p ** 2) / (s0p + eps) dg = torch.sqrt(s1g ** 2 + s2g ** 2) / (s0g + eps) mask = (s0g > 0.02).float() # 只在高光强区域监督偏振量 l_dolp = (((dp - dg) ** 2) * mask).sum() / (mask.sum() + 1e-6) ap = 0.5 * torch.atan2(s2p, s1p) ag = 0.5 * torch.atan2(s2g, s1g) diff = torch.abs(ap - ag) diff = torch.minimum(diff, math.pi - diff) # AoP 周期为 π,差值走环绕 l_aop = (diff * mask).sum() / (mask.sum() + 1e-6) total = w_pix * l_pix + w_dolp * l_dolp + w_aop * l_aop return total, {"pix": l_pix.item(), "dolp": l_dolp.item(), "aop": l_aop.item()}三个权重里,w_dolp=0.5和w_aop=0.1是调出来的经验值。DoLP 对边缘和材质边界敏感,加太大容易让模型在边缘处过度平滑;AoP 在低偏振度区域本身就不稳定,权重给高了会把噪声引回梯度里,所以配了 mask 再压到 0.1。math.pi - diff这一行是 AoP 误差计算的标准环绕处理,少了它,接近 ±π/2 的像素会被算成巨大误差,训练直接不收敛。
注意:辅助损失里的 mask 用的是干净图的 s0,不是预测图的 s0。用预测图会让 mask 随训练过程漂移,早期几乎全屏蔽,后期又全放开,曲线很难看。
4.3 训练循环与评估指标
训练循环保持最小实现,只加了梯度裁剪和按验证 PSNR 保存最优权重。
def train_one_epoch(model, loader, opt, device): model.train() total = 0.0 for noisy, clean in loader: noisy, clean = noisy.to(device), clean.to(device) pred = model(noisy) loss, parts = polar_loss(pred, clean) opt.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt.step() total += loss.item() * noisy.size(0) return total / len(loader.dataset)评估部分单独算偏振指标,PSNR 只能说明强度图好不好看,说明不了偏振特征对不对。
def psnr(a, b, max_val=1.0): mse = float(np.mean((a - b) ** 2)) return 10 * math.log10(max_val ** 2 / max(mse, 1e-10)) def polar_metrics(pred, gt): mp, mg = stokes_from_stack(pred), stokes_from_stack(gt) m = mg["valid"] if not m.any(): return {"dolp_mae": float("nan"), "aop_mae": float("nan")} dolp_mae = float(np.abs(mp["dolp"] - mg["dolp"])[m].mean()) d = np.abs(mp["aop"] - mg["aop"]) d = np.minimum(d, math.pi - d) # 同样的环绕处理 aop_mae = float(d[m].mean()) return {"dolp_mae": dolp_mae, "aop_mae": aop_mae}跑完一轮验证,正常的结果是 PSNR 在 32 到 36 dB 之间,DoLP 平均绝对误差在 0.03 上下,AoP 误差在 0.05 弧度以内。如果 PSNR 很高但 DoLP 误差超过 0.1,说明网络在强度域拟合得不错,偏振域完全没学到,九成是增强或损失配置出了问题,先回去查 3.3 节。
5. 踩坑与排查:五个让指标好看但结果不能用的坑
这个项目我前后跑过三版,前两版都栽在“指标涨了但效果崩了”上。下面五条是按出现频率排的,每条都写清现象、原因和解法,遇到类似症状可以直接对号入座。
5.1 数据准备阶段的三个坑
坑一:拿 DoLP 图当训练目标
现象是训练 loss 降得很漂亮,验证集 DoLP 图也平滑,但把模型输出反推回四方向图做材质分割,效果反而比不去噪还差。原因是 DoLP 到四方向强度不是可逆映射,一张 DoLP 图对应无穷多组强度和偏振角组合,网络丢掉 s0 强度信息后,反推出来的图在亮度关系上是错的。解法是把训练目标固定为四通道强度图,DoLP 和 AoP 只作为辅助损失和评估指标存在,任何时候都不作为网络输出。
坑二:低光强区域 DoLP 数值爆炸
现象是去噪后的图里,阴影区域出现一大片接近 1 的 DoLP 噪点。原因是 s0 接近 0 时,噪声除以噪声的结果随机落在[0,1]全区间。解法是解算时加valid_thr掩码,损失里也用同一套 mask 把低光强像素排除掉。阈值不要拍脑袋定,把整批数据的 s0 直方图画出来,取暗部峰右侧的拐点,我这边落在 0.02 附近。
坑三:增强时四个通道各翻各的
现象是 loss 卡在 0.03 下不去,验证 PSNR 停在 20 dB 左右。原因是用通用的图像增强库对(H,W,4)数组做随机翻转,通道没有跟着重排,物理方向全乱。解法是用 3.3 节那两个带通道映射的函数,并且禁用任意角度旋转——任意角度旋转会引入插值,四个通道的插值权重还不一致,等于人为制造偏振误差。只保留水平镜像和 90° 整数倍旋转,这两种变换在偏振域是精确的。
5.2 训练与评估阶段的两个坑
坑四:同一场景切 patch 后随机划分
现象是验证集 PSNR 冲到 38 dB,换一个新场景测试直接掉到 26 dB。原因是相邻 patch 重叠度高,训练集和验证集里存在几乎一样的样本,指标虚高。解法是按场景划分 split,训练、验证、测试各自持有完整场景,prepare_data.py默认就是这个逻辑,别为了“样本多一点”把 test 场景的 patch 混进训练集。
坑五:辅助损失的 target 用了加噪图
现象是训练日志里 dolp 分项一直很小,推理时 DoLP 误差反而比不加辅助损失还大。原因是构造 batch 时先加噪、再算 target,等于让模型去拟合带噪的偏振特征,辅助项在教模型记住噪声。解法是 target 一律用干净图现算,且训练和评估共用同一个stokes_from_stack,避免两套实现出现尺度差异。
提示:这五条里前三条属于“数据错了但代码不报错”,最难查。我的习惯是每换一批数据,先单独跑一个可视化脚本,把 s0、DoLP、AoP、mask 四张图并排存下来看一眼,再开始训练。
6. 二次开发:换数据集、换骨干与上线前的一次自检
拿到这个项目的人,最终多半要换成自己的数据。改动量其实不大,但有三处必须同步改,漏一处就会出现“能跑但不对”的状态。
6.1 换数据只需要动三处
第一处是通道顺序和角度定义。polar/io.py里ANGLES元组决定文件读取顺序,如果你的相机输出是 0/45/90/135 之外的组合,比如 0/60/120,斯托克斯公式本身也要跟着改,s1、s2的系数不再是简单的加减。第二处是位深归一化,16bit 换 12bit 要把除数改成 4095。第三处是噪声模型参数,add_mixed_noise里的sigma和scale要按新相机的暗噪声和满阱容量重新估,偷懒照抄默认值,训练出来的模型在真实数据上会偏软或者偏锐。
# configs/base.yaml 里需要改的几项 data: raw_dir: data/raw calib_dir: data/calib valid_thr: 0.02 patch: 128 stride: 64 noise: sigma: 0.03 scale: 200.06.2 换骨干网和损失的接口约定
换网络只要守住三条:输入(B,4,H,W)、输出同形状、不改变空间尺寸。剩余连接、注意力、Transformer 都能塞进去,train.py按getattr(model_module, cfg.model.name)实例化,类名对上就行。换损失函数守住两条:第一个返回值是标量 total,第二个是分项字典,键名保持pix、dolp、aop,否则日志和早停逻辑会读不到对应字段。
一个实际改动是把残差块换成带通道注意力的版本,或者直接把 4 通道拆成 s0/s1/s2 三通道再拼回去。后者要小心,s1 和 s2 是有符号量,ReLU 会把负半轴砍掉,激活函数得换成 LeakyReLU 或者干脆不加。
6.3 上线前的自检和一个提速技巧
部署前我固定跑一遍四步检查:拿一张真实拍摄的噪声图过模型,看输出有没有过曝或死黑;把同一场景的四通道分别可视化,确认通道顺序没串;算一遍 DoLP 和 AoP 的分布直方图,和干净参考对比形状是否一致;最后用留出场景跑一次完整评估,PSNR 和 DoLP 误差两个指标都要看。
推理提速最直接的是导出 ONNX 后用 onnxruntime 跑,CPU 上通常比 PyTorch 快一到两倍:
import torch model = PolarDenoiseNet().eval() dummy = torch.randn(1, 4, 128, 128) torch.onnx.export( model, dummy, "polar_denoise.onnx", input_names=["polar4"], output_names=["clean4"], dynamic_axes={"polar4": {2: "h", 3: "w"}, "clean4": {2: "h", 3: "w"}}, opset_version=17, )动态轴一定要开,否则只能跑固定 128×128。导出后拿同一张图分别用 PyTorch 和 onnxruntime 推理,逐像素比对差值,正常在 1e-5 量级,超过这个数量级说明某个算子导出时被近似了,得回去查。
早几年我做类似的图像复原项目,图省事直接用验证集 PSNR 当唯一指标,上线后才发现偏振通路整个是错的,返工了两周。从那以后我每次换数据,都强制先跑一遍“解算可视化 + 指标双查”,确认 s0、DoLP、AoP 三张图肉眼看着合理,再让训练脚本启动。希望帮到你。
本文还有配套的精品资源,点击获取