☰
Python卫星图像分割实战:从GeoTIFF读取到推理拼接全流程
2026/10/30 5:09:40 网站建设 项目流程

简介:这份资源面向遥感图像处理与计算机视觉方向的学习者,聚焦卫星图像分割这一核心任务,帮助读者理解从图像预处理、特征提取到分割算法选择与后处理的完整流程。包内共3个Python脚本文件,压缩包约3KB,分别对应主流程入口、屋顶检测示例与数据准备环节,结构精简,便于直接阅读与二次修改。内容涉及GDAL/OGR地理数据处理、OpenCV图像增强与边缘检测、Scikit-image多种分割算法,以及基于TensorFlow、PyTorch构建U-Net等深度学习模型的思路,可服务于土地覆盖变化评估、灾害区域识别、农业估产等实际场景。已有253人学习下载,适合具备一定Python基础、希望快速上手遥感图像分割实践的中级读者参考借鉴。

1. 卫星图像分割:从一张 8000×8000 的 GeoTIFF 说起

去年帮某实验室处理一批遥感影像,拿到手的是一张 8000×8000 的 GeoTIFF,四个波段,16 位深度。第一反应是直接丢进常规的语义分割网络训练,结果显存直接爆掉,切成 256×256 的图块又发现大量图块全是纯色背景,正样本比例低得离谱。这就是卫星图像分割和普通图像分割最本质的差别:分辨率大、通道多、类别极度不平衡,而且像素坐标背后还挂着地理坐标。这份资源围绕 Python 生态下的卫星图像分割展开,覆盖数据读取、切片、增强、模型训练到推理拼接的完整链路,适合已经会用 PyTorch 跑通基础分割、但一碰到遥感数据就翻车的从业者。下面按我实际拆包的顺序讲,先讲清楚为什么这么设计,再落到能直接抄的命令和参数。

2. 数据读取与切片:rasterio 比 PIL 多出来的那几件事

2.1 为什么不能直接用 PIL 或 OpenCV 读遥感影像

普通图像是 8 位三通道,PIL 的Image.open一把梭没问题。但卫星影像常见 16 位甚至 32 位浮点,波段数从 1(全色)到 8(多光谱)不等,还带地理变换参数和投影信息。用 PIL 读 16 位 TIFF 会被截断成 8 位,动态范围直接损失一大半,归一化之后地物边界全糊。常见做法是用rasterio读,它保留原始数据类型,还能顺手拿到transform和crs,后面做地理对齐和拼接全靠这两个东西。

import rasterio import numpy as np def read_image(path): with rasterio.open(path) as src: # 按波段读取,返回 (bands, H, W) img = src.read() # 保留地理变换和投影,推理拼接时要用 meta = src.meta.copy() transform = src.transform crs = src.crs # 转成 (H, W, bands) 方便后续处理 img = np.transpose(img, (1, 2, 0)) return img, meta, transform, crs

src.read()不带参数时读全部波段,返回形状是(bands, H, W),和 PyTorch 的通道优先习惯一致,但做可视化时得转置。meta里存着dtype、count、width、height、crs、transform,写结果文件时直接改count和dtype复用即可。这里有个容易忽略的点:16 位影像读进来是uint16,直接除以 255 会得到 0 到 257 的范围,归一化必须按实际位深来,img.astype(np.float32) / 65535.0才是对的。

2.2 大图切片:滑窗参数怎么定

8000×8000 的图不可能整张进网络,切片是绕不开的。切片有两个关键参数:patch_size和stride。patch_size一般取 256 或 512,取决于显存和地物尺度;stride小于patch_size时会产生重叠,重叠区域在推理阶段做加权融合能消除拼接缝。训练阶段我一般用stride = patch_size,不重叠,省存储;推理阶段用stride = patch_size // 2,重叠 50%,拼接质量明显更好。

def slide_crop(img, patch_size=256, stride=256): H, W, C = img.shape patches = [] coords = [] for y in range(0, H - patch_size + 1, stride): for x in range(0, W - patch_size + 1, stride): patch = img[y:y+patch_size, x:x+patch_size, :] patches.append(patch) coords.append((y, x)) return np.stack(patches), coords

range的上界用H - patch_size + 1是为了保证最后一块不越界。如果图像尺寸不是patch_size的整数倍,边缘会丢掉一条,常见做法是先做 padding 再切,或者单独处理边缘块。coords记录每块的左上角坐标,推理拼接时按坐标回填,这是后面能还原整图的前提。切片完记得统计一下每块的类别占比,全背景块直接过滤掉,不然训练时负样本能占到七成以上,模型会学成「全预测背景」的摆烂状态。

2.3 波段归一化与无效值处理

多光谱影像每个波段的数值分布差异很大,蓝波段可能集中在 0 到 2000,近红外能到 8000。逐波段做 z-score 归一化比全局除以最大值稳得多。另外遥感影像常有无效值(nodata),比如填充的 0 或者 -9999,这些像素必须在归一化前 mask 掉,否则会把统计量带偏。

def normalize_per_band(img, nodata=0): img = img.astype(np.float32) out = np.zeros_like(img) for b in range(img.shape[2]): band = img[:, :, b] valid = band != nodata if valid.sum() == 0: continue mean = band[valid].mean() std = band[valid].std() + 1e-6 out[:, :, b] = (band - mean) / std out[:, :, b][~valid] = 0 return out

1e-6是防止整波段恒定导致除零。valid掩码同时用于统计和回填,无效像素归一化后置 0,训练时配合 ignore_index 的损失函数跳过这些位置。这套流程跑下来,同一批数据换不同传感器也能复用,不用每次手动调归一化系数。

3. 模型与损失:类别不平衡下的选型与调参

3.1 骨干网络选型:U-Net 还是 DeepLabV3+

卫星图像分割的主流 backbone 就那几个:U-Net 系列、DeepLabV3+、SegFormer。U-Net 的跳跃连接对小目标边界友好,适合建筑物、道路这类有清晰轮廓的类别;DeepLabV3+ 的空洞卷积感受野大,适合大范围地物分类;SegFormer 是 Transformer 路线,全局建模能力强但显存吃得凶。我一般先用 U-Net + ResNet34 编码器跑 baseline,收敛快、调参少,等 baseline 稳定了再换 SegFormer 看有没有提升。资源里如果带预训练权重,优先用 ImageNet 预训练的编码器,遥感数据量通常不够从头训,预训练能省一半以上的收敛时间。

import torch import torch.nn as nn from torchvision.models import resnet34 class UNet(nn.Module): def __init__(self, in_channels=4, num_classes=5): super().__init__() # 用预训练 ResNet34 做编码器,首层改成多波段输入 backbone = resnet34(weights='IMAGENET1K_V1') self.encoder = nn.Sequential(*list(backbone.children())[:-2]) # 首层卷积替换为 in_channels 输入 self.encoder[0] = nn.Conv2d(in_channels, 64, 7, 2, 3, bias=False) self.decoder = nn.Sequential( nn.ConvTranspose2d(512, 256, 2, 2), nn.ReLU(), nn.Conv2d(256, num_classes, 1) ) def forward(self, x): feat = self.encoder(x) return self.decoder(feat)

首层卷积替换后,预训练权重对前三个通道仍然有效,第四个波段(常见是近红外)的权重是随机初始化的,训练时会慢慢学出来。num_classes要包含背景类,比如建筑物、道路、水体、植被加背景就是 5 类。这个简化版只做了一次上采样,实际用的时候解码器要按 U-Net 的对称结构补齐,跳跃连接别省,边界精度差很多。

3.2 损失函数组合:Dice + CE 的权重怎么配

遥感分割的类别不平衡是常态,一景影像里道路可能只占 2%,水体占 5%,剩下全是背景和植被。纯交叉熵会被背景主导,模型学不到小类。常见做法是 Dice Loss 和 CrossEntropy 加权组合,Dice 负责拉小类的重叠度,CE 负责稳定梯度。权重我一般设0.5 * Dice + 0.5 * CE,如果小类特别少(低于 1%),把 Dice 权重提到 0.7。

class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, logits, targets): probs = torch.softmax(logits, dim=1) # one-hot 编码 target targets_onehot = torch.nn.functional.one_hot( targets, num_classes=logits.shape[1] ).permute(0, 3, 1, 2).float() intersection = (probs * targets_onehot).sum(dim=(2, 3)) union = probs.sum(dim=(2, 3)) + targets_onehot.sum(dim=(2, 3)) dice = (2 * intersection + self.smooth) / (union + self.smooth) return 1 - dice.mean() # 组合损失 criterion = lambda logits, targets: ( 0.5 * DiceLoss()(logits, targets) + 0.5 * nn.CrossEntropyLoss(ignore_index=255)(logits, targets) )

smooth防止分子分母同时为 0 时出现 NaN,尤其在某个 batch 里某类完全没出现的时候。ignore_index=255对应前面归一化时置 0 的无效像素,标签里这些位置要提前标成 255,不然损失会被无效区域污染。Dice 对每个类别单独算再取平均,小类的梯度权重自然被放大,这是它比 CE 更适合不平衡数据的原因。

3.3 训练循环里的三个关键参数

学习率、batch size、warmup 这三个参数决定了能不能顺利收敛。遥感数据预训练编码器加载后,编码器学习率要设小一点(比如1e-4),解码器可以设大一点(1e-3),分组设置。batch size 受显存限制,256 的 patch 在 8G 显存上大概能跑 8 到 16,不够就用梯度累积凑等效 batch。warmup 跑 3 到 5 个 epoch,避免一开始学习率过大把预训练权重冲坏。

optimizer = torch.optim.AdamW([ {'params': model.encoder.parameters(), 'lr': 1e-4}, {'params': model.decoder.parameters(), 'lr': 1e-3}, ], weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr=[1e-4, 1e-3], total_steps=num_epochs * len(train_loader), pct_start=0.1 # 前 10% 做 warmup )

OneCycleLR自带 warmup 和退火,pct_start=0.1表示前 10% 的步数用来爬升学习率。weight_decay设1e-4是常规正则,数据量小的时候可以提到1e-3。训练过程中盯两个指标:训练 loss 和验证集上每个类的 IoU,别只看总体 accuracy,背景类会把 accuracy 拉到 0.9 以上,看着很美实际小类全崩。

4. 推理与拼接:重叠融合和边缘效应怎么处理

4.1 滑窗推理与加权融合

推理阶段用 50% 重叠切片,每块的预测结果按位置回填到整图,重叠区域做加权平均。权重用高斯核或者简单的距离加权,中心像素权重高、边缘低,能有效消除拼接缝。直接取平均也行,但块边缘的预测往往不准,平均会把误差摊到整片重叠区。

def predict_and_stitch(model, img, patch_size=256, stride=128, num_classes=5): H, W, C = img.shape prob_map = np.zeros((H, W, num_classes), dtype=np.float32) count_map = np.zeros((H, W, 1), dtype=np.float32) # 高斯权重,中心高边缘低 weight = gaussian_window(patch_size) for y in range(0, H - patch_size + 1, stride): for x in range(0, W - patch_size + 1, stride): patch = img[y:y+patch_size, x:x+patch_size, :] tensor = torch.from_numpy(patch).permute(2, 0, 1).unsqueeze(0).float() with torch.no_grad(): logits = model(tensor) prob = torch.softmax(logits, dim=1).squeeze(0).permute(1, 2, 0).numpy() prob_map[y:y+patch_size, x:x+patch_size, :] += prob * weight count_map[y:y+patch_size, x:x+patch_size, :] += weight prob_map /= np.maximum(count_map, 1e-6) return np.argmax(prob_map, axis=2)

gaussian_window生成一个patch_size × patch_size的高斯核,中心值接近 1,边缘趋近 0。count_map累加权重做归一化,保证每个像素的权重和为 1。np.maximum(count_map, 1e-6)防止边缘没被覆盖到的像素除零。这套拼接逻辑对任意尺寸的图都适用,只要保证stride能覆盖到最后一块,边缘缺的那条单独补切一次就行。

4.2 结果写回 GeoTIFF

预测结果是(H, W)的类别索引图,要写回带地理信息的 GeoTIFF 才能和原始影像对齐。用前面读图时保存的meta,改count=1、dtype='uint8',transform和crs原样传回去。

def save_prediction(pred, meta, out_path): meta.update({ 'count': 1, 'dtype': 'uint8', 'compress': 'lzw' }) with rasterio.open(out_path, 'w', **meta) as dst: dst.write(pred.astype(np.uint8), 1)

compress='lzw'能显著减小文件体积,类别图重复值多,压缩率很高。写完后用 GIS 软件打开确认一下坐标对齐,transform传错的话整张图会偏移,这种错误在拼接大区域时特别隐蔽,单看预测图看不出来,叠到原始影像上才发现错位。

5. 避坑与排查:五个让我返工的血泪记录

5.1 归一化用了 255 导致模型完全不收敛

现象:训练 loss 从第一个 epoch 就卡在 2.3 左右不降,验证集 IoU 接近 0。原因:16 位影像按 8 位习惯除以 255,输入值域到了 0 到 257,网络第一层激活直接饱和。解决:按实际位深归一化,uint16除以 65535,或者逐波段 z-score。改完之后 loss 第一个 epoch 就降到 1.5 以下。这个坑我踩过两次,现在读图第一件事就是打印img.dtype和img.min()/max()。

5.2 切片时把无效区域当正样本训练

现象:模型在验证集上表现正常,一推理整图就出现大片误检,集中在影像四角。原因:原始影像边缘有 nodata 填充区,切片时没过滤,这些区域的标签被错误标成了某个类别,模型学会了在纯色区域输出预测。解决:切片前用 nodata 掩码统计每块的有效像素占比,低于 90% 的块直接丢弃,标签里无效像素统一标 255 配合ignore_index。过滤后训练集小了三分之一,但误检没了。

5.3 预训练权重加载时通道数不匹配直接报错

现象:load_state_dict抛size mismatch for encoder.0.weight。原因:ResNet 首层是 3 通道,遥感影像是 4 通道或 8 通道,直接加载形状对不上。解决:先加载除首层外的所有权重,首层单独初始化。用strict=False加载,然后检查missing_keys里是不是只有首层,其他层都命中了才说明加载正确。别图省事把首层也塞进去,形状不匹配会静默跳过或者报错,两种都烦。

5.4 推理拼接出现网格状接缝

现象:整图预测结果上能看到明显的方格边界,每个 patch 边缘的类别和相邻块对不上。原因:推理时stride等于patch_size,没有重叠,块边缘的预测精度本来就低,直接硬拼就露馅。解决:推理stride改成patch_size // 2,重叠区做高斯加权融合。改完接缝基本看不出来,代价是推理时间翻倍,但离线推理不在乎这点时间。

5.5 标签类别编号和模型输出对不上

现象:训练指标很好,可视化出来类别全错位,建筑物被标成水体。原因:标签制作时类别编号是 1 到 4,模型输出是 0 到 4(含背景),推理时argmax出来的索引和标签编号差了一位。解决:训练前统一类别映射表,背景固定为 0,其他类别从 1 开始连续编号,标签转换和推理解码共用同一张映射表。这种错位不会报错,指标还虚高,是最难发现的一类问题,建议训练前先抽几张图把标签和原图叠着看一眼。

6. 进阶技巧:用测试时增强把 IoU 再抬两个点

baseline 跑通之后,想再挤一点精度,测试时增强(TTA)是性价比最高的手段,不用重训,推理时多跑几遍取平均。对分割任务,常用的 TTA 是水平翻转、垂直翻转、旋转 90 度,四种变换各推理一次,把概率图逆变换回原方向后平均。代价是推理时间乘 4,离线场景完全能接受。

def tta_predict(model, tensor): # tensor: (1, C, H, W) preds = [] transforms = [ lambda x: x, lambda x: torch.flip(x, dims=[3]), # 水平翻转 lambda x: torch.flip(x, dims=[2]), # 垂直翻转 lambda x: torch.rot90(x, 1, dims=[2, 3]), # 旋转 90 度 ] inverse = [ lambda x: x, lambda x: torch.flip(x, dims=[3]), lambda x: torch.flip(x, dims=[2]), lambda x: torch.rot90(x, -1, dims=[2, 3]), ] for t, inv in zip(transforms, inverse): with torch.no_grad(): logits = model(t(tensor)) preds.append(inv(torch.softmax(logits, dim=1))) return torch.stack(preds).mean(dim=0)

每个变换都要有对应的逆变换,顺序不能错,翻转的逆还是翻转,旋转 90 度的逆是反向旋转 90 度。平均的是 softmax 后的概率而不是 logits,概率平均对置信度更敏感,实测比 logits 平均稳。TTA 对小类提升最明显,因为不同变换下小目标的预测方差大,平均能压掉一部分噪声。

验证 TTA 有没有用,别只看总体 IoU,按类别拆开看。我一般会做一张对比表,记录 baseline 和 TTA 在每个类上的 IoU 变化:

类别baseline IoUTTA IoU变化
建筑物0.7120.734+0.022
道路0.5480.571+0.023
水体0.8310.836+0.005
植被0.7890.795+0.006

小类涨得多、大类涨得少,符合预期。如果某个类 TTA 之后反而降了,大概率是这类目标本身方向性强(比如长条形的道路),翻转后预测分布偏移,这时候可以只保留部分变换,不用四种全上。

还有一个容易被忽略的点:TTA 的收益和模型本身的稳定性强相关。如果模型训练不充分、预测方差大,TTA 平均反而可能把错误放大。所以 TTA 是锦上添花,baseline 没调好之前别急着上。从那以后我每次上 TTA 之前都强制先跑一遍单模型的分类别 IoU,确认每个类都过了及格线再叠加,不然就是在噪声上做平均。希望帮到你。

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

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

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

立即咨询