简介面向智慧课堂场景的学生上课状态检测数据集包含1698张由演员在教室模拟拍摄的图像覆盖认真听讲、睡觉、玩手机三类状态适合课程作业、算法设计、比赛以及课堂智能监控等实际项目可支撑目标检测与行为识别模型训练。包体共2000个文件含jpg原图、xmlVOC、txtYOLO与json标签可直接适配多种检测框架整体约973.64MB目录结构便于分类使用。目前已有1435人学习下载。数据标注精准、背景丰富、分布均匀配套多格式标签可显著降低标注成本便于快速开展实验和效果验证适合智慧课堂、课堂氛围检测方向的学习者与开发者。1. 上课状态检测数据集为什么三个格式标签比图片本身更重要做课堂行为识别的人迟早会卡在同一个地方模型结构选好了预训练权重也下好了但手里没有一套“能直接喂给YOLO或SSD”的标签数据。你上网找开源数据集要么图片够但标注格式老旧要么标签是VOC但训练脚本只认YOLO最后大半时间都耗在格式转换上。这个名为“学生上课状态检测数据集(听讲-睡觉-玩手机)1698张-含voc(xml)yolo(txt)json三种格式标签.zip”的包解决的就是这个痛点1698张课堂实拍图每张都同时给了听讲、睡觉、玩手机三类目标的VOC(xml)、YOLO(txt)和JSON三种标签拿到手不用写转换脚本就能直接开训。适合正在做课堂考勤、网课注意力分析、教育督导系统的算法工程师和学生也适合刚入门目标检测、想拿一份干净数据练手YOLOv5/v8的开发者。这类数据集的真正价值不在于图片数量而在于标签格式的完整性。很多入行者第一次训练时翻车翻的不是模型是数据加载环节——labelImg导出的xml和YOLO需要的txt之间类别顺序、坐标归一化方式都不一样差一步就边界框全偏。所以这篇笔记会按“数据集怎么组织、三种标签怎么解析、怎么互相转换、踩过哪些坑、怎么把数据用出花来”的顺序把这1698张图的用法讲透。2. 解压后先别急着训练搞清目录结构与三种标签的底层逻辑拿到zip包第一步不是解压后直接扔进训练脚本而是先花五分钟把目录结构看明白顺便确认三个格式之间到底是什么关系。常见做法是先建一个干净的工作目录再把压缩包解压进去然后逐层查看文件组织方式。mkdir -p classroom_dataset cd classroom_dataset unzip ../学生上课状态检测数据集*.zip -d . # 解压后先看顶层结构不要直接去看JPEGImages find . -maxdepth 2 -type d | sort解压后常见目录组织是JPEGImages或images放jpg原图Annotations或xml放VOC格式的xmllabels或yolo_labels放YOLO格式的txtjson文件夹放JSON标注。有些打包者还会附带classes.txt或data.yaml前者记类别名后者是YOLO训练时直接引用的配置文件。先执行上面的find命令确认实际目录名避免后面脚本写错路径。这里要理解一个关键点三种格式描述的是同一批目标但组织方式完全不同。VOC的xml是“单张图片一个文件”用标签树描述每个目标的矩形框坐标xmin, ymin, xmax, ymax和类别名YOLO的txt是“一行一个目标”格式为“类别id x_center y_center width height”且四个坐标值必须是在0到1之间的归一化浮点数JSON则通常用一个数组或字典把所有图片的标注汇总在一起。三者之间不是互相替代而是服务于不同训练框架——VOC是传统检测框架如Faster R-CNN原版的输入YOLO是YOLO系列的原生格式JSON则方便你写自定义数据加载器或做跨框架迁移。在写任何代码之前先用一个简单命令确认图片尺寸是否统一因为YOLO格式的归一化坐标依赖图片宽高如果数据集里混有不同分辨率转换时就得逐张读取宽高不能想当然用固定值。下一步我们进入三种格式的具体解析。# 随机抽5张图用Python快速看尺寸分布 python3 -c from PIL import Image import glob for p in sorted(glob.glob(JPEGImages/*.jpg))[:5]: im Image.open(p) print(p, im.size) 这一步输出的宽高比很重要。如果发现某些图是1920x1080另一些是1280x720后面写xml转yolo脚本时就必须用PIL或cv2逐张取宽高而不能用硬编码。这个细节很多新手会忽略导致转换出来的YOLO标签里归一化坐标算错训练时loss直接nan。3. 从XML到YOLO再到JSON三种标签的解析要点与最小可用代码3.1 先读懂VOC格式的XML命名空间与关键节点VOC格式的xml是有固定结构的但不同的标注工具labelImg、Label Studio等生成的xml可能在根节点名称或子节点顺序上有细微差异。解压后先随机打开一个xml确认里面的关键字段。典型的VOC xml包含annotation根节点下面有folder、filename、source、size含width、height、depth然后是多个object节点每个object里有name类别名、bndbox含xmin、ymin、xmax、ymax。解析xml时我最常用的是Python自带的xml.etree.ElementTree不用额外装第三方库先把xml里所有object提取出来转成字典列表备用。import xml.etree.ElementTree as ET def parse_voc_xml(xml_path): tree ET.parse(xml_path) root tree.getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) objects [] for obj in root.findall(object): name obj.find(name).text box obj.find(bndbox) xmin int(float(box.find(xmin).text)) ymin int(float(box.find(ymin).text)) xmax int(float(box.find(xmax).text)) ymax int(float(box.find(ymax).text)) objects.append({ name: name, bbox: [xmin, ymin, xmax, ymax] }) return img_w, img_h, objects # 测试解析第一个xml w, h, objs parse_voc_xml(Annotations/000001.xml) print(w, h, objs)注意一个常见陷阱有些标注工具导出的xmin等字段是整数有些是字符串形式的浮点数比如“100.0”所以上面用int(float(...))做了二次转换这样无论原始是int还是float字符串都能安全处理。另外如果xml里有difficult或truncated节点表示该目标是否难以辨认或被截断训练是否使用这些样本取决于你的具体策略至少解析时别把它们和正常目标混在一起。3.2 理解YOLO标签的归一化坐标为什么是0到1的小数YOLO格式的txt每个文件对应一张图片文件名与图片名相同只是扩展名不同。每一行代表一个目标格式是“class_id x_center y_center width height”其中x_center和y_center是目标中心点坐标除以图片宽高后的归一化值width和height是目标框宽高除以图片宽高后的归一化值。所以这个txt里不可能出现大于1的数如果出现说明数据有问题或者转换脚本写错了。直接读一个YOLO txt文件看一下内容cat labels/000001.txt # 预期输出每行5个数第一列是类别id后面四个是归一化坐标如果这个数据集里缺失了类别映射关系你可以通过查看classes.txt或data.yaml来确认id含义一般约定为0听讲或正常听讲1睡觉2玩手机。但具体顺序必须以包内文件为准因为在有些课堂数据集里作者会把“玩手机”设为0、“睡觉”设为1、“听讲”设为2顺序不同你的模型最终输出的类别含义就不同。训练前必须确认这一点否则模型推理时classes顺序全错等于白训。3.3 解析JSON标注嵌套结构与数组操作JSON格式的标注通常有两种组织方式。第一种是“每张图片一个对象”的大数组每个对象含文件名、宽高、目标和坐标第二种是Key-Value结构key是文件名value是标注列表。解析JSON用标准库json模块即可关键是遍历时不要假设每张图都有目标有些图可能是纯背景或只有一类目标代码要兼容空列表。import json with open(json/annotations.json, r, encodingutf-8) as f: data json.load(f) # 兼容两种常见结构 if isinstance(data, dict): items list(data.items()) elif isinstance(data, list): items enumerate(data) for key, ann in items: if isinstance(ann, dict): filename ann.get(filename, ann.get(image_id, fimage_{key})) width ann.get(width, ann.get(img_w, 0)) height ann.get(height, ann.get(img_h, 0)) objs ann.get(objects, ann.get(annotations, [])) print(filename, width, height, len(objs))这种兼容写法很重要因为不同打包者对JSON字段命名不一致。有的用bbox有的用box有的用points有的用xmin等单独字段。解析时先打印出字典的keys再写具体取值逻辑别一上来就假设字段名。JSON格式的优势在于可以用自定义字段携带附加信息比如目标是否被遮挡、置信度、跟踪ID如果这个数据集里包含这类字段解析时要决定是丢弃还是保留。4. 把VOC转成YOLO格式转换脚本与四个边界坑4.1 写一个能跑的批量转换脚本绝大多数训练场景下你最终需要的是YOLO格式的txt。如果你只需要YOLO可以直接用数据集自带的txt训练但如果想用某些基于VOC或JSON的框架或者想自定义数据划分就需要写转换脚本。这里给一个我习惯用的xml转yolo脚本处理起来比较稳妥。import os import xml.etree.ElementTree as ET from PIL import Image # 类别顺序必须与数据集里classes.txt一致 class_names [听讲, 睡觉, 玩手机] class_to_id {name: i for i, name in enumerate(class_names)} def convert_xml_to_yolo(xml_path, img_path, out_txt_path): tree ET.parse(xml_path) root tree.getroot() # 关键从图片文件读取宽高不要从xml读取 with Image.open(img_path) as im: img_w, img_h im.size lines [] for obj in root.findall(object): name obj.find(name).text if name not in class_to_id: print(f[WARN] {xml_path} 包含未知类别: {name}) continue cls_id class_to_id[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(0, min(xmin, img_w)) ymin max(0, min(ymin, img_h)) xmax max(0, min(xmax, img_w)) ymax max(0, min(ymax, img_h)) # 有可能出现xminxmax的边角情况 if xmax xmin or ymax ymin: print(f[WARN] 无效框: {xml_path}, bbox{[xmin, ymin, xmax, ymax]}) continue # 转YOLO归一化坐标 x_center (xmin xmax) / 2 / img_w y_center (ymin ymax) / 2 / img_h width (xmax - xmin) / img_w height (ymax - ymin) / img_h # 坐标clip到0-1之间防止浮点误差 x_center min(max(x_center, 0.0), 1.0) y_center min(max(y_center, 0.0), 1.0) width min(max(width, 0.0), 1.0) height min(max(height, 0.0), 1.0) lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}) with open(out_txt_path, w, encodingutf-8) as f: f.write(\n.join(lines)) # 批量转换 annotations_dir Annotations images_dir JPEGImages out_dir yolo_labels os.makedirs(out_dir, exist_okTrue) for xml_name in os.listdir(annotations_dir): if not xml_name.endswith(.xml): continue stem xml_name[:-4] xml_path os.path.join(annotations_dir, xml_name) img_path os.path.join(images_dir, stem .jpg) if not os.path.exists(img_path): print(f[ERROR] 找不到图片: {img_path}) continue out_txt os.path.join(out_dir, stem .txt) convert_xml_to_yolo(xml_path, img_path, out_txt) print(转换完成输出目录:, out_dir)这段脚本的核心逻辑是解析xml拿类别和原始像素坐标用PIL读图片真实宽高做归一化最后写txt。但我特意加了三层防御——未知类别跳过、坐标裁剪、宽高为负跳过这些在真实数据里都可能遇到。4.2 边界坑一xml里的宽高和图片真实宽高不一致时以图片为准这是最隐蔽的问题。labelImg在某些缩放操作后保存的xml里size节点记录的可能是标注时的显示尺寸而不是图片原始尺寸。如果直接用xml里的宽高做归一化而YOLO训练时加载的是原始图片宽高比不对就会导致所有框偏移。解决方式就是上面的脚本用PIL或cv2读取图片实际尺寸忽略xml中的size节点。4.3 边界坑二类别名大小写与空格不一致课堂上三类目标偶尔会有命名变体listening和Listening、play_phone和playPhone、sleep和sleeping。如果标签里有这些变体直接按字符串匹配会漏检目标。解决方式是写一个别名映射表把同义类别归一化到主类别名上。具体做法是先在转换前跑一遍所有xml统计所有出现过的name值。from collections import Counter name_counter Counter() for xml_name in os.listdir(Annotations): tree ET.parse(os.path.join(Annotations, xml_name)) for obj in tree.getroot().findall(object): name_counter[obj.find(name).text] 1 print(name_counter)一次性把类别分布打出来如果发现“听讲 ”带空格或“sleeping”这种变体记得在转换脚本里处理。否则你训练时类别数量对不上模型会傻掉。4.4 边界坑三同一张图多个目标重叠时YOLO格式天然支持但注意anchor匹配YOLO格式允许一张图的多个目标写在同一个txt的多行。如果两个目标高度重叠比如一个睡觉的学生趴在桌上头部和手臂被另一个目标遮挡YOLO训练时anchor分配可能会有问题导致两个框总有一个被忽略。这不是数据标签的问题而是模型本身对重叠目标的限制。你可以通过调整anchor尺寸或开启更精细的标签分配来缓解。在处理数据集时如果发现大量重叠框建议做一次可视化看看是真实场景如此还是标注误差。这里提供一个可视化验证脚本把YOLO标签画回图片上随机抽几张确认坐标是否正确import cv2 img_path JPEGImages/000001.jpg txt_path yolo_labels/000001.txt img cv2.imread(img_path) h, w img.shape[:2] with open(txt_path, r) as f: for line in f.readlines(): parts line.strip().split() cls_id, xc, yc, bw, bh int(parts[0]), float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]) xmin int((xc - bw / 2) * w) ymin int((yc - bh / 2) * h) xmax int((xc bw / 2) * w) ymax int((yc bh / 2) * h) color (0, 255, 0) if cls_id 0 else ((0, 0, 255) if cls_id 1 else (255, 0, 0)) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), color, 2) cv2.imwrite(visual_check.jpg, img)这一步很有必要。坐标转换后肉眼看几张框是否贴合目标、类别颜色是否正确一眼就能发现问题。建议随机抽10张以上检查不要只看着色号的框就完事。4.5 边界坑四JSON转YOLO时坐标可能是“中心点宽高”或“左上角宽高”JSON里的坐标表示非常不统一。有的数据用[x, y, width, height]其中x,y是左上角有的用[cx, cy, width, height]即中心点还有极少数用[xmin, ymin, xmax, ymax]。转换前必须先确认是哪种。判断方法很简单打印一条标注对比图片尺寸看x值是否远小于图片宽度的一半。# 如果你不确定JSON里bbox的含义先用这个脚本打印样例 with open(json/annotations.json, r, encodingutf-8) as f: data json.load(f) first_key next(iter(data)) if isinstance(data, dict) else 0 first_ann data[first_key] if isinstance(data, dict) else data[0] print(json.dumps(first_ann, ensure_asciiFalse, indent2))如果JSON里是中心点格式转换到YOLO时只需把cx和cy除以宽高如果已经是归一化的就更省事但如果是左上角格式就要先加宽高的一半得到中心点再归一化。写错这一个步骤整个坐标系统翻车而且这种错从可视化上很难发现因为框的位置看起来“接近”但始终不对。5. 训练前必做的检查清单从标签清洗到YOLO数据配置四大高频翻车点5.1 类别名与模型配置对不上训练报错或loss异常最常见的翻车场景是数据里类别是“听讲、睡觉、玩手机”但你在YOLO的data.yaml里写的names是[sleep, phone, listen]顺序完全错位训练出来的模型推理结果语义全错。解决方式是在训练前打印数据集的类别文件和模型配置做一次人工对照。在YOLOv5/v8的data.yaml里一般这样写# data.yaml 内容示例 train: ./images/train val: ./images/val nc: 3 names: [listening, sleeping, playing_phone]必须确认names顺序和txt里第一列的类别id严格对应。建议直接从数据集的classes.txt自动生成yaml而不是手写这样不会出错。如果数据集没提供classes.txt从xml或json里提取类别顺序来生成。5.2 训练集/验证集划分时图片和标签没配对导致验证集没有标签这是另一个高频翻车点。很多人划分数据集时只移动了图片忘记移动对应的txt或xml导致训练时所有图都能加载验证时一堆图片没有标签文件模型精度报告变成0。划分时建议用脚本统一处理别手动拖文件。# 创建训练/验证目录按8:2比例划分 mkdir -p images/train images/val labels/train labels/val python3 -c import os, random, shutil random.seed(42) imgs sorted(os.listdir(JPEGImages)) random.shuffle(imgs) split int(len(imgs) * 0.8) for i, name in enumerate(imgs): stem name[:-4] sub train if i split else val shutil.copy(fJPEGImages/{name}, fimages/{sub}/{name}) shutil.copy(fyolo_labels/{stem}.txt, flabels/{sub}/{stem}.txt) print(ftrain: {split}, val: {len(imgs)-split}) 注意这里用了随机种子保证每次划分一致。划分完成后最终确认一下labels/val里是否有txt文件且数量与images/val里图片一致。5.3 空标签问题部分图片没有目标训练时YOLO报错或跳过课堂场景下有些图可能是全景空镜或所有学生都在低头写字没被标注为三类中的任何一类。这类图的txt文件是空的0字节。YOLO训练可以接受空标签但有些老版本框架会报错。处理方式有两种如果图片完全无意义直接从数据集里删掉如果场景中有未被标注的目标但属于三类之外保留图片但不加标签让模型学会输出背景类。最好先做个统计看看有多少张图是空标签find yolo_labels -name *.txt -size 0 | wc -l如果超过总图片数的5%建议检查一下标注逻辑是漏标还是这三类真的覆盖不了部分图片。如果只是漏标需要手动补标否则模型会把这些图片当纯背景训练影响检测精度。5.4 类别不平衡睡觉得很少而听讲很多导致模型偏向预测听讲课堂场景下“听讲”通常占大头“睡觉”和“玩手机”比例低。如果不做任何处理模型会倾向于把所有目标预测为听讲因为整体loss里听讲的梯度占主导。解决方式有几种计算每个类别的目标数量做类别权重或对少数类做过采样让每个epoch里少数类图片重复出现或者在训练参数里调整cls_loss的权重。先统计一下标签分布import os from collections import Counter counter Counter() for txt_name in os.listdir(yolo_labels): with open(os.path.join(yolo_labels, txt_name), r) as f: for line in f: if line.strip(): cls_id int(line.strip().split()[0]) counter[cls_id] 1 print(各类别目标数量:, dict(counter)) total sum(counter.values()) for cid, cnt in counter.items(): print(f类别{cid}: {cnt}, 占比{cnt/total:.2%})如果少数类占比小于10%建议训练时在YOLO超参数里把对应类别的loss权重调高或者在数据增强阶段对包含少数类的图片多复制几份。实操经验是多训练几十个epoch比调权重更省事但效果不一定好还是要看具体数据分布。6. 让数据集发挥最大价值课堂场景的迁移学习与模型微调技巧6.1 基于预训练权重做迁移学习别从零训练这种课堂检测数据集的图片量级1698张不足以支撑从零训练一个深层检测网络。正确做法是加载在COCO或ImageNet上预训练的权重冻结骨干网络的前几层只微调后面层和检测头。用YOLOv8举例训练命令如下yolo detect train datadata.yaml modelyolov8n.pt epochs100 imgsz640 batch16这里的modelyolov8n.pt会自动下载COCO预训练权重然后在你自己的课堂数据上微调。正因为你的数据只有1698张建议选yolov8n或yolov8s这种小模型不要一上来用yolov8x否则特征提取能力过剩且收敛慢GPU显存也容易爆。epochs先跑100轮看验证集mAP曲线是否还在上升如果50轮就过拟合要记得加早停。6.2 数据增强策略要克制课堂场景不适合暴力增强课堂场景下的图片相对固定——摄像头角度不变、光照稳定、人物姿态有限。因此不适合用随机旋转90度、水平翻转这类强增强因为翻转后文字方向会变模型反而学不到有效特征。推荐增强组合是轻微亮度抖动±20%、轻微饱和度抖动、随机缩放0.8到1.2倍、随机平移±5%。这些增强能提升模型对不同教室光照的鲁棒性而不会破坏课堂语义。YOLOv8里可以通过augment参数控制或者直接修改超参配置文件。实操中建议先不开增强训练一个baseline再开启增强对比mAP变化你会发现增强并没有想象中提升大因为课堂图片本身多样性就有限增强只会增加训练时长。6.3 用训练好的模型做实时课堂状态统计部署时省内存的技巧模型训练完成后实际落地时通常不只需要检测框还需要按人头统计课堂状态比例。如果你用YOLOv8训练导出模型时建议做模型量化把权重从FP32转成FP16或INT8在Jetson或边缘盒子上的推理速度能提升2到3倍。导出命令yolo export modelbest.pt formatengine device0 halfTrue导出engine格式专用于TensorRT部署推理速度最快但要注意输入尺寸必须和训练时一致。如果教室摄像头是全景的建议把画面切成4到6个区域分别推理拼接结果时按目标中心点去重否则同一个学生可能被两个区域的框重复检测到。6.4 其他场景能不能复用这套格式处理流程其实这套“VOC/YOLO/JSON三格式互转训练前检查”的方法不止用于课堂数据集。任何多类别目标检测项目比如电力红外数据集、工厂安全帽检测、工地违规行为识别面临的都是同样的痛点目标检测项目的流程和格式转换方法才是数据集的真正附加值。核心就一句话无论原始标签格式是什么先转换成你训练框架的原生格式再做可视化验证最后才开训。最后说一个我自己的教训第一次拿到课堂数据集时我偷懒没有做可视化验证直接转完格式就开训训了50个epoch发现loss降到0.3但mAP只有0.2后来画出标签一看JSON坐标中心点解析错了所有框往右下角偏了15%。把坐标修正后重新训练同样的epoch数mAP直接到0.67。从那以后我养成了一个习惯任何数据集到手第一步永远是解析一个样本、可视化一个样本、统计一遍类别分布这三件事做完数据层面的问题基本能暴露80%。希望这个流程对你的课堂行为检测项目有帮助。本文还有配套的精品资源点击获取