简介:这份文档面向电力设备检测方向的研究人员与工程师,系统整理了一套电力场景电气设备红外图像变压器检测数据集说明,可用于目标检测模型的数据准备与实验设计。文档对应资源包仅含1个docx文件,大小约1006KB,篇幅集中介绍数据集的构成、标注格式与类别统计。数据集共收录4271张红外图像,每张图像配套VOC格式xml与YOLO格式txt标注,覆盖ACB、CT、Connection、LA、LBS、MCCB、PT、VCB、body、core等14个类别,合计11896个矩形标注框,采用labelImg完成标注,便于直接迁移到YOLO系列或Pascal VOC流程。文中还给出各类别框数分布与使用边界,并说明不对训练所得模型或权重精度作保证。已有101人学习,适合需要快速了解数据规模、类别长尾与标注规范,进而评估其是否适用于变电站设备缺陷检测与异常状态识别任务的读者。
1. 红外图里的变压器检测:4271 张、14 类数据先看清再动手
拿到这份电力场景电气设备红外图像数据集,第一反应别急着喂给检测器。它名字里带“变压器检测”,但 14 个类别标的是开关柜内的设备与部件:ACB、CT、PT、VCB、MCCB、LA、LBS、MOF、PF、body、core,以及三个拼写相近的连接器类。4271 张 jpg、4271 个 xml、4271 个 txt,共 11896 个矩形框,VOC 与 YOLO 双格式并存,用 labelImg 画的框。适合做电力设备红外目标检测的基线训练、类别长尾实验和现场巡检算法的预研;不适合直接拿来做“变压器本体缺陷诊断”,因为标注体系里没有缺陷类型,只有设备位置。先看清类别账,再谈训练,能省掉很多返工。
2. 标注体系拆解:三个 connection 类与 11896 个框的分布账
2.1 三个 connection 类:拼写差异背后是标注批次问题
这份数据最需要先处理的是类别名。Connection有 3961 个框,connection有 1952 个框,Conection有 471 个框。前两个只差大小写,第三个少了一个n,中文语义都指向“连接器”。labelImg 的类别列表是人工维护的,不同批次标注时手输或复制粘贴,很容易出现这种串号。血泪经验是:如果直接把 14 类原样塞进 YOLO,模型会把三个类当成三个不同目标学,推理时同一个连接器可能在不同置信度下跳类,评估曲线看着有 14 类,实际有两类始终学不稳。
处理方式取决于业务定义。如果现场只关心“连接器”这个大类,就合并成一个Connection,总框数变成 3961+1952+471=6384,占总框数的 53.67%。合并后类别数降到 12 类,长尾更集中,但连接器类会占据一半以上标注,其他类容易被压制。如果业务上要区分“插接式连接器”和“螺栓连接点”,就不能盲目合并,得先抽 100 张图看这三类框是否落在不同部件上。我的建议是保守起步:先保留 14 类做一次统计和可视化,确认Conection不是另一种部件后,再合并成 12 类重训。不要一上来就合并,合并错了没有后悔药。
先跑一个只读统计脚本,把 xml 里的类别名和框数点清楚。不要用 txt 统计,因为 txt 只有 class id,看不出拼写问题。
from pathlib import Path import xml.etree.ElementTree as ET from collections import Counter root = Path("data/ir_power") xml_dir = root / "Annotations" name_counter = Counter() img_with_cls = Counter() bad_xml = [] for xml_path in sorted(xml_dir.glob("*.xml")): try: tree = ET.parse(xml_path) except ET.ParseError as e: bad_xml.append((xml_path.name, str(e))) continue names_in_img = set() for obj in tree.getroot().findall("object"): name = (obj.findtext("name") or "").strip() if not name: continue name_counter[name] += 1 names_in_img.add(name) for n in names_in_img: img_with_cls[n] += 1 print("类别框数:") for k, v in name_counter.most_common(): print(f"{k:12s} {v:5d} 出现在 {img_with_cls[k]:4d} 张图中") print("坏 xml:", bad_xml[:5], "总数", len(bad_xml))这段脚本只读 xml,不改文件。name_counter统计每个类别名出现多少次,img_with_cls统一种类别出现在多少张图里,后者能看出类别覆盖面。比如PF只有 56 个框,如果只分布在 20 张图里,验证集切分时很容易一张都分不到。bad_xml用来提前发现截断文件,避免转换到一半报错。实际跑完如果发现Conection和Connection的图片高度重叠,基本可以判定是同一类拆分;如果各自集中在不同子目录,就要谨慎。
2.2 框数分布决定训练策略:3961 比 56 的类别失衡
把原始 14 类的框数摊开看,长尾非常明显。下面这张表按原始类别统计,占比以 11896 为分母。MOF、PF、core 在原数据里没有给中文释义,先保留缩写,训练前找业务方确认,不要自己猜。
| 类别名 | 中文/说明 | 框数 | 占比 | 训练关注点 |
|---|---|---|---|---|
| ACB | 空气断路器 | 94 | 0.79% | 极少数类,单独看 AP |
| CT | 电流互感器 | 790 | 6.64% | 中等样本,注意小目标 |
| Conection | 连接器(拼写变体) | 471 | 3.96% | 先确认是否合并 |
| Connection | 连接器 | 3961 | 33.30% | 主导类,防压制 |
| LA | 避雷器 | 267 | 2.24% | 小样本,增强慎用 |
| LBS | 负荷开关 | 548 | 4.61% | 中等样本 |
| MCCB | 塑壳断路器 | 1600 | 13.45% | 主力学稳 |
| MOF | 原标注未给中文 | 273 | 2.29% | 先确认语义 |
| PF | 原标注未给中文 | 56 | 0.47% | 最少类,别只看 mAP |
| PT | 电压互感器 | 661 | 5.56% | 中等样本 |
| VCB | 真空断路器 | 144 | 1.21% | 小样本 |
| body | 柜身 | 380 | 3.19% | 大目标,尺度差异大 |
| connection | 连接器(小写变体) | 1952 | 16.41% | 与 Connection 高度疑似重复 |
| core | 原标注未给中文 | 699 | 5.87% | 先确认语义 |
总框数 11896。连接器三个变体加起来 6384 个框,占 53.67%;如果把Conection也并进去,连接器类在训练集里会形成绝对主导。反过来,PF只有 56 个框,ACB94 个,VCB144 个,这三个类在随机切分下很可能验证集只有几张图。训练时如果只看整体 mAP50,连接器类把分数拉高,小类全军覆没也看不出来。我的做法是:训练前先按类别分层统计图片数,验证集保证每个类至少有 10 张图出现;不够的类,从训练集里按图片而不是按框补,避免同一张图重复采样导致过拟合。
类别失衡还会影响 NMS 后的类别竞争。连接器框多,模型在柜内密集区域容易把 CT、PT 的局部也预测成连接器。解决不是简单加类别权重,而是先做合并去重,再用 per-class AP 和混淆矩阵定位。权重的作用是让损失更关注小类,但如果标签本身有三个重复类,权重会放大噪声。
2.3 文件组织与命名一致性检查
数据集给的是 jpg 图片、VOC xml 和 YOLO txt,三者数量都是 4271,理论上按同名主键一一对应。实际拿到手的包经常出现a.jpg配a.xml但 txt 叫a.txt的情况,也可能有图片存在而 xml 缺失。先跑一致性检查,不要等到训练时报“标签找不到”。
from pathlib import Path root = Path("data/ir_power") img_dir = root / "JPEGImages" xml_dir = root / "Annotations" txt_dir = root / "labels" imgs = {p.stem for p in img_dir.glob("*.jpg")} xmls = {p.stem for p in xml_dir.glob("*.xml")} txts = {p.stem for p in txt_dir.glob("*.txt")} print("图片数", len(imgs), "xml数", len(xmls), "txt数", len(txts)) print("有图无xml", sorted(imgs - xmls)[:10], "总数", len(imgs - xmls)) print("有xml无图", sorted(xmls - imgs)[:10], "总数", len(xmls - imgs)) print("有图无txt", sorted(imgs - txts)[:10], "总数", len(imgs - txts)) print("有txt无图", sorted(txts - imgs)[:10], "总数", len(txts - imgs))stem是去掉扩展名的主键,三个集合做差就能定位缺失。实际处理时,有图无 xml 的样本不要直接删,先看是不是负样本;如果业务允许负样本,可以保留图片但不进标注转换,训练时作为背景图。有 xml 无图的样本必须删,否则转换脚本读不到尺寸。有图无 txt 的样本,如果决定以 xml 为准重建 YOLO 标签,可以忽略原 txt;如果决定沿用原 txt,就要补齐。我的习惯是以 xml 为唯一真源重建 txt,因为 xml 保留了类别名,能发现拼写问题,txt 只有数字 id,错了很难追溯。
3. VOC 到 YOLO 的落地:坐标归一化、分组切分与 data.yaml 对齐
3.1 从 XML 到 YOLO:坐标归一化与越界裁剪
VOC 的框是xmin, ymin, xmax, ymax绝对像素坐标,YOLO 要的是class_id x_center y_center width height,全部除以图像宽高归一化到 0~1。转换看着简单,翻车点集中在三处:图像尺寸用 xml 里的size还是实际 jpg 尺寸;框超出图像边界要不要裁;类别名到 id 的映射顺序是否和data.yaml一致。我的做法是以实际 jpg 尺寸为准,因为 xml 的size是标注时写入的,图片被重存或裁剪后可能不一致。框先裁剪到[0, W-1]和[0, H-1],再算中心点和宽高,宽高小于 1 像素的框直接丢弃。
from pathlib import Path from PIL import Image, ImageOps import xml.etree.ElementTree as ET # 固定类别顺序,必须与 data.yaml 的 names 完全一致 CLASSES = [ "ACB", "CT", "Connection", "LA", "LBS", "MCCB", "MOF", "PF", "PT", "VCB", "body", "core", ] # 如果决定合并三个连接器变体,用这个映射;保守方案先设 ALIAS = {} ALIAS = {"Conection": "Connection", "connection": "Connection"} CLS_ID = {name: i for i, name in enumerate(CLASSES)} def convert_one(xml_path: Path, img_path: Path, out_txt: Path): with Image.open(img_path) as im: im = ImageOps.exif_transpose(im) # 处理手机/相机 EXIF 旋转 W, H = im.size root = ET.parse(xml_path).getroot() lines = [] for obj in root.findall("object"): name = (obj.findtext("name") or "").strip() name = ALIAS.get(name, name) if name not in CLS_ID: continue # 未登记类别先跳过并记录,不要静默丢 b = obj.find("bndbox") if b is None: continue xmin = max(0.0, min(W - 1.0, float(b.findtext("xmin")))) ymin = max(0.0, min(H - 1.0, float(b.findtext("ymin")))) xmax = max(0.0, min(W - 1.0, float(b.findtext("xmax")))) ymax = max(0.0, min(H - 1.0, float(b.findtext("ymax")))) if xmax - xmin < 1.0 or ymax - ymin < 1.0: continue xc = (xmin + xmax) / 2.0 / W yc = (ymin + ymax) / 2.0 / H bw = (xmax - xmin) / W bh = (ymax - ymin) / H lines.append(f"{CLS_ID[name]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") out_txt.write_text("\n".join(lines), encoding="utf-8")CLASSES是唯一 id 来源,顺序一旦定下就不要改。ALIAS是可选合并开关,保守方案留空,跑完统计再决定。ImageOps.exif_transpose会按 EXIF 方向把图转正,返回的size才是标注时人眼看到的宽高;如果原图有 EXIF 旋转而训练时用 OpenCV 读图不会自动转,就会导致框整体错位。这里的处理是转换阶段按转正后的尺寸算坐标,但保存的 jpg 并没有物理旋转,所以更稳的做法是在转换前把 jpg 也按 EXIF 转正后重存,保证训练读图和标注坐标系一致。xmin和xmax都夹到W-1,避免右边界框算出大于 1 的宽。小于 1 像素的框丢弃,通常来自标注误点。脚本没有写“未登记类别”的日志,实际跑的时候建议加一个Counter把跳过的类别名打出来,防止新类别被静默吞掉。
3.2 训练/验证切分:按设备分组,别让同一台变压器同时出现在两边
随机按图片切分是红外数据集最容易踩的坑。同一台柜子可能拍了几十张,角度、曝光略有差异,随机切分后训练集和验证集里出现同一设备的近重复帧,验证 mAP 会虚高,现场换一座变电站就掉点。正确做法是先做近重复分组,再按组切分。文件名有规律时按前缀分组最省事;文件名是纯序号时,用感知哈希把视觉上接近的图聚成组。下面这段用imagehash的 dhash 做粗聚类,阈值 6 是经验值,红外图纹理少,阈值调太大会把不同设备合并,调太小又切不开近重复帧。
from pathlib import Path from PIL import Image import imagehash import random img_dir = Path("data/ir_power/JPEGImages") images = sorted(img_dir.glob("*.jpg")) random.seed(42) groups = [] # 每个组是一个图片列表 for p in images: h = imagehash.dhash(Image.open(p).convert("L")) placed = False for g in groups: if h - g["hash"] <= 6: g["items"].append(p) placed = True break if not placed: groups.append({"hash": h, "items": [p]}) random.shuffle(groups) n_val = max(1, int(len(groups) * 0.2)) val_groups, train_groups = groups[:n_val], groups[n_val:] val = [p for g in val_groups for p in g["items"]] train = [p for g in train_groups for p in g["items"]] print("组数", len(groups), "训练图", len(train), "验证图", len(val))dhash对亮度变化不敏感,适合红外图。h - g["hash"]是汉明距离,小于等于 6 认为视觉接近。分组后再按 8:2 切,保证同一设备的近重复帧只落在一边。实际项目里如果文件名带设备编号,比如stationA_cabinet03_001.jpg,直接取stationA_cabinet03作为组键更可靠,感知哈希只作为兜底。切分完把文件名列表存成train.txt和val.txt,后面复制图片时按列表走,不要重新随机。
3.3 data.yaml 与 dataloader 对接:names 顺序必须和 txt 的 class id 一致
YOLO 训练读取的是images/train、images/val和对应的labels/train、labels/val。目录结构建议统一成下面这样,图片和标签分开放,主键同名。data.yaml里的names顺序就是 txt 里 class id 的顺序,错一位整个训练都在学错类。
path: /data/ir_power train: images/train val: images/val names: 0: ACB 1: CT 2: Connection 3: LA 4: LBS 5: MCCB 6: MOF 7: PF 8: PT 9: VCB 10: body 11: core如果保留 14 类,names要按原始 14 个名字展开,并把Conection、connection单独给 id。合并方案则只留 12 类,ALIAS在转换时已经把三个变体映射到Connection。两种方案不能混用:转换脚本用 12 类映射,yaml 却写 14 类,训练时不会报错,但类别会整体错位,这是最隐蔽的翻车。训练命令以常见检测框架为例,字段名按所用框架调整:
yolo detect train \ data=/data/ir_power/data.yaml \ model=your_detector.pt \ imgsz=960 \ epochs=100 \ batch=8 \ workers=4 \ seed=42 \ project=runs/ir_power \ name=baseline_12clsimgsz=960是为了小目标,后文展开。batch=8是 8GB 显存下的保守值,显存够可以上 16。workers=4避免数据加载成为瓶颈。seed=42固定随机种子,方便复现。name里带类别数,防止不同实验目录混在一起。训练前先跑一次val或小样本过拟合,确认标签能被读出来,再开长训练。
4. 训练参数取舍:960 输入、长尾采样与红外增强的边界
4.1 输入分辨率与增强:红外图别照搬 RGB 那套
红外图像和可见光最大的差别是通道信息单一、纹理弱、对比度靠温度差撑。直接把 RGB 检测那套 HSV 色调增强、通道互换、马赛克拼图搬过来,轻则学不到有效特征,重则把设备边缘拼断。我的默认配置是:输入 960,关闭 HSV 色调和饱和度扰动,保留少量亮度扰动,关闭马赛克和 MixUp,保留随机缩放和平移。body这种柜身大目标在 960 下仍然完整,PF、ACB这种小目标也不至于被下采样成几个像素。如果显存只够 640,优先保证小类图片的切图训练,而不是硬拉全图。
# 训练配置片段,字段名按所用框架调整 imgsz: 960 hsv_h: 0.0 # 红外没有色调概念,关掉 hsv_s: 0.0 # 饱和度扰动同样关掉 hsv_v: 0.15 # 只留少量亮度扰动,模拟曝光差异 degrees: 0.0 # 柜内设备有安装方向,不做大角度旋转 translate: 0.1 scale: 0.25 fliplr: 0.0 # 左右翻转会破坏柜内布局语义,先关 mosaic: 0.0 # 小目标被拼图缩得更小,先关 mixup: 0.0hsv_h和hsv_s置零是因为红外图转成三通道后,色调和饱和度是伪彩色映射出来的,扰动它们等于改变温度到颜色的映射关系,模型会学到不存在的线索。hsv_v保留 0.15 是模拟不同曝光。degrees置零是因为开关柜内设备有明确安装方向,旋转 90 度会造出不存在的场景。fliplr关掉也是同理,左右翻转后断路器操作面板位置反了,模型可能记住错误布局。mosaic关闭的代价是背景多样性下降,但换来了小目标不被缩小。如果数据量不够导致过拟合,再考虑低概率 mosaic,比如 0.2,不要一上来就 1.0。
对比度增强可以用 CLAHE 在数据加载阶段做,但要注意别把噪声也放大。常见做法是离线生成一份 CLAHE 增强副本,只对训练集做,验证集保持原图,这样评估分布和现场一致。在线增强更灵活,但调试成本高。我一般先跑原图基线,看 per-class AP 再决定要不要加 CLAHE。
4.2 长尾类别采样与损失函数:别只看总 mAP
连接器类占一半以上框,PF只有 56 个框,直接用默认损失,模型会把概率 mass 全压到连接器上。类别权重不是越猛越好,按框数反比1/count会给PF极大权重,几十个样本反复学,很快过拟合。更稳的是平方根倒数,把权重差距压平一些。下面这段按框数算权重,训练时传给损失函数或采样器。
import numpy as np # 按第 2 章统计的 12 类合并后框数,顺序与 CLASSES 一致 counts = np.array([ 94, 790, 6384, 267, 548, 1600, 273, 56, 661, 144, 380, 699, ], dtype=np.float64) w = 1.0 / np.sqrt(counts) w = w / w.mean() for i, v in enumerate(w): print(i, round(float(v), 3))1/sqrt(count)让PF的权重约为连接器的 10 倍,而不是 100 倍。权重归一化到均值 1,避免整体损失尺度变化太大。实际训练时如果框架不支持类别权重,可以用重采样:每个 epoch 按权重从图片池抽图,但同一张图重复出现会加剧过拟合,采样率别超过 2 倍。另一个办法是 focal loss,降低易分样本的损失贡献,让模型关注小类难样本。focal loss 的gamma从 1.5 起步,不要直接上 3,红外图噪声大,gamma 太高会把难样本当噪声压掉。
验证阶段不要只报一个总 mAP。把PF、ACB、VCB三个小类的 AP 单独打出来,再和连接器类对比。如果总 mAP 涨了但小类 AP 掉了,说明模型在牺牲小类换主导类,这种模型上线后现场漏检的正是少数设备。
4.3 评估指标与阈值选择:per-class AP 与按类阈值
评估时用较低的置信度阈值把 AP 算全,常见做法是conf=0.001、iou=0.6,让 PR 曲线完整。部署阈值另算,不能拿评估阈值直接上线。连接器类样本多,置信度 0.25 可能就够;PF样本少,模型输出分数整体偏低,阈值 0.25 会漏掉一半,需要按类调低到 0.1 左右再人工复核。下面这张表是起步建议,实际值要用验证集 PR 曲线定,不要照抄。
| 类别组 | 代表类别 | 框数特征 | 验证重点 | 初始 conf 建议 |
|---|---|---|---|---|
| 主导类 | Connection | 6384(合并后) | 防压制其他类 | 0.25 |
| 主力学 | MCCB、CT、PT | 600~1600 | 框回归稳定性 | 0.25 |
| 小样本 | PF、ACB、VCB | 56~144 | 召回率、漏检 | 0.10~0.15 |
| 大目标 | body | 380 | 边界完整性 | 0.30 |
| 待确认 | MOF、core | 273、699 | 语义是否正确 | 先人工抽检 |
评估命令用低阈值算 AP:
yolo detect val \ model=runs/ir_power/baseline_12cls/weights/best.pt \ data=/data/ir_power/data.yaml \ imgsz=960 \ conf=0.001 \ iou=0.6 \ plots=Trueconf=0.001是为了让 PR 曲线覆盖低分样本,plots=True会输出混淆矩阵和 PR 曲线。看混淆矩阵时重点看两件事:连接器是否把 CT、PT 大量误吸;body是否和core混淆。如果连接器行非对角线上全是其他类,说明类别定义或标注边界有问题,调阈值救不回来。
5. 避坑排查:标注错位、类别串号和评估虚高的 5 个现场
5.1 现象:验证集 mAP 很高,换一批图就大面积漏检
现象很典型:验证集 mAP50 到 0.9,现场抽 50 张新图,连接器和 CT 漏检一半。原因通常不是模型不行,是切分泄漏。同一台柜子连续拍了多帧,随机切分后训练集和验证集共享近重复帧,模型记住了背景而不是设备。解决分三步:先用 3.2 的 dhash 分组,把汉明距离小于等于 6 的图聚成组;再按组切 8:2;最后人工抽 20 张验证集图,确认没有和训练集同角度、同曝光的孪生图。分组键能用品编号就用品编号,不要图省事用纯随机。
5.2 现象:训练 loss 正常下降,但推理时所有连接器都跳成同一个类
现象是混淆矩阵里Conection、Connection、connection三行互相串,或者模型只输出Connection。原因是三个类本质是同一部件,标注批次不同导致类别名分裂,而data.yaml的 names 顺序又和 txt 里的 id 对不上。解决:以 xml 为真源重建 txt,固定CLASSES列表,合并方案用ALIAS把三个变体映射到一个类,data.yaml同步改成 12 类。重建后抽查 100 个 txt,确认 class id 落在 0~11 且与 names 对应。不要保留原 txt 再手工改 yaml,两边顺序一旦错位,训练不会报错,但全盘学错。
5.3 现象:框整体偏移、缩放,或者只剩一半
现象是可视化时框跑到设备外面,或者框只盖住设备上半截。原因常见两个:xml 里的size和实际 jpg 尺寸不一致;图片带 EXIF 旋转,标注是在转正后的图上画的,训练读图却没转。解决:转换阶段用ImageOps.exif_transpose读实际尺寸,并把 jpg 物理转正后重存,保证训练读图和标注坐标系一致;同时校验 xml 的 width/height 与 PIL 读出的尺寸,差异超过 2 像素就标记出来人工确认。不要直接信任 xml 的 size,尤其是图片被二次导出过的包。
5.4 现象:PF、ACB 的 AP 一直是 0,训练日志里这几个类没有梯度
现象是小类 AP 为 0,但数据集中确实有这些框。原因通常是小目标在 640 输入下被下采样到几个像素,加上 mosaic 把图缩小,特征直接消失;或者类别权重没生效,梯度被主导类淹没。解决:输入提到 960,关闭 mosaic 和 mixup,先跑 20 个 epoch 看小类是否有正样本匹配;检查 txt 里小类 id 是否真的存在,别只信统计脚本;损失端用平方根倒数权重或 focal loss,别用 1/count 硬压。如果显存不够 960,切图训练比硬拉全图更有效,切图时保证框不被切断。
5.5 现象:同一份配置两次训练结果差很多,评估复现不了
现象是同样的 yaml、同样的数据,两次训练 mAP 差 3~5 个点,混淆矩阵也不一样。原因是随机种子没固定、数据加载顺序变了、标签缓存没清。解决:训练命令固定seed,关闭或固定workers的随机性,每次换数据后删掉旧的labels.cache再训练;验证时用同一份权重和同一组conf/iou。如果框架默认开 AMP,混合精度也会带来小幅波动,对比实验时先关 AMP 跑基线。复现性不是玄学,是这些开关有没有钉死。
6. 进阶验证:混淆矩阵、可视化抽检与一次小样本回归
训练完不要只看一个 mAP 数字。我的验收顺序是:先看混淆矩阵,再看 per-class PR 曲线,最后抽 50 张图人工画框。混淆矩阵重点看连接器与 CT、PT 的交叉,以及body和core是否互相吸;PR 曲线看小类曲线是否贴着坐标轴,贴轴说明阈值稍高就全漏。抽检时每个类别至少覆盖 3 张图,小类优先,框画在图上直接看有没有偏移、漏标、重复框。下面这段是抽检可视化脚本,按类别从验证集里抽图,画真值和预测。
from pathlib import Path from PIL import Image, ImageDraw import random # 伪代码:preds 为 {图片名: [(cls_id, xc, yc, w, h, conf), ...]} # gt 为 {图片名: [(cls_id, xc, yc, w, h), ...]} def draw_one(img_path, gt_boxes, pred_boxes, names, out_path): im = Image.open(img_path).convert("RGB") W, H = im.size dr = ImageDraw.Draw(im) for cls_id, xc, yc, w, h in gt_boxes: x1, y1 = (xc - w / 2) * W, (yc - h / 2) * H x2, y2 = (xc + w / 2) * W, (yc + h / 2) * H dr.rectangle([x1, y1, x2, y2], outline="lime", width=3) dr.text((x1 + 3, y1 + 3), names[cls_id], fill="lime") for cls_id, xc, yc, w, h, conf in pred_boxes: x1, y1 = (xc - w / 2) * W, (yc - h / 2) * H x2, y2 = (xc + w / 2) * W, (yc + h / 2) * H dr.rectangle([x1, y1, x2, y2], outline="red", width=2) im.save(out_path)绿色画真值,红色画预测,同一张图上直接看偏移和漏检。names用data.yaml的顺序,不要另起一套。抽检发现框整体偏移,回到 5.3 查 EXIF 和尺寸;发现小类全是漏检,回到 4.3 调低该类阈值再看,不要急着重训。验收表可以按下面几项过一遍,任何一项不通过都先停,别把问题带进部署。
| 验收项 | 通过标准 | 不通过时先查 |
|---|---|---|
| 类别 id 一致性 | 抽 100 个 txt,id 与 names 全对应 | 转换脚本的 CLASSES 顺序 |
| 框可视化 | 50 张抽检无整体偏移、无半框 | EXIF、xml size、jpg 实际尺寸 |
| 小类召回 | PF/ACB/VCB 在低阈值下召回可接受 | 输入分辨率、mosaic、类别权重 |
| 切分泄漏 | 验证集无训练集近重复帧 | dhash 分组阈值、文件名分组键 |
| 复现性 | 两次同配置 mAP 差异小于 1 个点 | seed、cache、AMP 开关 |
从那以后我每次拿到新数据集,都先把三个 connection 类的统计和可视化跑一遍,再决定合并还是保留,然后才开训练。这个习惯救过好几次——有一次合并后忘了同步data.yaml,训练 loss 看着正常,混淆矩阵一画全串了。希望帮到你。
本文还有配套的精品资源,点击获取