ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

YOLO人脸检测实战:1853张带标签数据集的训练前体检与调参指南

YOLO人脸检测实战:1853张带标签数据集的训练前体检与调参指南 简介这份资源面向从事目标检测的算法工程师、学生与研究者提供一套可直接用于人脸检测任务训练的YOLO系列数据集解决从零标注数据耗时、格式不统一的问题。压缩包共2000个文件约90.78MB其中1646个xml文件为VOC格式标注354个txt文件为YOLO格式标注两种标签分别存放便于按需选用。数据集已划分好训练与验证集并附带data.yaml配置文件可无缝接入yolov5、yolov8、yolov9、yolov7、yolov10及yolo11等主流框架。YOLO格式采用归一化的中心点与宽高比例值类别索引从0开始符合标准训练输入要求。目前已有357人学习下载适合希望快速验证模型、开展人脸检测实验或进行算法对比的读者能省去数据清洗与格式转换环节直接投入训练与测试。1. 拿到一份 1853 张带标签的人脸数据集先别急着 train很多人拿到「yolo算法-人脸检测数据集-1853张图像带标签」这类压缩包第一反应是解压、改 data.yaml、直接yolo train。我见过太多人这么干结果 mAP 卡在 0.6 上不去回头怀疑模型、怀疑显卡就是不怀疑数据本身。1853 张这个量级很微妙它比玩具数据集大能训出东西又比工业数据集小任何标注噪声、类别失衡、尺寸分布偏移都会被放大成训练曲线上的抖动。人脸检测这个任务本身也有它的特殊性——目标尺度跨度极大近景人脸能占满半张图远景人脸可能只有十几个像素而 YOLO 的 anchor 和特征金字塔对这种跨度是有脾气的。这篇文章面向的是手上已经有一份「图像 标签」人脸数据集、想用 YOLO 系列跑通检测的从业者。我会按「先看清数据长什么样 → 再决定怎么切分和转换 → 然后配训练参数 → 最后排错和验证」的顺序讲中间给可直接抄的命令和脚本。不假设你用的是哪个具体仓库只讲通用做法你按自己环境替换路径即可。核心判断是1853 张带标签人脸数据能不能训出可用的检测器取决于你在训练前对数据做了多少「体检」而不是训练时调了多少超参。2. 先给数据集做体检1853 张到底够不够、脏不脏在写任何训练命令之前我会先花半小时把数据集的统计特征跑出来。这一步不做后面所有调参都是盲调。人脸检测数据集常见的坑集中在三处标注框越界或宽高为负、单图人脸数量极端不均、图像尺寸和长宽比分布和你的推理场景不匹配。1853 张这个规模如果标注质量高、场景集中训一个单类人脸检测器是够的如果标注框大量偏移那再多图也白搭。2.1 用脚本统计标注框的宽高分布和越界情况假设你的标签是 YOLO 格式的 txt每行class cx cy w h归一化到 0~1先跑一段统计。这段代码不依赖任何训练框架纯 Python 标准库就能跑目的是把「黑匣子」打开。import os import glob # 标签目录按你的实际路径改 label_dir ./labels/train img_dir ./images/train w_list, h_list, per_img_counts [], [], [] bad_boxes [] # 记录越界或非法框 for txt_path in glob.glob(os.path.join(label_dir, *.txt)): with open(txt_path, r) as f: lines [l.strip() for l in f if l.strip()] per_img_counts.append(len(lines)) for idx, line in enumerate(lines): parts line.split() if len(parts) ! 5: bad_boxes.append((txt_path, idx, 字段数不对)) continue cls, cx, cy, w, h parts cx, cy, w, h map(float, (cx, cy, w, h)) # 归一化坐标合法范围判断 if not (0 cx 1 and 0 cy 1): bad_boxes.append((txt_path, idx, 中心点越界)) if w 0 or h 0 or w 1 or h 1: bad_boxes.append((txt_path, idx, 宽高非法)) w_list.append(w) h_list.append(h) print(f图像数: {len(per_img_counts)}) print(f总框数: {sum(per_img_counts)}) print(f单图最多人脸: {max(per_img_counts)}, 最少: {min(per_img_counts)}) print(f平均每图: {sum(per_img_counts)/len(per_img_counts):.2f}) print(f宽 min/mean/max: {min(w_list):.4f}/{sum(w_list)/len(w_list):.4f}/{max(w_list):.4f}) print(f高 min/mean/max: {min(h_list):.4f}/{sum(h_list)/len(h_list):.4f}/{max(h_list):.4f}) print(f非法框数量: {len(bad_boxes)}) for b in bad_boxes[:10]: print(b)逻辑说明遍历所有标签文件把每张图的框数、每个框的归一化宽高收集起来。bad_boxes专门抓三类问题——字段数不对说明标注工具导出格式不一致、中心点越界常见于标注时拖拽超出图像边界、宽高非法负值或大于 1。参数上label_dir和img_dir按你解压后的实际结构改YOLO 常见结构是images/train配labels/train。跑完你会得到几个关键数字。如果单图最多人脸是 50 而平均只有 2说明存在极端密集场景训练时 mosaic 增强会把小脸进一步缩小需要留意。如果宽 min小于 0.01意味着有小于图像 1% 宽度的人脸这类目标在 640 输入下只有 6 个像素YOLO 的 P3 特征层也救不回来要么提高输入分辨率要么在数据层面过滤掉。非法框数量不为零就必须先修否则训练时 loss 会出现 NaN 或者框回归直接跑飞。2.2 图像尺寸和长宽比分布决定输入分辨率第二个体检项是图像本身的尺寸。人脸检测数据集经常是混合来源——一部分是监控截图16:9一部分是证件照3:4还有一部分是网络爬取的任意尺寸。YOLO 训练时默认rectFalse会统一 letterbox 到正方形长宽比差异大的图会被填充大量灰边有效像素占比下降。from PIL import Image import glob import os sizes [] for img_path in glob.glob(os.path.join(img_dir, *)): try: with Image.open(img_path) as im: sizes.append(im.size) # (宽, 高) except Exception as e: print(f打不开: {img_path}, {e}) ratios [w / h for w, h in sizes] print(f图像数: {len(sizes)}) print(f宽范围: {min(s for s, _ in sizes)} ~ {max(s for s, _ in sizes)}) print(f高范围: {min(h for _, h in sizes)} ~ {max(h for _, h in sizes)}) print(f长宽比 min/mean/max: {min(ratios):.2f}/{sum(ratios)/len(ratios):.2f}/{max(ratios):.2f})逻辑说明用 PIL 只读文件头拿尺寸不加载像素1853 张几秒跑完。ratios反映长宽比离散程度。如果 mean 在 1.0 附近且 min/max 在 0.8~1.25 之间说明数据接近正方形imgsz640直接训没问题。如果出现 0.3 或 3.0 这种极端值建议开rectTrue做矩形训练减少填充浪费但代价是 batch 内尺寸不一致会拖慢一点速度。提示这一步的结论直接决定你后面imgsz设 640 还是 960。人脸小且密集就往上加但显存要跟着算。2.3 训练集验证集切分别用随机切分骗自己1853 张如果随机 8:2 切分很容易出现「同一个人、同一场景的连续帧」被分到训练和验证两边验证集 mAP 虚高上线就翻车。人脸数据尤其如此如果是从视频抽帧来的相邻帧几乎一样。我的做法是按来源或按时间切分没有来源信息时至少做一次去重。import hashlib import glob import os import shutil def file_md5(path): h hashlib.md5() with open(path, rb) as f: h.update(f.read()) return h.hexdigest() seen {} dups [] for p in glob.glob(os.path.join(img_dir, *)): m file_md5(p) if m in seen: dups.append((p, seen[m])) else: seen[m] p print(f重复图像对数: {len(dups)}) for d in dups[:5]: print(d)逻辑说明用 MD5 做精确去重能抓出完全相同的文件。更严格的做法是感知哈希pHash抓近似重复但 MD5 先跑一遍成本低。如果重复对超过总数的 5%说明数据集有采集冗余切分前先去重否则验证集里混入训练集副本指标没有参考意义。参数上img_dir指向全部图像目录去重后再做切分。3. 标签格式转换与 data.yaml把 1853 张喂给 YOLO 的正确姿势体检做完数据干净了接下来是格式对齐。YOLO 系列v5/v8/v11 及衍生吃的是「图像 同名 txt」结构txt 每行class cx cy w h。如果你拿到的标签是 VOC XML 或 COCO JSON就得转。人脸检测通常只有一类class恒为 0但有些数据集会把「人脸」和「人脸关键点」混在一起转换时要过滤。3.1 VOC XML 转 YOLO txt 的脚本与四个边界坑VOC 格式的 XML 里是绝对坐标xmin ymin xmax ymax转 YOLO 要归一化。这段脚本处理单类人脸顺手把越界框裁到图像范围内。import xml.etree.ElementTree as ET import os import glob from PIL import Image xml_dir ./annotations out_dir ./labels_all os.makedirs(out_dir, exist_okTrue) for xml_path in glob.glob(os.path.join(xml_dir, *.xml)): tree ET.parse(xml_path) root tree.getroot() # 从 XML 里读图像尺寸 size root.find(size) img_w int(size.find(width).text) img_h int(size.find(height).text) lines [] for obj in root.findall(object): name obj.find(name).text.strip().lower() # 只保留人脸类按你数据集的实际类名改 if name not in (face, human_face, 人脸): continue bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) # 坑1坐标裁到图像范围内 xmin max(0, min(xmin, img_w - 1)) ymin max(0, min(ymin, img_h - 1)) xmax max(0, min(xmax, img_w - 1)) ymax max(0, min(ymax, img_h - 1)) # 坑2裁完可能宽高为 0跳过 if xmax xmin or ymax ymin: continue cx (xmin xmax) / 2.0 / img_w cy (ymin ymax) / 2.0 / img_h w (xmax - xmin) / img_w h (ymax - ymin) / img_h # 坑3归一化后仍可能因浮点误差略超 1夹一下 cx, cy min(max(cx, 0), 1), min(max(cy, 0), 1) w, h min(max(w, 0), 1), min(max(h, 0), 1) lines.append(f0 {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) # 坑4没有人脸框的图也要生成空 txt否则训练时找不到标签会报错 out_name os.path.splitext(os.path.basename(xml_path))[0] .txt with open(os.path.join(out_dir, out_name), w) as f: f.write(\n.join(lines))逻辑说明四个坑分别是坐标越界、裁完零面积、归一化浮点溢出、空标签文件缺失。参数上xml_dir是 VOC 标注目录out_dir是输出 txt 目录。类名过滤那行按你数据集实际写如果 XML 里类名五花八门先跑一遍统计所有name值再决定保留哪些。空 txt 这点很多人忽略YOLO 训练时如果某张图没有对应标签文件部分实现会直接跳过该图等于白白浪费了负样本。3.2 data.yaml 的字段含义与路径写法转换完写data.yaml。这个文件看着简单路径写错是最常见的翻车点。# data.yaml path: /abs/path/to/dataset # 数据集根目录绝对路径最稳 train: images/train # 相对 path 的训练图像目录 val: images/val # 相对 path 的验证图像目录 nc: 1 # 类别数人脸检测就是 1 names: 0: face # 类别名和 txt 里的 class 0 对应逻辑说明path用绝对路径避免训练时工作目录变化导致找不到文件。train和val是相对path的路径指向图像目录YOLO 会自动去找同级的labels目录把images替换成labels。nc和names必须和标签里的 class 索引一致人脸单类就是nc: 1。如果你的目录结构不是images/labels平行而是所有图在一个文件夹、所有标签在另一个文件夹那就得在 yaml 里写绝对路径或者提前用脚本整理成标准结构。注意names的键是整数YAML 里写0: face没问题但别写成字符串0某些解析器会报类型错误。3.3 用一行命令验证标签和图像是否一一对应转换和 yaml 都就绪后跑一个对应性检查避免训练到一半才发现有图无标签或有标签无图。# 统计图像和标签数量按你的路径改 ls images/train | sed s/\.[^.]*$// | sort /tmp/img_names.txt ls labels/train | sed s/\.txt$// | sort /tmp/lbl_names.txt # 找出有图无标签的 comm -23 /tmp/img_names.txt /tmp/lbl_names.txt | head -20 # 找出有标签无图的 comm -13 /tmp/img_names.txt /tmp/lbl_names.txt | head -20逻辑说明sed去掉扩展名sort后comm做集合差。第一组输出是「有图无标签」这些图训练时会被当负样本或跳过取决于实现第二组是「有标签无图」属于脏数据直接删标签。参数上把images/train和labels/train换成你的实际路径验证集同理再跑一遍。4. 训练参数怎么设1853 张人脸数据的 epoch、imgsz 与增强数据就绪进入训练。1853 张这个量级我的经验是 epoch 不宜太少因为单 epoch 迭代次数有限模型还没看够就停了但也不宜盲目堆到 300容易过拟合。关键参数是imgsz、batch、epochs和增强开关下面逐个说清楚为什么这么设。4.1 imgsz 的选择人脸像素尺寸说了算imgsz决定输入网络的分辨率直接影响小脸能不能被检测到。判断依据是你在 2.1 里统计的宽 min。如果最小人脸归一化宽度是 0.02在imgsz640下就是 12.8 像素YOLO 的 P3 层 stride 8特征图上约 1.6 个格子勉强能学如果最小是 0.01640 下只有 6.4 像素基本学不动得把imgsz提到 960 或 1280。# 以 640 为基准训练路径按实际改 yolo detect train \ data./data.yaml \ modelyolov8n.pt \ imgsz640 \ epochs150 \ batch16 \ workers4 \ project./runs_face \ nameexp_640逻辑说明model用预训练权重起步人脸检测和通用目标检测有共性预训练能省不少 epoch。batch16是 8G 显存的保守值显存够可以往上加但要注意 batch 变大时学习率通常也要跟着调。workers是数据加载线程设成 CPU 核数的 1/4 到 1/2。project和name决定输出目录方便对比不同实验。如果 640 训完验证集小脸漏检严重换 960 再训一版对比yolo detect train \ data./data.yaml \ modelyolov8n.pt \ imgsz960 \ epochs150 \ batch8 \ workers4 \ project./runs_face \ nameexp_960逻辑说明imgsz翻倍显存占用大约翻四倍所以batch从 16 降到 8。两版跑完对比验证集上小脸按框面积分档的召回率再决定上线用哪个。参数上没有绝对最优只有和你的推理场景匹配。4.2 增强开关mosaic 和 mixup 在人脸数据上的取舍YOLO 默认开 mosaic四图拼接和 mixup。mosaic 对通用检测很有效但人脸数据要小心四图拼接后原本中等大小的人脸可能被缩到很小如果数据集本身小脸就多mosaic 会加剧尺度失衡。我的做法是前期开 mosaic 提升泛化后期关掉做微调。# 前 120 epoch 开 mosaic后 30 epoch 关闭做微调 yolo detect train \ data./data.yaml \ modelyolov8n.pt \ imgsz640 \ epochs150 \ batch16 \ mosaic1.0 \ close_mosaic30 \ mixup0.0 \ project./runs_face \ nameexp_mosaic逻辑说明close_mosaic30表示最后 30 个 epoch 自动关闭 mosaic让模型在真实分布上收敛。mixup0.0是我在人脸数据上的保守选择mixup 把两张图按透明度叠加人脸会变成半透明鬼影对检测框回归不友好除非你的数据量实在太小需要强正则。参数上mosaic1.0是开启概率想减弱可以设 0.5。4.3 学习率和优化器小数据集的稳定优先策略1853 张属于小数据集学习率不宜太大否则 loss 震荡。YOLO 默认 SGD 的lr00.01在小数据集上我一般降到 0.005 或换 AdamW。yolo detect train \ data./data.yaml \ modelyolov8n.pt \ imgsz640 \ epochs150 \ batch16 \ optimizerAdamW \ lr00.001 \ lrf0.01 \ weight_decay0.0005 \ warmup_epochs3 \ project./runs_face \ nameexp_adamw逻辑说明optimizerAdamW对学习率不那么敏感适合不想反复调参的场景。lr00.001是 AdamW 的常见起点lrf0.01表示最终学习率是初始的 1%余弦衰减到底。warmup_epochs3让学习率从很小线性升到lr0避免一开始就大步长破坏预训练权重。weight_decay做 L2 正则小数据集上防过拟合。提示如果训练 loss 在前 10 个 epoch 就降到接近 0 而验证 mAP 不涨基本是过拟合了回头检查数据增强是不是太弱、或者训练验证切分有泄漏。5. 训练过程排查loss 不降、mAP 抖动、显存爆的常见原因训练跑起来不等于跑对了。这一章列几个我实际踩过的坑按「现象 → 原因 → 解决」写你对号入座。5.1 现象box_loss 一直不降cls_loss 正常原因通常是标签坐标有问题。最常见的是归一化时用了错误的图像尺寸——比如 XML 里写的width/height和实际图像尺寸不一致或者转换脚本读的是缩略图尺寸。另一个原因是标签里混入了非人脸类但没过滤模型在学一些莫名其妙的框。解决回到 2.1 的统计脚本重点看宽 min/mean/max是否合理。正常人脸框归一化宽度应该在 0.05~0.8 之间如果出现大量 0.001 级别的框就是坐标算错了。再抽查几张图用画框脚本把标签可视化出来肉眼比对。import cv2 import os img_path ./images/train/000001.jpg label_path ./labels/train/000001.txt img cv2.imread(img_path) h, w img.shape[:2] with open(label_path) as f: for line in f: cls, cx, cy, bw, bh map(float, line.split()) x1 int((cx - bw / 2) * w) y1 int((cy - bh / 2) * h) x2 int((cx bw / 2) * w) y2 int((cy bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(./check_vis.jpg, img)逻辑说明把归一化坐标还原成像素坐标画框输出一张可视化图。参数上换几张不同来源的图各跑一次重点看框有没有整体偏移、有没有框到背景。这个脚本不依赖训练框架纯 OpenCV。5.2 现象验证集 mAP 上下抖动超过 5 个点原因一般是验证集太小或分布不均。1853 张按 8:2 切验证集约 370 张如果里面某类场景比如侧脸、遮挡样本很少每轮评估时这批样本的检测结果波动就会放大整体 mAP。解决要么扩大验证集比例到 7:3要么做交叉验证。更实际的做法是固定验证集后看每个类别的 AP 而不是只看 mAP定位到底是哪类样本在抖。如果抖动来自少量难样本可以在训练后期针对性补充这类数据而不是盲目调参。5.3 现象训练到一半 CUDA out of memory原因可能是imgsz或batch设大了也可能是数据加载时某些超大图没被正确 resize。YOLO 的 letterbox 会按imgsz缩放但如果某张图尺寸异常大比如 8000x6000解码时内存峰值会很高。解决先降batch到 8 试还爆就降imgsz。同时在数据加载前用脚本把超大图筛出来超过 4000 像素的单独处理或直接排除。另外workers设太大也会因为多进程各自缓存数据而吃内存设成 2~4 更稳。5.4 现象训练完推理时框位置整体偏移原因通常是训练时的 letterbox 填充和推理时的预处理不一致。YOLO 官方推理接口会自动处理但如果你自己写预处理很容易在缩放比例和 padding 上算错。解决优先用官方predict接口别自己造轮子。如果必须自己写记住 letterbox 是「等比缩放 居中填充」还原坐标时要先减去 padding 再除以缩放比例。这个顺序错了框就会整体偏移。6. 验证与进阶用混淆矩阵和分档召回定位真实短板训练跑完runs_face/exp_*/下会有一堆曲线和指标。多数人只看 mAP50但人脸检测上线前我更关注两件事不同尺度人脸的召回率以及误检集中在什么背景上。这一章讲怎么把验证做细以及一个提升小脸召回的具体技巧。6.1 用验证集跑混淆矩阵和 PR 曲线yolo detect val \ data./data.yaml \ model./runs_face/exp_640/weights/best.pt \ imgsz640 \ conf0.001 \ iou0.6 \ plotsTrue \ project./runs_face_val \ nameval_640逻辑说明conf0.001把置信度阈值压到很低让 PR 曲线覆盖完整召回范围这样算出来的 AP 才准。iou0.6是 COCO 风格的匹配阈值人脸检测也可以用 0.5 看宽松指标。plotsTrue会输出混淆矩阵、PR 曲线、F1 曲线到输出目录。参数上model指向你训练出的best.pt。看混淆矩阵时人脸单类任务只有「face」和「background」两行两列。重点看 background 被误判成 face 的数量误检以及 face 被漏成 background 的数量漏检。如果误检多提高推理时的conf阈值如果漏检多说明模型对某些人脸不敏感回到数据层面找原因。6.2 按人脸像素面积分档统计召回率mAP 是一个平均数会掩盖小脸漏检。我一般写个脚本把验证集标注框按面积分成小32²、中32²~96²、大96²三档分别统计召回。import glob import os from PIL import Image # 这里用标注框的像素面积分档实际召回需要模型预测结果简化演示分档逻辑 label_dir ./labels/val img_dir ./images/val buckets {small: 0, medium: 0, large: 0} for txt in glob.glob(os.path.join(label_dir, *.txt)): stem os.path.splitext(os.path.basename(txt))[0] # 找对应图像拿尺寸 img_path None for ext in (.jpg, .png, .jpeg): p os.path.join(img_dir, stem ext) if os.path.exists(p): img_path p break if not img_path: continue with Image.open(img_path) as im: w, h im.size with open(txt) as f: for line in f: parts line.split() if len(parts) ! 5: continue _, cx, cy, bw, bh map(float, parts) area (bw * w) * (bh * h) if area 32 * 32: buckets[small] 1 elif area 96 * 96: buckets[medium] 1 else: buckets[large] 1 print(buckets)逻辑说明这段先统计验证集里各档人脸的数量分布让你知道小脸占比。如果 small 占比超过 30%而你的模型 mAP 又不高基本可以断定瓶颈在小脸。真正的分档召回需要把模型预测结果和标注做匹配逻辑更长但思路是对每个标注框找 IoU 最大的预测框若 IoU 达标且类别正确则算召回。参数上label_dir和img_dir指向验证集。6.3 提升小脸召回的一个具体技巧切片推理如果小脸占比高且imgsz已经提到 960 还不行可以试切片推理SAHI 思路。把一张大图切成有重叠的小块每块单独推理再合并结果。这样小脸在切片里相对变大更容易被检测到。# 切片推理的简化逻辑实际用现成库更稳 import cv2 def slice_infer(img, model, slice_size640, overlap0.2): h, w img.shape[:2] step int(slice_size * (1 - overlap)) boxes [] for y in range(0, h, step): for x in range(0, w, step): patch img[y:yslice_size, x:xslice_size] if patch.shape[0] 32 or patch.shape[1] 32: continue # model.predict 返回该切片的框坐标要加回 (x, y) 偏移 results model.predict(patch, verboseFalse) for r in results: for box in r.boxes: x1, y1, x2, y2 box.xyxy[0].tolist() boxes.append([x1 x, y1 y, x2 x, y2 y, box.conf[0].item()]) # 合并重叠框用 NMS这里省略具体实现 return boxes逻辑说明slice_size是切片边长overlap是重叠比例0.2 表示相邻切片有 20% 重叠避免目标被切在边界上漏掉。每个切片推理后坐标要加回切片在原图的偏移。最后所有切片的框合并做一次 NMS 去重。这个方法的代价是推理时间成倍增加适合对召回要求高、能接受延迟的场景。参数上slice_size一般设成和训练imgsz一致overlap在 0.2~0.3 之间。我自己在这类 1800 张量级的人脸数据上折腾过好几轮最大的教训是别在训练参数上反复横跳先把数据体检做扎实。标注框的坐标错一位你调一百次学习率也补不回来。现在我的习惯是拿到任何带标签数据集先跑统计脚本、画几张可视化、做一次去重这三步做完再碰训练命令。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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