ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于深度学习Python的课堂专注度行为识别系统搭建指南

基于深度学习Python的课堂专注度行为识别系统搭建指南

简介:这是一套基于深度学习与Python开发的课堂专注度行为识别系统,面向高校人工智能、计算机、电子信息等专业学生及毕业设计开发者,可用于课堂场景中学生抬头、低头、专注状态等行为的自动识别与分析。压缩包共2000个文件,主要涵盖Python源码、Java与Vue构成的界面工程、C/C++与HPP底层模块、模型权重及ONNX/TRT推理相关文件,同时包括XML、JSON、YAML等配置描述和Markdown说明文档,整体约114.36MB,目录结构便于按模块查阅。项目说明与设计文档齐全,经过测试可正常运行,既适合直接作为课程设计、毕业设计或项目演示,也适合在此基础上调整功能、扩展其他课堂行为分析场景。当前已有78人学习下载,对需要快速上手深度学习项目或完成毕设的同学有一定参考价值。

1. 课堂专注度行为识别:一套深度学习Python方案能解决什么问题

很多做教育信息化的人第一次看到“基于深度学习python开发的课堂专注度行为识别系统”这个标题时,以为就是把摄像头画面喂给一个神经网络,输出“专注”或“不专注”。实际做下来你会发现,真正的难点不在分类模型,而在课堂场景本身:学生坐得远、画面里人多、遮挡频繁、光照不均,任何一个因素都会让单一网络直接失效。这套系统的成熟做法是两阶段:先做人体的姿态估计,让每个学生变成一组骨架关键点;再对骨架序列做时序行为分类,判断专注、分心、举手、趴桌等状态;最后按时间窗口聚合成整节课的专注度曲线。这篇文章想说的,就是从拿到这样一个项目包开始,环境怎么搭、数据怎么备、模型怎么训、坑在哪,让新手能跟下来,让做过类似方案的也能对一对边界。适合做教育类产品原型的开发者,也适合需要从零搭建行为识别Demo的工程预研或课设深化。

2. 为什么先提骨架再判行为:架构拆解与选型逻辑

2.1 两阶段方案为什么比端到端视频分类更省事

先看最常见的替代路线:直接用3D卷积或视频Transformer对连续帧做端到端分类。这条路线听起来很省事,录好视频打上标签就能训,但它有三个问题在课堂场景里是致命的。第一,它需要海量的标注视频,一个类别至少几千个样本才能体现优势,而课堂视频数据往往只有几节课、几十节课的量级,远远不够。第二,3D卷积的输入是原始像素,背景、课桌、黑板上的字全都进网络,模型很容易学到“这个教室的窗帘颜色”而不是“这个学生在做什么”,换个教室就失效。第三,多人场景下端到端模型很难输出“哪个学生正在做什么”,只能给出整帧的一个类别,对课堂这种需要逐人统计的场景基本不可用。

两阶段方案(姿态估计+时序分类)把这些问题逐个拆掉了。第一阶段只做关键点回归,输出的是一组人体关节点坐标,比如头顶、肩膀、手肘、手腕。第二阶段输入的已经不是像素,而是骨架序列,模型学的全是动作的几何形态。这样背景信息被彻底丢弃,换教室、换摄像头位置都不影响骨架的语义;每个学生的骨架是独立抽取的,所以第二阶段的分类天然就是逐人的,想统计全班每个学生的状态,只需要在第二阶段对每个人的骨架序列分别过一遍模型。从工程角度看,这个路径还有一个隐性优势:两段可以分开调优。姿态估计不准时,单独换一个更强的模型就行;行为分类不准时,只调时序模型,不用整条链路重训。这对课堂这种长尾场景非常友好。

我自己在实际项目里比较过同样的数据量:端到端方案在训练集上能到92%,换一个教室录的验证集直接掉到70%以下;两阶段方案即便姿态估计用的是轻量模型,跨教室验证集也能稳定在85%左右。这个差距来自特征本身——骨架是抽象过的高层表征,天然屏蔽了场景噪声。做课堂专注度这类任务,我一般都会先选两阶段路线,除非你有上万小时的同一机位数据,才值得回头考虑端到端。

2.2 单帧姿态估计怎么选:轻量库、高精度库与课堂场景的匹配

姿态估计这个阶段,业界可选的开源方案大概分成三档。第一档是以MediaPipe为代表的轻量级方案,靠CPU就能跑,单帧推理在几毫秒到十几毫秒量级,检测的是全身33个关键点,精度在近距离大目标上表现不错,远距离小目标会掉点。第二档是以OpenPose为代表的经典多人姿态估计方案,精度和多人鲁棒性都更好,但模型体积大、计算量高,单帧在GPU上也要几十毫秒起步,部署成本明显上了一个台阶。第三档是HRNet这类以精度为目标的骨干网络,效果最好,但训练和推理成本都最高,适合实验室做基准,不适合直接塞进一个要实时出结果的课堂系统。

课堂场景的选型逻辑其实很清晰:摄像头固定在教室前方,拍摄距离跨度大,后排学生的人体区域可能只有几十像素高;同时系统通常需要边录边分析,推理速度决定了能不能实时生成课堂报告。两相权衡,我一般建议默认选第一档轻量方案跑通全流程,先把数据管道和行为分类做起来,中间如果发现后排小目标关键点频繁丢失,再单独把姿态估计替换成更高精度的方案,或在预处理里加一步目标检测裁剪。这样做的原因是,课堂系统的开发瓶颈往往在数据标注和行为分类,不在姿态估计——你换一个再强的姿态模型,后排几万像素的模糊目标一样难救,反而不如通过预处理和跟踪逻辑来解决。

选型时还要留意一个容易被忽略的点:姿态估计的输出格式。不同方案的输出定义差异很大,比如关键点顺序不同、是否带置信度、是否有左右手索引的区分。这些细节会在第二阶段的特征构造里引入大量对齐工作。别等代码写到一半才发现两个库的关键点索引对不上,回头改特征工程比换模型还要费时间。

2.3 专注度状态怎么定义:从骨架坐标到可学习特征

有了骨架关键点,下一步就是把“专注”这类抽象概念变成可计算的数值。如果你直接拿33个关键点的原始坐标丢给时序模型,模型当然能学,但收敛慢,而且对摄像头位置、画面尺寸极度敏感。我一般会先做一版手工特征,把定义明确的行为信号提出来:头部俯仰角(低头还是抬头)、头部偏转角(左顾右盼)、手部移动速度(频繁小动作)、手腕与桌面的相对距离(举手、趴桌)。这些特征本身就有很强的语义,时序模型只要在它们上面学组合模式,就会少走弯路。

下面是一个从单帧骨架坐标构造特征的典型代码片段,以常见轻量姿态估计库输出的33点格式为例:

import numpy as np def build_single_frame_feature(landmarks, frame_w, frame_h): # landmarks: 按库输出顺序排列的33个关键点,每个点是 x, y, z, visibility def pt(i): return np.array([ landmarks[i].x * frame_w, landmarks[i].y * frame_h ]) nose = pt(0) left_shoulder = pt(11) right_shoulder = pt(12) left_eye = pt(5) right_eye = pt(6) left_wrist = pt(15) right_wrist = pt(16) shoulder_mid = (left_shoulder + right_shoulder) / 2 eye_mid = (left_eye + right_eye) / 2 # 头部俯仰角:眼睛中点相对肩部中点的纵向偏移 pitch = np.arctan2(eye_mid[1] - shoulder_mid[1], eye_mid[0] - shoulder_mid[0] + 1e-6) # 头部偏转角:双眼连线与水平线的夹角,转头时明显变化 yaw = np.arctan2(left_eye[1] - right_eye[1], left_eye[0] - right_eye[0] + 1e-6) # 手部活跃度:双手相对肩部的距离波动,需跨帧计算 hand_dist = (np.linalg.norm(left_wrist - shoulder_mid) + np.linalg.norm(right_wrist - shoulder_mid)) / 2 return np.array([pitch, yaw, hand_dist], dtype=np.float32)

这段代码有几个地方值得说明。pitch和yaw是用角度而非原始坐标,目的就是去掉画面尺寸的影响,让特征在讲台近景和后排远景下量纲一致。hand_dist单帧看只是手的绝对位置,但连续30帧拼接后,它的方差就能反映“手在频繁动还是安静放着”,这个统计量对分心检测特别有效。实际使用时,我还会把连续两帧之间的关键点位移量也拼进特征,作为速度维度,因为很多课堂小动作(转笔、玩手机)在位置特征上不明显,但在速度特征上峰值非常突出。特征维度控制在6到10维就够了,不需要把33个点全塞进去,维度太多反而会让时序模型懒得去学结构化模式。

3. 最小可运行环境与数据准备:把课堂视频喂给模型前要做的事

3.1 创建虚拟环境与依赖安装顺序:先PyTorch还是先装姿态估计库

拿到项目包之后,最忌讳的是直接用全局Python环境跑,课堂项目依赖的深度学习框架、视觉库、数值计算库之间经常存在版本咬合问题。我通常的习惯是新建一个独立虚拟环境,再按固定顺序装依赖,顺序错了后面会花大量时间解决冲突。推荐先建虚拟环境,然后先装深度学习框架,再装计算视觉和姿态估计相关库,最后补装数据处理工具。原因在于姿态估计库的底层依赖了特定版本的numpy和opencv,如果先把视觉库装好,回头再装深度学习框架,很可能把numpy版本直接顶掉,导致视觉库间接损坏。

python -m venv class_env source class_env/bin/activate # Windows 下用 class_env\Scripts\activate pip install --upgrade pip # 先装深度学习框架,CPU 版本优先用来验证流程 pip install torch torchvision # 再装姿态估计与视觉处理库 pip install opencv-python mediapipe # 最后装数据处理与训练辅助库 pip install numpy pandas matplotlib scikit-learn

这里有几个参数层面的注意点。torch默认会装最新的稳定版本,如果显卡驱动较老,建议安装时明确指定CUDA版本对应的安装源,否则会出现“安装成功但import报错”的尴尬情况;如果你手头只有CPU机器,装CPU版就够了,姿态估计跑得动,训练只是慢一点,不影响验证流程。mediapipe装完后建议立刻跑一个import mediapipe as mp,打包好的项目有时会带旧版环境配置,你不主动验证就不知道冲突在哪个库上。第一个Demo跑通之前,别急着装高精度姿态估计库,先把最小链路打通比什么都重要。

3.2 自制课堂数据集:抽帧、标注格式与防泄漏划分

课堂行为识别的通用公开数据集很少,场景和国内教室差异也大,所以多数项目最终都要自己录一段课来标注。录视频的时候注意三点:分辨率至少720p,帧率24或30即可;摄像头尽量放在教室前上方,正对人群;录完按每节课存一个文件,别一录就是几小时的大文件,不好管理。视频拿到后第一步不是标注,而是抽帧。抽帧的目的不是减少工作量,而是降低相邻帧的冗余度,让标注出来的样本更有信息量。

import cv2 import os def extract_frames(video_path, out_dir, sample_fps=5): os.makedirs(out_dir, exist_ok=True) cap = cv2.VideoCapture(video_path) # 读取原始帧率,决定隔几帧抽一帧 original_fps = cap.get(cv2.CAP_PROP_FPS) skip = max(1, int(round(original_fps / sample_fps))) frame_idx = 0 saved_idx = 0 while True: ret, frame = cap.read() if not ret: break if frame_idx % skip == 0: cv2.imwrite(os.path.join(out_dir, f"{saved_idx:05d}.jpg"), frame) saved_idx += 1 frame_idx += 1 cap.release() print(f"saved {saved_idx} frames from {video_path}") # 用法示例 extract_frames("lecture_01.mp4", "./data/frames/lecture_01", sample_fps=5)

抽帧率sample_fps=5是我验证过比较稳的起点:5帧每秒能覆盖一个抬手、转头的完整过程,又不会让相邻帧几乎一样。如果你想保留更多运动细节,可以调到8到10帧每秒,但标注量会跟着涨。标注格式我推荐最简单的CSV或者JSON,每一行是一个人、一个帧号、一个动作标签,标签建议用数字编码,训练时少一层转换。标注工具用常见的开源标注工具导出即可,格式再自己转成下面的样子:person_id, frame_id, state(0=专注,1=分心,2=举手,3=趴桌), landmark_file。注意每个学生的关键点结果要单独存文件,或者直接在标注时统一跑一遍姿态估计预处理,存成骨架序列文件,后面的训练阶段就不用再回头读原始视频了。

数据划分是课堂项目里最容易翻车的环节。很多项目随手按文件列表随机划分训练集和验证集,结果同一节课的帧同时出现在两边,模型相当于提前看到了答案。正确的做法是:按“课”维度划分,训练集用前几节课,验证集用完全没参与训练的另一节课,这样才能测出模型换课堂的真实表现。划分代码很简单,但这一条不遵守,后面所有指标都是虚高的。

3.3 课堂场景数据增强:亮度、遮挡、模拟远景三种扰动

课堂数据的多样性主要来自三个方向:不同时段的自然光照变化、学生举手或转头时产生的短暂自遮挡、以及座位远近带来的尺度差异。数据增强就应该针对这三类扰动来做,而不是照搬常规图像分类的那套随机裁剪和翻转。随机裁剪在课堂场景要慎用,因为裁剪掉头部区域会让姿态估计直接失败,产生的反而是坏样本。

我一般在数据管线里做三类增强。亮度扰动:用随机gamma变换模拟上午、下午不同光照,范围取[0.7, 1.3],太大画面就失真了。关键点噪声:给标注的骨架坐标加小幅度高斯噪声,模拟姿态估计在模糊帧上的抖动,幅度控制在关键点归一化坐标的±0.02以内。局部遮挡模拟:随机把画面中某个同学的区域盖住几帧,逼模型学会在短暂遮挡下也能维持状态判断,这个对课堂场景特别实用,因为一个学生低头系鞋带的瞬间,他旁边同学很容易被挡住。

import numpy as np def augment_landmarks(landmarks, noise_scale=0.02, dropout_prob=0.1): # 输入: 单帧关键点坐标序列 shape=(33, 2) # 输出: 增强后的坐标,模拟姿态估计抖动与局部关键点丢失 aug = landmarks.copy() # 高斯噪声:模拟轻量姿态估计在运动模糊下的抖动 aug += np.random.normal(0, noise_scale, size=aug.shape) # 随机丢弃部分关键点,模拟自遮挡 dropout_mask = np.random.rand(33) < dropout_prob aug[dropout_mask] = 0.0 return aug

这段增强逻辑里的两个参数值得细调。noise_scale如果设得太大,骨架会变得扭曲,时序模型学到的是“抖动=分心”这种错误关联;设得太小又起不到正则化作用。我一般先在验证集上试几个值,观察loss曲线,选择让验证loss最平稳的那个。dropout_prob也别超过0.2,关键点大面积丢失后特征本身就不可信,靠增强硬造这种样本只会增加训练难度。数据增强不是越多越好,对课堂这个场景,保住头部和手部关键点是底线。

4. 训练专注度分类模型:序列拼法、损失函数与超参设置

4.1 滑窗构造序列样本:序列长度30帧的依据

行为识别和图像分类最大的不同在于,模型需要看到一段变化过程才能做出判断。你给她一张静止帧,她低头看手机和低头看书在视觉上几乎无法区分,但连续看3秒,手的动作模式完全不同。所以训练样本不能是单帧,而是一段固定长度的骨架序列。

我一般用滑窗把长序列切成固定长度的短序列,窗口长度选30帧,配合前面5帧每秒的抽帧率,刚好覆盖6秒的上下文。这个长度是经过对比得出的:短于15帧时,模型分不清“临时低头”和“持续趴桌”;长于60帧时,样本数量锐减,而且一个窗口里可能包含两三个动作切换,反而把标签搞混。30帧是一个折中点,既能捕捉完整动作,又能保证一个窗口里大概率只有一个主要状态。

def build_sequences(person_feats, seq_len=30, stride=10): # person_feats: 某个学生按时间排列的特征序列,shape=(总帧数, 特征维度) # 返回: 滑窗后的样本序列与对齐标签 sequences, labels = [], [] for person in person_feats: feats = person["features"] label_seq = person["labels"] n = len(feats) if n < seq_len: continue for start in range(0, n - seq_len + 1, stride): sequences.append(feats[start:start + seq_len]) # 标签取窗口内出现次数最多的类别 window_labels = label_seq[start:start + seq_len] labels.append(max(set(window_labels), key=window_labels.count)) return np.array(sequences), np.array(labels)

这里有两个参数需要理解透。stride=10决定了相邻窗口之间的重叠度,重叠越大,训练样本越多,但样本之间相关性也越高,容易造成过拟合;课堂数据本来就少,我一般保留较大的重叠来扩充样本量。窗口标签用投票而非取首帧或尾帧,是因为一个窗口里动作可能有小幅波动,多数投票能让标签更贴近窗口的主体状态。如果你发现模型对某个类别特别迟钝,可以先检查一下是不是窗口标签的投票策略把少数派样本全归到了多数类。

4.2 样本不均衡怎么办:加权损失的表现与边界

课堂数据的天然后果是类别严重不均衡:一整节课里,“专注”可能占70%,“举手”只占百分之几。如果直接拿原始分布去训,模型的最优策略是“永远预测专注”,然后整体准确率还很好看。这也是很多项目报告里准确率90%以上、现场一测就露馅的根源。

我建议优先尝试加权交叉熵,给少数类更高的损失权重。权重可以按训练集类别频率的倒数做归一化,再乘一个缩放系数。

import torch import torch.nn as nn # 按训练集统计出的类别占比: 专注0.68, 分心0.17, 举手0.06, 趴桌0.09 class_freq = torch.tensor([0.68, 0.17, 0.06, 0.09]) class_weights = 1.0 / class_freq class_weights = class_weights / class_weights.mean() criterion = nn.CrossEntropyLoss(weight=class_weights)

这里面最需要调的就是class_weights的缩放。权重比拉得太大,少数类在训练集上被强行记住,换到新课堂反而误报激增;太接近1.0又起不到纠偏作用。我的经验是权重均值控制在1.5到3.0之间,先跑10个epoch看验证集上少数类的召回率是否在逐步上升,如果没有,再整体放大权重,而不是单独放大某一个类。加权损失解决不了的问题,下一步再用Focal Loss或对少数类做时间轴上的过采样,但那是后话,别一开始就上复杂方案。

4.3 训练曲线怎么看:过拟合信号与早停参数

时序模型比图像模型更容易过拟合,因为样本量小、特征维度低,模型记忆训练序列的速度非常快。判断过拟合不要只看验证准确率,要看“训练损失持续下降、验证损失在某一步开始反弹”的剪刀差。课堂项目里训练损失降到很低而验证损失回升,通常发生在第20到第30个epoch之间,早停就是抓这个转折点。

下面是一组我常用的训练配置,适合小数据集和轻量时序模型:

参数推荐值说明
序列长度30帧约6秒上下文
batch size64样本量小时别太大,影响收敛稳定性
学习率1e-3配合Adam优化器
weight decay5e-4防过拟合的关键项
early stopping patience10个epoch验证损失连续10轮不降就停
学习率衰减每10轮乘0.5降到最后收敛会更稳

训练过程中我会额外关注每一类的召回率,而不是全局准确率。课堂系统的可用性由少数类决定:举手没检测到,老师复盘时就发现整节课没人互动;趴桌没检测到,课堂管理就失去了意义。建议每两个epoch打印一次混淆矩阵,重点看“分心”被预测成“专注”的比例,那才是真实的漏报率。如果验证集上少数类召回率迟迟不动,先别急着堆模型复杂度,回到数据层面确认一下是不是标注本身就有歧义——比如“发呆看窗外”有人标分心、有人标专注,模型学到的边界自然也是乱的。

5. 课堂场景五大翻车点:从现象到原因的排查清单

5.1 现象:后排小目标的关键点频繁丢失,骨架断成几截

教室后排学生的人体区域在画面中往往只有几十像素高,轻量姿态估计库在这种目标上的检测置信度断崖式下降,表现就是后排学生的骨架时有时无,行为分类完全失去依据。原因有两个层面:一是目标像素太少,关键点回归的输入信息不足;二是课堂背景复杂,后排常贴着窗帘、黑板、标语,干扰了检测器的注意力。

解决这个问题的常见做法,是增加一个目标检测前置步骤。先做一次行人检测把每个学生框出来,再把框裁剪放大后送进姿态估计,相当于给后排学生单独开了一个“特写镜头”。代价是每帧多一次检测的算力开销,但对课堂这种固定机位场景非常值得。另一个工程技巧是提高姿态估计的输入分辨率,很多轻量方案默认输入是256或320,课堂场景可以调到640,后排关键点完整率能有明显提升,代价是推理耗时会增加一到两倍。预算有限的话,先做检测裁剪,再调输入分辨率,两个手段叠加效果最好。

5.2 现象:两个学生座位相邻,骨架突然串到另一个人身上

遮挡是课堂里绕不开的问题。一个学生伸手拿东西,胳膊正好挡住旁边同学的肩膀,姿态估计的输出就会在两个ID之间跳跃,要么同一个人的骨架突然多了一只手,要么两个人的轨迹直接互换。这类问题单靠调姿态估计参数解决不了,因为遮挡造成的视觉歧义对任何单帧算法都是难题。

解决思路要从时序上找。我的做法是在姿态估计后面加一个人体跟踪ID的平滑模块,核心是“上一帧的每个人,在当前帧先找最接近的框,距离在阈值内就延续ID,超阈值才认为是新的人”。这个逻辑不复杂,但要写对,否则会出现ID频繁切换这个更麻烦的新问题。还有一个补救手段:行为分类只输出“当前状态”,不做跨帧的身份绑定,最终课堂统计时用跟踪ID把骨架串成轨迹,轨迹中间丢掉的部分用前后状态插值补齐。这样即使单帧偶尔串人,时序统计层面也不会放大错误。

5.3 现象:训练集里只有正面视角,换到教室侧面的摄像头指标暴跌

课堂摄像头的位置不是统一的,有的在正前方,有的在侧面墙角。姿态估计对视角有一定鲁棒性,但行为分类模型完全是数据驱动的——训练集里全是正面视角的“转头”,模型学到的特征偏向于左右偏转;一旦换成侧面视角,同一个动作在骨架坐标上的模式完全变了,模型直接失效。这是典型的训练分布和推理分布不一致。

解决要从数据准备阶段下手。录数据时尽量多角度采集:正面、左侧、右侧,至少三个视角。如果已经训好了模型才发现视角问题,退而求其次的办法是在训练集里针对关键点坐标做归一化处理,以肩部中点为原点、两肩距离为单位长度,把骨架坐标变换到人体自身坐标系。这样不同视角下的同一动作会在归一化空间里重叠度更高,模型泛化能力明显增强。但要注意,这个归一化必须用在训练集和推理时保持一致,不能只在一边做。

5.4 现象:趴桌被识别成低头专注,手撑脸被识别成举手

这类误判最容易发生在动作边界的过渡状态。趴桌的初始阶段和低头的确很像,都是头部大幅下沉、手部前伸;手撑脸和举手也共享同一条“手部抬离桌面”的轨迹。问题不在于模型笨,而在于标签定义本身有重叠区。很多标注规范里只给了四个类别,却没有定义“过渡帧算哪个状态”。

解决这个问题的思路,一是调整标注粒度和标签定义,给状态加细分,比如把“低头”和“趴桌”在标注时用头肩距离做区分定义,阈值写进标注规范;二是给模型加后处理规则,预测结果先过一帧级平滑,再用动作持续时间过滤,比如“手撑脸”通常持续很久,而“举手”一般几秒内就放下,按持续时间长短做二次分类。这个后处理规则不增加训练成本,还能挡住一大半边界误判。

5.5 现象:离线测试没问题,接入实时视频流后帧率上不去

离线跑视频时模型处理速度50帧每秒,看起来够用;接上实时摄像头后,实际体验卡顿严重。问题通常不出在模型,而出在数据管线上。摄像头采集、姿态估计、跟踪、行为分类、结果绘制如果全在同一个进程里串行执行,任何一个环节抖动都会拖累整条链路;姿态估计偶尔一次耗时翻倍,后续所有帧都被阻塞。离线测试用的是本地视频文件,磁盘读取是稳定的,掩盖了这个问题。

解决方法是把姿态估计放到独立线程,单帧画面到达后,如果上一帧还没处理完,就丢弃当前帧而不是阻塞采集线程。课堂场景的行为识别不需要逐帧分析,抽样分析完全够用,牺牲一点帧率换来整体流畅度非常划算。另一个常见瓶颈是结果可视化,往画面上叠加骨骼、状态标签、统计数字都要做图像绘制,这个操作比想象中耗时,建议画布分辨率单独调低一档,只在主画面上画人框,状态面板每隔半秒刷新一次,不要每帧重绘全部内容。

6. 从单帧预测到整节课的可视化:专注度曲线与状态时间线

6.1 用滑窗聚合加指数平滑生成专注度曲线

模型输出的是每个学生每一帧的状态,但课堂上老师和管理者真正需要的是“这节课整体专注度怎么样”“第10分钟出现了什么波动”。这需要把状态预测按时间窗口聚合成一条曲线。我的做法是每10秒为一个统计单元,计算这个窗口内专注类状态占比作为该时刻的专注度,再用指数平滑把相邻窗口的突变拉平,过滤掉个别误判产生的毛刺。

import numpy as np def smooth_curve(raw_curve, alpha=0.3): # raw_curve: 按时间窗口统计的原始专注度序列 # alpha: 平滑系数,越大曲线越贴近原始值,越小越平滑 smoothed = np.zeros_like(raw_curve, dtype=float) smoothed[0] = raw_curve[0] for i in range(1, len(raw_curve)): smoothed[i] = alpha * raw_curve[i] + (1 - alpha) * smoothed[i - 1] return smoothed

alpha这个参数的语义要理解透:它决定曲线对突发变化的响应速度。设得太小,某一段真实的分心潮会被抹平;设得太大,个别误判又会形成假的波谷。我一般设0.2到0.4之间,具体看你的时间窗口长度。曲线生成后,再配上状态时间线(每个学生的状态变化甘特图),课堂复盘时就非常直观:老师能看到“第25分钟全班专注度骤降”,对应的原始记录里正好有一批学生出现小动作,这个联动对课堂质量分析是最有价值的输出。

6.2 在视频帧上叠加实时状态面板

最终交付给使用方的,往往不是一条离线曲线,而是实时画面加状态标注。给每帧画面叠加学生框和状态标签,能让在场的人第一时间知道系统正在做什么、判断准不准。这个界面我习惯保持极简,避免干扰原始画面,也避免因为绘制开销拖慢推理。用一个固定位置的状态面板展示当前全班统计,比在每个人头上都画标签更清爽。

import cv2 def draw_panel(frame, stats, students_info): # stats: {"focus": 32.5, "distract": 5.2, "handup": 2} # students_info: 每个学生的框和状态标签列表 h, _ = frame.shape[:2] panel_w = int(frame.shape[1] * 0.22) overlay = frame.copy() cv2.rectangle(overlay, (0, h - 80), (panel_w, h), (0, 0, 0), -1) frame = cv2.addWeighted(overlay, 0.35, frame, 0.65, 0) cv2.putText(frame, f"Focus: {stats['focus']:.1f}%", (10, h - 50), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.putText(frame, f"Distract: {stats['distract']:.1f}%", (10, h - 25), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) for info in students_info: x1, y1, x2, y2, state = info color = (0, 200, 0) if state == "focus" else (0, 0, 255) cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) return frame

这个叠加逻辑里,绘制操作放在推理线程之外,面板只在画面底部固定区域更新,避免大量绘制阻塞主循环。alpha=0.35的叠加权重是调试出来的,太透明面板看不清,太实又挡住学生动作。注意stats里的百分比要在独立线程里按固定周期刷新,不要每帧重算全班状态,那会白白浪费算力。整套系统跑通之后,你会意识到课堂专注度识别的价值不在于“测出谁在走神”,而在于把模糊的课堂观察变成了可回放、可对比、可复盘的数据序列——这才是投入这个方向最值得的部分。

最后说一个自己踩过的教训:有一版实验,验证集划分时没按“课”隔离,结果同一节课的一部分帧进了训练集,另一部分留在验证集,指标漂亮得惊人,换了一间教室立刻打回原形。从那以后,凡是涉及课堂数据的项目,第一件事就是锁死数据划分规则,先划好验证集再碰任何模型。数据划分这种枯燥的事,反而是整个系统里最不能省的一环。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进