无监督遥感变化检测:PCAKmeans原理与实战解析
发布时间:2026/10/1 13:39:48来源:尧图网络
简介基于主成分分析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维特征重新表达成少数几个互不相关的主成分。第二是白化设置whitenTrue时PCA会把每个主成分缩放到单位方差让特征空间变成各向同性。这一步对KMeans尤其关键因为KMeans的聚类边界基于欧氏距离如果某个主成分的方差是另一个的10倍这个主成分会单方面主导距离计算另一个方向的差异全被淹没。白化之后每个方向贡献相同KMeans才能公平地看待每个维度。2.3 KMeans的K值、球形簇假设与“变化簇”的判定方式KMeans假设每个簇近似球形且簇与簇之间大小差别不大。在变化检测里K2是最常见的设置对应“变化/未变化”两个语义类。但实际数据很少完美满足球形假设变化像元占比通常只有百分之几变化簇的方差远大于未变化簇两个簇的大小也严重不对称。这时候KMeans容易把一个大簇的边缘切给另一个小簇。缓解办法有两个方向一是先做白化让各方向等尺度二是允许K2把差异更细致的拆分。比如一片区域同时存在城市建设和植被枯死差分空间里可能出现三个或四个自然簇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, dtypenp.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_size3): p patch_size h, w, c img1.shape r p // 2 # 边界用reflect填充避免边缘像元差分特征被0填充污染 img1p np.pad(img1, ((r, r), (r, r), (0, 0)), modereflect) img2p np.pad(img2, ((r, r), (r, r), (0, 0)), modereflect) 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(axis0, keepdimsTrue) sd feats.std(axis0, keepdimsTrue) 1e-6 return (feats - mu) / sd feats standardize(feats) # 先用足够多的主成分看累计方差曲线 k_max min(8, feats.shape[1]) pca_full PCA(n_componentsk_max, whitenTrue) 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_componentsn_components, whitenTrue) feats_pc pca.fit_transform(feats) print(保留主成分数:, n_components)whitenTrue使得各主成分方差被缩放到1KMeans的欧氏距离不会偏向某一方向这个设置对聚类很有利。3.4 KMeans聚类生成变化掩膜簇标签自动对准“变化”类KMeans设置K2随机状态固定保证复现。聚类中心需要反变换回原始差分空间才能判断哪个簇代表变化。from sklearn.cluster import KMeans km KMeans(n_clusters2, n_init10, random_state42) 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, axis1) change_label int(np.argmax(norms)) change_mask (labels change_label).astype(np.uint8)判断变化簇的原则是差分特征空间中远离原点的簇对应变化。inverse_transform能正确还原whitenTrue带来的尺度变化所以这里必须先反变换再算模长而不是直接在主成分空间里比较。如果直接在主成分空间比较经过白化后所有方向尺度一致簇中心的模长依然有效但对解释性不好反变换回原始差分空间后每个维度对应实际波段差方便后续人工核验。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_size3) feats standardize(feats) pca PCA(n_components3, whitenTrue) feats_pc pca.fit_transform(feats) km KMeans(n_clusters2, n_init10, random_state42) 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, axis1))) change_mask (labels change_label).astype(np.uint8) meta.update(count1, dtypeuint8, compresslzw) 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, size3) mask_dn ndimage.binary_opening(mask_dn, iterations1).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_size1) print(各波段差分均值:, diff_feats.mean(axis0))如果差分均值过小可以改用直方图匹配或者只做灰度拉伸不做严格匹配。遥感影像的变化检测里宁可让差分离散度高一点也不要为了视觉一致把所有差异抹平。4.3 PCA把真实变化当成低方差噪声压缩掉了现象是PCA降维后聚类结果非常干净但变化区域大面积漏检。这类翻车最隐蔽因为结果看起来完全合理直到和真实变化图对比才发现漏了一整片。原因是PCA的优化目标是最大化方差而真实变化像元往往只占全图的5%以下它们贡献的方差远小于全局辐射差异和传感器噪声。PCA把前三个主成分留给总体辐射变化真实变化信息被挤到第四或第五主成分里一旦n_components3就直接丢弃了。解决方法是不要只看累计解释方差曲线把每个主成分都还原成图像看变化区域在哪一维上最明显。for i in range(n_components): pc_img feats_pc[:, i].reshape(img1.shape[0], img1.shape[1]) meta.update(count1, dtypefloat32) with rasterio.open(fpc_{i}.tif, w, **meta) as dst: dst.write(pc_img, 1)在GIS软件里打开这些主成分图像哪个波段上变化区域发亮就保留到哪一维。实战中我遇到过变化信息集中在前两个主成分的情况也遇到过集中在第四主成分的情况固定用前三个主成分并不可靠。参数设置上我倾向于n_components4或5让KMeans自己判断哪些维度对聚类有用。4.4 KMeans每次运行得到的结果都不一样现象是同一份输入多次运行变化掩膜在细节上有差异连变化簇的标签都可能互换。KMeans的初始簇中心是随机选取的结果受初始化影响很大。原因是KMeans的损失函数非凸不同初始化会收敛到不同局部最优。虽然n_init10会从10次初始化里选目标函数最小的结果但变化检测的数据里两簇大小极不对称局部最优解之间差别很大。解决方法是固定随机种子并增加评价维度确认聚类质量。random_state42保证结果可复现n_init10增加搜索次数。如果两个运行结果差异仍然肉眼可见说明数据本身的簇结构不明显需要回到特征构造阶段而不是继续调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), dtypenp.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得到候选掩膜后贴在假彩合成上目视三分钟确认没有明显辐射问题再决定是否需要引入开放词汇做语义标注。这套流程替我省掉了很多因为直接上监督学习而不得不大量返工的后悔药。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网