简介:基于主成分分析(PCA)与K-means聚类的遥感变化检测Python工程包,面向遥感图像处理与机器学习初学者,适合用于监测地表覆盖变化、城市扩张、植被破坏等场景。压缩包内共14个文件,以Python源码为主,涵盖数据预处理、PCA降维、K-means聚类和变化检测的完整流程,并包含数据读取、特征提取、结果可视化等模块;同时附有3张结果示意图,便于对照算法输出;另有项目配置文件,方便直接导入IDE运行调试。整个压缩包仅59KB,轻量易用。目前已有254人学习下载,代码结构简洁,模块划分合理,适合作为课程设计或科研入门的参考实现。通过阅读该项目,可快速掌握如何利用PCA降低多光谱数据维度、用K-means区分地物类别,并比较不同时相影像聚类结果以提取变化区域,是一份兼顾原理与实战的实用资源。
1. 没有标注数据也能做变化检测:PCAKmeans这套组合到底解决什么问题
遥感变化检测落到工程上,就是回答一句话:同一块地面,两个时相之间哪里变了?很多从业者第一反应是用深度学习,但现实项目里经常连一份靠谱的标注都凑不出来。PCAKmeans变化检测恰好是这条赛道上最经典的无监督方案:先用主成分分析把高维的光谱差分特征压缩成少数相互独立的成分,再用K均值聚类把像元分成“变化”和“未变化”两类。整个过程不需要任何标签,两张配准好的影像就能跑。
适合的人群很明确:做地理国情监测、土地违规图斑抽查、灾害快速评估,手里只有影像没有标注,想先快速出一版候选变化图给下一步人工排查当线索的人。监督方法精度高,但标注成本不现实的时候,PCAKmeans是最值得先试的零样本起点。它不追求把每个变化语义说清楚,只负责把可能变化的像元筛出来,这正是后续所有工作需要的底图。
后面从原理讲起,给出一套可以直接运行的Python流程,再把参数陷阱和它与开放词汇变化检测这类新工具的协作方式逐一拆开。
2. PCA为什么排在KMeans前面:高维差分空间里的聚类逻辑与参数含义
2.1 把两期影像变成差分向量:变化检测问题的第一个建模选择
两期影像可以看作两个 (H \times W \times B) 的数组,(B) 是波段数。最简单的变化检测模型是逐波段做差:(d = x_1 - x_2),得到每个位置的差分向量,再取模长。模长越大,说明光谱变化越剧烈。这个思路很直观,但真正操作时会发现,直接对差分向量设阈值非常不稳。同一块建设用地,t1是裸土、t2是水泥地,差分模长只有1.2;而同一块农田,t1是湿润土壤、t2是干燥土壤,差分模长可能到2.5。阈值设低了全是噪点,设高了漏掉真实变化。
PCAKmeans的建模方式绕开了阈值判断:它把所有像元的差分向量放到同一个高维空间里,然后问一个问题,这些点是否自然分成两群?一群靠近原点,对应光谱几乎没变的像元;另一群远离原点,对应变化像元。KMeans负责找出这两个群的划分边界。这里要注意,差分向量可以只取逐波段光谱差,但更稳的做法是让每个像元带上空间邻域信息,比如取该像元周围3×3邻域内所有波段的差值,构成一个 (B \times 3 \times 3) 的窗口差分特征。这样做的原因是地表变化很少只动一个像元,通常伴随边界和纹理变化,邻域上下文能显著减少孤立噪点。窗口越大上下文越强,但边界区域的定位精度会下降,一般取3×3或5×5。
2.2 高维空间里的冗余让KMeans失效,PCA先做去相关与白化
差分特征维度通常不高,比如4波段影像取3×3窗口后是36维。但KMeans在这个维度上直接聚类经常表现很差,问题不在维度数量,而在维度之间的相关性。多光谱波段并不是彼此独立的:绿光和红光在植被区域的变化模式高度一致,近红外与红光在水体区域的响应也互相牵连。这些相关性让差分向量的分布被压扁成一条狭窄的流形,而不是一个漂亮的球形分布。KMeans用的是欧氏距离,在挤瘪的流形上,距离大小会被冗余方向反复稀释,聚类边界很容易被噪声拖歪。
PCA在这里解决的问题有两个。第一是去相关,PCA通过特征分解找到数据方差最大的几个正交方向,把原始36维特征重新表达成少数几个互不相关的主成分。第二是白化,设置whiten=True时,PCA会把每个主成分缩放到单位方差,让特征空间变成各向同性。这一步对KMeans尤其关键,因为KMeans的聚类边界基于欧氏距离,如果某个主成分的方差是另一个的10倍,这个主成分会单方面主导距离计算,另一个方向的差异全被淹没。白化之后每个方向贡献相同,KMeans才能公平地看待每个维度。
2.3 KMeans的K值、球形簇假设与“变化簇”的判定方式
KMeans假设每个簇近似球形,且簇与簇之间大小差别不大。在变化检测里,K=2是最常见的设置,对应“变化/未变化”两个语义类。但实际数据很少完美满足球形假设:变化像元占比通常只有百分之几,变化簇的方差远大于未变化簇,两个簇的大小也严重不对称。这时候KMeans容易把一个大簇的边缘切给另一个小簇。缓解办法有两个方向:一是先做白化,让各方向等尺度;二是允许K>2,把差异更细致的拆分。比如一片区域同时存在城市建设和植被枯死,差分空间里可能出现三个或四个自然簇,KMeans会对应分成多个变化模式。得到多个簇之后,再根据簇中心的模长决定哪些簇代表变化。
簇的语义判定有一个稳定技巧:差分特征向量的原点对应“光谱没变”。未变化簇的中心会靠近原点,变化簇的中心会远离原点。KMeans结果里哪个簇中心的欧氏距离更大,哪个簇就对应变化。这个规则比“看哪个簇像素少”可靠得多。我在实际项目里还会做一个辅助校验:把聚类中心反变换回原始差分空间,再计算各簇平均差分向量在主要波段上的数值,人工扫一眼是否符合该区域常见的变化模式,比如建设用地表现为红边波段差值大、水体变化表现为近红外差值显著。这里的判断逻辑完全不需要标签,只需要对地物光谱常识有一点了解。
2.4 辐射校正是前提不是调参:先消除光照差异再做差分
差分特征的质量取决于两期影像是否处于同一个辐射尺度。如果t1是夏季正午、t2是秋季清晨,两期影像的太阳高度角、大气路径辐射都不同,整幅影像的亮度差异可能压过真实地表变化。这时候PCAKmeans聚出来的第一大类往往是“光照差异”而不是“地表变化”。这不是算法参数能调整回来的,必须在差分之前先做相对辐射归一化。
常见做法是以一期影像为基准,把另一期的每个波段用线性回归匹配到基准的均值和方差。更严格一点可以用直方图匹配,或者用IR-MAD这类稳健回归选取不变像元来计算线性关系。工程上最省事的办法是逐波段做均值-方差拉伸:把t2每个波段的均值和标准差匹配到t1对应波段的均值和标准差。这个方法对大多数项目够用,但要注意它假设两期之间存在全局线性关系,如果影像中云、阴影比例很高,需要先做掩膜把云剔除,否则回归会被云端干扰。辐射归一化做不好,后面所有步骤都会得到一个看起来合理但实际包含大量虚假变化的掩膜。
3. 用Python把PCAKmeans变化检测跑通:从两张TIF到一张变化掩膜
3.1 读图与预处理:对齐、类型转换和一次必要的断言
开始编码前,先用Rasterio把两期影像读进来,并验证尺寸完全一致。这里最容易被忽略的是空间对齐:两期影像投影一致但像元网格错半个像元,差分结果会在地物边缘形成一条条虚假变化带。读图时重点检查shape,如果两期影像的宽高不一致,先用gdalwarp统一投影和分辨率,再回到这个步骤。读进来的数据是整数型(uint16居多),需要转成浮点型,否则后面差分计算中负值会被截断。
import rasterio import numpy as np def load_pair(path1, path2): with rasterio.open(path1) as ds1, rasterio.open(path2) as ds2: img1 = ds1.read() # shape (C, H, W) img2 = ds2.read() meta = ds1.meta.copy() if img1.shape != img2.shape: raise ValueError("两期影像尺寸不一致,先做几何配准") # 转成 (H, W, C) 的 float32,避免uint16做减法时的截断问题 img1 = np.transpose(img1, (1, 2, 0)).astype(np.float32) img2 = np.transpose(img2, (1, 2, 0)).astype(np.float32) return img1, img2, metameta保存了影像的仿射变换参数和投影信息,后面写结果时直接复用。断言放在读图阶段而不是预处理阶段,是为了尽早暴露几何问题。很多项目里的翻车现场都是两期影像肉眼看着差不多,一跑差分全是噪声,最后定位到是投影坐标系差了一个带号。这一步多花两分钟,后面能省下大半天的排错时间。
3.2 辐射归一化与差分特征矩阵:让每个像元带上邻域上下文
相对辐射归一化采用逐波段均值-方差匹配,以t1为基准,把t2拉回到t1的辐射尺度。这里的实现有一个边界防御:当某波段标准差趋近于0时,直接赋基准均值,避免除以接近0的数产生极端值。
def relative_radiometric_normalize(img1, img2): out = np.empty_like(img2, dtype=np.float32) for b in range(img1.shape[2]): m1, s1 = img1[..., b].mean(), img1[..., b].std() m2, s2 = img2[..., b].mean(), img2[..., b].std() if s2 < 1e-6: out[..., b] = m1 else: out[..., b] = (img2[..., b] - m2) / s2 * s1 + m1 return out归一化这一步对PCAKmeans的影响是决定性的。不做归一化直接聚类,大概率得到的是两期影像的整体亮度差,尤其在大范围影像中,这种现象几乎必然出现。做完归一化后,再构建差分特征矩阵。
from numpy.lib.stride_tricks import sliding_window_view def build_diff_features(img1, img2, patch_size=3): p = patch_size h, w, c = img1.shape r = p // 2 # 边界用reflect填充,避免边缘像元差分特征被0填充污染 img1p = np.pad(img1, ((r, r), (r, r), (0, 0)), mode='reflect') img2p = np.pad(img2, ((r, r), (r, r), (0, 0)), mode='reflect') w1 = sliding_window_view(img1p, (p, p, c)) # (H, W, p, p, C) w2 = sliding_window_view(img2p, (p, p, c)) diff = w1 - w2 feats = diff.reshape(h * w, p * p * c) return featssliding_window_view是在内存中建立视图,不复制数据,比用循环逐像元取邻域快很多。但要注意,diff.reshape会触发一次复制,因为窗口视图的存储不连续。这一步在影像规模达到数千万像元时会占用大量内存,后面避坑章节会专门讨论大图的处理方案。
差分特征矩阵每一行代表一个像元的窗口差分向量。3×3窗口、4波段影像,每行36维;RGB影像每行27维。这个矩阵就是PCA和KMeans的输入。
3.3 PCA降维与主成分数量选择:累计解释方差只是一个起点
PCA的输入需要先做标准化。虽然PCA本身会做中心化,但不同波段差分值的量级差异仍然会影响主成分方向,标准化后再进PCA更稳。
from sklearn.decomposition import PCA def standardize(feats): mu = feats.mean(axis=0, keepdims=True) sd = feats.std(axis=0, keepdims=True) + 1e-6 return (feats - mu) / sd feats = standardize(feats) # 先用足够多的主成分看累计方差曲线 k_max = min(8, feats.shape[1]) pca_full = PCA(n_components=k_max, whiten=True) pca_full.fit(feats) ratio = np.cumsum(pca_full.explained_variance_ratio_) for i, r in enumerate(ratio, 1): print(f"前 {i} 个主成分累计解释方差: {r:.1%}")主成分数量选择不能只盯住累计解释方差。变化区域在整幅影像里占比很小,它的差异信息往往分布在方差贡献较低的主成分上。如果强行让前三个主成分解释90%以上的方差,很可能把真正的小面积变化当成噪声丢弃。更稳妥的做法是把前4到6个主成分分别还原成二维图像,用影像拉伸看一下哪个主成分上变化区域显得最亮,再决定保留多少个。这一步虽然带点人工目视的成分,但在无监督变化检测里比纯数值规则可靠。
n_components = 3 pca = PCA(n_components=n_components, whiten=True) feats_pc = pca.fit_transform(feats) print("保留主成分数:", n_components)whiten=True使得各主成分方差被缩放到1,KMeans的欧氏距离不会偏向某一方向,这个设置对聚类很有利。
3.4 KMeans聚类生成变化掩膜:簇标签自动对准“变化”类
KMeans设置K=2,随机状态固定,保证复现。聚类中心需要反变换回原始差分空间,才能判断哪个簇代表变化。
from sklearn.cluster import KMeans km = KMeans(n_clusters=2, n_init=10, random_state=42) labels = km.fit_predict(feats_pc) labels = labels.reshape(img1.shape[0], img1.shape[1]) # 簇中心在主成分空间,反变换回差分特征空间 centers = km.cluster_centers_ centers_orig = pca.inverse_transform(centers) norms = np.linalg.norm(centers_orig, axis=1) change_label = int(np.argmax(norms)) change_mask = (labels == change_label).astype(np.uint8)判断变化簇的原则是:差分特征空间中,远离原点的簇对应变化。inverse_transform能正确还原whiten=True带来的尺度变化,所以这里必须先反变换再算模长,而不是直接在主成分空间里比较。如果直接在主成分空间比较,经过白化后所有方向尺度一致,簇中心的模长依然有效,但对解释性不好;反变换回原始差分空间后,每个维度对应实际波段差,方便后续人工核验。
3.5 整合成一份可重复运行的脚本
把前面几个函数串起来,形成最小可运行脚本。这个脚本可以直接放到命令行里跑,输出一张变化掩膜。
if __name__ == "__main__": img1, img2, meta = load_pair("t1_2023.tif", "t2_2024.tif") img2 = relative_radiometric_normalize(img1, img2) feats = build_diff_features(img1, img2, patch_size=3) feats = standardize(feats) pca = PCA(n_components=3, whiten=True) feats_pc = pca.fit_transform(feats) km = KMeans(n_clusters=2, n_init=10, random_state=42) labels = km.fit_predict(feats_pc).reshape(img1.shape[0], img1.shape[1]) centers = pca.inverse_transform(km.cluster_centers_) change_label = int(np.argmax(np.linalg.norm(centers, axis=1))) change_mask = (labels == change_label).astype(np.uint8) meta.update(count=1, dtype="uint8", compress="lzw") with rasterio.open("change_mask.tif", "w", **meta) as dst: dst.write(change_mask, 1)这段脚本的逻辑链路很清晰:读图 → 辐射归一化 → 构建差分特征 → 标准化 → PCA降维 → KMeans聚类 → 反变换判定变化簇 → 写出结果。参数调整也集中在三个地方:patch_size控制空间上下文范围,n_components控制保留多少主成分,n_init控制KMeans稳定性。第一次运行时建议在测试区域上把这三个参数各换几个值,观察变化掩膜差异,再确定正式参数。
4. PCAKmeans变化检测避坑指南:最常翻车的五个场景
4.1 整张变化图全是椒盐噪声,看不出成片图斑
现象是变化掩膜上散布大量孤立像元,像撒了一把盐。这些孤立像元在目视审核时基本都会被当作误检,严重影响结果可信度。
原因是逐像元差分特征只考虑了邻域光谱差,没有利用空间连续性。土地变化天然是成片的,单个像元的剧烈光谱变化很可能来自传感器噪声或配准残差。KMeans对这类噪点没有过滤能力,因为它只做光谱聚类,不看周围像元的聚类结果。
解决办法是在聚类完成后加一步形态学后处理。我常用的组合是3×3中值滤波加一次二值开运算。中值滤波去掉孤立噪点,开运算断开细小的连接。如果变化区域边界需要保持锐利,可以只做开运算而不做中值滤波,代价是会有零星噪点残留。
from scipy import ndimage mask_dn = ndimage.median_filter(change_mask, size=3) mask_dn = ndimage.binary_opening(mask_dn, iterations=1).astype(np.uint8)后处理参数要根据空间分辨率调整。分辨率0.5米的影像里,3×3窗口对应1.5米,适合过滤细小噪点;分辨率30米的Landsat影像里,3×3窗口对应90米,可能把真实的小图斑也过滤掉。建议先试中值滤波,再看开运算,两个都不满意时改用连通域面积过滤。
4.2 结果只剩一个簇,变化区域一个都没有
现象是KMeans聚类后两簇几乎平分数据,但语义上看着像一堆乱分,真实变化区域并没有被单独识别出来。更常见的是,整幅影像像元都被归到同一个簇,变化掩膜基本是空的。
原因通常是辐射归一化做得太狠,把两期影像的差异压缩到了接近零的水平。逐波段均值-方差匹配假设两期影像之间存在全局线性关系,但云、阴影、水体波动等区域并不满足这个假设。另一个常见原因是标准化时直接除以标准差,导致差分特征整体幅度被压到0附近,PCA提取出的大方差方向全是噪声。
解决方法是重新检查数据质量,先剔除云和阴影再做归一化,并对比归一化前后的差分特征均值。我一般会在归一化后打印每个波段的差分均值,如果所有波段差分均值都小于0.01,说明差异被过度压平,需要扩大标准化尺度或改用更稳健的归一化方法。
# 检查归一化后差分幅度,别急着聚类 diff_feats = build_diff_features(img1, img2_norm, patch_size=1) print("各波段差分均值:", diff_feats.mean(axis=0))如果差分均值过小,可以改用直方图匹配,或者只做灰度拉伸不做严格匹配。遥感影像的变化检测里,宁可让差分离散度高一点,也不要为了视觉一致把所有差异抹平。
4.3 PCA把真实变化当成低方差噪声压缩掉了
现象是PCA降维后聚类结果非常干净,但变化区域大面积漏检。这类翻车最隐蔽,因为结果看起来完全合理,直到和真实变化图对比才发现漏了一整片。
原因是PCA的优化目标是最大化方差,而真实变化像元往往只占全图的5%以下,它们贡献的方差远小于全局辐射差异和传感器噪声。PCA把前三个主成分留给总体辐射变化,真实变化信息被挤到第四或第五主成分里,一旦n_components=3就直接丢弃了。
解决方法是不要只看累计解释方差曲线,把每个主成分都还原成图像看变化区域在哪一维上最明显。
for i in range(n_components): pc_img = feats_pc[:, i].reshape(img1.shape[0], img1.shape[1]) meta.update(count=1, dtype="float32") with rasterio.open(f"pc_{i}.tif", "w", **meta) as dst: dst.write(pc_img, 1)在GIS软件里打开这些主成分图像,哪个波段上变化区域发亮,就保留到哪一维。实战中我遇到过变化信息集中在前两个主成分的情况,也遇到过集中在第四主成分的情况,固定用前三个主成分并不可靠。参数设置上我倾向于n_components=4或5,让KMeans自己判断哪些维度对聚类有用。
4.4 KMeans每次运行得到的结果都不一样
现象是同一份输入,多次运行变化掩膜在细节上有差异,连变化簇的标签都可能互换。KMeans的初始簇中心是随机选取的,结果受初始化影响很大。
原因是KMeans的损失函数非凸,不同初始化会收敛到不同局部最优。虽然n_init=10会从10次初始化里选目标函数最小的结果,但变化检测的数据里两簇大小极不对称,局部最优解之间差别很大。
解决方法是固定随机种子,并增加评价维度确认聚类质量。random_state=42保证结果可复现,n_init=10增加搜索次数。如果两个运行结果差异仍然肉眼可见,说明数据本身的簇结构不明显,需要回到特征构造阶段,而不是继续调KMeans参数。另外,簇标签互换不是bug,我的代码里通过簇中心模长判定变化簇,就是为了让标签语义固定,不管你随机到哪次初始化,变化簇永远是对应模长更大的那个簇。
4.5 大影像跑出内存错误,差分特征矩阵太大
现象是影像尺寸达到上万乘上万像元时,build_diff_features生成的特征矩阵动辄几十GB,内存直接溢出。这是所有逐像元特征方法绕不开的工程问题。
原因是sliding_window_view虽然视图阶段不复制数据,但reshape(h * w, p * p * c)阶段必须复制出连续数组。一个2万×2万像元、4波段、3×3窗口的影像,特征矩阵是4亿行乘36维,换算成float32要50GB以上,远超单机内存。
解决方法是不再一次性构建全图特征矩阵,而是先用少量样的子块拟合PCA和KMeans,再用训练好的模型对每个分块做变换和预测。
h, w = img1.shape[:2] block_rows = 512 # 先取一小块样本拟合模型 sample_img1 = img1[:block_rows, :] sample_img2 = img2_norm[:block_rows, :] sample_feats = standardize(build_diff_features(sample_img1, sample_img2, 3)) pca.fit(sample_feats) km.fit(pca.transform(sample_feats)) # 再分块预测,用一个固定模型跑全图 pred_mask = np.zeros((h, w), dtype=np.uint8) for row0 in range(0, h, block_rows): row1 = min(row0 + block_rows, h) block_feats = standardize(build_diff_features(img1[row0:row1], img2_norm[row0:row1], 3)) block_pc = pca.transform(block_feats) pred = km.predict(block_pc) pred_mask[row0:row1, :] = pred.reshape(row1 - row0, w) == change_label这种先拟合后分块预测的做法,内存占用从几十GB降到几个GB,代价是PCA和KMeans是在样本上拟合的,样本要有足够代表性。样本块尽量分布在影像的不同区域,而不是只取左上角一块。
5. 从PCAKmeans到开放词汇变化检测:没有GroundTruth时的验证与协作方式
5.1 三种快速验证PCAKmeans结果的方法
没有标注数据,不意味着结果无法验证。第一种方法是把两期影像的同一个波段分别放进RGB通道做成假彩合成,例如把t1的红波段放R通道、t2的红波段放G通道、t1的红波段再放B通道。没变化的地方RGB三通道近似相等,呈现灰白色;有变化的地方会产生明显色偏。变化掩膜叠加到假彩合成上,一眼就能看出掩膜和色偏区域是否对齐。
第二种方法是利用第三期影像做时间一致性验证。如果手上有三期影像,分别对t1-t2和t2-t3跑PCAKmeans,两次都检出的变化才是大概率真实变化,只出现一次的多半是云影或传感器噪声。这个逻辑不依赖标注,只是时间连续性常识。
mask12 = rasterio.open("change_mask_t1_t2.tif").read(1) mask23 = rasterio.open("change_mask_t2_t3.tif").read(1) stable_change = ((mask12 == 1) & (mask23 == 1)).astype(np.uint8)第三种方法是统计面积和形态合理性。变化图斑的总面积占研究区比例应该在一个合理区间,比如城市扩张场景通常不超过5%。如果变化比例超过30%,基本可以断定是辐射归一化失败。
5.2 开放词汇变化检测的定位:先用PCAKmeans找候选,再用语义模型认类别
开放词汇变化检测是当前遥感领域热度上升的方向,它不限定预先定义的变化类别,而是通过视觉语言模型对变化区域做开放式语义识别。听起来很强大,但直接用它做全图扫描的成本依然很高,而PCAKmeans恰好能降低这个成本。
实际项目里我倾向于把两者串成接力流程:PCAKmeans先从全图筛出变化像元,聚类成候选图斑;然后对每个候选图斑裁出t2时期的影像块,交给开放词汇模型去判断变化类型。这样PCAKmeans负责“哪里变了”,开放词汇模型负责“变成了什么”。没有PCAKmeans先限定范围,开放词汇模型需要逐窗口滑动推理,计算量不是一个量级。而只靠PCAKmeans,能定位却给不出语义,下一环节就没法做业务分类。两个方法结合,才是无标注场景下从像素级变化到语义级变化最务实的一条路径。
我现在的固定习惯是,来了新区域先跑一版PCAKmeans,得到候选掩膜后贴在假彩合成上目视三分钟,确认没有明显辐射问题,再决定是否需要引入开放词汇做语义标注。这套流程替我省掉了很多因为直接上监督学习而不得不大量返工的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取