基于OpenCV车辆检测的红绿灯控制系统:从HSV到状态机实战
简介使用Python语言与开源计算机视觉库构建的交通路口红绿灯控制系统完整毕业设计源码面向计算机、人工智能及相关专业的学生主要用于智能交通方向的课程设计、论文实验与创新实践。项目实现了车辆检测、颜色空间转换、形态学处理、红绿灯定时切换等功能并附带模拟路口脚本能够在没有真实摄像头的环境下完整跑通识别与控制流程。压缩包内共29个文件主要包含Python程序代码、HTML与CSS网页管理界面、XML工程配置文件、PNG与JPEG图像素材、README说明文档等体积仅有1.24MB轻量紧凑目录划分清晰适合直接导入开发环境学习与二次开发。目前已有170人学习浏览说明其在毕业设计选题和计算机视觉入门方面具备一定参考价值。通过研读该源码可以理解从视频帧捕获、车辆目标识别到红绿灯逻辑控制的完整链路还能进一步扩展Web端实时监控与数据库交互为今后研究智能交通系统提供扎实基础。1. 为什么毕业设计会把红绿灯控制做成OpenCV视觉项目第一次看到这个选题大多数人会以为要写 PLC 或 FPGA 的逻辑控制。实际上这套 Python 毕设源码把重点放在了「用摄像头感知车流」上——红绿灯的切换逻辑本身只有几十行真正占工作量的是 OpenCV 的车辆检测、画面里的模拟路口以及 Web 端的状态展示。这个方向很聪明控制系统的难点从来不是定时而是怎么知道哪个方向排了车、排了多少车。项目里video.py负责图像采集与处理main.py负责启动调度sql.py把每次灯色切换记录进数据库index.html和admin.html提供实时状态页面。适合三类读者一是想把计算机视觉算法落到一个完整系统里的毕业生二是想快速搭一套「带视频回放 数据存储」视觉 Demo 的工程师三是准备在树莓派或 Jetson 上做边缘端交通感知的人。它不是生产级的信号灯控制系统但把视觉检测、状态机、Web 可视化和数据持久化串了起来是一个能跑、能改、能答辩的完整案例。2. 车辆检测管线从 VideoCapture 捕获到 HSV 阈值与形态学去噪2.1 视频源选择与帧读取video.py的核心输入来自cv2.VideoCapture它既可以打开摄像头也可以读取本地视频文件。源码里的movies目录存放的就是模拟路口的视频素材这意味着没有实体摄像头也能运行整套流程。我一般会这样封装一个读取器import cv2 def get_capture(source0, video_pathNone): if video_path: cap cv2.VideoCapture(video_path) else: cap cv2.VideoCapture(source) # 0 表示默认摄像头 if not cap.isOpened(): raise IOError(无法打开视频源: {}.format(source if not video_path else video_path)) # 统一分辨率减少计算量 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) return capCAP_PROP_FRAME_WIDTH和CAP_PROP_FRAME_HEIGHT直接限制采集分辨率640×480 对车辆检测足够用还能把每帧处理时间压到可控范围。注意source0是摄像头索引号笔记本上一般为 0如果接入 USB 摄像头可能需要改成 1 或 2这就是最常见的一个坑。读视频时一定要检查isOpened()否则后面每帧都是None会立刻报错。2.2 从 BGR 到 HSV把光照问题和颜色分离OpenCV 默认读进来的图像是 BGR 通道但 BGR 对光照非常敏感——同一个红色车漆在阴影里可能变成深灰。所以毕业设计的常规做法是转成 HSV色相、饱和度、亮度这样色相基本不受亮度干扰只要设定好色相范围就能稳定分割目标颜色。转换和阈值过滤的代码长这样import cv2 import numpy as np def detect_vehicle(frame): hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) # 白色车身的HSV范围可以根据实际视频素材调整 lower np.array([0, 0, 180]) upper np.array([180, 30, 255]) mask cv2.inRange(hsv, lower, upper) return mask这里我选的是检测白色车辆lower/upper中第一维是 H0180第二维是 S饱和度第三维是 V亮度。白色物体的饱和度低所以 S 上限设到 30亮度 V 从 180 开始避免把路面亮斑也框进来。如果你用的视频素材里车辆是红色就把 H 放在 010 和 170180 两个区间因为红色在 HSV 圆环上跨越了 0 度两端inRange一次只能取一个区间要合并两个 mask 才能完整覆盖。2.3 形态学操作与轮廓面积统计裸的 mask 会出现大量噪点比如路面上反光的小块、护栏缝隙。这时用开运算先腐蚀后膨胀能去掉孤立噪点再用闭运算先膨胀后腐蚀填补车身上的空洞。cv2.morphologyEx配合cv2.getStructuringElement就能实现kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) cleaned cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) cleaned cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) area 0.0 for cnt in contours: if cv2.contourArea(cnt) 500: # 过滤小面积噪声 area cv2.contourArea(cnt)findContours的第二个参数用RETR_EXTERNAL只取最外层轮廓避免同一个车辆被内部纹理拆成多个轮廓。面积阈值 500 像素是经验值分辨率为 640×480 时它能把路沿、树叶等小目标滤掉。最终累加车辆面积area就是判断是否拥堵的关键指标后面章节的红绿灯状态机会直接拿它做输入。3. 红绿灯状态机定时切换、车流触发与模拟路口数据生成3.1 三色灯的定时状态机红绿灯控制逻辑可以用一个简单的状态机描述初始绿灯、倒计时结束转黄灯、再转红灯。毕设里的main.py通常实现为一个类每个周期只维护当前状态和剩余时间import time class TrafficController: GREEN green YELLOW yellow RED red def __init__(self, green_time20, yellow_time3, red_time20): self.durations {self.GREEN: green_time, self.YELLOW: yellow_time, self.RED: red_time} self.state self.GREEN self.last_change time.time() def update(self): now time.time() elapsed now - self.last_change if elapsed self.durations[self.state]: if self.state self.GREEN: self.state self.YELLOW elif self.state self.YELLOW: self.state self.RED else: self.state self.GREEN self.last_change now return self.stateupdate()是每帧调用的last_change记录上次切换的绝对时间用time.time()和elapsed判断是否到期。这种写法避免了多线程休眠导致的漂移也方便随时读取剩余时间给 Web 界面显示。核心参数都在构造器里绿灯 20 秒、黄灯 3 秒、红灯 20 秒这个比例适合双向四车道的模拟场景。3.2 车流触发检测面积如何干预信号周期纯定时切换不算智能这套系统真正有意思的地方在「车多就放行」。判断逻辑一般这样定义当检测到的车辆总面积超过阈值说明该方向拥堵就把当前方向的绿灯延长或者提前结束另一方向的绿灯。我建议按下述方式实现def decide(self, area_forward, area_crossing): area_threshold 8000 # 像素面积阈值 if area_forward area_threshold and self.state self.RED: # 当前方向红等但对面来车多则切换为绿 self.state self.GREEN self.last_change time.time() elif area_crossing area_threshold and self.state self.GREEN: self.state self.YELLOW self.last_change time.time()这个阈值和面积的对应关系放在了video.py的检测结果和main.py的决策函数之间。不同摄像头视角下同一辆车的像素面积差别很大所以最好在配置文件里预留一个AREA_THRESHOLD而不是写死。更稳妥的做法是把面积归一化area / (frame_width * frame_height)用占比 0.05 这类比值做判断这样换摄像头也不用重新调数字。3.3 模拟路口随机车辆与地面真值真实视频很难拿到连续、可控的堵车场景所以源码里提供了模拟方案。模拟路口文件夹或者movies里的视频应该就是程序生成的画面生成方式大致是在静态路口背景图上叠加矩形色块当作虚拟车辆然后按固定路径移动class SimTraffic: def __init__(self, width640, height480): self.width, self.height width, height self.cars [] self.tile 0 def step(self): # 每20帧生成一辆新车 self.tile 1 if self.tile % 20 0: x np.random.randint(50, 400) self.cars.append([x, 0, 40, 30]) # x, y, 宽, 高 for car in self.cars: car[1] 2 # 向下移动2像素/帧 self.cars [c for c in self.cars if c[1] self.height]模拟器的好处是你可以事先知道场景里到底有几辆车然后反过来验证检测算法算出的面积是否准确。代码里car[1] 2是每帧移动速度改成 3 或 4 就能模拟更快的车流。把背景做成黑色或深灰色白色矩形当车辆正好和前面 HSV 白色范围匹配检测面积与模拟车数呈线性关系方便写实验报告里的性能曲线。4. 控制系统的可视化与存储Flask/Web 端、SQLite 与视频回放4.1 Flask 后端与状态接口main.py除了跑检测循环还要启动一个 Web 服务。适合毕设的轻量方案是 Flask路由只有三四个首页、管理页、状态 API、视频流。状态接口直接返回当前灯色和剩余秒数from flask import Flask, jsonify, render_template from video import TrafficCamera from sql import save_log app Flask(__name__) camera TrafficCamera() controller TrafficController() app.route(/) def index(): return render_template(index.html) app.route(/api/status) def api_status(): state controller.update() return jsonify({state: state, elapsed: time.time() - controller.last_change}) if __name__ __main__: app.run(host0.0.0.0, port5000)host0.0.0.0表示监听所有网卡同一局域网里的手机或平板就能访问http://192.168.x.x:5000这在答辩演示时特别方便。jsonify返回的字段直接喂给 JS 刷新页面不需要刷新浏览器就能看到灯色变化。4.2 SQLite 日志表设计与切换记录sql.py文件负责把每次灯色切换写入 SQLite这样能复盘「哪一时刻、哪个方向、持续了多久」。表结构用CREATE TABLE一次建好CREATE TABLE IF NOT EXISTS traffic_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, state TEXT NOT NULL, area REAL NOT NULL, created_at TEXT DEFAULT (datetime(now, localtime)) );插入操作封装成函数在控制器切换灯色时调用import sqlite3 def save_log(state, area): conn sqlite3.connect(traffic.db) conn.execute(INSERT INTO traffic_log (state, area) VALUES (?, ?), (state, area)) conn.commit() conn.close()area字段记录的是切换那一刻的车辆面积这样你可以在实验报告里画出「面积 vs 灯色」的曲线证明是拥堵触发了绿灯而不会按规定时间硬切。SQLite 单文件、无需安装服务比 MySQL 更适合这种模拟项目但如果同时有多个浏览器页面刷新状态接口写库时要做好互斥毕设规模下commit一次的时间足够短不必加锁。4.3 MJPEG 视频流推送index.html里通常会有一个img标签显示实时视频。要给这个标签喂画面后端最省事的方式是 MJPEG 流也就是把每帧 JPEG 图片连续写进 HTTP 响应。核心代码在video.py或独立的stream.py里from flask import Response def generate_frames(): while True: ret, frame camera.read() if not ret: break # 在画面上叠加红绿灯状态文本 cv2.putText(frame, controller.state, (30, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) ret_code, buf cv2.imencode(.jpg, frame, [cv2.IMWRITE_JPEG_QUALITY, 80]) yield (b--frame\r\n bContent-Type: image/jpeg\r\n\r\n buf.tobytes() b\r\n) app.route(/video_feed) def video_feed(): return Response(generate_frames(), mimetypemultipart/x-mixed-replace; boundaryframe)浏览器会自动识别multipart/x-mixed-replace并不断刷新图片。IMWRITE_JPEG_QUALITY设为 80压缩率不错且画面清晰度足够用来观察车辆。要注意这个while True是阻塞的如果 Flask 用多线程模式跑一个持续请求会占住一个线程毕设里并发不大问题不大但别用 Flask 自带的调试模式在公网直接跑。4.4 前端状态刷新与管理员页面admin.html是管理界面常见做法是用setInterval每 500 毫秒拉一次状态接口setInterval(async () { const r await fetch(/api/status); const data await r.json(); document.getElementById(light).className data.state; document.getElementById(timer).innerText Math.floor(data.elapsed); }, 500);500ms的轮询间隔足够顺势显示也不至于给 Flask 和 SQLite 造成压力。若需要调绿灯时长可以让admin.html通过 POST 请求把新的green_time传给后端后端更新controller.durations。这里的参数传递必须做整数校验否则一个非法字符串会让整个状态机崩掉。5. 参数调节与排错检测阈值、帧率取舍和 OpenCV 运行环境坑5.1 用滑动条快速标定 HSV 阈值在真实场景下HSV 阈值是最容易翻车的环节。不要每次改参数都重启程序用 OpenCV 的createTrackbar做一个调参面板cv2.namedWindow(tuning) cv2.createTrackbar(H_min, tuning, 0, 180, lambda x: None) cv2.createTrackbar(H_max, tuning, 180, 180, lambda x: None) cv2.createTrackbar(V_min, tuning, 180, 255, lambda x: None) while True: _, frame camera.read() h_min cv2.getTrackbarPos(H_min, tuning) h_max cv2.getTrackbarPos(H_max, tuning) v_min cv2.getTrackbarPos(V_min, tuning) mask cv2.inRange(cv2.cvtColor(frame, cv2.COLOR_BGR2HSV), np.array([h_min, 0, v_min]), np.array([h_max, 30, 255]))拖动滑条能看到 mask 实时变化找出目标车辆最完整的范围后再把数值回填到代码里。记住 HSV 的 H 在 OpenCV 里是 0180不是美术里的 0360如果从别的教程复制 0255 的 H 范围检测效果会完全错乱。5.2 帧率与处理量的取舍实时车道检测没必要每帧都跑完整的轮廓提取。我一般会设置跳帧只用一半帧做检测另一半帧只推送显示画面。因为车辆运动是连续的相邻两帧差距很小把检测频率降到 1015 FPS 对决策影响几乎可以忽略但 CPU 占用能降一半。对应代码如下frame_count 0 while True: ret, frame cap.read() if frame_count % 2 0: mask detect_vehicle(frame) area calc_area(mask) controller.maybe_switch(area) frame_count 1frame_count % 2控制奇数帧检测、偶数帧跳过。如果红灯切换逻辑依赖面积持续超过阈值跳帧也不会错过因为车辆不会在 50 毫秒内消失。这个技巧对于在树莓派上跑 OpenCV 的部署非常关键。5.3 高频报错与对应解决报错/现象可能原因常见解决办法ModuleNotFoundError: No module named cv2未安装 opencv-pythonpip install opencv-python(-215:Assertion failed) size.width0读取的视频路径错误或帧为空检查movies路径确认cap.isOpened()检测面积几乎为 0HSV 阈值过低或视频素材颜色不匹配用滑动条重调阈值或换用背景减除视频流页面卡顿JPEG 质量过高或分辨率太大把IMWRITE_JPEG_QUALITY降到 70输入流缩到 480psqlite3.OperationalError: database is locked多线程同时写库改用连接池或每次操作后立即关闭连接opencv-python装的是 CPU 版跑 640×480 没问题如果你用 Jetson 设备官方源里有支持 CSI 摄像头的nvarguscamerasrc但参数格式和普通 USB 摄像头完全不同不能直接用VideoCapture(0)需要走 GStreamer 管道这一点在部署到嵌入式平台时要特别注意。5.4 一个直接可用的验证技巧最后分享一个我常用的验证方法在video.py里加一个show_statistics函数把当前帧的 mask、检测轮廓和面积数值同时显示出来并把关键帧保存到本地。答辩时把这些截图放进论文的测试章节比只贴代码有说服力得多def show_statistics(frame, mask, area, state): cv2.rectangle(frame, (0, 0), (250, 40), (0, 0, 0), -1) cv2.putText(frame, Area: {:.0f} State: {}.format(area, state), (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) cv2.imshow(result, frame) cv2.imshow(mask, mask)imshow的窗口标题要唯一重复创建同名窗口会导致内存占用不断上涨。运行一分钟后你可以用cv2.imwrite把包含面积数字的帧存下来作为「系统运行效果」素材。调通这套流程后替换真实摄像头或新的路口视频只需改VideoCapture的输入、重新标定 HSV 阈值整个红绿灯控制系统就能迁移到新场景这正是毕设里最值得写进总结的能力。本文还有配套的精品资源点击获取