OpenCV交通路口红绿灯控制系统:HSV识别与状态机实战解析
发布时间:2026/9/28 13:14:55来源:尧图网络
简介一套基于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.pyPython 脚本系统入口负责启动服务、调度控制线程video.pyPython 脚本视频采集与 OpenCV 识别输出当前灯色sql.pyPython 脚本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(targetcontrol_loop, daemonTrue).start() app.route(/) def index(): return render_template(index.html, statecurrent_state) if __name__ __main__: app.run(host0.0.0.0, port5000)这段代码里最关键的设计是 daemonTrue 的后台线程主进程退出时线程自动结束不会卡住端口。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 而不是 RGBRGB 颜色空间对人眼直观但交给机器做颜色分类时问题很大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(fred 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 摄像头索引可能变成 10 变成无设备的空槽位。更隐蔽的情况是摄像头被其他软件如微信、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下限80100车尾灯误检减少30%red V下限6050傍晚识别率提升检测ROI全图画面上半区帧率从12提升到21黄灯阈值新增无H 15-35黄灯识别可用这张表的逻辑是一次只改一个参数记录修改前后的误检次数和帧率。不要同时调三四个参数出了问题根本不知道是哪一步改坏的。这是我的血泪经验早期我一次性把 H、S、V 全部按想象调整结果白天正常晚上崩排查花了两天最后逐项回退才发现是 H 区间被移得太窄。从那以后我每次做这类识别工程都强制走一遍单变量对照调参的流程先把静态图跑通、再上视频、最后接实时流每轮改动都落到一张表上。希望这套拆解能让你少走我走过的弯路照着这个流程把源码跑起来再按自己的场景把阈值和控制逻辑调成自己的版本祝你好运。本文还有配套的精品资源点击获取
网站建设高端定制企业官网