首页 / 资讯中心 / 文章详情

航拍滑坡目标检测实战:从VOC转YOLO到滑窗推理的完整指南

航拍滑坡目标检测实战:从VOC转YOLO到滑窗推理的完整指南 ★ FEATURED ARTICLE
简介面向滑坡灾害识别与遥感目标检测研究者这份航拍影像数据集以VOC与YOLO两种格式提供目标检测标注可直接用于YOLO系列等主流检测模型的训练适用于地质灾害监测、应急救援等视觉任务的前期研究。压缩包共2000个文件以XML标注文件为主要类型另含说明文档整体大小约200.98MB。数据集中landslide类别共标注11315个矩形框对应4315张512×512分辨率的航拍图片标注采用labelImg完成数据已做增强处理有助于提升模型泛化能力。需要注意的是训练、验证与测试集未预先划分使用时可按照VOC或YOLO目录规范自行拆分。目前已有35人学习适合作为滑坡目标检测任务的基础数据资源可有效缩短数据准备与标注周期。1. 拿到 4315 张航拍滑坡数据集第一件事不是开训而是读懂格式用这个航拍滑坡数据集做目标检测大多数人会直接把 4315 张图丢给框架跑通再说。我的建议是反着来先把 zip 里的 VOC 和 YOLO 两套标注读透再动手。原因很简单这类数据集的 XML 和 txt 往往不是同一份脚本产出的命名对不上、类别名不统一、个别框坐标超界都是常态。多花二十分钟做一次体检能省掉训练中后期那些查不清的玄学问题。这篇文章会按照我自己处理这类数据的流程走一遍看懂标注、校验数据、VOC 转 YOLO、按场景划分、启动训练最后聊一聊把模型搬上整幅大图时的坑和习惯。适合刚拿到数据准备跑第一个滑坡检测模型的开发者也适合被数据格式折腾过的遥感方向工程师。2. 解压之后看懂 VOC 和 YOLO 两套标注目录结构、XML 字段与体检脚本2.1 目录怎么排、文件名怎么对先建立一一映射压缩包解压后常见的组织方式是三个目录并排放images 放航拍原图voc_xml 放 PASCAL VOC 的 XML 标注yolo_txt 放 YOLO 格式的 txt 标注。目录名称未必是这几个但逻辑基本一致先找的就是这三类文件。判断一张图有没有完整标注关键看文件名能不能一一对上s1_0001.jpg 应该同时存在 s1_0001.xml 和 s1_0001.txt前缀相同只是扩展名不同。我拿到压缩包后会先跑三条命令把三个目录的文件数分别列出来再抽查前几组文件名find . -type f | sed s#^./## | sort | head -30 echo --- 数量核对 --- ls images | wc -l ls voc_xml | wc -l ls yolo_txt | wc -l第一条命令是在数据根目录下列出前 30 个文件用来看清楚目录布局和命名规律避免后面写脚本时路径写错。后面三条分别统计图片、XML、txt 的数量如果三者数字不相等说明至少存在缺失或者多余标注需要往下排查。注意这里统计的是文件个数不是标注框个数一张图里可能有多个滑坡框那是后面的粒度。有一个细节容易被忽略如果三个目录的文件数对得上不代表 XML 和 txt 内容也一致。有的工程先把图片复制出来标注文件是从另一套流程同步的中间可能混入旧版本标注。所以文件层面核对完之后还要做内容和语义层面的校验这一步放在 2.3 节专门处理。2.2 VOC 的 XML 在描述什么object、bndbox、difficult 的语义VOC 格式的标注是单张图对应一个 XML 文件里面把整张图的基本信息和每个目标对象都写清楚了。拿一个最典型的滑坡标注 XML 来拆解annotation filenames1_0001.jpg/filename size width1920/width height1080/height depth3/depth /size object namelandslide/name difficult0/difficult bndbox xmin512/xmin ymin660/ymin xmax1284/xmax ymax910/ymax /bndbox /object /annotation这里面的字段各有用途。size里存的是整张图的宽高这个值在后面转 YOLO 格式时是归一化的分母非常重要。object节点代表一个目标name是类别名滑坡场景下通常是 landslide 或者 slump也可能混着 mudflow 之类的名字后面转换脚本里要根据实际情况做映射。bndbox里是像素级的坐标左上角 (xmin, ymin)右下角 (xmax, ymax)表示一个矩形框。difficult为 1 表示这个目标很难辨认一般训练时会被跳过VOC 官方约定是难例不参与评估。一张图里可能有一个或多个object节点转换脚本要遍历所有节点一个都不能漏。另外有些工具产出的 XML 只包含图片尺寸和文件名没有 depth 字段这种情况解析时也要兼容。YOLO 格式那边就简单得多一行一个目标每一行的内容是class_id center_x center_y width height全部是归一化数值0 0.4670 0.7269 0.4021 0.2315这五个数字和上面 XML 里的框是同一个框。中心点坐标是把 bndbox 的中心点分别除以图片宽度和高度得到的宽度和高度也是归一化后的值。正因为两套格式描述同一个几何信息转换才有意义。2.3 给 4315 张图做体检用脚本把漏标、坏图、数量不一致找出来格式看懂之后别急着转换先写一个体检脚本把整份数据过一遍。我一般在拿到任何标注数据的第一时间都会先跑这一步它挡掉了后面训练时一半以上的低级错误。下面这段脚本可以原样用# preflight_check.py import os import glob from xml.etree import ElementTree as ET image_dir images # 图片所在目录 voc_dir voc_xml # XML 所在目录 yolo_dir yolo_txt # txt 所在目录 for xml_path in sorted(glob.glob(os.path.join(voc_dir, *.xml))): root ET.parse(xml_path).getroot() filename root.findtext(filename) img_path os.path.join(image_dir, filename) txt_path os.path.join( yolo_dir, os.path.splitext(filename)[0] .txt ) if not os.path.exists(img_path): print([IMG MISSING], xml_path) continue obj_count len(root.findall(object)) if not os.path.exists(txt_path): print([TXT MISSING], filename, objects:, obj_count) continue with open(txt_path) as f: yolo_count len([l for l in f.read().splitlines() if l.strip()]) if obj_count ! yolo_count: print([COUNT MISMATCH], filename, xml:, obj_count, yolo:, yolo_count)脚本的逻辑是遍历每个 XML取出filename拼出对应的图片路径和 txt 路径然后依次判断图片是否存在、txt 是否存在、XML 里的object数量是否和 txt 行数一致。三处不满足都会打印一行带标签的输出。输出里的[IMG MISSING]、[TXT MISSING]、[COUNT MISMATCH]是为了方便 grep量大的时候可以直接按标签筛。这一步能查出命名不匹配、XML 缺对象、txt 空文件等问题。注意脚本假设 txt 每行就是一个目标如果某个 txt 用空行分隔或者某行只有类别 id 没有坐标也会造成数量对不上这是后续转换脚本要处理的范围。体检通过以后再进到 VOC 转 YOLO 那一步才安全。3. VOC 转 YOLO 格式转换脚本逐行拆解和 4 个容易翻车的细节3.1 转换公式从 bndbox 到归一化中心坐标VOC 转 YOLO 的数学关系本身很简单就是把左上角和右下角的像素坐标换算成中心点加宽高的归一化形式。假设 XML 里给出 xmin、ymin、xmax、ymax而size节点给出了图片的 width 和 height那么转换公式就是cx (xmin xmax) / 2 / width cy (ymin ymax) / 2 / height w (xmax - xmin) / width h (ymax - ymin) / height四个输出值全部落在 0 到 1 之间正好对应 YOLO txt 一行的后四个数。第一位的类别 id 由类别名映射表决定通常从 0 开始编号滑坡作为单类数据集时 id 就是 0。公式虽然简单但实际工程里出错率不低问题几乎都出在分母用错、坐标没有做边界钳制、类别名没对齐这三个地方后面 3.3 节会展开说。还有一个容易忽略的点xmax - xmin得到的是框宽但有的标注工具把xmax存成框右边像素的坐标值有的存成逻辑最大值两者在数学上差 1 像素。对归一化来说影响很小但转换的时候统一按xmax - xmin算不要在中间环节加 1 减 1否则前后工程的数据一致性很难维护。3.2 可直接跑的 voc2yolo.py注释按生产习惯给下面这段转换脚本是按生产习惯写的把难例跳过、坐标钳制、退化框过滤都放在里面。直接保存为voc2yolo.py改好目录路径就能跑#!/usr/bin/env python3 # voc2yolo.py import os import glob from xml.etree import ElementTree as ET # 类别顺序决定 id不要随意调整顺序 CLASS_NAMES [landslide] def xml_to_yolo(xml_path, out_dir): root ET.parse(xml_path).getroot() img_w int(root.findtext(size/width)) img_h int(root.findtext(size/height)) if img_w 0 or img_h 0: print([SKIP BAD SIZE], xml_path) return lines [] for obj in root.findall(object): difficult obj.findtext(difficult, 0) if int(difficult) 1: continue # 难例默认丢弃训练时容易干扰 name obj.findtext(name) if name not in CLASS_NAMES: print([SKIP UNKNOWN CLASS], xml_path, name) continue b obj.find(bndbox) xmin max(0.0, float(b.findtext(xmin))) ymin max(0.0, float(b.findtext(ymin))) xmax min(img_w, float(b.findtext(xmax))) ymax min(img_h, float(b.findtext(ymax))) box_w xmax - xmin box_h ymax - ymin if box_w 0 or box_h 0: print([SKIP DEGENERATE], xml_path, name) continue cx (xmin xmax) / 2.0 / img_w cy (ymin ymax) / 2.0 / img_h bw box_w / img_w bh box_h / img_h cls_id CLASS_NAMES.index(name) lines.append(f{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) # 一张图有至少一个有效框才输出 txt否则跳过 if lines: txt_name os.path.splitext(os.path.basename(xml_path))[0] .txt with open(os.path.join(out_dir, txt_name), w) as f: f.write(\n.join(lines)) os.makedirs(yolo_labels, exist_okTrue) for xml_path in glob.glob(voc_xml/*.xml): xml_to_yolo(xml_path, yolo_labels)脚本的核心逻辑在xml_to_yolo这个函数里。注意它先从size节点读出整张图的宽高这个值在后面做分母然后遍历每一个object跳过难例和未知类别取坐标时做了双层钳制负值归零、超出图片范围的压到边界上防止标注手滑导致归一化数值超界最后输出六位小数的空格分隔文本。我在实际处理滑坡数据时CLAS_NAMES 遇到过landslide、slump混写的情况。如果体检脚本或者转换日志里出现[SKIP UNKNOWN CLASS]说明类别名不统一需要先人工确认是不是同一类目标再合并千万不要没看日志就直接转了。3.3 转换的 4 个容易翻车的细节难例、脏坐标、类别名、分母第一个翻车点是难例的处理。VOC 里difficult等于 1 的框是标注者都拿不准的目标很多转换脚本不检查这个字段直接把所有框导出。结果是训练集里混入一批边界模糊的标注loss 会出现周期性抖动模型在一个很差的局部点上反复横跳。做法是转换时默认跳过并且打日志方便随时统计被丢弃了多少。第二个是脏坐标。有些框的 xmax 大于图片宽度或者 ymin 是负数甚至四个顶点重合。如果直接做除法归一化坐标会大于 1 或出现负值YOLO 框架读进去后损失函数容易出现 NaN。所以脚本里必须做钳制和退化框过滤打印日志后跳过。第三个是类别名映射。转换脚本用的是类名和 id 的对应关系先遍历一遍 XML把所有出现过的name统计出来人工确认后再写进CLASS_NAMES。不要想当然地认为全部叫 landslide。第四个是分母。有的转换脚本会用 bbox 内某一组坐标当分母或者直接把图片尺寸写死为 1920x1080一旦数据集里有不同分辨率的航拍图这些坐标全部会错位。必须以每张 XML 的size/width、size/height为准逐图计算这样即使数据集混入不同分辨率图片也不会出错。4. 按场景划分数据集并跑通第一个 YOLO 滑坡检测模型4.1 为什么不能全局随机划分同场景连拍会拉高虚高指标数据转换完之后下一步就是划分训练集、验证集和测试集。很多人直接random.shuffle然后按比例切片这在大多数常规数据集上没问题但航拍滑坡数据有个特殊性同一个滑坡体往往连着拍十几张图这些图的拍摄时间、角度、光照都很接近。如果随机划分把其中一部分放进训练集另一部分放进验证集验证集和训练集的相似度太高mAP 会显得很乐观模型实际换个场地立刻打回原形。这个问题在遥感目标检测里非常常见业内通常叫场景泄漏。解决办法是按场景或者按拍摄架次分组再以组为单位分配数据集合保证同一场景的所有图要么全在训练集要么全在验证集。滑坡数据集的图片文件名如果包含拍摄批次或地点前缀就能直接用来分组。常见的做法是利用文件名里的前缀比如shanxi_001.jpg和yunnan_034.jpg前缀不同就视为不同场景。4.2 组级划分脚本按前缀分组后按组拆 train / val / test下面这段脚本演示了怎么按文件名前缀做组级划分# split_by_group.py import os import glob import random from collections import defaultdict files glob.glob(images/*.jpg) groups defaultdict(list) for f in files: base os.path.basename(f) prefix base.split(_)[0] # 场景前缀按实际命名规则调整 groups[prefix].append(base) keys list(groups.keys()) random.Random(42).shuffle(keys) total len(keys) train_keys set(keys[: int(total * 0.8)]) val_keys set(keys[int(total * 0.8): int(total * 0.9)]) test_keys set(keys[int(total * 0.9):]) train_list, val_list, test_list [], [], [] for k, imgs in groups.items(): for img in imgs: if k in train_keys: train_list.append(img) elif k in val_keys: val_list.append(img) else: test_list.append(img) with open(train.txt, w) as f: f.write(\n.join(train_list)) with open(val.txt, w) as f: f.write(\n.join(val_list)) with open(test.txt, w) as f: f.write(\n.join(test_list)) print(len(train_list), len(val_list), len(test_list))脚本先按文件名里的前缀把所有图片分到场景组然后把场景组随机打乱按 8:1:1 分配到三个集合里。最后每个集合里是所有命中的图片文件名。写入的列表是相对路径的纯文件名使用时可以再拼接绝对路径这样即使移动目录也不影响数据清单。这里有两个参数需要按实际数据微调。第一个是前缀分隔符base.split(_)[0]如果文件名结构是20230101_area1_001.jpg那应该改成_.join(base.split(_)[:2])把日期和地点一起作为场景标识。第二个是比例 0.8 / 0.9如果某个场景组特别大而其他组很小用小比例会导致验证集中在某几个场景上需要先看一眼每个组的大小分布再决定。4.3 从 data.yaml 到训练命令输入尺寸、batch、轮数的取舍新版 YOLO 工程用 data.yaml 描述数据集路径和类别。对航拍滑坡检测我一般这样组织# data.yaml path: /your/abs/path/to/dataset train: images/train val: images/val test: images/test nc: 1 names: 0: landslidepath写数据集绝对路径根目录train、val、test是相对于根目录的图片目录。如果图片还没有按集合拆目录也可以用 txt 列表的写法把train指向生成的 train.txt 即可两种方式可以灵活切换。nc是类别数滑坡单类别就是 1names里的名称要和转换脚本里的类名一致。训练命令我通常这样起yolo detect train \ datadata.yaml \ modelyolov8s.pt \ imgsz1280 \ epochs100 \ batch8 \ device0 \ projectlandslide_exp \ nameexp1这条命令里值得解释的是imgsz和batch。航拍滑坡是小目标检测原始图通常很大目标框占图面积比例很低。imgsz640会把整张图压到 640x640很多小滑坡被压成十几个像素特征全丢。我一般先用imgsz1280起步显存不够就配合cache关闭或减小 batch。batch 大小受显卡显存影响8 或 16 都是可以接受的起点如果出现显存溢出优先从 8 往下砍不要先动 imgsz。epochs 取 100 是因为数据集 4315 张不算大加上验证集按场景划分后训练量进一步缩小跑 100 轮足够看到收敛趋势。如果 60 轮后验证指标已经平台期直接提前停掉。5. 滑坡检测实战避坑从 loss 不降到误检成片的 5 条排查记录5.1 现象训练 loss 收敛整幅航拍图却漏检一片训练过程一切正常loss 平稳下降验证集 mAP 也不难看但一旦把模型放到整幅航拍图上推理漏检严重。最常见的原因是训练时用低分辨率把整张大图直接压进模型滑坡小目标被压缩成几个像素模型根本没学到足够特征。其次就是验证集和训练集场景重叠给出的指标虚高。解决分两步。第一imgsz 提到 1280 及以上如果目标是几十像素级别的滑坡考虑 1536 甚至更高。第二训练数据增强里加入随机裁剪让模型在训练时看到没有缩放的局部细节。常规的全局缩放对遥感小目标来说等于数据增强都没吃到。5.2 现象路径带中文或空格训练一启动就报图片读取失败很多数据集解压后路径里有中文目录名或者空格比如D:\数据集合集\滑坡\。训练启动时报错信息类似Assertion img is not None或者数据加载进度条卡住不动。这个坑不是模型问题是 OpenCV 对非 ASCII 路径支持很差cv2.imread遇到中文路径直接返回 None。解决办法是把整个数据集迁移到纯英文路径下目录名全部改为landslide/*这一类格式。如果数据集很大不想迁移可以在自定义 Dataset 里用np.fromfile配合cv2.imdecode绕过import cv2 import numpy as np def imread_unicode(path): data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)这段函数把文件先读成字节流再解码规避了 imread 的路径问题。但我的习惯是能改路径就改路径自定义读取函数只是给存量数据兜底。5.3 现象loss 在某个 batch 出现 inf训练直接中断训练到中途突然 loss 变成 inf随后训练进度中断或验证阶段报 NaN。通常有两个来源第一是标注坐标里混入了超界值或退化框归一化后出现负数或者大于 1损失计算直接爆掉第二是类别 id 和 data.yaml 不匹配某个类别编号超过了nc数据加载时越界。处理方法是回到体检脚本和转换脚本看日志重点过滤[SKIP DEGENERATE]和[SKIP UNKNOWN CLASS]的输出检查是不是漏改了类别映射。这两个坑在训练日志里都会表现为 loss 突然异常但根源不同一个在标注文件一个在 yaml 配置。5.4 现象验证集 mAP 虚高换一批图全露馅验证集 mAP 0.9自己拍的现场数据一测几乎不行。这种场景泄漏前面已经讲过航拍连续采集的图在颜色、角度、地形上高度相似随机划分时同一个滑坡体的多张图一边在训练集一边在验证集模型等于提前见过答案。这也解释了为什么测试集指标好看但跨区域迁移能力差。解决方式是按场景划分而且验证集里尽量只放跨架次、跨时间拍的图。如果数据本身没有场景可区分只能靠硬隔离比如同一经纬度附近的图归入同一集合用经纬度聚类做划分。5.5 现象裸岩、道路和阴影边缘被大量误检成滑坡模型收敛后 precision 不高大量正检出现在裸岩、道路、建筑阴影边缘上。原因在于滑坡在视觉上确实和这些地物很像尤其是黄土高原的裸地和滑坡体颜色纹理几乎一致仅靠 RGB 图像本身就很难区分。数据集里的负样本数量和难度不够模型没有见过足够多的难负例。常规做法是把误检图抽出来裁剪成背景样本加到训练集里做第二轮微调。更工程化的方式是训练后在后处理加先验过滤比如按分类结果输出一个 mask 或者置信度阈值调高到 0.35-0.4。数据层面我一般还会统计滑坡框在图片中的位置分布如果绝大多数集中在山坡区域可以在推理时对明显不属于滑坡地形的区域直接降低评分权重。6. 把模型迁移到整幅航拍大图滑窗推理、坐标还原与全局 NMS 的落地习惯6.1 一次封装tile 推理、坐标还原和全局 NMS 的稳定组合模型训练完真正的应用场景是整幅航拍大图不是训练用的切片。大图直接丢进模型会造成显存溢出因为检测网络的输入是固定尺寸大图缩放又会丢失小目标所以行业常规做法是把大图切成 tile 推理再把结果拼回原图坐标。下面这段代码把主流程串起来import cv2 import numpy as np def tile_infer(model, big_img, tile1280, step640, conf0.25): h, w big_img.shape[:2] final_boxes [] for y0 in range(0, h - tile 1, step): for x0 in range(0, w - tile 1, step): crop big_img[y0:y0 tile, x0:x0 tile] res model(crop, imgsztile, confconf) for det in res[0].boxes: cx, cy, bw, bh det.xywh[0].tolist() # 还原回原图坐标 abs_x0 x0 cx - bw / 2 abs_y0 y0 cy - bh / 2 final_boxes.append([ abs_x0, abs_y0, abs_x0 bw, abs_y0 bh, float(det.conf[0]) ]) return np.array(final_boxes)这段代码里有几个参数是实践里反复调出来的。tile和step决定切块大小和步长步长小于 tile 意味着相邻块之间有重叠区域同一个目标可能出两次框后面需要全局 NMS 合并。step640配tile1280是重叠率 50% 的常用组合既能控制漏检又不会让推理时间翻太多倍。坐标还原的逻辑是把框的绝对坐标加回 tile 在整图中的偏移量注意遍历时x0、y0是 tile 左上角在原图中的坐标。拼接后立刻对全部框做一次 NMS。如果用 torchvision一行nms(boxes, scores, iou_threshold)就能解决。这里最容易翻车的点有两个。第一多个 tile 的框宽度高度还原后没有加回来最后画图所有框挤在左上角。第二NMS 的 iou_threshold 设太紧把相邻 tile 的真实双检框删掉设太松又保留重复框一般设 0.45 到 0.5 比较稳。我第一次跑完整幅拼接图的时候画完框发现有一半框全堆在左上角排查了半小时才意识到是坐标偏移漏加。从那以后我把滑窗推理、坐标还原、全局 NMS 封装成固定函数每次换数据集、换模型先拿一张合成图验证还原后的框位置是否正确再放心跑批量推理。这个习惯帮我省掉过很多次无效实验也希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站