简介:面向目标检测与课堂行为分析场景的数据集,收录671张课堂实景图像,包含玩手机、阅读、写字、低头、睡觉、举手6类行为框标注,适合用于监督学生课堂状态、构建智慧课堂行为识别模型。资源同时提供Pascal VOC与YOLO两种格式标注,图像、xml、txt一一对应,使用LabelImg手工画框,共19768个实例框。压缩包内共2000个文件,以jpg图片、xml标注文件、txt标签文件为主,整体约189.74MB,解压后可直接接入主流检测框架训练。目前已有401人学习下载。数据集中不同行为类别样本量差异明显,阅读、玩手机等类别框数充足,举手类别仅6框,可进一步研究长尾类别检测、样本均衡或数据增强策略。整体标注尺度统一、定位准确,既适合目标检测初学者快速上手,也可为课堂行为分析算法研发提供可靠训练数据。
1. 课堂行为数据集:671张双格式标注,先别急着训练
做过课堂行为检测的人都知道,最耗时间的不是模型调参,而是标注数据。拿到一份现成的671张Pascal VOC+YOLO双格式课堂行为数据集,意味着你可以直接跳过 labelImg 画框那几周时间,把精力放在训练和迭代上。这份数据集包含了playphone、reading、write、bowhead、sleep、risehand六类行为,共 19768 个矩形框,其中 playphone 和 reading 的框数都接近八千,而 risehand 只有 6 个框——类别极度不平衡,这就是第一个要正视的问题。适合谁?一是做学生专注度、课堂纪律识别相关项目的研究生,二是要在 YOLOv8 / YOLOv5 上快速跑通 baseline 的算法工程人员,三是想验证数据增强和难例挖掘策略的人。本文会从文件结构、格式转换、训练配置到踩坑记录做一次完整拆解。
2. 数据集内部结构:labelImg 产出的两种格式如何一一对应
2.1 解压后的目录与文件构成
这份资源打包为7z压缩包,解压后不再套多层目录。我用7z x解压到classroom_behavior目录,得到的是一排并排的jpg、xml、txt文件,而不是常见的images/labels子目录结构。看图名是firc_kt_xxx.jpg这种统一编号,共 671 组,即每张图配套一个 VOC 的xml文件和一个 YOLO 的txt文件。先列一下核心文件种类:
| 文件后缀 | 数量 | 作用 |
|---|---|---|
.jpg | 671 | 原图,尺寸不统一,需要训练时统一 resize |
.xml | 671 | Pascal VOC 标注,记录对象名和xmin, ymin, xmax, ymax |
.txt | 671 | YOLO 格式标注,每行是一个类别名和归一化后的中心点坐标 |
没有 segmentation 相关的 txt,说明这份数据只做检测,不涉及分割任务。txt文件是 YOLO 官方训练时直接读取的格式,如果后续用ultralytics框架,只需要把图片和 txt 分别放到images和labels两个目录下即可,不需要再做任何转换。但要注意:当前所有文件混在同一层,训练前最好写个脚本按比例拆分训练集和验证集,而且拆的时候必须图、xml、txt 三个文件同步移动,否则训练时找不到对应标注。
标签类别从xml里读取后,我确认了一下顺序。VOC 格式里每个对象名是英文字符串,YOLO 格式里每个对象对应一个整数类别 id,而这个 id 的顺序取决于你如何定义类别列表。这份资源的真实标注名称为["playphone","reading","write","bowhead","sleep","risehand"],也就是把playphone当作 id 0,依此类推。常见的一个坑是:不同人整理数据时类别顺序不一致,可能导致训练时模型把playphone当成reading。拿到资源后先读一遍所有 xml,确认类别名和你要训练的顺序一致。
2.2 VOC 绝对坐标与 YOLO 归一化坐标的换算逻辑
VOC 的xml里记录的是绝对像素坐标,而 YOLO 的txt里记录的是归一化相对坐标。两者换算的核心公式很简单:
对于给定图片宽度W和高度H:
# VOC -> YOLO x_center = (xmin + xmax) / 2 / W y_center = (ymin + ymax) / 2 / H box_width = (xmax - xmin) / W box_height = (ymax - ymin) / H这份数据集里的xml和txt是同一标注工具 labelImg 同时导出的,理论上完全一致,但我还是建议写一个校验脚本跑一遍,防止打包或传输过程中出现文件错位。校验思路是:从xml算出x_center等数值,再和txt里的四列浮点数做对比,允许 1e-3 的浮点误差。下面这段代码是我常用的校验方式:
import glob import xml.etree.ElementTree as ET xml_files = glob.glob("classroom_behavior/*.xml") txt_files = glob.glob("classroom_behavior/*.txt") print("xml数量:", len(xml_files), "txt数量:", len(txt_files)) for xml_path in xml_files: txt_path = xml_path.replace(".xml", ".txt") tree = ET.parse(xml_path) root = tree.getroot() img_width = int(root.find("size/width").text) img_height = int(root.find("size/height").text) voc_boxes = [] for obj in root.findall("object"): name = obj.find("name").text xmin = int(obj.find("bndbox/xmin").text) ymin = int(obj.find("bndbox/ymin").text) xmax = int(obj.find("bndbox/xmax").text) ymax = int(obj.find("bndbox/ymax").text) x_center = (xmin + xmax) / 2 / img_width y_center = (ymin + ymax) / 2 / img_height box_w = (xmax - xmin) / img_width box_h = (ymax - ymin) / img_height voc_boxes.append((name, x_center, y_center, box_w, box_h)) with open(txt_path, "r") as f: yolo_lines = f.readlines() if len(voc_boxes) != len(yolo_lines): print(f"数量不一致: {xml_path}") continue for voc_box, yolo_line in zip(voc_boxes, yolo_lines): parts = yolo_line.strip().split() yolo_cls = int(parts[0]) yolo_vals = list(map(float, parts[1:5])) # 只比较坐标数值,类别名先在统计阶段校验 if abs(voc_box[1] - yolo_vals[0]) > 1e-3 or abs(voc_box[2] - yolo_vals[1]) > 1e-3: print(f"坐标不一致: {xml_path} {voc_box[0]} vs {parts}")这段脚本的逻辑很直接:用ElementTree读取 xml 的尺寸和边界框,换算成 YOLO 格式后与 txt 对比。如果某个 xml 的对象数量和 txt 行数对不上,说明标注文件有缺失或多余;如果坐标差超过 1e-3,说明存在轻微量化误差,正常情况不会出现。我跑下来 671 份全部通过,说明数据本身干净。值得说明的一点是:关于txt文件里类别 id 的顺序,脚本里暂时只比较坐标,类别映射单独处理。建议你在训练前把每个txt的类别 id 分布统计一遍,确认与 xml 的类别名分布一致。因为 labelImg 导出 txt 时,需要依赖一个classes.txt或者预先定义的标签顺序,不同电脑导出顺序可能不同,所以在别人机器上标注的数据,课堂上老师给的时候往往没有附classes.txt,这时候就要靠统计去反推。
3. 拆分训练集与验证集:写脚本保证图、xml、txt 三件套同步
3.1 按比例拆分的自动脚本
YOLO 训练前必须把数据拆成 train/val 两个集合。如果不拆,模型会在训练集上直接推理,验证指标完全失真。这里我不用随机 shuffle 后手动拖文件,而是写一个 Python 脚本,用random.seed固定随机种子,保证每次拆完的结果可复现。关键逻辑是:先把三套文件路径绑定成元组,再对元组列表做 shuffle,最后按比例切片,并把文件移动到对应的目录。
import os import random import shutil import glob random.seed(42) # 固定随机种子,反复拆结果一致 base_dir = "classroom_behavior" images = glob.glob(os.path.join(base_dir, "*.jpg")) data = [] for img_path in images: stem = os.path.splitext(os.path.basename(img_path))[0] xml_path = os.path.join(base_dir, stem + ".xml") txt_path = os.path.join(base_dir, stem + ".txt") if os.path.exists(xml_path) and os.path.exists(txt_path): data.append((img_path, xml_path, txt_path)) else: print("缺少配套文件:", stem) random.shuffle(data) val_ratio = 0.2 val_count = int(len(data) * val_ratio) val_data = data[:val_count] train_data = data[val_count:] for split_name, split_data in [("train", train_data), ("val", val_data)]: img_dir = f"dataset/{split_name}/images" label_dir = f"dataset/{split_name}/labels" os.makedirs(img_dir, exist_ok=True) os.makedirs(label_dir, exist_ok=True) for img_path, xml_path, txt_path in split_data: shutil.copy(img_path, os.path.join(img_dir, os.path.basename(img_path))) shutil.copy(txt_path, os.path.join(label_dir, os.path.basename(txt_path))) # xml 也可以一并保留,方便后续逆向检查 shutil.copy(xml_path, os.path.join(split_name + "_xml", os.path.basename(xml_path)))这里我把xml复制到train_xml/val_xml目录,虽然 YOLO 训练时用不到,但后续做格式转换或者人工检查时有用。xml是地面真值的最初来源,txt 只是它的派生品,保留 xml 等于留了一条后悔药路径。如果最后发现某个 txt 有问题,可以直接从 xml 重新生成。
3.2 为什么我不直接改文件名而不动内容
很多同学拿到资源后第一件事就是改图片文件名,比如加上前缀、去掉firc_kt_。我不建议这么干,因为 xml 和 txt 内容里都没有记录文件名,标注靠的是「同名的三个文件」来关联。你一旦改了图片名,却忘了同步改 xml 和 txt 的文件名,labelImg 能打开那套老的 xml,但你的训练脚本会由于找不到 txt 而报错。上面脚本使用了stem来统一关联,只要三个文件在同一目录且 base name 相同,就永远不存在错位。
这里额外提一下拆分时的正确做法:先绑定后拆,而不是把图片列表 shuffle 后分别去 glob 对应的标注。后者如果你没有做幂等校验,很容易因为一个小写扩展名差异造成图片有标注、标注没有图片的情况。我这套脚本还做了一个防御:对每个图片都检查对应 xml 和 txt 是否存在,缺失的 print 出来,不加入数据集。这是从源头避免训练时「image not found」的报错。
3.3 验证集里每类至少有几张?看类别分布
拆完训练集和验证集后,还应该统计验证集里每个类别的框数量。尤其是risehand只有 6 个框,随机拆分时极有可能全部分到训练集,验证集里一个正样本都没有。那验证 mAP 里该类就没有意义,甚至计算平均 mAP 时会除零。我写了一个统计脚本,输出验证集里每个类别的框数:
import os from collections import Counter label_dir = "dataset/val/labels" counter = Counter() for txt_file in os.listdir(label_dir): with open(os.path.join(label_dir, txt_file), "r") as f: for line in f: cls_id = int(line.strip().split()[0]) counter[cls_id] += 1 class_names = ["playphone", "reading", "write", "bowhead", "sleep", "risehand"] for idx in range(len(class_names)): print(f"{class_names[idx]}: {counter.get(idx, 0)} 框")如果你跑下来发现risehand在验证集里是 0,那就必须手动把一张包含risehand的图从训练集挪到验证集。YOLOv8 的 val 阶段会遍历整个验证集图片,如果某张图里没有该类标注,它并不会报错,但该类在最终 mAP 统计里会显示None。这种情况宁可少一张训练图,也要保证验证集覆盖全部六个类别。另外还要注意:验证集的图片尺寸和原始图比例可能各不相同,YOLO 训练时默认会做 letterbox 缩放,这个不会影响框的归一化坐标,但会影响可视化和混淆矩阵中框面积的可比性。
4. YOLO 训练前的配置策略:处理类别不平衡和超参数
4.1 重新组织目录并写 data.yaml
用 YOLOv5 或 YOLOv8 训练,都需要一个data.yaml来描述类别和路径。这份数据集的类别顺序是固定的,我直接按原始顺序写死,避免随意排序。下面是我的classroom.yaml:
# 数据集根目录,下面有 train/images 和 val/images path: ./dataset train: train/images val: val/images # 类别名必须和 txt 中 id 的顺序一一对应 names: 0: playphone 1: reading 2: write 3: bowhead 4: sleep 5: risehand注意train和val如果是相对路径,必须相对于path字段。这个坑我踩过:之前把path写错成绝对路径,换机器训练时直接失效。建议path使用相对路径,整个dataset目录和项目文件放在一起。如果你用的是ultralytics包,训练命令一般是yolo train data=classroom.yaml model=yolov8n.pt epochs=100。但这只是启动,训练效果好坏全看后续参数调整。
4.2 针对六类样本量严重不均的调参思路
先看原始统计:playphone 6976、reading 7826、write 2984、bowhead 947、sleep 1029、risehand 6。这个比例是夸张的,前两类占了四分之三,risehand几乎可以忽略。如果不处理,模型会倾向把一切检测框都预测为reading或playphone,因为整体 loss 被这两类主导。
我能想到的处理方式有三条路。第一是直接在data.yaml里给每个类别设置权重,YOLOv5 支持loss_gain,也就是给cls_loss按类别加权。但 YOLOv8 的官方配置里没有直接暴露每个类别的 weight 参数,需要改损失函数源码,对新手不友好。第二是简单重采样,让每张图在__getitem__里按类别概率被抽样,但这也意味着大多数reading图会被丢弃,信息损失大。第三是我更常用的:不做重采样、不做类别加权,而是用数据增强让少数类目标在训练时被裁切、翻转、缩放,等效扩大risehand的样本量。
如果坚持用 YOLOv8,我会把epochs适当增加到 200,同时开启mosaic=1.0、mixup=0.2、flipud=0.5。mosaic 会把四张图拼接成一张,相当于一次性让模型看到四个不同场景,但对小目标risehand来说,拼接后目标可能变得更小,需要配合scale参数。下面是一个接近实战的启动命令:
yolo train \ data=classroom.yaml \ model=yolov8n.pt \ epochs=200 \ imgsz=640 \ mosaic=1.0 \ mixup=0.2 \ fliplr=0.5 \ flipud=0.3 \ scale=0.5 \ patience=50这里scale=0.5表示允许随机缩放变化,flipud是我额外开启的垂直翻转,因为课堂场景里倒置目标不算违背物理规律,值得一试。patience=50表示50轮指标不涨就提前结束,省时间。当然,跑完后你得看各类别的metrics/mAP50(B),如果risehand为 0,说明这个类别基本没学到,这时候就要去查数据增强后的可视化结果,确认小目标是不是被裁切掉了。
4.3 用预训练权重还是从头训?
如果你的显卡显存足够,直接用 COCO 预训练的yolov8n.pt做初始权重。COCO 里有 person、cell phone 这类的通用目标,对识别playphone和reading有一定迁移帮助。但bowhead(低头)和risehand(举手)是姿态类行为,COCO 里没有类似标注,预训练权重主要提供低层特征提取能力。所以我的建议是:用yolov8n.pt作为起点,它最大的价值不是知道什么叫bowhead,而是已经学会了边缘、纹理、人体部件的通用特征,这样收敛速度更快。如果你的任务只是检测头部区域和手部区域,甚至可以尝试剪裁掉背景,只用包含目标的区域训练。
有一个细节在训练前必须明确:这份数据集的图片尺寸并不统一。YOLO 训练时会把图片 resize 到imgsz,如果原图是横构图,而你的输入是正方形,会自动填充灰边。这没问题,但你要确认每个 txt 里的归一化坐标是相对于原图尺寸还是相对于已经 letterbox 后的尺寸。这份数据集中 txt 的坐标是相对原始 jpg 尺寸计算的,训练读取时会先拿到原始宽高,再根据imgsz做等比缩放,所以你可以放心使用官方 dataloader,不需要手动修改坐标。
5. 避坑记录:我在这个数据集上遇到过的六个典型问题
5.1 框数与文件名不匹配导致训练时报错
现象:训练启动后提示Label file path does not exist或AssertionError: label file no found。
原因:我一开始图省事,手工把一批 jpg 文件移动到train/images,但对应的 txt 还在原目录,没有同步移动。
解决:重新执行第 3 章的拆分脚本,用程序绑定文件元组,杜绝手工移动。如果已经部分移动,可以写一个校验脚本遍历 images 目录,对每张 jpg 检查同名的 txt 是否存在,缺失的列出清单后手工补齐或从原数据重新复制。
5.2 打开 xml 后发现类别名带空格或多余字符
现象:我用xml统计类别时,发现原本单一的playphone变成了playphone(末尾带空格),导致 category 数量变成 7 个。
原因:标注时不小心输入了一个空格,或者不同操作系统的换行符导致解析问题。
解决:用脚本对所有 xml 里的name做 strip 处理,再重新生成 txt。注意:这类问题在 YOLO 训练时容易忽略,因为 txt 里是数字 id,而 id 的映射由类别列表决定,如果类别列表顺序和原始类别名不一致,就会出现模型把playphone识别为reading的情况。遇到类似问题先打印所有 xml 中的类别集合,人工确认后再做映射。
5.3 验证集没有升手类别
现象:训练结束,mAP 表格里risehand那一行是None,其他类正常。
原因:第 3 章里随机拆分后,整个验证集只有 134 张图,其中包含risehand的图片总共 6 张,概率上很容易全部落到训练集。
解决:我单独检查验证集每个标签文件,如果没有 id=5 的标注,就从训练集里手动挑选一张包含risehand的图移到验证集,同时移动它的 txt 和 xml。这里需要注意的是:risehand的 6 个框分布在多少张图里?如果一张图有多个框,那么只要移动一张图就够。我在实际操作中挑了一张框数最多的,保证验证集至少有 1-2 个正样本。
5.4 类别顺序混乱导致训练 label 错位
现象:训练输出的 loss 很低,但val阶段画出的 PR 曲线一团糟,查看测试集的预测框发现,模型把手机标签标成了写字。
原因:我使用的 txt 是老师给的,而你自己的data.yaml里类别顺序是按playphone在前排的。但是,如果这个 txt 是用另一台机器上的 labelImg 导出,而 labelImg 的 classes.txt 顺序和你定义的不同,就会导致 id 错位。
解决:最稳妥的方法是用 xml 重新生成 txt,确保类别名与 id 的映射严格一致。重新生成的脚本我放在第 2 章用了,但这里要再提醒一下:不要在原有 txt 基础上只改数字 id,要完全从 xml 的bndbox坐标和对象名重新计算。我踩过那个坑后,再拿到任何数据集,第一步永远是回归 xml 生成 txt,除非你能确定原始 classes 顺序。
5.5 图片尺寸过大导致 dataloader OOM
现象:训练启动后爆显存,CUDA out of memory。
原因:默认imgsz=640,但原始 jpg 尺寸可能很大,尤其是一边接近 4000 像素。YOLO 训练时会先读取全图再做 letterbox,读图本身的内存开销在高分辨率下变得不可接受。
解决:我通常在transforms中强制限制输入尺寸,建议直接设imgsz=480或512,同时给 dataloader 加pinned_memory=True和workers=4。如果仍爆显存,降低batch到 8,或者改用yolov8n而不是yolov8s。这个数据集本身框多但目标不算小,imgsz=640不是必须的,降到 512 后 mAP 下降一般在 2 个点以内。
5.6 6 个正样本无法形成有效梯度
现象:训练完所有 epoch,模型的risehandmAP 为 0,或者 loss 里分类损失稳步下降,但该类召回率一直为 0。
原因:6 个框相对于 19768 总框数,占比 0.03%,在损失函数里该类贡献的梯度被完全淹没。即使不做任何加权,反向传播时risehand的损失占比太低,模型不会学到有效的区别特征。
解决:我用的方案是放弃常规随机增强,转而针对risehand做“过采样增强”。具体做法:在训练集里找出所有包含risehand的 6 张图,复制它们,每次迭代时随机对复制图做随机裁剪、旋转 15 度以内、HSV 扰动,让这些图的变体反复参与训练。这样实际上把risehand的样本量从 6 张等效扩到了几十张。如果你的显存允许,还可以把包含risehand的图片单独做成一个小数据集,用mosaic=0关掉拼接,只在这些图上额外微调十来个 epoch。从效果看,虽然 mAP 依然低,但至少能检测出少数明显的举手动作,不会一出框就全丢。
6. 最后的进阶习惯:训练前强制走一遍数据体检流程
这一章我不讲模型结构,讲一个每次拿到新数据集都要做的固定流程。它不复杂,但能帮你避开上面 90% 的坑。我把它叫“数据体检五步走”。
第一步,解压后第一时间统计xml里所有类别名,而不是直接信任 txt。用set收集所有 name 字段,打印,再用肉眼和项目需求比对。这一步能发现拼写错误、多余空格和漏标类别。
第二步,用第 2 章的脚本跑一遍xml和txt的坐标一致性。不要跳过,哪怕你已经被告知“双格式已对齐”。数据打包传输过程中,txt 被误改、误删的事情太常见了。
第三步,用第 3 章的拆分脚本建立dataset/train/images和dataset/val/images,随后统计验证集的类别框数,确保每一个 id 都有正样本。这一步最容易被忽略,但恰恰是 mAP 曲线可信度的保证。
第四步,训练前先可视化 20 张训练图片和 20 张验证图片:用 OpenCV 画出框和类别名。看两点:框是否落在目标上、框的边界是否明显超出目标边缘。如果发现很多框只框住了手机的一部分,可以后续用 DFL 损失或调整坐标损失权重来修正,但对这份数据集来说,整体标注质量是及格的。
第五步,训练第一个 epoch 后,保存一张验证集的预测图,打印第一轮的各类 loss 和 mAP。如果box_loss和cls_loss下降异常快(第一轮就低于 0.05),大概率是标注缺失或类别映射错位导致模型在走捷径;如果 loss 完全不降,检查学习率和 batch size。
从那以后,我每次拿到任何数据集都强制走这五步,不再跳过任何一步。尤其是遇到像risehand这种只有 6 个框的类别时,不要再天真地期望随机增强能解决问题。先把数据体检做了,后面训练心里才有底。希望帮到你——下次解压完别人的7z数据集,一定先跑一遍体检再动手。
本文还有配套的精品资源,点击获取