拿到这个数据集的第一天,我差点被它的目录结构带沟里去了。Massachusetts道路语义分割数据集是遥感分割领域绕不开的经典数据,全称叫 Massachusetts Roads Dataset。它的核心标签很简单:一个类别,就是 road;图片数量按常见下载版本是训练集809张,验证集73张,测试集172张;单张分辨率1500x1500;存储格式是VOC格式。这套数据很适合做语义分割入门、道路检测、遥感影像分析这一类项目。我前前后后拿着它跑了三轮实验,把整个过程里该踩的坑基本都踩了一遍,这篇文章就按我实际操作的顺序,把数据解析、代码实现、训练配置和疑难排查完整复盘一遍。1. 数据集背景:为什么这个“老数据”到现在还是好用1.1 数据来源与覆盖范围Massachusetts道路数据集出自一项早期的道路分割研究工作,发布距今已经很多年了,但实用性一点没打折。数据来源是美国地质调查部门的航拍影像,覆盖一片真实的城市与乡村混合区域,裁剪成一张张1500x1500的独立图片。注意,这个1500x1500不是屏幕像素那么随意,它对应的是地面约1米一个像素,每张图实际覆盖约2.25平方公里的地表范围。所以你在图里看到的房子、树木、路口都是真实世界的俯视视角,道路形态也是实打实的航拍形状。这套数据最大的价值在于“真实”。它不像很多合成数据集那样道路干净、边界清晰,航拍图里有树荫遮挡、车辆遮挡、建筑物阴影,道路宽度也不均匀,这些干扰项能让模型学到更鲁棒的特征。我第一次拿它训练时,明显感觉到比那些“精修过”的路面数据集更能暴露模型短板。1.2 标注内容与类别设计整个数据集只标一个类别:road。也就是说它是一个纯粹的“道路 vs 背景”二分类语义分割问题。标注图是逐像素的掩码,道路像素标记为前景,其余全部是背景。很多第一次接触的人会问:既然只有两类,为什么不叫目标检测或者二分类分类?因为语义分割要求给每一个像素打上类别标签,输出的是和输入图同尺寸的分割图,这点和输出box或者一个类别的任务有本质区别。这套数据的标注风格偏“粗”。道路区域是以中心线为基础扩展标注的,边界算不上精细,有的地方道路标注甚至比实际路面略宽一些。这意味着你不能拿它去追求“毫米级道路边缘”那种极致效果,但用它做城区范围的粗粒度道路提取、车道级路径规划前处理、地图要素自动更新,完全够用。对任务来说,关键是判断一个大区域里哪里是路、哪里不是,而不是把路缘石边界抠到像素级。1.3 官方划分与图片数量按照我实际下载到的版本,ImageSets/Segmentation目录下给出三个列表文件:train.txt列了809个训练集文件,val.txt列了73个验证集文件,test.txt列了172个测试集文件。有些资料里会把训练集写成1371张,这个数字差异来自官方早期发布时的不同整理版本,实际以你解压出来的文件列表为准。训练量不大,但也不是小到没法训练,配合数据增强跑一个U-Net绰绰有余。官方划分有个特点:验证集只有73张,偏少。这带来一个实际问题——单次验证的IoU指标波动会很明显,很可能这轮验证集的IoU是0.72,下一轮变成0.76,不一定代表模型真的变好了。我建议在训练中采用“训练集里再切一小部分做内部验证”或者“多次重复取平均值”的策略,后面第5章会详细展开。2. VOC格式逐目录拆解:先把数据结构摸透再用2.1 标准目录长什么样所谓VOC格式,就是经典的Pascal VOC目录组织方式。Massachusetts数据集按这个规则把图像和标注放在对应位置,目录结构如下:Massachusetts_Roads/ ├── JPEGImages/ │ ├── 10030719_15.jpg │ ├── 10030719_16.jpg │ ├── ... │ └── 25953929_22.jpg ├── SegmentationClass/ │ ├── 10030719_15.png │ ├── 10030719_16.png │ ├── ... │ └── 25953929_22.png └── ImageSets/ └── Segmentation/ ├── train.txt ├── val.txt └── test.txtJPEGImages放原始遥感图,SegmentationClass放对应的PNG掩码,ImageSets/Segmentation下放划分好的文件名列表。JPEG图和PNG掩码的文件名是一一对应的,没有坐标文件,也没有矢量边界,所有信息都体现在像素上。这个结构很轻量,做深度学习训练时直接按文件名读取就行,不需要解析XML或者JSON。有一点要注意:这个数据集不是标准的“VOC 20类”语义分割,而是只借用VOC的目录规范。所以不要想当然认为SegmentationClass里的PNG是RGB伪彩色图,它本质是单通道索引图,每个像素取值为整数类别编号。这一点坑了很多人,我在2.2节细说。2.2 标注掩码的像素值陷阱这是整个数据集最容易踩的第一个坑:用常见的看图软件或者OpenCV打开SegmentationClass里的PNG,你看到的是一张接近全黑的图。于是很多人以为自己下载坏了,或者标注丢了一半。其实没坏,只是像素值太小了。原始掩码里,背景像素值是0,道路像素值是1。在8位灰度图里,0和1显示出来都是接近纯黑,肉眼根本分不出来。必须用代码处理之后才能看清道路区域。读取和转换的推荐方式如下:import cv2 import numpy as np # 读掩码时用IMREAD_UNCHANGED保留原通道 mask = cv2.imread("SegmentationClass/10030719_15.png", cv2.IMREAD_UNCHANGED) # 有些版本掩码是单通道,有些是RGB三通道但内容相同 if len(mask.shape) == 3: mask = mask[:, :, 0] # 关键一步:把大于0的像素统一置为前景 mask = (mask > 0).astype(np.uint8) # 这样mask里就是0和1两类,直接可以参与交叉熵/one-hot计算这里为什么用“大于0”而不是“等于1”?因为不同渠道下载的版本里,道路像素值可能被存成1,也可能被某些转换工具重映射成255,甚至别的值。统一做一次二值化,能保证后面所有代码行为一致。我后期所有实验都基于这行转换,再没有出过掩码解析问题。2.3 训练列表文件的作用train.txt、val.txt、test.txt里每一行是一个不带扩展名的文件名,比如:10030719_15 10030719_16 10030719_17 ...这个列表的作用是决定哪些图进训练、哪些进验证。写Dataset类时按这个列表读名字,再拼接JPEGImages和SegmentationClass的路径。好处是官方已经帮你做了划分,不用自己临时切;坏处是如果你希望重新划分(比如我为了内部验证切了100张出来),得自己去改列表。我实际操作时习惯写一个工具函数,读取列表后过滤掉本地不存在的文件,防止某些渠道下载的数据有缺失导致训练中断。代码如下:def load_image_names(list_path): with open(list_path, "r") as f: names = [line.strip() for line in f.readlines() if line.strip()] return names3. 数据加载与预处理实战3.1 写一个标准的Dataset类基于上面的目录规则,我写了一个专门用于道路分割的Dataset类。核心逻辑很简单:读原图、读掩码、转数组、归一化、随机裁剪。重点在于三点:一是掩码二值化必须做,二是裁剪时原图和掩码要同步裁剪,三是验证集不能用随机裁剪,否则指标没法复现。import os import cv2 import numpy as np import torch from torch.utils.data import Dataset from torchvision import transforms class RoadsDataset(Dataset): def __init__(self, root, image_list, train=True, crop_size=512): self.root = root self.img_dir = os.path.join(root, "JPEGImages") self.mask_dir = os.path.join(root, "SegmentationClass") self.train = train self.crop_size = crop_size with open(image_list, "r") as f: self.names = [line.strip() for line in f.readlines()] def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] image = cv2.imread(os.path.join(self.img_dir, name + ".jpg")) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask = cv2.imread(os.path.join(self.mask_dir, name + ".png"), cv2.IMREAD_UNCHANGED) if mask.ndim == 3: mask = mask[:, :, 0] mask = (mask > 0).astype(np.uint8) if self.train: image, mask = self._random_crop(image, mask) if np.random.rand() > 0.5: image = image[:, ::-1] mask = mask[:, ::-1] else: image = cv2.resize(image, (self.crop_size, self.crop_size)) mask = cv2.resize(mask, (self.crop_size, self.crop_size), interpolation=cv2.INTER_NEAREST) image = torch.from_numpy(image.transpose(2, 0, 1)).float() / 255.0 mask = torch.from_numpy(mask).long() return image, mask def _random_crop(self, image, mask): h, w = image.shape[:2] size = self.crop_size if h <= size or w <= size: pad_h = max(0, size - h) pad_w = max(0, size - w) image = cv2.copyMakeBorder(image, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value=0) mask = cv2.copyMakeBorder(mask, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value=0) h, w = image.shape[:2] y = np.random.randint(0, h - size + 1) x = np.random.randint(0, w - size + 1) return image[y:y + size, x:x + size], mask[y:y + size, x:x + size]这段代码我实测可以直接跑。image被归一化到0到1之间的浮点数,mask保持为0和1的整数。因为只有两类,mask的shape是(H, W),直接作为交叉熵的target即可,不需要额外做one-hot。如果你在别的任务里用多类分割,需要在loss函数里自己扩展one-hot。3.2 数据增强策略怎么选很多人拿到1500x1500的图,第一反应是什么?直接resize到512或224。我劝你千万别这么干。1500x1500的图直接缩放,道路细线会被严重压缩,很多只有几个像素宽的小路直接断掉,模型学到的是“断裂的道路”而不是“连续的道路”。我的做法是训练阶段随机裁剪512x512或768x768的patch,验证阶段为了保证效率才resize到512。这个策略的效果立竿见影。随机裁剪本身既解决了尺寸问题,又天然实现了平移不变性的数据增强。增强组合上,我用了随机水平翻转、随机旋转(90度倍数)、微小的亮度对比度调整。因为遥感图本身是俯视正射图,不涉及地平线或重力方向,旋转90度是完全合理的。高斯噪声之类的不建议用,会破坏遥感影像的纹理信息,对道路分割没有明显帮助。3.3 类别不平衡问题道路在航拍图里的面积占比通常只有5%到15%,背景占比极高。如果直接用普通交叉熵,模型很容易陷入“全部预测成背景”的局部最优,因为这样loss已经很小了。我第一次实验就踩了这个坑,训练到第20轮,验证集IoU还是0,全部像素都预测成背景。解决不平衡问题有三个套路,我实验后认为可以组合使用:第一个是在loss上做文章,使用DiceLoss或者FocalLoss;第二个是在数据层面做采样,让裁剪patch尽量覆盖到有道路的区域;第三个是给交叉熵加权重,把道路类的权重调到背景类的3到5倍。我最终用的是BCEWithLogitsLoss加DiceLoss的加权组合,训练稳定后比单独交叉熵高了不少。4. 模型选型与训练配置复盘4.1 二分类分割的模型选择对于单类别道路分割,不需要特别复杂的模型结构。我对比过U-Net、DeepLabV3+、SegNet三套主流方案,最终长期使用的是ResNet34编码器加U-Net解码器的组合。原因很直接:数据集只有809张训练图,属于小样本,模型太大容易过拟合;U-Net的跳跃连接能保留更多空间细节,对道路这种细长结构特别友好。模型显存占用道路IoU表现上手难度适用场景U-Net (ResNet34)中较高低小样本、细长结构、快速迭代DeepLabV3+高高中精度优先、显存充足SegNet低一般低低显存环境验证如果显卡显存有限,用普通编码器的轻量U-Net跑512x512输入,batch size设4,8G显存是能吃得下的。DeepLabV3+效果不差,但对这个数据集的标注粗粒度来说,精度的上限受限于标注质量,花的额外成本未必值。4.2 损失函数与评估指标二分类语义分割最常用的损失函数是BCEWithLogitsLoss。它把网络的最后一层原始logits直接丢进来,内部做sigmoid再算交叉熵,数值稳定性比手动sigmoid加BCELoss更好。我实际用的加权公式大概是:bce = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([4.0])) dice = DiceLoss() loss = bce * 0.5 + dice * 0.5DiceLoss可以有效缓解类别不平衡,实现上就是对道路类算Dice系数然后取负。注意二分类Dice在预测全为背景时会出现“分母为0”的问题,实现时要加一个平滑项。评估指标建议关注道路IoU和Dice系数,而不是全局准确率。全局准确率在这个数据集上没什么意义,因为背景占90%以上,全预测背景准确率也有0.9,完全看不出模型好坏。IoU的计算方法如下:IoU = 预测道路∩真实道路 / 预测道路∪真实道路4.3 训练参数与策略我最常用的一组参数:输入裁剪尺寸512x512,batch size 8(取决于显存,8G显卡设4更稳),优化器Adam,初始学习率1e-4,训练100轮,学习率采用阶梯下降(第60轮和80轮各乘0.1)。如果换成SGD+momentum,学习率初始值可以调到0.01,配合多项式衰减节奏,效果也不错但收敛偏慢。验证频率设为每2轮一次,用验证集73张图算平均IoU。由于验证集数量少,建议同时保存“验证集IoU最高的模型”和“最近一轮模型”,防止最佳模型被中途覆盖。实测下来最好的结果通常是第70到90轮之间出现,之后波动不大。5. 踩坑实录:七个高频问题与排查思路5.1 掩码打开全黑,看不到道路原因就是道路像素值为1,肉眼无法分辨。解决方式是用代码把大于0的像素全部转成255显示,或者直接转成0/1数组参与计算。这不算数据损坏,是正常编码方式。我第一次拿到时也怀疑过数据坏了,后来统一二值化后一切正常。5.2 图像尺寸与掩码尺寸不一致某些渠道下载的包可能出现极个别图与掩码尺寸差几个像素的情况。直接训练会报维度错误。我的处理方式是在Dataset的__getitem__里加一句断言和强制resize,确保输入模型时尺寸绝对一致:if image.shape[:2] != mask.shape[:2]: mask = cv2.resize(mask, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_NEAREST)5.3 显存爆炸1500x1500直接输入大多数8G显卡是扛不住的。解决思路三种:随机裁剪512x512、减小batch size、开启混合精度训练。我实测混合精度能把显存占用降30%左右,速度还更快,是性价比最高的方案。代码上只需要在PyTorch中使用原生混合精度上下文,无需改动模型结构。5.4 验证集指标波动大73张验证图太少了,跑完一轮IoU波动能到几个百分点。我给出的解决思路:一是固定随机种子保证每次验证结果可复现;二是如果条件允许,把训练集里的100张挪出来并入验证集,重新划分;三是最终评测时多次推理取平均。另外保存模型时要求“连续三轮验证IoU都不低于历史最佳”,能过滤掉单轮偶然高分。5.5 道路分割结果断裂、不连续这是细长结构分割最容易出现的问题。后处理可以救一部分:用形态学闭运算(先膨胀后腐蚀)把小断点连起来。操作上可以用OpenCV的morphologyEx函数配合适当大小的核。我实测对512分辨率下的输出做一次5x5闭运算,IoU能提升0.5到1个百分点。更彻底的办法是增大训练时的裁剪尺寸,让模型每次看到更大范围的上下文,对长距离连续性有好处。5.6 模型全预测成背景训练初期可能会出现整体输出全为背景。除了前面说的加DiceLoss和正样本权重外,还有一个容易被忽略的点:学习率不能太大。我一开始用3e-4的初始学习率,模型在二分类上很容易陷入全背景的局部最优;降到1e-4后反而能正常收敛。原因很简单,全背景的梯度方向过于一致,学习率太大直接一步跨过最优区域。5.7 训练验证划分不当导致数据泄露如果图省事直接用全量数据不分验证集,或者划分时没有固定随机种子,容易造成同一张图同时出现在训练集和验证集,指标虚高到不真实。我最保险的做法是只用官方列表,如果要重新划分,用文件名的hash值做种子切分,保证每次运行结果一致,且同一张图不会被重复分配。问题可能原因解决方案掩码全黑像素值1显示不明显统一做大于0的二值化尺寸不一致下载版本差异resize对齐显存爆炸输入过大裁剪、小batch、混合精度指标波动验证集太小多次验证取平均、扩充验证集道路断裂细长结构+上下文不足形态学闭运算、更大裁剪全预测背景类别不平衡、lr过大DICE损失、降低学习率指标虚高数据划分泄露固定随机种子、按文件列表划分最后再分享一个我在实际使用中的体会:Massachusetts道路数据集虽然标注粗、类别少,但它极其适合作为遥感分割和细长结构分割的“试炼场”。因为数据量适中,几分钟就能跑一轮实验;因为道路形态复杂,任何模型缺陷都会直接暴露在IoU上。我后续还尝试过在这个数据集上训练一个粗分割模型,再用分割结果去指导更高分辨率影像上的道路中心线提取,效果比直接在高清图上从零训练稳定很多。如果你也打算拿它做类似的事,强烈建议先把本文对应到5.1和5.5的两个预处理细节处理好,这两处能让你少走一大半弯路。
拿到这个数据集的第一天,我差点被它的目录结构带沟里去了。Massachusetts道路语义分割数据集是遥感分割领域绕不开的经典数据,全称叫 Massachusetts Roads Dataset。它的核心标签很简单:一个类别,就是 road;图片数量按常见下载版本是训练集809张,验证集73张,测试集172张;单张分辨率1500x1500;存储格式是VOC格式。这套数据很适合做语义分割入门、道路检测、遥感影像分析这一类项目。我前前后后拿着它跑了三轮实验,把整个过程里该踩的坑基本都踩了一遍,这篇文章就按我实际操作的顺序,把数据解析、代码实现、训练配置和疑难排查完整复盘一遍。1. 数据集背景:为什么这个“老数据”到现在还是好用1.1 数据来源与覆盖范围Massachusetts道路数据集出自一项早期的道路分割研究工作,发布距今已经很多年了,但实用性一点没打折。数据来源是美国地质调查部门的航拍影像,覆盖一片真实的城市与乡村混合区域,裁剪成一张张1500x1500的独立图片。注意,这个1500x1500不是屏幕像素那么随意,它对应的是地面约1米一个像素,每张图实际覆盖约2.25平方公里的地表范围。所以你在图里看到的房子、树木、路口都是真实世界的俯视视角,道路形态也是实打实的航拍形状。这套数据最大的价值在于“真实”。它不像很多合成数据集那样道路干净、边界清晰,航拍图里有树荫遮挡、车辆遮挡、建筑物阴影,道路宽度也不均匀,这些干扰项能让模型学到更鲁棒的特征。我第一次拿它训练时,明显感觉到比那些“精修过”的路面数据集更能暴露模型短板。1.2 标注内容与类别设计整个数据集只标一个类别:road。也就是说它是一个纯粹的“道路 vs 背景”二分类语义分割问题。标注图是逐像素的掩码,道路像素标记为前景,其余全部是背景。很多第一次接触的人会问:既然只有两类,为什么不叫目标检测或者二分类分类?因为语义分割要求给每一个像素打上类别标签,输出的是和输入图同尺寸的分割图,这点和输出box或者一个类别的任务有本质区别。这套数据的标注风格偏“粗”。道路区域是以中心线为基础扩展标注的,边界算不上精细,有的地方道路标注甚至比实际路面略宽一些。这意味着你不能拿它去追求“毫米级道路边缘”那种极致效果,但用它做城区范围的粗粒度道路提取、车道级路径规划前处理、地图要素自动更新,完全够用。对任务来说,关键是判断一个大区域里哪里是路、哪里不是,而不是把路缘石边界抠到像素级。1.3 官方划分与图片数量按照我实际下载到的版本,ImageSets/Segmentation目录下给出三个列表文件:train.txt列了809个训练集文件,val.txt列了73个验证集文件,test.txt列了172个测试集文件。有些资料里会把训练集写成1371张,这个数字差异来自官方早期发布时的不同整理版本,实际以你解压出来的文件列表为准。训练量不大,但也不是小到没法训练,配合数据增强跑一个U-Net绰绰有余。官方划分有个特点:验证集只有73张,偏少。这带来一个实际问题——单次验证的IoU指标波动会很明显,很可能这轮验证集的IoU是0.72,下一轮变成0.76,不一定代表模型真的变好了。我建议在训练中采用“训练集里再切一小部分做内部验证”或者“多次重复取平均值”的策略,后面第5章会详细展开。2. VOC格式逐目录拆解:先把数据结构摸透再用2.1 标准目录长什么样所谓VOC格式,就是经典的Pascal VOC目录组织方式。Massachusetts数据集按这个规则把图像和标注放在对应位置,目录结构如下:Massachusetts_Roads/ ├── JPEGImages/ │ ├── 10030719_15.jpg │ ├── 10030719_16.jpg │ ├── ... │ └── 25953929_22.jpg ├── SegmentationClass/ │ ├── 10030719_15.png │ ├── 10030719_16.png │ ├── ... │ └── 25953929_22.png └── ImageSets/ └── Segmentation/ ├── train.txt ├── val.txt └── test.txtJPEGImages放原始遥感图,SegmentationClass放对应的PNG掩码,ImageSets/Segmentation下放划分好的文件名列表。JPEG图和PNG掩码的文件名是一一对应的,没有坐标文件,也没有矢量边界,所有信息都体现在像素上。这个结构很轻量,做深度学习训练时直接按文件名读取就行,不需要解析XML或者JSON。有一点要注意:这个数据集不是标准的“VOC 20类”语义分割,而是只借用VOC的目录规范。所以不要想当然认为SegmentationClass里的PNG是RGB伪彩色图,它本质是单通道索引图,每个像素取值为整数类别编号。这一点坑了很多人,我在2.2节细说。2.2 标注掩码的像素值陷阱这是整个数据集最容易踩的第一个坑:用常见的看图软件或者OpenCV打开SegmentationClass里的PNG,你看到的是一张接近全黑的图。于是很多人以为自己下载坏了,或者标注丢了一半。其实没坏,只是像素值太小了。原始掩码里,背景像素值是0,道路像素值是1。在8位灰度图里,0和1显示出来都是接近纯黑,肉眼根本分不出来。必须用代码处理之后才能看清道路区域。读取和转换的推荐方式如下:import cv2 import numpy as np # 读掩码时用IMREAD_UNCHANGED保留原通道 mask = cv2.imread("SegmentationClass/10030719_15.png", cv2.IMREAD_UNCHANGED) # 有些版本掩码是单通道,有些是RGB三通道但内容相同 if len(mask.shape) == 3: mask = mask[:, :, 0] # 关键一步:把大于0的像素统一置为前景 mask = (mask > 0).astype(np.uint8) # 这样mask里就是0和1两类,直接可以参与交叉熵/one-hot计算这里为什么用“大于0”而不是“等于1”?因为不同渠道下载的版本里,道路像素值可能被存成1,也可能被某些转换工具重映射成255,甚至别的值。统一做一次二值化,能保证后面所有代码行为一致。我后期所有实验都基于这行转换,再没有出过掩码解析问题。2.3 训练列表文件的作用train.txt、val.txt、test.txt里每一行是一个不带扩展名的文件名,比如:10030719_15 10030719_16 10030719_17 ...这个列表的作用是决定哪些图进训练、哪些进验证。写Dataset类时按这个列表读名字,再拼接JPEGImages和SegmentationClass的路径。好处是官方已经帮你做了划分,不用自己临时切;坏处是如果你希望重新划分(比如我为了内部验证切了100张出来),得自己去改列表。我实际操作时习惯写一个工具函数,读取列表后过滤掉本地不存在的文件,防止某些渠道下载的数据有缺失导致训练中断。代码如下:def load_image_names(list_path): with open(list_path, "r") as f: names = [line.strip() for line in f.readlines() if line.strip()] return names3. 数据加载与预处理实战3.1 写一个标准的Dataset类基于上面的目录规则,我写了一个专门用于道路分割的Dataset类。核心逻辑很简单:读原图、读掩码、转数组、归一化、随机裁剪。重点在于三点:一是掩码二值化必须做,二是裁剪时原图和掩码要同步裁剪,三是验证集不能用随机裁剪,否则指标没法复现。import os import cv2 import numpy as np import torch from torch.utils.data import Dataset from torchvision import transforms class RoadsDataset(Dataset): def __init__(self, root, image_list, train=True, crop_size=512): self.root = root self.img_dir = os.path.join(root, "JPEGImages") self.mask_dir = os.path.join(root, "SegmentationClass") self.train = train self.crop_size = crop_size with open(image_list, "r") as f: self.names = [line.strip() for line in f.readlines()] def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] image = cv2.imread(os.path.join(self.img_dir, name + ".jpg")) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask = cv2.imread(os.path.join(self.mask_dir, name + ".png"), cv2.IMREAD_UNCHANGED) if mask.ndim == 3: mask = mask[:, :, 0] mask = (mask > 0).astype(np.uint8) if self.train: image, mask = self._random_crop(image, mask) if np.random.rand() > 0.5: image = image[:, ::-1] mask = mask[:, ::-1] else: image = cv2.resize(image, (self.crop_size, self.crop_size)) mask = cv2.resize(mask, (self.crop_size, self.crop_size), interpolation=cv2.INTER_NEAREST) image = torch.from_numpy(image.transpose(2, 0, 1)).float() / 255.0 mask = torch.from_numpy(mask).long() return image, mask def _random_crop(self, image, mask): h, w = image.shape[:2] size = self.crop_size if h <= size or w <= size: pad_h = max(0, size - h) pad_w = max(0, size - w) image = cv2.copyMakeBorder(image, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value=0) mask = cv2.copyMakeBorder(mask, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value=0) h, w = image.shape[:2] y = np.random.randint(0, h - size + 1) x = np.random.randint(0, w - size + 1) return image[y:y + size, x:x + size], mask[y:y + size, x:x + size]这段代码我实测可以直接跑。image被归一化到0到1之间的浮点数,mask保持为0和1的整数。因为只有两类,mask的shape是(H, W),直接作为交叉熵的target即可,不需要额外做one-hot。如果你在别的任务里用多类分割,需要在loss函数里自己扩展one-hot。3.2 数据增强策略怎么选很多人拿到1500x1500的图,第一反应是什么?直接resize到512或224。我劝你千万别这么干。1500x1500的图直接缩放,道路细线会被严重压缩,很多只有几个像素宽的小路直接断掉,模型学到的是“断裂的道路”而不是“连续的道路”。我的做法是训练阶段随机裁剪512x512或768x768的patch,验证阶段为了保证效率才resize到512。这个策略的效果立竿见影。随机裁剪本身既解决了尺寸问题,又天然实现了平移不变性的数据增强。增强组合上,我用了随机水平翻转、随机旋转(90度倍数)、微小的亮度对比度调整。因为遥感图本身是俯视正射图,不涉及地平线或重力方向,旋转90度是完全合理的。高斯噪声之类的不建议用,会破坏遥感影像的纹理信息,对道路分割没有明显帮助。3.3 类别不平衡问题道路在航拍图里的面积占比通常只有5%到15%,背景占比极高。如果直接用普通交叉熵,模型很容易陷入“全部预测成背景”的局部最优,因为这样loss已经很小了。我第一次实验就踩了这个坑,训练到第20轮,验证集IoU还是0,全部像素都预测成背景。解决不平衡问题有三个套路,我实验后认为可以组合使用:第一个是在loss上做文章,使用DiceLoss或者FocalLoss;第二个是在数据层面做采样,让裁剪patch尽量覆盖到有道路的区域;第三个是给交叉熵加权重,把道路类的权重调到背景类的3到5倍。我最终用的是BCEWithLogitsLoss加DiceLoss的加权组合,训练稳定后比单独交叉熵高了不少。4. 模型选型与训练配置复盘4.1 二分类分割的模型选择对于单类别道路分割,不需要特别复杂的模型结构。我对比过U-Net、DeepLabV3+、SegNet三套主流方案,最终长期使用的是ResNet34编码器加U-Net解码器的组合。原因很直接:数据集只有809张训练图,属于小样本,模型太大容易过拟合;U-Net的跳跃连接能保留更多空间细节,对道路这种细长结构特别友好。模型显存占用道路IoU表现上手难度适用场景U-Net (ResNet34)中较高低小样本、细长结构、快速迭代DeepLabV3+高高中精度优先、显存充足SegNet低一般低低显存环境验证如果显卡显存有限,用普通编码器的轻量U-Net跑512x512输入,batch size设4,8G显存是能吃得下的。DeepLabV3+效果不差,但对这个数据集的标注粗粒度来说,精度的上限受限于标注质量,花的额外成本未必值。4.2 损失函数与评估指标二分类语义分割最常用的损失函数是BCEWithLogitsLoss。它把网络的最后一层原始logits直接丢进来,内部做sigmoid再算交叉熵,数值稳定性比手动sigmoid加BCELoss更好。我实际用的加权公式大概是:bce = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([4.0])) dice = DiceLoss() loss = bce * 0.5 + dice * 0.5DiceLoss可以有效缓解类别不平衡,实现上就是对道路类算Dice系数然后取负。注意二分类Dice在预测全为背景时会出现“分母为0”的问题,实现时要加一个平滑项。评估指标建议关注道路IoU和Dice系数,而不是全局准确率。全局准确率在这个数据集上没什么意义,因为背景占90%以上,全预测背景准确率也有0.9,完全看不出模型好坏。IoU的计算方法如下:IoU = 预测道路∩真实道路 / 预测道路∪真实道路4.3 训练参数与策略我最常用的一组参数:输入裁剪尺寸512x512,batch size 8(取决于显存,8G显卡设4更稳),优化器Adam,初始学习率1e-4,训练100轮,学习率采用阶梯下降(第60轮和80轮各乘0.1)。如果换成SGD+momentum,学习率初始值可以调到0.01,配合多项式衰减节奏,效果也不错但收敛偏慢。验证频率设为每2轮一次,用验证集73张图算平均IoU。由于验证集数量少,建议同时保存“验证集IoU最高的模型”和“最近一轮模型”,防止最佳模型被中途覆盖。实测下来最好的结果通常是第70到90轮之间出现,之后波动不大。5. 踩坑实录:七个高频问题与排查思路5.1 掩码打开全黑,看不到道路原因就是道路像素值为1,肉眼无法分辨。解决方式是用代码把大于0的像素全部转成255显示,或者直接转成0/1数组参与计算。这不算数据损坏,是正常编码方式。我第一次拿到时也怀疑过数据坏了,后来统一二值化后一切正常。5.2 图像尺寸与掩码尺寸不一致某些渠道下载的包可能出现极个别图与掩码尺寸差几个像素的情况。直接训练会报维度错误。我的处理方式是在Dataset的__getitem__里加一句断言和强制resize,确保输入模型时尺寸绝对一致:if image.shape[:2] != mask.shape[:2]: mask = cv2.resize(mask, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_NEAREST)5.3 显存爆炸1500x1500直接输入大多数8G显卡是扛不住的。解决思路三种:随机裁剪512x512、减小batch size、开启混合精度训练。我实测混合精度能把显存占用降30%左右,速度还更快,是性价比最高的方案。代码上只需要在PyTorch中使用原生混合精度上下文,无需改动模型结构。5.4 验证集指标波动大73张验证图太少了,跑完一轮IoU波动能到几个百分点。我给出的解决思路:一是固定随机种子保证每次验证结果可复现;二是如果条件允许,把训练集里的100张挪出来并入验证集,重新划分;三是最终评测时多次推理取平均。另外保存模型时要求“连续三轮验证IoU都不低于历史最佳”,能过滤掉单轮偶然高分。5.5 道路分割结果断裂、不连续这是细长结构分割最容易出现的问题。后处理可以救一部分:用形态学闭运算(先膨胀后腐蚀)把小断点连起来。操作上可以用OpenCV的morphologyEx函数配合适当大小的核。我实测对512分辨率下的输出做一次5x5闭运算,IoU能提升0.5到1个百分点。更彻底的办法是增大训练时的裁剪尺寸,让模型每次看到更大范围的上下文,对长距离连续性有好处。5.6 模型全预测成背景训练初期可能会出现整体输出全为背景。除了前面说的加DiceLoss和正样本权重外,还有一个容易被忽略的点:学习率不能太大。我一开始用3e-4的初始学习率,模型在二分类上很容易陷入全背景的局部最优;降到1e-4后反而能正常收敛。原因很简单,全背景的梯度方向过于一致,学习率太大直接一步跨过最优区域。5.7 训练验证划分不当导致数据泄露如果图省事直接用全量数据不分验证集,或者划分时没有固定随机种子,容易造成同一张图同时出现在训练集和验证集,指标虚高到不真实。我最保险的做法是只用官方列表,如果要重新划分,用文件名的hash值做种子切分,保证每次运行结果一致,且同一张图不会被重复分配。问题可能原因解决方案掩码全黑像素值1显示不明显统一做大于0的二值化尺寸不一致下载版本差异resize对齐显存爆炸输入过大裁剪、小batch、混合精度指标波动验证集太小多次验证取平均、扩充验证集道路断裂细长结构+上下文不足形态学闭运算、更大裁剪全预测背景类别不平衡、lr过大DICE损失、降低学习率指标虚高数据划分泄露固定随机种子、按文件列表划分最后再分享一个我在实际使用中的体会:Massachusetts道路数据集虽然标注粗、类别少,但它极其适合作为遥感分割和细长结构分割的“试炼场”。因为数据量适中,几分钟就能跑一轮实验;因为道路形态复杂,任何模型缺陷都会直接暴露在IoU上。我后续还尝试过在这个数据集上训练一个粗分割模型,再用分割结果去指导更高分辨率影像上的道路中心线提取,效果比直接在高清图上从零训练稳定很多。如果你也打算拿它做类似的事,强烈建议先把本文对应到5.1和5.5的两个预处理细节处理好,这两处能让你少走一大半弯路。