ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

卫星图像分割实战:瓦片切分、U-Net训练与滑窗拼接全流程

卫星图像分割实战:瓦片切分、U-Net训练与滑窗拼接全流程

简介:这份资源面向具备一定Python与遥感基础的开发者,聚焦卫星图像分割这一遥感图像处理核心任务,帮助读者理解从预处理、特征提取到分割算法与后处理的完整流程,并借助Python生态完成落地实践。压缩包共3个文件,均为py脚本,整体约3KB,体量轻便,涵盖主流程入口、屋顶检测与数据准备等模块,便于快速阅读与二次修改。已有253人学习下载,具备一定参考热度。内容围绕GDAL/OGR读写遥感影像、OpenCV图像增强与边缘检测、Scikit-image多种分割算法,以及TensorFlow、PyTorch构建U-Net等深度学习模型展开,可帮助读者掌握阈值分割、区域生长、聚类与CNN端到端分割的实现思路,并了解其在土地覆盖变化评估、灾害监测与农业估产等场景中的应用,适合作为入门与实验参考。

1. 卫星图像分割:从一张遥感大图到可用的地物掩膜

第一次拿到卫星影像做分割任务时,我盯着那张 8000×8000 像素的 GeoTIFF 愣了半天——显存装不下,直接 resize 又会把道路、建筑边缘糊成一团。卫星图像分割和常规的语义分割 Demo 完全是两回事:单张图动辄上亿像素、类别极度不均衡(水体占大半,小目标建筑零星分布)、标注成本高得离谱。这个项目要解决的就是把大幅遥感影像切成可训练的瓦片、跑通分割模型、再把预测结果拼回原图这套完整链路。适合谁?手里有遥感数据想做地物提取的算法同学,以及想把 U-Net 那套东西真正落到工程上的 Python 开发者。下面按「能干什么 → 怎么跑 → 怎么改」的顺序拆。

2. 项目定位与技术选型:为什么是 Python + 瓦片化管线

2.1 这个项目到底在做什么

一句话概括:输入一张带地理坐标的卫星影像,输出同尺寸的类别掩膜(水体、植被、建筑、道路、裸地这类)。听起来和普通语义分割一样,但工程上的差异全在「大幅」两个字上。常规数据集是 512×512 的小图,直接喂给网络就行;卫星影像单景覆盖几十平方公里,像素量是常规图的几百倍,必须做瓦片化(tiling)切分,训练时按瓦片采样,推理时滑窗预测再拼接。

项目的核心链路我拆成四段:影像读取与预处理(含坐标参考系处理)、瓦片切分与数据集构建、分割模型训练、滑窗推理与结果拼接。每一段都有独立的坑,后面逐段讲。技术栈上,Python 是绝对主力,影像 IO 用 rasterio(比 GDAL 的 Python 绑定好用,能直接读地理变换参数),数组运算和切片靠 numpy,模型部分用 PyTorch,数据增强用 albumentations,评估指标用自定义的 IoU/mIoU 实现。这套组合是遥感分割场景里最常见的做法,没有花哨的依赖,装起来不折腾。

为什么不用 OpenCV 读图?因为 cv2.imread 会丢掉地理坐标信息,而拼接预测结果时需要原始影像的 transform(仿射变换参数)才能把瓦片坐标映射回原图坐标。rasterio 读进来能同时拿到数组和 profile,这是后续拼接的关键。我一般会在项目入口就把 profile 存下来,后面所有坐标换算都靠它。

2.2 目录结构长什么样

一个能跑的分割项目,目录大致是这样组织的(不同实现细节会有出入,但骨架大同小异):

satellite-seg/ ├── configs/ │ └── train.yaml # 数据路径、瓦片大小、超参 ├── data/ │ ├── raw/ # 原始影像与标注 │ └── tiles/ # 切好的瓦片缓存 ├── src/ │ ├── dataset.py # 瓦片数据集与增强 │ ├── tiling.py # 切分与拼接 │ ├── model.py # 分割网络定义 │ ├── train.py # 训练入口 │ ├── infer.py # 滑窗推理 │ └── metrics.py # IoU 等指标 ├── scripts/ │ ├── prepare_tiles.py # 预处理脚本 │ └── evaluate.py # 评估脚本 └── requirements.txt

这个结构的好处是「切分/拼接」和「模型」解耦——换模型不用动 tiling 逻辑,换数据源不用动训练代码。我见过不少项目把切图逻辑塞进 Dataset 的__getitem__里,训练时每次采样都重新切一遍,IO 直接成为瓶颈,血泪经验:切分一定要做成离线预处理,落盘缓存。

2.3 依赖与运行环境

依赖清单不长,但版本要对齐,尤其是 rasterio 和 GDAL 的底层绑定,装错版本会报一堆找不到 proj 库的错。常见做法是先用 conda 装 rasterio(它会顺带把 GDAL 的二进制依赖处理好),再 pip 装其余包:

conda create -n satseg python=3.10 -y conda activate satseg conda install -c conda-forge rasterio -y pip install torch torchvision numpy albumentations pyyaml tqdm

参数说明:python=3.10是稳妥选择,太新的版本某些科学计算包还没轮子;-c conda-forge指定频道,rasterio 的预编译包在这里最全。装完跑一句python -c "import rasterio; print(rasterio.__version__)"验证,能打印版本号就说明 GDAL 链路通了。如果这一步就报错,八成是系统里残留了旧版 GDAL,清掉重装比修依赖快。

3. 核心模块拆解:瓦片切分、数据集与滑窗推理

3.1 瓦片切分:带重叠的滑窗是标配

切分是整个管线的地基。最朴素的做法是按固定大小无重叠切,但这样会在瓦片边界产生「割裂」——一个建筑正好跨在两块瓦片中间,两块各看到一半,模型学到的都是残缺目标。所以工程上几乎都用带重叠(overlap)的切分,训练时重叠比例可以小一点(比如 25%),推理时重叠要大(50%),给拼接留足冗余。

import numpy as np import rasterio from rasterio.windows import Window def tile_image(src_path, tile_size=512, overlap=128, out_dir="data/tiles"): """把大幅影像切成带重叠的瓦片并落盘""" with rasterio.open(src_path) as src: H, W = src.height, src.width stride = tile_size - overlap # 步长 = 瓦片边长 - 重叠 idx = 0 for row in range(0, H, stride): for col in range(0, W, stride): # 边界瓦片向内收,避免出现不足 tile_size 的碎块 r = min(row, H - tile_size) c = min(col, W - tile_size) window = Window(c, r, tile_size, tile_size) data = src.read(window=window) # (C, tile, tile) profile = src.profile.copy() profile.update(width=tile_size, height=tile_size, transform=src.window_transform(window)) with rasterio.open(f"{out_dir}/tile_{idx:05d}.tif", "w", **profile) as dst: dst.write(data) idx += 1 return idx

逻辑说明:stride控制相邻瓦片的位移,overlap 越大瓦片数越多、冗余越高。min(row, H - tile_size)这行是处理右边缘和下边缘的关键——如果直接按 stride 走,最后一块可能只剩几十像素,网络输入尺寸对不上。向内收的代价是边缘区域被重复覆盖,但换来的是所有瓦片尺寸统一,训练时不用做 padding 分支。window_transform把瓦片在原图中的位置写进它自己的 transform,后面拼接时靠这个反推坐标。参数上,tile_size 一般取 512 或 1024,取决于显存;overlap 训练用 64~128,推理用 256 更稳。

3.2 数据集与增强:类别不均衡是第一道坎

瓦片切好后,Dataset 的职责就是读瓦片、配对掩膜、做增强。这里最大的问题是类别不均衡——水体可能占 60%,建筑只占 3%。直接训练模型会倾向于全预测成水体,IoU 看着还行但小目标全丢。常见做法有两层:数据层面用加权采样,让含稀有类别的瓦片被采到的概率更高;损失层面用 Dice Loss 或 Focal Loss 替代纯交叉熵。

import torch from torch.utils.data import Dataset import albumentations as A class TileDataset(Dataset): def __init__(self, img_paths, mask_paths, size=512, train=True): self.img_paths = img_paths self.mask_paths = mask_paths self.train = train self.aug = A.Compose([ A.RandomRotate90(), A.HorizontalFlip(), A.VerticalFlip(), A.RandomBrightnessContrast(p=0.3), ]) if train else None def __len__(self): return len(self.img_paths) def __getitem__(self, i): img = read_tif(self.img_paths[i]) # (H, W, C) float32, 归一化到 0~1 mask = read_tif(self.mask_paths[i]) # (H, W) int64 if self.aug: out = self.aug(image=img, mask=mask) img, mask = out["image"], out["mask"] img = torch.from_numpy(img).permute(2, 0, 1).float() mask = torch.from_numpy(mask).long() return img, mask

逻辑说明:增强只做几何变换和轻度色彩扰动,不做 Cutout 之类会破坏地物连续性的操作——遥感图像里地物是成片分布的,随机遮挡会让模型学到错误的空间关系。permute(2,0,1)把 HWC 转成 CHW,这是 PyTorch 卷积层的输入约定。掩膜保持 long 类型,因为交叉熵损失要求类别索引是整型。加权采样器我一般单独写一个WeightedRandomSampler,权重按每块瓦片里稀有类像素占比算,占比越高权重越大,这样小目标瓦片不会被淹没。

3.3 模型与训练循环:U-Net 骨架够用,别急着上大模型

分割网络选型上,U-Net 及其变体(ResNet 编码器 + 解码器)在遥感场景依然是性价比最高的选择。卫星图像的分辨率有限,地物边界不像医学影像那么精细,上太重的 backbone 收益递减还拖慢推理。项目里模型定义大致是编码器逐级下采样提特征、解码器逐级上采样恢复分辨率、跳跃连接把浅层细节补回来。

import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.block(x) class UNet(nn.Module): def __init__(self, in_ch=4, num_classes=5): super().__init__() # in_ch=4 对应 RGB + 近红外,遥感常用四波段 self.enc1 = DoubleConv(in_ch, 64) self.enc2 = DoubleConv(64, 128) self.pool = nn.MaxPool2d(2) self.up = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False) self.dec2 = DoubleConv(128 + 64, 64) self.head = nn.Conv2d(64, num_classes, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) d2 = self.dec2(torch.cat([self.up(e2), e1], dim=1)) return self.head(d2)

逻辑说明:in_ch=4是遥感场景的常见设定,多一个近红外波段对植被和水体区分帮助很大;如果你的数据只有 RGB,改成 3 即可。torch.cat([up(e2), e1], dim=1)就是跳跃连接,把编码器浅层的高分辨率特征和解码器的语义特征拼起来,这是 U-Net 能保住边界细节的核心。head用 1×1 卷积把通道数压到类别数,输出每个像素的类别 logits。训练循环里损失用CrossEntropyLoss(weight=class_weights)配合 Dice Loss 加权求和,优化器 AdamW,学习率 1e-4 起步,余弦退火。这些超参不是玄学,遥感分割任务里这套配置基本能收敛。

3.4 滑窗推理与拼接:把瓦片预测缝回原图

推理阶段最容易翻车的地方在拼接。每块瓦片的预测结果在重叠区域会有不一致,直接覆盖会留下明显的接缝。标准做法是加权平均:重叠区域按到瓦片中心的距离给权重,中心权重高、边缘权重低,这样接缝处过渡自然。

import numpy as np def stitch_predictions(tiles, positions, full_shape, num_classes): """tiles: 每块瓦片的概率图 (C, t, t);positions: 每块的 (row, col)""" prob = np.zeros((num_classes, *full_shape), dtype=np.float32) weight = np.zeros(full_shape, dtype=np.float32) t = tiles[0].shape[-1] # 高斯权重,中心高边缘低 g = np.outer(np.hanning(t), np.hanning(t)).astype(np.float32) for tile, (r, c) in zip(tiles, positions): prob[:, r:r+t, c:c+t] += tile * g weight[r:r+t, c:c+t] += g weight[weight == 0] = 1e-6 return prob / weight # 归一化后取 argmax 即得掩膜

逻辑说明:np.hanning生成一维汉宁窗,外积得到二维权重核,中心接近 1、边缘趋近 0。累加时概率图和权重图同步叠加,最后逐像素除以权重完成归一化。weight == 0的保护是防止原图某些角落没被任何瓦片覆盖(理论上不会,但边界处理有 bug 时会遇到)。拿到prob后argmax(axis=0)就是最终掩膜,再按原始 profile 写回 GeoTIFF,地理坐标就对齐了。

4. 避坑与排查:那些让我加班到凌晨的问题

4.1 显存爆了但 batch_size 已经设成 1

现象:训练第一个 iter 就 OOM,nvidia-smi 显示显存瞬间打满。原因通常不是 batch 太大,而是输入尺寸或通道数超预期——比如误把 1024 的瓦片喂给了按 512 设计的模型,或者数据里混进了 8 波段影像而模型只声明了 4 通道。解决:在 Dataset 里加一句断言assert img.shape == (512, 512, 4),让错误在数据层就暴露,而不是等到卷积层报一个看不懂的 shape mismatch。另外用torch.cuda.empty_cache()清理缓存只能缓解,治标不治本。

4.2 训练 loss 正常但验证 IoU 极低

现象:训练集 loss 一路下降,验证集 mIoU 卡在 0.1 上不去。原因八成是掩膜的类别索引和模型输出对不上——比如标注里背景是 255、前景是 1,而模型输出 0~4 的连续类别,交叉熵算出来全是错的。解决:预处理阶段就把掩膜重映射成 0 起始的连续整数,并打印每类的像素统计确认。我一般会在训练前跑一遍np.unique(mask),看到非预期值立刻停下来查。

4.3 拼接结果出现网格状接缝

现象:推理输出的大图上有规律的方格纹路。原因是瓦片重叠不足或拼接时直接覆盖而非加权。解决:推理 overlap 提到 50%,拼接用上面的汉宁窗加权。如果还有轻微接缝,检查瓦片 transform 是否写对——window_transform用错会导致坐标偏移半个瓦片,接缝位置就会错位。

4.4 rasterio 读出来的数组和预期波段顺序不一致

现象:明明是真彩色影像,读出来颜色发蓝或者植被显示异常。原因是部分卫星产品的波段顺序是 BGR 或带全色波段,不是标准 RGB。解决:读完后先查src.descriptions和src.count,按产品文档确认波段顺序再重排。这个坑很隐蔽,因为数组 shape 是对的,只有肉眼看图才能发现。

4.5 推理速度慢到无法接受

现象:单景影像推理要几十分钟。原因通常是逐瓦片串行推理且没开torch.no_grad()。解决:推理包在with torch.no_grad():里,batch 化瓦片输入(一次喂 8~16 块),并考虑半精度model.half()。注意半精度在 CPU 上不生效,得确认在 GPU 上跑。

5. 二次开发:换数据源和换模型时我会怎么改

拿到这个项目想改成自己的数据,第一步永远是「先跑通再改」。用项目自带的样例数据把 prepare_tiles → train → infer 整条链路走一遍,确认环境没问题,再动代码。换数据源时,改动集中在三处:configs/train.yaml里的路径和波段数、dataset.py里的归一化参数(不同卫星的辐射量化范围差异很大,有的 0~255,有的 0~10000)、以及类别映射表。我一般会写一个inspect_data.py小脚本,先统计所有影像的尺寸、波段数、像素值范围,心里有数了再改配置,比盲改快得多。

换模型时,只要保证输入输出契约不变——输入(B, C, H, W)、输出(B, num_classes, H, W)——就能直接替换model.py里的网络定义,训练和推理代码一行不用动。想上 SegFormer 或 DeepLabV3+,把 backbone 换掉即可,但要注意预训练权重的输入通道数,四波段数据没法直接用三通道的 ImageNet 权重,常见做法是把第一层卷积的权重在通道维度上复制或补零,再微调。

改造目标改动文件关键注意点
换卫星数据源configs、dataset.py波段顺序、量化范围、坐标系
换分割模型model.py输入输出 shape 契约不变
加新类别标注重映射、损失权重类别索引连续、权重重算
提升小目标召回采样器、损失函数加权采样 + Dice/Focal

验证改造是否成功,我有个笨但有效的习惯:拿一张小尺寸的图(比如 1024×1024)跑完整推理,把预测掩膜和原图叠在一起可视化,肉眼确认地物边界对得上、没有整体偏移。指标只是参考,可视化才是照妖镜。从那以后我每次改完数据管线,都强制先跑这张小图的可视化,确认没问题再上全量训练——这个习惯帮我省下了无数次「训练一天才发现坐标错位」的后悔药。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进