简介:成套的毕业设计行人识别检测系统源码,基于OpenCV与PyQt开发,核心场景是车辆行驶中自动探测车前行人,一旦有人进入行进路线立即触发警告,适用于计算机相关专业的学生作为毕业设计、课程设计或期末大作业,也适合希望上手深度学习视觉实战的开发者参考。压缩包共21个文件,大小约7.79MB,主要包含9个Python源文件、2个UI界面文件、1个中文字体文件以及使用说明、依赖清单和README等文档;其中Python代码负责行人检测与逻辑控制,UI文件对应可视化操作界面,文档辅助理解结构和启动方式。项目已通过调试、解压即可运行,目前已有214人学习下载。压缩包内除了入口脚本和核心检测模块,还配有说明文档、图标和背景图片,目录划分清晰;从环境依赖、界面交互到检测预警的完整链路都有对应文件支撑,便于读者按模块研读、替换模型或扩展功能,快速完成自己的毕业设计或课程项目。
1. 行人检测必须本地推理,整车控制链路耗不起一次云端往返
车载前置摄像头场景下的行人识别检测系统,最怕的不是模型精度不够,而是响应链路太长。一个行人从进入车前区域到出现在挡风玻璃前,留给系统的判断时间往往只有几百毫秒。把画面传云端、等推理结果、再回传告警,一次往返的网络抖动就足够让告警失去意义。所以这套基于深度学习 Opencv+PyQt 的行人识别检测系统源码,把检测推理全部放在本地完成:OpenCV 读取摄像头帧,PyQt 构建实时界面,threads 模块管理采集与检测的多线程调度。它适合两类人消化:一类是正在做计算机相关毕业设计、需要一套能跑通全链路的完整项目源码;另一类是刚接触目标检测落地、想搞清楚检测框到告警之间那层工程逻辑的开发者。run.py 是唯一入口,GUI、检测、线程、资源目录划分得很干净,顺着调用链往下拆,每一步都能对上号。
2. OpenCV 深度学习检测链路:detect 模块的模型加载与前向传播
2.1 模型选型:为什么走 OpenCV DNN 而不是 PyTorch 直接推理
打开项目的 requirements.txt 看一眼依赖,检测权重通常是 .weights 或 .onnx 格式,放在 detect 目录下。这套系统的核心推理走的是 OpenCV DNN 路线:用 cv2.dnn.readNetFromDarknet 或 readNetFromONNX 加载模型,通过 blobFromImage 做预处理,再 net.forward 拿到输出张量。源码里 detect 模块的 Detector 类就是这个逻辑的载体。
选这条路线而不是 PyTorch 直接推理,主要是部署成本。毕设机器上很可能没有 GPU,PyTorch 的 CPU 推理在 640 分辨率下跑一次 YOLOv5s 大约需要 100 到 200 毫秒,而同样的模型导出成 onnx 后用 OpenCV DNN 在 CPU 上能做到 60 到 120 毫秒,还不用装 torch 全家桶。对毕设来说,少一个装不上的依赖,就少一个答辩现场翻车的可能。另一个原因是 OpenCV DNN 天生为部署设计:setPreferableBackend 可以切 OpenCV 自带算子,也可以切 CUDA/OpenCL;权重文件和推理代码解耦,换模型不用动业务逻辑。这个项目把它作为默认推理后端,是典型的工程化取舍。
检测器的初始化部分值得逐行看:
class Detector: def __init__(self, cfg_path, weights_path, conf_thresh=0.4, iou_thresh=0.45): self.net = cv2.dnn.readNetFromDarknet(cfg_path, weights_path) # 没有 GPU 就锁 CPU,避免部分机器自动选后端失败 self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV) self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) self.conf_thresh = conf_thresh self.iou_thresh = iou_thresh self.output_layers = [ self.net.getLayerNames()[i[0] - 1] for i in self.net.getUnconnectedOutLayers() ]conf_thresh 是置信度阈值,低于它的检测框直接丢弃。0.4 意味着模型对行人遮挡、小目标的召回会打折扣;场景里行人大多是中近景时可以放松到 0.35。iou_thresh 是 NMS 的 IoU 阈值,0.45 是 YOLO 系列的惯例,行人密集时可以降到 0.4 减少重叠框。getUnconnectedOutLayers 拿到的是 YOLO 三个输出层的名字,forward 只跑这几层,不会把整张特征图全部算一遍。
注意一个版本差异:OpenCV 4.5.2 之前 getUnconnectedOutLayers 返回的是二维数组,索引要写[i[0] - 1];4.5.2 之后返回一维数组,直接[i - 1]。如果运行时报IndexError: invalid index to scalar variable,八成是这里的问题,改成新写法即可。
2.2 blobFromImage 预处理与输出张量解析
推理前的预处理核心是 cv2.dnn.blobFromImage。这一行决定了模型看到的输入长什么样:
blob = cv2.dnn.blobFromImage( frame, 1/255.0, (416, 416), swapRB=True, crop=False ) self.net.setInput(blob) outs = self.net.forward(self.output_layers)blobFromImage 的参数顺序容易记混。第一个参数是 BGR 帧;第二个 scale 是像素缩放,1/255 把 0 到 255 归一化到 0 到 1;第三个是模型要求的输入尺寸,源码里是 416x416。想提速可以降到 320x320,想提升小目标精度可以提到 608x608,但推理尺寸尽量贴近训练时的输入尺寸,跨太多模型泛化会受影响。swapRB=True 是因为 OpenCV 读进来是 BGR,训练时一般用 RGB;crop=False 表示等比缩放不裁剪,避免目标形变。
拿到 outs 之后,需要把 YOLO 输出解成坐标。每个输出张量的最后维度是 85,等于 4 个框坐标加 1 个 objectness 加 80 个类别分数。COCO 数据集里行人 ID 是 0,所以过滤条件写成 class_id == 0:
for out in outs: for detection in out[0]: scores = detection[5:] class_id = np.argmax(scores) if class_id != 0: continue confidence = scores[class_id] if confidence < self.conf_thresh: continue cx, cy, w, h = detection[:4] * np.array( [frame_w, frame_h, frame_w, frame_h] ) x1, y1 = int(cx - w / 2), int(cy - h / 2) boxes.append([x1, y1, int(w), int(h)]) confidences.append(float(confidence)) idxs = cv2.dnn.NMSBoxes(boxes, confidences, self.conf_thresh, self.iou_thresh)这里做了两件事:把归一化坐标乘回原图尺寸,再用 NMSBoxes 做非极大值抑制。detection[:4] 拿到的 cx、cy、w、h 是 0 到 1 的归一化值,必须乘上原图宽高。很多复现出错就是跳过这一步,直接用 416 尺寸画框,导致框的位置整体偏移。NMSBoxes 返回的是保留框的索引列表,后续画框和告警判定都以它为输入。检测耗时对整个采集循环影响最大,可以在 forward 前后各打一次 time.time(),如果单帧推理超过 200 毫秒,界面刷新会明显卡顿。
2.3 检测结果如何交给上层
Detector 类对外只暴露一个 detect 方法,输入原始 BGR 帧,输出过滤后的框列表和置信度列表,不关心界面和线程。上层不管是从视频文件读、从摄像头读,还是从 QThread 里调用,都只跟这个方法打交道。这个设计保证了 GUI 与检测逻辑解耦,是这套毕设源码里值得直接抄的部分。后续换模型,比如把 Darknet 权重换成 YOLOv5 导出的 onnx,只需要改 Detector 内部的加载函数和输出解析,调用方代码一概不动。
3. PyQt + QThread:摄像头采集、检测与界面刷新怎么协同
3.1 GUI 的文件结构与主窗口逻辑
gui 目录下是 PyQt 的主窗口和界面文件,入口由最外层的 run.py 统一拉起。主窗口是一个 QMainWindow,中间用 QLabel 作为视频画布,底部或侧边放开始、停止、选择视频源几个 QPushButton,状态栏显示帧率、检测耗时和告警信息。启动时先实例化 Detector,再创建采集线程和检测线程,界面本身不参与任何 cv2 调用,只负责显示和交互。
界面刷新的核心是 QLabel.setPixmap,把当前帧转成 QImage 再转 QPixmap 显示。这一步比较重,每帧都做高频刷新会把 Qt 事件循环拖慢。这套源码里线程之间用 signal/slot 传帧,界面收到帧后判断距上次刷新的时间差,超过 50 毫秒(约 20FPS)才真正更新画布,没到间隔的帧直接丢弃。
3.2 threads 模块:采集线程与检测线程的职责边界
线程划分的常见做法是两条线程:一条负责 VideoCapture.read(),另一条负责模型推理。为什么要拆开?因为摄像头 read 的阻塞时间不稳定,USB 摄像头在弱光下偶尔会卡几十毫秒;模型推理又是纯 CPU 密集。两条叠在一起,界面信号会被拖垮。分开之后,采集线程只管把新帧塞进队列,检测线程取一帧跑一次推理,互不阻塞。
采集线程的简化实现:
class CaptureThread(QThread): frame_captured = pyqtSignal(object) def __init__(self, video_source=0): super().__init__() self.cap = cv2.VideoCapture(video_source) self.running = True def run(self): while self.running: ret, frame = self.cap.read() if ret and frame is not None: self.frame_captured.emit(frame.copy()) time.sleep(0.03) # 约 30FPS 上限,防止队列被塞爆emit 出去的是 frame.copy(),这一步值得解释。VideoCapture.read() 内部可能复用缓冲,如果直接把原 frame emit 给检测线程,检测线程处理时缓冲区被下一帧覆盖,画面会出现撕裂或花屏。copy 是花钱买稳定,毕设场景下宁可多一次拷贝,也不要偶发诡异 bug。
检测线程接收 frame_captured 信号后调用 Detector.detect,再把标注好框的帧通过信号发回界面线程。Qt 的信号跨线程默认是队列连接,天然不会同时写一块内存。关键点在于:不要在检测线程里直接调用 QLabel 的更新方法,必须通过信号回到 GUI 线程,否则会报 "Cannot create children for a parent that is in a different thread"。
提示:检测线程里任何对 QWidget 的直接操作都会触发跨线程访问异常,统一通过 pyqtSignal 回到主线程处理,这是 PyQt 多线程界面的铁律。
3.3 信号槽传帧的裁剪时机与界面防抖
检测完成的帧通常先画框再发信号。给 GUI 的帧可以缩小,比如画布只有 960 宽,就不需要把 1920 的原图整个发过去。在检测线程里先frame = cv2.resize(frame, (960, 540))再 emit,信号传输的开销能省将近四分之三。numpy 数组传给信号需要包一层辅助函数:
def to_qimage(frame): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape bytes_per_line = ch * w return QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888)这里注意 rgb.data 在 PyQt 下返回的是 memoryview,转换成 QImage 后,必须保证原数组生命周期在 QPixmap 转换完成之前不被释放。常见坑是 QImage 和 QPixmap 每次转换都新建对象,导致内存碎片,运行半小时后变卡。解决方法是成员变量保存 QPixmap,尺寸没变就复用。界面收到帧后加时间闸:
def on_frame_ready(self, frame): now = time.time() if now - self._last_render < 0.05: return self._last_render = now qimg = to_qimage(frame) self.video_label.setPixmap(QPixmap.fromImage(qimg).scaled( self.video_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ))50 毫秒的时间闸把刷新率压在 20FPS 以下,配合检测线程满速跑,界面既不卡,检测结果也不至于因为渲染阻塞而丢帧。这个参数是经验值,如果机器性能好可以压到 0.03,人眼对流畅度的感知极限大概在 30FPS。
4. 告警逻辑与行进路线判定:检测框到“是否走入路线”的几何判断
4.1 如何定义“行人的行进路线”
摘要里说得清楚:探测车前行人,如果有人走入汽车的行进路线就发出警告。要落地成代码,先把“行进路线”翻译成几何区域。车辆直行时,路线是一个以车为原点向前延伸的梯形区域:近处宽、远处窄。放在图像坐标系里,就是梯形四个顶点组成的多边形 ROI。检测框的底边中点可以近似为行人的落脚点,这个点落在 ROI 内,就认为行人处于行车路线中。
这个简化为什么成立?车辆直线行驶时,地面平面与像平面的映射是单应关系,梯形比矩形更贴近真实的可通行空间。矩形会把路边行人误判进路线,梯形把两侧干扰排除了。源码里 ROI_POINTS 通常是init里的常量,不同摄像头安装角度需要重新标定。我调试时的做法是:对着平整路面,把画面里“左边路沿、右边路沿、近处车头下沿、远处视野消失点”四个位置用鼠标点出来,记到配置里。
4.2 点与 ROI 的包含判定及距离估算
OpenCV 自带包含判定函数 cv2.pointPolygonTest,不用自己写射线法。配合检测框底边中点,判定逻辑可以独立成一个函数:
def on_route(box, roi_points): x1, y1, w, h = box bottom_x = int(x1 + w / 2) bottom_y = int(y1 + h) # 底边近似为脚点 # measureDist=False 只返回正负,不计算距离 return cv2.pointPolygonTest( roi_points, (bottom_x, bottom_y), False ) >= 0box 是 NMS 之后保留的检测框,roi_points 是 numpy 的 int32 坐标数组,形状 (N, 2)。pointPolygonTest 第三个参数是 measureDist,True 返回带符号距离,表达“在边界内多深”;False 只返回 1(内)、0(边)、-1(外)。二值判定用 False 速度更快。多边形顶点要首尾相接,cv2.pointPolygonTest 不要求顺时针或逆时针,但要求是连续点集。
进一步估算距离是为了分级告警。只有“在不在 ROI 内”只能给布尔值,加上距离才能区分“前方 3 米有行人”和“前方 15 米有行人”。常见做法是在地面平坦假设下,把检测框底边 y 坐标线性映射到距离:
def estimate_distance(bottom_y, near_y=600, far_y=250, near_dist=1.0, far_dist=20.0): # 画面下方的行人近,画面上方的行人远 t = (bottom_y - far_y) / (near_y - far_y) t = np.clip(t, 0.0, 1.0) return near_dist + (far_dist - near_dist) * t这个线性映射不完全准确,因为透视关系本质非线性,但对毕设场景够用。想更精确,可以标定地面平面的单应矩阵 H,用np.dot(H, [x, y, 1])做透视逆变换再算距离。单应矩阵标定需要四个以上地面点,工程量大,如果论文没要求,线性映射完全撑得住答辩。距离值被后续告警策略用来分级。
4.3 告警策略:连续帧确认与分级触发
单帧命中不代表要立刻告警。检测模型偶尔跳变,一帧检出、下一帧丢掉,如果每次都触发,界面会闪烁不停。源码里的常见做法是连续 N 帧命中才发一次告警,同时用递减计数做遗忘:
class AlertManager: def __init__(self, confirm_frames=3): self.confirm_frames = confirm_frames self.hit_streak = 0 def update(self, is_hit): if is_hit: self.hit_streak += 1 else: self.hit_streak = max(0, self.hit_streak - 2) if self.hit_streak >= self.confirm_frames: self.hit_streak = 0 return True return False连续 3 帧确认,漏检一帧衰减 2,偶发漏检不会立刻清零,单帧误检也不会立刻触发。confirm_frames 按帧率调整:30FPS 时 3 帧约 100 毫秒,车辆 30km/h 行驶约走 0.8 米,还在反应时间范围内;拉流场景帧率只有 10,可以降到 2 帧。分级告警对应的动作:
| 告警级别 | 估算距离范围 | y 坐标参考 | 界面动作 |
|---|---|---|---|
| 不处理 | 大于 15m | bottom_y < 250 | 只画框 |
| 预警 | 8~15m | 250 ~ 420 | 状态栏黄色提示 |
| 告警 | 3~8m | 420 ~ 550 | 标签变红 + 蜂鸣 |
| 紧急 | 小于 3m | bottom_y > 550 | 暂停检测,强制弹窗 |
QMessageBox 在车机场景不能直接弹,模态框会阻塞整个 GUI,司机反而看不到画面。改成标签变红加系统蜂鸣更合理。这一点在答辩时可以讲成“从交互设计角度考虑实时告警的可用性”,比较加分。
4.4 告警的联动与线程回收
触发告警后检测线程不能停,行人走出路线后告警要能撤销。AlertManager 的状态变化通过 alert_state 信号发给界面,界面根据状态切换样式。测试时用项目提供的 images/1.jpg、2.jpg、3.jpg 三张静态图做单元验证,分别对应无行人、行人在 ROI 外、行人在 ROI 内三种情况,比对 on_route 输出是否符合预期。从 run.py 启动后先跑一段视频,再切摄像头,可以避免答辩现场摄像头初始化的意外。
5. 调参与排错技巧:帧率、置信度、字体与模型加载边界
5.1 帧率上不去先看这三个地方
单帧耗时超标的排查顺序是:先量化再优化。在检测线程的 run 里,对 read、detect、画框、emit 四段分别打时间戳,输出到日志,找到瓶颈再动手,不要凭感觉调。第一是模型输入尺寸,416x416 改 320x320,耗时大约降 30% 到 40%,代价是小目标漏检率上升;第二是置信度阈值,从 0.4 提到 0.5,误检下降但远处半身行人容易丢,适合行人密度不高的场景;第三是判定顺序,pointPolygonTest 非常快,但如果先做距离估算就浪费了。把 on_route 放最前面,不在 ROI 内的框直接跳过距离计算:
if not on_route(box, self.roi_points): continue distance = estimate_distance(box[1] + box[3])按这个顺序,大部分检测框在第一步就被过滤,距离估算只对少数目标执行,整体耗时能再压一截。
5.2 中文字体与依赖的两个老坑
源码带了一个 SimHei.ttf,这是为了避免 Linux 服务器上汉字乱码。PyQt 默认字体在部分 Linux 发行版上不包含中文字体,QLabel 显示“行人告警”会出现方框。正确做法是启动时加载字体文件,而不是依赖系统字体:
font_dir = "SimHei.ttf" font_id = QFontDatabase.addApplicationFont(font_dir) families = QFontDatabase.applicationFontFamilies(font_id) if families: QApplication.setFont(QFont(families[0], 10))依赖方面,requirements.txt 里如果是 opencv-python 而不是 opencv-contrib-python,部分旧教程的 xfeatures2d 不可用;本项目只用到 DNN 和图像基础操作,opencv-python 完全够用。遇到 ModuleNotFoundError 时优先检查安装的是不是 CPU 版,ARM 设备上需要 pip install opencv-python-headless 去 GUI 依赖,但 headless 没有 imshow 和 HighGUI,调试画框预览会失效,需要注意区分环境。
5.3 验证告警正确性的闭环方法
在项目根目录放一段测试脚本,依次加载 images/1.jpg、2.jpg、3.jpg,调用 Detector.detect,打印每张图的框数量和 on_route 结果。再把 AlertManager 接到模拟信号源上,连续发 True、False、True、True、True,确认第 5 次才输出告警,验证 confirm_frames 逻辑没写错。最后用视频文件而不是摄像头完整跑一遍 run.py,对比告警出现时机和行人实际位置的时间差。整套系统的检测、线程、告警三层链路在源码里是解耦的,排查时按 run.py → CaptureThread → Detector → AlertManager 的顺序依次打点,问题一定落在其中一个环节的边界上。
本文还有配套的精品资源,点击获取