简介:一套基于Python和OpenCV的交通路口红绿灯控制系统源码,面向计算机视觉初学者及需要实战项目的开发者,也可作为课程设计或毕业设计的参考。资源以完整工程形式组织,压缩包共三十四个文件,主体为py脚本,同时包含配置文件、网页前端页面、示例图片以及说明文档,整体大小约一点三五MB,便于快速下载与查阅。目前已有128人学习/下载,说明该资源在同类项目中具有较好的参考热度。源码重点演示了颜色空间转换、阈值处理、轮廓检测和实时视频流处理等常用视觉技术,并设计了从红绿灯状态识别到信号切换的控制逻辑,覆盖图像分析到决策输出的完整链路。此外,项目中还准备了依赖清单、使用前必读和说明文档,能够帮助使用者理解工程结构并顺利运行代码,也为后续接入硬件控制或机器学习分类模块留出扩展空间。
1. OpenCV交通路口红绿灯控制系统:这套源码的结构和你的预期可能不一样
拿到这套 Python 基于 OpenCV 的交通路口红绿灯控制系统源码时,我第一反应是又要接树莓派、点 GPIO 电平了。拆开压缩包才发现,它走的完全是另一条路:以 OpenCV 视觉识别为中轴的模拟控制系统。main.py 是整个程序的入口,video.py 负责从摄像头或者"模拟路口"目录里的视频素材逐帧读图,在 HSV 颜色空间里识别红灯和绿灯,随后由控制逻辑里的一组状态机驱动信号切换,最后通过 Flask 把当前路口状态推到 index.html 和 admin.html 上做实时展示。它真正解决的问题不是硬件接线,而是"怎么从视频帧里稳定判断灯色、怎么让识别结果驱动一套可用的调度逻辑"。这条路更适合做课程设计、毕业设计,或者想入门 OpenCV 图像处理项目、又不想一上来碰硬件的开发者。
2. 项目骨架与运行链路:从文件清单看清系统数据流
2.1 压缩包里到底有什么:先看文件再动手
拿到压缩包不要急着找代码跑,先把文件清单过一遍。这个包里的结构不算复杂,但有几个文件容易让人误解,我拆开时对着列表逐个确认过,它们的分工基本是这样的:
| 路径 | 类型 | 职责说明 |
|---|---|---|
| main.py | Python 脚本 | 系统入口,负责启动服务、调度控制线程 |
| video.py | Python 脚本 | 视频采集与 OpenCV 识别,输出当前灯色 |
| sql.py | Python 脚本 | SQLite 数据存取,记录信号切换日志 |
| index.html | 前端页面 | 路口实时状态展示页 |
| admin.html | 前端页面 | 后台管理、历史记录查看 |
| static/ | 目录 | 前端用到的 CSS、JS 等静态资源 |
| 模拟路口/ | 目录 | 测试用图片或视频序列,模拟路口场景 |
| requirements.txt | 文本 | Python 依赖清单 |
| README.md | 文档 | 项目说明 |
| 使用前必读.txt | 文档 | 前置事项 |
| web.pd | 文件 | 后缀不多见,多半是 web.py 的改名备份或说明文件,不影响主流程 |
这套系统的数据流是一条很清晰的单向链路:main.py 启动后,视频源(摄像头或模拟路口素材)交给 video.py 做逐帧识别,识别结果返回给主逻辑用于状态机切换,切换动作会和当前配时参数一起写入 sql.py 对应的数据库,前端 index.html 再通过接口把状态拉走展示。整个链路里,OpenCV 只负责"看",真正决定红绿灯切换的还是控制逻辑。
2.2 环境准备:requirements.txt 装依赖,这三类包是主力
先装环境再讲代码。一般来说 requirements.txt 里会固定 opencv-python、numpy、flask 这一组,刚好对应视觉处理、数值计算和 Web 展示三块。我自己在复现这类工程时习惯先建虚拟环境,避免把系统 Python 搞乱,尤其注意 Python 版本不要太高,3.8 到 3.10 之间和 opencv-python 的兼容性最稳,3.11 以上装老版本 opencv 偶尔会有 wheel 缺失的问题。
# 创建虚拟环境,注意 Python 版本控制在 3.8-3.10 python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate # 优先用国内镜像源安装,能避开大部分网络超时 pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple这段命令里,python -m venv 是创建隔离环境的标准做法;activate 切换进环境后再装依赖,包不会污染全局 Python。镜像源参数 -i 后面跟的是清华 PyPI 镜像,如果你在公司网络或者教育网环境下,用豆瓣源 https://pypi.douban.com/simple 效果也差不多。装完后用 pip list 确认一下 opencv-python 是否变成了 opencv_python 的包名,这个细节经常让人装完找不到模块。
安装完成后建议先跑一个最小验证,确认 OpenCV 能正常读到视频设备或者打开 test 图片,再进入下一步。很多新手在这一步翻车的原因并不是代码问题,而是环境中缺少 MSVC 运行库或者摄像头驱动异常,这些问题会在后面的避坑章节专门展开。
2.3 main.py 启动了什么:一个 Web 服务加一个后台控制循环
main.py 在我的复现里承担的是典型的"双线程"结构:主线跑 Flask 提供页面访问,后台线程跑信号灯控制循环。这里给出一个和原工程逻辑等价的最小骨架,方便你理解它启动后内部在做什么:
from flask import Flask, render_template import threading import video import sql app = Flask(__name__) current_state = {"light": "red", "crossing": "A", "timestamp": None} def control_loop(): """后台控制循环:识别灯色 -> 更新状态 -> 记录日志""" while True: detected = video.detect_current_light() # 返回 {'light': 'red'|'green'|'yellow'} if detected is not None: current_state.update(detected) sql.save_record(detected["light"], "A", 0) time.sleep(0.1) threading.Thread(target=control_loop, daemon=True).start() @app.route("/") def index(): return render_template("index.html", state=current_state) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)这段代码里最关键的设计是 daemon=True 的后台线程,主进程退出时线程自动结束,不会卡住端口。control_loop 每 0.1 秒调用一次 video.detect_current_light(),这个频率对 30 帧的视频流来说是安全的,不会因为处理不过来而积压帧。Flask 的 app.run 绑定 0.0.0.0 之后,同一局域网内其他机器也能访问到监控页面,做展示的时候很有用。
原工程在逻辑上比这个骨架多一层配时管理,识别结果会被喂给状态机而不是直接改灯色,这一点放到第 4 章详细说。你现在只需要记住:打开 main.py 相当于同时启动了"眼睛"(video.py)、"大脑"(控制逻辑)和"显示器"(Flask 页面)三部分。
3. HSV颜色空间与红灯识别:RGB到HSV这一步省掉,后面全是坑
3.1 为什么 OpenCV 识别红绿灯要用 HSV 而不是 RGB
RGB 颜色空间对人眼直观,但交给机器做颜色分类时问题很大:R、G、B 三个通道都受光照强度的直接影响,同一个红灯在白天强光下可能是(240, 60, 50),傍晚暗光下变成(120, 30, 30),像素值整体平移后,你写死的红色区间根本无法覆盖两种场景。HSV 把颜色拆成色调、饱和度、明度三个维度,其中 H(色调)基本与光线强弱解耦,所以"红色大约在哪个色调范围"这个判断在白天和晚上都成立。
OpenCV 实现的时候有一个特例要注意:它把 HSV 的 H 范围压到了 0 到 180,而不是常规的 0 到 360,也就是说你在别的教程里看到的红色阈值,直接搬过来要除以 2。红色的 H 值在 0 到 10 和 160 到 180 两个区段,因为色环上红色正好横跨 0 度的两端,只取一边就会漏掉一半红色像素。这是新手写红灯识别最常见的翻车点。
3.2 红灯绿灯的 HSV 阈值参考:一套能直接跑的参数
这里我给出一组在模拟路口素材上实测有效的阈值,配合中值滤波和形态学开运算,可以过滤掉大部分像素级噪声:
import cv2 import numpy as np cap = cv2.VideoCapture(0) # 摄像头编号,用视频文件则填路径 # 红色在HSV色环上分两段,必须合并 red_low_1 = np.array([0, 80, 60]) red_high_1 = np.array([10, 255, 255]) red_low_2 = np.array([160, 80, 60]) red_high_2 = np.array([180, 255, 255]) # 绿色集中在35-85,避开青色 green_low = np.array([35, 80, 60]) green_high = np.array([85, 255, 255]) while True: ok, frame = cap.read() if not ok: break hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask_red = cv2.inRange(hsv, red_low_1, red_high_1) | \ cv2.inRange(hsv, red_low_2, red_high_2) mask_green = cv2.inRange(hsv, green_low, green_high) # 中值滤波去孤立噪点,开运算断开细小连接 mask_red = cv2.medianBlur(mask_red, 5) kernel = np.ones((5, 5), np.uint8) mask_green = cv2.morphologyEx(mask_green, cv2.MORPH_OPEN, kernel)这套参数的选值是经过对比的。S 下限设 80 是为了过滤掉白色的灯罩和灰白色路面,那些物体色调不稳定但饱和度极低,很容易被 inRange 误收。V 下限设 60 是为了在夜间场景下保留暗处的红灯,同时排除纯黑区域。红区的 H 上限 10 和红色区段 160 到 180 之间留了 10 个单位的余量,避免两个区段之间衔接不上而出现空洞。
如果你发现白天识别正常、傍晚灯色区域大面积丢失,优先调 V 下限而不是 H 区间,这个经验在后面避坑章还会提到。mask 生成之后不要直接拿去 findContours,先肉眼看一下效果,cv2.imshow("mask", mask_red),白色区域就是当前被判定为红色的像素集合,这一步能帮你快速判断阈值偏宽还是偏窄。
3.3 从 mask 到"这是一个灯":轮廓面积、宽高比和填充率三重校验
拿到 mask 还不够,画面里红色物体可能很多:车尾灯、红色招牌、行人衣服。交通信号灯有自己的结构特征——它是竖排的圆形或矩形灯组,单颗灯在图像里的宽高比接近 1,而且灯内部颜色饱满,轮廓填充率高。利用这些先验知识能过滤掉大量假目标:
contours, _ = cv2.findContours( mask_red, cv2.RETR_EXTERNAL, # 只取最外层轮廓 cv2.CHAIN_APPROX_SIMPLE # 压缩轮廓点,减少计算量 ) for c in contours: area = cv2.contourArea(c) if area < 300: # 面积太小,多半是远处噪点或小灯珠 continue x, y, w, h = cv2.boundingRect(c) ratio = w / h fill_rate = area / (w * h) # 信号灯灯体接近圆形:宽高比0.3-1.2,填充率大于0.5 if 0.3 < ratio < 1.2 and fill_rate > 0.5: print(f"red light detected at ({x}, {y}), size=({w}, {h})")轮廓处理这里我用了 RETR_EXTERNAL 而不是 RETR_TREE,原因是信号灯是一个实心发光体,不需要内部层级结构,取最外层轮廓可以避免把灯体上的高光区域拆成多个小块。CHAIN_APPROX_SIMPLE 只保留端点,对后续 boundingRect 计算没有影响,但能明显降低轮廓点数量。
三个过滤条件的含义要理解透:面积阈值 300 是按 640x480 分辨率定的经验值,如果你把分辨率提到 1920x1080,这个数值要按比例放大到 2000 以上;宽高比过滤利用了红灯通常是单颗灯体这一事实,如果画面里出现的是横向排列的红灯组,需要改成检测多个轮廓的相对位置;填充率是区分实心灯体和空心圆环的关键,红色圆环标志的填充率往往不到 0.3,直接就被滤掉了。
到这里,"识别到红灯"只是一个坐标和轮廓信息,下一章要解决的是它如何参与信号灯切换。
4. 信号灯控制逻辑与Web展示:识别结果怎么变成一次状态切换
4.1 控制逻辑不是 if-else 堆出来的:状态机与配时表
很多新手把控制逻辑写成"当前是红灯就切绿灯,是绿灯就切黄灯",用一串 if-else 硬编码。这套源码里更合理的做法是引入状态机和配时表,明确每个状态的持续时长,以及状态之间的跳转关系。这里给出与常见实现一致的简化状态机:
class SignalState: RED = "red" GREEN = "green" YELLOW = "yellow" # 配时表:每个状态的持续时间,单位秒 DURATION = { SignalState.RED: 30, SignalState.GREEN: 40, SignalState.YELLOW: 5, } def next_state(current: str) -> str: """按固定次序切换:红 -> 绿 -> 黄 -> 红""" if current == SignalState.RED: return SignalState.GREEN elif current == SignalState.GREEN: return SignalState.YELLOW return SignalState.RED配时表把"状态持续多久"从代码里分离出来,改红绿灯时长只需要改 DURATION 字典,不需要动切换逻辑。next_state 函数里固定的跳转顺序保证了红灯和绿灯永远不会同时出现,这是路口安全的基本要求。实际工程里,黄灯 3 到 5 秒是常识,如果你在模拟路口素材里看到黄灯闪烁频率异常,优先检查这个字典的值有没有被误改。
更完整的实现会把上一章 video.py 的识别结果和状态机结合起来:只有连续 N 帧识别到同一种灯色才允许切换,避免单帧误判导致信号灯跳变。这个"连续 N 帧确认"机制我强烈建议保留,它是整个系统稳定性的基石。
4.2 sql.py 与数据记录:每次切换都留下痕迹
信号灯系统如果只做实时展示,顶多算一个好看的识别 demo。加上 sql.py 的数据记录之后,它才具备"可追溯、可统计"的能力。从文件命名可以看出它用的是 SQLite,连接本地数据库文件,无需额外部署数据库服务:
import sqlite3 def save_record(crossing: str, state: str, duration: int): """记录一次信号切换事件,含路口、灯色、持续时长""" conn = sqlite3.connect("traffic_light.db") conn.execute( """ INSERT INTO signal_log(crossing, state, duration, ts) VALUES(?, ?, ?, datetime('now')) """, (crossing, state, duration), ) conn.commit() conn.close()SQLite 的事务开销比 MySQL 小得多,控制线程每切换一次状态写一条记录,完全无压力。这里用参数化查询而不是字符串拼接,是为了防止 SQL 注入,虽然本地程序谈不上安全风险,但从一开始养成这个习惯没有坏处。datetime('now') 返回的是 UTC 时间,如果你发现日志时间比本地时间差 8 小时,不是代码 bug,是 SQLite 的时区设定如此,查询的时候改用 datetime('now','localtime') 即可。
admin.html 里的历史记录表大概率就是从这张表里读取的。sql.py 的作用是让"这个路口什么时候变过灯、每次亮了多久"变成可查的数据,做实验报告的时候,直接从数据库导出一段切换日志,比截图有说服力得多。
4.3 前端页面怎么显示实时状态:接口轮询是最稳妥的姿势
index.html 和 admin.html 在这个项目里不是静态装饰。index.html 展示路口模拟界面和当前灯色,admin.html 展示历史记录。两者都需要从后端拿数据,而 Flask 默认的模板渲染只能解决"打开页面时拿一次数据"的需求,之后后端状态变了前端不会自动更新。常见做法是前端用 setInterval 定时轮询后端接口:
async function refresh() { const resp = await fetch('/api/state'); const data = await resp.json(); document.getElementById('signal-light').className = data.light; } setInterval(refresh, 1000); // 每1秒拉一次最新状态轮询间隔设 1 秒,对信号灯这种秒级变化的状态来说足够及时,也不会给 Flask 服务造成压力。这里不用 WebSocket 有两个原因:一是信号灯变化频率低,WebSocket 的长连接优势体现不出来;二是轮询代码简单,浏览器兼容性零负担。如果你是新手,先跑通轮询再考虑 SSE 或 WebSocket 优化,不要一上来就上重武器。
fetch 返回的 JSON 里至少包含 light 和 crossing 两个字段,前者是当前灯色,后者是路口编号。前端拿到灯色后切换 CSS 类名,用样式让红黄绿三个圆形对应点亮,整个模拟路口页面就"活"了。
5. 实战避坑手册:交通灯识别从能跑到能用的五个坎
5.1 光线一变就误判:固定 HSV 阈值在傍晚全部失效
现象:上午调试好的红绿阈值,到下午五点左右开始疯狂误判,红灯识别框在红色招牌和红灯之间反复横跳,绿灯基本消失在背景里。
原因:日落时段环境色温变化剧烈,HSV 里的 H 虽然抗光照强度,但不抗色温偏移,夕阳的暖色光会把原本偏白或偏灰的区域染成淡红色,调好的 S 和 V 下限在这种环境下失去过滤作用。另一个因素是太阳角度变化让灯体表面产生镜面反光,反光区域的 S 值骤降,直接从阈值区间里漏出去。
解决:不要固定死一套阈值,我一般会在代码里加一个"时段加权":早中晚各保存一组 HSV 参数,检测前根据系统时间选择对应参数组;更进一步的做法是取画面整体亮度的中位数,动态调整 V 下限。如果你只是做课程设计,最简单的兜底方案是把识别结果做时间平滑,连续 5 帧判定为红灯才触发红灯状态,单帧误判会被吞掉。
5.2 VideoCapture 一直读取不到帧:摄像头设备号对不上
现象:代码运行后视频窗口黑屏,打印 cap.read() 的返回值一直是 (False, None),程序不报错但就是没有画面。
原因:VideoCapture(0) 里的 0 是按摄像头索引来的,笔记本自带摄像头通常占 0,但如果先接了一个 USB 摄像头,索引可能变成 1,0 变成无设备的空槽位。更隐蔽的情况是摄像头被其他软件(如微信、Zoom)占用,Windows 下摄像头设备被独占时 OpenCV 拿不到帧,但不抛异常,表现就是 read 持续返回 False。
解决:先写一个三行脚本遍历索引 0 到 3,逐个尝试打开并打印 isOpened 结果,确认设备号后再改代码。如果是设备被占用,关掉占用程序重新运行。视频文件路径作为输入时,记得把路径写成绝对路径,相对路径很容易因为工作目录不对而报错。
5.3 红灯和车尾灯分不清:颜色区域撞车
现象:识别结果里频繁出现"红灯",仔细看画面才发现检测框打在前方车辆的红色尾灯上,真正的交通信号灯亮绿灯时反而没识别到。
原因:交通灯红色和车尾灯在 HSV 空间里高度重叠,只用颜色阈值无法区分语义。两者的细微差别是位置和形状:交通灯在路口高处,车尾灯在低处且通常成对出现;交通灯是整颗圆形灯体,车尾灯是半透明的灯罩加内部高光点。
解决:加位置约束,只检测画面上半部分的红色区域;加帧间稳定性约束,交通灯位置每帧基本不动,车尾灯随车移动。如果你把识别区域裁剪到信号灯区域(ROI),这个问题会消失大半,这也是我在实际项目里一直推荐先做 ROI 裁剪而不是全图检测的原因。ROI 还可以用 2.3 节里轮廓校验里的填充率过滤来配合,半透明尾灯的填充率通常不达标。
5.4 画面处理一慢就卡:实时性崩在预处理顺序上
现象:视频播放像幻灯机,帧率掉到五帧以下,程序 CPU 占用接近 100%,画面延迟两三秒,识别结果完全跟不上路口节奏。
原因:在原始分辨率上直接做全图 inRange、medianBlur、findContours 三个操作,每帧的计算量非常大。尤其是 findContours,处理 1080p 全图时内存占用和耗时都会暴涨。另一常见原因是盲目加滤波,每帧做两次模糊、一次形态学开闭,预处理比识别还慢。
解决:把处理流程改成"缩小-ROI-滤波-检测"四步。先用 cv2.resize 把图像缩到 320 宽再进识别,或者直接按信号灯位置裁 ROI,把处理范围缩小到几百乘几百像素;medianBlur 的核从 5 降到 3,不过度平滑;findContours 只跑一次,不要对红绿两个 mask 分别跑再合并。这样改完,同一台机器上帧率能从五帧提到二十帧以上。记住一句话:预处理是给识别服务,不是给视觉美观服务。
5.5 opencv-python 装好却 import 报错:版本和 Python 版本冲突
现象:pip install opencv-python 显示安装成功,import cv2 时提示 ModuleNotFoundError,或者好不容易装好了,运行代码又报 numpy 版本不兼容。
原因:opencv-python 的 wheel 包对 Python 版本有严格的构建匹配,Python 3.11 以上安装某些旧版本 opencv 时,pip 会静默下载一个兼容性有问题的版本;另外如果之前装过 opencv-contrib-python 和 opencv-python,两个包混在一起会导致模块损坏。
解决:确认 pip 安装的是哪个包,用 pip uninstall 把 opencv-python 和 opencv-contrib-python 全部清掉,再单独装 opencv-python;固定版本号,opencv-python 4.8.1.78 在 Python 3.8-3.10 下兼容性较好。numpy 版本冲突时报错信息里会直接提示 numpy.core 找不到,这时按提示执行 pip install -U numpy 即可。环境问题一律建议走 2.2 节的虚拟环境方案,隔离干净,重装不伤全局。
6. 把参数调到能用的程度:一套验证与调参的实操习惯
6.1 三步验证法:静态图片、视频文件、摄像头实时流
我不会一上来就对着摄像头调参,那样混沌程度太高。第一步先截一张包含红灯和绿灯的画面存成 jpg,跑单帧检测,把 mask 用 imshow 显示出来,阈值宽了窄了一目了然。第二步把模拟路口目录里的素材作为视频文件输入,跑完整的一轮红绿切换,重点观察状态机的切换是否正确、误检频率高不高。第三步才切换摄像头实时流,因为实时流的噪声和抖动比录好的素材大,能通过前两步说明算法本身没问题,剩下的偏差主要来自光线和设备。
6.2 调参记录表:每次改动都留下对比数据
很多人调参靠感觉,今天调一下 S 下限觉得差不多,明天又调回来。我把每次改动的效果记录下来,形成一个最小对比表:
| 调整项 | 修改前 | 修改后 | 效果 |
|---|---|---|---|
| red S下限 | 80 | 100 | 车尾灯误检减少30% |
| red V下限 | 60 | 50 | 傍晚识别率提升 |
| 检测ROI | 全图 | 画面上半区 | 帧率从12提升到21 |
| 黄灯阈值新增 | 无 | H 15-35 | 黄灯识别可用 |
这张表的逻辑是:一次只改一个参数,记录修改前后的误检次数和帧率。不要同时调三四个参数,出了问题根本不知道是哪一步改坏的。这是我的血泪经验,早期我一次性把 H、S、V 全部按想象调整,结果白天正常晚上崩,排查花了两天,最后逐项回退才发现是 H 区间被移得太窄。从那以后我每次做这类识别工程,都强制走一遍"单变量对照调参"的流程,先把静态图跑通、再上视频、最后接实时流,每轮改动都落到一张表上。
希望这套拆解能让你少走我走过的弯路,照着这个流程把源码跑起来,再按自己的场景把阈值和控制逻辑调成自己的版本,祝你好运。
本文还有配套的精品资源,点击获取