简介:面向智慧教室中的考试防作弊这一应用场景,这套基于人脸表情识别技术的工程方案,将身份验证、专注度监测与异常行为预警整合为完整系统,适合教育行业技术研发工程师、AI算法研究者以及智慧校园项目人员研习参考。方案将OpenCV、PyTorch等框架与人脸检测、表情分析模型深度结合,覆盖课堂点名、考生核验、考试过程监控等典型环节,具备从算法设计到系统落地的对照价值。资源合计625个文件、约87.2MB,其中382个Python脚本构成算法主干,覆盖数据处理、模型训练与推理实现;41篇Markdown文档整理部署说明与实验笔记;32个YAML配置保存训练参数;另有预训练权重、模型结构文件及示例图片,方便按模块检索学习。包内还集成YOLOv3、RetinaFace等检测模型的配置与CUDA自定义层源码,有助于复现人脸检测和表情分析全流程。已吸引300人学习下载,对于需要完整工程代码、可运行模型与中文文档的读者,是一份可上手的项目参考。
1. 人脸表情识别做考试防作弊:先抓状态,再抓动作
在智慧教室场景里,基于人脸表情识别的考试防作弊系统,本质上不是抓“抄小抄”的动作,而是抓“作弊之前或之中”的状态变化——长时间低头、频繁侧头张望、视线反复偏离试卷、表情在短时间内大幅波动。把这些信号用时间窗口攒成预警,监考老师才有机会在作弊发生前走到考生旁边,而不是事后翻监控录像。做这套系统的常见身份是学校信息中心、智慧教室集成商或项目外包团队。本文按选型、代码、部署、避坑的顺序,把一条能落地的路径讲透。
2. 识别链路选型:人脸检测、表情分类与状态机设计
2.1 为什么考场场景要用两段式结构,而不是端到端模型
很多人第一反应是找“作弊行为识别模型”,输入完整画面直接输出是否作弊。这个方向在真实考场走不通,核心原因是样本。作弊是小概率事件,一场考试可能零发生,标注数据里“作弊”类别的数量和多样性都不够。端到端模型训出来常常靠光线或背景做判断,换一间教室就失灵,而且深度模型是个黑匣子,一旦误报高,甲方没法接受“不知道它为什么报警”。
常见做法是两段式:先做人脸检测,裁出人脸区域,再做表情分类;最后把表情、头部姿态、视线这些弱特征交给状态机。这样每一段都能独立调参、独立替换,误报时能定位到是哪一段出了问题。两段式的计算开销也小,先检测跳过背景,分类只处理人脸小块,在普通 CPU 上也能跑起来。对智慧教室这种固定机位、光照相对可控的场景,两段式比端到端可靠得多。
提示:端到端方案需要一个包含大量真实作弊画面的训练集,这个条件在绝大多数学校项目里不成立。两段式最大的收益不是精度,而是每一段都可以用公开数据集和公开模型。
2.2 人脸检测选型:YuNet、RetinaFace 与考场实际效果
人脸检测是整条链路的地基。教室摄像头视角固定,考生一般坐在座位上,正脸和半侧脸居多,但光线会随窗帘、投影变化。选型时主要看三件事:模型体积、CPU 上的延迟、对侧脸的容忍度。
| 模型 | 输入尺寸 | 参数量 | 单人脸延迟(CPU) | 适用场景 |
|---|---|---|---|---|
| YuNet | 320x320 | 约 0.31M | 5–15ms | 考场固定机位,正脸为主 |
| RetinaFace | 640x640 | 约 1.7M | 30–60ms | 需要高精度关键点,可接受 GPU |
| SCRFD | 640x640 | 约 2.5M | 40–80ms | 密集人群,对算力要求高 |
我一般会用 OpenCV 自带的 YuNet,理由很实际:不用额外装框架,模型文件 300KB 左右,CPU 上跑得动,且自带 5 个面部关键点,可以直接拿来做头部姿态粗判。初始化时三个参数最关键:score_threshold控制人脸误检,考场固定机位 0.6 合适;如果摄像头角度偏,降到 0.4 会漏得少一些,但同时会引入误检;nms_threshold保持 0.3 左右,避免同一个考生被框两三次;top_k限制每帧最多输出的人脸数,教室一排 6 个人,给 10 足够。
接口的输入尺寸决定检测精度和速度的平衡。320x320 时 CPU 延迟最低,但远排考生人脸可能只有 24 像素左右,检测不稳定。建议初始用 640x640 跑离线测试,再根据实际教室大小决定是否降到 320。人脸太小的时候,表情分类基本不可用,这时候应该让状态机把“人脸不可用”本身当成一个信号来处理。
2.3 表情分类模型的选型与置信度阈值
表情分类通常用 FER2013 数据集微调,输出 7 类:angry、disgust、fear、happy、sad、surprise、neutral。考场里 neutral 是绝大多数时间的状态,所以重点不是“认出”某种表情,而是“发现非 neutral 的异常持续出现”。常见做法是 MobileNetV2 在 FER2013 上微调,输入 48x48,模型导出成 ONNX 后只有几 MB,跟人脸检测共用 CPU 也能达到实时。
这里有一个反直觉的点:happy 表情在考场里并不代表人作弊成功后的放松,反而可能是考生之间对答案后露出的神态。但 happy 的误报率也很高,对话、憋笑都会触发,所以不能把 happy 单独当成强信号。真正有用的弱特征是:非 neutral 表情的持续时间、表情切换的频率、以及低置信度的比例。
置信度阈值怎么定?输出层是 7 类概率,如果直接取 argmax,模型在模糊画面上也会硬分出一个类,这种硬分类是误报的主要来源。我一般会做一个低置信度归并:当最大概率低于 0.6 时,把这一帧强制归为 neutral。也就是说,系统不确定的表情不参与判定,宁可不报,不能误报。如果现场误报压力大,可以把阈值提高到 0.7;如果漏报多,再降到 0.5。这个数字要通过离线测试集调,而不是拍脑袋。
2.4 用状态机把单帧误判攒成时间证据
单帧识别的准确率再高,考试是连续几十分钟的,一帧误判就会产生一条假预警,累积起来就是刷屏。真正扛误报的是时间窗口状态机。核心思路是:不靠一帧判断考生在作弊,而是看一个时间窗口内“嫌疑帧”的占比,占比超过阈值才升级状态。
状态分三档:normal、watch、alert。每个考生对应一个状态对象,每帧检测结果喂进去,状态机内部用一个固定长度的环形队列记录历史。常见参数如下:
| 参数 | 默认值 | 含义 |
|---|---|---|
| 窗口长度 | 150 帧 | 按 10fps 抽帧就是 15 秒 |
| watch 阈值 | 0.6 | 窗口内嫌疑帧占比超过 60% 进入 watch |
| alert 阈值 | 0.8 | 窗口内嫌疑帧占比超过 80% 进入 alert |
| 连续 alert 次数 | 3 | 连续 3 个窗口都是 alert 才产生预警 |
| 去重窗口 | 5 分钟 | 同一考生触发预警后 5 分钟内不再重复报警 |
嫌疑帧的定义可以由两个特征构成:表情非 neutral(且置信度超过阈值)、头部姿态异常(低头角度大或侧脸角度大)。把这些弱特征叠加进窗口,既保留了单帧特征的灵敏度,又不会让一帧误判直接变成预警。作弊行为和“低头写字”在单帧上长得一样,只有持续时间的分布不同,状态机正是用来捕捉这个差异的。
3. 跑通最小可用系统:从摄像头视频流到预警落库
3.1 目录结构与依赖安装
这一章给出一个可以直接跑通的最小闭环:读 RTSP 摄像头流、做人脸检测和表情分类、状态机判定、预警落库、提供告警接口。目录结构保持简单,方便在教室盒子上部署。
exam_monitor/ ├── config.yaml # 摄像头、阈值、窗口参数 ├── inference.py # 人脸检测 + 表情分类 ├── state_machine.py # 状态机判定 ├── main.py # FastAPI 入口 + 主循环 └── store.py # SQLite 落库依赖用 pip 安装,模型文件单独下载后放到models/目录。
pip install opencv-python onnxruntime fastapi uvicorn pyyamlOpenCV 自带 FaceDetectorYN,不需要额外装 opencv-zoo 的代码库,只需要下载 YuNet 的 onnx 模型文件。表情分类模型用 MobileNetV2 微调后导出 ONNX。这两个文件加起来不超过 10MB,放教室盒子本地没问题。
3.2 人脸检测 + 表情分类的推理代码
核心推理类把两段式串起来,输入一帧 BGR 图像,输出每个人脸的表情和关键点。代码可以直接作为inference.py使用。
# inference.py import cv2 import numpy as np import onnxruntime as ort EMOTIONS = ["angry", "disgust", "fear", "happy", "sad", "surprise", "neutral"] class FaceEmotionInference: def __init__(self, detector_path, fer_onnx_path, detect_size=(320, 320)): self.detector = cv2.FaceDetectorYN_create( detector_path, "", detect_size, score_threshold=0.6, nms_threshold=0.3, top_k=10 ) self.session = ort.InferenceSession(fer_onnx_path, providers=["CPUExecutionProvider"]) self.input_name = self.session.get_inputs()[0].name # 表情模型输入约定为 NCHW,1x3x48x48 _, _, self.h, self.w = self.session.get_inputs()[0].shape def detect_and_classify(self, frame_bgr): rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) self.detector.setInputSize((rgb.shape[1], rgb.shape[0])) ret, faces = self.detector.detect(rgb) results = [] if not ret: return results for face in faces: x, y, w, h = face[:4].astype(int) score = float(face[-1]) face_roi = rgb[y:y + h, x:x + w] if face_roi.size == 0: continue blob = cv2.resize(face_roi, (self.w, self.h)) blob = blob.astype(np.float32) / 127.5 - 1.0 blob = np.transpose(blob, (2, 0, 1))[None, ...] out = self.session.run(None, {self.input_name: blob})[0][0] emo_idx = int(np.argmax(out)) conf = float(np.max(out)) # 低置信度不硬分类,归为 neutral,避免误报 if conf < 0.6: emo_idx = 6 results.append({ "bbox": [x, y, w, h], "score": score, "emotion": EMOTIONS[emo_idx], "conf": conf, "landmarks": face[4:14].reshape(5, 2).tolist(), }) return resultsdetect返回的每个人脸数组包含框坐标、5 个关键点和置信度,landmarks的顺序是左眼、右眼、鼻尖、左嘴角、右嘴角。关键点会在状态机里用来估算低头和侧脸角度。表情分类前先做归一化,/127.5 - 1.0对应训练时的 [-1, 1] 区间。如果模型训练时用的是均值和方差归一化,这里要改成对应的值,否则分类准确率会明显下降。
3.3 状态机判定与预警记录写入
每个考生需要一个独立的状态对象。状态机维护一个固定长度的布尔队列,记录每一帧是否属于嫌疑帧。嫌疑帧的定义由上层传入,状态机只管统计占比。
# state_machine.py from collections import deque class CandidateState: def __init__(self, window_size=150, watch_ratio=0.6, alert_ratio=0.8): self.buffer = deque(maxlen=window_size) self.watch_ratio = watch_ratio self.alert_ratio = alert_ratio self.level = "normal" self.alert_hits = 0 def update(self, is_suspect: bool) -> str: self.buffer.append(1 if is_suspect else 0) if len(self.buffer) < self.buffer.maxlen: return self.level ratio = sum(self.buffer) / len(self.buffer) if ratio >= self.alert_ratio: self.level = "alert" self.alert_hits += 1 elif ratio >= self.watch_ratio: if self.level == "normal": self.level = "watch" else: self.level = "normal" self.alert_hits = 0 return self.levelwindow_size用的是帧数,实际时间取决于抽帧频率。按 10fps 抽帧,150 帧就是 15 秒窗口。alert_hits记录连续命中 alert 的次数,主程序里只有连续 3 次才会真正发预警,避免偶发姿态变化造成误报。
主循环里还需要做跨帧关联,判断前一帧的某个人脸和当前帧的某个人脸是不是同一个考生。最简单的方式是 IoU 匹配:当前帧的人脸框与上一帧已有轨迹的 IoU 大于 0.3 就认为属于同一人。这个逻辑不复杂但容易被忽略,如果不做,状态机就会把同一个考生当成不同的人来回建状态,预警也会重复。
3.4 FastAPI 预警接口与告警屏轮询
状态机判定为 alert 后,需要把事件写入数据库并提供给前端轮询。用 FastAPI 提供两个接口:POST /alarm写入预警,GET /events供告警屏拉取最近事件。
# main.py from fastapi import FastAPI from pydantic import BaseModel import sqlite3, time app = FastAPI() class AlarmEvent(BaseModel): camera_id: str seat_no: str event_type: str # head_down / side_face / emotion_change / leave_seat confidence: float @app.on_event("startup") def init_db(): conn = sqlite3.connect("exam_monitor.db") conn.execute(""" create table if not exists alarm ( id integer primary key autoincrement, camera_id text, seat_no text, event_type text, confidence real, ts integer ) """) conn.commit() conn.close() @app.post("/alarm") def push_alarm(ev: AlarmEvent): conn = sqlite3.connect("exam_monitor.db") conn.execute( "insert into alarm(camera_id, seat_no, event_type, confidence, ts) values(?,?,?,?,?)", (ev.camera_id, ev.seat_no, ev.event_type, ev.confidence, int(time.time())) ) conn.commit() conn.close() return {"ok": True}POST /alarm的调用方是主循环程序,主循环里完成检测、状态机判定之后,把事件推到本地接口。告警屏每 3 秒轮询一次GET /events,只返回近 5 分钟的数据并按时间倒序。前端展示时按教室聚合,一个教室最多显示 5 条,避免刷屏。seat_no的获取需要提前做一次座位标定:考试开始前把每个座位的人脸特征或位置区域记录下来,然后映射到座位号。
4. 部署到智慧教室现场:算力盒子、跨校区 VXLAN 与带宽预算
4.1 教室端推理还是集中推理:按路数算账
这个系统部署在哪,决定了整个网络架构。两种方案:教室端放一台推理盒子,或者所有摄像头视频流集中到机房统一推理。最终选择取决于教室数量和摄像头路数。
| 方案 | 适用规模 | 设备 | 优缺点 |
|---|---|---|---|
| 教室端推理 | 30 间教室以内 | Jetson Orin Nano 8G 或 i5 迷你主机 | 单教室故障不扩散,带宽压力小,单台成本高 |
| 集中推理 | 30 间以上或机房统一管控 | GPU 服务器 + 万兆接入 | 算力复用,但视频流全量上传,断网就全停 |
按单路计算:1080p 视频按 5fps 抽帧,缩放后做人脸检测 320x320 约 8–12ms,表情分类 48x48 约 5–8ms,两段合计不超过 20ms。4 路摄像头串行推理约 80ms,CPU 盒子完全吃得消。如果每间教室 8 路,串行到 160ms,再加上采集和推送,还能接受。超过 8 路建议用 Jetson,GPU 上的检测和分类都比 CPU 快一倍以上。
我一般建议教室端推理。现场断网或机房故障只影响一间教室,不会出现全校系统瘫痪。集中推理唯一的好处是模型更新不用逐台刷盒子,但对考场项目来说,通常一个学期才更新一次模型,这笔账不划算。
4.2 跨校区智慧教室专网用 VXLAN 打通二层:设备部署要点
跨校区部署时,网络是第一个坑。每间教室的摄像头和推理盒子需要一个固定内网地址,如果两个校区各有一套独立的二层网络,摄像头 IP 会冲突,或者每跨一个校区就要改一次配置。常见做法是跨校区的智慧教室专网用 VXLAN 打通二层,让所有校区处于同一个 overlay 二层域,摄像头统一规划 172.16.x.x 段,教室盒子按楼层分配子网,跨校区迁移时不用改 IP。
VXLAN 是把二层报文封装在 UDP 里的 overlay 技术,底层走三层 IP 网络,适合跨校区场景。设备部署时有几个要点,缺一个都可能翻车。
# 以常见交换机 CLI 为例,不同厂商语法略有差异 # 核心交换机上创建 VXLAN 并关联 VNI vlan 100 vxlan vni 10100 # 进入 VXLAN 配置,绑定 VNI 和 RT bridge-domain 100 vxlan vni 10100 evpn route-distinguisher 65001:10100 evpn vpn-target 65001:10100 import exportVNI 10100相当于二层 VLAN ID 的扩展,跨校区统一约定同一个 VNI,设备才能互相通信。route-distinguisher和vpn-target在 EVPN 模式下用于控制路由学习范围,两个校区配置必须一致。这里最容易踩的坑是 MTU:VXLAN 封装会在原始报文上增加 50 字节头,如果物理链路 MTU 是 1500,大帧会被丢弃,表现为摄像头画面时断时续。解决办法是把接入交换机的物理口 MTU 调到 1550,或者启用 jumbo frame,然后再启用 VXLAN。
组播也要注意。RTSP 组播在 VXLAN 里不会自动跨 VTEP 转发,需要在 VTEP 上开启 head-end replication,或者直接用 EVPN 的组播同步能力。如果不处理,跨校区巡查看不到画面,单独一间教室的录像又回到原来的单播方式。配置完成后,用两间跨校区的教室盒子互 ping 验证二层连通性,再拉一路 RTSP 流测试跨校区取流。
4.3 带宽与延迟预算:单教室、单校区与跨校区
带宽要分开算两条链路:摄像头原始视频流,以及推理盒子与服务器之间的事件流量。很多方案翻车都翻在只算了前者,没算后者。
单间教室 4 路 1080p 摄像头,H.264 码率按 2–4Mbps 估算,总共 8–16Mbps。如果视频流集中到机房推理,30 间教室就是 240–480Mbps,跨校区专线很容易被打满。如果教室端推理,视频流只在教室本地交换机和盒子之间流动,跨校区只传事件数据,一条预警 JSON 不到 1KB,就算全考场同时报警,带宽压力也几乎为零。
延迟预算从摄像头画面产生到监考端看到预警,通常要求 3 秒以内。分解下来:视频流延迟 200ms,抽帧和推理 100ms,状态机累计需要完整窗口 15 秒才能判 alert,这个才是大头。也就是说,单帧处理再快,状态机的窗口长度决定了系统的最快反应时间。如果要用 15 秒窗口,就不要承诺“秒级预警”。要缩短反应时间,只能把窗口缩短到 10 秒,同时调高嫌疑帧阈值,靠更敏锐的特征来弥补。
5. 现场最容易翻车的五个坑:摄像头、口罩、误报统计与预警轰炸
5.1 考场里有人戴口罩:表情分类直接失效
现象:考生戴口罩后,表情分类结果频繁跳向 neutral,偶尔出现 fear 或 sad,状态机跟着乱跳。原因:FER2013 训练集没有口罩样本,口罩遮住了嘴巴和大部分面部肌肉,模型只能靠眉眼区域猜测,置信度很低。解决:在检测到口罩后关闭表情分类,只保留头部姿态和离座检测。实现上用一个人脸 ROI 的上半部分单独过一遍小型口罩分类器,输出 mask 或 no_mask。口罩状态下,把“表情异常”这个特征从状态机里去掉,避免低置信度输出把状态机推向 alert。
5.2 低头看卷被大量预警,低头看小抄却漏掉
现象:考生正常低头写字,低头时长很容易超过 15 秒窗口的 60% 阈值,状态机频繁报 head_down;考生把小抄放在桌面上低头看,系统反而识别不出来。原因:只有头部姿态,没有视线方向,也没有手部位置。低头的语义在“写字”和“看小抄”之间完全靠上下文区分。解决:加入视线估计和手部检测。MediaPipe Face Mesh 的 iris 模型可以输出眼球中心位置,结合鼻尖和左右眼关键点,能估算出视线是落在桌面偏左还是正前方。手部检测在桌面 ROI 内看是否有手部进入小抄区域。然后把“低头”降级为弱特征,只有同时满足“低头 + 视线偏离试卷 + 手部在桌面边缘”才升级为嫌疑帧。
5.3 侧脸与离座:人脸检测召回率崩塌
现象:考生侧头和邻座说话,或者转头看后方,人脸检测直接检测不到,状态机认为该考生“正常”,实际上异常行为恰好发生在这个阶段。原因:YuNet 对大幅侧面的人脸召回率低,人脸离开画面边框时轨迹中断,状态机没收到任何输入。解决:用关键点计算侧脸角度,左右眼的 x 坐标距离和脸宽的比值低于 0.3 就判定为 side_face,这个特征不依赖表情模型。离座判定要做“丢失轨迹处理”:一个人在座位上连续 60 秒检测不到人脸,直接产生 leave_seat 事件。另外在教室后墙加一路全景摄像头,主摄像头负责正脸,全景摄像头负责离座和侧脸接力,两路信号同时喂给状态机。
5.4 预警刷屏之后,监考老师反而什么都不看
现象:系统部署第一天,预警列表刷出几百条事件,监考老师第一小时还会点开看,之后完全无视。原因:没有优先级和去重,每一条 head_down 都算事件,信息过载。解决:按教室聚合,同一时间窗口内按“关注等级”排序,最高的一档是 leave_seat 和 side_face + 视线偏移,其次是 head_down + 手部动作,最后才是 emotion_change。去重窗口 5 分钟,同一考生命中同一事件类型只报一次,前端最多展示 Top 5。如果预警量还把屏幕占满,说明阈值设得太低,不是前端问题。
5.5 误报率按“人”统计会骗你
现象:验收时按“考生人数”统计,误报率只有 2%,校方觉得可以接受;实际监考体验是一节课收到十几条预警。原因:按人统计的分母是考生数,而一个考生可以在一小时内被误报几十次,人均误报被摊薄了。解决:按“考生·分钟”统计。计算公式是:误报分钟数 / (考生数 × 考试时长分钟)。比如 30 个考生考 90 分钟,分母是 2700,某考生被误报 5 分钟,别的考生 0 误报,误报率是 0.19%,这个数字才接近真实体验。验收时统一口径,避免在统计方式上扯皮。
6. 验收前做三件事:离线测试集、事件指标与灰度窗口
6.1 用自己教室录一场“模拟考试”建测试集
调参不能只靠现场试错。真实考试不能拿来反复测试,所以我一般会在目标教室录一场模拟考试:两小时正常答题,穿插 30 个异常片段,包括低头、侧头、翻找桌格、离座、对答案。让参与模拟的学生提前写好剧本,异常片段精确到分钟。录完后按时间段标注成 JSON:start_minute, end_minute, seat_no, event_type。这套数据量不大,但比公开数据集更能反映这间教室的光线和摄像头角度。
6.2 事件级误报率、漏报率与 p95 延迟的计算方法
模型输出和人工标注做时间重叠匹配,重叠超过 1 分钟就算命中。核心指标三个:误报率、漏报率和端到端延迟。
| 指标 | 计算方式 | 达标参考 |
|---|---|---|
| 事件级误报率 | 系统报警但标注中无异常的事件数 / 系统总报警数 | 低于 10% |
| 事件级漏报率 | 标注异常但系统未报警的片段数 / 标注异常总数 | 低于 20% |
| p95 延迟 | 从帧采集时间到告警落库时间排序取 95 分位 | 小于 3 秒 |
两个班的模拟考试分别跑一轮,结果不一致时优先看漏报率差异,如果同一个异常片段在一班报、二班不报,通常不是模型问题,而是摄像头角度差异。
6.3 灰度期间的比对策略与参数修正
模拟考试通过后再上真实考场,但要灰度。前两周只记录不推送,监考老师正常监考,结束后用人工标注和处理结果做对比。参数修正顺序我一般固定是:先调窗口长度,再调嫌疑帧阈值,最后才动模型置信度。因为窗口长度对误报率的影响最直接,阈值次之,模型置信度动多了会改变检测行为。上线第一个月,每周出一份“误报分钟数 / 总分钟数”的趋势,如果连续两周下降,就可以逐步放开更多教室。
我第一次做这类系统,直接按单帧表情结果报警,happy 表情在考场里成了高频误报源,校方差点把这个方向整体否掉。后来把时间窗口和状态机加进去,又按“考生·分钟”重新统计误报,才把指标拉到能接受的程度。现在每次上线前,我都会先问一句:这个报警是“一帧”的证据,还是“一段时间的证据”。想清楚这句话,比调十个参数都有用。希望帮到你。
本文还有配套的精品资源,点击获取