偏振图像分析实战:去马赛克、斯托克斯与穆勒矩阵解析
2026/9/13 2:48:10 网站建设 项目流程

简介:偏振图像分析工具用Python实现了完整的偏振图像处理流程,面向图像处理、光学检测、遥感、生物医学成像和材料科学等方向的开发者,涵盖去马赛克、斯托克斯向量计算与穆勒矩阵分析三大任务,便于理解偏振光变化规律,并完成图像复原、偏振参数提取和目标材质差异分析。压缩包共25个文件,大小为1.49MB,其中包含11个源代码文件、9张示例照片、3张示意图、1份说明文档和许可证文件。源代码包括偏振图像去马赛克、斯托克斯参数求解、穆勒矩阵计算等功能模块,示例图片展示了不同偏振角度下拍摄的强度图以及经偏振角图、偏振度图处理后的效果图,说明文档则对代码结构、依赖环境和使用方法进行了梳理。目前已有1516人学习/下载。借助这些代码,读者可以快速搭建偏振图像分析实验环境,参考示例数据理解偏振参数的计算过程,并在此基础上修改或扩展算法,适合希望在偏振成像方向快速上手的科研人员和工程师。

1. 偏振图像分析工具的第一步:从马赛克原图到物理量

打开dragon_IMX250MZR_intensity.jpg这类偏振相机原始图时,第一眼往往不是彩色照片,而是一张灰扑扑的马赛克。普通相机拜耳阵列分的是 R/G/B 颜色,偏振相机(比如 Sony IMX250MZR)则把 0°、45°、90°、135° 四个偏振方向的微偏振片铺在相邻 2×2 像素上,原始图需要先拆成四个方向的强度通道,才能继续算斯托克斯向量和穆勒矩阵。Polanalyser 就是这套流程的 Python 实现:demosaicing.py负责去马赛克,stokes.py计算 S0/S1/S2 与 DoLP/AoLP,mueller.py处理多偏振态测量下的 16 元素矩阵反解。它适合用 Python 做偏振成像算法验证、光学检测标定或材料偏振响应研究的人,不需要自己从头造轮子。

2. 去马赛克:把偏振拜耳阵列拆成四个独立强度

2.1 偏振马赛克与普通拜耳马赛克的本质区别

普通相机的拜耳阵列在 2×2 单元里放 R、G、G、B 三种滤色片,去马赛克的本质是插值:用周围像素估计每个位置的完整 RGB。偏振相机的 2×2 单元则放 0°、45°、90°、135° 四种微偏振片,每个像素只能测到某一个方向的线偏振强度。它没有“颜色”需要恢复,要的是把同一场景在四个方向的强度分别取出来。 如果你把原图直接当灰度图用,2×2 内的偏振调制会一直混在像素里,后续算 S1 = I0 − I90 时,每个像素拿到的都可能是相邻方向的串扰,结果完全失真。

这里还有一个很多人没注意的代价:空间分辨率减半。四个方向的像素共享同一块靶面,拆开后每个方向的有效图片是原图宽度和高度各一半。也就是说,偏振相机的“原生分辨率”和普通相机同像素时并不等价。Polanalyser 的demosaicing.py不打算做超分辨,它的职责就是把像素按微偏振片位置挑选出来,排成四通道强度图。理解了这点,下面读代码就不会被形状变化吓到。

2.2 用 demosaicing 的核心逻辑拆分强度图

下载的polanalyser-master里,demosaicing.py对外暴露的函数在源码里通常就叫demosaicing。它的核心计算并不复杂,如果你只想快速跑通,下面这个切片版本足够:

import numpy as np import cv2 def demosaic_polar(raw, pattern="imx250mzr"): """拆分偏振马赛克图,返回 (H/2, W/2, 4) 的 float32 强度图。 通道顺序:0度、45度、90度、135度。 """ if pattern == "imx250mzr": I0 = raw[0::2, 0::2].astype(np.float32) I45 = raw[0::2, 1::2].astype(np.float32) I90 = raw[1::2, 0::2].astype(np.float32) I135 = raw[1::2, 1::2].astype(np.float32) else: raise ValueError("unknown polarization mosaic pattern") return np.stack([I0, I45, I90, I135], axis=-1) raw = cv2.imread("dragon_IMX250MZR_intensity.jpg", cv2.IMREAD_UNCHANGED) if raw.ndim == 3: raw = raw[..., 0] # 某些原图会以三通道形式保存,但只用了同一通道 img = demosaic_polar(raw) print(img.shape, img.dtype) # (H/2, W/2, 4) float32

逻辑说明:raw[0::2, 0::2]取的是偶数行偶数列,对应 0° 微偏振片的位置;raw[0::2, 1::2]对应 45°,raw[1::2, 0::2]对应 90°,raw[1::2, 1::2]对应 135°。切片本身是视图,astype(np.float32)会复制数据,这样后续做减法时不会因为 uint16 溢出。pattern参数留给不同相机的微偏振片排布差异,IMX250MZR 是常见的 0/45/90/135 按行排列。

需要强调读取方式:用cv2.imread(path, cv2.IMREAD_COLOR)会把单通道马赛克复制成三通道等值图,再去切片就会让四个方向完全相同。所以IMREAD_UNCHANGED不是可选项,是必须项。工具包里的examples脚本有的还会用tifffileimageio读 12bit 原图,效果类似。

2.3 参数、dtype 与常见坑

项目推荐做法原因
读取原图cv2.imread(path, cv2.IMREAD_UNCHANGED)默认 flag 会把 12bit 压成 8bit,破坏偏振强度线性度
数据 dtypeuint16 读入,分割后立即转 float32S1/S2 是差值,会出现负数,uint16 无法表示
输入形状必须是 (H, W) 或 (H, W, 1)三通道等值马赛克会让四个方向完全相同
通道顺序先统一成 0/45/90/135stokes.py的 I0−I90 和 I45−I135 依赖这个顺序
输出形状(H/2, W/2, 4)每个方向共用一帧,空间分辨率减半

一个常见坑是相机厂商的 SDK 可能已经把数据排成了四通道,或把 2×2 块改成“交错行”。这种情况下继续用上面的切片会得到棋盘状伪影。我一般会先对均匀光照下的平面拍一张图,拆完后看四个通道均值是否接近;如果某个通道明显偏亮或出现周期性条纹,通常是行列偏移了一位,把1::20::2对调即可。

2.4 验证拆分是否正确

means = img.reshape(-1, 4).mean(axis=0) print(means) # 均匀散射区域,四个通道均值应接近

逻辑说明:对一片没有明显偏振特性的区域,DoLP 接近 0,四个方向的强度应该近似相等。任何一个通道显著偏离,说明马赛克相位没对齐。这个检查只需要几行,却是后面所有计算正确性的地基。如果直接跳到斯托克斯计算,S1/S2 的错误会被可视化图掩盖成“颜色有点怪”,很难定位是算法问题还是像素错位。

3. 斯托克斯向量计算:从四方向强度到 S0、S1、S2

3.1 斯托克斯参数的线性定义

斯托克斯向量把光的偏振态写成四个实数,对线偏振成像场景通常只关心前三个:S0 是总光强,S1 表示水平与垂直线偏振的差,S2 表示 45° 与 135° 线偏振的差。只要拿到了去马赛克后的 I0、I45、I90、I135,S0/S1/S2 就是简单的加减法:

def calc_stokes(img): """输入 (H/2, W/2, 4),返回 (H/2, W/2, 3)。""" I0, I45, I90, I135 = img[..., 0], img[..., 1], img[..., 2], img[..., 3] S0 = I0 + I90 S1 = I0 - I90 S2 = I45 - I135 return np.stack([S0, S1, S2], axis=-1)

逻辑说明:S0 用一对正交方向相加,是因为 0° 和 90° 强度之和等于总强度;S1、S2 则是两组正交方向的差分。源码包里的stokes.py基本就是这几行,只是额外处理了输入通道顺序和 NaN。要注意坐标系定义:有些教材把 S1 写成 I90 − I0,这样可视化时 S1 的符号会左右翻转,AoLP 也会偏移 90°。同一个包里demosaicing.pystokes.py必须保持同一套约定,我一般会在一开始写注释固定通道顺序。

3.2 DoLP 与 AoLP:归一化和弧度处理

只拿到 S0/S1/S2 还不够,实际分析经常要偏振度 DoLP 和偏振角 AoLP。DoLP 表示偏振光占总强度的比例,范围 0 到 1;AoLP 表示偏振主轴方向,物理上只有 0 到 π 的周期。实现如下:

def calc_DoLP(stokes, eps=1e-6): S0, S1, S2 = stokes[..., 0], stokes[..., 1], stokes[..., 2] return np.sqrt(S1 ** 2 + S2 ** 2) / np.maximum(S0, eps) def calc_AoLP(stokes): S1, S2 = stokes[..., 1], stokes[..., 2] aolp = 0.5 * np.arctan2(S2, S1) return np.mod(aolp, np.pi) # 主轴在 [0, π) 内

参数说明:eps=1e-6是为了避免 S0 接近 0 时除零;np.mod(aolp, np.pi)必须加,因为0.5 * arctan2返回范围是 −π/2 到 π/2,而偏振角是 90° 周期,直接转成 0 到 π 的循环量才方便做伪彩色和统计。如果想要角度显示,乘180 / np.pi,注意是 0 到 180 度,不是 0 到 360 度。

3.3 视觉化:examples/visualize_AoLP.py 的思路

示例目录里的visualize_AoLP.py不是简单地显示灰度图,而是把 AoLP 映射到色环:颜色代表偏振方向,亮度和饱和度分别表示光强和偏振度。这样一张图能同时看出三个维度。核心代码如下:

def visualize_stokes(stokes): S0 = stokes[..., 0] DoLP = calc_DoLP(stokes) AoLP = calc_AoLP(stokes) hsv = np.zeros((S0.shape[0], S0.shape[1], 3), dtype=np.uint8) hsv[..., 0] = (AoLP / np.pi * 179).astype(np.uint8) hsv[..., 1] = (np.clip(DoLP, 0, 1) * 255).astype(np.uint8) hsv[..., 2] = (S0 / np.max(S0) * 255).astype(np.uint8) return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)

逻辑说明:OpenCV 的 8bit HSV 模式中 Hue 范围是 0 到 179,所以把 AoLP 的 0 到 π 映射到 0 到 179。Saturation 放 DoLP,Value 放归一化光强 S0。dragon_IMX250MZR_AoLP_color.jpg这类示例图就是这么生成的。jpge保存时质量选高一些,否则色环边缘会出现条带。

3.4 用 S0 做自检

斯托克斯计算完成后,我会先做一个最小验证:把 S0 单独显示出来,它应当和原图经过 2×2 池化后的结果很像,只是尺寸减半。如果 S0 出现棋盘格,说明去马赛克的行列错位;如果 S0 基本正确但 S1/S2 一片杂乱,则要检查暗场和坏点。下面的代码可以替代人眼判断:

S0 = stokes[..., 0] S0_expected = img[..., 0] + img[..., 2] # I0 + I90 print(np.max(np.abs(S0 - S0_expected)))

逻辑说明:S0_expected直接从去马赛克结果里相加,理论上必须与calc_stokes输出的 S0 完全一致。这个断言能在早期发现通道顺序写反、数组被原地修改等问题。注意这里不是和img.mean(axis=-1)对比,因为mean除以了 4。

4. 穆勒矩阵计算:从一组偏振态测量反解物体的偏振响应

4.1 为什么一张图算不出穆勒矩阵

斯托克斯向量描述光的偏振状态,穆勒矩阵描述物体把入射偏振态变成出射偏振态的 4×4 线性变换。每个像素在不同入射偏振态下会给出不同出射强度,单张偏振图只对应一次入射,最多得到一个方程,而一个未知物体有 16 个矩阵元素要解。因此至少需要四个线性无关的入射 Stokes 向量,配合四个线性无关的分析态测量。Polanalyser 的mueller.py做的事情,就是把一组测量强度图按最小二乘反解成每个像素的 4×4 矩阵。

这里要区分两个概念:去马赛克和斯托克斯计算都是“单帧处理”,穆勒矩阵需要多帧。最简实验装置是入射侧放一个可旋转偏振片产生偏振态 PSG,出射侧放另一个偏振片分析 PSA,中间放被测样品。你下载的源码包里examples/mueller.py就是这个流程的演示。

4.2 测量方程与线性反解

理想线偏振片在角度 θ 处的 Stokes 向量是(1, cos2θ, sin2θ, 0)。如果入射 Stokes 是s_in,样品矩阵是 M,出射经过分析器后的强度满足:

I = a_out^T · M · s_in

把 M 按行优先展平成 16 维向量m,则每个测量就是一个行向量kron(a_out, s_in)m的内积。16 组不同偏振态组成 16×16 矩阵 A,强度图展平成(16, N),用最小二乘一次解出所有像素的矩阵元素。

以下是通用的方程构建函数:

import numpy as np def build_mueller_equations(S_in_list, A_out_list): """根据入射/分析斯托克斯向量构造测量矩阵 A。""" A = [] for s_in in S_in_list: for a_out in A_out_list: A.append(np.kron(a_out, s_in)) return np.array(A) states = [ [1, 1, 0, 0], # 0° 线偏振 [1, 0, 1, 0], # 45° 线偏振 [1, 0, 0, 1], # 右旋圆偏振 [1, -1, 0, 0], # 90° 线偏振 ] A = build_mueller_equations(states, states) print("condition number:", np.linalg.cond(A))

逻辑说明:states同时作为入射和分析器状态,覆盖了线偏振和圆偏振,保证 A 可逆。np.kron(a_out, s_in)的排列顺序与M.flatten()的展开方式一致,kron中参数顺序不能反。如果只用 0/45/90/135 线偏振,A 会奇异,因为所有输入输出状态都落在 S3=0 的子空间,穆勒矩阵的第三行和第三列永远解不出来。真实测量至少需要一组四分之一波片产生圆偏振态。

4.3 像素级反解与内存控制

拿到 A 矩阵后,按测量顺序把强度图堆成(16, H, W)的数组,再展平求解:

# I_meas 形状 (16, H, W),顺序必须和 build_mueller_equations 的循环顺序一致 I_flat = I_meas.reshape(16, -1) M_flat = np.linalg.lstsq(A, I_flat, rcond=None)[0] M = M_flat.reshape(4, 4, H, W) # 查看某个区域的平均穆勒矩阵 print(M[..., :3, :3].mean(axis=(2, 3)))

参数说明:I_flat是 16 行、N 列的矩阵,每列对应一个像素的 16 次测量。lstsq在这种情况下比inv(A) @ I_flat更稳,因为偏振片不理想时 A 的条件数会变大。M_flat.reshape(4, 4, H, W)把每个像素恢复成 4×4 矩阵,第 0 维是矩阵行,第 1 维是矩阵列,后面两维是空间坐标。

内存方面,16 张 1200 万像素的 float32 图就有约 768MB,直接把全部像素丢进lstsq很容易爆内存。我会改成按行或按块施解,比如每次取 4096 个像素:

block = 4096 M_block = np.empty((4, 4, block, I_meas.shape[2])) for start in range(0, I_meas.shape[1], block): end = min(start + block, I_meas.shape[1]) M_block[..., :end - start, :] = np.linalg.lstsq(A, I_flat[:, start:end], rcond=None)[0].reshape(4, 4, end - start, -1)

逻辑说明:把高度方向切块,每块内所有列共享同一个 A 矩阵,lstsq一次处理一个块,耗时几乎线性增加,但内存占用可控。M_block的最后一维仍然是宽度,宽度太大时可以再继续切。

4.4 偏振态选取与误差来源

入射/分析 Stokes说明
[1, 1, 0, 0]0° 线偏振,能量集中在 S1
[1, 0, 1, 0]45° 线偏振,能量集中在 S2
[1, 0, 0, 1]右旋圆偏振,提供 S3 信息
[1, -1, 0, 0]90° 线偏振,与第一个状态互补

选状态时要看 A 的条件数。条件数接近 1 表示方程组对噪声不敏感;大于 100 时,即使测量强度有 1% 噪声,矩阵元素可能被放大到 100% 误差。用np.linalg.cond(A)在测量前验证是值得的。

实际采集还要注意三件事:一是暗场,偏振相机长时间曝光后暗电流会叠加,拍摄前盖住镜头拍一帧减去;二是光强饱和,穆勒矩阵反解是线性运算,饱和像素会直接破坏方程组;三是偏振片旋转误差,哪怕 1° 的角度偏差也会在圆偏振项上放大,最好用相机标定出的偏振效率而非理想模型。

5. 进阶:批量出图与 AoLP 相位缠绕的统计技巧

5.1 一个可以直接跑的批量脚本

把前面的函数串起来,并加上合理的输出命名,就能对一组偏振图批量生成 S0、DoLP、AoLP 图:

from pathlib import Path import cv2 import numpy as np import polanalyser as pa raw_root = Path("/data/polar_raw") out_root = Path("/data/polar_out") out_root.mkdir(exist_ok=True) for raw_path in sorted(raw_root.glob("*.jpg")): raw = cv2.imread(str(raw_path), cv2.IMREAD_UNCHANGED) if raw.ndim == 3: raw = raw[..., 0] img = pa.demosaicing(raw) stokes = pa.calc_Stokes(img) dolp = pa.calc_DoLP(stokes) aolp = pa.calc_AoLP(stokes) prefix = out_root / raw_path.stem cv2.imwrite(str(prefix) + "_S0.png", stokes[..., 0].astype(np.uint16)) cv2.imwrite(str(prefix) + "_DoLP.png", (np.clip(dolp, 0, 1) * 65535).astype(np.uint16)) cv2.imwrite(str(prefix) + "_AoLP.png", (aolp / np.pi * 180).astype(np.uint8))

参数说明:S0用 uint16 保存,因为它是强度累加值,8bit 会丢失暗部细节;DoLP本身是 0 到 1 的小数,乘 65535 转成 16bit;AoLP只有 0 到 180 度,用 8bit 足够,但要注意 0 和 180 在显示上都是黑色,不适合直接看细节,需要配合伪彩色映射。

5.2 统计 ROI 角度时先平均 S1/S2

AoLP 是循环量,0° 和 180° 物理上是同一个方向,但直接对角度做平均会得出完全错误的结果。比如两个像素分别是 1° 和 179°,直接平均是 90°,而真实主轴方向接近 0°。正确做法是先对 S1、S2 取平均,再反算角度:

roi = (slice(200, 400), slice(300, 500)) S1_roi = stokes[..., 1][roi] S2_roi = stokes[..., 2][roi] mean_aolp = 0.5 * np.arctan2(np.mean(S2_roi), np.mean(S1_roi)) mean_aolp_deg = np.degrees(np.mod(mean_aolp, np.pi))

逻辑说明:S1/S2 是斯托克斯空间的线性分量,平均后仍然是有物理意义的合成偏振方向。这个方法比调用scipy.stats.circmean更直接,因为它没有损失偏振度信息。当区域内 DoLP 很低时,S1/S2 的绝对值都很小,此时mean_aolp会被噪声主导,应该在 ROI 统计时把DoLP低于阈值的像素先排除。保存图片时的最后一个细节是:AoLP只适合做定性展示,做定量分析一定要保留原始 S0/S1/S2,不要从伪彩色图反推角度。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询