简介:面向计算机相关专业毕业生与项目实战学习者,这份基于Python+OpenCV的智能监考系统源码,可用作毕业设计、课程设计或期末大作业。项目来自高分毕设,评审99分,代码完整、可直接运行,覆盖人脸检测、识别及监考辅助等核心环节,能有效解决选题难、复现难等常见问题。资源包共353个文件,以253个Python源码文件为核心,配合pyd动态库、exe工具、mp4演示视频、jpg/png样例图片,以及dlib人脸关键点模型与数据库文件,整体约110.78MB。目前已有123人学习下载,适合需要参考高分项目、快速跑通完整系统流程的读者。资源还附带环境配置脚本、依赖模型、运行数据库等辅助内容,有助于理解OpenCV与dlib在真实监考场景中的结合方式,学习模块划分与调用思路,并以此为基础二次开发和拓展。
1. 智能监考系统:为什么先盯“异常行为”而不是盯“人”
毕设里能一眼看出工作量、又能完整体现技术链路的题目不多,Python + OpenCV 的智能监考系统是其中一个。它把摄像头画面连续解析成人脸数、闭眼状态、头部旋转角度和是否出现第二人,并把长时间异常累积成告警事件。这个题目的反直觉点在于:它不试图“识别作弊行为本身”,而是用可量化的注意力特征去覆盖考场中最高频的作弊动作——转头、低头、长时间不看卷面、离座、交头接耳。我建议用 OpenCV 做画面处理,用 dlib 提供 68 点面部关键点,让这条链路在普通笔记本上就能接近实时运行;适合计算机视觉、图像处理方向的本课毕设,也适合想往真实产品方向扩展的课程设计。论文要的是能解释、能演示、能复现,智能监考系统恰好满足这三条。
2. 方案选型与检测原理:为什么先做“人脸存在性”再做视线和姿态
智能监考系统看起来是一个模型问题,实际是一个多任务融合问题。只靠一个人脸检测器无法判断“这个人是不是在看桌下的手机”,更无法区分“低头答题”和“低头偷看”。因此第一件事不是堆模型,而是先拆任务:人脸在哪、脸朝哪个方向、眼睛有没有闭、画面里是否出现第二张脸。只有把这些基础特征算出来,后面的告警规则才有依据。
常见的一个误区是:直接去找“作弊检测开源模型”。且不说公开数据集很难覆盖真实考场遮挡,单是“作弊”这个标签本身就定义不清。毕设项目最怕黑匣子式的黑箱模型,评委一问“为什么这样判断”就答不上来。所以我建议把系统拆成可解释的特征提取模块,再用规则做时间维度上的累积判断。
2.1 检测链路选型:Haar、dlib 和 YOLO 怎么分工
选择方案时,我习惯把检测任务按“对象的尺度”和“需要输出什么”分开看:
- 人脸存在性和人脸框:用 dlib 的 HOG 人脸检测器,比 OpenCV Haar 对轻微侧脸更稳,CPU 上也能跑。
- 眼睛区域和头部姿态:用 dlib 的 68 点面部关键点模型,输出 68 个点,能覆盖眼睛、鼻子、嘴巴、轮廓。
- 手机等小目标检测:可选 YOLOv8n 或 YOLOv5s,导出 ONNX 后用 OpenCV 的 DNN 模块推理,作为加分项。
- 抬头低头角:用 OpenCV 的
solvePnP做 3D-2D 对应点求解,不需要训练新模型。
| 检测目标 | 首选方案 | 备选方案 | 选择理由 |
|---|---|---|---|
| 人脸 | dlib HOG detector | OpenCV Haar cascade | 多人、轻微侧脸场景漏检少 |
| 眼睛眼睑关键点 | dlib 68 点模型 | MediaPipe Face Mesh | EAR 需要稳定的眼睑上下点 |
| 头部姿态 | solvePnP | MediaPipe Face Mesh | 只需 6 组对应点,概念清晰 |
| 手机/书本 | YOLOv8n ONNX | SSD MobileNet | 小目标召回率更高,导出方便 |
为什么强调“先做人脸存在性”?因为监考逻辑全部建立在人脸数量上:人脸数为 0,可能是离座;人脸数大于 1,可能是交头接耳;人脸数为 1,才继续判断闭眼和低头。如果人脸检测器本身不稳定,后面所有特征都会跟着抖,所以不要把精力一开始就投入到视线追踪上。
用 dlib 做检测的命令很小,真正影响效果的是参数upsample_num_times:
face_detector = dlib.get_frontal_face_detector() faces = face_detector(gray, 1)参数1表示人脸检测前对图像做一次金字塔上采样,能有效提升较远距离、较小人脸的召回,代价是单帧检测耗时增加约 10%~20%。现场摄像头离考生常有两三米,这个参数填 0 会导致后排人脸经常检测不到,这是新手最容易翻车的地方。
2.2 注意力判定的三个关键特征:人脸数、EAR 和欧拉角
我把“注意力”定义成三个可计算特征:
第一个是画面中的人脸数。dlib 返回的人脸框数量就是交头接耳和离座的判定来源。但要注意人脸检测有漏检,所以不能单帧判断,要配合时间窗口。
第二个是眼睛纵横比 EAR(Eye Aspect Ratio)。dlib 68 点中,右眼是索引 36 到 41,左眼是索引 42 到 47。EAR 的计算公式是上下眼睑距离之和除以眼睑宽度:
def eye_aspect_ratio(eye): # eye 是 6 个关键点,按 dlib 坐标顺序传入 A = np.linalg.norm(eye[1] - eye[5]) B = np.linalg.norm(eye[2] - eye[4]) C = np.linalg.norm(eye[0] - eye[3]) return (A + B) / (2.0 * C)EAR 的物理含义很好解释:人睁眼时上下眼睑距离大,EAR 在 0.3 左右;闭眼时上下眼睑距离趋近于 0,EAR 会掉到 0.15 以下。常见的默认阈值是 0.22,但这是正脸、正常光照下的经验值。戴眼镜、低头、侧脸都会让 EAR 变小,所以更可靠的做法是先录 30 秒正常状态,取正常 EAR 的均值再向下偏移 0.03 到 0.05,作为当前场景阈值。
第三个是头部姿态欧拉角。用 6 组 3D 人脸模型点和对应图像点做cv2.solvePnP,得到旋转向量,再转换成 pitch(低头)、yaw(左右转头)、roll(歪头)三个角度。对监考场景来说,最有价值的是低头角度,因为很多作弊动作都伴随头部明显下压。
| 特征 | 来源 | 默认阈值 | 判定含义 |
|---|---|---|---|
| EAR | 68 点中眼睑点计算 | 0.22 | 低于阈值视为闭眼 |
| pitch | solvePnP 输出 | 30 度 | 低头幅度过大 |
| yaw | solvePnP 输出 | 30 度 | 左右转头幅度过大 |
| 人脸数 | dlib detector | 大于 1 | 出现第二人 |
为什么不直接做真正的视线追踪?因为严格的视线追踪需要相机标定、屏幕坐标和眼球模型,现场监控摄像头的视角、距离一变,结果就不可复现。用“头部朝向 + 眼部闭合”作为注意力的代理特征,虽然不是百分之百精确,但足够支撑毕设答辩,也方便扩展。
2.3 异常事件的定义与置信度:别用单帧下结论
我在第一次实现时犯过这样一个错误:某一帧 EAR 小于阈值,就立刻弹窗告警。结果是系统因为考生正常眨眼而刷屏。后来我把告警规则改成“事件必须持续一段时间才触发”,误报立刻降下来。
监考事件不是单帧二分类问题,而是时间序列上的持续状态判断。我一般会定义四类事件:
- 人脸消失持续 2 秒以上:判定为离座。
- 人脸数大于 1 持续 1 秒以上:判定为交头接耳或他人靠近。
- 双眼 EAR 持续低于阈值 2 秒以上:判定为长时间闭眼。
- 低头或转头角度持续超过阈值 3 秒以上:判定为疑似非正常观看。
持续时间的设定取决于你想要“敏感”还是“保守”。敏感模式适合演示:动作出现 1 秒就提示;保守模式适合答辩时展示低误报:阈值调到 2 到 3 秒。这里的核心思想是置信度,而不是单帧条件。后面第 4 章会给出实际代码,用计数器代替复杂的滑窗,既能解释原理,又不会把代码写到让人看不完。
3. 把系统跑起来:环境搭建与核心模块实现
章节目标只有一个:让你在本地把最小可运行版本跑通。不要一开始就追求完整功能,先实现“读摄像头 → 检测人脸 → 算 EAR 和头部姿态 → 打印结果”,确认每一步没问题,再往上加告警和日志。这个顺序能帮你迅速定位问题,而不是等到所有代码写完再debug。
3.1 环境依赖与摄像头采集封装
依赖以轻量为准。需求文件里至少包含下面几样:
opencv-python dlib numpy如果系统没有安装 dlib,Windows 下建议直接pip install dlib,新版通常有预编译 wheel。如果编译报错,说明缺少 C/C++ 构建工具,优先检查 Visual Studio Build Tools 是否安装,而不是强行换 Python 版本。
项目目录建议这样组织:
smart_proctor/ ├── config.py ├── detector.py ├── monitor.py ├── logger.py ├── main.py └── shape_predictor_68_face_landmarks.dat配置项单独放一个文件,方便答辩时快速改阈值。我的习惯是把所有“可调参数”都放进 config.py,而不是散落在业务代码里:
# config.py CAMERA_ID = 0 FRAME_WIDTH = 640 FRAME_HEIGHT = 480 # 注意力阈值 CLOSED_THRESH = 0.22 HEAD_POSE_THRESH = 30.0 WINDOW_SECONDS = 3.0 MULTI_FACE_SECONDS = 1.0 NO_FACE_SECONDS = 2.0 ALERT_RATIO = 0.6摄像头采集封装成生成器是比较灵活的写法,我通常会这样写 main.py 的基础循环:
import cv2 import config cap = cv2.VideoCapture(config.CAMERA_ID) cap.set(cv2.CAP_PROP_FRAME_WIDTH, config.FRAME_WIDTH) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, config.FRAME_HEIGHT) if not cap.isOpened(): raise RuntimeError("摄像头打开失败,请检查设备索引号") while True: ok, frame = cap.read() if not ok: break # 后续检测代码放在这里 cv2.imshow("Smart Proctor", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()这里把摄像头分辨率固定为 640x480 是有意的。很多笔记本摄像头的 1080p 画面看起来清楚,但人脸检测时间随分辨率上升,监控场景下 640x480 已经足够,还能保证 25 帧以上的处理速度。如果你在答辩现场需要用外接摄像头,把CAMERA_ID改成 1 或 2 再试几次即可。
3.2 人脸检测与 EAR 计算
我把人脸检测和关键点定位写进同一个模块,返回人脸框和对应的 68 点坐标,方便后面统一使用:
# detector.py import cv2 import dlib import numpy as np face_detector = dlib.get_frontal_face_detector() landmark_predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") def detect_faces(frame): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) return face_detector(gray, 1) def get_landmarks(frame, face_rect): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) shape = landmark_predictor(gray, face_rect) return np.array([(shape.part(i).x, shape.part(i).y) for i in range(68)], dtype=np.float32) def eye_aspect_ratio(eye_points): A = np.linalg.norm(eye_points[1] - eye_points[5]) B = np.linalg.norm(eye_points[2] - eye_points[4]) C = np.linalg.norm(eye_points[0] - eye_points[3]) return (A + B) / (2.0 * C)dlib.shape_predictor返回的是各关键点的像素坐标,顺序是固定的。我在这里把 68 个点一次性转成 numpy 数组,后面取眼睛区域、画轮廓、算头部姿态都基于同一个数据结构,不容易出现索引错位。
EAR 函数里的eye_points[0]和eye_points[3]分别对应眼睑外侧点和内侧点,[1]、[2]是上眼睑点,[4]、[5]是下眼睑点。传入时顺序不能乱,否则算出的值没有任何意义。建议在 debug 阶段用 OpenCV 把 36 到 47 号点画出来,确认点和眼睛位置是对应的,再继续往下写告警逻辑。
3.3 头部姿态估计:solvePnP 与三个欧拉角
头部姿态估计的原理是用“已知 3D 人脸模型点”和“当前图像 2D 关键点”求相机外参。3D 模型点不需要真实人脸尺寸,只要相对坐标合理即可。我用的是 6 组标准点:鼻尖、下巴、左眼外角、右眼外角、嘴左角、嘴右角。
def get_head_pose(landmarks, frame_width, frame_height): image_points = np.array([ landmarks[30], # 鼻尖 landmarks[8], # 下巴 landmarks[36], # 左眼外角 landmarks[45], # 右眼外角 landmarks[48], # 嘴左角 landmarks[54], # 嘴右角 ], dtype="double") model_points = np.array([ (0.0, 0.0, 0.0), (0.0, -63.6, -12.5), (-43.3, 32.7, -26.0), (43.3, 32.7, -26.0), (-28.4, -63.6, -24.1), (28.4, -63.6, -24.1), ], dtype="double") camera_matrix = np.array([ [frame_width, 0, frame_width / 2], [0, frame_width, frame_height / 2], [0, 0, 1] ], dtype="double") dist_coeffs = np.zeros((4, 1)) _, rvec, _ = cv2.solvePnP(model_points, image_points, camera_matrix, dist_coeffs) rmat, _ = cv2.Rodrigues(rvec) rmat = rmat.T pitch, yaw, roll = cv2.RQDecomp3x3(rmat)[0] return pitch, yaw, roll代码里的camera_matrix是近似内参,焦距直接取画面宽度,主点取画面中心。这样算出的角度不是精确相机坐标系下的角度,但作为低头、转头判定足够。这里有两个经验点:一是rmat需要转置一次,否则角度方向和手头动作会相反;二是不同 OpenCV 版本的RQDecomp3x3输出顺序可能有差异,实际调试时打印三个角,自己左右摇头,看哪一路在变化最明显,就是 yaw。
我在实际项目里会避免只依赖pitch < 阈值,而是读绝对值后容忍一定方向误差。例如低头时 pitch 是负值,抬头时是正值,但正常写题的轻微低头也可能到 20 度,所以阈值不能设太低。
4. 判定逻辑与考场告警:如何把特征变成“作弊”事件
特征算出来后,真正决定系统体验的是告警逻辑。我的建议是:先确认每帧特征是否稳定,再写规则。一个常见错误是直接在 while 循环里堆 if 分支,最后代码混乱、阈值难调。这里我把逻辑分成两层:一层负责把单帧特征打包,另一层负责按时间累计出事件。
4.1 把单帧特征汇总成时间窗口
时间窗口的朴素实现是存一个最近 N 帧的特征历史,但更工程化的做法是用计数器。计数器在逻辑上等价于“连续满足条件多久”,实现起来更直观,参数也更好解释。我用一个统一函数处理所有事件的累计:
# monitor.py import time class EventCounter: def __init__(self, fps): self.fps = fps self.counters = {} def tick(self, event_name, condition, trigger_seconds): key = event_name if key not in self.counters: self.counters[key] = 0 if condition: self.counters[key] = min(self.counters[key] + 3, int(self.fps * trigger_seconds) + 10) else: self.counters[key] = max(0, self.counters[key] - 3) if self.counters[key] >= self.fps * trigger_seconds * config.ALERT_RATIO: self.counters[key] = 0 return True return False说三点:第一,条件满足时计数器加 3 而不是加 1,是为了让短暂抖动不易触发告警,同时让告警速度更快;第二,不满足时减 3 而不是清零,是为了避免偶发漏检一帧就重启整个计时;第三,触发条件乘以 0.6 的ALERT_RATIO,表示窗口内超过 60% 的帧异常才告警,这就是“置信度”的直观实现。
在主循环里,先把当前帧所有特征算出来:
face_rects = detector.detect_faces(frame) feature = { "face_count": len(face_rects), "ear_avg": 0.0, "pitch": 0.0, "yaw": 0.0, } if len(face_rects) == 1: landmarks = detector.get_landmarks(frame, face_rects[0]) left_ear = detector.eye_aspect_ratio(landmarks[42:48]) right_ear = detector.eye_aspect_ratio(landmarks[36:42]) feature["ear_avg"] = (left_ear + right_ear) / 2.0 pitch, yaw, _ = detector.get_head_pose(landmarks, config.FRAME_WIDTH, config.FRAME_HEIGHT) feature["pitch"] = abs(pitch) feature["yaw"] = abs(yaw)为什么要单独取abs()?因为低头可能是正偏差也可能是负偏差,取决于相机的安装高度。取绝对值后,阈值设定更通用,答辩时解释也更简单:“我们只关注角度偏离,不关注偏向哪一侧”。
4.2 基于连续帧投票的告警规则
基于前面打包好的 feature,事件定义就非常清晰:
counter = EventCounter(fps=30) alert_no_face = counter.tick( "no_face", feature["face_count"] == 0, config.NO_FACE_SECONDS ) alert_multi = counter.tick( "multi_face", feature["face_count"] > 1, config.MULTI_FACE_SECONDS ) alert_closed = counter.tick( "closed_eye", feature["face_count"] == 1 and feature["ear_avg"] < config.CLOSED_THRESH, config.WINDOW_SECONDS ) alert_head = counter.tick( "head_down", feature["face_count"] == 1 and max(feature["pitch"], feature["yaw"]) > config.HEAD_POSE_THRESH, config.WINDOW_SECONDS )这套规则的好处是,每个事件对应一个函数调用,参数都是可读的英文名,答辩时你可以直接对着这四行讲“系统是怎么判断离座、交头接耳、闭眼和低头”的。max(pitch, yaw)表示只要低头或转头任意一个方向偏离超过阈值,就认为头部姿态异常,而不是要求两个角度都超。
如果你想把系统调得更容易在演示时出效果,可以把WINDOW_SECONDS从 3.0 改成 1.5,这样低头动作出现 1 秒半就会告警。但要提醒一句:调低之后,正常整理卷子等动作也可能触发误报,至少要留 1 秒以上的持续时间,否则系统看起来像是在乱报。
4.3 告警联动:截图、CSV 日志和声音提醒
告警触发后只弹窗是不够的,毕设项目至少要有“留证”能力。我一般会做两个动作:保存当前帧截图,并把告警信息写入 CSV 日志。这样评委看演示时,能直接看到历史告警列表,而不是只看到实时画面。
# logger.py import csv import time import cv2 import os def save_alert(frame, event_name): os.makedirs("alerts", exist_ok=True) ts = time.strftime("%Y%m%d_%H%M%S") path = f"alerts/{ts}_{event_name}.jpg" cv2.imwrite(path, frame) with open("exam_alert.csv", "a", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow([time.strftime("%Y-%m-%d %H:%M:%S"), event_name, len(frame)])这里唯一的“坑”是 Windows 下打开 CSV 时如果没加encoding="utf-8",中文事件名会乱码。encoding="utf-8"写在日志模块里,比在 Excel 里反复转换编码要省事得多。
声音提醒可以用系统蜂鸣,Windows 下用winsound:
import sys import winsound def beep(): if sys.platform == "win32": winsound.Beep(880, 300)如果你在 Linux 或 macOS 上做答辩演示,winsound会报错,所以写成sys.platform == "win32"判断一下,避免现场翻车。完整告警时只beep()一次就好了,不要每帧都响,原因看下一章。
5. 智能监考系统避坑指南:最常见的 5 个刁钻问题
这里写的每一条都是实际调系统时容易卡住的地方,按“现象 → 原因 → 解决”的格式记录。你至少会遇到其中 3 条。
5.1 戴眼镜和刘海让 EAR 频繁误报
现象:有同学戴着黑框眼镜坐在镜头前,什么都没做,系统每隔几秒就弹一次“长时间闭眼”。还有留长刘海的考生,低头时眼睛被遮住,也触发闭眼告警。
原因:EAR 依赖眼睑上下 6 个点的距离。黑框眼镜会降低关键点检测精度,让上眼睑点被外推到镜框边缘;刘海遮住眼睛时,关键点直接被拉到头发上,EAR 值骤减。
解决:先在灰度图上做一次对比度增强,保留关键点的视觉纹理:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.equalizeHist(gray)然后把左右眼 EAR 取平均,而不是要求“左眼和右眼都低于阈值”。单眼被遮挡时,另一只眼还是正常值,均值不会瞬间掉到阈值以下。最后一定用本机摄像头先录 30 秒正常状态,算一个本地阈值,不要直接用网上常见的 0.22。
5.2 侧脸和大幅度抬头时关键点结构性丢失
现象:考生低头写题时,画面里人脸框断断续续,系统把人脸消失误判成“离座”,直接告警。或者考生抬头看屏幕时,68 点中的下巴点已经超出图像区域,头部姿态角抖得很厉害。
原因:dlib HOG 人脸检测器在正脸和大角度侧脸之间召回率波动很大。68 点模型对超出训练分布的大角度姿态会“外推”,外推出的坐标不是真实位置。
解决:给系统加一个“上一帧位置优先”的检测策略。如果当前帧检测不到新的人脸,就在上一个人脸框周围扩大 20% 的区域重新找一次,并且记录持续丢失的帧数,不要让告警逻辑立刻认为人脸消失了。
如果你觉得代码写起来太绕,可以引入 OpenCV 内置跟踪器:
tracker = cv2.TrackerKCF_create() tracker.init(frame, face_rects[0]) ok, box = tracker.update(frame)跟踪器的作用不是支持长时间跟踪,而是在两帧检测之间维持人脸框的连续性。当检测器连续多帧丢帧时,用跟踪结果顶替,避免离座误报。真正离座之后,跟踪器也会在几十帧内逐渐丢失,所以告警逻辑仍然要等到“连续无脸超过 2 秒”才触发。
5.3 逆光和暗光场景下检测概率呈现玄学
现象:靠窗的座位逆光,画面白茫茫;或者考场灯没开全,人脸黑成一片。同一个 dlib 模型,换一个光线位置,检测结果可能从 100 帧全中变成一半丢帧,完全没有任何规律可循,像玄学一样。
原因:光线问题本质上是图像纹理丢失。人脸检测依赖局部梯度特征,过曝或欠曝会让皮肤区域几乎没有梯度,HOG 描述子失效。
解决:在 feeding 之前加 CLAHE,限制对比度自适应直方图均衡化:
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray = clahe.apply(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY))clipLimit控制在 2.0 到 3.0 之间,太小等于没做,太大会放大噪声。建议保留这个模块,并把它作为“预处理参数”写进毕业论文里,是很受评委认可的工程细节。
5.4 CPU 占用高、画面掉帧到个位数
现象:用笔记本自带摄像头测,分辨率 1280x720,人脸检测加 68 点加 solvePnP 全跑下来,fps 只有 8 到 10,把打印日志加上后直接卡顿到 3 帧。
原因:dlib 的人脸检测器和关键点模型都是 CPU 密集计算,每帧全图检测会让 CPU 跑满。很多人没有控制摄像头分辨率,或者没有合理分配检测频率。
解决:第一,把采集分辨率统一降到 640x480,这是性价比最高的做法;第二,每两帧只做一次完整检测,中间一帧用上一帧结果;第三,如果画面里人数不超过 3 人,不需要每帧都对全图重新检测,可以用跟踪器维持人脸框。
if frame_id % 2 == 0: face_rects = detector.detect_faces(frame) else: face_rects = last_face_rects这个改动逻辑简单,不容易引入 bug,却能明显降低 CPU 占用。答辩时你还可以补一句“我们为了实时性,使用了隔帧检测与跟踪补帧的策略”,这就是一个加分表述。
5.5 多人同时入镜产生“交头接耳”重复告警
现象:监考老师从摄像机前走过,系统开始连续弹“交头接耳”,1 秒内弹了 10 次;考生旁边有人交卷路过,日志里瞬间多出几十条记录。
原因:告警触发后没有冷却机制。只要当前帧仍满足多人脸条件,条件每秒被判定多次,事件就会不断重新触发。系统没有区分“同一事件的持续状态”和“新发生的事件”。
解决:给每个事件增加冷却时间。告警触发后,在设定的冷却时间内不再重复触发:
last_alert_time = {} def need_cooldown(event_name, cooldown_seconds=5.0): now = time.time() if now - last_alert_time.get(event_name, 0) < cooldown_seconds: return False last_alert_time[event_name] = now return True对于“交头接耳”事件,冷却 5 秒比较合适:既有留存证据,又不会刷屏。对“离座”事件,我通常也设置 5 秒冷却,因为离座告警的本身目的是提醒监考老师,而不是记录每一次瞬间离开。
6. 让毕设从“能跑”到“能答辩”:验证方法、演示脚本和两个进阶方向
最后这一步是最容易拉开分数的地方。很多同学习惯把摄像头打开,对着镜头比划两下就算演示完了,但评委更看重的是“你怎么证明系统有效”。最可行的方法是准备一个固定回放视频,用代码控制和复现检测过程。
6.1 用录制视频建可复现测试集
把摄像头输入改成视频文件,只需要一行改动:
cap = cv2.VideoCapture("exam_sim.avi")这样答辩时不受现场光线和设备影响。我习惯录三段素材:正常答题 60 秒、低头看桌下 30 秒、离座 30 秒。跑完之后统计每一段的告警次数。正常段应接近 0 次告警,低头段应至少有 3 次告警,离座段应至少有 1 次。把这些数字写进论文的实验部分,比贴十张检测图更有说服力。
6.2 面向评委的演示脚本
演示不要直接进主界面,先给一条清晰的操作路径:
- 第一步:运行程序,展示摄像头实时画面,框出人脸并显示 EAR 和头部角度数值。
- 第二步:请一位同学正常坐在镜头前,系统保持绿色状态。
- 第三步:让同学低头保持 3 秒,系统弹出手写“低头告警”,并出现截图文件。
- 第四步:打开日志 CSV,显示刚才的告警记录和截图文件名。
这四步全程不到 2 分钟,却覆盖了采集、特征提取、规则判断、存储留证四个环节。答辩时不用解释全部代码,重点讲 EAR 的公式含义、solvePnP 的输入输出、ALERT_RATIO 是什么,基本就能把技术逻辑讲完。
6.3 可选进阶优化:YOLO 手机检测与行为热力图
如果评委员追问“为什么不管手机”,那就说明你还有扩展空间。我建议增加一条可选模块:用 YOLOv8n 检测手机。导出 ONNX 后用 OpenCV DNN 模块推理,每帧只检测画面中的小目标,置信度大于 0.4 且类别 ID 为“cell phone”时,叠加一条“手机检测”告警。
另一个能体现工作量的是“考场行为热力图”:把画面划分成座位网格,统计每个网格的低头发言次数、离座次数,按时间生成热力图。这个功能不需要新模型,只需在日志里记录人脸框中心点对应的网格位置,评审时非常直观。
我现在的习惯是,每次改完阈值都用固定的三分钟回放视频跑一遍,记录正常段误报数,再让 A 同学按要求演两遍违规动作。这个习惯让我在答辩前少翻了很多次车。系统不用做得多花哨,关键是每个告警都能讲清“为什么触发、依据什么参数、怎么调阈值”。希望帮到你。
本文还有配套的精品资源,点击获取