ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

无人机城市图像语义分割实战:270张图用U-Net跑通完整流程

无人机城市图像语义分割实战:270张图用U-Net跑通完整流程 简介面向无人机遥感与深度学习语义分割方向的开发者和研究者这套高分辨率城市图像数据集覆盖建筑、公路等8个常见类别可用于语义分割模型的训练、验证与基准测试。数据已按训练集和验证集划分训练集包含约200张原始图像及对应的mask标签验证集约70张每张图像均提供同名PNG掩膜目录结构清晰便于直接接入现有训练流程另有classes文件说明具体类别名称。资源包为7z格式共543个文件以541张PNG数据图为主体外加1个类别说明txt和1个可视化Python脚本总大小约263.73MB。该脚本可随机抽取一张图片将原始图、真实标签及标签在原图上的蒙版效果同屏展示方便快速检查标注质量。目前已有387人学习下载适合需要现成城市语义分割数据、希望专注模型设计与调参的入门者或科研人员直接使用。1. 图像分割数据集在无人机城市识别里的真实分量270张能做什么做遥感图像语义分割的人经常忽略一件事图像分割数据集里标签的干净程度往往比模型选型更决定最终 mIoU。这份高分辨率无人机城市图像语义分割数据集用无人机低空采集城市地表按 8 个类别逐像素标注共约 270 张图像和对应的标签掩码。放在动辄上百 GB 的公开遥感数据集面前它算不上大但对入门者和中小团队来说它刚好能把“加载数据—训练模型—评估指标—切片推理”这条链路完整跑通小到一晚上能训一轮大到值得你认真调类别权重、切片尺寸和增强策略。适合想用真实城市场景练手语义分割算法的新手也适合在选型无人机视觉方案前先拿一份干净数据做算法验证的工程师。270 张图不是用来刷 SOTA 的它是用来让你把每个环节摸透的。2. 读懂8类标签与无人机影像格式先统计像素再谈训练2.1 8类城市地物的标签映射是怎么定的语义分割和图像分类最大的区别在于标签不是一张图一个编号而是每个像素一个类别。这份数据集里的 8 类常见划分方式是按城市地物的可区分度和工程价值来定背景或未分类、建筑、道路、植被、水体、车辆、行人、裸地。为什么把背景单独列一类因为无人机低空影像的边缘、阴影和遮挡区域很难归入任何语义类单独给一个类别能让模型在训练时不被这些像素的梯度干扰。拿到数据后第一件事不是看图片而是确认类别编号和像素值的对应关系。不同数据集对同一类地的编号可能完全不同有的把道路编成 0有的把建筑编成 0有的会在掩码里混入 255 表示“忽略区域”。下面这张表是我拿到一份陌生数据集时的标准检查动作类别英文标签常见像素值标注说明背景/未分类background0阴影、遮挡、边缘建筑building1屋顶、立面、施工区域道路road2路面、人行道、停车场植被vegetation3树冠、草地、灌木水体water4河流、湖泊、水池车辆vehicle5轿车、卡车、公交车行人pedestrian6行人、骑行者裸地bareground7工地、裸露土壤忽略ignore255不参与 loss 计算实际拿到的数据集可能把“忽略”也归进背景或者车辆和行人合并成一类“移动目标”。所以这张表只能当参考框架真正的类别分布要用脚本统计出来。8 类城市分割里最容易被模型忽略的是车辆和行人因为它们在高空视角下往往只有几十个像素在全图占比常常不到 1%这个问题到第 5 章会专门展开。2.2 和Cityscapes、Potsdam、DOTA这些公开集相比它特殊在哪不少人有拿现成语义分割模型直接跑无人机影像的习惯结果验证集分数不错一换新场景就翻车。原因在于数据集之间的视角和标注粒度差距很大。Cityscapes 是车载街景视角类别细到“摩托车”“自行车”但它的图像基本没有俯视畸变Potsdam 是正射影像靠多光谱和高程数据区分地物模型依赖的是光谱特征而不是几何轮廓DOTA 和 HRSC2016 这类遥感集做的是旋转框目标检测给出的是 box 而不是逐像素掩码和语义分割根本不是一个标注粒度。数据集视角标注粒度类别数与本文数据集的差异Cityscapes车载平视像素级19无俯视畸变小目标占比高Potsdam卫星正射像素级6光谱信息强无透视形变DOTA遥感俯视旋转框15检测框不含像素掩码HRSC2016遥感俯视旋转框1单类别船只检测本数据集无人机低空像素级8倾斜视角屋顶与立面共存无人机低空影像的特点是同一张图里同时出现屋顶、建筑立面和地面道路树冠阴影会打断道路的连续性车辆和行人以极小的尺度散布在画面里。直接拿 Cityscapes 预训练模型来迁移前几次训练往往会出现“道路被树影切成碎片”“车辆完全分不出来”这类问题不是模型笨是源域和目标域之间的视角差距太大。这也是为什么做无人机城市分割时我一般会先花半小时把数据分布的细节摸干净再决定用哪条技术路线。2.3 标签文件检查清单用一次像素统计看清标签是否干净拿到约 270 张图和标签后别急着切训练集。先把标签读进来做一次全局像素统计确认三件事掩码是不是单通道、类别编号是不是连续、每个类别的像素占比是否符合预期。用一段很短的程序就能完成我这里用的是 PIL 的调色板模式读取这也是最容易踩坑的一步。from PIL import Image import numpy as np from collections import Counter mask Image.open(labels/0001.png).convert(P) # 按调色板读成单通道索引图 arr np.array(mask) # 此时 shape 是 (H, W)没有通道维 print(mask shape:, arr.shape) print(unique values:, np.unique(arr)) cnt Counter(arr.flatten()) for cls_id, num in sorted(cnt.items()): print(fclass {cls_id}: {num} px, 占比 {num / arr.size:.2%})这里的关键点是convert(P)。很多格式的掩码图本身是 RGB 的三通道 PNG直接用np.array(Image.open(path))会得到 (H, W, 3) 的三维数组类别编号被拆散到三个通道里后面做 CrossEntropyLoss 一定出问题。转成P模式后每个像素只有一个整数索引。unique values的输出能帮你确认类别是否连续如果出现 255说明数据里有 ignore 区域如果出现 0、1、2、3、4、5、6、7 以外的大编号后面加一层重映射就能解决。这一轮统计还要看一个容易被忽略的细节标签和图像的文件名是否完全一一对应。270 张图里有几张缺标签、有几张标签是全黑的这类情况靠人工翻几乎看不出来但统计脚本一跑就能发现。全黑的标签说明这张图可能是采集时飞手误触或者传感器丢帧留下的直接把它从文件清单里剔除就好。我一般会把统计结果存成一个 CSV后面做训练集划分和类别权重计算时都要反复引用它。3. 把270张图切成模型能吃的样子划分、滑窗切片与类别权重3.1 用固定随机种子做训练/验证/测试划分约 270 张图不算多划分比例可以按 70% 训练、15% 验证、15% 测试来做。验证集用来盯训练过程中的 mIoU 变化测试集只在最终评估时碰一次。划分脚本里必须固定随机种子这是基本功但也是大量复现翻车的根源不设 seed每次跑出来的划分都不同今天验证集 mIoU 0.82明天同样代码变成 0.79你会误以为是模型问题。import os import random random.seed(42) # 任意固定值即可目的是让划分可复现 imgs sorted(os.listdir(images)) random.shuffle(imgs) n len(imgs) n_val int(n * 0.15) n_test int(n * 0.15) val_imgs imgs[:n_val] test_imgs imgs[n_val:n_val n_test] train_imgs imgs[n_val n_test:] print(ftotal{n}, train{len(train_imgs)}, val{len(val_imgs)}, test{len(test_imgs)})这里有个细节值得多说一句如果 270 张图来自同一次飞行的连续航迹相邻帧之间的场景高度相似纯随机划分会让训练集和验证集出现严重的“数据泄漏”验证分数虚高。常见做法是先把图像按采集批次或者区域分组再在批次级别做划分而不是在一张张图片级别做随机。先检查文件名里有没有批次信息像flight01_033.png里的flight01就是天然的分组键。如果文件名看不出分组就只能靠拍摄时间戳来分。我通常会在随机划分之外再交叉验证一次避免撞上“运气极好的切分”。3.2 滑窗切片把高分辨率影像切成GPU吃得下的尺寸无人机低空影像的分辨率常常是 4000×3000 甚至更高直接整图缩放到 512×512 塞进模型车辆、行人会被缩成几个像素基本等于从标签里消失直接整图训练显存也扛不住。业内做遥感语义分割的常见做法是滑窗切片把大图切成固定大小的 patch同时保留每个 patch 在原图中的坐标方便推理时拼回去。def slice_image_mask(img_arr, mask_arr, win512, stride256): h, w img_arr.shape[:2] out_img, out_mask, coords [], [], [] for y in range(0, h - win 1, stride): for x in range(0, w - win 1, stride): out_img.append(img_arr[y:y win, x:x win]) out_mask.append(mask_arr[y:y win, x:x win]) coords.append((y, x)) return out_img, out_mask, coords参数上最常用的组合是win512, stride256stride 取 win 的一半让相邻切片有 50% 重叠。这样设置有两个目的一是目标物体如果恰好在某张切片的边缘在下一张切片里会完整出现模型不至于总是看到“半个车”二是数据量直接乘以大约 4 倍对只有 270 张原图的数据集来说这是最便宜的数据扩充手段。如果显存只有 6Gwin 可以降到 384stride 对应减到 192但低于 384 后小目标的语义信息会明显受损。切片后的 mask 要用Image.NEAREST插值不能用双线性这点在第 4 章的 Dataset 代码里会再次强调。3.3 类别权重不要让建筑物主导你的loss函数城市地物天然不均衡一张航拍图里建筑和植被常占 60% 以上像素车辆行人不到 2%。如果不加处理模型会把所有像素都预测成“建筑”整体准确率看着很高mIoU 却惨不忍睹。处理类别不均衡最常见的方法不是过采样而是给损失函数算类别权重权重和类别像素占比成反比。def class_weight_from_masks(mask_paths, num_classes8): counts np.zeros(num_classes, dtypenp.float64) for p in mask_paths: m np.array(Image.open(p).convert(P)) for c in range(num_classes): counts[c] int((m c).sum()) freq counts / counts.sum() weights 1.0 / (freq 1e-6) weights weights / weights.sum() * num_classes # 均值归一化到 1 return weights.astype(np.float32)给频率加一个1e-6的平滑项是为了防止某个类别像素数为 0 时算出无穷大。归一化到均值 1 则是为了让加了权重之后的总 loss 量级和不加权重时接近这样学习率不用因为换权重而大幅调整。我在实际项目中会把weights打印出来看一眼如果某个类别的权重超过 20说明它在全部训练切片里占比低于 5%这时光靠权重硬拉已经不太够了得配合第 6 章的重采样或者增强来做。4. 用U-Net跑通一次最小训练模型选型、超参数与mIoU评估4.1 为什么小样本城市分割首选U-Net而不是DeepLabV3270 张图切成约 1000 个 patch对 DeepLabV3 这类带有 ASPP 空洞金字塔池化的模型来说样本量偏紧空洞卷积的大感受野需要足够多样的梯度才能学到有效的上下文小数据集上更容易过拟合到纹理上。U-Net 结构简单、参数量小跳跃连接让浅层细节直接传到解码器在屋顶边缘、道路边界这类对边界精度敏感的任务里反而占便宜。所以入门选 U-Net 是最省心的路线等把数据管线跑通了再换预训练 backbone 的 DeepLabV3 做效果对比。这里要区分一个概念YOLOv8-seg 走的是实例分割路线输出每个目标的 mask 加 box适合“找出每个具体的车”这种任务本数据集做的是逐像素地物分类每个像素只能属于一个类语义分割用 U-Net 这类模型更对口。4.2 一份能直接改着用的PyTorch训练脚本下面这份代码是我做小数据集分割时最常用的骨架包含 Dataset 定义、数据加载、损失函数和训练循环四块。数据集类里最关键的是 mask 的读取方式必须用PIL的convert(P)得到单通道索引resize 时必须用Image.NEAREST而不是默认的双线性。import torch import torch.nn as nn import numpy as np from PIL import Image from torch.utils.data import Dataset, DataLoader from torchvision import transforms class SegDataset(Dataset): def __init__(self, img_paths, mask_paths, size512): self.img_paths img_paths self.mask_paths mask_paths self.size size def __len__(self): return len(self.img_paths) def __getitem__(self, i): img Image.open(self.img_paths[i]).convert(RGB) mask Image.open(self.mask_paths[i]).convert(P) img img.resize((self.size, self.size)) mask mask.resize((self.size, self.size), Image.NEAREST) x transforms.ToTensor()(img) y torch.from_numpy(np.array(mask)).long() return x, y接着是训练循环。优化器我一般用 Adam学习率 1e-3搭配余弦退火调度器。小数据集上 Adam 比 SGD 更容易收敛因为每个 batch 的梯度方差大Adam 的自适应步长能缓解梯度抖动余弦退火让学习率在训练后期降到 0 附近对收敛稳定性有帮助。model UNet(in_channels3, num_classes8) # 具体UNet实现可替换 weights torch.tensor(class_weights, dtypetorch.float32) criterion nn.CrossEntropyLoss(weightweights) optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max60) for epoch in range(60): model.train() run_loss 0.0 for x, y in loader: x, y x.cuda(), y.cuda() pred model(x) loss criterion(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() run_loss loss.item() scheduler.step() print(fepoch {epoch}, loss {run_loss / len(loader):.4f})这个脚本里的class_weights来自第 3 章算好的类别权重数组。注意CrossEntropyLoss接收的pred是不带 softmax 的原始 logits所以模型最后一层不要接 softmaxactivationNone否则概率分布被压平后梯度的数值范围会变差。训练循环里没写验证集评估实际使用时每个 epoch 结束要调一次第 4.4 节的 mIoU 函数不然你没法判断模型是没收敛还是在过拟合。4.3 必调参数和显存预算表小样本分割的场景下超参数的经验值相对固定。以下是我在 6G 和 12G 显存两种配置下的常用起点参数建议值说明输入尺寸512×512低于 384 小目标信息丢失batch size46G/ 812G优先降 batch不要先降尺寸学习率1e-3Adam 配合 1e-3 是稳妥起点训练轮数60小数据集 50-100 轮足够损失函数带类别权重的 CE权重来自训练集像素统计调度器CosineAnnealingLRT_max 等于总轮数如果训练初期 loss 居高不下先检查 mask 读取链路和类别权重而不是调学习率。模型结构错误、标签通道错乱这类问题在学习率上找半天是浪费时间的这点做过几次就明白了小数据集上 80% 的训练失败来自数据管线15% 来自标签噪声只有 5% 来自模型结构。4.4 mIoU评估别只看整体分数语义分割的通用评估指标是 mIoU实现上很容易出错的地方在于有人直接把所有像素的预测正确的比例当成 mIoU这是像素准确率不是 IoU。正确做法是逐类计算 IoU 再取平均这样车辆行人这类小类别如果 IoU 是 0立刻就能被发现。def compute_miou(pred, target, num_classes8, eps1e-6): # pred: B,C,H,W 的 logitstarget: B,H,W pred pred.argmax(dim1) ious [] for c in range(num_classes): p (pred c) t (target c) inter (p t).sum().float() union (p | t).sum().float() ious.append(((inter eps) / (union eps)).item()) return float(np.mean(ious)), iouseps1e-6是防止某个类别在验证集里根本没有像素时出现除零。返回的ious列表要按类别打印出来看这是整个评估里最有价值的信息如果建筑 IoU 0.91、车辆 IoU 0.05说明模型完全没学会分割车辆不是“整体效果不错”能盖过去的。5. 无人机城市分割的5个常见坑从标签读取到验证集划分5.1 掩码读成RGB图训练loss虚低、mIoU为0的元凶现象训练时 loss 很漂亮地下降但验证集 mIoU 稳定在 0.1 以下打印标签数组却发现 shape 是 (H, W, 3) 而不是 (H, W)。原因用np.array(Image.open(mask))直接读掩码PIL 按 RGB 三通道读入了 PNG类别编号被拆到三个通道里。解决统一用Image.open(mask).convert(P)读取并在 Dataset 的__getitem__里断言y.shape (H, W)。这个坑几乎每个从目标检测转语义分割的人都会踩一次因为检测的标签是 XML/TXT 文本不存在通道问题图像分割数据集的掩码是图片通道语义完全不同。5.2 类别编号不连续导致CrossEntropyLoss输出NaN现象训练到第二个 epoch 时 loss 变成 NaN 或负数程序直接崩。原因掩码里有 255、254 这类填充值超过了num_classes8的索引范围CrossEntropyLoss 对越界 target 的索引操作产生非法值。解决在数据加载阶段做一次标签重映射把所有非 0-7 的像素值统一映射到合法类别。old_to_new {0:0, 1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7, 255:0, 254:0} mask_arr np.array(Image.open(mask_path).convert(P)) for old, new in old_to_new.items(): mask_arr[mask_arr old] new这里的映射策略是把 255 和 254 归入背景类对城市分割场景来说是合理近似因为这些像素多半是标注时留下的空白边缘。如果这类填充像素占比很高把它们归入背景反而能让背景类的统计更真实。重映射要在统计类别权重之前完成否则权重算出来也是错的。5.3 验证集里小类都没出现结果虚高、上线露馅现象验证集 mIoU 0.89看起来很理想但把模型放到新的无人机图上跑车辆和行人几乎全部漏检。原因划分训练/验证集时用了纯随机而包含车辆行人的图像只占全部的很小一部分随机切分把带小目标的图全分进了训练集验证集里其实没有小目标可测。解决按场景或拍摄批次先分层再在每个分层内部做随机划分确保验证集包含车辆和行人的样本另外评估时把 8 个类别的 IoU 分别打出来而不是只看 mIoU 这个平均值。5.4 滑窗切片把目标切成两半重叠和投票是双重保险现象训练集里车辆和路灯的 IoU 一直很低检查切片发现大量目标正好被裁在切片边缘只剩半个轮廓。原因切片时用了stridewin相邻切片没有重叠目标只要稍微越界就被拦腰斩断。解决切片时改用stridewin/2推理时在重叠区域做预测投票多个切片的预测结果按像素取平均再 argmax。只要训练和推理都用同样的滑动参数模型看到的和最终拼图时面对的形态是一致的。5.5 数据增强只对图增强、没对掩码增强松动的标签比噪声还可怕现象训练 loss 曲折下降但验证集边界预测出现锯齿状错位屋顶和植被的交界处频繁串类。原因自己做随机旋转、翻转时只有图像做了变换掩码没跟着转标签和图像的几何关系错位。解决不要手写几何变换直接用 albumentations 的 Compose它定义image和mask两个输入自动保证同一套随机参数同时作用到图和掩码上。这也是我在第 6 章数据增强部分选择 albumentations 的原因。6. 进阶玩法预训练backbone、同步增强与大图拼接推理6.1 用ImageNet预训练的backbone给270张图续命小数据集最怕随机初始化带来的冷启动。常见做法是用带 ImageNet 预训练权重的 resnet34 当 U-Net 的编码器解码器保持随机初始化。具体实现上我一般直接用 segmentation_models_pytorch 这类封装好的库省去手动截断 resnet 的麻烦import segmentation_models_pytorch as smp model smp.Unet( encoder_nameresnet34, encoder_weightsimagenet, classes8, activationNone, )activationNone是因为后面接CrossEntropyLoss原始 logits 直接进损失函数。预训练编码器的学习率可以设得比解码器低一半常见做法是编码器 5e-4、解码器 1e-3避免预训练权重被小数据集的噪声梯度冲乱。6.2 增强组合同步处理图像和掩码的albumentations配置数据增强对 270 张原始图的价值在于它把有限的场景变成多种光照和视角下的多样样本。我这里常用的组合是随机裁剪、翻转、旋转和颜色抖动import albumentations as A from albumentations.pytorch import ToTensorV2 train_aug A.Compose([ A.RandomCrop(512, 512), A.HorizontalFlip(p0.5), A.RandomRotate90(p0.5), A.ColorJitter(brightness0.2, contrast0.2, saturation0.2, p0.5), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ])RandomRotate90对城市俯视图场景特别合适因为屋顶和道路的方向本身不具有固定的重力方向ColorJitter模拟不同光照条件无人机在不同时间拍摄的图像亮度差异很大。注意不要把RandomCrop的尺寸设成 256小目标在 256 的 patch 里只剩十几个像素切割本身就成了信息损毁。这个 Compose 在调用时以image... , mask...传入几何变换会自动同步到掩码上。6.3 大图推理的拼接与翻转TTA验证训练完成后真正要部署到一张完整无人机航拍图时不能直接整图缩放预测。先把图切成与训练时相同的 512×512 patch预测得到每个 patch 的类别概率再按切片坐标把概率图拼回原图大小重叠区域取平均最后对整个概率图做 argmax。跑一次完整航拍图的拼接推理后把输入做水平翻转再预测一次两次预测的概率取平均如果 mIoU 只提升 0.2-0.5 个百分点说明模型已经比较稳了如果提升超过 2 个点说明模型对方向敏感翻转应作为推理时的固定环节。这是我的一个根深蒂固的习惯只要换了新数据集先在训练前把标签可视化叠加到原图上逐张翻看 8 个类别的标注是否对齐这样的预览只花十几分钟却能在训练前发现大多数标注错位和格式问题。靠着这个习惯我在 270 张这样的小样本集上避开过很多次整晚训练的返工也建议你下一次训练前先做这一步。希望帮到你。本文还有配套的精品资源点击获取
返回列表