学生上课状态检测:VOC/YOLO/JSON标签转换与YOLOv8训练实战
发布时间:2026/10/2 3:51:57来源:尧图网络
简介这是一份面向智慧课堂、课堂智能监控及学生学习状态检测场景的图像目标检测数据集共包含1698张真实拍摄的学生上课图片覆盖“认真听讲”“睡觉”“玩手机”三类状态适用于课程设计、算法比赛及实际项目中的模型训练与验证。资源包共2000个文件主要文件类型为jpg图像、txt标签YOLO格式和xml标签VOC格式同时提供JSON标注压缩包整体约973.64MB。目前已有1435人学习浏览。数据集由作者组织演员在教室内模拟拍摄标注精准背景丰富类别分布均衡标签与图像一一对应无需额外整理可直接用于YOLO、Faster R-CNN等常见目标检测框架也可用于自定义模型的迁移学习。无论是搭建智慧教室监控原型还是训练课堂行为识别模型这份数据都能提供可靠支撑。1. 学生上课状态检测数据集1698 张图、三套标签开箱即训教室后方的摄像头画面里前排低头刷手机中排趴着睡觉后排几个在抄笔记。做智慧课堂行为分析你第一件事不是建模而是找数据。“学生上课状态检测数据集(听讲-睡觉-玩手机)1698张含voc(xml)yolo(txt)json三种格式标签”值得关注的理由就在这规模不算大但三种标签格式一次给齐省掉最磨人、最容易算错的格式转换环节拿到手就能按 YOLO 流程直接开训。适合做智慧课堂、课堂氛围检测的工程师拿课堂行为做项目或毕设的在校生以及刚装好 YOLO 环境、正缺一份干净数据练手的人。先说结论1698 张对三类检测足够出 demo、复现完整流程但离生产级还有距离后续要补数据增强和真实教室场景的采样。2. VOC、JSON 与 YOLO 三种标签格式字段差异与互相转换同一个包里有三种格式容易让人误以为是三份独立标注。实际不是这样三种格式描述的是同一批框区别只在坐标表达、类别表达和文件组织方式。VOC 和 JSON 用绝对像素坐标类别用文本名YOLO 用归一化坐标类别用整数 id。三者可以互相推导所以理论上同一张图的三个标签应该完全对齐——但你实战中遇到的第一个坑恰恰是同一张图三种格式的框对不上。2.1 VOC xmlobject 与 bndbox 的读取逻辑VOC xml 是 Pascal VOC 时期的祖传结构annotation 根节点下挂 filename、size 和若干 object每个 object 里面有 name 和 bndboxbndbox 里是 xmin、ymin、xmax、ymax 四个整数绝对坐标。课堂画面里一张图通常有 2 到 5 个目标所以一个 xml 里有多个 object 节点是常态。我拆这种标签包时第一步永远写一个解析脚本把 xml 读成 Python 字典先确认字段能对上。解析方式是固定的用 xml.etree.ElementTree 即可不依赖第三方库。import xml.etree.ElementTree as ET def parse_voc(xml_path): tree ET.parse(xml_path) root tree.getroot() # 读图片尺寸后续转 YOLO 归一化坐标必须用它 size root.find(size) img_w int(size.find(width).text) img_h int(size.find(height).text) objs [] for obj in root.findall(object): name obj.find(name).text box obj.find(bndbox) xmin int(box.find(xmin).text) ymin int(box.find(ymin).text) xmax int(box.find(xmax).text) ymax int(box.find(ymax).text) objs.append({name: name, bbox: [xmin, ymin, xmax, ymax]}) return img_w, img_h, objs这段代码里有两个容易出问题的点。第一findall(object) 是按层级找比 iter(object) 更稳iter 会把嵌套在其他节点里的同名元素也捞出来。第二img_w、img_h 不是拿来好看的后面做 YOLO 归一化必须用这组真实尺寸而不是拍脑袋填 1920x1080。数据集的图如果是从视频抽帧而来宽高可能是 1280x720也可能是 1920x1080写死尺寸等于报废一批标签。2.2 JSONCOCO 风格的 categories 与 annotations数据集里的 json 标签常见做法是按 COCO 检测格式组织一个 json 文件包含 images、annotations、categories 三个数组。images 记录每张图的 id、file_name、width、heightcategories 记录类别annotations 记录每个目标框bbox 字段是 [x, y, w, h]。这个结构有一处专门坑人COCO 的 bbox 是左上角坐标加宽高不是 VOC 的右下角坐标。也就是说一个框在 VOC 里写 xmin100, ymin80, xmax240, ymax220在 COCO json 里就是 bbox[100, 80, 140, 140]。很多人直接拿 VOC 的 xmax 和 ymax 填进 json 的 bbox结果框变成了两倍大。把 COCO 转成 VOC 时xmax x w、ymax y h这一步必须重新计算不能直接抄。import json with open(labels.json) as f: coco json.load(f) # 建立 image_id 到文件名的映射 img_id2name {img[id]: img[file_name] for img in coco[images]} cat_id2name {cat[id]: cat[name] for cat in coco[categories]} for ann in coco[annotations]: img_name img_id2name[ann[image_id]] cat_name cat_id2name[ann[category_id]] # COCO bbox 是左上角 宽高先转成 VOC 的左上角 右下角 x, y, w, h ann[bbox] x1, y1, x2, y2 x, y, x w, y h # 这里再一步转 YOLO 归一化坐标 cx (x1 x2) / 2 / img_width cy (y1 y2) / 2 / img_height bw (x2 - x1) / img_width bh (y2 - y1) / img_height注意最后四行转 YOLO 坐标时用的是中心点公式 (xmin xmax) / 2 再除以图像宽不是 xmin 除以宽。这一步算错检测框会整体向右下偏移而且训练时模型不会报错只在可视化时暴露。img_width 和 img_height 在正式脚本里应该从 images 数组里对应记录读取不要在循环外写死。2.3 YOLO txt归一化坐标与类别 id 的对应YOLO 格式的标签是每个 txt 文件对应一张图每行代表一个目标class_id cx cy w h。所有坐标都是 0 到 1 之间的浮点数class_id 从 0 开始递增。以三类为例0 对应听讲1 对应睡觉2 对应玩手机这个顺序必须和训练配置文件里的 names 完全一致错一位就是全量标签错位。读取 txt 也是固定套路但要注意过滤空行和坐标越界的行。有些转换脚本会在某张图没有目标时生成一个空 txtYOLO 允许空标签文件但如果你用的是旧版工具空文件也可能被误读成一行 0 0 0 0 0训练 loss 直接跑飞。def read_yolo_txt(txt_path, img_w, img_h): boxes [] with open(txt_path) as f: for line in f: line line.strip() if not line: continue cid, cx, cy, w, h map(float, line.split()) # 越界检查归一化坐标理论上应在 [0, 1] 内 if not all(0 v 1 for v in (cx, cy, w, h)): print(f越界标签: {txt_path} - {line}) continue # 转回像素坐标 x1 int((cx - w / 2) * img_w) y1 int((cy - h / 2) * img_h) x2 int((cx w / 2) * img_w) y2 int((cy h / 2) * img_h) boxes.append((int(cid), x1, y1, x2, y2)) return boxes这段代码筛选掉了越界的归一化坐标。常见情况是 w 或 h 超过 1说明转换脚本里把 xmax 当成宽度在用cx、cy 为 0说明转换时忘了做归一化而是直接抄了原值。越界行先打印出来而不是直接丢弃这样你能从文件名快速倒查是某张图的问题还是转换脚本的通病。在这三种格式之间来回切换时我一般建议以 VOC 坐标作为中转先统一解析成 xmin、ymin、xmax、ymax 像素坐标再分头输出成 json 或 yolo。不要直接做 json 到 yolo 的跳跃中间少一步坐标解释排查起来会省很多时间。3. 训练前的三个硬关卡完整性校验、类别统一与数据划分很多人拿到数据集解压后直接开训这是翻车率最高的一条路。1698 张图对应的三个标签目录只要有一个文件名对不上、一个类别名拼写不一致、一个划分脚本数据泄露训练过程不一定报错但最后 val 指标一定有问题。我习惯在跑任何训练命令之前按下面三个关卡过一遍。3.1 文件名与样本数完整性校验第一关是核对三个目录里的文件名是否完全一致。解压后你大概率看到 images 目录、voc 目录、yolo 目录和 json 文件。图片可能混着 jpg 和 png标签文件却是统一的扩展名所以比对时必须用文件名主干stem而不是完整文件名。import os from pathlib import Path base Path(/path/to/dataset) img_dir base / images voc_dir base / voc yolo_dir base / yolo def get_stems(d, exts): result set() for p in d.iterdir(): if p.suffix.lower() in exts: result.add(p.stem) return result imgs get_stems(img_dir, {.jpg, .jpeg, .png}) voc get_stems(voc_dir, {.xml}) yolo get_stems(yolo_dir, {.txt}) print(图片数量:, len(imgs)) print(VOC 数量:, len(voc)) print(YOLO 数量:, len(yolo)) print(缺 VOC 标签的图片:, imgs - voc) print(缺 YOLO 标签的图片:, imgs - yolo) print(多余标签文件:, (voc | yolo) - imgs)这段脚本直接在命令行跑几秒钟就能发现三类问题图片和标签数量对不上、某个 stem 只在其中一个目录出现、以及标签文件的 stem 带后缀导致匹配失败。suffix.lower() 是为了防止 JPG 和 jpg 这类大小写差异造成的匹配误判。如果差集不为空说明这个包在某处重新命名过文件或转换中断过。常见的处理方式是统一以 images 目录为准把标签目录里多余的文件移到一个 backup 目录不要把缺的标签硬凑出来——缺标签宁可删图也不要让模型对着错位标签练。3.2 类别口径统一向一份 names 清单对齐第二个高频问题出现在类别名不统一。VOC 的 name 字段写的可能是中文“玩手机”json 的 categories 里写的是 using_phone而 yolo 的 txt 里只有 class_id 0、1、2。乍看不影响训练因为 YOLO 只看 txt不看 xml 和 json。但你一旦要用 json 做数据增强筛选、用 xml 做可视化中文名和英文名对不上就会立刻卡住。这个数据集从名字看类别是三分类按场景语义我给的建议映射是 0-listening、1-sleeping、2-using_phone。实际包内到底用哪个英文名以你解压后看到的为准但不管原始名字是什么训练前都要把三类归到一个统一的 names 清单里。name_map { 听讲: listening, 睡觉: sleeping, 玩手机: using_phone, student_listening: listening, # 兼容其他命名 } import xml.etree.ElementTree as ET from pathlib import Path for xml_path in Path(voc).glob(*.xml): tree ET.parse(xml_path) root tree.getroot() for obj in root.findall(object): raw obj.find(name).text mapped name_map.get(raw) if mapped is None: print(f{xml_path.stem} 类别异常: {raw}) else: obj.find(name).text mapped tree.write(xml_path, encodingutf-8, xml_declarationTrue)这个脚本把 VOC 里的中文类别名统一改成英文标准名。注意它不只是替换还会把解析不到的类别名打印出来。看到“类别异常”不要直接跳过结合 json 里的 categories 字段去查是不是有第四类噪声标签比如“低头”或者“读写”这种与三分类不完全重叠的行为。噪声类别要么并入相近类要么删除对应样本留着只会让分类边界模糊。提示统一之后把三类的框数量各统计一遍。正常课堂数据集里 listening 的数量会明显多于 sleeping 和 using_phone如果少数类占比低于 10%训练时要做类别权重或者过采样否则模型会把少数类全判成多数类val 看起来是 0.9实则少数类 AP 为 0。3.3 train/val 划分与目录整理第三个关卡是划分训练验证集。目标检测的标准划分是 80% 训练、20% 验证但划分方式有个隐藏问题如果这 1698 张图是从连续视频抽帧而来相邻帧内容几乎一样随机划分会把同一人的相邻帧同时分进训练和验证造成数据泄露。验证集 loss 会异常好看模型一出训练环境就现原形。我处理这种课堂抽帧数据优先按时间窗口或者视频片段分组划分先看目录是不是按日期或班级分子目录。如果没有子目录可以依赖就用文件名里的序号范围做切分比如前 80% 序号进训练后 20% 进验证至少保证时间域不重叠。下面是按序号范围划分的脚本。import random import shutil from pathlib import Path src_img Path(images) src_txt Path(yolo) train_img Path(split/train/images) train_txt Path(split/train/labels) val_img Path(split/val/images) val_txt Path(split/val/labels) for d in [train_img, train_txt, val_img, val_txt]: d.mkdir(parentsTrue, exist_okTrue) files sorted(src_img.glob(*.jpg)) random.seed(42) # 固定随机种子保证每次划分结果一致 random.shuffle(files) val_count int(len(files) * 0.2) for i, f in enumerate(files): stem f.stem txt_path src_txt / f{stem}.txt if not txt_path.exists(): print(f跳过缺失标签: {stem}) continue if i val_count: shutil.copy(f, val_img / f.name) shutil.copy(txt_path, val_txt / f{stem}.txt) else: shutil.copy(f, train_img / f.name) shutil.copy(txt_path, train_txt / f{stem}.txt)脚本里的 random.seed(42) 是拿来做复现的下次分配还是同一批图进训练。如果你是想严格按时间切分把 random.shuffle 去掉直接用 sorted 后的序号前 80% 做训练。这里我选择随机划分因为大多数打包数据集的图片顺序不等于拍摄时间顺序贸然按文件名序号切分反而可能把同类场景集中到某一个集合。划分完成后手动抽查验证集里的 5 张图确认没有和训练集里同一连续片段重复的帧。这一步花不了两分钟但能避免后面 val 曲线失真带来的无效调参。4. 用 YOLOv8 跑通训练data.yaml、参数设置与框回归验证格式确认、类别统一、数据划分这三关都过了才轮到真正跑训练。我通常直接用 ultralytics 的 YOLOv8 起步因为它是现在目标检测里最省事的框架一个 CLI 命令就能训练、验证、导出。下面按实际执行顺序来。4.1 data.yaml 与目录结构YOLOv8 的数据配置靠一个 yaml 文件描述。它需要知道三件事图片根目录在哪、训练和验证图片目录在哪、类别有几类且名字是什么。注意 train 和 val 填的是图片目录不是标签目录YOLO 会自动去图片目录的同级 labels 目录找 txt 标签。# data.yaml path: /absolute/path/to/split train: train/images val: val/images nc: 3 names: 0: listening 1: sleeping 2: using_phonepath 字段建议直接写成绝对路径。如果你用相对路径而终端当前目录不在 split 所在目录YOLO 会报图片找不到。names 的顺序必须和 yolo/txt 文件里的 class_id 一一对应这一点和前面 2.3 节反复强调的是同一件事顺序错位等于所有标签错位且训练全程不会给你任何警告。目录结构上YOLO 的约定是 train/images 里有图train/labels 里有同名 txtval 同理。上一节划分脚本已经按这个结构生成好了data.yaml 指向即可。4.2 训练参数怎么给从数据量倒推1698 张图对三分类目标检测来说属于小规模数据训练参数要顺着数据的性子来不能照搬 COCO 大模型的配方。我的推荐参数如下。参数推荐值原因modelyolov8n.pt数据量小n 起步s/m 容易过拟合epochs100配 early stopping不用死等 100 轮跑完imgsz640教室后排目标小640 比 480 更保精度batch16显存不够降到 8太小会震荡patience20val 连续 20 轮不升即停augmentmosaic、fliplr 开小数据集靠增强撑泛化命令行执行yolo detect train \ data/path/to/data.yaml \ modelyolov8n.pt \ epochs100 \ imgsz640 \ batch16 \ patience20 \ projectruns \ namestudent_state拆开说。batch16 是 8GB 显存左右的稳妥值如果你的卡只有 4GB降到 8否则 OOM 会中断训练。epochs100 不是必跑满patience20 的意思是从当前最优模型开始算连续 20 轮验证指标不涨就自动停通常 50 到 60 轮就能收敛。modelyolov8n.pt 是官方预训练权重YOLO 会自动下载如果你网络环境不方便可以先用小批量数据把模型跑一遍让框架缓存权重。这里有个和数据量直接相关的一点不要一上来就开 yolov8x参数太多1698 张图喂不饱val 指标反而比 n 还差。我见过不少人在小数据上迷信大模型结果训练 3 个小时 val mAP 还在 0.3 晃悠换成 n 之后两小时收敛到 0.7。小数据用小模型这个顺序别反过来。训练结束后在 runs/detect/student_state/weights/ 下会生成 best.pt 和 last.pt验证指标打印在 terminal 里混淆矩阵图在 runs/detect/student_state/ 目录下。拿到 best.pt 后用下面的可视化脚本做一次交叉验证。4.3 标签交叉验证把框画回原图再开训训练完做的第一件事不是看 mAP而是把预测框画到验证集原图上肉眼过一遍。这一步能发现三类问题框有没有整体偏移、类别有没有错位、小目标有没有被漏检。下面这个脚本读 YOLO 格式的 txt把框还原到图上同时标出 category id 对应的类别名。import cv2 img cv2.imread(split/val/images/frame_0001.jpg) h, w img.shape[:2] names {0: listening, 1: sleeping, 2: using_phone} colors {0: (0, 255, 0), 1: (0, 128, 255), 2: (0, 0, 255)} with open(split/val/labels/frame_0001.txt) as f: for line in f: line line.strip() if not line: continue cid, 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), colors[int(cid)], 2) cv2.putText(img, names[int(cid)], (x1, y1 - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, colors[int(cid)], 2) cv2.imwrite(check_frame_0001.jpg, img)这段脚本每行按 YOLO 格式解析转回像素坐标时注意先把中心点坐标解出来再算 x1、y1。如果你在这个阶段看到的框比人小一圈或者框的中心落在人脸上而框体漂移说明 txt 标签本身有问题需要回到第 2 章的转换脚本查归一化公式。类别的颜色区分也别省绿色听讲、橙色睡觉、红色玩手机一眼就能看出大量“睡觉”框有没有被画到桌面轮廓上。这个交叉验证和第 5 章的排查配合起来能覆盖 80% 以上的训练事故。确认可视化没问题后再信 mAP 数字。5. 常见问题排查四个翻车现场与对应的处理顺序小数据训练目标检测翻车点其实就那几个。下面四条是我在类似数据集上反复遇到、也最有代表性的每条按现象、原因、解决给出。遇到问题时先对号入座别一上来就改网络结构。5.1 现象loss 卡在某个值不降训练到 40 轮左右box_loss 停在 1.0 上下浮动cls_loss 下降速度明显变慢。原因是多方面的最常见的是标签文件和图片没对齐部分 txt 是空行或者坐标全为 0模型被这些无效框干扰其次是类别不平衡sleeping 和 using_phone 的框数远少于 listening模型倾向于把少数类全判成多数类。处理顺序是先跑第 3.1 节的完整性校验把空 txt 和越界坐标筛出来该删的删然后把数据增强开起来mosaic 和 fliplr 对课堂场景都有明显帮助最后如果还是卡住给少数类做单类过采样把 sleeping 和 using_phone 的样本重复 2 到 3 回合进训练集。5.2 现象val AP 打印出来全部为 0训练正常收敛loss 曲线正常但验证阶段 mAP50 和 mAP50-95 全是 0。这种情况十有八九是 data.yaml 里的 names 顺序和 yolo txt 里的 class_id 对应不上。好比 txt 里 class_id2 原本是 using_phonedata.yaml 里第 3 个 names 却写成了 listening模型每张图的预测都被按错位类别计算 AP。排查时不要肉眼翻几百个文件直接随机抽 3 张验证图的 txt对照 data.yaml 里的 names 逐个核对。修改后重新训练或者至少重新运行一次 val 命令yolo detect val \ data/path/to/data.yaml \ modelruns/detect/student_state/weights/best.ptval 输出的混淆矩阵图会直接显示类别错位的形态比如“sleeping”的真实标签大量落在“listening”预测列上。看到这种对角线偏移别调模型回去改 names。5.3 现象检测框整体向右下偏移模型能检测到人但框整体比目标大一圈且向右下漂移。这类问题几乎全部出在坐标转换环节。常见错误有两种一是把 xmin、ymin 直接填进了中心点 cx、cy 的位置二是转归一化坐标时分子用的是 xmax - xmin 算出的宽度但分母忘除以图片宽。排查方法最直接写 4.3 节那样的画框脚本把训练集标签画回原图对比原标注框。如果脚本里画出的框就是偏移的问题在标签转换脚本如果画出的框是准的但模型预测框偏移那问题在预处理或者增强参数先关掉 fliplr 试一轮。5.4 现象训练刚开始就报图片路径不存在终端里报 JPEG images not found 或 FileNotFoundError检查一下 data.yaml 的 path 是不是相对路径。命令行终端所在目录不在 split 目录时相对路径就会指空。另一个常见原因是路径里夹了中文或空格ultralytics 在部分环境对非 ASCII 路径解析不稳定。解决data.yaml 的 path 改成纯英文绝对路径目录层级里不要有中文跑训练前先执行 pwd 确认当前路径命令里用绝对路径引用 data.yaml。这个看着蠢但实际遇到过不止一次有同事调了两个小时没发现是桌面路径里带了个空格。6. 进阶验证用 Grad-CAM 热力图看模型到底在盯哪里把 val mAP 调到 0.7 以上不算完。有一次我把模型接到教室摄像头视频上发现它把“手托腮发呆”连判成“玩手机”把“趴在桌上写作业”判成“睡觉”。框的位置是对的类别却错得离谱。mAP 只能说明框落得准不准说明不了模型在靠什么特征做分类。从那以后我每次训练完都会加一步注意力热力图检查。做法是对 YOLOv8 的检测头注册 forward hook拿到最后一层特征图后做梯度加权生成热力图叠加到原图上。import cv2 import numpy as np import torch from ultralytics import YOLO model YOLO(runs/detect/student_state/weights/best.pt) net model.model net.eval() feat_map {} def hook_fn(module, inp, out): feat_map[f] out.detach() for name, m in net.named_modules(): if m.__class__.__name__ Detect: m.register_forward_hook(hook_fn) break img cv2.imread(val_imgs/student_sleeping_001.jpg) rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) model.predict(rgb, imgsz640, conf0.25, verboseFalse) if f in feat_map: f feat_map[f][0].mean(dim0) f f.numpy() f cv2.resize(f, (img.shape[1], img.shape[0])) f (f - f.min()) / (f.max() - f.min() 1e-8) heat cv2.applyColorMap((f * 255).astype(uint8), cv2.COLORMAP_JET) overlay cv2.addWeighted(img, 0.5, heat, 0.5, 0) cv2.imwrite(heatmap_check.jpg, overlay)代码里点不多核心是 hook 拿 Detect 模块的输出对通道维做均值池化得到二维响应再放大到原图尺寸叠加。判断标准很简单睡觉样本的热力应该集中在头部和桌面的交叠区域而不是整片桌面玩手机样本的热力集中在手部或屏幕附近听讲样本的热力应该在面部和书本之间。如果热力散布在椅背、墙面这些人眼能识别但姿态无关的背景上说明模型在走捷径。从那次“手托腮翻车”之后我每个课堂模型的交付清单里都强制加上一步热力图抽查和 mAP、混淆矩阵一起看才敢把权重交给项目方。希望这个小习惯能帮你少走一段弯路。本文还有配套的精品资源点击获取
网站建设高端定制企业官网