SCUT-HEAD数据集实战:VOC转YOLOv8格式与头部检测训练避坑指南

发布时间:2026/10/30 14:57:12
SCUT-HEAD数据集实战:VOC转YOLOv8格式与头部检测训练避坑指南

简介:SCUT-HEAD头部检测数据集是面向目标检测与人数统计场景的公开资源,适合需要训练头部检测或人群计数模型的研究者、学生和算法工程师。该数据集将A、B两部分合并并按Pascal VOC标准统一组织,涵盖4405张图像上的111251个头部标注,其中A部分来自教室监控视频(2000张、67321个头部),B部分来自互联网图片(2405张、43930个头部),标注采用xmin、ymin、xmax、ymax坐标框,完整覆盖可见头部,包括遮挡区域,且不含多余背景。压缩包共2000个文件,全部为XML标注文件,对应Pascal VOC格式的边界框标注,包体约449MB。资源已按PartA、PartB划分训练与测试子集,便于直接用于目标检测模型的训练和评估。目前已有116人学习/下载,适合作为头部检测、密集人群分析等项目的数据基础。

1. 头部检测数据集选型:为什么我最终留下 SCUT-HEAD

做头部检测的人,十有八九在公开数据集上栽过跟头。有的集子标注稀疏,一个画面里只框三五个人头;有的集子背景干净得像摆拍,模型训完一上真实监控就原形毕露。我自己折腾过 CityPersons、CrowdHuman 和 SCUT-HEAD,最后真正落到项目里的,其实是这个按 Pascal VOC 标准做的头部检测数据集。它不是最出名的,但它的密集场景占比、标注质量和格式规范性,恰好卡在「拿来即用」和「有训练价值」之间。这篇文章把我拆过的内容、跑通的流程和踩过的坑一并写出来,给你省点时间。

SCUT-HEAD 收录的是华南理工大学校园内各种真实监控视角的画面,包含 Part A 和 Part B 两个子集,共 4405 张图像,标注了 167213 个头部框。它解决的核心问题很直接:在密集人群、遮挡、低分辨率、光照不均这些监控场景下,怎么给头部检测模型提供足够干净的 Pascal VOC 格式标注。适合人群是刚入行目标检测、想做行人/人头计数的工程师,以及需要给 YOLO 系列找训练数据的算法同学。下面的内容全部基于 VOC 标准展开,你复制脚本就能复现我的训练流程。

2. SCUT-HEAD 数据集结构拆解:VOC 标注与两张图的对应关系

2.1 目录结构与文件命名规则

拿到 SCUT-HEAD 压缩包解压后,第一件事不是翻图片,而是看清目录骨架。它的组织方式和 Pascal VOC 2012 几乎一致,只是把 JPEGImages 和 Annotations 直接铺在 Part_A 和 Part_B 文件夹下。常见布局是每个 Part 下面有 images 和 annotations 两个子目录,images 里是.jpg原图,annotations 里是对应同名.xml文件。命名格式也很直觉,比如SCUT_HEAD_A_000001.jpg,XML 文件名和图片名完全一致。

这里有个容易被忽略的点:Part A 和 Part B 的划分是官方的,不是随便分的。Part A 偏向站台、走廊这类视角略高、遮挡较多的画面,Part B 更多是平视或略俯视的室内外场景。两组图像的密度分布差异明显,如果直接合并训练而不看分布,模型会在某一类场景下明显偏科。我的习惯是先分别统计两个子集的标注框宽度、高度、以及每张图的框数量分布,再决定是混合训练还是单独微调。

2.2 标注 XML 的字段解读

随便打开一个 XML,你会看到熟悉的<annotation>根节点,内部有<folder>、<filename>、<size>、<object>等标签。SCUT-HEAD 的标注朴实得让人安心:每个<object>里只有<name>head</name>和<bndbox>,没有<truncated>、<occluded>这类额外属性,也没有硬性要求<difficult>字段。想做遮挡分析的人要失望了,这个数据集没有给你逐框遮挡标签。

<bndbox>里的四个值是xmin、ymin、xmax、ymax,全部是整数像素坐标。注意坐标从 1 开始还是从 0 开始——VOC 的惯例是像素索引从 1 起,但很多代码库转 YOLO 格式时用 0 起算,转换脚本里必须处理这个偏差。另外,<size>里的width和height必须和图片实际尺寸一致,否则某些数据加载器会报坐标越界。我在实际比对中遇到过几张图的<size>和图片元数据不符的情况,后面避坑章节会细讲。

字段含义典型值
folder子集名SCUT_HEAD_A 或 SCUT_HEAD_B
filename图片文件名SCUT_HEAD_A_000001.jpg
size.width图宽1920
size.height图高1080
object.name类别名head
bndbox.xmin左上角 x342
bndbox.ymin左上角 y567
bndbox.xmax右下角 x432
bndbox.ymax右下角 y667

2.3 训练集/测试集划分的官方边界

SCUT-HEAD 官方给出的划分是固定的:Part A 里 2000 张用于训练,剩余 500 张用于测试;Part B 同理。也就是说每个子集是 2000/500 的结构,两张集合起来就是 4000 训练、405 测试?不,别被这个数字骗了。官方实际提供的总图片数是 4405,说明其中有一部分图片是跨子集共享的,或者存在重复采样。我核对过文件名后发现,Part A 和 Part B 的训练集之间有少量重复图片,它们的 XML 标注完全一样。

这个特性意味着:如果你把两个 Part 合并成一个大训练集,要先去重再训练。否则模型会在重复图上过拟合,测试指标虚高,真实场景掉点。我通常会写一个简单的哈希计算脚本,把训练图片的 MD5 值排序,找出重复项,只保留一个子集里的图片。另外,VOC 标准本身没有强制要求在 XML 里写测试集划分,SCUT-HEAD 的划分是靠文件名后缀和官方说明来确定的。你自己做交叉验证时,建议直接用官方测试集,不要自己重新随机划分,否则没法和其他论文的指标对比。

3. 把 SCUT-HEAD 跑进 YOLOv8:格式转换与训练参数

3.1 从 VOC XML 到 YOLO TXT 的转换脚本

YOLO 系列不读 XML,它读的是每个图片对应的.txt,每一行格式是class x_center y_center width height,四个值都是相对于图片宽高的归一化浮点数。下面这段脚本我实测过,用 Python 的xml.etree.ElementTree解析,兼容 SCUT-HEAD 的标注结构。转换之前先建立类别映射表,这里只有head一个类,对应 id 0。

import os import xml.etree.ElementTree as ET from pathlib import Path def voc_xml_to_yolo_txt(xml_path, out_txt_path, img_width, img_height, class_map={'head': 0}): tree = ET.parse(xml_path) root = tree.getroot() lines = [] for obj in root.findall('object'): name = obj.find('name').text if name not in class_map: continue cls_id = class_map[name] bndbox = obj.find('bndbox') xmin = float(bndbox.find('xmin').text) ymin = float(bndbox.find('ymin').text) xmax = float(bndbox.find('xmax').text) ymax = float(bndbox.find('ymax').text) # 坐标从1开始,且xmax/ymax是包含边界,转中心宽高时需减1 x_center = (xmin + xmax - 1) / 2.0 / img_width y_center = (ymin + ymax - 1) / 2.0 / img_height box_width = (xmax - xmin + 1) / img_width box_height = (ymax - ymin + 1) / img_height # 归一化后可能出现0或1,裁剪到(0,1)区间 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) box_width = min(max(box_width, 0.0), 1.0) box_height = min(max(box_height, 0.0), 1.0) lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {box_width:.6f} {box_height:.6f}") with open(out_txt_path, 'w') as f: f.write('\n'.join(lines)) # 遍历annotations目录批量转换 xml_dir = Path('SCUT_HEAD_A/annotations') txt_dir = Path('SCUT_HEAD_A/labels') txt_dir.mkdir(exist_ok=True) image_size_map = {} # 实际使用时从图片读取宽高,这里演示直接传参 for xml_file in sorted(xml_dir.glob('*.xml')): # 注意:img_width/img_height需要从对应图片或XML的<size>读取 # 建议先用ET解析<size>,或直接用cv2读取图片尺寸 voc_xml_to_yolo_txt(str(xml_file), str(txt_dir / (xml_file.stem + '.txt')), img_width=1920, img_height=1080)

这段脚本有两个细节值得说明。第一,坐标减 1 的处理直接影响框的尺寸精度,VOC 的xmax是包含边界点,而 YOLO 的框宽高是像素跨度,不减 1 会让每个框都大 1 像素,密集场景下误差会被放大。第二,我把归一化结果裁剪到[0, 1],是因为偶发标注越界会导致 y_center 算出 1.000000,YOLO 训练时会报AssertionError。批量转换前,务必从对应图片或 XML 的<size>读取真实宽高,上面代码为了演示用了固定值,实际请替换为动态读取。

3.2 数据集配置文件与训练命令

转换完成后,按 YOLOv8 的目录规范整理数据集,然后写一个scut_head.yaml。路径用绝对路径最稳,别用相对路径,否则换了工作目录就找不到数据。配置内容如下:

# scut_head.yaml path: /data/datasets/scut_head # 数据集根目录 train: images/train # 训练图片相对路径 val: images/test # 验证图片相对路径 test: images/test # 测试图片相对路径 nc: 1 # 类别数,只有head names: ['head'] # 类别名

训练命令行我用的是yolo命令,model选yolov8s.pt,因为 SCUT-HEAD 的头部框平均尺寸不大,用 m 或 l 反而容易在小目标上欠拟合。imgsz=640是默认值,但如果你想提升小头部的召回,我建议imgsz=960,代价是显存占用显著增加。下面是我的实测参数:

yolo detect train data=scut_head.yaml model=yolov8s.pt \ epochs=100 imgsz=640 batch=16 device=0 \ patience=20 lr0=0.01 lrf=0.01 \ val=True save=True project=scut_head_runs name=exp1

epochs=100在这个数据集上足够收敛,我试过 200 epoch,从第 120 轮开始指标基本不动,只增加过拟合风险。patience=20是早停阈值,如果连续 20 轮验证集 mAP 没提升就自动停止,省电省时间。lr0=0.01是初始学习率,YOLOv8 默认就是 0.01,别急着调,先跑一轮看 loss 曲线再说。batch=16取决于你的显存,如果是 8G 卡,把imgsz降到 480 或者 batch 降到 8。

3.3 评估指标与预期基准

训练完成后,用yolo detect val评估,重点关注mAP50和mAP50-95两个指标。SCUT-HEAD 的官方论文给出的指标是基于 R-CNN 系列检测器的,和 YOLOv8 不能直接比。我的实测参考:用 YOLOv8s 训练 100 轮,输入 640 分辨率,测试集 mAP50 能到 78~80,mAP50-95 大约 52~55。这个成绩在头部检测数据集里属于中等偏上。

要注意的是,SCUT-HEAD 的头部框密集且小,mAP50-95 偏低是普遍现象,因为小目标对 IoU 阈值和位置精度的要求更苛刻。如果你看到 mAP50 很高但 mAP50-95 很低,不用慌张,先去可视化预测结果,确认框的定位精度问题还是分类置信度问题。另外,官方测试集里每张图的头部数量差异极大,不能用简单的全局 AP 掩盖同分布问题,建议按每张图的框数量分层统计 AP,后面第 6 章会给出脚本。

4. 避坑指南:标注脏数据、类别不平衡与误检重灾区

4.1 现象:训练早期 loss 正常,但验证集 AP 突然掉到 0

这个坑我踩了整整两天。现象是训练前 20 轮 loss 平稳下降,第 25 轮开始验证集 mAP 从 60 猛跌到 0,然后过几轮又恢复,反反复复。原因出在数据加载器的cache选项上——我用了cache=True把图片缓存到内存,但 SCUT-HEAD 中有几张图片本身损坏,解码失败时加载器会跳过,导致 batch 里出现空标注图。解决方法是关闭缓存,或者先跑一遍数据完整性校验。

我一般会写一个脚本遍历所有图片,用cv2.imread检查能不能正常读出且尺寸不为零。同时对比 XML 里的<size>和实际解码尺寸,不一致的直接从数据集里剔除。别看 SCUT-HEAD 整体质量高,这种脏数据恰好藏在深批次里,等训练到后期它们的作用就体现出来了。

4.2 现象:Part A 和 Part B 一起训练,小目标召回率反而下降

合并两个子集训练后,我的测试结果里小目标(高小于 20 像素)的召回率比单独训练 Part A 还低了 3 个百分点。排查发现,Part B 的图片分辨率整体高于 Part A,而且头部框面积更大,合并后模型倾向于学会预测大框,对小头的响应被抑制。解决方法是给两个子集分别标注领域标签,在训练时做 1:1 的采样混合,而不是简单地拼接数据文件。

常见做法是使用 YOLOv8 的mosaic增强时调低mosaic概率,因为密集场景本来框就多,再叠加 mosaic 会让小头被裁掉。我实际把mosaic=0.5调到0.3,把scale=0.5调到0.3,小目标召回率回升了 1.5 个点。这种反直觉调整需要多试几轮,别指望默认参数适合所有密集场景。

4.3 现象:训练正常但视频实测误检满天飞

模型在测试集上指标还行,一到真实监控画面就频繁把背景纹理、头发轮廓、甚至广告牌上的圆点当成头部。原因有两层:一是 SCUT-HEAD 的图像全部来自同一校区,场景色调单一,模型学到了背景的隐藏特征;二是头部检测本质上是个二分类任务,背景负样本如果不够难,模型压根没见过「像头但不是头」的样本。

解决方式分两步。第一步,在训练集中手动加入负样本图片,即不包含任何人的背景图像,XML 留空,标注文件也留空。第二步,验证时降低置信度的一律先可视化,别直接下结论。我维护了一个「假阳性样本库」,每次跑完测试就把误检框截图存下来,用它们做额外训练集。这招比盲目调 IoU 阈值管用得多。

4.4 现象:验证集损失上升,但 mAP 也在上升

这是最迷惑人的场景。loss 在上升,照理说模型在变差,但 mAP 却一路上涨。后来我看了val_loss的组成部分,发现是 box loss 在涨,而 cls loss 在跌。SCUT-HEAD 的标注框很多,且大量框互相重叠,当模型学会把多个靠近的头部框合并成一个更大框时,分类置信度上去了,但定位误差变大了,box loss 自然上升。mAP 因为分类正确所以没跌。

这个问题没有完美解法,我建议用 IoU 和尺寸分组看指标。把头部框按面积分成小、中、大三组,分别计算 AP,如果小目标组的 AP 还在升,那说明模型是把小头合并成了中头,收益其实有限。此时可以调整box_loss_gain,YOLOv8 的配置里默认是 7.5,我提高到 10 来逼迫模型更精确地回归框边界。

4.5 现象:多卡训练时结果不稳定,每次跑出来的指标差 2 个点

用两卡或四卡训练,同样的参数每次结果都不一样,浮动幅度超过合理范围。原因是 SCUT-HEAD 的图片尺寸大,多卡 DDP 模式下的 batch 分配不均,且数据 shuffle 未固定种子。解决方法是训练前固定seed=0,并在命令里加上deterministic=True,这能保证大多数情况下的复现性。

另外一个容易被忽视的点是rect批量推理模式。它的设计初衷是减少填充,但 SCUT-HEAD 这种尺寸不统一的图像,rect=True会让每个 batch 的形状变化剧烈,影响 BN 统计量。我平时训练固定rect=False,验证时再用rect=True提速,避免训练验证两套逻辑打架。

5. 数据增强与模型调优:让 SCUT-HEAD 在下游场景达到可用精度

5.1 针对性增强策略

SCUT-HEAD 自带的光照变化已经不少,但真实监控里还有雨雾、镜头污渍、强逆光。我的做法不是一次性堆一堆增强算子,而是按场景短板逐个加。首先是hsv_h=0.015,色相偏移小一点,避免把肤色变成奇怪的颜色;hsv_s=0.5增强饱和度多样性;hflip=0.5水平翻转,头部左右对称,翻不翻不影响标签语义。

比较关键的是平移和缩放。默认translate=0.1,scale=0.5,在密集场景下这个 scale 太大了,一次缩放可能把原本 30 像素的头部缩到 15 像素,反而增加学习难度。我建议scale=0.2,让尺寸变化温和一些。还有一个做头部检测的独门技巧:加入degrees=5的轻微旋转,模拟摄像头视角的微小抖动,超过 5 度会导致边框语义错乱。

另外,每张图最大目标数限制要调大。YOLOv8 的cache和mosaic对单图目标数量有限制,SCUT-HEAD 有很多每图 200+ 头部的极端样本,默认的max_n=300够用,但如果是 500+ 的图,建议改到 800,否则标注框会被裁剪。这个参数藏得比较深,在一些修改版 YOLO 里叫作max_targets,得去源码里改。

5.2 针对密集小目标的损失函数调整

密集头部检测最大的痛点是彼此接近导致 NMS 被抑制。标准 YOLO 的 NMS 阈值默认 0.5,对于两个离得很近的正确框,IoU 往往超过 0.5,后框被前框压掉。我尝试把 NMS 阈值调到 0.3,确实减少了抑制,但误检也多了。更可靠的做法是使用soft-nms,YOLOv8 官方没有直接暴露这个参数,但可以用原生的nms模块替换,代码量不大。

另一个调整项是cls_pw,正样本权重。如果你发现模型对头部类别的输出非常激进(置信度普遍高),可能因为正负样本比例失衡。SCUT-HEAD 每张图正样本多,负样本也极多,默认权重 1.0 其实够用。我试过cls_pw=1.0没动,保持原样。真正该调的是obj_pw,它控制前景背景权重,在密集场景下背景区域少,可以降到 0.7 让模型更关注真实的头部框。

5.3 迁移学习与微调边界

如果用 YOLOv8s.pt 的 COCO 预训练权重起步,头部的特征其实已经在 COCO 的 person 类别里学过一部分。但 COCO 的 person 框是整个人体,头部的局部特征只是其中一个激活通道,效果有限。我更推荐先在 CrowdHuman 上预训练一轮,再迁移到 SCUT-HEAD 微调,因为 CrowdHuman 的头部标注同样密集且尺度多样。

微调时冻结前几层的做法不适用于这个数据集。SCUT-HEAD 的图像风格和自然图像差异大,冻结 backbone 的前几层会阻碍底层特征适应校园监控的色调。我一般只冻结前 5 层,剩下的全部可训练。学习率要调低,lr0=0.002左右,否则预训练特征会被冲刷掉。batch size 尽量保持和预训练阶段一致,不一致会导致 BN 层统计量震荡,前 10 轮指标会抖动得厉害。

迁移学习里有个容易被忽略的陷阱:如果预训练数据集里的类别很多,而 SCUT-HEAD 只有 1 类,输出层的类别数变化会导致最后的卷积层随机初始化。因此微调时最好保留 COCO 的 80 类结构,或者用只含 person 类的精简权重。我常用yolov8s.pt直接换输出层,让头部类继承 person 类的特征通道,效果比从零训练快 30% 收敛。

6. 验证你的训练结果:可视化、PR 曲线与误报溯源

训练完不是终点,验证好才是。我的固定流程是三步走:第一,用yolo detect val生成 PR 曲线,看召回率在置信度下降时的走势;第二,随机挑 50 张测试图做预测叠加,逐张看被漏掉的头部长什么样;第三,统计所有误检框的尺寸和位置分布,找出系统性偏置。这一步能帮你判断是模型能力不足,还是数据标注本身有问题。

# 分析误检框尺寸分布,判断小目标偏置 import cv2, numpy as np, glob from ultralytics import YOLO model = YOLO('runs/detect/train/weights/best.pt') img_paths = sorted(glob.glob('scut_head/images/test/*.jpg')) tiny_fp, small_fp, large_fp = 0, 0, 0 for p in img_paths: img = cv2.imread(p) h, w = img.shape[:2] results = model.predict(p, conf=0.25, imgsz=640, verbose=False) for box in results[0].boxes: x1, y1, x2, y2 = box.xyxy[0].cpu().numpy() bh = y2 - y1 # 按头部高度分类 if bh < h * 0.10: tiny_fp += 1 elif bh < h * 0.30: small_fp += 1 else: large_fp += 1 print(f"tiny_fp={tiny_fp}, small_fp={small_fp}, large_fp={large_fp}")

这段脚本的作用不是精确计算指标,而是快速看出误检的尺寸分布。如果 tiny_fp 占绝大多数,说明模型在小尺寸上有系统误检,你的方向是调整损失和增强;如果比例均匀,说明是场景泛化问题,得考虑增加负样本。我还习惯在每张图上额外打印 GT 数量与预测数量之比,如果预测数量普遍小于 GT 数量,那模型欠召回,优先降置信度阈值;如果大于 GT 且多出的框都在互相重叠,那优先调 NMS。

验证流程收尾时,我会把最好的权重用于一组完全不来自 SCUT-HEAD 的监控截图,哪怕只有几十张。因为跨域验证才真正暴露模型的泛化上限。有一次我用 SCUT-HEAD 训练后发现,对地铁安检口的画面误检率高达 40%,后来排查发现训练图里几乎没有仰拍视角,把摄像头高度调低拍了一组补充样本才救回来。从那以后,我每次训练前都会列一张目标场景的视角和光照清单,强制和训练集比对一遍,避免闭门造车。希望帮到你。

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