ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

翻越栏杆检测实战:VOC/YOLO数据集清洗与YOLOv8训练全流程

翻越栏杆检测实战:VOC/YOLO数据集清洗与YOLOv8训练全流程

简介:面向翻越栏杆检测任务的目标检测数据集,包含1680张JPEG图片及对应的Pascal VOC与YOLO双格式标注文件,适合需要训练翻越围栏/攀爬行为识别模型的算法工程师、科研人员及计算机视觉学习者。数据集采用labelImg工具画矩形框标注,共定义climbing和person两个类别,总计2454个标注框,其中climbing框2060个、person框394个,覆盖多人、多角度及部分增强场景,可为模型训练提供较充足的样本,并减少格式转换带来的额外工作量。压缩包内含约2000个文件,以jpg图片、xml标注和txt标注为主,整体大小约100.25MB,解压后目录结构清晰,便于按VOC或YOLO流程直接读取使用;同时内置说明文件,便于快速了解标注规则。已有598人学习/下载,适合用于翻越栏杆、周界安防、行为分析等视觉项目的前期数据准备与算法验证。

1. 翻越栏杆围栏检测,先看数据集里到底有什么

翻越栏杆围栏检测,不是让你把照片里的行人框出来,而是要判断“一个人有没有正在跨越栏杆、围栏、闸机口这类边界物”。这个动作在安防场景里是典型的高风险行为,生产现场、工地周界、轨道交通都在找现成的目标检测数据集做基线。标题里的这份数据是 1680 张 VOC+YOLO 格式的翻越栏杆围栏数据集,附带增强副本,意思是除了原始标注,还提供经过翻转、旋转、亮度扰动等手段扩增的样张,适合直接丢进 YOLO 系列做训练。它解决的核心问题很明确:让你不必花两周时间自己标注“翻越/正常”这种边界模糊的动作,直接拿到一套能跑起来的数据,先复现结果,再往自己业务场景迁移。适合谁?正在做周界安防算法、想用 YOLOv8 训练自定义行为检测,以及还没想清楚增强策略该怎么设的工程师。

2. VOC 和 YOLO 两套标注,解压后怎么快速核对

拿到压缩包别急着解压,先说一个背景:VOC 和 YOLO 是目标检测数据集最常见的两种标注载体,VOC 是 PASCAL VOC 项目定义的 XML 格式,YOLO 是 Darknet/YOLO 系列使用的 txt 格式,二者能互转,但很多新手直接把 zip 解开就训练,结果类别名对不上、图片找不到,卡了一整天。常见做法是,先从 zip 里把两个标注目录结构统一列出来,再抽样核对三五个文件,确保 XML 的 object.name 和 txt 的第一列数字是一一对应的。我每次拿到新数据集,都会先用一条 bash 把目录和文件数看清楚,再写个小脚本刷一遍标注,避免后面训练时被黑匣子一样的报错拖死。

2.1 解压后先做三件事:文件数、目录、标注一致

第一件事是确认真实图像数量和匹配的标注数量。1680 张是原始图还是总图数?这类数据集通常会在压缩包里区分 origin 和 augmented,所以不要看到 5050 个jpg 就激动。先用命令把 zip 内部结构和文件数量拉出来:

# 统计压缩包内不同扩展名的文件数量,确认 xml/txt/jpg 是否对齐 unzip -l "${dataset}.zip" | awk '{print $4}' | grep -E '\.(xml|txt|jpg|jpeg|png)$' | awk -F. '{print $NF}' | sort | uniq -c

这里的unzip -l先看包内文件路径前缀,比如JPEGImages/、Annotations/、labels/,然后第二条命令统计每种扩展名的文件数,重点看xml和txt数量是否一致,jpg 数量是否等于 xml 数。很多分享出来的数据集,VOC 和 YOLO 标注会各放在VOC/Annotations与YOLO/labels两个目录,因为当初标注工具导出过一次。看到数字对不上不要急着删文件,先继续往下查。

第二件事是确认图片名与标注名是否同主文件名。常见做法是再写一个两行的 Python 脚本去对比set(),防止出现IMG_0001.xml对应0001.jpg这种命名错位。第三件事是抽查一张 XML,看类别名是person还是cross还是trespass,YOLO 的标签文件里第一列是不是同一个类别。这个只靠肉眼无法全部完成,所以我在下面给了完整核对脚本。

import xml.etree.ElementTree as ET from pathlib import Path xml_root = Path("VOC/Annotations") txt_root = Path("YOLO/labels") img_root = Path("JPEGImages") classes = set() for xml_file in sorted(xml_root.glob("*.xml")): tree = ET.parse(xml_file) for obj in tree.findall("object"): classes.add(obj.findtext("name").strip()) base = xml_file.stem # 同主文件名的图片和 txt 标签必须同时存在 if not (img_root / f"{base}.jpg").exists(): print(f"[missing image] {base}.jpg") txt_file = txt_root / f"{base}.txt" if not txt_file.exists(): print(f"[missing label] {base}.txt") print("classes:", sorted(classes))

这段脚本的目的是在训练之前把最隐蔽的三类问题暴露出来:XML 里的类别名集合、图片缺失、标签缺失。为什么单独把类别打印出来?因为 VOC 转 YOLO 时最常见的坑就是类别顺序,VOC 的name是字符串,YOLO 的class_id是整数,你必须在自己的data.yaml里让names: [person, climb]的顺序和 txt 第一列的 0/1 对齐。如果数据集本身就有person和person_climb两个类,你的 yaml 就要写两个名字,顺序不能乱。

2.2 VOC 的 XML 里藏着哪些字段

接着看一张 XML 的关键字段。一个典型的 VOC 文件长这样:

<annotation> <filename>IMG_0001.jpg</filename> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <object> <name>climbing</name> <bndbox> <xmin>340</xmin> <ymin>220</ymin> <xmax>610</xmax> <ymax>740</ymax> </bndbox> </object> </annotation>

需要看的字段有三个:filename决定图片文件名;size里的宽高决定后续归一化计算的分母;object/name和bndbox决定类别与框坐标。需要注意bndbox是整数像素坐标,左上角和右下角闭区间,很多老标注工具会把xmax写成xmin + width的格式,语义上差一个像素,对模型影响几乎为零,但转 YOLO 时建议统一按xmin, ymin, xmax, ymax处理。

一个文件可能包含多个object,代表一张图里有多个人在翻越,也可能一个框同时框住两个人,这种数据后期识别时会被视为单目标,mAP 算起来会有偏差。用 XML 读取的话,len(tree.findall("object"))是框数量,不是人数。翻越栏杆数据集里还有一类特殊样本,就是一张图里只有半个人跨过栏杆,另一个人的下半身被栏杆完全挡住,这种在标注时通常会只标注可见区域。

2.3 YOLO txt 是怎么从 XML 算出来的

YOLO 标签文件的每一行是class_id x_center y_center width height,所有值都是相对于图片宽高归一化的小数,范围 0~1。转换公式就是:

xc = (xmin + xmax) / 2 / width yc = (ymin + ymax) / 2 / height w = (xmax - xmin) / width h = (ymax - ymin) / height

我给一个严格按上面公式写的 Python 转换函数,顺便加一个越界检查:

import xml.etree.ElementTree as ET def convert_xml_to_yolo(xml_path, class_map, img_w, img_h): tree = ET.parse(xml_path) lines = [] for obj in tree.findall("object"): name = obj.findtext("name").strip() cls_id = class_map[name] box = obj.find("bndbox") xmin = float(box.findtext("xmin")); xmax = float(box.findtext("xmax")) ymin = float(box.findtext("ymin")); ymax = float(box.findtext("ymax")) xc = (xmin + xmax) / 2 / img_w yc = (ymin + ymax) / 2 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h # 防止边缘样本越界,避免训练报 labels out of bounds xc = min(1.0, max(0.0, xc)); yc = min(1.0, max(0.0, yc)) w = min(1.0, w); h = min(1.0, h) lines.append(f"{cls_id} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}") return lines class_map = {"climbing": 0, "normal": 1} lines = convert_xml_to_yolo("VOC/Annotations/IMG_0001.xml", class_map, 1920, 1080) print("\n".join(lines))

参数说明:class_map决定了类别编号,这个映射必须和训练时的data.yaml完全一致;img_w、img_h从 XML 的size节点读取,不建议从图片文件重新读宽高,因为增强后的图片有可能在保存时被压缩,尺寸对不上。最后的越界处理是把宽高钳制到 1.0,实际当中如果漏写了这一行,一个 xmax 等于图片宽度的小数误差可能触发 YOLO 训练时 “labels out of bounds” 的断言报错。

3. 数据增强的取舍:1680 张原始图怎么用出 5000 张的效果

很多人的第一个目标检测数据集都是 1680 张这个规模,直接丢进 YOLOv8 也能训练,但翻越栏杆这个动作有一个天然问题:正样本占比低,栏杆、围栏本身又长得像背景。我一般把增强看成两个层面:经典几何扰动(翻转、旋转、缩放)、光度扰动(亮度、对比度、颜色抖动)和高级组合(马赛克、随机拼贴)。这个数据集里提到的“含增强”指的就是已经做过的变换副本,通常会增加 2~3 倍样本量,但增强不是堆数量,方向错了会让模型学到“左右翻转后被识别成翻越”这种假规律。

3.1 增强方式的适用场景与瓶颈

栏杆翻越数据集里最有效的增强按经验排前三:水平翻转、亮度扰动、随机裁剪马赛克。水平翻转对翻越动作其实有点敏感,栏杆的扶手位置在左右翻转后仍然真实,但人翻越的方向变了,模型应该学到“动作模式”而不是“方向”,所以翻转是安全的;亮度扰动主要防黑夜,安防摄像头很多是红外补光,夜间图偏灰;马赛克增强会让模型被迫在遮挡中寻找人,对栏杆这类长条遮挡物很有帮助。不太建议上来就做大幅旋转或拉伸,因为人会变形,和真实摄像头视角差距太大。

增强方式适合场景翻车点
水平翻转横杆、围栏、闸机场景本身有左右不对称(比如栏杆背后就是墙)时会把墙翻错边
亮度/对比度扰动夜间、逆光、红外补光拉太高会把暗部细节全部吃掉,模型学到“全黑”这个特征
随机裁剪/马赛克多目标遮挡、小目标裁得太狠把“翻越”动作裁成“站立”
旋转 ±10°枪机俯视、球机旋转视角超过 15° 后透视失真,栏杆形变严重
色彩抖动不同墙体和栏杆颜色只对颜色敏感的场景无效,还会引入色偏

我在自己的数据集上遇到的现实就是,效果最好的组合是“水平翻转 + 亮度抖动 + 少量马赛克”,把原始 1680 张扩到 4500 张左右,性价比最高。增强的力度要看图片内容,如果原图本身就有很强的方向信息,比如“禁止翻越”的指示牌文字,那就别做翻转,否则模型会把倒着的文字当成正样本特征。

3.2 训练集/验证集划分和增强范围

增强样本只能进训练集,验证集和测试集必须用原始图。这是很多人会踩的坑:把增强后的图片也随机分到 val 里,然后 val 的 mAP 看起来特别高,骗自己模型很强,实拍视频里却漏检。常见做法是先把原始 1680 张按场景分层划分,比如按“白天/黑夜/室内/室外”各抽 15% 做验证,剩下的做训练,然后再对训练部分做增强。这样验证集里不会出现和训练图几乎一样的翻转副本。

下面的脚本用固定随机种子把 VOC 两个标注目录同时拆分,保证图像和标注一一对应:

import random from pathlib import Path import shutil random.seed(42) img_files = sorted(Path("JPEGImages").glob("*.jpg")) random.shuffle(img_files) val_ratio = 0.15 val_num = int(len(img_files) * val_ratio) val_set = set(img_files[:val_num]) train_set = set(img_files[val_num:]) for split, files in [("train", train_set), ("val", val_set)]: out_img = Path(f"split/{split}/images") out_lbl = Path(f"split/{split}/labels") out_img.mkdir(parents=True, exist_ok=True) out_lbl.mkdir(parents=True, exist_ok=True) for f in files: shutil.copy(f, out_img / f.name) lbl = Path("YOLO/labels") / f.with_suffix(".txt").name if lbl.exists(): shutil.copy(lbl, out_lbl / lbl.name)

这段脚本的逻辑是先打乱所有原始图,再按比例切分出 val 集合。把 val 集合固定成set之后,交给后续增强流程去做判断:如果图片在train_set,才做增强;如果在val_set,原样保留。为什么要用set而不是列表?因为增强脚本会反复查询文件名是否属于训练集,set 的查询复杂度是 O(1),几千张图差距不大,但代码读起来更清晰。随机种子42固定能让每次拆分的边界完全一致,后面跑多次实验对比 mAP 时,即使没有保存拆分文件也能重新生成同样的划分。如果你用 ultralytics 训练,它会自动把 train 目录下的图作为训练集,不需要你手动复制图片到split/train/images,但手动拆分的好处是后续用 OpenCV 写增强逻辑时,可以直接在源目录上操作。

3.3 增强后的分布统计:防止类别失衡和小目标消失

数据增强完成后,先别急着训练,花五分钟统计每个类别的框数量、框面积和长宽比。翻越行为是细长目标,人体跨越时框的长宽比通常在 0.4 到 1.2 之间,如果增强脚本把目标裁成了小方块,模型的 anchor 就匹配不上了。下面统计脚本可以直接输出分布:

import glob import numpy as np boxes = [] for txt in glob.glob("split/train/labels/*.txt"): for line in open(txt): parts = line.strip().split() if len(parts) < 5: continue cls = int(parts[0]); w = float(parts[3]); h = float(parts[4]) # 面积 = 归一化宽乘高,用于判断小目标占比 boxes.append((cls, w, h, w * h)) boxes = np.array([b[1:] for b in boxes], dtype=float) print("box count:", len(boxes)) print("area percentiles:", np.percentile(boxes[:, 2], [10, 50, 90])) print("width percentiles:", np.percentile(boxes[:, 0], [10, 50, 90])) print("height percentiles:", np.percentile(boxes[:, 1], [10, 50, 90]))

输出里w*h是归一化面积,10%分位数如果在 0.01 以下说明有一批极小目标,YOLOv8n 在小目标上效果会打折,建议换更大分辨率训练,或者把这种极小目标样本单独拿出来多增强几轮。长宽比在这里没有直接体现,但看宽度和高度的分位数就能判断是不是细长框:宽度中位数远小于高度中位数,说明动作框是纵向的,这和跨栏姿势相符。如果发现有一类占比特别高,比如 “normal” 占八成、只有两成是 “climbing”,那后面训练时一定要给少数类提高 loss 权重,或者用类别权重参数去加权。

4. 用 YOLO 训练翻栏杆数据集:配置文件、关键参数和一条训练命令

VOC 和 YOLO 格式都核对完了,增强批次也划分好了,接下来就到了把数据集变成模型的过程。我自己最常用的是 YOLOv8 或 YOLO11 的 ultralytics 库,原因很简单:不需要手写数据加载器,YAML 写清楚路径就能跑,数据集结构天然兼容 VOC/YOLO。但这里的“兼容”有个前提——你要把数据组织成images/train/、images/val/、labels/train/、labels/val/这种布局,YOLO 格式的 txt 放在对应 labels 目录下,训练时改一下数据配置文件和两三个超参数就行。

4.1 数据配置文件怎么写

先给一份可以直接改的 data.yaml:

path: ./datasets/climb train: images/train val: images/val names: 0: climbing 1: normal

参数说明:path是数据集根目录,建议填相对路径或绝对路径,但不要填../这种容易在 Docker 容器里跑挂的相对路径;train和val要写相对于path的目录名,不要写带*.jpg的匹配模式,ultralytics 会自己在目录里找图片;names的顺序必须和 txt 第一列的类别编号一致,尤其是这个数据集同时提供 VOC 和 YOLO 两套标注时,很多人直接把names写成["cross"]去跑,结果类别对不上,loss 全部变成 NaN。

如果数据集里存在“含增强”标记但增强文件名字相同,还有个更隐蔽的坑:目录下既有原始图也有增强图,文件名重复会被 ultralytics 自动跳过,实际训练的远远不是 1680 张。我一般会把增强图统一命名为原始名_aug1.jpg这种带后缀的形式,然后用下面的脚本扫一遍目录里的重名:

from collections import Counter from pathlib import Path # 检查同主文件名的图片数量,重复会导致训练集被覆盖 names = [p.stem for p in Path("images/train").glob("*.jpg")] dups = [k for k, v in Counter(names).items() if v > 1] print("duplicate stems:", dups[:10], "count:", len(dups))

重名检查之所以值得单独跑一次,是因为 zip 包在 Windows 上解压时大小写不敏感,IMG_0001.jpg和img_0001.jpg会互相覆盖,Linux 上不会,但训练脚本在 Linux 容器里跑,数据是 Windows 传过来的,这种错位很难一眼看出来。跑完发现大量重复主文件名,就直接在解压阶段统一做一次小写化重命名。

4.2 三个必调参数:imgsz、batch 和类别权重

先说imgsz。翻越栏杆这些目标在监控画面里通常只占整图 5%~10% 的面积,用默认的 640 能跑,但小目标容易丢。我的做法是先跑一次默认 640 作为基线,把 val 的 mAP50 记录下来,如果低于 70%,再把imgsz提到 960 或 1280 重跑,代价是显存占用翻倍。这个数据集有 1680 张原始图,单卡 8G 显存上 960 分辨率配合 batch 8 基本是上限。

第二个是batch。YOLOv8 默认 batch 16,对于 1680 张的小数据集,batch 太大容易过拟合;batch 太小又导致训练时间长、BN 统计不稳定。我常用的组合是batch=8,配合epochs=150和patience=20。patience是早停参数,验证集 mAP 连续 20 轮不涨就停止,这时候基本已经收敛,再跑下去只会记住训练集里的噪声。

第三个是类别权重。这个数据集如果只有“翻越”一个动作类别,其实不用调;但如果像前面统计那样类别不平衡,就要用类别权重去拉一把。Ultralytics 训练接口支持传入class_weights,常见做法是建一个类别到权重的字典,少数类给高权重。我给一个最可行的方案:

from ultralytics import YOLO model = YOLO("yolov8n.pt") weights = {0: 1.0, 1: 0.6} # climbing 少,给相对更高权重 model.train( data="climb.yaml", imgsz=960, batch=8, epochs=150, patience=20, lr0=0.001, class_weights=weights, )

class_weights的取值是靠统计训练集每个类别的框数决定的,少数类给大于 1 的权重,多数类小于 1,但差值不要拉太大,1.0 对 0.6 就够,拉到 3 以上会让模型产生重复检测同一个人的倾向。上面的代码里有一个细节:lr0设成 0.001,比默认的 0.01 小了一个数量级。理由是小数据集用大学习率容易震荡,跨栏动作的边界又模糊,第一次跑我用 0.01 在 20 轮后 loss 直接跳到 NaN,换成 0.001 才稳定。

4.3 训练命令和终端里该看什么

如果你不想写 Python,直接在终端里跑:

# 训练翻栏杆模型,runs 目录保留每次实验的完整结果 yolo train model=yolov8n.pt data=climb.yaml imgsz=960 batch=8 epochs=150 patience=20 lr0=0.001 project=runs name=climb_v1

命令参数和上面 Python 完全一致。project和name最好每次实验换一个名字,这样runs目录下会保留每一轮的结果,后面对比 mAP 时不用重新训练。训练过程中要盯三个东西:train/box_loss曲线是否震荡、val/box_loss是否早停、metrics/mAP50(B)前 10 个 epoch 有没有上涨趋势。如果前 10 轮 mAP50 一直是 0,检查方向优先看数据是不是加载对了;不要先怀疑模型,因为 YOLOv8n 在这种单类数据集上通常 5 轮就能出 0.1 以上的 mAP50。

验证命令也别忘掉,训练完跑一次验证会生成混淆矩阵和各类别 AP:

# 用 best.pt 在原始 val 集上评估,不要用增强后的图片 yolo val model=runs/climb_v1/weights/best.pt data=climb.yaml imgsz=960

验证输出里的all行会看到mAP50(B)和mAP50-95(B)。如果 mAP50 高但有大量漏检,去看类别那一行normal的 AP,说明模型把翻越行为误判成了站立;如果空白 AP 竖直向上,说明模型学会了用背景骗分数。

5. 翻栏杆数据集训练的 5 个常见坑:现象、原因与排查

后面这部分其实才是这次下载数据集真正值得花时间的地方。1680 张图的数据集,训练时间短,你看不到传统超大数据集上没那么明显的坑。我按自己的踩坑频率排了前五个。

5.1 现象:loss 不降,训练早期 mAP50 就是 0

原因:最常见的是 XML 里的类别名和 YOLO txt 的第一列没对齐。比如 VOC 里是person_climb,txt 里第一列是 1,但data.yaml里把names写成了 0: climbing,这样相当于模型在预测一个空类,loss 计算里根本没有真实标签。还有一种是 txt 存在空行或全 0 坐标,ultralytics 会直接跳过那条样本。

解决:用第 2 章的脚本重新刷一遍classes打印,把 yaml 的names改成和 XML 的name一一对应;顺便检查 txt 的行数和 XML 的object数量是否一致。我遇到过 xml 有 5 个框、txt 只有 3 行的数据,这种情况往往是数据集作者做 VOC→YOLO 转换时丢了一部分框,直接换用 VOC 标注再转一次最保险。

5.2 现象:验证集 mAP 很高,实拍视频里漏检严重

原因:这是增强方式和验证集划分共同导致的。如果 1680 张图里原始样本多数是白天摄像头拍的,你做了亮度扰动增强,但验证集还是白天图片,增强后的模型实际没学会黑夜的抗干扰能力。另一种情况是把增强副本放进了 val 集,val 里有一半是训练图的翻转版本,mAP 自然虚高。

解决:把 val 集全部换成原始图,重新计算 mAP。如果实拍视频仍然漏检,从视频里抽 50 帧,用yolo predict看一下,把置信度阈值从默认 0.25 降到 0.1,试着找是阈值问题还是特征问题。阈值降下去能检出来,说明模型学到的特征是对的,只是置信度分布偏低,后面在推理端调阈值就行。

5.3 现象:训练日志报 “assertion failed: labels shape”

原因:这个报错在 ultralytics 常见于标注文件出现小数越界或 nan。翻越栏杆数据集在增强时,如果对 XML 做旋转,但图片是用 OpenCV 的warpAffine转的,XML 里的框坐标没有跟着变换,最后保存的 txt 会出现 xmax > width 的情况。

解决:写一个解析检查脚本,把每一个 txt 里的 5 个值都读出来,校验是否在 [0,1] 区间,遇到越界就打印文件名和行号:

import glob bad = [] for path in glob.glob("labels/**/*.txt", recursive=True): for line_no, line in enumerate(open(path), 1): parts = line.strip().split() if len(parts) != 5: bad.append((path, line_no, "len")) continue cls = int(parts[0]) vals = [float(x) for x in parts[1:]] # 归一化坐标超出 [0,1] 的样本需要重建 if any(v < 0 or v > 1 for v in vals): bad.append((path, line_no, "range")) print(bad[:20])

输出里如果出现range,不要只改这个值,更稳妥的做法是从 VOC 的 XML 重新转一遍 txt。修复一次的成本低,带着坏数据训练到一半再排查,浪费的时间更多。

5.4 现象:模型把“翻越”和“站立靠栏”混淆

原因:静止图片里的动作歧义是这类数据集的固有属性。一个人站在栏杆前和一个人正在跨栏,在若干连续帧里可能看起来几乎一样,标注人员在标注时也可能只凭肉眼判断。数据增强里的随机裁剪如果裁掉了栏杆上部,模型就更容易把部分人体当成“翻越”。

解决:如果业务上允许,把模型输出与时间上下文结合,例如使用连续 3 帧的检测框做位置平滑,框序列从站立变成跨栏再变成另一侧站立才触发报警。或者重新标注时增加第三类stand_near,让模型先学会区分“靠近栏杆”和“翻越动作”。这个数据集如果只有两个类别,那就把训练图片中“靠近栏杆但没跨越”的样本挑出来,单独作为 hard negative 加入训练,数量大概 10% 就够。

5.5 现象:增强后图片文件名重复,训练集实际不足 1680 张

原因:从 zip 解压时,原始图和增强图可能同时叫IMG_0001.jpg,只是在不同目录。你写成images/train时,ultralytics 只保留了其中一个副本,另一个在导入时被覆盖,实际训练数量缩水。

解决:解压后立刻用脚本重命名,给增强图追加_flip、_mosaic等后缀,并且保持同名 xml/txt 同步更新。下面的脚本可以处理这一类的后缀追加:

# 遍历增强目录,给 jpg 和对应的 txt 追加 _aug 后缀,避免与原始图重名 for f in augmented/*.jpg; do base=$(basename "$f" .jpg) if [ -f "augmented/${base}.txt" ]; then mv "augmented/${base}.jpg" "augmented/${base}_aug.jpg" mv "augmented/${base}.txt" "augmented/${base}_aug.txt" fi done

这个 bash 片段简单但有效,注意mv之后同时移动了 xml 或 txt,不要只移动 jpg 不管标签。更彻底的做法是在解压时就用带 hash 的文件名,但 zip 内的路径结构往往已经定死,事后改后缀是成本最低的方案。

6. 从数据集到上线:帧级验证与置信度阈值调整

6.1 先做帧级验证,再谈部署

训练收尾不等于模型能上线。翻越栏杆这种动作,错报和漏报都有实际代价,所以我会在真正部署前加上两个验证环节:帧级测试和阈值-召回走查。从原始 1680 张里再随机抽出 100 张完全没进过训练和验证集的原始图(这份数据集如果有 test 子目录,直接用 test),跑一遍推理,把置信度阈值从 0.05 到 0.5 按 0.05 步进扫一遍,画出召回率-精确率曲线。选择工作点的原则是让误报数量满足业务要求。比如周界报警系统允许每 100 帧出现 1 次误报,那就把阈值定在对应的精度最高点,同时接受漏掉一部分低置信度的翻越行为。实际操作里,这个阈值往往在黑匣子之外,需要反复用真实视频验证。

6.2 时序滤波:单帧检测的后悔药

单帧模型的输出抖动在栏杆场景特别明显,人在跨栏过程中身体前后晃动,框中心点会在栏杆两侧来回跳。一个简单有效的做法是保存最近 5 帧的检测结果,只有当连续 3 帧都是climbing且框中心点 y 坐标单调变化时才触发报警。这个滤波逻辑不复杂,但能消掉一大半因单帧误判造成的告警风暴。我自己的教训是第一次部署时没做时序滤波,结果一个工人在栏杆边摸了摸口袋,被重复报警 37 次;后来加了连续帧确认,漏报率没变,误报率直接下降了 80%。

6.3 导出 ONNX 时保持 imgsz 一致

最后建议你把best.pt导出成 ONNX 再走测试,导出时的imgsz要和训练时保持一致:

# 导出 ONNX,opset 12 对 onnxruntime 兼容性最好 yolo export model=runs/climb_v1/weights/best.pt format=onnx imgsz=960 opset=12

导出的 ONNX 后用 onnxruntime 做推理,能看到每一帧的耗时。如果每帧推理超过摄像头帧间隔,优先降低imgsz到 640 而不是换更小的模型,因为模型变小对细长目标的影响比分辨率降低更明显。希望这套从拆包到调阈值、再到上线的流程能帮到你,至少可以让你拿到数据集后少走我走过的这两步弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进