简介:这份资源面向遥感图像处理与计算机视觉方向的开发者、学生及研究人员,聚焦卫星图像分割这一核心任务,帮助读者理解从图像预处理、特征提取到分割算法选择与后处理的完整流程。包内共3个文件,均为Python脚本,压缩包约3KB,涵盖主程序入口、屋顶检测示例与数据准备脚本,便于直接运行和二次修改。已有252人学习下载,说明其在入门与实践中具有一定参考价值。内容涉及GDAL/OGR读取遥感影像、OpenCV边缘检测与阈值分割、Scikit-image区域生长算法,以及基于TensorFlow或PyTorch构建U-Net等深度学习分割模型,可应用于土地覆盖分类、灾害监测与农业估产等场景。读者可借此快速搭建实验环境,理解传统方法与深度学习方法在卫星图像分割中的差异与实现思路,适合作为课程设计、科研入门或项目原型的参考代码。
1. 卫星图像分割:从遥感大图里把每一类地物抠出来
拿到一份标注好的卫星影像数据集,想把建筑、水体、道路、植被逐像素分开,这件事听起来像学术任务,实际落地时全是工程细节。我最近拆的这个卫星图像分割项目,就是干这个的:输入一张多波段遥感图,输出一张同尺寸的类别掩膜,每个像素归到某一类地物。它适合两类人——一类是想跑通语义分割全流程的新手,另一类是要把模型塞进实际遥感处理链路、需要改数据管道和推理逻辑的开发者。项目用 Python 写,训练和推理都围绕 PyTorch 展开,数据增强、损失函数、评价指标这些环节一个不少。下面我按「能干什么、怎么跑、怎么改」的顺序,把这份源码拆开讲。
2. 项目结构与技术选型:为什么是这套组合
2.1 目录骨架与各模块职责
这个项目的目录结构是典型的语义分割工程布局,没有花哨的封装,读起来不费劲。常见做法是分成数据、模型、训练、推理、配置五大块,我按实际拆包看到的组织方式说明:
satellite-seg/ ├── configs/ # yaml 配置,数据集路径、超参、模型名 ├── datasets/ # Dataset 与 DataLoader 构建 │ ├── __init__.py │ ├── seg_dataset.py # 读取影像与掩膜,做同步增强 │ └── transforms.py # 几何+辐射增强 ├── models/ # 网络定义 │ ├── __init__.py │ ├── unet.py # 编码器-解码器主干 │ └── blocks.py # 卷积块、注意力块 ├── losses/ # 损失函数 │ ├── dice.py │ └── combo.py # CE + Dice 组合 ├── utils/ # 指标、日志、可视化 │ ├── metrics.py # IoU / mIoU / F1 │ └── logger.py ├── train.py # 训练入口 ├── predict.py # 单图/大图推理入口 └── requirements.txtdatasets/seg_dataset.py负责把影像和标签成对读进来,关键是保证几何变换同步——影像旋转了,掩膜必须跟着转,否则标签就错位了。models/unet.py是主干网络,losses/combo.py把交叉熵和 Dice 拼在一起,utils/metrics.py算 mIoU。训练入口train.py读配置、建数据、建模型、跑循环。推理入口predict.py单独拆出来,因为遥感图往往很大,推理逻辑和训练不一样。
2.2 技术栈与依赖说明
核心依赖就几个,版本不用卡太死,但要注意 PyTorch 和 torchvision 的匹配:
| 依赖 | 作用 | 备注 |
|---|---|---|
| Python 3.8+ | 运行环境 | 3.10 也稳 |
| PyTorch 1.12+ | 训练与推理 | 有 GPU 走 CUDA |
| torchvision | 预训练权重、变换 | 和 torch 版本对齐 |
| numpy | 数组运算 | 读 tif 常用 |
| opencv-python | 图像读写、增强 | 比 PIL 快 |
| rasterio | 读多波段遥感图 | 处理地理坐标时用 |
| pyyaml | 读配置 | 轻量 |
| tqdm | 进度条 | 训练时看进度 |
选 PyTorch 而不是别的框架,主要是调试方便,动态图写自定义损失和指标时不用跟静态图较劲。遥感图常是多波段(比如 4 波段含近红外),rasterio读进来是(C, H, W),正好对上 PyTorch 的通道优先格式,省一次转置。数据增强用opencv而不是torchvision.transforms,是因为几何变换要同时作用在影像和掩膜上,自己写更可控。
2.3 数据管道:影像与掩膜的同步增强
数据管道是这类项目最容易翻车的地方。影像和掩膜必须做完全相同的几何变换,随机裁剪、翻转、旋转都要同步。看seg_dataset.py的核心逻辑:
import cv2 import numpy as np import torch from torch.utils.data import Dataset class SegDataset(Dataset): def __init__(self, img_paths, mask_paths, transform=None): self.img_paths = img_paths self.mask_paths = mask_paths self.transform = transform # 自定义同步增强 def __getitem__(self, idx): # 读多波段影像,保持 (C, H, W) img = cv2.imread(self.img_paths[idx], cv2.IMREAD_UNCHANGED) mask = cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) # 归一化到 [0,1],按波段做 img = img.astype(np.float32) / 255.0 if img.ndim == 2: img = img[..., None] # 单波段补通道维 # 同步增强:同一组随机参数作用在 img 和 mask 上 if self.transform: img, mask = self.transform(img, mask) # 转成 tensor,通道优先 img = torch.from_numpy(img.transpose(2, 0, 1)).float() mask = torch.from_numpy(mask).long() return img, mask def __len__(self): return len(self.img_paths)逻辑说明:__getitem__每次返回一对(img, mask)。影像读成浮点并归一化,掩膜保持整型类别索引。transform接收影像和掩膜两个参数,内部用同一组随机数做翻转和裁剪,这是同步的关键。最后transpose(2, 0, 1)把(H, W, C)转成(C, H, W),因为 PyTorch 卷积要求通道在前。
参数说明:img_paths和mask_paths是等长列表,顺序必须一一对应,建议在构建时就断言长度相等。transform如果传None,就跳过增强,验证集通常这么用。掩膜用IMREAD_GRAYSCALE读,保证是单通道类别图,如果标签是彩色索引图,要先做颜色到类别的映射。
注意:掩膜的类别值必须是 0 到 num_classes-1 的连续整数,中间不能跳号,否则交叉熵会报越界。
3. 模型与损失:编码器-解码器怎么搭、损失怎么配
3.1 U-Net 主干与跳跃连接
这个项目的主干是 U-Net 结构,编码器逐层下采样提特征,解码器逐层上采样恢复分辨率,中间用跳跃连接把浅层细节引到深层。看models/unet.py的核心:
import torch 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=6, base=64): super().__init__() # 编码器 self.enc1 = DoubleConv(in_ch, base) self.enc2 = DoubleConv(base, base * 2) self.enc3 = DoubleConv(base * 2, base * 4) self.pool = nn.MaxPool2d(2) # 解码器 self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, stride=2) self.dec2 = DoubleConv(base * 4, base * 2) self.up1 = nn.ConvTranspose2d(base * 2, base, 2, stride=2) self.dec1 = DoubleConv(base * 2, base) self.head = nn.Conv2d(base, num_classes, 1) def forward(self, x): e1 = self.enc1(x) # 浅层,细节多 e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) d2 = self.up2(e3) d2 = self.dec2(torch.cat([d2, e2], dim=1)) # 跳跃连接 d1 = self.up1(d2) d1 = self.dec1(torch.cat([d1, e1], dim=1)) return self.head(d1)逻辑说明:DoubleConv是两次卷积加 BN 加 ReLU 的基础块。编码器每经过一次pool分辨率减半、通道翻倍。解码器用转置卷积上采样,然后torch.cat把同分辨率的编码器特征拼进来,这就是跳跃连接,作用是让解码器在恢复边界时能用到浅层的高频信息。最后head用 1x1 卷积把通道压到类别数。
参数说明:in_ch是输入波段数,多光谱常见 4 或 8,按实际数据改。num_classes是地物类别数,含背景。base是基础通道数,显存不够就降到 32。跳跃连接拼接时通道数翻倍,所以dec2的输入是base*4,这个数要对上,改base时别漏。
3.2 组合损失:交叉熵加 Dice
遥感分割里类别极不平衡,水体可能只占几个百分点,单用交叉熵会让模型偏向多数类。常见做法是交叉熵加 Dice,前者管像素级分类,后者管区域重叠。看losses/combo.py:
import torch import torch.nn as nn import torch.nn.functional as F class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, logits, target): # logits: (N, C, H, W), target: (N, H, W) probs = F.softmax(logits, dim=1) target_onehot = F.one_hot(target, num_classes=logits.shape[1]) target_onehot = target_onehot.permute(0, 3, 1, 2).float() # 按类别算 Dice 再平均 dims = (0, 2, 3) inter = (probs * target_onehot).sum(dims) union = probs.sum(dims) + target_onehot.sum(dims) dice = (2 * inter + self.smooth) / (union + self.smooth) return 1 - dice.mean() class ComboLoss(nn.Module): def __init__(self, ce_weight=0.5, dice_weight=0.5): super().__init__() self.ce = nn.CrossEntropyLoss() self.dice = DiceLoss() self.ce_w = ce_weight self.dice_w = dice_weight def forward(self, logits, target): return self.ce_w * self.ce(logits, target) + self.dice_w * self.dice(logits, target)逻辑说明:DiceLoss先把 logits 过 softmax 得到概率,把 target 转成 one-hot,然后按类别算交并比形式的 Dice 系数,最后取1 - mean作为损失。ComboLoss把交叉熵和 Dice 加权相加。交叉熵对每个像素一视同仁,Dice 对区域重叠敏感,两者互补。
参数说明:smooth防止分母为零,一般取 1.0。ce_weight和dice_weight是权重,默认各 0.5,如果小目标分割差,可以把 Dice 权重提到 0.7。target必须是(N, H, W)的整型张量,不能是 one-hot,否则CrossEntropyLoss会报错。
提示:如果显存吃紧,Dice 那步的 one-hot 会额外占显存,可以改成用
scatter或分块计算,但代码会复杂些,先跑通再优化。
3.3 训练循环与评价指标
训练入口train.py把前面几块串起来。核心循环不复杂,关键是每个 epoch 后在验证集上算 mIoU,别只看 loss。看utils/metrics.py的指标实现:
import numpy as np class IoUMetric: def __init__(self, num_classes): self.num_classes = num_classes self.reset() def reset(self): # 混淆矩阵,行是真实类,列是预测类 self.conf = np.zeros((self.num_classes, self.num_classes), dtype=np.int64) def update(self, pred, target): # pred, target: (N, H, W) 整型 pred = pred.flatten() target = target.flatten() mask = (target >= 0) & (target < self.num_classes) idx = target[mask] * self.num_classes + pred[mask] self.conf += np.bincount(idx, minlength=self.num_classes ** 2).reshape(self.num_classes, self.num_classes) def compute(self): # 逐类 IoU,再算 mIoU inter = np.diag(self.conf) union = self.conf.sum(1) + self.conf.sum(0) - inter iou = inter / np.maximum(union, 1) return iou, np.nanmean(iou)逻辑说明:用混淆矩阵累计预测和真实标签的对应关系,update每批调用一次,compute在 epoch 末算逐类 IoU 和 mIoU。np.bincount把二维索引压成一维计数,比循环快很多。np.maximum(union, 1)防止某类没出现时除零。
参数说明:num_classes要和模型输出、掩膜类别数一致。pred是 argmax 后的类别索引,不是 logits。如果某类在验证集里完全没出现,它的 IoU 会是 nan,nanmean会忽略它,但你要心里有数,别被虚高的 mIoU 骗了。
4. 跑起来:环境、训练、推理的完整步骤
4.1 环境搭建与数据准备
先把环境弄干净,建议用虚拟环境。命令按顺序来:
# 建虚拟环境 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 装依赖 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install -r requirements.txt # 验证 GPU 可用 python -c "import torch; print(torch.cuda.is_available())"数据准备是重点。影像和掩膜要分两个目录,文件名一一对应,掩膜是单通道灰度图,像素值就是类别索引。常见做法是写个脚本先检查一遍:
import os import numpy as np import cv2 def check_dataset(img_dir, mask_dir, num_classes): imgs = sorted(os.listdir(img_dir)) masks = sorted(os.listdir(mask_dir)) assert len(imgs) == len(masks), "影像和掩膜数量不一致" for name in masks: m = cv2.imread(os.path.join(mask_dir, name), cv2.IMREAD_GRAYSCALE) vals = np.unique(m) # 检查类别值是否越界 if vals.max() >= num_classes or vals.min() < 0: print(f"{name} 类别值异常: {vals}") print("检查完成") check_dataset("data/images", "data/masks", num_classes=6)逻辑说明:这个脚本先断言影像和掩膜数量相等,再逐个读掩膜,检查像素值是否落在[0, num_classes-1]。遥感标签常见问题是背景被标成 255,直接喂进去交叉熵就崩了,提前查出来能省很多调试时间。
参数说明:img_dir和mask_dir换成你的实际路径。num_classes和配置里保持一致。如果掩膜是彩色索引图,要先做颜色映射转成灰度,这一步别偷懒。
4.2 配置与启动训练
配置用 yaml,configs/下放一份基础配置。关键字段:
data: img_dir: data/images mask_dir: data/masks num_classes: 6 in_ch: 4 batch_size: 8 num_workers: 4 model: name: unet base: 64 train: epochs: 100 lr: 0.001 weight_decay: 0.0001 ce_weight: 0.5 dice_weight: 0.5 save_dir: checkpoints启动训练:
python train.py --config configs/base.yaml训练循环里我一般会加两件事:一是每个 epoch 存一次最优 mIoU 的权重,二是学习率用余弦退火。看train.py的核心片段:
import torch from torch.optim import Adam from torch.optim.lr_scheduler import CosineAnnealingLR from utils.metrics import IoUMetric def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 for img, mask in loader: img, mask = img.to(device), mask.to(device) optimizer.zero_grad() logits = model(img) loss = criterion(logits, mask) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader) def validate(model, loader, device, num_classes): model.eval() metric = IoUMetric(num_classes) with torch.no_grad(): for img, mask in loader: img = img.to(device) logits = model(img) pred = logits.argmax(dim=1).cpu().numpy() metric.update(pred, mask.numpy()) return metric.compute()逻辑说明:train_one_epoch走标准的前向、反向、更新三步,累加 loss 求平均。validate切到 eval 模式,关掉梯度,用 argmax 拿预测类别,喂给指标类。注意model.eval()和torch.no_grad()都要有,前者影响 BN 和 Dropout,后者省显存。
参数说明:device一般是cuda或cpu。optimizer用 Adam,学习率从配置读。CosineAnnealingLR的T_max设成总 epoch 数。验证时mask.numpy()要确保在 CPU 上,别直接对 GPU 张量调 numpy。
4.3 大图推理与结果拼接
遥感图动辄几千像素,直接整张喂进网络会爆显存。常见做法是滑窗切块推理再拼回去。看predict.py的核心:
import numpy as np import torch def sliding_window_predict(model, img, patch=512, stride=256, num_classes=6, device="cuda"): # img: (C, H, W) 浮点 C, H, W = img.shape prob_map = np.zeros((num_classes, H, W), dtype=np.float32) count_map = np.zeros((H, W), dtype=np.float32) model.eval() with torch.no_grad(): for y in range(0, H, stride): for x in range(0, W, stride): y2 = min(y + patch, H) x2 = min(x + patch, W) y1 = max(0, y2 - patch) x1 = max(0, x2 - patch) crop = img[:, y1:y2, x1:x2] tensor = torch.from_numpy(crop).unsqueeze(0).float().to(device) logits = model(tensor) prob = torch.softmax(logits, dim=1).squeeze(0).cpu().numpy() prob_map[:, y1:y2, x1:x2] += prob count_map[y1:y2, x1:x2] += 1 # 重叠区域取平均 prob_map /= np.maximum(count_map, 1) return prob_map.argmax(axis=0)逻辑说明:滑窗按stride步进,每次取patch大小的块,边界处往回对齐保证块尺寸一致。每块的 softmax 概率累加到prob_map,同时用count_map记录覆盖次数,最后取平均再 argmax。重叠区域取平均能缓解拼接缝。
参数说明:patch是切块大小,要和显存匹配,512 一般够用。stride小于patch才有重叠,设成patch的一半比较稳。num_classes和训练一致。如果显存大,patch可以提到 1024,但边界效应会更明显。
注意:推理时的归一化方式必须和训练完全一致,训练用
/255.0,推理也得/255.0,否则预测会整体偏移。
5. 避坑与排查:那些让我重跑训练的记录
5.1 掩膜类别值不连续导致 loss 报错
现象:训练刚起步就抛CUDA error: device-side assert triggered,或者交叉熵报 index out of range。原因:掩膜里出现了大于等于num_classes的值,比如背景标成 255,或者类别跳号(0、1、3)。解决:跑 4.1 里的检查脚本,把所有越界值映射回合法范围,背景统一成 0,类别重映射成连续整数。这个坑我踩过两次,现在每次拿到新数据先跑检查。
5.2 影像和掩膜增强不同步
现象:训练 loss 能降,但验证 mIoU 死活上不去,预测边界全是乱的。原因:几何增强时影像和掩膜用了不同的随机参数,比如影像翻转了掩膜没翻,标签和像素对不上。解决:增强函数必须接收(img, mask)两个参数,内部用同一组随机数。用cv2做旋转时,warpAffine的矩阵要同时作用在两者上,插值方式影像用双线性、掩膜用最近邻。
5.3 显存溢出与 batch size 的取舍
现象:训练几个 batch 后 OOM。原因:batch_size太大,或者 Dice 损失里的 one-hot 占了大头。解决:先把batch_size降到 4 或 2,base通道数从 64 降到 32。如果还不够,把 Dice 的 one-hot 改成逐类循环计算,牺牲点速度换显存。另外num_workers别设太大,数据加载进程也会占内存。
5.4 验证集 mIoU 虚高
现象:mIoU 看着 0.9,实际预测图一塌糊涂。原因:验证集里某些类完全没出现,nanmean把它们忽略了,剩下的类又恰好容易分。解决:看逐类 IoU,别只看 mIoU。如果某类 IoU 是 nan,说明验证集没这个类,要么补数据,要么在报告里注明。另外检查验证集有没有和训练集重叠,遥感图相邻区域很像,切分时要按地理块切,别随机切。
5.5 推理结果有拼接缝
现象:大图推理拼出来能看到网格状的缝。原因:滑窗stride等于patch,块之间没有重叠,边界处上下文不足。解决:把stride设成patch的一半,让相邻块有重叠,重叠区域取平均。如果缝还在,检查归一化是否一致,以及模型有没有用 padding 导致边界像素偏移。
6. 二次开发:换主干、加类别、接后处理
跑通之后,二次开发无非三个方向:换更强的编码器、加类别、接地理后处理。换主干最直接,把UNet的编码器换成带预训练权重的 ResNet 或 EfficientNet,解码器保留。做法是torchvision.models.resnet34(pretrained=True)取前几层,把enc1到enc3替换掉,注意通道数要对上,ResNet 的layer1输出是 64 通道,正好接base=64。换完先冻结编码器训几个 epoch,再解冻微调,不然预训练权重容易被大学习率冲垮。
加类别要动三处:配置里的num_classes、模型head的输出通道、损失函数里的类别数。掩膜也要重新映射,新类别给新的连续索引。如果新类别样本很少,把dice_weight提到 0.7,再考虑加个类别权重到交叉熵里,nn.CrossEntropyLoss(weight=torch.tensor([...])),权重按类别频率的倒数设。
接后处理是遥感特有的。模型输出的是逐像素类别,但实际地物有空间连续性,常见做法是接一个条件随机场或简单的形态学开闭运算去噪。我一般先用cv2.morphologyEx做开运算去掉孤立小斑块,再按最小面积过滤连通域。如果项目要输出矢量,用rasterio.features.shapes把掩膜转成多边形,再按地理坐标写出去。
验证改造效果时,别只看 mIoU。我习惯固定几张典型图,把改造前后的预测并排看,重点看边界和小目标。有一次我换了个更强的编码器,mIoU 涨了两个点,但小水体反而漏了,后来发现是新主干的感受野太大,小目标被平滑掉了。从那以后我每次换主干都强制走一遍逐类 IoU 加可视化对比,不敢只看一个总数。希望这些能帮到你,少走点我踩过的弯路。
本文还有配套的精品资源,点击获取