简介:输电线路螺栓销钉缺失检测图像数据集专注电力设施智能巡检场景,面向计算机视觉研究者与电力运维开发人员,旨在解决销钉缺失这一高危隐患的自动识别问题。数据集共2000个文件、约89.52MB,包含791张高清JPEG巡检图像及1209个PASCAL VOC格式XML标注文件,XML记录目标边界框与类别信息,覆盖正常与缺失两类状态,可直接用于YOLOv7等目标检测模型的训练。图像以大目标为主,清晰呈现螺栓销钉的局部细节,配合labelimg工具生成的规范标注,便于开展模型微调与精度对比。基于该数据训练的目标检测模型mAP可达93.7%,在输电线路缺陷识别中具有较高实用价值。目前已有1203人学习下载,适合希望快速搭建电力视觉检测方案的研究者使用。
1. 输电线路螺栓销钉缺失检测,为什么值得用这份1209张大目标VOC数据集
一条110kV线路一年拍回来的精细化巡检照片少说几万张,里面最常见的金具缺陷之一就是螺栓销钉缺失。销钉一旦脱落,螺栓会在风振下逐渐退出,严重时整套悬垂线夹脱开,这是巡线老师傅最怕的“暗病”。人工盯着屏幕找这种小目标非常容易疲劳,视觉检测在这个环节几乎是刚需。这份《输电线路螺栓销钉缺失检测图像数据集(1209张,大目标,VOC)》就是为近距离精细化巡检场景准备的:1209张图,目标在画面里占比大,标注使用Pascal VOC格式(大家常说的VOC),图像和XML分离,能直接套YOLO、mmdetection、RT-DETR这类主流框架。用它来做迁移学习验证、算法选型和现场demo,是那种“拿来就能跑”的数据底座。
2. 把VOC标注盘清楚:目录结构、XML字段与一次数据体检
拿到数据集先别急着转格式,先花半天把VOC的家底摸清楚。VOC全称Pascal VOC,是视觉领域流传最广的目标检测标注格式之一。它不像LabelMe那样一张图一个JSON,而是把标注拆成三块:图像目录、XML标注目录、划分清单目录。LabelImg标注器默认也输出这套结构,所以很多电力线路数据集都长这样。
dataset/ ├── JPEGImages/ │ ├── IMG_0001.jpg │ ├── IMG_0002.jpg │ └── ... ├── Annotations/ │ ├── IMG_0001.xml │ ├── IMG_0002.xml │ └── ... └── ImageSets/ └── Main/ ├── train.txt ├── val.txt ├── trainval.txt └── test.txtImageSets/Main下的txt每行是一个去掉扩展名的图像文件名,训练框架按这些文件名去找JPEG和XML。有的数据集没有test.txt,只给trainval,这都正常,划分比例以实际拿到手为准。重点检查的是:train.txt和val.txt之间有没有文件名重叠,以及这些文件名是不是同一个拍摄场景的连拍。
打开一个XML,典型结构长这样:
<annotation> <folder>JPEGImages</folder> <filename>IMG_0001.jpg</filename> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <object> <name>bolt</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>512</xmin> <ymin>360</ymin> <xmax>640</xmax> <ymax>450</ymax> </bndbox> </object> </annotation>size里的宽高一定要和JPEG实际分辨率对齐,否则后面归一化坐标会全部错位。object里的name是类别名,这个数据集里到底写的是bolt、pin还是missing,不要靠猜,要把所有XML里的name枚举一遍。truncated表示目标被图像边界截断,difficult表示难例,VOC官方评测里difficult=1的目标不计入AP,主流训练框架基本也不读这个字段,但如果你发现数据里大量框被截断,训练时要留意边界框质量。
有一个根目录下常用的“体检”脚本,我用它来判断这份数据的类别分布和目标大小:
import os import xml.etree.ElementTree as ET from collections import Counter, defaultdict ann_dir = "Annotations" img_dir = "JPEGImages" cls_counter = Counter() area_ratio = defaultdict(list) img_size_set = set() per_image_obj = [] for xml_name in os.listdir(ann_dir): if not xml_name.endswith(".xml"): continue tree = ET.parse(os.path.join(ann_dir, xml_name)) root = tree.getroot() size = root.find("size") img_w = int(size.find("width").text) img_h = int(size.find("height").text) img_area = img_w * img_h img_size_set.add((img_w, img_h)) objs = 0 for obj in root.iter("object"): name = obj.find("name").text cls_counter[name] += 1 bnd = obj.find("bndbox") xmin = int(bnd.find("xmin").text) ymin = int(bnd.find("ymin").text) xmax = int(bnd.find("xmax").text) ymax = int(bnd.find("ymax").text) box_area = (xmax - xmin) * (ymax - ymin) area_ratio[name].append(box_area / img_area) objs += 1 per_image_obj.append(objs) print("图像尺寸种类:", img_size_set) print("类别分布:", dict(cls_counter)) print("平均每图目标数:", sum(per_image_obj) / len(per_image_obj)) for name, ratios in area_ratio.items(): sorted_r = sorted(ratios) mean_r = sum(ratios) / len(ratios) median_r = sorted_r[len(sorted_r) // 2] print(f"{name}: 相对面积均值 {mean_r:.3f}, 中位数 {median_r:.3f}, 最大 {max(ratios):.3f}")这段脚本用ElementTree解析XML,没有外置依赖,跑完能回答三个问题:类别有几种、每类目标多少、目标在图像中的相对面积有多大。相对面积是判断“大目标”的直接依据。如果用这个脚本算下来大部分目标的相对面积中位数超过0.05,那就是名副其实的大目标;如果普遍超过0.15,说明拍摄距离很近,模型输入分辨率可以适当降低,或者不需要在训练时做大量尺度增强。相反,如果中位数只有0.01左右,那整个训练策略要换成小目标方案,比如更高输入分辨率、更大程度的数据增强、专门的切片检测。
体检还有一个容易被忽略的项:每张图像里的目标数量。脚本里per_image_obj记录了每张图的框数,你可以算一下p90。如果p90大于5,说明存在密集金具场景,训练时NMS的IoU阈值要适当调高一点,否则邻近目标会被压成同一个框。如果大量图像只有1个目标,那模型学到的主要是“金具局部特写”,现场部署时遇到“一串绝缘子多个金具”的全局画面就比较容易误检,这个要在后面评估阶段专门测。
最后确认标注策略:有的数据集只标注“完好的螺栓/销钉”,缺失的销钉位置不画框,模型只能学到“有”,判断“无”要靠背景推理;有的数据集把缺失位置也显式标成一个类别,比如missing或者defect,模型就能直接输出“这里缺了一个销钉”。这两种方案训练出来的模型行为完全不同,所以拿到数据的第一件事就是分类别统计,不要急着进训练流程。确认清楚之后,才进入转换环节。
3. VOC转YOLO与数据集划分:转换脚本和分组策略
VOC格式虽然通用,但YOLO系列训练时读取的是txt标签:每行一个目标,格式是“类别id cx cy w h”,坐标全部归一化到0~1。这个转换本身不复杂,但坐标边界和类别顺序两个地方容易出幺蛾子。我一般先把VOC统一转成YOLO格式,后续换模型、做坏例分析都方便。
import os import xml.etree.ElementTree as ET # 类别顺序是训练时的id依据,和后面data.yaml里的names必须完全一致 class_names = ["bolt", "pin", "missing"] def voc_to_yolo(xml_path, out_dir, eps=1e-6): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") img_w = int(size.find("width").text) img_h = int(size.find("height").text) lines = [] for obj in root.iter("object"): name = obj.find("name").text if name not in class_names: print(f"跳过未登记的类别: {name}, 文件: {xml_path}") continue bnd = obj.find("bndbox") xmin = max(int(bnd.find("xmin").text), 0) ymin = max(int(bnd.find("ymin").text), 0) xmax = min(int(bnd.find("xmax").text), img_w) ymax = min(int(bnd.find("ymax").text), img_h) w = xmax - xmin h = ymax - ymin if w <= 2 or h <= 2: continue # 过滤标注误差产生的退化框 cx = (xmin + xmax) / 2.0 / img_w cy = (ymin + ymax) / 2.0 / img_h nw = w / img_w nh = h / img_h # 归一化坐标保护:等于1.0时部分框架读取会出数组越界 cx = min(max(cx, 0.0), 0.9999) cy = min(max(cy, 0.0), 0.9999) nw = min(nw, 0.9999) nh = min(nh, 0.9999) lines.append(f"{class_names.index(name)} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}") if lines: base = os.path.splitext(os.path.basename(xml_path))[0] with open(os.path.join(out_dir, base + ".txt"), "w") as f: f.write("\n".join(lines)) # 示例用法 for xml_name in os.listdir("Annotations"): if xml_name.endswith(".xml"): voc_to_yolo(os.path.join("Annotations", xml_name), "labels")坐标一定要用XML里size的宽高做分母,不能假设所有图都是同一个分辨率。这个数据集虽然是“大目标”,但标注时偶尔会把框画到图像边界外一点点,转出来的坐标会超过1.0,训练时某些数据加载器会报IndexError,所以我在这里统一做了截断。w或h小于2像素的框直接丢弃,这种框在训练时只会引入噪声。
转换完还要做一件小事:统计labels目录下txt的数量,应该和Annotations里的xml数量一致,不一致就查文件名匹配。再检查有没有空txt文件,有空的说明该图没有目标,这在检测训练里是合法负样本,不需要删除,但你要知道它的存在。
转完之后是划分。很多新手直接random.shuffle然后按8:2切,这在螺栓销钉数据集上会制造一个假高分:同一杆塔同一个金具的三张连拍,一张进train两张进val,模型在val上等于“见过”目标,跑到0.95的mAP也说明不了现场能力。我常用的做法是先看文件名能不能分组。
import os import random from collections import defaultdict ann_dir = "labels" all_ids = [f[:-4] for f in os.listdir(ann_dir) if f.endswith(".txt")] # 策略1:纯随机划分,适合文件名没有场景语义的情况 random.seed(42) random.shuffle(all_ids) n = len(all_ids) train_ids = all_ids[:int(n * 0.7)] val_ids = all_ids[int(n * 0.7):int(n * 0.85)] test_ids = all_ids[int(n * 0.85):] # 策略2:按文件名前缀分组,避免同一场景同时进train和val groups = defaultdict(list) for file_id in all_ids: # 以最后一个下划线前的部分作为组键,比如 "Tower001_angle1_0001.jpg" -> "Tower001_angle1" prefix = file_id.rsplit("_", 1)[0] groups[prefix].append(file_id) group_names = list(groups.keys()) random.seed(42) random.shuffle(group_names) group_count = len(group_names) train_groups = group_names[:int(group_count * 0.7)] val_groups = group_names[int(group_count * 0.7):int(group_count * 0.85)] test_groups = group_names[int(group_count * 0.85):] train_ids = [fid for g in train_groups for fid in groups[g]] val_ids = [fid for g in val_groups for fid in groups[g]] test_ids = [fid for g in test_groups for fid in groups[g]] def write_list(ids, path): with open(path, "w") as f: f.write("\n".join(ids)) write_list(train_ids, "train.txt") write_list(val_ids, "val.txt") write_list(test_ids, "test.txt") print(f"train {len(train_ids)} val {len(val_ids)} test {len(test_ids)}")分组键怎么取取决于文件名规律。如果命名是“杆塔号_设备名_序号”,那prefix取到设备名这一级是最好的,同一个设备上的螺栓销钉不会跨集合。如果命名是无序hash或者纯流水号,就只能用策略1随机切,但要在验证时多留个心眼,抽几张val图看是否和train图高度相似。
大目标场景还有一个专门针对anchor的处理:COCO预训练模型自带的anchor偏中小目标,直接拿来跑“大目标”会浪费大量anchor容量。跑一次k-means聚类,重新生成适合这份数据的anchor:
import os import numpy as np from sklearn.cluster import KMeans all_wh = [] for txt in os.listdir("labels"): with open(os.path.join("labels", txt)) as f: for line in f: parts = line.strip().split() if len(parts) == 5: w = float(parts[3]) h = float(parts[4]) if w > 0.01 and h > 0.01: all_wh.append([w, h]) all_wh = np.array(all_wh) kmeans = KMeans(n_clusters=9, random_state=42, n_init=10, max_iter=300) kmeans.fit(all_wh) anchors = kmeans.cluster_centers_ * 640 # 训练输入分辨率,按你的imgsz改 anchors_sorted = sorted(anchors.tolist(), key=lambda x: x[0] * x[1]) print("重新聚类的anchors:", [[int(w), int(h)] for w, h in anchors_sorted])这里的输入是归一化宽高,乘640是因为我准备用640训练。如果最终imgsz用1280,要重新乘1280。聚类结果如果全是(50,60)到(200,180)这种大框,说明原数据集确实大目标占比极高,那就在模型的yaml文件里把anchors替换成这组值,同时把scale_anchor的遗传进化也放开。要注意的是,anchor只影响召回上限,对已经够大的大目标帮助有限,真正的收益在训练速度上:模型不会浪费时间在小anchor上生成大量无效预测。
转完、切完、聚完类,训练前的准备才算闭环。一个容易漏的检查是:打开train.txt和val.txt,随机各挑三行对应的图像,人工看一下内容是否来自同一场景。这个动作只要五分钟,但能避免训练完才发现验证集“污染”。
4. YOLOv8微调训练:大目标场景的参数取舍与评估指标
我一般用YOLOv8s作为默认底座。为什么不是n?n的参数量太少,螺栓销钉在复杂背景下的边缘纹理和金属反光它学不扎实;为什么不是m?这份数据只有1209张,模型大了过拟合来得太快。如果手上有COCO预训练的yolov8s.pt,就用它做起点,千万不要从头训练,1209张从头训练是纯折磨。
先把data.yaml写好:
path: /path/to/dataset train: train.txt val: val.txt test: test.txt names: 0: bolt 1: pin 2: missingnames的顺序必须和前面转换脚本里的class_names完全一致,不然训练时类别id对不上,推理结果会串类。写完data.yaml,用YOLOv8的命令行直接开训练:
yolo train \ model=yolov8s.pt \ data=data.yaml \ epochs=200 \ imgsz=640 \ batch=16 \ lr0=0.005 \ lrf=0.01 \ mosaic=0.0 \ close_mosaic=0 \ degrees=5 \ hsv_h=0.01 \ hsv_s=0.2 \ hsv_v=0.4 \ fliplr=0.5 \ flipud=0.0 \ patience=30 \ device=0mosaic=0.0是全命令里最关键的改动。YOLOv8默认开启mosaic,训练时把四张图拼成一张。这对小目标数据集是神技,对这份“大目标”数据却是反向操作:一个占画面15%的销钉被mosaic切成四块之后变成四个小碎片,模型学到的尺度分布完全乱了。销钉缺失检测需要看“这个位置是不是空着”,一个被裁掉一半的螺栓根本没有上下文,模型只能靠猜。所以直接关掉,代价是数据多样性下降,但配合后面的HSV增强够用了。
其余参数逐个说。degrees=5表示允许正负5度随机旋转,模拟无人机云台的俯仰抖动;超过15度会把“朝上安装的销钉”变成“朝下悬挂”,金具的安装方向有语义,不能乱转。fliplr=0.5可以开,导线和金具在左右方向上近似对称;flipud=0.0注意不要开,垂直翻转会让重力方向颠倒,模型会把“上方挂点”和“下方挂点”学混。
色彩增强上,hsv_h只给0.01,金属件的颜色不是稳定特征,色相扰动大反而引入噪声。hsv_s给0.2,饱和度稍微扰动一下。hsv_v给0.4是重点,电压线路现场最常见的翻车场景就是逆光、晨昏、金属反光,把亮度对比度扰动拉大,等于免费给模型加了“抗逆光”训练。
学习率从默认的0.01降到0.005,因为数据量小,迁移学习时步子迈大了容易把预训练特征冲掉。batch=16在24G显存上跑YOLOv8s没问题,如果只有12G显存就降batch=8,同时把lr0降到0.002,不要只降batch不动学习率,否则收敛会非常不稳。epochs写200但patience=30,模型通常在50~80轮就收敛了,后面基本在过拟合边缘,早停能帮你省时间。
下面这张参数表是我在这个场景下的默认配置,直接抄作业问题不大:
| 参数 | 建议值 | 理由 |
|---|---|---|
| imgsz | 640 | 大目标场景够用,超过1280训练时间翻倍 |
| batch | 16 | 24G显存可跑,显存小的降到8 |
| mosaic | 0.0 | 防止大目标被切碎导致语义破坏 |
| degrees | 5 | 模拟云台小角度抖动 |
| hsv_v | 0.4 | 增强对逆光和反光的鲁棒性 |
| fliplr | 0.5 | 左右对称可翻转 |
| flipud | 0.0 | 保持重力方向语义 |
| lr0 | 0.005 | 小数据迁移学习不宜大 |
| patience | 30 | 早停防过拟合 |
训练过程中看日志不要只看mAP,重点观察train/loss和val/loss的间距。如果val/loss连续20轮不降而train/loss还在匀速下降,这是过拟合的典型信号。哪怕patience没触发,也可以手动ctrl+c,把最后一次下降的权重挑出来验证。
训练完的评估命令:
yolo val model=runs/detect/train/weights/best.pt data=data.yaml imgsz=640 conf=0.25 iou=0.45输出里mAP50和mAP50-95都会给。这份数据是大目标,mAP50刷到0.95以上很正常,别高兴太早,这是目标大带来的福利。我更看重混淆矩阵里missing类的recall,也就是漏检率。漏检率等于1减去recall,计算公式是FN除以TP加FN。在输电线路缺陷检测里,漏掉一个缺失销钉意味着可能发展成掉线事故,这个数比mAP重要得多。在验收时如果只能报一个指标,我就报“missing类在conf=0.25下的召回率”。
如果训练完发现missing类的recall明显低于bolt和pin,不要马上调阈值,先回数据层面查原因。可能是缺失样本数量太少,模型把“空位”当成了背景;也可能是标注不一致,同一个空位有的标了有的没标。数据层面的问题靠调loss和阈值救不回来,反而会把模型权重带歪。
我自己还会额外做一步:把val集预测结果里置信度在0.25到0.45之间的框全部挑出来,按“人工该不该认”的标准筛一遍。这个区间的框是模型认知最模糊的地方,也是现场误检和漏检的高发带。如果很多低置信度框其实是对的,说明模型学到了特征但不够自信,考虑在后面的训练里把这部分图加权重重训;如果低置信度框全是背景噪声,那说明现场部署时把conf阈值往上提到0.35更安全。这一步没有现成命令,需要写几行脚本读取val的预测结果和高保真标注做IoU匹配,但它是整个训练流程里性价比最高的一次人工介入。
超参数不是一次就能定死的。第一次跑完之后,把预测最差的那批图打印出来,看看是逆光、过曝、遮挡还是密集排列,再回头调对应的增强参数。YOLO的参数组合是玄学的地方很多,但大目标场景的坑相对集中:关mosaic、控旋转、拉对比度,这三板斧能解决八成问题。
5. 五个必踩坑点:从数据重叠到金属反光的排查记录
这一章的坑一半是我自己踩出来的,一半是看群里同行翻车后总结的。每一条都按“现象、原因、解决”写清楚,训练前扫一遍能省下不少冤枉时间。
5.1 验证集分数虚高到0.98,现场却一塌糊涂
现象:训练日志里val mAP50漂亮得吓人,0.98甚至0.99,但拿另外一段线路的照片一测,漏检和误检全出来了。跑去复查标注,发现XML本身没有大问题。
原因:划分数据集时用了纯随机,同一杆塔同一个金具的多张连拍被拆进了train和val。模型在val的图上几乎就是“开卷考试”,它见过同一个螺栓的另一个角度,分数自然虚高。这是小数据集最常见的隐形污染。
解决:回到第3章的按前缀分组划分,至少保证同一个设备的图像只出现在一个集合。如果文件名没有分组语义,那就手动抽看50张val图,凡是发现和train图像内容几乎一样的,把后出现的挪到train或者直接删掉。数据量小的时候,宁缺毋滥。
5.2 mosaic开启后,模型学会了看“半截销钉”
现象:不关mosaic训练,loss能正常下降,但val的混淆矩阵里missing类recall特别低。打印bad case发现模型在画了半个螺栓的图上疯狂漏检。
原因:大目标被mosaic的四图拼接切成碎片,模型被迫用“半个目标”来做特征。如果目标的语义信息依赖整体结构——比如销钉的“缺失”需要对比两侧安装位——切碎后模型根本无法判断。这在视觉上比小目标更伤,因为小目标本来就只有几十个像素,切碎后影响不大;而大目标一旦被切成残块,训练分布和真实场景严重偏移。
解决:mosaic直接设0。如果确实需要mosaic增加多样性,可以留0.3以下,但我个人在这个场景里完全不用。Ultralytics默认的close_mosaic参数是训练最后10轮自动关闭mosaic恢复真实分布,如果开了mosaic,这个参数一定要保持默认或更大。
5.3 金属反光成了模型的“盲区”
现象:晴天顺光拍的照片检测效果很好,一到早晨或傍晚、逆光或者刚刚下过雨的潮湿天气,漏检率明显上升。尤其是不锈钢材质的螺栓,表面反光在图像里形成一片高亮噪点,目标边缘和背景糊在一起。
原因:训练数据大部分是白天顺光采集,亮度分布单一。模型在高亮区域学到的特征是“这里有反光”,而不是“这里有螺栓”。加上线夹周围的金属表面会互相反射,形成类似镜面的纹理,模型倾向于把高光区域整体划为背景。
解决:训练时加大hsv_v扰动,把亮度范围拉到0.2到0.6之间,模拟不同光照。更激进的做法是在在线增强里加入随机高斯模糊或者轻微过曝,模拟雾天和晨昏。但要记住,推理时不要自己做CLAHE或直方图均衡,除非训练时也用了同样的预处理,否则分布不一致,效果只会更差。
5.4 缺失销钉的样本太少,模型只会找“有的”不会找“没的”
现象:val结果里missing类的precision很高但recall很低。换句话说,模型一旦判定“缺失”,基本都对;但它漏掉了一半真正缺失的位置。从混淆矩阵看,那些漏掉的缺失目标都被预测成了背景。
原因:这类数据集的天然问题是正负样本失衡。完好的螺栓销钉在每张图里都能看到,但缺失的位置往往只在少数缺陷图里出现。模型训练的绝大多数梯度来自“背景认识”和“正常目标定位”,对缺失位置缺少直接监督。
解决:从两个方向补。数据层面,把缺失位置的框从原本的“不标注”改成显式标注成missing,哪怕手动补标100张,也比多训500张完好的图有价值。训练层面,不要盲目调低conf阈值来提高召回,那样会把整图的背景噪声一起拉进来;应该针对missing类做二次微调,或者用难例挖掘:第一次训练完,把所有漏检的缺失图片挑出来,和原始数据混合后重训一版。
5.5 归一化坐标刚好等于1.0,线上推理时数组越界
现象:转换后训练一切正常,但部署到某些边缘端框架或者用部分开源推理库时,偶尔报IndexError,或者预测框的中心点跑到图像外面去。
原因:标注框的xmax或ymax恰好等于图像宽度或高度时,除以宽高后得到1.0。有些数据加载器和推理库内部会在整数化坐标时越界。VOC的边界是像素坐标,从0到width,所以这个情况并不罕见。
解决:转换时做边界保护,把所有归一化坐标限制在0.0001到0.9999之间,已经在第3章的转换脚本里写过了。实际部署时还要在模型输出后做一次clip,防止NMS出来越界框。这一条是纯工程细节,但它能解释为什么同一个模型在不同推理框架上表现不同。
6. 训练后的验证习惯:可视化假阴性与边缘端部署前检查
训练跑完,指标好看,不代表可以上线。我自己的习惯是建立一个hard_examples目录,每次训练结束后,把val集里所有与GT匹配不上的预测框按置信度排序输出。具体做法是:跑一遍val预测,把每张图的GT框和预测框做IoU匹配,IoU小于0.1且置信度低于阈值的GT框就是假阴性,对应那张图就是需要人工复审的漏检样例;把漏检样例的图像和预测结果保存到hard_examples,按线路名和拍摄日期归档。
这个动作的意义在于把模型的弱点变成可跟踪的列表。第一次训练完,hard_examples里通常是逆光图、密集金具图和缺失样本图;补标、重训一轮之后,列表应该明显变短。如果某一类图像永远在列表里出现,就说明训练分布和真实场景存在系统性偏差,不是调阈值能解决的。迭代几轮之后,这份数据集的价值会从1209张原始图慢慢扩展成一套“原始数据加难例库”的组合,比单纯堆张数有意义得多。
部署前还有一个检查:ONNX导出和阈值设置。训练结束后用如下命令导出ONNX:
yolo export model=runs/detect/train/weights/best.pt format=onnx opset=12导出时opset不用追新,12够用。边缘端推理时conf阈值我一般从0.25起步,但输电线路项目我会看hard_examples里的假阴性分布:如果假阴性集中在0.2~0.3置信度,就不要把阈值提得太高,宁可让现场多留几个待复核框;如果假阳性集中在低置信度,那就把conf调到0.35以上,减少无效报警。NMS的IoU阈值保持0.45左右,金具彼此靠近的密集场景可以降到0.4,避免邻近目标被合并成一个框。
大目标场景还有一个现场后处理技巧:根据无人机拍摄距离,设置一个最小检测框面积阈值。比如在10米拍摄距离下,一枚标准销钉在1080p画面里至少占500像素,那么推理阶段可以过滤掉所有面积小于某个下限的预测框。这个后处理能挡住大部分把远处导线、背景纹理当目标的小框误报。阈值怎么定,从现场实拍图里量几个框就知道,不要拍脑袋。
最后讲一个我自己的教训。有一版模型mAP50跑到了0.97,我以为稳了,拿去现场测试,结果连续漏掉两个被绝缘子串遮挡的缺失销钉。后来把假阴性列表拉出来才发现,模型对“遮挡一半的缺失位”完全没有泛化能力,因为训练数据里这样的样本太少。从那以后,我把hard_examples当成模型健康度指标,每次迭代先看列表长度,再看mAP。这个习惯救了我不止一次。这份1209张的VOC数据集本身是个好起点,但真正让它发挥价值的是你围绕它建起的数据闭环:持续收集现场难例、人工确认、重组训练集。希望帮到你。
本文还有配套的精品资源,点击获取