简介:面向陆地卫星(Landsat)与哨兵卫星(Sentinel)图像配准需求的MATLAB实现包,旨在解决不同传感器、不同时间或不同视角获取的遥感影像之间的空间对齐难题。代码已适配多个MATLAB版本,兼容性良好;采用参数化编程,关键参数集中在一处,修改后即可适应不同影像场景,极大地方便二次开发;同时整套代码注释详细,每一步操作均有说明,即使是零基础学习者也能按图索骥,快速掌握配准流程。资源内置SIFT、SURF、BRISK等多种主流特征匹配算法,并在说明文档中比较了各算法的优缺点与适用条件,能够帮助使用者针对实际影像选择最合适的配准策略。压缩包共含11个文件,主要文件为8个MATLAB脚本,覆盖图像预处理、特征提取、特征匹配、变换矩阵计算与重采样等完整环节;另有1个Python辅助脚本、1个Markdown说明文档和1份License许可证文件,整体容量仅19KB,轻量精简,便于携带和分享。随包附赠可直接运行的案例数据,无需自行准备真实卫星影像,即可快速复现配准实验,降低上手门槛。目前已有39人学习浏览,特别适合遥感、电子信息工程、数学等专业的大学生用于课程设计、期末大作业和毕业设计,也能为科研人员提供算法对比验证的便利,支撑环境监测、土地利用变化分析等实际应用。通过运行与调试,使用者还能深入理解特征点提取、匹配及变换矩阵估计等核心概念,为独立开展遥感研究打基础。
1. 图像配准合并 Landsat 和 Sentinel:为什么这件事绕不开
做多源遥感合成的人,第一次把 Landsat 30 米影像和 Sentinel-2 10 米影像叠在一起时,多半会被那个“错半条街”的重影搞到怀疑人生。同一个地物,在红波段里是一栋房子,在另一个影像里却跑到相邻像元上去了。这就是图像配准没做好的典型症状。Landsat 和 Sentinel 虽然都是光学卫星,但传感器、轨道、投影基准、成像时间都不一样,直接拿 zip 包解压出来的 GeoTIFF 叠图,几乎不可能天然对齐。图像配准要做的事情,就是把两景影像的几何关系算出来,通过重采样合并到同一个网格上,让同名地物落到同一个像元。这篇笔记适合正在做 NDVI 时序、土地利用分类或变化检测的从业者,目标是让你能照着步骤跑通一个最小配准流程,并知道参数怎么调、坑在哪里。
2. 先把坐标系对齐:Landsat 和 Sentinel 的几何差异与配准原理
2.1 两种卫星数据的空间分辨率与投影基准差异
Landsat 系列(L8/L9)的 OLI 传感器可见光波段分辨率是 30 米,而 Sentinel-2 的 MSI 传感器在可见光和近红外波段是 10 米,红边波段是 20 米。分辨率差异本身不是配准问题,真正的麻烦在于投影基准。Landsat Level-1 产品默认提供 UTM 投影下的地理参考,而 Sentinel-2 L1C 产品也是 UTM,但两者的 WGS84 椭球实现、像元原点坐标、以及几何精矫正中使用的 GCP(地面控制点)来源都不同,导致两者虽然都在“UTM 投影”下,实际像元边界却可能错开 1 到 2 个 Landsat 像元。如果你下载的是压缩包,解压后第一件事不应是急着合并,而是先检查两个文件的投影信息和像元尺寸。
常见做法是在 QGIS 或 Python 里用 GDAL 读取影像元数据,对比两者的GeoTransform和Projection。你会发现 Landsat 的像元坐标原点往往和 Sentinel 差半个像元,或者整个网格有旋转角。另一个隐蔽问题是,某些数据集(比如从 AWS 拉取的 STAC 项目)给的是 COG(Cloud Optimized GeoTIFF),内部有 overview 和不同的 tile 切分,直接读和直接写会踩内存坑。
2.2 配准的三种常用技术路线:特征点、互信息、相位相关
图像配准按原理分,用的最多的三条路是特征点匹配、互信息最大化、相位相关。特征点匹配适合纹理丰富的地表,比如城区、农田边界、河网交叉处,OpenCV 里的 SIFT、ORB 或 AKAZE 都是成熟工具。互信息方法不依赖具体地物形状,适合处理多传感器之间的辐射差异,比如 Landsat 的可见光波段和 Sentinel 的红边波段,灰度分布完全不同,但互信息仍然能找到正确的空间变换。相位相关方法基于傅里叶变换,适合平移量估计,速度快,但对旋转和尺度变化敏感。
实际工程里,我最常用的组合是:先用相位相关算一个初始平移量,再用 SIFT 特征点做精配准,最后用 RANSAC 剔除粗差。这样即使两景影像之间有明显的亮度差,也能稳得住。纯靠 SIFT 在植被区域会炸,因为纹理重复度高,匹配点大量错乱;纯靠相位相关在旋转超过几度时也会失效。所以选型不是单选题,而是一条流水线。
3. 用 Python 跑通最小配准流程:从读取影像到计算变换矩阵
3.1 准备数据:解压 zip 后的文件清单与读取要点
你拿到的通常是这样的 zip 包:里面可能带着 Landsat 的 MTDOI 元数据、波段 TIF,以及 Sentinel 的 JP2 或 TIF 文件。第一步不是急着写代码,而是要把两个数据集统一成同一坐标系和同一波段组合。常见做法是把 Sentinel 的 10 米波段重采样到 Landsat 的 30 米网格上,或者反过来把 Landsat 升采样到 10 米。我一般建议以 Sentinel 的网格为基准,因为高分辨率网格保留更多空间细节,后续你做融合时高分辨率信息不容易丢。
用 Python 读取时,优先用rasterio而不是裸 GDAL,因为 rasterio 的 API 更友好,且能直接处理 COG。如果文件是 JP2,需要确保 GDAL 编译了 JP2OpenJPEG 驱动,否则会报 “JP2ECW: Read error” 一类的玄学错误。解压 zip 时,注意文件名里可能带空格或中文,Linux 下解压没问题,Windows 下遇到超长路径也会出错。建议统一解压到一个纯英文、无空格的目录下。
3.2 基于 OpenCV 的特征点配准实现
下面这段代码是配准流程的核心,我把每一步都写在注释里。它会读取两个波段的灰度数组,提取 SIFT 特征点,用 FLANN 匹配,再用 RANSAC 算单应矩阵。
import cv2 import numpy as np import rasterio from rasterio.warp import reproject, Resampling # 读两个单波段灰度数组,这里以 blue 波段为例 with rasterio.open("landsat_blue.tif") as src: landsat = src.read(1) landsat_profile = src.profile with rasterio.open("sentinel_blue.tif") as src: sentinel = src.read(1) sentinel_profile = src.profile # 转成 uint8,SIFT 需要 8 位灰度图 landsat_u8 = ((landsat - landsat.min()) / (landsat.max() - landsat.min()) * 255).astype(np.uint8) sentinel_u8 = ((sentinel - sentinel.min()) / (sentinel.max() - sentinel.min()) * 255).astype(np.uint8) # 初始化 SIFT,设置关键点数量上限和对比度阈值 sift = cv2.SIFT_create(nfeatures=5000, contrastThreshold=0.04) # 分别检测特征点和描述子 kp1, des1 = sift.detectAndCompute(sentinel_u8, None) # 以 Sentinel 为基准 kp2, des2 = sift.detectAndCompute(landsat_u8, None) # Landsat 需要被校正 # FLANN 匹配器参数,针对 SIFT 的 128 维描述子 FLANN_INDEX_KDTREE = 1 index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5) search_params = dict(checks=50) flann = cv2.FlannBasedMatcher(index_params, search_params) matches = flann.knnMatch(des1, des2, k=2) # Lowe's ratio test,剔除模糊匹配 good_matches = [] for m, n in matches: if m.distance < 0.75 * n.distance: good_matches.append(m) # 提取匹配点坐标 src_pts = np.float32([kp1[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2) dst_pts = np.float32([kp2[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2) # RANSAC 计算单应矩阵,设置重投影误差阈值 3.0 M, mask = cv2.findHomography(dst_pts, src_pts, cv2.RANSAC, 3.0) print("变换矩阵:\n", M) print("内点数量:", mask.sum(), "匹配总数:", len(good_matches))逻辑说明:这里把 Sentinel 当基准,Landsat 是待配准影像。findHomography算出的矩阵M能把 Landsat 的像元坐标映射到 Sentinel 的坐标系里。注意,src_pts和dst_pts的对应关系不能搞反,否则你会得到一个反向的矩阵,影像越配越歪。参数方面,nfeatures控制特征点数量上限,影像越大越需要提高;contrastThreshold控制特征点对纹理的敏感程度,值越大特征点越少,适合噪声大的影像;RANSAC 的3.0是重投影误差阈值,单位是像素,值越小要求越严格,但太小会把正确点也剔掉。
3.3 基于 GDAL 的仿射变换与重采样合并
得到单应矩阵后,下一步是把 Landsat 重采样到 Sentinel 的网格上。单应矩阵是 3x3 的,而遥感影像的几何变换通常用仿射六参数表达。如果你的影像之间主要是平移和轻微旋转(大多数相同轨道方向的情况是这样),可以直接把单应矩阵近似成仿射矩阵,或者直接用cv2.warpPerspective配合rasterio写文件。但更推荐的做法是把单应矩阵转换成 GDAL 的 GCP 或直接写进目标变换里,这样能保留地理坐标。
下面这段代码展示了如何使用rasterio的reproject来完成重采样合并。注意,这里没有用warpPerspective,因为那会丢失地理参考信息。
import rasterio from rasterio.transform import from_gcps, AffineTransformer from rasterio.control import GroundControlPoint import numpy as np # 假设 src_pts 和 dst_pts 是上一步得到的内点(已剔除粗差) inliers_src = src_pts[mask.ravel() == 1] inliers_dst = dst_pts[mask.ravel() == 1] # 把像素坐标转成地理坐标:利用 Sentinel 的 transform sentinel_transform = sentinel_profile["transform"] geo_pts = [sentinel_transform * (x, y) for x, y in inliers_src.reshape(-1, 2)] # 构建 GCP 列表:源点用 Landsat 的像素坐标,目标点用 Sentinel 的地理坐标 gcps = [] for (px, py), (gx, gy) in zip(inliers_dst.reshape(-1, 2), geo_pts): gcps.append(GroundControlPoint(row=py, col=px, x=gx, y=gy)) # 由 GCP 计算最优仿射变换 new_transform, new_gcps = rasterio.warp.calculate_default_transform( rasterio.crs.CRS.from_epsg(32650), # 假设 UTM 50N landsat_profile["width"], landsat_profile["height"], gcps=gcps ) # 用该变换执行重采样 with rasterio.open("landsat_blue.tif") as src: dst_array = np.zeros((sentinel_profile["height"], sentinel_profile["width"]), dtype=np.float32) dst_profile = sentinel_profile.copy() dst_profile.update({"dtype": np.float32}) with rasterio.open("landsat_registered_blue.tif", "w", **dst_profile) as dst: reproject( source=rasterio.band(src, 1), destination=dst_array, src_transform=src.transform, src_crs=src.crs, dst_transform=sentinel_transform, dst_crs=sentinel_profile["crs"], resampling=Resampling.bilinear, ) dst.write(dst_array, 1)这里有一个关键点:calculate_default_transform我们传入的是 GCP 和原始影像尺寸,它会用最小二乘拟合出一个仿射变换,但严格来说单应矩阵的自由度是 8,仿射是 6,如果两景影像之间存在非仿射畸变(比如地形起伏导致的局部变形),这种近似会留残余误差。处理方式是把 GCP 直接写进 GeoTIFF,让后续工具支持局部扭曲,但那样做会复杂不少。对于同一传感器家族的两颗卫星,仿射足够。
4. 配准参数怎么调:关键参数与验证指标
4.1 特征点检测器与匹配器参数
SIFT 的contrastThreshold和edgeThreshold是调参重点。contrastThreshold默认是 0.04,如果影像对比度低(比如阴天浓云区),要降到 0.01,否则特征点稀疏,匹配数量不够。edgeThreshold默认 10,它控制边缘响应,留得越高越容易把长直边缘上的不稳定点算进来,建议保留默认。nfeatures设到 10000 并不总是好事,因为特征点太多会导致 FLANN 匹配变慢,而且低质量匹配点增多,反而降低 RANSAC 的内点率。我常用 3000 到 5000。
FLANN 的trees和checks影响速度和召回。trees越多,索引占内存越大;checks越大,搜索越精确,但耗时线性增加。5000 个特征点时,trees=5, checks=100已经能拿到足够匹配。匹配完成后,用 Lowe 的 ratio test,阈值 0.75 是经典值,如果你发现内点太少,可以放宽到 0.8,但不建议超过 0.85,否则错误匹配会爆炸。
4.2 重采样方法选择与像元大小设置
重采样方法有 nearest、bilinear、cubic、lanczos。做图像配准时,Landsat 重采样到 Sentinel 网格,最怕引入额外平滑。Nearest 会保留原值,但边缘锯齿明显,而且可能把一个像元的纹理宽度拉伸成两个像元;Bilinear 会折中;Cubic 和 Lanczos 更平滑,但也会钝化本地细节。我的偏好是:如果后续做分类,用 nearest 或 bilinear,避免光谱值被过度插值;如果做目视融合,用 lanczos,纹理更柔和。
像元大小建议直接用 Sentinel 原始 10 米分辨率,不要强行设置 15 米或 20 米。因为 Landsat 是 30 米,重采样到 10 米并不新增信息,但如果你需要把两者合并成同一个栅格,就必须有一个统一的网格尺寸。另一个容易错的地方是:不要只重采样一个波段就完事。如果你合并的是多光谱影像,要对全部波段执行同一套几何变换,否则波段之间会错位。
4.3 用 RMSE 和控制点验证配准精度
配准精度不能只靠眼看,要有量化指标。在刚才的代码里,可以用findHomography返回的 mask 和匹配点计算均方根误差(RMSE)。做法是:把内点用变换矩阵投影,计算投影坐标和实际坐标的欧氏距离,再求均方根。
# 计算配准 RMSE inlier_src = src_pts[mask.ravel() == 1] inlier_dst = dst_pts[mask.ravel() == 1] proj_dst = cv2.perspectiveTransform(inlier_dst.reshape(-1, 1, 2), M).reshape(-1, 2) errors = np.linalg.norm(inlier_src.reshape(-1, 2) - proj_dst, axis=1) rmse = np.sqrt(np.mean(errors ** 2)) print(f"配准 RMSE: {rmse:.2f} 像素")这个 RMSE 是在像素坐标系下算的,如果你有地理控制点,建议换算成米。比如 Sentinel 10 米分辨率下,RMSE 0.3 像素意味着约 3 米误差,这对多星合并来说已经可接受。RMSE 超过 0.5 像素时,就要检查是否有局部畸变或匹配点分布不均。
5. 合并 Landsat 与 Sentinel 的 4 个常见坑与排查方法
5.1 影像偏暗或发花:拉伸与重采样的坑
现象:合并后的影像整体发灰,或者像蒙了一层雾,尤其是 Landsat 重采样到 10 米以后,纹理变糊,颜色发淡。
原因:两个数据集的辐射分辨率不同,Landsat OLI 是 16 位,Sentinel-2 L1C 也是 16 位,但两者的量化范围不一致,直接拉伸到 0-255 做显示会失真。另外重采样时如果用了 cubic 或 lanczos,会产生负值或超过原有范围的像元,导致直方图被拉宽。
解决:重采样前对每个波段做分位拉伸,比如取 2%~98% 的线性拉伸,把异常值裁剪掉。如果使用 lanczos,重采样后要重新裁剪到有效数值范围(比如 Landsat 的 0-65535)。同时确保两个影像的物理单位一致,都是表面反射率或都是 DN 值,别一个是反射率一个是未定标的大气顶辐亮度。
5.2 配准后出现重影或错位:控制点分布不均匀
现象:整体看起来对齐了,但河流拐弯处或山脊线附近出现双线重影。
原因:RANSAC 算出的单应矩阵是所有内点的全局拟合,如果控制点集中在一半区域,另一半区域没有任何约束,那么模型在该区域的外推就会产生局部扭曲或偏移。很多时候测试影像用城区,匹配点密集,换到农田场景就崩,因为纹理重复度高,SIFT 匹配点集中在田埂边缘,数量不够且分布不均匀。
解决:在提取特征点后,检查匹配点在图中的分布,通常的做法是把影像分成 4x4 的网格,每个网格至少要保留 20 个内点。如果某个网格没有匹配点,考虑对该区域单独提特征点,或者改用互信息法做局部配准。另一个补救是用cv2.findHomography的method=cv2.RANSAC之后,再用cv2.estimateAffinePartial2D试一试,如果两种方法输出的变换矩阵差异很大,说明匹配点集中在一侧。
5.3 数据量太大内存爆炸:分块处理与内存映射
现象:读两张 10000x10000 的影像,直接转 numpy 数组,内存瞬间吃了 2GB,然后进程被杀。
原因:Landsat 单波段 30 米一般 7000x7000 左右,Sentinel-2 10 米波段是 10980x10980,单波段 float32 就是 480MB,如果一下子读 4 个波段,再加中间数组,轻松超过 4GB。很多人忽略这个,直接用read()读整个影像。
解决:用 rasterio 的窗口读取,分块匹配。做法是先读取低分辨率的 overview 做特征点匹配,算好变换矩阵,再用窗口按块重采样。实际工作中,配准变换矩阵通常是在低分辨率下也能算准,因为全局几何变形是平滑的。如果必须全分辨率,可以用np.memmap把数组映射到磁盘,或者对每块单独计算局部变换,但那样要保证相邻块之间的拼接平滑。
5.4 zip 解压后文件命名混乱:波段顺序与元数据读取错误
现象:代码里读 B4 波段,实际拿到的却是 B3 波段的数据,或者把 Sentinel 的 B8 近红外当成红波段,导致特征点匹配错乱。
原因:不同来源的压缩包命名不一致,有的用SR_B4.TIF,有的用B04.jp2,而且 Sentinel-2 的波段编号和 Landsat 不同。如果直接按文件名排序读,可能会把红波段和红边波段混淆。另一个坑是 zip 伪加密,某些数据集为了防直链下载,给 zip 加了伪加密标志,Linux 下标准 unzip 会报错,但 7-Zip 能强制解出来。
解决:永远不要靠文件名猜波段。用rasterio打开后,直接检查src.descriptions或元数据的BAND_NAME,或者用波段的中心波长属性来判断。如果是 Landsat,读取MTL.txt里的RADIOMETRIC_RESCALING信息;如果是 Sentinel,读取MTD_MSIL2A.xml里的波段信息和物理单位。解压伪加密 zip 时,可以用 7-Zip 或者zipfile -P ""强制解压,但也要注意安全来源。
6. 把合并结果做厚:多时相序列配准与精度验证技巧
配准不是一次性工作。如果你要做长时间序列的 Landsat-Sentinel 融合,每次下载新影像后,都重新对齐到同一个基准网格,而不是两两互配。我的习惯是维护一个基准影像(通常选第一期的 Sentinel-2),后续所有 Landsat 都配准到它上面,这样时间序列里的所有影像在空间上自洽,后续做变化检测才不会因为配准误差产生假斑。
精度验证的另一个技巧是使用独立的地面控制点,而不是只用特征点计算的 RMSE。常见做法是在配准后的影像上手动选取 10 到 20 个地物点,比如道路交叉口、建筑角点,比较他们在两张影像上的地理坐标差值,计算水平和垂直方向的偏移。这个值如果超过 5 米,就要重新检查配准流程。
我还习惯把配准后的影像叠加成假彩色合成图,用闪烁方式目视检查:在 QGIS 里把两张影像放在两个画布,切换透明度。这个方法虽然原始,但对发现局部错位极其有效。自动化指标和人工目视结合,才敢把结果用在定量分析里。
一个容易忽略的参数是:处理投影坐标时,如果两个影像的 UTM 带不同,比如 Landsat 落在 50N,Sentinel 落在 51N,那必须先把两者重投影到同一个 UTM 带,否则findHomography算出的矩阵没有任何物理意义。这就是我为什么在代码里用calculate_default_transform时显式指定了 EPSG 代码。做多时相时,更省事的办法是全序列统一到 WGS84 经纬度网格,但那会损失像元面积精度,适合定性分析,不适合面积统计。
这套流程我已经用了两年,最大的教训是:配准前的数据清洗比配准本身更耗时。每次压缩包解压后,我都会把投影信息、波段顺序、数值范围打印出来单独存成一个 JSON,下次直接复用。希望帮到你。
本文还有配套的精品资源,点击获取