備紅外圖像數(shù)據(jù)集與YOLO訓(xùn)練實(shí)踐)
簡介面向電力設(shè)備視覺檢測方向的科研人員、工程師與目標(biāo)檢測學(xué)習(xí)者這份文檔型資料圍繞一套4271張紅外圖像、14類電氣設(shè)備標(biāo)注數(shù)據(jù)集展開重點(diǎn)服務(wù)變壓器及相關(guān)電力部件的識別與定位任務(wù)。資源以1個(gè)docx文檔交付約1006KB集中說明Pascal VOC與YOLO雙格式的組織方式、jpg圖片與xml/txt標(biāo)注對應(yīng)關(guān)系以及ACB、CT、Connection、LA、LBS、MCCB、PT、VCB等類別劃分和總框數(shù)11896的標(biāo)注概況。文檔還交代labelImg矩形框標(biāo)注規(guī)則、類別框數(shù)分布及精度不保證等使用說明便于讀者在訓(xùn)練前核對數(shù)據(jù)規(guī)格、理解標(biāo)簽含義并評估適用性。已有101人學(xué)習(xí)適合需要快速掌握該數(shù)據(jù)集結(jié)構(gòu)與檢測場景的讀者查閱。1. 4271 張紅外圖值多少工時(shí)一個(gè)巡檢識別項(xiàng)目的起步賬前年冬天幫一個(gè)做變電站巡檢的團(tuán)隊(duì)看模型效果他們卡在一個(gè)很實(shí)在的地方現(xiàn)場熱像儀每天產(chǎn)出上千張圖人眼一眼能從熱像里挑出過熱的接頭模型換個(gè)站就崩。查下來問題不在骨干網(wǎng)絡(luò)而在訓(xùn)練集只有三百多張自拍圖、類別只標(biāo)了兩種。這類場景的解法往往落在數(shù)據(jù)側(cè)——一份標(biāo)注齊全、類別覆蓋夠廣的紅外圖像數(shù)據(jù)集比換三個(gè)檢測頭都實(shí)在。這個(gè)電力場景電氣設(shè)備紅外圖像變壓器檢測數(shù)據(jù)集4271 張圖、14 類設(shè)備、11896 個(gè)矩形框VOC 與 YOLO 雙格式并存補(bǔ)的正是這塊短板。它適合兩類人正在做電力設(shè)備目標(biāo)檢測、需要現(xiàn)成標(biāo)注先把 baseline 跑起來的算法同學(xué)手上有紅外巡檢業(yè)務(wù)、想驗(yàn)證自動(dòng)識別可行性的工程團(tuán)隊(duì)。2. 數(shù)據(jù)集拆解4271 張圖、14 類標(biāo)簽與 11896 個(gè)框的分布拿到一個(gè)數(shù)據(jù)集我第一件事不是急著寫 train.py而是把包解開、數(shù)一遍文件、畫一張類別分布圖。數(shù)完心里就有底這活兒是能訓(xùn)出東西還是會(huì)在長尾上翻車。這份數(shù)據(jù)集的骨架很規(guī)整圖片、VOC 標(biāo)注、YOLO 標(biāo)注三份文件數(shù)量完全對齊都是 4271說明沒有半途而廢的標(biāo)注殘留這對后續(xù)寫轉(zhuǎn)換腳本省了很多事。2.1 文件組織jpg、xml、txt 三件套怎么擺壓縮包解開后是典型的 Pascal VOC 布局外加一份 YOLO 格式的平行目錄。里面不含 ImageSets 那種劃分路徑的 txt也就是說訓(xùn)練集/驗(yàn)證集的名單得自己生成這一點(diǎn)在動(dòng)手前要清楚別指望拿現(xiàn)成的 train.txt。ir_power_dataset/ ├── JPEGImages/ # 4271 張 .jpg紅外偽彩或灰度渲染圖 │ ├── 000001.jpg │ └── ... ├── Annotations/ # 4271 個(gè) VOC .xml與 jpg 同名 │ ├── 000001.xml │ └── ... ├── labels/ # 4271 個(gè) YOLO .txt與 jpg 同名 │ ├── 000001.txt │ └── ... └── classes.txt # 14 行類別名行號即 class_id從 0 起判斷一個(gè)數(shù)據(jù)集能不能直接喂給訓(xùn)練腳本我一般跑三行命令核對ls JPEGImages | wc -l、ls Annotations | wc -l、ls labels | wc -l三個(gè)數(shù)必須相等再用comm -3比一次文件名列表確認(rèn)沒有孤兒文件。紅外數(shù)據(jù)集常見的一個(gè)毛病是同一設(shè)備同一角度連拍多張文件名帶(1)、(2)后綴轉(zhuǎn)換腳本里如果按stem拼路徑就會(huì)找不到對應(yīng)的 xml。這份數(shù)據(jù)集名字規(guī)整但檢查這一步我從不省血淚經(jīng)驗(yàn)是一次漏檢讓整個(gè)驗(yàn)證集指標(biāo)虛高 6 個(gè)點(diǎn)。2.2 14 個(gè)類別與長尾分布誰是大頭誰是稀有物種14 個(gè)類別的框數(shù)差了兩個(gè)數(shù)量級這是這份數(shù)據(jù)最需要提前知道的信息。按框數(shù)排下來class_id類別名含義框數(shù)占比0ACB空氣斷路器940.79%1CT電流互感器7906.64%2Conection連接器拼寫變體4713.96%3Connection連接器396133.30%4LA避雷器2672.24%5LBS負(fù)荷開關(guān)5484.61%6MCCB塑殼斷路器160013.45%7MOF計(jì)量/互感器類2732.29%8PF電力附件560.47%9PT電壓互感器6615.56%10VCB真空斷路器1441.21%11body柜身3803.19%12connection連接器小寫195216.41%13core鐵芯6995.88%—合計(jì)—11896100%表格里最扎眼的是Conection、Connection、connection三個(gè)條目——語義上都是連接器/接線端子卻因?yàn)闃?biāo)注時(shí)的拼寫和大小寫差異被拆成了三個(gè)類。PF只有 56 個(gè)框ACB94 個(gè)這兩個(gè)類在 640 輸入尺寸下如果目標(biāo)本身還小訓(xùn)到后期 AP 大概率是 0 或者個(gè)位數(shù)。我的判斷是先按原始 14 類跑一版當(dāng)基線記錄每類 AP再?zèng)Q定要不要合并成 810 類而不是一上來就改標(biāo)簽?zāi)菢映隽藛栴}分不清是數(shù)據(jù)的問題還是改造的問題。2.3 標(biāo)注文件長什么樣xml 與 txt 的字段對照VOC 與 YOLO 表達(dá)的是同一個(gè)框只是坐標(biāo)系不同。VOC 存絕對像素的左上、右下角點(diǎn)YOLO 存歸一化的中心點(diǎn)與寬高。理解這一層寫轉(zhuǎn)換腳本時(shí)就不會(huì)在/w和/h上搞反。annotation filename000123.jpg/filename size width640/widthheight480/heightdepth3/depth /size object nameConnection/name bndbox xmin210/xminymin96/yminxmax388/xmaxymax302/ymax /bndbox /object /annotation# 000123.txt —— 一行一個(gè)目標(biāo)class_id cx cy w h全部歸一化到 0~1 3 0.467188 0.414583 0.278125 0.429167對應(yīng)的換算關(guān)系是cx(xminxmax)/2/width、w(xmax-xmin)/width縱軸同理。這里有個(gè)容易忽略的點(diǎn)xml 里的size字段不一定和 jpg 實(shí)際尺寸一致有的標(biāo)注工具在圖片縮放后沒回寫尺寸直接信 xml 會(huì)讓框整體偏移。穩(wěn)妥做法是轉(zhuǎn)換時(shí)用cv2.imread(...).shape讀實(shí)際尺寸和 xml 記錄的尺寸做一次比對不一致就報(bào)警并統(tǒng)一按實(shí)際尺寸換算。3. 跑通第一步VOC 轉(zhuǎn) YOLO 的轉(zhuǎn)換腳本與訓(xùn)練配置數(shù)據(jù)集同時(shí)給了兩套格式理論上 YOLO 訓(xùn)練只吃 txtVOC 那份是給 TensorFlow/Paddle 系或 MMDetection 用的。但我不建議直接拿包里的 txt 開訓(xùn)——class_id 的分配順序你得自己確認(rèn)一遍一旦和data.yaml里 names 的順序?qū)Σ簧嫌?xùn)練照樣跑loss 照樣降最后預(yù)測出來避雷器的位置標(biāo)著柜身這種錯(cuò)誤沒有報(bào)錯(cuò)提示純純的黑匣子。3.1 類別映射別用 sorted() 偷懶定 class_id自己寫轉(zhuǎn)換腳本時(shí)第一步是把類別順序釘死成一份列表轉(zhuǎn) xml 和生成 yaml 都用這一份絕不在兩個(gè)地方各寫一遍。用sorted(set(names))自動(dòng)推導(dǎo)看著聰明實(shí)際會(huì)引入大小寫排序問題——大寫字母全部排在小寫前面body和core會(huì)被擠到中間去和包內(nèi) txt 的編號完全錯(cuò)位。# classes.py —— 全項(xiàng)目唯一的類別定義來源 CLASSES [ ACB, CT, Conection, Connection, LA, LBS, MCCB, MOF, PF, PT, VCB, body, connection, core, ] NAME2ID {name: idx for idx, name in enumerate(CLASSES)} ID2NAME {idx: name for name, idx in NAME2ID.items()}這份列表的順序是照抄數(shù)據(jù)集說明里給出的順序ACB是 0 號core是 13 號。如果決定合并那三個(gè)連接器類別只需要在這一個(gè)文件里改其他地方全部引用NAME2ID改完跑一次全量轉(zhuǎn)換即可不用滿項(xiàng)目找散落的數(shù)字。參數(shù)上唯一要盯的是enumerate的起點(diǎn)YOLO 的 class_id 從 0 開始而有些老腳本按 VOC 習(xí)慣從 1 開始差一位就是全盤錯(cuò)。3.2 轉(zhuǎn)換腳本越界裁剪與退化框過濾真正容易出問題的地方在坐標(biāo)上。紅外圖像邊緣經(jīng)常只拍到半個(gè)設(shè)備標(biāo)注的人手一抖就把 xmax 標(biāo)到了圖片寬度之外也有框被標(biāo)成xmin xmax的一條線。這兩種情況不處理寫進(jìn) txt 后就是越界的歸一化值訓(xùn)練時(shí)要么被框架靜默丟掉要么在計(jì)算 loss 時(shí)炸出 NaN。# voc2yolo.py import os import xml.etree.ElementTree as ET import cv2 from classes import NAME2ID def convert_one(xml_path, txt_path, img_path): h, w cv2.imread(img_path).shape[:2] # 以圖片實(shí)際尺寸為準(zhǔn) root ET.parse(xml_path).getroot() lines, dropped [], 0 for obj in root.iter(object): name (obj.find(name).text or ).strip() if name not in NAME2ID: # 不在白名單里的類別直接跳過 dropped 1 continue b obj.find(bndbox) x1, y1 float(b.find(xmin).text), float(b.find(ymin).text) x2, y2 float(b.find(xmax).text), float(b.find(ymax).text) # 越界裁剪先夾到圖像范圍內(nèi) x1, x2 max(0.0, min(x1, w)), max(0.0, min(x2, w)) y1, y2 max(0.0, min(y1, h)), max(0.0, min(y2, h)) bw, bh x2 - x1, y2 - y1 if bw 2 or bh 2: # 退化框?qū)捇蚋卟蛔?2 像素 dropped 1 continue cx, cy (x1 x2) / 2 / w, (y1 y2) / 2 / h lines.append(f{NAME2ID[name]} {cx:.6f} {cy:.6f} {bw/w:.6f} {bh/h:.6f}) with open(txt_path, w) as f: f.write(\n.join(lines)) return len(lines), dropped if __name__ __main__: root_dir /data/ir_power_dataset total_box, total_drop, empty_img 0, 0, 0 for fn in os.listdir(f{root_dir}/Annotations): stem os.path.splitext(fn)[0] n, d convert_one( f{root_dir}/Annotations/{fn}, f{root_dir}/labels/{stem}.txt, f{root_dir}/JPEGImages/{stem}.jpg, ) total_box n; total_drop d empty_img (n 0) print(f寫入框 {total_box}丟棄 {total_drop}空標(biāo)注圖 {empty_img})這段腳本干了三件事按白名單過濾類別、把越界坐標(biāo)夾回圖內(nèi)、丟掉寬高不足 2 像素的退化框。跑完打印三個(gè)數(shù)字正常情況是寫入框數(shù)接近 11896、丟棄數(shù)為個(gè)位數(shù)如果丟棄數(shù)上百多半是某批圖的標(biāo)注尺寸和圖片尺寸對不上需要回去查那批文件。empty_img也不能忽略標(biāo)注工具過濾之后留下空 txt 的圖仍會(huì)被加載雖然不算負(fù)樣本錯(cuò)誤但會(huì)讓一個(gè) batch 的有效梯度變少我一般把空 txt 對應(yīng)的圖一起從訓(xùn)練名單里剔掉。想省事直接用包里現(xiàn)成的 txt 也行那就跳過這一步但至少抽樣把labels/里的 class_id 分布統(tǒng)計(jì)一次和上面的表對得上才放心。3.3 劃分與 data.yaml一次寫對后面都省事數(shù)據(jù)集沒帶 ImageSets切分得自己做。我固定用 8:1:1并且按類別分層抽樣保證PF、ACB這類小類在驗(yàn)證集里至少有十幾個(gè)框否則算出來的 mAP 波動(dòng)大得沒法看。# split.py —— 分層切分保證小類在驗(yàn)證集也有樣本 import random, shutil from pathlib import Path from collections import defaultdict random.seed(42) root Path(/data/ir_power_dataset) stems [p.stem for p in (root / labels).glob(*.txt)] # 用每個(gè)文件里出現(xiàn)的類別作為分層鍵 bucket defaultdict(list) for s in stems: ids {line.split()[0] for line in open(root / labels / f{s}.txt) if line.strip()} bucket[tuple(sorted(ids))].append(s) train, val, test [], [], [] for key, items in bucket.items(): random.shuffle(items) n len(items) train items[: int(n * 0.8)] val items[int(n * 0.8): int(n * 0.9)] test items[int(n * 0.9):] for split, items in [(train, train), (val, val), (test, test)]: for sub in (images, labels): (root / splits / split / sub).mkdir(parentsTrue, exist_okTrue) for s in items: shutil.copy(root / JPEGImages / f{s}.jpg, root / splits / split / images / f{s}.jpg) shutil.copy(root / labels / f{s}.txt, root / splits / split / labels / f{s}.txt) print(len(train), len(val), len(test))分層鍵用的是這張圖里出現(xiàn)了哪些類別的組合而不是單一主類別效果比隨機(jī)切分穩(wěn)。代價(jià)是組合數(shù)量可能很碎樣本少的組合分出來只有一兩張這沒關(guān)系小類本來就要靠訓(xùn)練時(shí)的采樣策略補(bǔ)切分只能保證不全落到訓(xùn)練集里。# data.yaml path: /data/ir_power_dataset/splits train: train/images val: val/images test: test/images names: 0: ACB 1: CT 2: Conection 3: Connection 4: LA 5: LBS 6: MCCB 7: MOF 8: PF 9: PT 10: VCB 11: body 12: connection 13: corenames 的編號必須和classes.py逐行一致包括大小寫。檢查方法很土但有效隨便挑一張圖把它的 txt 第一行讀出來拿 class_id 去 yaml 里查名字再回頭看 xml 里對應(yīng) object 的 name能對上就說明整條鏈路沒錯(cuò)位。# 先在 640 分辨率上跑一版基線別急著堆 epochs yolo detect train modelyolov8s.pt datadata.yaml imgsz640 epochs100 \ batch16 workers8 device0 patience30 close_mosaic10參數(shù)取值說明modelyolov8s.pt小模型先驗(yàn)證數(shù)據(jù)可用性跑通再換 m/limgsz640紅外小目標(biāo)多顯存允許可上 960 試一版batch168G 顯存的安全值OOM 就降到 8patience30連續(xù) 30 輪沒提升就早停避免過擬合小類close_mosaic10最后 10 輪關(guān)掉 mosaic讓框回歸收斂得更準(zhǔn)第一版訓(xùn)練我只關(guān)心兩件事loss 曲線是不是平滑下降以及val上的每類 AP 分布和框數(shù)分布是否一致。如果Connection的 AP 上到 0.9 而PF停在 0.01那是樣本量的問題不是模型的問題別在那調(diào)學(xué)習(xí)率白費(fèi)時(shí)間。4. 訓(xùn)練鏈路適配紅外圖像的數(shù)據(jù)加載與增強(qiáng)策略把數(shù)據(jù)塞進(jìn) YOLO 默認(rèn)管線能跑但跑出來的模型往往在真實(shí)紅外圖上表現(xiàn)不穩(wěn)定原因多半出在增強(qiáng)上??梢姽獾脑鰪?qiáng)配方搬到紅外場景有些操作是在幫倒忙——熱像圖的顏色是偽彩映射出來的不是物體本色HSV 擾動(dòng)一上模型學(xué)到的可能是配色規(guī)律而不是設(shè)備形狀。4.1 紅外圖像的三個(gè)脾氣低對比度、偽彩失真、噪點(diǎn)第一是動(dòng)態(tài)范圍窄。紅外相機(jī)的原始數(shù)據(jù)是溫度映射成 8 位圖之后柜體和背景的灰度差可能只有十幾個(gè)灰階肉眼看著一片糊模型的淺層卷積很難提出邊緣。第二是偽彩。同一臺設(shè)備在不同色帶設(shè)置下可以是紅黃也可以是藍(lán)紫若訓(xùn)練集全是一種配色換臺設(shè)備就廢。第三是噪聲熱像儀的非均勻性和隨機(jī)噪點(diǎn)在低對比度區(qū)域特別明顯容易被誤檢成小目標(biāo)。對應(yīng)的策略我一般這么定用 CLAHE 提升局部對比度、隨機(jī)灰度化打散偽彩依賴、亮度/對比度擾動(dòng)控制在 ±0.2 以內(nèi)、不用HueSaturationValue 這類強(qiáng)色彩增強(qiáng)。翻轉(zhuǎn)可以留水平翻轉(zhuǎn)垂直翻轉(zhuǎn)要慎用——電力柜里的斷路器、儀表安裝是有明確上下方向的翻過來就成了現(xiàn)實(shí)中不存在的樣本。4.2 自定義 Dataset把增強(qiáng)控制在合理范圍如果要用 Ultralytics 之外的框架Dataset 得自己寫。核心是三段讀圖統(tǒng)一成三通道、讀 txt 組 boxes、走 albumentations 管線并保證 boxes 跟著變換。# ir_dataset.py import cv2 import numpy as np import torch from torch.utils.data import Dataset import albumentations as A from albumentations.pytorch import ToTensorV2 train_tf A.Compose([ A.LongestMaxSize(max_size640), A.PadIfNeeded(640, 640, border_modecv2.BORDER_CONSTANT, value0), A.HorizontalFlip(p0.5), # 垂直翻轉(zhuǎn)對柜內(nèi)設(shè)備不合理關(guān)掉 A.RandomBrightnessContrast(0.2, 0.2, p0.5), # 模擬曝光差異幅度收窄 A.CLAHE(clip_limit2.0, tile_grid_size(8, 8), p0.3), # 拉開局部對比度 A.GaussNoise(var_limit(5.0, 20.0), p0.2), # 貼合熱像儀噪點(diǎn) A.ToGray(p0.1), # 隨機(jī)去色降低偽彩依賴 ToTensorV2(), ], bbox_paramsA.BboxParams(formatyolo, label_fields[cls], min_visibility0.3)) class IRDataset(Dataset): def __init__(self, img_dir, lbl_dir, tf): self.img_dir, self.lbl_dir, self.tf img_dir, lbl_dir, tf self.names sorted(p.stem for p in img_dir.glob(*.jpg)) def __len__(self): return len(self.names) def __getitem__(self, idx): stem self.names[idx] img cv2.imread(str(self.img_dir / f{stem}.jpg)) # 偽彩圖按 BGR 讀 if img is None: raise FileNotFoundError(stem) cls, boxes [], [] for line in open(self.lbl_dir / f{stem}.txt): if not line.strip(): continue c, cx, cy, w, h line.split() cls.append(int(c)); boxes.append([float(cx), float(cy), float(w), float(h)]) out self.tf(imageimg, bboxesboxes, clscls) return { image: out[image], boxes: torch.tensor(out[bboxes], dtypetorch.float32).clamp(0, 1), labels: torch.tensor(out[cls], dtypetorch.long), }幾個(gè)參數(shù)的取舍值得說明。min_visibility0.3是裁剪/翻轉(zhuǎn)后框只剩不到三成面積就丟棄避免產(chǎn)生一堆幾乎看不見的殘框污染標(biāo)簽ToGray(p0.1)概率不高目的是讓模型偶爾見到無顏色信息的輸入學(xué)會(huì)靠形狀判別CLAHE的clip_limit2.0是個(gè)保守值調(diào)到 4 以上會(huì)把噪聲也一起放大熱像圖的噪點(diǎn)被強(qiáng)化后很像是小目標(biāo)反而拉低精度。如果直接用 Ultralytics 訓(xùn)練等價(jià)做法是在命令行加hsv_h0.0 hsv_s0.0 hsv_v0.3 degrees0 fliplr0.5 flipud0.0把色彩相關(guān)的增強(qiáng)全關(guān)掉。提示紅外數(shù)據(jù)集如果混了灰度和偽彩兩種渲染務(wù)必確認(rèn)讀圖時(shí)通道數(shù)一致?;叶葓D用cv2.imread默認(rèn)讀成三通道沒問題但用cv2.IMREAD_GRAYSCALE讀進(jìn)來再送進(jìn)需要 3 通道的骨干網(wǎng)絡(luò)會(huì)直接報(bào)維度錯(cuò)誤。4.3 評估怎么讀每類 AP、混淆矩陣和漏檢圖只看一個(gè) mAP50 是自欺欺人。這份數(shù)據(jù) 14 類里框數(shù)從 56 到 3961總 mAP 會(huì)被Connection和connection兩個(gè)大類拉到 0.8 以上看著漂亮實(shí)際PF、ACB全軍覆沒。我通常按這個(gè)順序看先拉每類 AP 表和框數(shù)表并排放找框數(shù)不少但 AP 極低的類這通常是標(biāo)注質(zhì)量問題再看混淆矩陣重點(diǎn)看那三個(gè)連接器類別之間是否互相串——串得厲害就是拼寫分裂導(dǎo)致的最后抽 20 張驗(yàn)證集原圖做可視化專門找漏檢紅外圖里設(shè)備密集堆疊的區(qū)域漏檢率明顯更高這類樣本要回流到訓(xùn)練集。驗(yàn)證命令跑完之后runs/detect/val/下會(huì)生成confusion_matrix.png、PR_curve.png和各分類的val_batchX_pred.jpg。我習(xí)慣把預(yù)測圖按文件名排序后快速翻一遍翻到哪個(gè)類標(biāo)錯(cuò)顏色就記下來比盯著數(shù)字有用。5. 避坑排查標(biāo)簽拼寫、坐標(biāo)越界與驗(yàn)證集泄漏的 5 個(gè)坑這一節(jié)是整篇里我最想讓人先看的部分。上面那些步驟照著做基本不會(huì)出岔子真正耗時(shí)間的是跑起來之后指標(biāo)不對勁、又找不到原因的場面。下面五條都是這類數(shù)據(jù)集上真實(shí)會(huì)撞見的按現(xiàn)象 → 原因 → 解決記下來。5.1 標(biāo)簽層面的三個(gè)坑坑一三個(gè)連接器類別互相搶框混淆矩陣一片紅?,F(xiàn)象是訓(xùn)練能收斂但Conection、Connection、connection三類的 AP 都在 0.4 上下徘徊混淆矩陣?yán)锼鼈內(nèi)齻€(gè)互相大量錯(cuò)分同一張圖上的接線端子一會(huì)兒被預(yù)測成 2 號一會(huì)兒成 12 號。原因是標(biāo)注階段不同人對同一個(gè)部件的寫法不統(tǒng)一本質(zhì)上是一個(gè)類被拆成了三份每份樣本量都不夠模型學(xué)不到穩(wěn)定特征。解決辦法是合并成一個(gè)類在classes.py里把這三項(xiàng)合成單個(gè)connector重跑轉(zhuǎn)換腳本同時(shí)注意合并后body/core的編號會(huì)前移data.yaml的 names 必須跟著改兩處一起改別只改一邊。坑二訓(xùn)練到一半 loss 變 NaN或者某些框完全學(xué)不到。現(xiàn)象是前幾輪 loss 正常下降十幾輪之后突然炸成 NaN或者日志里出現(xiàn)大量被忽略的框。原因是原始 xml 里存在越界坐標(biāo)和零面積框轉(zhuǎn)換時(shí)沒裁干凈歸一化后出現(xiàn)大于 1 的值或?qū)挾葹?0 的行。解決辦法就是 3.2 節(jié)那段腳本里的截?cái)噙壿嬣D(zhuǎn)換完再補(bǔ)一道校驗(yàn)把所有 txt 讀一遍統(tǒng)計(jì)cx1 or cy1 or w0 or h0的行數(shù)正常應(yīng)該是 0不為 0 就說明那批 xml 有問題回去看是哪個(gè)標(biāo)注批次??尤硰垐D上有目標(biāo)但 txt 是空的訓(xùn)練時(shí)框全丟?,F(xiàn)象是驗(yàn)證時(shí)發(fā)現(xiàn)明明有設(shè)備的圖模型一個(gè)框都不出。原因通常是標(biāo)注工具里畫了框但沒保存或者類別名寫成了表里沒有的拼寫比如把MCCB寫成MCCB帶了尾部空格轉(zhuǎn)換腳本按白名單過濾時(shí)就靜默跳過了。解決辦法是在轉(zhuǎn)換腳本里加一行日志把未命中白名單的類別名連同文件名打印出來跑一次全量轉(zhuǎn)換就能把這個(gè)名單收齊另外strip()一定要加尾部空格是最隱蔽的一種。5.2 訓(xùn)練與評估層面的兩個(gè)坑坑四驗(yàn)證集指標(biāo)高得離譜換到現(xiàn)場就崩?,F(xiàn)象是 val 上 mAP50 輕松上 0.9拿去跑另一批現(xiàn)場圖連大類都認(rèn)不全。原因是隨機(jī)切分把同一臺設(shè)備、同一角度、甚至是連拍的近似幀同時(shí)分進(jìn)了訓(xùn)練集和驗(yàn)證集模型等于在背答案。解決方法是切分時(shí)按采集批次/設(shè)備編號分組同一組只進(jìn)一個(gè) split。如果文件名里沒有批次信息就用圖像相似度聚類把近鄰幀歸為一組再切。這一步多花半天能省掉現(xiàn)場調(diào)試的兩周。坑五小類別 AP 長期為 0調(diào)參調(diào)不動(dòng)。現(xiàn)象是PF56 個(gè)框、ACB94 個(gè)框從第一輪到最后一輪 AP 都是 0其他類正常。原因是這兩類樣本量太少且目標(biāo)在圖中往往偏小標(biāo)準(zhǔn)的隨機(jī)采樣下每個(gè) batch 里幾乎見不到它們。解決辦法分三步先確認(rèn)這兩類的框確實(shí)存在且坐標(biāo)正確用可視化腳本畫出來看再開啟類別加權(quán)采樣或過采樣小類圖片最后考慮提高輸入分辨率到 960讓小平米目標(biāo)在特征圖上多占幾個(gè)像素。如果三步做完 AP 還是 0接受它——56 個(gè)框本來就不足以支撐一個(gè)獨(dú)立類別合并進(jìn)語義相近的類更實(shí)際。注意這五條里坑一和坑四最容易被忽略因?yàn)樗鼈兊墓餐卣魇怯?xùn)練過程完全正常、沒有任何報(bào)錯(cuò)。前者錯(cuò)在標(biāo)簽后者騙在切分兩者都不會(huì)在終端里給你一行紅字。6. 二次開發(fā)類別重劃、半自動(dòng)預(yù)標(biāo)注與難例回流把基線跑通只是起點(diǎn)真正落到業(yè)務(wù)里14 類這個(gè)粒度大概率要?jiǎng)?。我一般的做法分三層推進(jìn)每一步都用指標(biāo)驗(yàn)證不憑感覺。第一層是類別重劃。按業(yè)務(wù)需求把 14 類壓成設(shè)備大類 關(guān)鍵部件比如斷路器ACB/MCCB/VCB 合并、互感器CT/PT/MOF 合并、連接器三個(gè)拼寫合并、柜身、鐵芯、避雷器、負(fù)荷開關(guān)。合并后類別數(shù)從 14 降到 8 左右每個(gè)類的框數(shù)都在 300 以上PF這種 56 框的稀有類要么并入鄰近類要么單獨(dú)留出來做二階段分類。改造方式是只動(dòng)classes.py里的映射表加一個(gè)RAW2MERGED字典轉(zhuǎn)換腳本里多查一次表原始 xml 一個(gè)字都不改隨時(shí)可以回退。改造項(xiàng)改造前改造后驗(yàn)證方式類別粒度14 類8 類每類 AP 是否整體上移小類是否脫離 0輸入分辨率640960小目標(biāo)召回率、顯存占用與推理耗時(shí)數(shù)據(jù)量4271 張4271 預(yù)標(biāo)注復(fù)核新增樣本中人工修正比例是否逐步下降第二層是半自動(dòng)預(yù)標(biāo)注。用訓(xùn)好的模型對未標(biāo)注的現(xiàn)場圖做推理置信度閾值卡在 0.4 左右把預(yù)測框直接寫成 YOLO txt再人工過一遍只改錯(cuò)的不畫新的。這一步能省掉標(biāo)注成本的大頭但有個(gè)細(xì)節(jié)要注意預(yù)標(biāo)注生成的 txt 里混著模型的系統(tǒng)性偏差如果全部無條件接受下一輪訓(xùn)練只是在放大誤差。我的經(jīng)驗(yàn)是每天抽 10% 的預(yù)標(biāo)注結(jié)果做人工復(fù)核并統(tǒng)計(jì)修正率修正率降到 5% 以下再考慮擴(kuò)大自動(dòng)接受范圍。# prelabel.py —— 用已有權(quán)重給新圖打預(yù)標(biāo)注低置信度直接丟不留給人工糾結(jié) from ultralytics import YOLO from pathlib import Path model YOLO(runs/detect/train/weights/best.pt) src, dst Path(/data/new_ir), Path(/data/new_labels) dst.mkdir(parentsTrue, exist_okTrue) for r in model.predict(sourcestr(src), imgsz960, conf0.4, iou0.5, streamTrue): stem Path(r.path).stem lines [] for b in r.boxes: c, x, y, w, h int(b.cls), *b.xywhn[0].tolist() lines.append(f{c} {x:.6f} {y:.6f} {w:.6f} {h:.6f}) (dst / f{stem}.txt).write_text(\n.join(lines))conf0.4是刻意壓低的值寧可多給人看幾個(gè)框也別漏掉真目標(biāo)——漏掉的框人工根本發(fā)現(xiàn)不了多出來的框一眼就能刪。imgsz960要和訓(xùn)練時(shí)保持一致尺寸不匹配會(huì)讓小目標(biāo)直接消失。第三層是難例回流。把驗(yàn)證集和現(xiàn)場測試?yán)锫z、誤檢的圖單獨(dú)存一個(gè)目錄每隔一輪訓(xùn)練就補(bǔ)進(jìn)去同時(shí)在data.yaml里加一組hard路徑做專項(xiàng)評估。這個(gè)循環(huán)跑上三五輪模型在現(xiàn)場的穩(wěn)定性提升通常比換更大的骨干網(wǎng)絡(luò)明顯。一個(gè)教訓(xùn)我早期做這類改造時(shí)習(xí)慣一次性把類別、增強(qiáng)、分辨率全改完結(jié)果指標(biāo)掉了也不知道是哪一項(xiàng)的責(zé)任只能全量回滾重來。從那以后我每次改數(shù)據(jù)集只動(dòng)一個(gè)變量先跑 20 輪小實(shí)驗(yàn)看趨勢確認(rèn)有效再進(jìn)全量訓(xùn)練改動(dòng)記錄寫在classes.py的注釋里隔兩個(gè)月回來也能看懂自己當(dāng)時(shí)干了什么。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取