简介:围绕偏振图像处理与去噪的Python实践包,面向计算机视觉、图像分析与机器学习方向的学习者和研究者,可用于理解偏振成像特性、复现去噪流程并评估细节保持效果。压缩包共38个文件,约906KB,以15个py源码和14个pyc缓存为主,另含5张jpg示意图及txt说明、Dockerfile、license等,代码涵盖数据加载与变换、模型定义、评价指标、可视化及容器化环境,便于按模块阅读和运行。已有401人学习下载,说明其在相关小众方向具有一定参考价值。读者可借助随包数据集,在不同噪声与光照条件下测试中值滤波、高斯滤波、非局部均值等方法,也可结合网络模型观察去噪后纹理与偏振信息的保留情况,并利用指标脚本与可视化结果做对比分析,适合作为课程设计、课题预研或算法复现的起点。
1. 从一张“毛刺”DoLP图说起:这套偏振图像去噪工程能替你省掉哪些试错
做偏振图像去噪时,最容易被忽略的不是选 TV 还是 BM3D,而是数据形态:四张不同偏振角的强度图只要配准偏半个像素,DoLP 图就会长出满屏毛刺,后面调参全是玄学。这个 Python 工程把四方向强度图读取、Stokes 参数计算、DoLP/AoP 重算、TV/BM3D 基线和一个轻量 CNN 放在同一条管线里,连所用数据集和切分脚本一起给到。
它适合两类人:手里有偏振相机数据、想把去噪结果落到材料应力或目标检测前处理上的开发者;以及刚接触偏振成像、需要一套能跑通且指标可复现的基线的人。先跑scripts/run_baseline.py看一张 DoLP 对比图,比从零拼环境更快判断这套管线是否合你的数据。
2. 技术栈与工程骨架:numpy、OpenCV、scikit-image、PyTorch 各自管哪一段
这套工程的定位不是给偏振图套一个“美颜滤镜”,而是保证去噪后的 Stokes 分量还能重新算出物理上说得通的 DoLP 和 AoP。输入是同一场景下 0°、45°、90°、135° 四个偏振方向的强度图,输出是去噪后的 S0/S1/S2 三通道、重算的 DoLP/AoP,以及 PSNR、SSIM、AoP 角度误差这些可对比的指标。技术选型围绕“先把传统基线跑稳,再上学习型方法”展开,依赖不多,但每层职责分得比较开。
| 层次 | 库/工具 | 在工程里的职责 |
|---|---|---|
| 数据读取 | OpenCV、numpy | 读四方向图、位深归一化、尺寸校验 |
| 偏振量计算 | numpy | Stokes、DoLP、AoP、数值保护 |
| 传统基线 | scikit-image、bm3d | TV 去噪、BM3D 对照 |
| 学习型方法 | PyTorch | 轻量残差 CNN 训练与推理 |
| 评估 | scikit-image、numpy | PSNR/SSIM、AoP 圆统计误差 |
| 可视化 | matplotlib | 噪声图、去噪图、误差图对比 |
2.1 为什么偏振去噪不能照搬 RGB 去噪管线
RGB 去噪的常见流程是“读图、加噪、去噪、算 PSNR”,人眼看着干净就算过。偏振图不行。DoLP 是sqrt(S1^2 + S2^2) / S0,低照度区域 S0 一小,分母把噪声直接放大;AoP 又带 π 周期,普通 MSE 会把相差 π 的角度当成巨大误差。直接对 DoLP 图做滤波,视觉上可能平滑了,但去噪后的 DoLP 已经不再来自同一组 Stokes 分量,物理一致性就断了。
所以工程里坚持一条主线:噪声在 S0/S1/S2 上处理,DoLP 和 AoP 只做重算,不单独当滤波输入。传统基线用 TV 或 BM3D 对三个 Stokes 分量逐通道去噪,学习型方法则把 S0/S1/S2 拼成三通道输入,让网络在残差上学噪声。这样改损失函数、换网络结构时,评估仍然落在同一套物理量上,不会出现“指标好看但偏振信息糊掉”的翻车。
2.2 目录结构与依赖边界
工程目录按“数据进、算法出、脚本串”来分,避免把训练代码和数据处理揉在一个文件里。下面这棵树是常见骨架,实际拿到后先对照自己的数据命名改dataio.py里的 glob 规则。
polar_denoise/ ├── data/ │ ├── raw/ │ │ └── scene_001/ │ │ ├── scene_001_0.png │ │ ├── scene_001_45.png │ │ ├── scene_001_90.png │ │ └── scene_001_135.png │ └── splits/ ├── configs/ │ └── cnn.yaml ├── src/ │ ├── dataio.py │ ├── stokes.py │ ├── denoise.py │ ├── metrics.py │ ├── model.py │ └── train.py ├── scripts/ │ ├── run_baseline.py │ ├── infer.py │ └── make_split.py ├── requirements.txt └── README.mddata/raw下按场景分文件夹,每个场景放四个偏振角的强度图,工程配套的数据集也按这个约定组织;data/splits存按场景切分的训练/验证列表,避免每次跑脚本都重新随机。src/stokes.py只做 Stokes 和偏振量计算,src/denoise.py放 TV、BM3D 和推理封装,src/model.py只定义网络。这样二次开发时换网络不用动数据读取,换数据读取也不用碰训练循环。
2.3 配置项怎么落到命令行
训练参数放 YAML,基线脚本用命令行参数覆盖,避免把 batch size、学习率散落在代码里。配置文件里只放真正会变的项,路径和模型宽度分开,换数据集时改data.root就行。
data: root: data/raw patch_size: 128 batch_size: 8 model: in_ch: 3 width: 32 train: epochs: 50 lr: 0.0001 loss: l1patch_size控制训练时随机裁剪的空间尺寸,偏振图边缘配准误差大,裁太小会把边缘伪影学进去;batch_size按显存调,in_ch固定为 3 对应 S0/S1/S2,width是 CNN 第一层通道数,先小后大比一上来堆宽更稳。loss先留l1,后面换polar_loss时在训练脚本里加分支。
python scripts/run_baseline.py --data data/raw --method tv --weight 0.08 --out outputs/tv python scripts/train.py --config configs/cnn.yaml python scripts/infer.py --ckpt outputs/cnn/best.pth --input data/raw/scene_001 --out outputs/scene_001第一条跑 TV 基线,--weight越大越平滑,先从小值试,看 DoLP 边缘有没有被抹平;第二条按配置训练;第三条加载权重对单个场景推理。三条命令对应“先看传统方法效果、再训学习型模型、最后落到单场景”的顺序,别一上来就训练,先用基线确认数据读取和偏振量计算没出错。
3. 数据管线拆解:四方向强度图如何变成 Stokes、DoLP 与 AoP
数据管线是这套工程最值得先读的部分。偏振图像去噪的很多问题不是出在去噪算法,而是四方向图没对齐、位深归一化写错、训练集和验证集来自同一场景的相邻 patch。工程把读取、Stokes 计算、切分拆成三个独立步骤,每一步都能单独打印中间结果检查。
3.1 读取与命名约定
读取函数只认“场景目录 + 角度后缀”这一种约定,文件名里必须能明确看出 0/45/90/135。配套数据集按这个规则命名,换成自己的数据时改 glob 表达式即可。位深归一化按 dtype 判断,不写死 255,这是后面 DoLP 不爆掉的前提。
from pathlib import Path from typing import Union import cv2 import numpy as np ANGLES = ("0", "45", "90", "135") def read_polar_scene(scene_dir: Union[str, Path]) -> dict: scene_dir = Path(scene_dir) images = {} for ang in ANGLES: # 常见命名:scene_001_0.png / scene_001_45.tif candidates = list(scene_dir.glob(f"*_{ang}.*")) if len(candidates) != 1: raise FileNotFoundError(f"{scene_dir} 下角度 {ang} 的图像数量为 {len(candidates)}") img = cv2.imread(str(candidates[0]), cv2.IMREAD_UNCHANGED) if img is None: raise ValueError(f"读不到 {candidates[0]}") if img.ndim == 3: img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 16 位图不能按 255 归一化,否则 S0 尺度直接错 max_val = 65535.0 if img.dtype == np.uint16 else 255.0 images[ang] = img.astype(np.float32) / max_val shapes = {v.shape for v in images.values()} if len(shapes) != 1: raise ValueError(f"四个角度尺寸不一致: {shapes}") return images这段代码先按角度找唯一文件,找到多个或零个都直接报错,避免静默读错图。灰度化放在归一化之前,彩色偏振图通常不是这套管线的目标;如果原始数据是彩色,先确认四个角度是否同一通道顺序。max_val按uint16和uint8分支,images里统一成 0~1 的 float32。参数上scene_dir是一个场景文件夹,不是整个数据集根目录;跑批量时在外层遍历data/raw下的子目录。
3.2 Stokes 参数计算与数值保护
Stokes 计算是偏振量的入口,公式不复杂,但 eps 和 clip 不能省。S0 用四个方向平均,S1、S2 用差分,DoLP 算完裁到 0~1,AoP 用arctan2保留象限。
import numpy as np def compute_stokes(i0, i45, i90, i135, eps=1e-6): s0 = (i0 + i45 + i90 + i135) / 2.0 s1 = i0 - i90 s2 = i45 - i135 dolp = np.sqrt(s1 ** 2 + s2 ** 2) / (s0 + eps) dolp = np.clip(dolp, 0.0, 1.0) aop = 0.5 * np.arctan2(s2, s1) return {"S0": s0, "S1": s1, "S2": s2, "DoLP": dolp, "AoP": aop}eps防止全黑区域除零,clip处理实际采集里 DoLP 略大于 1 的情况,arctan2让 AoP 落在-π/2到π/2。参数i0到i135是四个已归一化的 float32 数组,形状必须一致。这里用四方向平均算 S0,比只取i0 + i90更抗单通道异常;如果相机手册给的是另一种 S0 定义,改这一行就行,但整条管线要一起改,不能训练用一种、评估用另一种。
注意:S1、S2 有负值,写入 8 位 PNG 前要先做可视化映射,不要直接截断到 0~255 当训练标签。
3.3 数据集切分与增强的边界
切分按场景走,不按 patch 随机切。同一场景的相邻 patch 同时进训练和验证,指标会虚高,这是偏振去噪里很常见的泄漏。配套数据集如果已经给了场景列表,直接用;没有就用下面这段按目录名切。
import random from pathlib import Path scenes = sorted([p.name for p in Path("data/raw").iterdir() if p.is_dir()]) random.seed(42) random.shuffle(scenes) n_val = max(1, int(len(scenes) * 0.2)) val_scenes, train_scenes = scenes[:n_val], scenes[n_val:]seed固定保证可复现,0.2是验证比例,场景少的时候至少留一个验证场景。这里有个血泪经验:不要用随机裁剪后的 patch 直接分训练验证,先分场景再裁 patch,否则同一个划痕会在两边都出现。数据增强也要克制,随机裁剪、加噪、亮度微扰可以;任意旋转和水平翻转要谨慎,翻转会改变 AoP 的参考方向,除非同步修正角度,否则网络学到的偏振关系是乱的。
4. 去噪核心:TV 基线、BM3D 对照与轻量 CNN 的落地写法
去噪模块分三层:TV 基线保证工程能跑通,BM3D 做对照,轻量 CNN 做可训练扩展。三层共用同一套 Stokes 输入和同一套评估,换方法时不换数据口径,结果才有可比性。传统方法先跑,能快速暴露配准和位深问题;学习型方法后上,避免把数据 bug 当成模型不收敛。
4.1 TV 去噪与偏振量重算
TV 基线对 S0、S1、S2 逐通道去噪,再去重算 DoLP/AoP。逐通道不是最优,但实现简单、参数少,适合当第一版对照。
import numpy as np from skimage.restoration import denoise_tv_chambolle def denoise_stokes_tv(stokes, weight=0.08): out = {} for key in ("S0", "S1", "S2"): out[key] = denoise_tv_chambolle(stokes[key], weight=weight, channel_axis=None) out["DoLP"] = np.clip( np.sqrt(out["S1"] ** 2 + out["S2"] ** 2) / (out["S0"] + 1e-6), 0, 1 ) out["AoP"] = 0.5 * np.arctan2(out["S2"], out["S1"]) return outweight是 TV 的平滑权重,越大去噪越强,但 S1/S2 的弱纹理也更容易被抹掉。参数上先试 0.04、0.08、0.12 三档,看 DoLP 边缘和 AoP 过渡区,不要只看 S0 的 PSNR。channel_axis=None表示逐灰度通道处理,这里每个 Stokes 分量本来就是单通道。逐通道去噪会轻微破坏 S0/S1/S2 之间的相关性,如果发现 DoLP 出现块状伪影,可以把三个分量叠成三通道一起做 TV,但权重需要重新调。
4.2 BM3D 对照实验的接入方式
BM3D 作为对照,接口单独包一层,没装就明确报错,不让基线脚本静默跳过。sigma_psd不要拍脑袋,先从噪声图估计,或者在小网格里搜。
try: import bm3d except ImportError: bm3d = None def denoise_stokes_bm3d(stokes, sigma_psd=0.02): if bm3d is None: raise ImportError("bm3d 未安装,先跑 TV 基线或安装后再试") out = {} for key in ("S0", "S1", "S2"): out[key] = bm3d.bm3d(stokes[key], sigma_psd=sigma_psd) out["DoLP"] = np.clip( np.sqrt(out["S1"] ** 2 + out["S2"] ** 2) / (out["S0"] + 1e-6), 0, 1 ) out["AoP"] = 0.5 * np.arctan2(out["S2"], out["S1"]) return outsigma_psd是噪声标准差,按 0~1 归一化后的尺度给,0.02 只是起步值。BM3D 对 S1/S2 的负值能处理,但去噪后可能产生轻微偏移,重算 DoLP 前不要额外裁剪负值。参数上stokes是compute_stokes的返回字典,sigma_psd建议从 0.01 到 0.05 扫一遍,看 AoP 误差而不是只看 SSIM。BM3D 比 TV 慢,批量跑的时候先拿几个场景验证参数,再全量跑。
4.3 轻量 CNN 训练与推理
学习型方法不追求大网络,先用一个残差 CNN 把 S0/S1/S2 三通道的噪声学出来。输入做逐通道标准化,输出残差加回输入,训练目标可以是 L1,后面再换带梯度约束的损失。
import torch from torch import nn class PolarDenoiseNet(nn.Module): def __init__(self, in_ch=3, width=32): super().__init__() self.body = nn.Sequential( nn.Conv2d(in_ch, width, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(width, width, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(width, in_ch, 3, padding=1), ) def forward(self, x): # 残差学习:网络只预测噪声,输出加回输入 return x + self.body(x)in_ch=3对应 S0/S1/S2,width=32是中间通道数,先小后大。输入不能直接拿原始 Stokes 值,S1/S2 有负值且量级和 S0 不同,训练前按训练集均值方差做逐通道标准化,推理时再反标准化。残差结构适合去噪,因为噪声占比较小,网络学残差比直接学干净图更容易收敛。参数上width翻倍会明显增加显存,先 32 跑通再试 48 或 64。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total = 0.0 for noisy, clean in loader: noisy, clean = noisy.to(device), clean.to(device) pred = model(noisy) loss = criterion(pred, clean) optimizer.zero_grad() loss.backward() optimizer.step() total += loss.item() * noisy.size(0) return total / len(loader.dataset)loader返回的noisy和clean都是三通道 Stokes 张量,criterion先用 L1,稳定后再换组合损失。device按环境给,total按样本数加权平均,避免最后一个 batch 小导致 epoch loss 抖动。训练时先冻结验证集场景,不要在训练中途用验证场景调增强参数,否则指标会偏乐观。
4.4 指标评估:PSNR/SSIM 不够时看什么
PSNR 和 SSIM 能看强度图恢复,但偏振量还要看 AoP 的角度误差。AoP 有 π 周期,普通 MAE 会把-π/2和π/2算成大误差,工程里用 2 倍角把周期消掉再算平均绝对误差。
import numpy as np from skimage.metrics import peak_signal_noise_ratio, structural_similarity def eval_psnr_ssim(gt, pred, data_range=1.0): return { "psnr": peak_signal_noise_ratio(gt, pred, data_range=data_range), "ssim": structural_similarity(gt, pred, data_range=data_range), } def aop_mae(gt, pred): # AoP 周期为 π,用 2 倍角映射后再比较 diff = np.angle(np.exp(1j * 2 * (pred - gt))) / 2.0 return float(np.mean(np.abs(diff)))data_range=1.0对应归一化后的输入,如果换回 8 位图要改成 255。aop_mae返回弧度误差,看平均误差的同时也看高亮边缘区域的最大误差。参数上gt和pred形状要一致,DoLP 评估前先裁掉配准边缘,否则边缘误差会拉高整体指标。
5. 避坑与排查:偏振去噪工程里最容易翻车的五处
偏振去噪的排查顺序和普通图像去噪不一样,先看物理量对不对,再看网络收不收敛。下面五处是这套工程里最容易翻车的地方,每条按现象、原因、解决写,照着查能省不少时间。
5.1 数据与坐标类问题
现象:DoLP 图边缘出现一圈亮边或毛刺,去噪后仍然存在。原因:四个偏振角的强度图没有严格配准,或者采集时场景动了,差分 S1/S2 把位移放大成边缘伪影。解决:先对四方向图做同一裁切,去掉边缘若干像素;采集时固定场景和相机,能用同一帧触发就不要分时拍。配准没解决之前,调 TV 权重和网络层数都是白费。
现象:S0 整体偏亮或偏暗,DoLP 接近 0 或大面积饱和。原因:位深归一化写死成除以 255,16 位图读进来后数值全在 0~255 之外;或者彩色转灰度时通道顺序不对。解决:按
img.dtype选max_val,读入后打印min/max确认范围;彩色图先确认四方向是否同一通道,不要直接cvtColor了事。现象:去噪后的 DoLP 很平滑,但和原始 Stokes 对不上,物理一致性丢了。原因:直接对 DoLP 图做滤波,或者对 DoLP 和 S0/S1/S2 分别去噪后再拼。解决:只对 S0/S1/S2 去噪,DoLP 和 AoP 一律从去噪后的 Stokes 重算;评估时也以重算结果为准,不要拿滤波后的 DoLP 当标签。
5.2 训练与评估类问题
现象:训练集 PSNR 很高,换一个场景就明显掉点。原因:按 patch 随机切分,同一场景的相邻块同时进训练和验证,模型记住了场景纹理而不是噪声分布。解决:按场景切分,先分场景再裁 patch;验证集场景在训练时完全不出现,增强参数也不在验证集上调。
现象:AoP 指标忽大忽小,肉眼看着角度差不多但误差很高。原因:用普通 MSE 或 MAE 比较角度,π 周期导致
-π/2和π/2被当成反向误差。解决:用 2 倍角映射后的圆统计误差,评估前裁掉配准边缘,同时看平均误差和边缘区域误差。BM3D 的sigma_psd也要在验证场景上扫,不要拿训练场景的最优值直接套。
排查顺序建议先跑单场景可视化:读四方向图、算 Stokes、看 DoLP/AoP 原图,再跑去噪和重算。单场景对不上,不要进批量;批量指标异常,先查切分泄漏和位深,再查网络。传统基线能跑通、指标合理,再上 CNN,这样出问题时能判断是数据还是模型。
6. 二次开发改造:从换数据集到换损失函数的可验证路径
换数据集先别急着训练,按下面这张表过一遍,能挡掉大部分低级错误。配套数据集的结构和工程约定一致时,改data.root和dataio.py的命名规则即可;结构不一致时,先写一个转换脚本把四方向图整理成“场景目录 + 角度后缀”,再动训练代码。
| 检查项 | 看什么 | 不通过会怎样 |
|---|---|---|
| 四方向齐全 | 每个场景是否有 0/45/90/135 | 缺角度直接报错,别静默跳过 |
| 尺寸一致 | 四个数组 shape 是否相同 | Stokes 差分广播错位 |
| 位深范围 | 读入后 min/max 是否在 0~1 | DoLP 整体偏移或饱和 |
| 场景切分 | 验证场景是否独立 | 指标虚高,换场景掉点 |
| 边缘配准 | DoLP 边缘是否有亮边 | 去噪后仍残留伪影 |
改损失函数时,先保持网络和数据不变,只换损失跑短周期对比。比如在 L1 上加梯度一致性,让去噪后的 Stokes 梯度靠近干净图,代码可以这样写:
import torch def polar_loss(pred, target, w_grad=0.1): l1 = torch.abs(pred - target).mean() dx_pred = pred[:, :, :, 1:] - pred[:, :, :, :-1] dx_gt = target[:, :, :, 1:] - target[:, :, :, :-1] dy_pred = pred[:, :, 1:, :] - pred[:, :, :-1, :] dy_gt = target[:, :, 1:, :] - target[:, :, :-1, :] grad = torch.abs(dx_pred - dx_gt).mean() + torch.abs(dy_pred - dy_gt).mean() return l1 + w_grad * gradw_grad从 0.05 到 0.2 试,太大容易把噪声当纹理保留,太小对边缘帮助不明显。验证方法不是只看 PSNR,要同时看 DoLP 边缘和 AoP 圆误差;如果 PSNR 涨了但 AoP 误差变差,说明损失在强度上过拟合。换网络也一样,先固定损失和数据,只改宽度或层数,跑三个种子看方差,别拿一次结果下结论。
从那以后我每次换数据集,都先只跑 10 张图的可视化,确认四方向对齐、位深和 DoLP 重算没问题,再开训练;这个习惯替我省掉了不少“模型不收敛”的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取