简介:面向考试监考与教务管理场景,这份教学辅助系统(作弊检测系统)源码包提供基于OpenCV、YOLO和Haar Cascade的完整检测方案,适合高校师生、图像处理学习者和需要快速搭建防作弊原型的技术人员使用。系统覆盖四类典型应用:利用YOLOv3与COCO权重识别考场中的手机和书籍,借助Haar级联分类器检测学生转身动作,通过OpenCV分析考生间距离异常,并附带人脸检测、签到与考勤记录等辅助功能。资源共87个文件,压缩包大小264.16MB,包含8个Python脚本、YOLO权重与Caffe模型、Haar Cascade的xml分类器、图片/动图示例、Streamlit看板文件及requirements依赖清单,目录模块划分清晰,便于直接参照运行和二次开发。当前已有1573人学习下载,适合作为课程设计、毕业设计或监考系统研发的参考实现。
1. 作弊检测系统不是“锦上添花”:考试监考系统的底线工程
在线考试做多了会发现一个反直觉的事实:真正导致考试成绩失效的作弊行为,往往不是那些被重点拦截的“切屏搜题”,而是替考、他人闯入画面、长时间低头看桌面小抄这类低技术手段。把题目里的“教学辅助系统(作弊检测系统)”和“学生考试监考系统”拆开看,前者解决的是“考生的行为是否可信”,后者解决的是“考场现场是否可控”。一套能用的监考系统,核心不是堆模型,而是把切屏事件、摄像头画面、头部姿态、人脸在场状态这些信号串成一条可回溯的证据链。这篇文章面向正在做在线考试平台、学校机房考试软件、教务辅助系统的开发者,讲清楚一个能上线的作弊检测/监考系统从信号采集到规则判定再到视频分析该怎么落地,以及哪些环节最容易翻车。
2. 作弊检测的特征来源:把“可疑行为”变成可计算的信号
2.1 客户端侧能拿到哪些信号:事件流比截图更可靠
在线监考最常见的数据来源是考生电脑上的浏览器或考试客户端。很多团队第一版会直接做“定时截屏”,然后让人工看截图,这是典型的且成本极高的做法。截图是黑匣子,它既不能告诉你考生在另一个显示屏上看什么,也不能实时反映焦点是否离开了考试页面,还会带来存储和隐私问题。
我一般会优先采集浏览器事件流,因为事件是轻量的、时序清晰的,而且可以直接驱动规则引擎。核心事件有这么几类:第一类是页面可见性变化,即visibilitychange事件,当考生切到其他标签页或最小化窗口时,文档可见性会变为 hidden;第二类是窗口焦点事件,window.blur和window.focus能捕获考生是否点击了浏览器外部;第三类是粘贴事件和快捷键事件,用于拦截复制题目到外部工具的行为;第四类是鼠标行为,包括长时间无操作、高频点击同一区域、鼠标轨迹异常。这些事件加起来,基本能还原考生在考试期间的操作轨迹。
需要注意一点:事件流可以被伪造。脚本模拟十几个“切屏事件”毫不费力,所以事件只能作为判定信号之一,不能作为唯一证据。常见做法是把事件流和摄像头画面分析结果做交叉验证,比如某考生一分钟内出现 3 次切屏且低头姿态占比超过 40%,两个信号一叠加,可信度就上来了。
2.2 服务端和摄像头侧:人脸、姿态、声音怎么做监测
摄像头信号是监考系统的另一只眼睛,而且比事件流更难伪造。摄像头画面能解决三类关键问题:画面里有没有人、画面里的人是考生本人吗、考生当前头部姿态是否异常。这三类问题对应三类技术:人脸检测与身份比对、头部姿态估计、多人脸检测。
人脸检测在考试场景里要同时做两个任务。第一是“单脸还是多脸”,当画面中检测到超过一张人脸且持续若干秒,基本可以判定有他人介入,这是替考和场外提示的高发信号。第二是“脸部是否持续存在”,如果考试过程中考生频繁离开摄像头视野,或者用物体遮挡摄像头,都是异常行为。头部姿态估计则用来捕捉低头、侧头、扭头这类动作。它的原理不复杂:从人脸关键点中取出鼻尖、眼角、嘴角等点的 2D 图像坐标,再和标准 3D 人脸模型做 PnP 解算,得到头部的俯仰角、偏航角和滚转角。
声音信号属于可选增强项。如果在机房考试场景中部署麦克风采集,可以做简单的环境音检测,比如持续的人声交谈、异常响动。但声音采集涉及隐私问题较多,建议仅在本地考试场景启用,并且明确告知考生。线上考试场景我一般不建议做音频,争议太大。
2.3 信号选型对照表:成本、误报率与主要漏报场景
不同的信号源覆盖的作弊类型不同,工程上不可能全做,要按场景取舍。下面这张表是我做选型时的常用参考:
| 信号 | 采集方式 | 成本 | 误报率 | 主要漏报场景 |
|---|---|---|---|---|
| 切屏/失焦事件 | 浏览器事件 | 低 | 中 | 无痕浏览、手机拍屏、外接显示器 |
| 鼠标/键盘行为 | 客户端埋点 | 低 | 中 | 脚本模拟操作 |
| 单脸检测 | 摄像头 + 人脸检测模型 | 中 | 低 | 侧脸角度过大、光线过暗 |
| 多人脸检测 | 摄像头 + 目标检测模型 | 中 | 低 | 人员出现在画面边缘 |
| 头部姿态 | 关键点 + PnP 解算 | 中 | 高 | 正常低头答题被误判 |
| 身份比对 | 人脸特征比对 | 高 | 低 | 照片攻击、视频重放攻击 |
线上考试一般组合是“切屏 + 失焦 + 单脸/多人脸 + 头部姿态”,机考场景则可以加身份比对。成本最低、见效最快的是事件流规则,但误报率偏高,后续必须靠摄像头信号来兜底。
3. 规则引擎与证据链:怎么把信号整合成“一次作弊判定”
3.1 最小可用的判定规则集
拿到信号之后要回答一个关键问题:什么程度算“可疑”,什么程度算“确认作弊”。这个决策放在规则引擎里做,不要去硬编码在采集端。我先说一组我常用的初始规则,实际部署时按考试级别调整阈值。
第一,切屏规则:10 分钟内切屏次数超过 5 次,或单次切屏时长超过 60 秒,记为严重异常。第二,低头规则:连续 10 秒内低头姿态占比超过 40%,或者某次连续低头超过 15 秒,记为异常。第三,无人脸规则:画面中检测不到人脸且持续超过 30 秒,记为异常。第四,多人脸规则:画面中出现两张及以上人脸且持续超过 10 秒,记为严重异常。第五,组合规则:切屏 2 次以上且同时触犯低头规则,直接升级为高可疑。
规则要分等级,不能一触即罚。我习惯把规则分为“提示级”和“告警级”。提示级只记录不打断考生,告警级会给监考老师推送提醒,确认级事件才会触发人工复核。这样的好处是让系统从“抓作弊”退回到“辅助老师发现异常”,这在真实考务场景里更容易被接受。
3.2 用 Python 实现一个打分与阈值判定服务
规则引擎的落地形式可以很小,一个带滑动窗口的评分服务就够了。下面是我用过的最小实现,事件进来后边累积边判分,分数超过阈值返回可疑,否则返回正常。
# scoring_engine.py # 作弊检测服务端的打分器:把事件流压缩成可解释的判定结果 import time from collections import deque class ScoringEngine: def __init__(self, config=None): self.config = config or { "switch_limit": 5, # 10分钟内切屏次数上限 "switch_seconds": 60, # 单次切屏超过60秒记为异常 "head_down_ratio": 0.4, # 低头时长占比阈值 "no_face_seconds": 30, # 无人脸连续时长阈值(秒) "multi_face_seconds": 10, # 多人脸连续时长阈值(秒) "score_limit": 60 # 判定为“高可疑”的分数阈值 } self.events = deque(maxlen=500) self.scores = {} def feed_event(self, user_id, event_type, payload, ts=None): ts = ts or time.time() self.events.append((user_id, event_type, payload, ts)) score = self._score_event(event_type, payload, ts) self.scores[user_id] = self.scores.get(user_id, 0) + score return self.scores[user_id] def _score_event(self, event_type, payload, ts): if event_type == "window_switch": # payload: {"duration": 12} 表示切屏持续秒数 if payload.get("duration", 0) > self.config["switch_seconds"]: return 20 return 10 if event_type == "head_pose": # payload: {"pitch": 30, "ratio": 0.5} # ratio 表示最近60秒内低头姿态的时间占比 if payload.get("ratio", 0) > self.config["head_down_ratio"]: return 15 if event_type == "face_lost": # payload: {"seconds": 35} 无人脸持续时间 if payload.get("seconds", 0) > self.config["no_face_seconds"]: return 25 if event_type == "multi_face": # payload: {"seconds": 15} 多人脸持续时间 if payload.get("seconds", 0) > self.config["multi_face_seconds"]: return 25 return 0 def judge(self, user_id): score = self.scores.get(user_id, 0) if score >= self.config["score_limit"]: return {"verdict": "high_risk", "score": score} return {"verdict": "normal", "score": score}这段代码的逻辑很直白:每个事件进来后按类型和参数映射出一个分值,累加到该考生的总分上,judge方法在考试结束时或周期性地给出判定结果。参数说明里值得注意的有两点:window_switch事件要带duration字段,单次切屏超过 60 秒直接给 20 分,比连续多次短切屏更可疑,因为长切屏大概率是去其他应用找答案;head_pose事件的ratio字段是最近 60 秒的滑动统计值,这要求采集端在做头部姿态检测时同时维护一个时间窗口,不能只上报瞬时角度。
这个打分器用deque存事件流,容量 500 条,目的是让近期的判定依据可以被回溯。生产环境中这个队列可以接到监控面板上,实时显示“谁在哪个时间点触发了哪条规则”,这是后续人工复核时的原始材料。
3.3 证据链设计:记录什么、存多久、怎么复核
判定结果出来之后,最重要的不是那张“高可疑”的结论表,而是证据链。我见过最惨的翻车案例是系统判了考生作弊,但管理员打开详情页发现只有一行“切屏次数超限”,没有任何截图和时间线,最后只能撤销判定。所以证据链必须在设计判定规则的同时就想清楚。
证据链至少要包含三部分:事件明细、画面证据、判定记录。事件明细就是类似上面代码里events队列中的原始数据,包含用户 ID、事件类型、事件参数、时间戳,存到独立的明细表里。画面证据是摄像头信号触发告警前后各 15 秒的视频片段或关键帧,这个由视频分析服务负责截取。判定记录则包含规则名称、触发原因、分数、复核状态和复核人。
存储成本要控制。考场一天会产生大量摄像头帧,全部保存不现实。常见做法是“只看不存”:视频流实时分析,分析结果进入规则引擎,只有规则引擎判定为“高可疑”时,才把触发时间窗内的帧序列落盘。这样存储量可以压缩到原来的千分之一以下,而且每条存储记录都与一次真实告警对应。
提示:事件明细至少保留到考试结束后 30 天以上,用于考后申诉复核。录像是敏感数据,建议按学校或机构的隐私政策限定保存周期,并在考试须知中明确告知。
4. 监考系统的视频分析接入:从摄像头帧到“是否有人离场”
4.1 先选方案再选模型:MediaPipe 还是 YOLOv8
视频分析是整个监考系统里技术门槛最高、也最容易过度设计的一环。很多初次接触的人一上来就想着训练一个“作弊动作识别模型”,这其实是把问题搞复杂了。监考场景需要的不是识别“作弊”这个抽象概念,而是识别几个基础事实:画面里有几张脸、脸在哪里、头部朝向如何、是不是考生本人。这些基础事实堆在一起,规则引擎自然能得出“是否可疑”的结论。
选型上我一般推荐两条路线。第一条是 Google MediaPipe 的 FaceMesh,它输出 468 个人脸关键点,自带头部姿态解算能力,部署体积小、CPU 上也能跑实时,特别适合单机摄像头监考。第二条是 YOLOv8 的人脸检测模型,适合做多人脸检测,模型本身和生态都很成熟,在 NVIDIA 显卡上跑 30 到 60 FPS 没有问题。如果项目里还需要识别考生是否佩戴口罩、是否在翻阅纸质资料这类动作,再考虑加重模型。
提示:工程上不建议一开始就追求“端到端作弊识别模型”。那个方向的训练样本极难定义,且不同考场动作差异大,模型只会变成“看着像的玄学”。把任务拆成人脸、姿态、事件三路信号,每一路都可解释、可调参、可定位问题。
4.2 从摄像头取帧到特征输出的最小流程
视频分析的最小链路是:OpenCV 读取摄像头帧,送进 MediaPipe FaceMesh 拿到关键点,再用solvePnP解算头部姿态欧拉角,最后把“头部角度 + 画面中人数”封装成结构化事件上报给打分引擎。下面是一个能跑通的示例核心代码。
# pose_estimator.py # 从摄像头帧中提取人脸关键点并估算头部姿态,输出欧拉角供判定服务使用 import cv2 import mediapipe as mp import numpy as np mp_face_mesh = mp.solutions.face_mesh face_mesh = mp_face_mesh.FaceMesh( static_image_mode=False, max_num_faces=2, # 允许多张人脸,用于识别“他人闯入” refine_landmarks=True, # 开启眼周关键点细化,便于视线估计 min_detection_confidence=0.5, min_tracking_confidence=0.5 ) # 简化的人脸关键点索引:左眼外角(33)、右眼外角(263)、 # 左嘴角(61)、右嘴角(291)、鼻尖(1)。真实项目中请对照 # FaceMesh 官方索引图核对后再使用。 FACIAL_INDEX = [1, 33, 263, 61, 291] # 5 个关键点对应的 3D 参考坐标(单位 mm),近似值,用于 solvePnP MODEL_POINTS = np.array([ [0.0, 0.0, 0.0], # 鼻尖 [-30.0, -30.0, -10.0], # 左眼外角 [30.0, -30.0, -10.0], # 右眼外角 [-20.0, -20.0, 10.0], # 左嘴角 [20.0, -20.0, 10.0] # 右嘴角 ], dtype=np.float64) def estimate_head_pose(frame): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = face_mesh.process(rgb) if not results.multi_face_landmarks: return None, 0 # 没人脸,交给业务方判断“离场” face_count = len(results.multi_face_landmarks) for landmarks in results.multi_face_landmarks: h, w = frame.shape[:2] image_points = np.array([ [landmarks.landmark[i].x * w, landmarks.landmark[i].y * h] for i in FACIAL_INDEX ], dtype=np.float64) focal_length = w center = (w / 2, h / 2) camera_matrix = np.array( [[focal_length, 0, center[0]], [0, focal_length, center[1]], [0, 0, 1]], dtype=np.float64 ) dist_coeffs = np.zeros((4, 1)) # 通过 2D-3D 点对解算头部旋转向量和平移向量 _, rvec, tvec = cv2.solvePnP( MODEL_POINTS, image_points, camera_matrix, dist_coeffs, flags=cv2.SOLVEPNP_ITERATIVE ) # 旋转向量转旋转矩阵,再转欧拉角 rotation_matrix, _ = cv2.Rodrigues(rvec) pitch = np.degrees(np.arcsin(-rotation_matrix[2, 0])) roll = np.degrees(np.arctan2(rotation_matrix[2, 1], rotation_matrix[2, 2])) yaw = np.degrees(np.arctan2(rotation_matrix[1, 0], rotation_matrix[0, 0])) return {"pitch": float(pitch), "roll": float(roll), "yaw": float(yaw)}, face_count return None, face_count代码里的几个参数值得细说。max_num_faces=2是故意设为 2 的,因为监考场景要识别的“多人闯入”通常只需要覆盖 1 到 2 张额外人脸,设得过大反而会在画面背景中出现路人时造成误报。refine_landmarks=True会额外计算眼周的稠密关键点,为后续做视线方向估计留了余地,代价是推理时间略微增加。solvePnP的SOLVEPNP_ITERATIVE适合点数少且 3D 点精确的场景,如果后续使用 68 点或 468 点的完整人脸模型,可以换成SOLVEPNP_SQPNP算法,求解更稳定。
这里 MODLE_POINTS 用的是近似值,只能得到一个相对姿态。实际部署时如果要更准确的角度读数,需要用标准人脸关键点数据集中的平均坐标来替换。对监考场景,相对姿态的精度已经足够:低头 30 度和抬头 15 度在欧拉角上的区分很明显。
4.3 把视频分析结果写回判分系统:异步与降频
视频分析服务输出的是逐帧结果,如果每一帧都上报给判分系统,判分服务会被请求打满,而且很多帧事件是重复的,没有业务价值。我一般会做两道处理:降频和阈值化。
降频是指把视频流的处理节奏降下来。线上考试场景中,考生人脸姿态不会在几百毫秒内发生剧烈变化,因此每秒分析 1 帧已经足够。机考场景如果要求更高,可以提到 2 到 3 FPS,再往上就是浪费算力。阈值化是指不把原始角度上报,而是在采集端先判断姿态是否超过阈值,连续 N 帧超限才上报一次状态事件。比如 pitch 小于 -25 度判定为低头,连续 10 帧低头才上报一次head_pose事件,这样评分的输入从“带噪声的帧”变成了“稳定的状态”。
另外,视频分析服务和判分服务之间要用异步队列解耦。摄像头取帧是实时性的,但模型推理和服务端判分都可能出现延迟,如果同步调用,视频流会阻塞。常见做法是把分析结果写入 Redis Stream 或 RabbitMQ 队列,判分服务异步消费,消费速度慢时允许积压,但分析端永远不阻塞采集。线上考场几百路视频同时推流时,这个设计直接决定了系统会不会在开考半小时后集体掉线。
5. 避坑与常见问题排查:上线后最容易翻车的五个细节
5.1 现象:摄像头角度变一下就疯狂误报
有次一个学校机房部署完系统,开考后不到五分钟,监考后台收到两百多条低头告警。排查发现摄像头安装在显示器侧上方,学生为了看清屏幕会自然仰头,而代码里把 pitch 的判定阈值设成了固定值 -25 度,导致仰头也落到阈值外。
原因在于头部姿态是一个绝对角度,但不同考生坐高、摄像头安装角度完全不同,同一个角度在 A 考生身上是平视,在 B 考生身上就是明显低头。
解决方法是加一个“开局校准”环节:考试进入倒计时前,让系统采集考生正常注视屏幕时 15 秒的姿态数据,计算平均 pitch 和 yaw,作为该考生的基线。之后所有姿态判定都基于“相对基线的偏移量”,比如基线 pitch 是 -5 度,低头 30 度对应的判定阈值就是 -35 度,这样摄像头安装角度差异就不再影响判定。
5.2 现象:切屏检测对无痕浏览器完全失效
线上考试时,部分考生使用 Chrome 无痕窗口打开考试页面,切到其他页面时,visibilitychange事件仍然会触发,但失焦事件blur在某些场景下不会正常派发。还有考生直接把考试页面拖到副屏,主屏上开着答案,事件流几乎没有异常。
原因不复杂,浏览器事件在不同内核和不同隐私模式下行为不一致,单靠一种事件判断切屏是存在盲区的。
解决办法是交叉验证:除了visibilitychange,还要监听window.blur、document.hasFocus()轮询和窗口大小变化。同时增加一个心跳机制,考试客户端每隔 10 秒向服务端发送一次状态包,包含页面可见性、焦点状态、窗口尺寸,服务端用这些信息交叉校验切屏事件。副屏答题这种情况,可以配合摄像头画面中的“考生视线长时间偏离摄像头中心”特征来兜底。
5.3 现象:低亮度考场人脸检测召回断崖下跌
普通教室的采光在傍晚会明显变差,摄像头自动曝光跟不上时,人脸检测的置信度会普遍下降,出现大量“无人脸”误报。最严重的一次是整场考试有三分之一的考生被判定为“离开摄像头”。
原因在于模型训练数据以正常光照为主,低对比度下人脸关键点提取不稳定,加上部分摄像头传感器本身低照度表现就差。
解决方法是做帧预处理。在送入模型之前,先对每一帧做亮度判断,如果平均灰度低于某个阈值,先做直方图均衡化或 CLAHE 局部对比度增强,再做检测。同时把“无人脸”判定条件从“单帧检测不到”改为“连续 30 秒内有效检测率低于 20%”,避免单帧抖动造成误报。
5.4 现象:视频分析服务高峰时 CPU 打满导致判定迟到
线上考试开考后,几百路视频同时推送,单机部署的 MediaPipe 分析服务 CPU 直接打满,帧分析结果延迟越来越大,判分系统的告警从实时变成三五分钟后才到,失去了监考意义。
原因是没有做异步降频。采集端把每一帧都送进分析服务,模型吞吐跟不上生产速度,积压越来越多。
解决的思路是“宁可丢帧,不可积压”。在分析服务前面加一个有限长度队列,比如队列长度 200,超出后直接丢弃新到的帧并记录丢帧率。视频分析要的是状态连续性,不是帧完整性,丢帧对最终判定的影响很小。同时把分析服务的并发线程数调小,让 CPU 专注于推理而不是上下文切换,吞吐反而更高。
5.5 现象:涉嫌作弊的记录被管理员一键删除,事后无法复核
有一次考后申诉阶段,教务老师误删了一条“高可疑”记录,连带聊天记录里的截图和日志一起清掉了。虽然最后证明考生确实有违规行为,但因为没有证据,只能撤销处分,这也算是非常惨痛的一次教训。
原因是管理后台给了删除权限,但没有做任何约束。考试判定记录属于敏感数据,不可以被普通管理员直接物理删除。
解决办法是设计软删除和审计日志。删除操作只将记录标记为“已移除”,原数据保留在一张不可直接访问的归档表中,且删除动作本身记录操作人、操作时间、操作原因。只有系统超级管理员可以彻底清理归档数据,而且清理前需要二次确认。
注意:判定记录被删后无法恢复,是监考系统最容易被忽略的合规风险。所有删除操作都应当有审计痕迹,这一点在招标和验收环节经常被翻出来检查。
6. 进阶:用一份测试工单校准整个作弊检测系统
规则引擎和视频分析都上线后,最需要做的不是继续加特征,而是校准。一套未校准的监考系统,误报率可能高到让监考老师直接放弃使用。我的习惯是每次版本上线前,先跑一份“作弊行为测试工单”,用可控的模拟行为验证系统最终的判定准确率。
测试工单按作弊类型拆成若干场景,每个场景用脚本或真人模拟固定次数,记录系统的判定结果。下面是一张常用的工单模板:
| 测试场景 | 模拟方式 | 期望结果 | 实际结果 |
|---|---|---|---|
| 快速切屏 3 次 | 脚本切换标签页 | 提示级告警 | 待验证 |
| 长时间切屏 90 秒 | 脚本切走后停留 90 秒 | 高可疑 | 待验证 |
| 低头答题 20 秒 | 真人低头并保持 | 高可疑 | 待验证 |
| 正常答题 10 分钟 | 真人正常姿态 | 无告警 | 待验证 |
| 他人入镜 15 秒 | 第二人走入摄像头范围 | 高可疑 | 待验证 |
| 离开座位 40 秒 | 真人起身离开 | 高可疑 | 待验证 |
校准的关键是调阈值,而不是调模型。每个场景跑完,把系统输出的score和真实标签放在一起画混淆矩阵,看误报率集中在哪些场景。比如发现“正常答题”被误报的次数多,优先怀疑头部姿态基线校准没起作用;如果“低头答题”没有被识别,优先检查pitch的判定方向和阈值符号是否正确。
最后提一个我一直坚持的习惯:任何监考系统的判定结果都不能直接作为“作弊定案”的凭证,它只是给监考老师的一张优先处理清单。机器负责把可疑行为从几千考生中捞出来,人负责看证据、下结论。系统上线后,我每周都会导出一次告警记录,手动抽查其中 10% 的判定是否合理,看到误报就回查规则参数。这套机制看起来原始,但比任何花哨的模型迭代都有效,也避免了系统变成脱离实际的黑匣子。希望帮到你。
本文还有配套的精品资源,点击获取