ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

人脸表情识别与状态机:考场防作弊系统的智能监控实践

人脸表情识别与状态机:考场防作弊系统的智能监控实践 简介面向智慧教室中的考试防作弊这一应用场景这套基于人脸表情识别技术的工程方案将身份验证、专注度监测与异常行为预警整合为完整系统适合教育行业技术研发工程师、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适用场景YuNet320x320约 0.31M5–15ms考场固定机位正脸为主RetinaFace640x640约 1.7M30–60ms需要高精度关键点可接受 GPUSCRFD640x640约 2.5M40–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% 进入 watchalert 阈值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_threshold0.6, nms_threshold0.3, top_k10 ) self.session ort.InferenceSession(fer_onnx_path, providers[CPUExecutionProvider]) self.input_name self.session.get_inputs()[0].name # 表情模型输入约定为 NCHW1x3x48x48 _, _, 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_size150, watch_ratio0.6, alert_ratio0.8): self.buffer deque(maxlenwindow_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 路摄像头串行推理约 80msCPU 盒子完全吃得消。如果每间教室 8 路串行到 160ms再加上采集和推送还能接受。超过 8 路建议用 JetsonGPU 上的检测和分类都比 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 模式下用于控制路由学习范围两个校区配置必须一致。这里最容易踩的坑是 MTUVXLAN 封装会在原始报文上增加 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 个异常片段包括低头、侧头、翻找桌格、离座、对答案。让参与模拟的学生提前写好剧本异常片段精确到分钟。录完后按时间段标注成 JSONstart_minute, end_minute, seat_no, event_type。这套数据量不大但比公开数据集更能反映这间教室的光线和摄像头角度。6.2 事件级误报率、漏报率与 p95 延迟的计算方法模型输出和人工标注做时间重叠匹配重叠超过 1 分钟就算命中。核心指标三个误报率、漏报率和端到端延迟。指标计算方式达标参考事件级误报率系统报警但标注中无异常的事件数 / 系统总报警数低于 10%事件级漏报率标注异常但系统未报警的片段数 / 标注异常总数低于 20%p95 延迟从帧采集时间到告警落库时间排序取 95 分位小于 3 秒两个班的模拟考试分别跑一轮结果不一致时优先看漏报率差异如果同一个异常片段在一班报、二班不报通常不是模型问题而是摄像头角度差异。6.3 灰度期间的比对策略与参数修正模拟考试通过后再上真实考场但要灰度。前两周只记录不推送监考老师正常监考结束后用人工标注和处理结果做对比。参数修正顺序我一般固定是先调窗口长度再调嫌疑帧阈值最后才动模型置信度。因为窗口长度对误报率的影响最直接阈值次之模型置信度动多了会改变检测行为。上线第一个月每周出一份“误报分钟数 / 总分钟数”的趋势如果连续两周下降就可以逐步放开更多教室。我第一次做这类系统直接按单帧表情结果报警happy 表情在考场里成了高频误报源校方差点把这个方向整体否掉。后来把时间窗口和状态机加进去又按“考生·分钟”重新统计误报才把指标拉到能接受的程度。现在每次上线前我都会先问一句这个报警是“一帧”的证据还是“一段时间的证据”。想清楚这句话比调十个参数都有用。希望帮到你。本文还有配套的精品资源点击获取
返回列表