基于 OpenCV 的校园课堂行为识别与智能考勤分析系统实战
项目目标与运行结果
课堂考勤如果只记录“是否签到”,很难反映课堂现场的真实状态。实际教学管理更关心的是:学生是否在座、课堂互动是否活跃、是否出现低头或趴桌等注意力下降行为,以及这些信息能否沉淀为可复盘的表格和报告。
本项目实现了一个固定机位课堂图片下的行为识别与智能考勤分析系统。基础版不依赖训练权重,也不要求 GPU,使用 OpenCV、NumPy、Pandas、Matplotlib 和 Pillow 完成图像分析、行为判断、统计汇总和报告生成。运行python main.py后,系统会读取内置真实课堂图片和座位 ROI 配置,生成标注图、行为分布图、考勤趋势图、CSV 表格、HTML 报告和系统看板。
项目默认样例是一张真实课堂举手互动图片,来源说明保存在docs/sources/real_classroom_sources.md。这张图片对应 5 个可见学生区域,当前运行结果为:5 人到位,3 人举手互动,1 人侧身讨论,1 人认真听讲。
识别结果会画回原图。每个座位框、头部候选框、行为标签和置信度都来自程序生成的座位级结果表,而不是静态效果图。
工程结构与数据流
项目目录按输入数据、核心识别、报告输出和说明材料拆分:
classroom_behavior_attendance_system/ ├── main.py ├── configs/config.json ├── demo_data/real_classroom/ │ ├── real_classroom_hands_raised.jpg │ └── real_classroom_seat_map.json ├── src/ │ ├── classroom_analyzer.py │ ├── report_generator.py │ ├── demo_generator.py │ └── vision_utils.py ├── images/ │ ├── figures/ │ ├── results/ │ └── sources/ ├── outputs/ ├── tests/ └── docs/main.py负责串联完整流程;src/classroom_analyzer.py负责 ROI 裁剪、肤色分割、连通域分析和行为判定;src/report_generator.py负责统计图、系统看板和 HTML 报告;src/vision_utils.py处理中文字体、目录创建和 OpenCV/PIL 图像转换。
系统数据流如下:
课堂图片 + 座位表 -> 按 ROI 裁剪每个学生区域 -> 提取肤色候选像素 -> 形态学去噪与连通域分析 -> 选择头部候选区域 -> 根据头部、手部和座位中心关系判断行为 -> 生成座位级 DataFrame -> 导出 CSV、标注图、统计图、HTML 报告和看板项目有一个重要设计:所有后续输出都从同一张 DataFrame 派生。标注图用它画框,行为分布图用它统计类别,CSV 直接保存它,HTML 报告把它转成表格。这样可以避免图表、表格和看板各算一套逻辑导致数字不一致。
算法方案:固定机位 ROI + 肤色连通域 + 行为规则
当前版本采用“固定机位座位区域校准算法”。在固定摄像头场景下,每个学生在画面中的活动范围相对稳定,可以先用 ROI 将全图切成多个座位区域,再在每个 ROI 内独立判断是否到位和处于哪种课堂行为。
算法输入包括两部分:
输入 1:课堂图像 I,格式为 BGR/RGB 图像 输入 2:座位表 S = {s1, s2, ..., sn}每个座位si至少包含以下信息:
roi_i = [x1, y1, x2, y2] center_x_i head_down_threshold_y_i side_turn_threshold_x_i head_hint_i(可选)算法输出是一张座位级结果表:
R = {r1, r2, ..., rn}每条结果ri包含座位编号、学生编号、是否到位、行为类别、中文行为名、头部框、头部中心、肤色连通域数量和展示置信度。后面的标注图、CSV、统计图、HTML 报告都只依赖这张结果表。
整体算法拆成 6 个阶段:
| 阶段 | 目标 | 关键数据 |
|---|---|---|
| ROI 裁剪 | 把整张课堂图拆成多个座位区域 | roi_i |
| 肤色分割 | 找出 ROI 内可能属于脸、手、手臂的像素 | skin_mask |
| 形态学处理 | 去噪并连接局部断裂区域 | 开运算、闭运算 |
| 连通域分析 | 提取候选脸部或手部区域 | bbox、area、center |
| 头部选择 | 从多个候选块中确定头部候选 | head_hint、面积排序 |
| 行为判定 | 根据头部位置、手部位置和座位偏移输出行为 | present、behavior |
伪代码如下:
Algorithm: ClassroomBehaviorAnalysis Input: I: classroom image S: seat map list Output: R: seat-level behavior table for each seat s in S: roi = crop(I, s.roi) if roi is empty: append absent record continue mask = skin_threshold(roi) mask = morphology_open(mask) mask = morphology_close(mask) components = find_contours(mask) components = filter_by_area(components) if components is empty: append absent record continue head = select_head_component(components, s.head_hint) hand_candidates = components - head if exists raised_hand(hand_candidates, head): behavior = hand_raise else if head.center_y > s.head_down_threshold_y: behavior = head_down else if abs(head.center_x - s.center_x) > s.side_turn_threshold_x: behavior = turned else: behavior = attentive append present record with behavior, head box and confidence return R这套方法的重点不只是“检测肤色”。它会结合多个几何关系:头部候选在哪里、其他肤色块是否高于头部、头部是否低于阈值、头部是否偏离座位中心。对于课程实践和软著材料整理来说,这类规则方案的优势是可解释、可运行、可调参,也便于后续替换成姿态估计模型。
座位表设计与 ROI 标定
固定机位课堂分析的第一步是定义“画面中的哪些区域对应哪些学生”。项目使用 JSON 座位表描述每个座位的 ROI、学生信息和行为判断阈值。默认文件为:
demo_data/real_classroom/real_classroom_seat_map.json真实样例中的一个座位配置如下:
{"seat_id":"S01","row":1,"col":1,"student_id":"2026001","student_name":"学生01","center_x":95,"desk_y":1015,"roi":[0,590,255,1060],"head_down_threshold_y":850,"side_turn_threshold_x":70,"head_hint":[72,760]}核心字段如下:
| 字段 | 作用 | 开发时的使用方式 |
|---|---|---|
roi | 座位检测区域,格式为[x1, y1, x2, y2] | 程序只分析该矩形区域,减少黑板、桌面、墙面等背景干扰 |
center_x | 座位中心横坐标 | 用头部中心与座位中心的横向偏移判断侧身讨论 |
head_down_threshold_y | 低头阈值 | 头部中心纵坐标超过该值时,认为头部位置偏低 |
side_turn_threshold_x | 侧身偏移阈值 | 控制侧身讨论判定的灵敏度 |
head_hint | 头部参考点 | 在真实图片中帮助程序从多个肤色连通域里选出头部 |
制作自己的座位表时,建议按这个顺序进行:
- 固定输入图片分辨率,避免后续坐标全部失效。
- 用图片查看工具或标注工具读取每个学生区域的左上角、右下角坐标,写入
roi。 - 根据人体中心附近位置设置
center_x,不要简单取 ROI 中点。 - 观察正常抬头时的头部位置,设置略低于正常头部中心的
head_down_threshold_y。 - 如果画面中有举手、遮挡或反光区域,补充
head_hint,避免把手掌或手臂选成头部。
ROI 标定完成后,系统就从“分析整张图片”变成“逐个分析座位区域”。这也是规则版能够稳定复现结果的关键。
核心实现:从 ROI 到行为标签
识别入口在ClassroomBehaviorAnalyzer.analyze_image()。程序读取图片后遍历座位表,每个座位调用_classify_seat():
forseatinself.seat_map:records.append(self._classify_seat(image_bgr,seat))ROI 裁剪与边界保护
_classify_seat()先读取 ROI 坐标,并把坐标限制在图像范围内:
x1,y1,x2,y2=[int(v)forvinseat["roi"]]h,w=image_bgr.shape[:2]x1,y1=max(0,x1),max(0,y1)x2,y2=min(w-1,x2),min(h-1,y2)ifx2<=x1ory2<=y1:returnself._empty_record(seat)roi=image_bgr[y1:y2,x1:x2]这段边界保护很有必要。自定义图片时,ROI 坐标很容易因为分辨率变化或标注错误而越界;程序遇到无效 ROI 时返回缺勤记录,而不是直接报错退出。对应逻辑在tests/test_real_pipeline.py中也有测试覆盖。
肤色阈值分割
当前项目在 ROI 内使用 BGR 通道阈值提取候选肤色区域:
b,g,r=cv2.split(roi_bgr)mask=((r>185)&(g>120)&(g<220)&(b>70)&(b<180)&(r>g)&(g>b*0.85)).astype(np.uint8)*255换成算法表达,就是对 ROI 内每个像素p(x, y)读取 B、G、R 三个通道。只要满足这些条件,就将该像素记为候选肤色像素:
R > 185 120 < G < 220 70 < B < 180 R > G G > 0.85 * B二值 mask 的定义为:
mask(x, y) = 255, if p(x, y) satisfies skin rule 0, otherwise该阈值用于当前固定机位样例的轻量检测,不等价于通用人脸检测。它的作用是把可能属于脸、手、手臂的像素从背景中分离出来,作为后续连通域分析的候选前景。
形态学处理与连通域分析
直接使用阈值 mask 容易得到零散噪声,因此项目在进入轮廓提取前加入开运算和闭运算:
kernel=np.ones((5,5),np.uint8)mask=cv2.morphologyEx(mask,cv2.MORPH_OPEN,kernel,iterations=1)mask=cv2.morphologyEx(mask,cv2.MORPH_CLOSE,kernel,iterations=2)开运算可以理解为“先腐蚀再膨胀”,主要去掉小白点;闭运算可以理解为“先膨胀再腐蚀”,主要连接局部断裂。课堂图片里,手指、脸部边缘和手臂区域可能受光照影响被切断,闭运算可以让这些区域形成更稳定的候选块。
之后使用 OpenCV 轮廓函数提取候选区域:
contours,_=cv2.findContours(mask,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)每个候选区域记录四类信息:
bbox:全图坐标下的外接框 area:轮廓面积 center:轮廓中心点 local_bbox:ROI 内部坐标bbox来自cv2.boundingRect(),格式为:
bbox = [x_min, y_min, x_max, y_max]候选区域中心点使用轮廓矩计算:
center_x = m10 / m00 center_y = m01 / m00如果轮廓矩m00为 0,就退回外接框中心点。程序还会把 ROI 内坐标加上偏移量(roi_x1, roi_y1),转换成全图坐标,后续绘制标注图时可以直接使用。
头部候选选择
如果只处理普通头像图,最大肤色连通域通常可以当作头部。但真实课堂图片中,举手时的手掌或手臂可能比脸部更大,最大连通域策略容易把手选成头部。项目因此在座位表中加入可选字段head_hint。
头部选择逻辑为:
- 没有候选区域时,返回缺勤。
- 如果座位配置了
head_hint,优先选择距离head_hint最近、且在半径范围内的连通域。 - 如果没有
head_hint或没有命中,再退回最大连通域。
距离计算公式为:
dist(c, h) = sqrt((center_x - hint_x)^2 + (center_y - hint_y)^2)候选块只有在dist <= head_hint_radius时才进入头部候选集合。候选集合中优先选择距离更近的区域;如果距离相近,再倾向选择面积更大的区域。这个设计保留了规则算法的轻量性,也通过一次性人工标定提升了真实课堂样例下的稳定性。
行为规则判定
项目支持 5 类状态:
attentive:认真听讲 hand_raise:举手互动 head_down:低头/趴桌 turned:侧身讨论 absent:缺勤第一层先判断缺勤:ROI 内没有有效肤色连通域,就认为该座位没有检测到学生。
ifnotcomponents:returnself._empty_record(seat)第二层判断举手互动。程序把头部以外的肤色连通域当作候选手部区域,如果该区域明显位于头部侧上方,或向上延伸足够明显,就判定为举手互动:
if(component["area"]>120andside_offset>25and(by1<head_cy-25or(comp_height>70andcy<head_cy+15))):has_raised_hand=True展开后可以理解为:
area(hand) > 120 abs(hand_center_x - head_center_x) > 25 hand_top_y < head_center_y - 25对于手臂和手掌形成竖向长条的情况,项目还加入补充条件:
component_height > 70 component_center_y < head_center_y + 15第三层判断低头。系统不做人脸姿态估计,而是比较头部中心纵坐标和座位表中的低头阈值:
elifhead_cy>float(seat["head_down_threshold_y"]):behavior="head_down"第四层判断侧身讨论。做法是比较头部中心和座位中心的横向偏移:
elifabs(head_cx-float(seat["center_x"]))>float(seat["side_turn_threshold_x"]):behavior="turned"对应公式为:
abs(head_center_x - seat_center_x) > side_turn_threshold_x如果以上条件都不触发,只要检测到学生,就判为认真听讲:
behavior="attentive"行为优先级为:
无有效肤色区域 -> 缺勤 有高位手部区域 -> 举手互动 头部中心低于阈值 -> 低头/趴桌 头部横向偏移过大 -> 侧身讨论 其他到位情况 -> 认真听讲这个顺序会影响结果。举手时头部可能同时发生偏移,如果先判断侧身,举手容易被归为侧身讨论;低头和侧身也可能同时出现,当前版本按展示需求优先输出更直观的课堂行为类别。
结果表、统计指标与报告生成
识别完成后,每个座位生成一行记录,最终拼成 DataFrame。当前真实样例 CSV 字段如下:
image_name seat_id row col student_id student_name present behavior behavior_cn confidence head_bbox head_center_x head_center_y skin_component_count真实运行输出摘要如下:
S01 举手互动 0.96 head_bbox=41,796,112,877 skin_component_count=7 S02 侧身讨论 0.94 head_bbox=215,675,290,901 skin_component_count=1 S03 举手互动 0.96 head_bbox=567,803,582,815 skin_component_count=6 S04 举手互动 0.96 head_bbox=609,681,662,859 skin_component_count=6 S05 认真听讲 0.665 head_bbox=727,730,765,767 skin_component_count=1confidence不是深度学习模型的 softmax 概率,而是规则版根据肤色面积和行为类别估算出的展示置信度。代码中的基础分数为:
base = min(0.94, 0.52 + total_skin_area / 6500)举手互动会在基础分上增加 0.05,低头/趴桌会增加 0.03,缺勤固定为 0.91。这个字段适合用于报告展示和结果排序,不应解释成严格的模型概率。
汇总统计由summarize()完成:
present=int(df["present"].sum())attentive=int((df["behavior"]=="attentive").sum())hand_raise=int((df["behavior"]=="hand_raise").sum())head_down=int((df["behavior"]=="head_down").sum())turned=int((df["behavior"]=="turned").sum())attendance_rate=present/totaliftotalelse0attention_rate=attentive/presentifpresentelse0当前样例结果为:
座位数:5 实到人数:5 缺勤人数:0 考勤率:100.0% 专注率:20.0% 认真听讲:1 举手互动:3 低头/趴桌:0 侧身讨论:1行为分布图由behavior_distribution_chart()生成,固定按 5 类行为统计人数:
order=["attentive","hand_raise","head_down","turned","absent"]values=[int((single["behavior"]==item).sum())foriteminorder]考勤与专注趋势图由attendance_trend_chart()生成。默认真实样例只有一张课堂图片,因此趋势图只有一个采样点;后续接入视频帧或多张课堂图片后,同一函数可以扩展为多时间点曲线。
系统看板由dashboard_image()合成,将顶部指标卡、标注图、行为分布图和趋势图整合到一张图片中,适合作为项目报告或软著说明书中的运行效果图。
HTML 报告由html_report()生成,路径为:
outputs/report.html报告中包含系统指标、运行截图、标注图、统计图和座位级表格。自动化测试会检查报告内容不包含本机绝对路径,避免公开发布时泄漏本地目录信息。
运行方式与自定义图片
安装依赖后,在项目根目录执行:
pipinstall-rrequirements.txt python main.py默认输入为:
demo_data/real_classroom/real_classroom_hands_raised.jpg demo_data/real_classroom/real_classroom_seat_map.json主要输出文件包括:
images/results/classroom_01_annotated.png images/results/behavior_distribution.png images/results/attendance_attention_trend.png images/results/system_dashboard.png outputs/classroom_analysis_results.csv outputs/classroom_batch_results.csv outputs/report.html如果要分析自己的课堂图片,不建议只替换图片后直接运行。固定机位方案依赖座位表,正确做法是重新标定 ROI:
- 将课堂图片放入项目目录,例如
demo_data/my_classroom/classroom_a.jpg。 - 按图片实际分辨率重新制作座位 ROI,不要沿用默认样例坐标。
- 给每个座位填写
seat_id、student_id、student_name、roi、center_x、head_down_threshold_y、side_turn_threshold_x。 - 如果图片中存在举手、遮挡或反光,建议补充
head_hint。 - 运行时显式指定图片和座位表。
命令示例:
python main.py--inputdemo_data/my_classroom/classroom_a.jpg --seat-map demo_data/my_classroom/seat_map.json调参时建议先配置 2 到 3 个座位,确认标注框和行为类别正确后再补齐全班座位。一次性配置几十个 ROI 后再排查,容易分不清问题来自坐标、阈值还是图片本身。
常见问题可以按这个顺序排查:
| 现象 | 优先检查 |
|---|---|
| 框没有画在学生身上 | roi坐标是否匹配当前图片分辨率 |
| 举手被识别成认真听讲 | 手部连通域是否在 ROI 内,head_hint是否选错头部 |
| 侧身误判较多 | center_x是否取到人体中心,side_turn_threshold_x是否过小 |
| 低头误判较多 | head_down_threshold_y是否高于正常抬头位置 |
| 中文显示异常 | 系统是否安装中文字体,或vision_utils.py是否找到可用字体 |
测试与可复现性
项目提供了最小自动化测试文件:
tests/test_real_pipeline.py测试覆盖两类关键场景。第一类验证真实课堂主流程能生成看板、标注图和 HTML 报告:
result=run_pipeline(args)self.assertTrue(Path(result["dashboard_path"]).exists())self.assertTrue(Path(result["annotated_path"]).exists())self.assertGreater(result["summary"]["present_count"],0)第二类验证 ROI 越界时不会抛异常,而是返回缺勤:
tiny_image=np.zeros((80,80,3),dtype=np.uint8)record=analyzer._classify_seat(tiny_image,analyzer.seat_map[0])self.assertEqual(record["behavior"],"absent")self.assertEqual(record["present"],0)验证命令如下:
python main.py python-munittest tests.test_real_pipeline文章中展示的结果来自默认真实课堂图片和默认座位表。只要图片、座位表和依赖环境保持一致,读者可以复现同样的 CSV、标注图、统计图和报告。
扩展方向与边界
当前版本适合固定机位演示、课程设计、软著材料整理和小范围二次开发,不应直接当作通用真实课堂监控系统。复杂教室环境会受到遮挡、背影、光照变化、肤色差异、摄像机角度变化等因素影响。
后续升级时,建议保持输出接口不变,只替换识别模块:
- 在
classroom_analyzer.py中新增模型版分析器,例如PoseClassroomAnalyzer。 - 使用 YOLO-Pose 或 MediaPipe Pose 输出人体关键点。
- 用关键点判断举手、低头和侧身,替换当前肤色连通域规则。
- 继续输出同样字段的 DataFrame,例如
seat_id、present、behavior、confidence。 - 保持
report_generator.py不变,继续生成 CSV、图表、看板和 HTML 报告。
这样做可以把 V1.0 的工程闭环保留下来。后续升级重点放在识别算法,而不是推倒重写报告、图表和导出逻辑。
参考资料
- 真实课堂图片 Hands raised,Wikimedia Commons:https://commons.wikimedia.org/wiki/File:Hands_raised_(7645867).jpg
- OpenCV 轮廓处理文档:https://docs.opencv.org/3.4/de/d09/tutorial_table_of_contents_contours.html
- opencv-python 项目页面:https://pypi.org/project/opencv-python/
- MediaPipe Pose Landmarker Python 文档:https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker/python
