
简介这是一套面向计算机相关专业毕业设计与课程设计场景的智能监考系统源码基于Python与OpenCV实现适合正在准备毕设、期末大作业或需要项目实战练习的学生参考。项目经导师指导并获评审99分代码完整可运行对新手较为友好。压缩包共353个文件约110.78MB以253个py源码文件为核心辅以pyd、dll、exe等运行依赖以及jpg、png、mp4等图像与视频素材另含pth、dat、cfg等模型与配置文件覆盖人脸检测、人脸识别等典型模块。已有123人学习关注。读者可据此获得一套结构完整的监考系统实现方案理解OpenCV图像处理、dlib人脸特征点与识别模型在实际项目中的调用方式并借助现成脚本与素材快速搭建环境、复现功能为毕设答辩与项目实战提供可参考的工程范例。1. 智能监考系统到底在监什么从一场机房考试翻车说起去年帮一个做培训的朋友处理过一件事他们机房组织线上结业考试30 台机器考到一半有学员把手机架在显示器旁边搜答案监考老师来回走动根本看不过来。事后调监控才发现两个小时的考试里至少有三个人在低头看手机。这件事之后他找我问能不能用 Python 加 OpenCV 做一套自动监考的东西哪怕只是把异常行为截下来事后人工复核也行。这就是「基于 PythonOpenCV 的智能监考系统」要解决的真实问题。它不追求科幻电影里那种全知全能的 AI 考官而是用摄像头采集画面通过人脸检测、头部姿态估计、多目标跟踪这些 OpenCV 能落地的能力判断考生有没有长时间低头、有没有第二个人出现在画面里、有没有离开座位。适合谁做计算机相关专业的毕业设计尤其是想拿高分、又不想纯堆 CRUD 的同学——这个题目有算法、有工程、有可视化界面答辩时能讲的东西比一个管理系统多得多。但我要先把预期说清楚这套系统的核心不是「抓作弊」而是「降低人工监考成本」。它一定会误报光线一变、人一歪头就可能触发告警。所以真正能用的方案是把告警做成事件流让人去复核而不是直接判定。下面我按自己实际搭过一遍的路径把选型、代码、参数和踩过的坑讲清楚。2. 技术选型与最小可跑环境为什么是 OpenCV 而不是深度学习全家桶2.1 毕设场景下 OpenCV 方案的真实优势很多同学一上来就想上 YOLOv8 做行为识别觉得模型越新越有面子。我一般会先问一句你的机器有独立显卡吗答辩现场能联网装依赖吗如果答案都是否那 OpenCV 加传统视觉方法反而是更稳的选择。OpenCV 自带的人脸检测器Haar 级联和 DNN 模块在 CPU 上就能跑到 20 帧以上Dlib 的 68 点关键点模型虽然慢一点但单帧 30 毫秒左右也能接受。相比之下一个未经优化的 YOLO 模型在纯 CPU 上跑 640 分辨率单帧可能要 200 毫秒以上30 路视频根本扛不住。毕设的评分点在于「完整可演示」不是「模型 SOTA」用 OpenCV 把检测、跟踪、告警、界面串起来比调一个跑不动的模型得分高得多。另一个现实原因是依赖安装。OpenCV 用 pip 一条命令就能装Dlib 稍微麻烦点但 Windows 下也有预编译包。而深度学习框架动辄要配 CUDA、cuDNN版本对不上就是ModuleNotFoundError或者ImportError: DLL load failed答辩前一天环境崩了血泪经验。2.2 环境搭建Python 版本与 OpenCV 安装的坑先说 Python 版本。我建议用 3.8 到 3.10不要用最新的 3.12。原因是 Dlib 和部分 OpenCV 扩展包对 3.12 的 wheel 支持还不全装的时候容易卡在编译。用 conda 或者 venv 建一个干净环境别在系统 Python 里直接装。# 创建虚拟环境指定 Python 3.9 python -m venv exam_env # Windows 激活 exam_env\Scripts\activate # Linux/Mac 激活 source exam_env/bin/activate # 安装核心依赖指定版本避免兼容问题 pip install opencv-python4.8.1.78 pip install opencv-contrib-python4.8.1.78 pip install dlib19.24.2 pip install imutils0.5.4 pip install numpy1.24.3 pip install PyQt55.15.9这里有几个参数要说明。opencv-python和opencv-contrib-python不要同时装两个不同版本否则会出现函数找不到的玄学问题。dlib在 Windows 上如果 pip 装不上去下载对应的.whl文件本地安装搜「dlib whl 19.24」就能找到。numpy锁 1.24 是因为 1.25 之后部分 OpenCV 版本会报RuntimeError: module compiled against API version。装完验证一下import cv2 import dlib print(cv2.__version__) # 应该输出 4.8.1 print(dlib.__version__) # 应该输出 19.24.2 # 测试摄像头能否打开 cap cv2.VideoCapture(0) if not cap.isOpened(): print(摄像头打开失败检查设备索引或驱动) else: ret, frame cap.read() print(读取成功画面尺寸:, frame.shape) cap.release()VideoCapture(0)里的 0 是设备索引笔记本自带摄像头一般是 0外接 USB 摄像头可能是 1 或 2。如果打开失败先换索引试再检查是不是被其他程序占用了。这段代码跑通环境就算立住了。3. 核心检测模块人脸、关键点与头部姿态怎么串起来3.1 用 Dlib 做 68 点关键点检测与姿态估计智能监考最核心的判断是「考生有没有看屏幕」。这件事可以拆成两步先找到人脸再根据关键点算头部朝向。Dlib 的 68 点模型把脸部拆成下巴、眉毛、鼻子、眼睛、嘴巴几个区域其中鼻子和下巴的点可以用来估算偏航角和俯仰角。import cv2 import dlib import numpy as np # 初始化检测器和关键点预测器 detector dlib.get_frontal_face_detector() predictor dlib.shape_predictor(shape_predictor_68_face_landmarks.dat) # 3D 参考模型点对应 68 点中的鼻尖、下巴、左右眼角、左右嘴角 model_points np.array([ (0.0, 0.0, 0.0), # 鼻尖 (0.0, -330.0, -65.0), # 下巴 (-225.0, 170.0, -135.0), # 左眼左角 (225.0, 170.0, -135.0), # 右眼右角 (-150.0, -150.0, -125.0), # 左嘴角 (150.0, -150.0, -125.0) # 右嘴角 ], dtypenp.float64) def get_head_pose(shape, frame): # 从 68 点中取出对应的 2D 点 image_points np.array([ (shape.part(30).x, shape.part(30).y), # 鼻尖 (shape.part(8).x, shape.part(8).y), # 下巴 (shape.part(36).x, shape.part(36).y), # 左眼左角 (shape.part(45).x, shape.part(45).y), # 右眼右角 (shape.part(48).x, shape.part(48).y), # 左嘴角 (shape.part(54).x, shape.part(54).y) # 右嘴角 ], dtypenp.float64) h, w frame.shape[:2] 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] ], dtypenp.float64) dist_coeffs np.zeros((4, 1)) success, rotation_vector, translation_vector cv2.solvePnP( model_points, image_points, camera_matrix, dist_coeffs, flagscv2.SOLVEPNP_ITERATIVE ) if not success: return None, None, None # 把旋转向量转成欧拉角 rmat, _ cv2.Rodrigues(rotation_vector) angles, _, _, _, _, _ cv2.RQDecomp3x3(rmat) pitch, yaw, roll angles[0], angles[1], angles[2] return pitch, yaw, roll这段代码的逻辑是把 2D 人脸关键点和 3D 标准人脸模型做匹配用solvePnP解出旋转向量再转成俯仰、偏航、翻滚三个角度。model_points里的数值是通用人脸比例不用改。focal_length用画面宽度近似对普通摄像头够用。判断低头主要看 pitch一般 pitch 小于 -15 度就认为在低头偏航看 yaw绝对值大于 25 度认为在看侧面。3.2 多目标跟踪用质心跟踪器管理多个考生一个考场画面里可能有好几个人光检测不够还要知道谁是谁。OpenCV 没有内置的多目标跟踪器但可以用centroidtracker这种轻量方案原理是计算每帧检测框的质心和上一帧的质心做距离匹配。from scipy.spatial import distance as dist from collections import OrderedDict class CentroidTracker: def __init__(self, max_disappeared30): self.next_object_id 0 self.objects OrderedDict() # id - 质心 self.disappeared OrderedDict() # id - 消失帧数 self.max_disappeared max_disappeared def register(self, centroid): self.objects[self.next_object_id] centroid self.disappeared[self.next_object_id] 0 self.next_object_id 1 def deregister(self, object_id): del self.objects[object_id] del self.disappeared[object_id] def update(self, rects): if len(rects) 0: for object_id in list(self.disappeared.keys()): self.disappeared[object_id] 1 if self.disappeared[object_id] self.max_disappeared: self.deregister(object_id) return self.objects input_centroids np.zeros((len(rects), 2), dtypeint) for i, (startX, startY, endX, endY) in enumerate(rects): cX int((startX endX) / 2.0) cY int((startY endY) / 2.0) input_centroids[i] (cX, cY) if len(self.objects) 0: for i in range(len(input_centroids)): self.register(input_centroids[i]) else: object_ids list(self.objects.keys()) object_centroids list(self.objects.values()) D dist.cdist(np.array(object_centroids), input_centroids) rows D.min(axis1).argsort() cols D.argmin(axis1)[rows] used_rows, used_cols set(), set() for (row, col) in zip(rows, cols): if row in used_rows or col in used_cols: continue object_id object_ids[row] self.objects[object_id] input_centroids[col] self.disappeared[object_id] 0 used_rows.add(row) used_cols.add(col) unused_rows set(range(D.shape[0])) - used_rows unused_cols set(range(D.shape[1])) - used_cols for row in unused_rows: object_id object_ids[row] self.disappeared[object_id] 1 if self.disappeared[object_id] self.max_disappeared: self.deregister(object_id) for col in unused_cols: self.register(input_centroids[col]) return self.objectsmax_disappeared是允许目标消失的帧数设 30 意味着目标消失一秒左右按 30 帧算才注销。这个值太小会导致人一低头检测不到就换 ID太大会导致人走了还留着幽灵框。实际调试时如果发现 ID 频繁跳变先把这个值调到 50 试试。质心跟踪的局限是目标交叉时会串 ID但考场里大家坐着不动这个问题不突出。4. 告警逻辑与界面集成把检测结果变成可复核的事件4.1 行为判定规则与阈值设置检测出姿态和跟踪 ID 之后要定义什么算异常。我一般设三条规则低头超过 3 秒、偏航超过 30 度持续 2 秒、画面中出现新的人脸且持续 5 秒。用帧计数来实现持续时间比用时间戳简单。# 每个跟踪 ID 维护一个状态字典 track_states {} def check_behavior(object_id, pitch, yaw, face_count): if object_id not in track_states: track_states[object_id] { low_head_frames: 0, side_face_frames: 0, alert: False } state track_states[object_id] # 低头判定 if pitch is not None and pitch -15: state[low_head_frames] 1 else: state[low_head_frames] 0 # 侧脸判定 if yaw is not None and abs(yaw) 30: state[side_face_frames] 1 else: state[side_face_frames] 0 # 触发告警30 帧约等于 1 秒 if state[low_head_frames] 90: state[alert] True return 低头超过3秒 if state[side_face_frames] 60: state[alert] True return 偏头超过2秒 if face_count 1: return 画面出现多人 return None阈值不是拍脑袋定的。低头 15 度这个值是我让几个同学坐在摄像头前正常看屏幕、再故意低头反复测出来的分界。偏航 30 度同理。如果你的摄像头装得比较高或者比较低这两个值要重新标定。标定方法很简单写个脚本实时打印 pitch 和 yaw让人做正常动作和异常动作看数值分布。4.2 PyQt5 界面实时画面加告警列表毕设要演示界面不能少。用 PyQt5 做一个左右分栏左边显示摄像头画面右边滚动显示告警记录带时间戳和截图缩略图。from PyQt5.QtWidgets import (QApplication, QMainWindow, QLabel, QVBoxLayout, QHBoxLayout, QWidget, QListWidget) from PyQt5.QtGui import QImage, QPixmap from PyQt5.QtCore import QTimer, Qt import cv2 import sys class MonitorWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(智能监考系统) self.resize(1200, 700) central QWidget() self.setCentralWidget(central) layout QHBoxLayout(central) self.video_label QLabel() self.video_label.setFixedSize(800, 600) layout.addWidget(self.video_label) self.alert_list QListWidget() self.alert_list.setFixedWidth(350) layout.addWidget(self.alert_list) self.cap cv2.VideoCapture(0) self.timer QTimer() self.timer.timeout.connect(self.update_frame) self.timer.start(33) # 约 30 帧 def update_frame(self): ret, frame self.cap.read() if not ret: return # 这里调用检测逻辑frame 上画框 # ... 省略检测代码假设返回了标注后的 frame 和告警文本 rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch rgb.shape qt_img QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888) self.video_label.setPixmap(QPixmap.fromImage(qt_img)) def add_alert(self, text): self.alert_list.addItem(text) def closeEvent(self, event): self.cap.release() event.accept() if __name__ __main__: app QApplication(sys.argv) win MonitorWindow() win.show() sys.exit(app.exec_())timer.start(33)控制刷新间隔33 毫秒约等于 30 帧。如果检测逻辑耗时超过 33 毫秒界面会卡这时候要么降分辨率要么把检测放到单独线程。毕设演示用 640x480 分辨率就够了别用 1080pCPU 扛不住。告警列表用addItem追加记得限制最大条数不然跑一晚上内存会涨。5. 避坑与排查那些让系统看起来像坏了的瞬间5.1 摄像头打开成功但读帧返回 False现象是cap.isOpened()返回 True但cap.read()一直返回 False。原因通常是摄像头被其他程序占用或者 USB 带宽不够。解决方法是先关掉其他用摄像头的软件换一个 USB 接口或者在VideoCapture后面加cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)降低分辨率。如果还不行换cv2.CAP_DSHOW后端cv2.VideoCapture(0, cv2.CAP_DSHOW)Windows 下这个后端更稳。5.2 Dlib 关键点检测在侧脸时直接失效现象是人一转头68 点检测就返回空或者点全挤在一起。原因是 Dlib 的模型是基于正脸训练的侧脸超过 45 度基本就废了。解决方法是不要依赖单帧用跟踪器维持 ID侧脸时用上一帧的姿态做插值同时把侧脸本身作为告警条件。另外可以在检测前做直方图均衡化cv2.equalizeHist对灰度图处理一下能提升暗光下的检测率。5.3 多线程下界面卡死现象是检测跑起来后PyQt 界面点不动告警列表不刷新。原因是检测逻辑在主线程里跑阻塞了 Qt 的事件循环。解决办法是把检测放到QThread里通过信号槽把结果传回主线程更新界面。如果不想改架构至少把timer间隔调大比如 100 毫秒牺牲流畅度换响应。5.4 打包成 exe 后 OpenCV 报错现象是pyinstaller打包后运行提示ImportError: No module named cv2或者缺少shape_predictor_68_face_landmarks.dat。原因是 PyInstaller 不会自动打包数据文件。解决办法是在 spec 文件里加datas[(shape_predictor_68_face_landmarks.dat, .)]并且用--hidden-importcv2显式导入。打包前先在干净环境里跑一遍确认没有多余依赖。5.5 光线变化导致误报暴增现象是下午阳光斜射进机房告警列表刷屏。原因是 Haar 检测器对光照敏感人脸框忽大忽小姿态角度也跟着跳。解决办法是固定光源拉窗帘或者在检测前做cv2.createCLAHE自适应直方图均衡。如果还不行在告警逻辑里加一个「连续 N 帧都异常才触发」的防抖N 设 15 到 30 之间。6. 进阶技巧用 DCT 盲水印给告警截图加防篡改标记告警截图存下来之后有个现实问题怎么证明这张图没被改过我在实际项目里用了一个轻量方案——DCT 中频盲水印。原理是把图片分块做离散余弦变换把水印信息嵌入到中频系数里人眼看不出来但提取时能验证。OpenCV 没有现成函数得手写。import cv2 import numpy as np def embed_watermark(image_path, watermark_bits, alpha25): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (512, 512)) img_float np.float32(img) h, w img_float.shape block_size 8 bit_index 0 for i in range(0, h, block_size): for j in range(0, w, block_size): if bit_index len(watermark_bits): break block img_float[i:iblock_size, j:jblock_size] dct_block cv2.dct(block) # 修改中频系数 (3,2) 位置 if watermark_bits[bit_index] 1: dct_block[3, 2] alpha else: dct_block[3, 2] - alpha idct_block cv2.idct(dct_block) img_float[i:iblock_size, j:jblock_size] idct_block bit_index 1 result np.uint8(np.clip(img_float, 0, 255)) cv2.imwrite(watermarked.png, result) return result def extract_watermark(image_path, num_bits, alpha25): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (512, 512)) img_float np.float32(img) h, w img_float.shape block_size 8 bits [] bit_index 0 for i in range(0, h, block_size): for j in range(0, w, block_size): if bit_index num_bits: break block img_float[i:iblock_size, j:jblock_size] dct_block cv2.dct(block) if dct_block[3, 2] 0: bits.append(1) else: bits.append(0) bit_index 1 return bitsalpha是水印强度25 是我试出来在肉眼不可见和抗压缩之间的平衡点。太小了 JPEG 一压就丢太大了图片上能看到方块噪点。嵌入位置选(3,2)是中频低频改了对画质影响大高频改了容易被压缩掉。提取时不需要原图直接读 DCT 系数符号就行。这个方案不能抗裁剪但毕设场景够用——截图存证能验证就行。最后说个习惯我每次调完阈值都会把当天的测试视频存下来第二天换个时间段再跑一遍。因为光线和人穿的衣服颜色都会影响检测早上调好的参数下午可能就飘了。智能监考这东西没有一劳永逸的参数只有不断标定的耐心。希望帮到你。本文还有配套的精品资源点击获取