拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

学生上课状态检测数据集:VOC/YOLO/JSON三格式完整解析与YOLO训练实战

学生上课状态检测数据集:VOC/YOLO/JSON三格式完整解析与YOLO训练实战

简介:这是一份面向智慧课堂场景的学生上课状态检测数据集,共包含1698张真实拍摄图片,覆盖“认真听讲”“睡觉”“玩手机”三类典型状态,适合用于课堂智能监控、智慧教室及学生学习行为分析等项目实践。数据集中图片背景丰富、类别分布均匀,所有标注均经人工精校,并同时提供VOC格式xml、YOLO格式txt及JSON格式标签,可直接接入主流目标检测框架使用。整个压缩包共2000个文件,包含1698张jpg原图及对应的xml/txt/json标注文件,体积约973.64MB,目录结构清晰便于训练与验证。目前已有约1400人学习浏览,数据集源自博主实际比赛与项目经验,标注质量较高,配套标签格式齐全,能有效降低算法适配成本,适合目标检测方向的学生、开发者及竞赛团队下载使用。

1. 学生上课状态检测数据集:1698张三格式标签到底能做什么

教室监控画面里,最常被要求检测的三件事就是听讲、睡觉、玩手机。过去我要么自己对着视频抽帧标注,要么到处找现成数据集,结果常拿到一堆只有 VOC 或只有 YOLO 标签的包,还得先花一晚上写格式转换脚本,中间还容易出错。这份“学生上课状态检测数据集(听讲-睡觉-玩手机)1698张-含voc(xml)+yolo(txt)+json三种格式标签.zip”算是把前置工作做完了:1698 张课堂图片,同一批标注同时输出成 XML、TXT、JSON 三份,解压就能用。

它能解决什么?简单说,你想跑 YOLO 就用 YOLO 格式,想跑 MMDetection 或 Detectron2 就用 JSON 或 VOC,不需要自己造轮子。适合正在做课堂行为分析、学生专注度评估、智慧教室项目的人。新手拿它能完整走一遍数据到训练再到验证的流程;熟手拿它能当种子集,快速跑出 baseline,再补自己的真实场景数据。

2. 拆开压缩包:VOC(xml)、YOLO(txt)、JSON 三种标签怎么对齐同一批图片

2.1 目录结构与文件命名规则:拿到手先看什么

拿到 zip 后的第一件事不是解压就训练,而是先把目录结构摸清楚。常见做法是解压后看到 images(或 JPEGImages)与几个标签目录,文件名通常是同一个前缀,比如 IMG_0001.jpg 对应 IMG_0001.xml、IMG_0001.txt。先跑一条命令看看扩展名分布,确认标签是不是每张图片都齐全:

unzip "学生上课状态检测数据集*.zip" -d classroom_dataset find classroom_dataset -type f | awk -F. '{print $NF}' | sort | uniq -c

这条命令会列出压缩包内每种扩展名的文件数量。如果 jpg 是 1698 个,而 xml、txt 明显小于这个数,说明有图片没标注,后面训练时要么删掉要么补标。这里有个小经验:数据集的目录名往往和标注工具的导出设置有关,有的把类别目录嵌套在路径里,有的用中文目录,解压后先看一遍顶层结构再写脚本,别上来就假设。

还需要顺手统计图片尺寸分布。课堂上不同教室的摄像头分辨率可能混着来,有的 1280x720,有的 1920x1080,这会影响 YOLO 归一化坐标的换算,也会影响训练时是否要开 letterbox。用 Python 批量读一下宽高:

from PIL import Image import glob sizes = {} for p in glob.glob('classroom_dataset/images/*.jpg'): w, h = Image.open(p).size sizes.setdefault((w, h), 0) sizes[(w, h)] += 1 print(sizes)

这段代码把图片尺寸分布打印出来。如果尺寸种类超过三四种,建议训练前统一做一次缩放或全部用 YOLO 的 letterbox,让模型少学一种无关变量。另外注意:有些图片本身带 EXIF 旋转信息,PIL 默认读到的宽高可能和真实显示方向相反,这种情况要先用 ImageOps.exif_transpose 处理再记录尺寸。

提示:课堂数据涉及学生隐私,建议所有处理都在校园内网完成,对外发布前对人脸做模糊或裁剪。

2.2 VOC XML 解析:从 到 的关键字段

VOC 格式的标签是 XML 文件,写起来杂,但结构非常固定,根节点是 annotation,里面先有 size 子节点记录图片宽高,然后是若干个 object 节点,每个 object 里有 name(类别名)和 bndbox(四个坐标)。我用 xml.etree.ElementTree 解析,脚本很短:

import xml.etree.ElementTree as ET def parse_voc(xml_path): tree = ET.parse(xml_path) root = tree.getroot() size = root.find('size') img_w = int(size.find('width').text) img_h = int(size.find('height').text) objects = [] for obj in root.iter('object'): name = obj.find('name').text bndbox = obj.find('bndbox') x1 = float(bndbox.find('xmin').text) y1 = float(bndbox.find('ymin').text) x2 = float(bndbox.find('xmax').text) y2 = float(bndbox.find('ymax').text) objects.append({ 'name': name, 'bbox': [x1, y1, x2, y2] }) return img_w, img_h, objects

这里返回的是绝对像素坐标,x1、y1 是左上角,x2、y2 是右下角。VOC 没有归一化,所以解析出来可以直接用于画框或转其他格式。要注意的是有些标注工具会把 bndbox 的坐标写成整数,有些写成带小数,统一转 float 比较稳。还有的 XML 里嵌了 difficult、truncated 字段,表示难例和被截断的物体。训练时这类框一般建议过滤掉,否则模型会被拉着去学一些标注本身就模糊的样本。

2.3 YOLO TXT 的归一化坐标与类别 ID 映射

YOLO 的标签完全不同,每张图片对应一个同名 txt 文件,每行是“类别ID 中心点x 中心点y 宽度 高度”,且 cx、cy、w、h 都是相对于图片宽高的归一化值,范围在 0 到 1 之间。它不是绝对坐标,所以读取时必须结合图片原始尺寸才能换算成像素框:

def parse_yolo(txt_path, img_w, img_h): with open(txt_path, 'r') as f: lines = f.readlines() boxes = [] for line in lines: line = line.strip() if not line: continue cid, cx, cy, bw, bh = map(float, line.split()) x1 = (cx - bw / 2) * img_w y1 = (cy - bh / 2) * img_h x2 = (cx + bw / 2) * img_w y2 = (cy + bh / 2) * img_h boxes.append((int(cid), x1, y1, x2, y2)) return boxes

这里的 5 个浮点数决定了框的几何信息,类别 ID 只是序号本身,它具体对应“听讲、睡觉、玩手机”里的哪一个,完全由训练时的 names 配置决定。所以拿到数据集后第一件事,是确认 txt 里的 0、1、2 到底对应哪三个类别。常见做法是随机抽几张图,把 txt 里的框画出来人工看一眼。不要只看类别名,因为不同人导出数据集时 ID 顺序可能完全不一样,这个映射错位是后面所有训练翻车的源头。

2.4 JSON 标签的字典结构与 COCO 风格转换

JSON 标签在不同数据集里的表现差异最大。有的包给的是 COCO 风格,顶层有 images、annotations、categories 三个数组,适合直接喂给 Detectron2、MMDetection;有的包给的是单文件列表,每条记录包含文件名和框数组。拿到 JSON 先别急着写转换,先打印它的类型和顶层 key:

import json with open('classroom_dataset/labels/annotations.json', 'r') as f: data = json.load(f) if isinstance(data, dict): print('keys:', data.keys()) if 'images' in data: print('images:', len(data['images'])) print('annotations:', len(data['annotations'])) print('categories:', data['categories']) else: print('list length:', len(data)) print('first item:', data[0])

输出结果基本就能判断格式。COCO 风格里 annotation 的 bbox 字段是 [x, y, width, height],x、y 是框左上角坐标,不是 x1,y1,x2,y2 那套。categories 里通常有 category_id 对应类别名,和 YOLO 的 names 需要一一对应。如果 JSON 是自定义列表,bbox 可能是 [x1,y1,x2,y2],也可能是 [cx,cy,w,h],必须用一张图的 ground truth 画出来验证。这一步不能省,我见过太多人栽在“看起来像 COCO,其实是 xyxy”上。

2.5 三格式一致性校验:训练前必做的坐标对齐检查

三种格式来自同一批标注,理论上框数量和坐标完全一致,但压缩包在生成、传输、转码过程中可能丢框、改字、截断。我见过不止一次 XML 里 10 个框、同图 TXT 里只有 8 个框的情况,训练时虽然不报错,但类别学习会受影响。所以训练前写个简单校验脚本:

import os, glob import xml.etree.ElementTree as ET xml_dir = 'classroom_dataset/voc' yolo_dir = 'classroom_dataset/yolo' def voc_box_count(xml_path): tree = ET.parse(xml_path) return len(tree.getroot().findall('object')) def yolo_box_count(txt_path): with open(txt_path, 'r') as f: lines = [l for l in f.read().splitlines() if l.strip()] return len(lines) mismatch = [] for xml_path in sorted(glob.glob(os.path.join(xml_dir, '*.xml'))): stem = os.path.splitext(os.path.basename(xml_path))[0] txt_path = os.path.join(yolo_dir, stem + '.txt') if not os.path.exists(txt_path): mismatch.append((stem, 'missing txt')) continue vc = voc_box_count(xml_path) yc = yolo_box_count(txt_path) if vc != yc: mismatch.append((stem, vc, yc)) print('mismatch files:', len(mismatch)) for m in mismatch[:10]: print(m)

这段脚本先数 XML 里的 object 数量,再数 txt 非空行数,不一致的记下来。坐标层面的比对更严格,需要把 txt 还原成像素坐标再与 XML 逐框比对,允许 1 像素误差。凡是校验不过的图片,建议直接从训练集剔除而不是强行修复,因为三份标签没有对齐意味着原始标注本身可能就有问题,修好一个坑会踩出下一个坑。

3. 用 YOLO 训练上课状态检测模型:从数据集到第一个 mAP

3.1 把 VOC/JSON 转成 YOLO 训练格式的标准脚本

前面讲了读法,这一步是批量转格式。既然数据包自带 YOLO 的 txt,理论上可以省掉这步,但实际中很多人拿到的 JSON 才是原始标注,VOC 或 YOLO 反而是转换产物。这里给一个从 VOC 到 YOLO 的批量脚本,逻辑是把 bndbox 的绝对坐标换算成归一化中心点与宽高:

import os, glob import xml.etree.ElementTree as ET class_names = ['听讲', '睡觉', '玩手机'] class2id = {name: idx for idx, name in enumerate(class_names)} def convert_voc_to_yolo(xml_path, output_dir, img_w, img_h): tree = ET.parse(xml_path) root = tree.getroot() lines = [] for obj in root.iter('object'): name = obj.find('name').text.strip() if name not in class2id: print(f'skip unknown class {name} in {xml_path}') continue bb = obj.find('bndbox') x1 = float(bb.find('xmin').text) y1 = float(bb.find('ymin').text) x2 = float(bb.find('xmax').text) y2 = float(bb.find('ymax').text) cx = (x1 + x2) / 2.0 / img_w cy = (y1 + y2) / 2.0 / img_h bw = (x2 - x1) / img_w bh = (y2 - y1) / img_h lines.append(f'{class2id[name]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}') if lines: stem = os.path.splitext(os.path.basename(xml_path))[0] with open(os.path.join(output_dir, stem + '.txt'), 'w') as f: f.write('\n'.join(lines))

这里的关键是 class2id 映射表,必须和最终训练用的 data.yaml 完全一致。脚本里遇到不在映射里的类别选择跳过并打印,比直接报错中断更适合批量处理,但前提是打印信息要认真看,别让不认识的类别悄悄消失。

如果原始标注是 JSON,转 YOLO 的逻辑完全一样,只是读取坐标的部分换成从 JSON 取 [x, y, w, h],先换算成左上角和右下角:x2=x+w、y2=y+h,再走上面的归一化公式。特别注意:JSON 的 bbox 是 xywh,直接套 VOC 公式会得到错得离谱的框,转换完务必抽三张图把结果画出来对比原图。

3.2 data.yaml 与模型选型:yolov8n 还是 yolov8s

YOLO 训练的第一步是写好 data.yaml,告诉框架图片路径、验证集路径和类别表。对这个数据集,我一般这样配置:

path: /data/classroom_dataset train: images/train val: images/val names: 0: 听讲 1: 睡觉 2: 玩手机

names 的顺序不是随便排的,它必须和 txt 文件里行首的数字一一对应。比如 txt 里某行是“2 0.5 0.6 0.1 0.2”,那这个框就代表 names 里索引 2 的“玩手机”。如果顺序错位,模型训练时会把听讲的框当玩手机学,loss 照降,mAP 照出,但实际推理结果完全不可用,这是数据格式里最安静的黑匣子。

模型选型上,YOLO 系列对比下来,对这个 1698 张、3 类的小规模任务,yolov8n 和 yolov8s 是首选。n 参数量小、训练快,适合先跑通全流程验证标签没问题;s 精度更高,适合最终部署。不要一上来就用 x,课堂场景的目标不算大,模型容量带来的收益远小于数据质量的影响。预训练权重从官方仓库下载即可,注意下载的权重版本要和训练脚本匹配,不同版本的 ultralytics 参数略有差异,跑之前先yolo checks确认环境。

注意:names 的顺序一旦写入 data.yaml,后续所有转换脚本都必须沿用同一张映射表,改一个数字就要重转一遍标签。

3.3 训练参数与数据划分:1698 张图怎么分才不翻车

数据划分是课堂检测任务里最容易翻车的环节。如果单纯随机划分,同教室、同时段的图片会同时出现在训练集和验证集,模型看着 mAP 很高,一换教室就现原形。正确做法是按时间段或教室维度划分,比如前 1360 张做训练、后 338 张做验证,或者按摄像头位置分开。比例上 train:val 用 8:2 比较稳,数据量小就别再抠出测试集,用验证集兼任即可。

训练命令我用 ultralytics 的标准写法:

yolo detect train \ model=yolov8s.pt \ data=classroom.yaml \ epochs=100 \ imgsz=640 \ batch=16 \ patience=20 \ device=0

几个参数值得说。epochs=100 给足迭代空间,配合 patience=20 做早停,在验证集不再提升时提前结束,省时间也防止过拟合。imgsz=640 是大多数课堂场景的合理值,但如果摄像头画面里学生离得远、手机目标只有 20 像素,建议试试 imgsz=960,小目标 mAP 会明显改善。batch=16 在 16GB 显存下跑 s 模型没问题,显存小就降到 8。device=0 指定 GPU,没有 GPU 就别硬跑 s,换 n 或直接用 CPU 慢跑一遍流程验证脚本没写错。

这里有个容易被忽略的参数 cache。小数据集完全可以加 cache=True,把图片缓存到内存里,训练速度能快一倍,前提是内存足够。1698 张的 640 分辨率图片缓存后大约占 2-3GB 内存,别在 8GB 的小机器上开。

3.4 训练中看什么指标:loss、mAP、混淆矩阵怎么看

训练开始后,终端会实时打印 box_loss、cls_loss、dfl_loss 三项损失。box_loss 衡量框的位置误差,cls_loss 衡量分类误差,dfl_loss 是分布焦点损失,负责框边界质量。它们的绝对数值没有统一标准,没必要纠结具体大小,重点看曲线是否平稳下降、验证集 loss 有没有在某个点反弹。验证集 loss 反弹就是过拟合信号,早停这时候就会生效。

epoch 结束后框架会输出 mAP50 和 mAP50-95。mAP50 是 IoU 阈值 0.5 下的平均精度,课堂检测场景看重它;mAP50-95 更严苛,从 0.5 到 0.95 每隔 0.05 算一次再平均,目标边框要求更准。对三类检测,mAP50 跑到 0.85 以上算是可用,低于 0.7 就要回看标签或数据处理。训练完跑验证集的命令是:

yolo detect val model=runs/detect/train/weights/best.pt data=classroom.yaml imgsz=640

混淆矩阵在验证结束后自动生成,横轴是真实类别,纵轴是预测类别。很多教程说混淆矩阵总和应该唯一,实际并不一定,因为漏检的框和背景类没有计入统计,出现过拟合时对角线数字也不整齐。矩阵里最值得看的是“睡觉被预测成听讲”这类跨类混淆,它直接告诉你该补哪类样本。

4. 标签格式转换与训练避坑:5 个我踩过的真实问题

4.1 类别顺序不一致,导致训练标签全错

现象:训练 loss 降得挺好看,但验证集 mAP 趋近 0,画出来的框类别全乱。原因是 txt 里行首的类别 ID 顺序和 data.yaml 里的 names 顺序对不上,常见于从 JSON 转 YOLO 时用了另一套排序。解决:训练前写个统计脚本,把所有 txt 的第一列数字做个分布统计:

from collections import Counter import glob cnt = Counter() for txt in glob.glob('classroom_dataset/yolo/*.txt'): with open(txt) as f: for line in f: if line.strip(): cnt[int(line.split()[0])] += 1 print(cnt)

打印结果如果是 Counter({0: 800, 1: 700, 2: 600}) 这种形状,就逐个数字确认它对应 names 里的哪个类别。这一步是纯人工确认,不依赖任何工具,但它是整个训练流程里最值得花五分钟做的事。

4.2 XML 里被截断的 bndbox:坐标越界与归一化后负数

现象:训练到一半 loss 突然震荡,或者推理时框跑到图像外。原因是标注过程中 bndbox 坐标被截断或粘贴复制出错,出现 xmin > xmax 或坐标超出图片宽高,换算成归一化坐标后就变成负数或大于 1。解决:在解析 XML 时对坐标做 clamp 并过滤非法框:

x1 = min(max(float(bb.find('xmin').text), 0), img_w - 1) y1 = min(max(float(bb.find('ymin').text), 0), img_h - 1) x2 = min(max(float(bb.find('xmax').text), 0), img_w - 1) y2 = min(max(float(bb.find('ymax').text), 0), img_h - 1) if x2 <= x1 or y2 <= y1: continue

参数上注意用 img_w - 1 而不是 img_w,否则归一化时可能出现等于 1 的边界点。这段代码放在解析函数里逐文件处理,同时打印出被过滤的文件名,事后回去看原始标注再决定是修还是删。

4.3 JSON 与 VOC 的坐标表示差异:左上右下 vs 左上宽高

现象:用 JSON 转换出来的框整体偏移,尤其在目标边缘,框的右下角位置明显不对。原因是 JSON 标的 bbox 几乎都是 xywh,左上角 x、左上角 y、宽度、高度,而 VOC 是 xyxy。两套表示法的转换公式是 x2 = x1 + w,y2 = y1 + h,很多人只改了取字段名,忘了加宽高。解决:写一个探测函数,先从 JSON 里抽一张图,把框画在图上人工确认,再写批量转换。这个过程不要省,我在这上面反复横跳过两次,都是因为太自信。

4.4 图片尺寸不一致时 YOLO TXT 归一化失效

现象:某些图片的框位置完全错误,但另一些图是好的。原因是 YOLO 的归一化坐标依赖于图片原始宽高,如果数据集混入了缩放过的图,或 txt 是从一个统一尺寸版本转换而来,而 images 目录里的实际尺寸变了,坐标就会错位。解决:解析 txt 时不要信任目录名或 XML size,要现场用 PIL 读图片宽高。同时对尺寸差异过大的图片做预统一。更隐蔽的是 EXIF 旋转问题,手机拍的图在标注工具里显示是正的,但训练框架读到的是带旋转的原始数据,宽高直接对调,这种情况建议解压后先统一清洗一遍。

4.5 背景类干扰:把“听课”误检成“玩手机”

现象:验证集 mAP 不低,但放到真实教室过视频时,记笔记、翻书、托腮这些动作频繁被检成玩手机。原因是上课场景里手部动作高度相似,玩手机和翻书在 640 分辨率下特征差异很小,模型学到的是“手在桌面下方有动作”这个粗糙模式。解决思路有两层。数据层:把这类误检帧抽出来做难负样本,标成 background 或直接作为无目标图片加入训练。逻辑层:部署时加时序约束,单帧检测结果只有连续 N 帧同时命中才输出报警,能压掉一大半抖动误报。这算是做课堂检测的血泪经验,前期标数据时没人提醒,后期全靠补样本。

5. 验证与进阶:把 1698 张数据集做成能上线的课堂检测方案

5.1 用混淆矩阵和分时统计验证模型真实可用性

训练完成后,先别急着部署。把验证集的预测结果落盘成 CSV,统计每张图的检测结果与真实标签的匹配情况,同时带上图片在课程中的时间位置,按“上课前 10 分钟、中段、后 10 分钟”分桶。课堂场景有个明显规律:睡觉和玩手机集中在后段,听讲集中在前段,分时准确率能直接暴露模型在哪个时段不可用。下面用 sklearn 的 confusion_matrix 做一个快速验证:

from sklearn.metrics import confusion_matrix import pandas as pd df = pd.read_csv('val_results.csv') # 包含 image, true_label, pred_label labels = ['听讲', '睡觉', '玩手机'] cm = confusion_matrix(df['true_label'], df['pred_label'], labels=labels) print(pd.DataFrame(cm, index=labels, columns=labels))

注意这里输入的 true_label 和 pred_label 是图片级标签,不是框级标签,需要先按 IoU 把检测框匹配到目标。验证的意义在于把模型从黑匣子变成能用数字判断的东西,之后决定要不要给项目组交付。

5.2 从三类扩展到更多状态的迭代思路

1698 张数据能跑通,但上线前最好扩到五类:加“举手”和“趴桌”。做法不是重新标一遍,而是把现有检测结果当预标注,人工快速修正。先训练三类的 s 模型,用它对新增教室图片预测,抽帧后人工只改框和类别,改完回流给模型做下一轮训练。这个闭环里,数据增强别加太猛,课堂场景的平移、翻转够用,颜色抖动和 mosaic 增强对这种小目标任务反而容易过拟合。

5.3 伪标签与数据回流:小数据集的后悔药

我的习惯是:第一轮只训练三个原始类别,然后让模型去标注同教室不同时段的未标注图片,把置信度高于 0.9 的框直接作为伪标签,0.5 到 0.9 的框人工复查,低于 0.5 的丢弃。这样 1698 张可以扩到 3000-4000 张有效标注,成本几乎为零。伪标签回流时注意只保留同一个模型能稳定复检的框,不要混入多条来源的标注标准。

说到底,这类数据集的价值不在数字本身,而在于它给了你一个干净、对齐、多格式的起点。拿到先解压、统计、校验,再谈训练;训练时先定类别映射,再调参数;部署前先看分时混淆矩阵。这套流程我踩过不少坑才固定下来,希望帮到你。

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

返回列表