简介:一套基于YOLO格式的刀具人员目标检测数据集,共1048张真实场景图片,训练/验证/测试划分为1013、25、10张,支持安全监控、智能安防及计算机视觉算法的模型训练与研究。压缩包含2000个文件,核心为950张jpg图像与1048个txt标注文件,另附yaml配置和docx说明文档,可直接对接主流检测框架。包体约40.31MB,轻量便于快速迭代,目前已有149人学习。借助该数据集,开发者可训练识别刀具和人员的检测模型,用于公共场所实时预警与持刀行为识别;同时可用于模型鲁棒性测试、算法优化及教学培训,是覆盖实际场景、标注规范的安防领域数据资源。
1. 刀具人员检测数据集:监控场景最缺的那一档标注数据
园区监控画面里,有人从口袋掏出一把折叠刀,这个动作从出现到结束往往不到一秒。刀具人员检测数据集.zip 这个压缩包,解决的就是这类监控场景下最刚需的一档任务:把画面里的"人"和"刀"同时、稳定地框出来。这个需求落到算法工程上,就是目标检测里的双类别任务,数据集的角色是把你从最费时的标注阶段里解放出来,直接进入格式检查、训练验证的环节。
但解压完直接开训,很容易在真实视频上翻车。我处理这类数据集的固定流程是:先查标注格式、类别定义、样本分布,再做格式转换和按场景划分数据,然后小模型跑基线、针对性调参数,最后拿视频抽帧做压测。这篇笔记按这个流程展开,每步都会给出能直接抄的脚本和参数,也把常见坑按现象、原因、解决的方式写清楚。
2. 拆开压缩包先查三件事:标注格式、类别定义、样本分布
目标检测训练对很多人来说是个黑匣子,但数据不是。拿到压缩包之后,我习惯先花十分钟看内部结构,而不是急着解压到训练目录。标注格式决定你要不要写转换脚本,类别定义决定模型学到的语义边界,样本分布直接影响后面的训练分辨率、增强策略和类别不平衡处理方案。这三样查完,才谈得上开训。
2.1 标注格式决定算法选型:VOC、COCO、YOLO txt 怎么互相认
第一步是列出压缩包内的目录结构,看它属于哪一种标注体系:
unzip -l 刀具人员检测数据集.zip | head -60这个命令只列出压缩包前 60 行文件清单,不用先占地解压。看输出里的顶层目录名就能判断格式:出现JPEGImages和Annotations两个目录,基本是 VOC 格式;出现images和labels两个平级目录,大概率是 YOLO txt 格式;如果只有一个很大的.json文件挂在根目录,那就是 COCO 格式。三种格式对后续训练的接入成本差别很大:
| 格式 | 目录/文件特征 | 标签内容 | 训练前要做的事 |
|---|---|---|---|
| VOC | Annotations/*.xml | 像素坐标,xmin/ymin/xmax/ymax | 转成 YOLO txt,或走转换工具 |
| COCO | 单个 .json | id + bbox,可能带 segmentation | 转换成 YOLO txt,或用适配接口 |
| YOLO txt | labels/*.txt | 归一化坐标,class cx cy w h | 基本可以直接开训 |
VOC 的 xml 里还会带一个<name>字段,写着类别名;COCO 的 json 里有categories数组维护类别 id 和名称的对应关系;YOLO txt 则只存类别 id 和归一化坐标,类别名全靠data.yaml里的names字段解释。也就是说,YOLO txt 本身不带语义信息,如果你搞不清第 0 类到底是 person 还是 knife,后面的训练全乱。
2.2 先算样本分布再训练:类别数量、目标面积、有效标注占比
格式确认后,我一般会先跑一个统计脚本,看看这个数据集里到底有多少框、小目标占比多高、有没有大量没有标签的图片。假设目录里是 YOLO txt 格式,脚本如下:
import os from collections import Counter label_dir = "labels/train" class_counter = Counter() small_target, total_obj = 0, 0 img_with_obj, img_total = 0, 0 for txt_name in os.listdir(label_dir): img_total += 1 path = os.path.join(label_dir, txt_name) with open(path) as f: lines = [line.strip() for line in f if line.strip()] if lines: img_with_obj += 1 for line in lines: parts = line.split() if len(parts) < 5: continue cls = int(parts[0]) w = float(parts[3]) h = float(parts[4]) class_counter[cls] += 1 total_obj += 1 if w * h < 0.01: small_target += 1 print("类别数量:", dict(class_counter)) print("有效标注图片占比: {:.2f}%".format(img_with_obj / img_total * 100)) print("小目标占比: {:.2f}%".format(small_target / total_obj * 100))脚本逻辑很简单:遍历labels/train下每个 txt,按行解析出类别 id 与归一化宽高,分别统计类别数量、有效标注图片占比、小目标占比。w * h是框面积占整张图的比例,阈值 0.01 表示目标面积不足整图的 1%。折叠刀这类刀具在监控画面里通常就落在这个区间,所以当小目标占比超过 50% 时,后面 imgsz 参数就必须认真对待。有效标注图片占比低于 60% 时,说明数据堆里混进了大量无标签背景图,这类图会让 loss 曲线变得非常难读,后面避坑章节会再展开。
2.3 类别定义里最容易踩的歧义:持刀、藏刀、刀尖朝向算同一类吗
统计完数量,还要确认标注语义。同样是"刀具",标注者手下可能有两种完全不同的标准:一种是只把"刀在手上"的瞬间标出来,另一种是"画面里出现刀"就算,哪怕刀放在桌上。这两个标准在业务上是两回事。如果需求是"发现有人持刀闯入",而数据里大量是"桌上放着刀",模型训出来后会在监控里频繁报警,这就是典型的标注口径与业务口径不一致。
查类别定义不需要复杂的分析,两类命令就能看明白。VOC 格式先看类别名:
grep -h "<name>" Annotations/*.xml | sort | uniq -cYOLO txt 格式看类别 id:
awk '{print $1}' labels/train/*.txt | sort | uniq -c对 YOLO txt 来说,类别 id 只应该出现 0 和 1(对应 person 和 knife)。如果出现 2、3,或者 VOC 的<name>里出现knife_wield、weapon、Knife这种变体,说明数据集内部语义是分裂的。我一般的处理策略是:把所有表示"刀"、但写法不同的标签统一映射到同一个 id,把语义明显不同的子类拆出去或者直接剔除,绝不打乱类别顺序直接训练。
3. 把标注转成模型能吃的结构:VOC 转 YOLO、按场景划分、目录校验
格式和类别定义确认清楚后,下一步是把数据统一成训练工具能直接读取的结构。最省事的做法是转成 YOLO txt 这套:images/{train,val}放图片,labels/{train,val}放同名 txt。这个阶段有三个容易踩坑的点:坐标转换时处理脏数据、按场景划分而不是随机划分、data.yaml 里的路径和类别名必须和实际目录完全一致。
3.1 VOC 转 YOLO 格式:坐标归一化与目录组织
如果原始数据是 VOC,需要先把 xml 里的像素坐标转成归一化坐标。这里给出一个可以直接改路径就用的转换脚本:
import os import glob import xml.etree.ElementTree as ET # 类别名到 id 的映射,顺序必须和后续 data.yaml 保持一致 class_map = {"person": 0, "knife": 1, "Knife": 1} voc_dir = "Annotations" out_dir = "labels/train" os.makedirs(out_dir, exist_ok=True) for xml_path in glob.glob(os.path.join(voc_dir, "*.xml")): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text if name not in class_map: print(f"跳过未定义类别: {name} -> {xml_path}") continue cls_id = class_map[name] box = obj.find("bndbox") xmin = float(box.find("xmin").text) ymin = float(box.find("ymin").text) xmax = float(box.find("xmax").text) ymax = float(box.find("ymax").text) # 钳制坐标到图像边界内 xmin = max(xmin, 0) ymin = max(ymin, 0) xmax = min(xmax, img_w - 1) ymax = min(ymax, img_h - 1) # 过滤反向框和零面积框 if xmax <= xmin or ymax <= ymin: continue x_center = ((xmin + xmax) / 2) / img_w y_center = ((ymin + ymax) / 2) / img_h box_w = (xmax - xmin) / img_w box_h = (ymax - ymin) / img_h # 归一化后再次钳制,防止越界产生负样本 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) box_w = min(max(box_w, 0.0), 1.0) box_h = min(max(box_h, 0.0), 1.0) lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}") 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))逻辑上,VOC 的 bndbox 是像素绝对值,YOLO 需要的是相对值,所以先求中心点和宽高,再除以图像宽高完成归一化。脚本里三个细节比较关键:class_map中把knife和Knife都映射到 id 1,避免同一个类别被拆成两个类;xmax <= xmin这种反向框直接跳过,这类脏数据在真实标注里不少见;归一化后再做一次min/max钳制,防止个别越界坐标把训练数据搞出 NaN。跑完后建议抽查十来个 txt,肉眼确认坐标在 0 到 1 之间。
3.2 按场景划分数据:随机划分文件会让验证集作弊
数据划分是很多人忽略、但影响最大的一个环节。如果图像是从视频里按帧抽出来的,同一个场景的相邻帧高度相似,随机按文件划分会让训练集和验证集里出现同一场景的帧,验证集的 mAP 会虚高,到实拍视频上立刻打回原形。所以正确做法是按场景划分,通常是按文件名前缀或者子目录归属来分组:
import os import random from collections import defaultdict random.seed(42) image_dir = "images" scene_groups = defaultdict(list) for img in os.listdir(image_dir): if not img.endswith(".jpg"): continue scene_id = img.split("_")[0] # 假设文件名格式 scene_id_xxxx.jpg scene_groups[scene_id].append(img) scene_ids = list(scene_groups.keys()) random.shuffle(scene_ids) train_scenes = set(scene_ids[: int(len(scene_ids) * 0.8)]) os.makedirs("images/train", exist_ok=True) os.makedirs("images/val", exist_ok=True) os.makedirs("labels/train", exist_ok=True) os.makedirs("labels/val", exist_ok=True) for scene_id in scene_ids: split_dir = "train" if scene_id in train_scenes else "val" for img in scene_groups[scene_id]: base = os.path.splitext(img)[0] img_src = os.path.join("images", img) lbl_src = os.path.join("labels", base + ".txt") if not os.path.exists(lbl_src): print(f"警告:{img} 没有对应标签,已跳过") continue os.rename(img_src, os.path.join(f"images/{split_dir}", img)) os.rename(lbl_src, os.path.join(f"labels/{split_dir}", base + ".txt"))脚本逻辑是先把图片按场景前缀分组,再打乱场景列表,取前 80% 的场景进训练集,其余进验证集。这样同一个场景的所有帧只会落在同一侧,验证结果更能反映真实泛化能力。注意两个参数:random.seed(42)固定随机种子,保证多次实验划分一致;0.8是训练比例,如果标注总量少,建议改成 0.9。移动和重命名操作执行前,先给原始目录留个备份,不然想回退都没有后悔药。
3.3 写 data.yaml 与目录校验:先跑通 1 个 epoch
数据划分完成后,写训练配置。yolo 工具链通过 data.yaml 定位数据集,内容如下:
path: ./knife_person_dataset train: images/train val: images/val nc: 2 names: 0: person 1: knife这里几个容易出问题的点:path建议写相对路径或者绝对路径,不要写带空格的路径,工具链对空格的处理在不同版本里表现不一致;train和val指向图片目录,工具会自动找同级的 labels 目录,所以不要手动把 labels 路径写进去;names的 id 顺序必须和转换脚本里的class_map完全一致,否则类别就串了。
目录校验可以用一个简单的 bash 循环,检查每张图是否都有对应标签:
for f in images/train/*.jpg; do b=$(basename "$f" .jpg) if [ ! -f "labels/train/$b.txt" ]; then echo "missing label: $b" fi done这个循环只做一件事:遍历训练图片,把缺少同名 txt 的图片打印出来。缺失列表为空再继续。通完之后跑一个 1 个 epoch 的训练,确认数据管线真的通了:
yolo detect train model=yolov8n.pt data=data.yaml epochs=1 imgsz=640这一步不是真的训练模型,而是让工具完整走一遍读图、读标签、建 batch 的流程。loss 能正常打印出来,说明图片尺寸、标签格式、类别数量全部对上了;如果报错,多半是标签越界、图片损坏或者类别 id 超出 nc。
4. 在本地跑通训练:模型选型、四个必调参数与类不平衡处理
数据管线通掉之后,训练本身反而简单。但很多人一上来就选最大的模型,结果显存溢出、训练时间以天计。对于人员加刀具这种场景,我更倾向于先用小模型跑基线,再根据基线的短板决定要不要加大模型。
4.1 模型选型:n 版起步,用基线决定要不要换 s 版
人员是大目标,检测难度不大;刀具是典型的小目标,难度全在它身上。所以我通常先用 n 版这种轻量模型跑 50 轮,拿到基线的 mAP 和每类 recall。如果 recall 满足业务要求,就继续用 n 版做部署,省下来的推理时间比模型精度更宝贵;如果刀具 recall 明显不够,再换 s 版或者把输入分辨率提上去,而不是盲目加大模型。
yolo detect train model=yolov8n.pt data=data.yaml epochs=50 imgsz=640 batch=16 patience=15 project=runs/train这个命令是完整的训练入口。model=yolov8n.pt表示用轻量预训练权重做起点,显存 8G 也能跑;project=runs/train把每次实验的结果分开存放,方便对比多组实验,我一般还会加上name=exp_base之类的小标签区分版本。
4.2 四个必调参数:imgsz、batch、epochs、patience
小目标漏检在目标检测里属于玄学重灾区,但大多数情况下能归因到输入分辨率不够。这四个参数是我每次训练前都会确认的:
| 参数 | 推荐起点 | 说明 |
|---|---|---|
| imgsz | 640 | 低于 640 时,刀具在输入图上只剩几个像素,建议 640 起步,漏检严重且有显存再上 960 |
| batch | 8~16 | 8G 显存用 8,16G 显存用 16;imgsz 提到 960 时 batch 减半 |
| epochs | 50 | 先跑 50 轮看趋势,val 指标还在涨就加到 100 |
| patience | 15 | val 指标连续 15 轮不涨就早停,防止过拟合 |
解释一下 imgsz 为什么对刀具这么敏感。1920x1080 的画面里,一把折叠刀的长边可能只有 60 像素;缩到 640 输入时只剩 20 像素,再经过模型多次下采样,最后特征图上一个目标可能连一个 anchor 都覆盖不到。这就是"理论上能检测、实际一直漏"最常见的原因。如果你的显卡能承受,这类数据集直接上 960 是最省事的提升手段。
4.3 类不平衡处理:过采样、mosaic 混入与多尺度训练
统计结果里如果刀具框占比很低(比如不到总框数的 5%),模型会倾向于把人检得很准、把刀当噪声忽略掉。处理办法有三个依次叠加:过采样含刀图片、利用 mosaic 增强提高刀具出现概率、保持多尺度训练。
给一个简单但有效的过采样脚本,把包含刀具标签的图片在训练目录里复制一份:
for f in labels/train/*.txt; do if grep -q "^1 " "$f"; then b=$(basename "$f" .txt) cp "$f" "labels/train/${b}_dup.txt" cp "images/train/${b}.jpg" "images/train/${b}_dup.jpg" fi done脚本逻辑是逐行检查 txt 的第一列,^1表示类别 id 为 1,也就是 knife。只要某张图里有刀,就把图片和标签一起复制,文件名加_dup后缀。这样每轮 epoch 里含刀图片的出现概率直接翻倍。注意这个脚本只能执行一次,重复执行会在_dup的基础上再复制一份,数据重复度过高会带来过拟合。mosaic 增强属于训练工具默认开启的策略,四张图拼成一张输入,局部目标变小但多样性大增;如果工具链支持copy_paste=0.1这类参数,可以顺手加上,让刀具目标被粘贴到其他场景上。多尺度则一般不用手动开,常见版本训练时会在 0.5~1.5 倍 imgsz 之间随机缩放,对小目标天然友好。
训练完成后不要只盯着 mAP,还要看每个类别的 recall。yolo 训练日志里会输出 per-class 的指标,重点看 knife 那一行的 recall。如果 mAP 高但 knife recall 低,说明模型学会了大部分容易检的样本,难的、遮挡的、小尺寸的还没学好,这时候优先调数据而不是调模型。
5. 刀具人员检测的避坑记录:数据干净比模型先进更值钱
这部分写的是我处理类似安全检测项目时实际踩过的坑,每一条都按现象、原因、解决来写。很多问题在训练日志里看不出来,只有拿到真实场景里才会暴露。
5.1 翻车一:验证集 mAP 很高,实拍视频里刀具框乱跳
现象是训练阶段 mAP@0.5 到了 0.9 以上,拿到摄像头视频上一测,刀框时有时无,相邻几帧一颗有、一颗没有,完全没法直接用。原因有两层:一是验证集划分不干净,随机划分把同场景帧放进了训练集和验证集,指标虚高;二是训练图大多是清晰的静态图,而视频帧里有运动模糊、光照变化,小目标的纹理信息被抹掉了。解决方法是把数据划分改成按场景分组,imgsz 从 640 提到 960,同时从目标场景视频里抽帧补进训练集,每 10 帧抽 1 帧,专门挑那些刀身有运动模糊的帧。视频里来的数据质量不高,但对提升推理稳定性非常有效。
5.2 翻车二:把扳手、手机、水杯当成了刀具
现象是负样本视频里根本没有刀,模型却频繁报出 knife 框,框住的往往是扳手、手机这类长条形物体。原因是 knife 类训练样本太少,模型学到的不是"刀具"而是"深色长条物体"这种粗糙特征。解决思路是收集易混物体的负样本,把包含扳手、手机、螺丝刀的帧单独整理出来,放进图片目录但不要给标签。yolo 训练时会跳过没有对应标签的图片,只给一次警告,它们不会参与 loss,但会在数据加载层面提供背景多样性。更彻底的办法是训练结束后专门用一段负样本视频做误报率统计,把误报率压到 2% 以下再考虑上线。
5.3 翻车三:person 和 knife 的框几乎重叠,模型不知道该学哪个
持刀场景里,刀在手上,刀具框和人体框大面积重合,目标检测的 NMS 后处理要求一个位置只保留一个框,小框很容易被大框过滤掉。于是出现一种常见结果:人框得很稳,刀框偶尔冒出来又消失。这在模型结构上很难完全规避,所以要从数据和任务定义两个方向解决。数据层面,给刀画框时尽量贴紧刀身,不要留大 padding,被手部遮挡一半的刀宁可不要标注也别标出半个影子框;任务层面,如果业务只关心"这个人有没有持刀",可以退一步改成两阶段方案:先检出人,再对裁剪出的人体区域做刀/非刀分类,这个方案对遮挡和小目标更友好。
5.4 翻车四:类别名大小写不一致,训练直接崩或出现未知类别
现象是转换脚本跑完后,训练第一轮就报类别索引越界,或者日志里类别数不是 2 而是 3。原因通常是 xml 里同一个刀一会儿写knife、一会儿写Knife,或者某张图里混进了person_1这种多余标签。解决方式是在转换脚本里把所有别名统一映射,就像第 3.1 节 class_map 里做的那样;转换完之后再跑一遍全量检查:
awk '{print $1}' labels/train/*.txt | sort -u这个命令把所有标签的类别 id 汇总去重,输出结果只能是 0 和 1。如果出现其他数字,回到源头查是哪张图的标签写错了,直接修正或剔除,不要带着脏标签往下走。
5.5 翻车五:数据集里混入大量没标注的纯背景图,loss 一直不降
现象是训练到第 30 轮,loss 还在高位震荡,val mAP 几乎没有变化。原因是有批次采集图像时没来得及打标,整批图片直接进了训练集。图片里有目标却不给标签,等于告诉模型"这里什么都没有",大量这样的图会压住真正目标产生的梯度。用第 2.2 节的统计脚本先看有效标注图片占比,如果低于 60%,很可能就是这批数据在拖后腿。把这些无标签图从训练目录里移走,单独做成负样本池,只保留已标注图片参与训练。我一般要求训练集里已标注图片占比保持在 90% 以上,loss 曲线才会稳定。
6. 上线前最后一公里:抽帧压测、误判统计与 ONNX 导出
训练指标只是参考,真实场景视频里的行为才是验收标准。我拿到best.pt后的第一件事,不是接摄像头,而是做抽帧压测。
import cv2 from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") cap = cv2.VideoCapture("scene_knife.mp4") fps = int(cap.get(cv2.CAP_PROP_FPS)) idx, total, hit = 0, 0, 0 while cap.isOpened(): ok, frame = cap.read() if not ok: break if idx % fps == 0: total += 1 labels = [model.names[int(c)] for c in model.predict(frame, conf=0.25, verbose=False)[0].boxes.cls.tolist()] if "knife" in labels: hit += 1 idx += 1 cap.release() print(f"抽帧{total},检出{hit},召回率{hit / max(total, 1):.2%}")脚本逻辑是每秒抽 1 帧,对每帧做推理并记录类别列表,最后统计检出刀具的帧占比。注意idx % fps这个抽帧间隔,如果场景里刀具出现时间不足 1 秒,抽帧太稀会直接把刀漏掉,这类快动作场景建议改成idx % max(fps // 5, 1),也就是大约每 5 帧抽 1 帧。压测时准备两段视频:一段是预期出现刀具的正样本,看召回率;一段是纯人员走动、没有刀具的负样本,把脚本里的hit判定改成"出现 knife 即误报",看误报率。我自己的验收线一般是正样本召回 80% 以上、负样本误报帧占比 2% 以下,达不到就回到数据和增强层面调。
压测通过后,导出 ONNX 做部署验证:
yolo export model=runs/detect/train/weights/best.pt format=onnx opset=12导出时的 imgsz 会固化在模型里,部署端输入尺寸必须和训练一致,不然会有明显精度损失。我习惯在导出参数里显式写imgsz=640,避免工具自动推断出意外值。
我第一次接这类安全检测项目时,只看了验证集 mAP 就安排部署,结果现场把矿泉水瓶当刀报警,后来养成的习惯就是:任何模型上线前必须过一段真实场景视频的抽帧压测,指标只当参考,视频里的行为才是验收标准。这个流程现在一直在用,希望帮到你。
本文还有配套的精品资源,点击获取