城市里修路越来越难,路口改造的边际成本实在太高,真正见效快的反而是把已有的信号灯“用聪明”。在智慧城市的大背景下,交通信号灯优化是个看起来不复杂、做起来却很容易翻车的场景。我最近用 Python 加 OpenCV 做了一套智能交通信号灯优化系统原型,不靠昂贵的雷达和地磁线圈,只靠普通监控摄像头,就把车流量、排队长度和绿灯需求算清楚了,还能实时调整信号配时。这篇文章就聊聊我踩过的坑、调过的参数和整套系统的实现思路。
这套系统解决的问题很具体:一个固定配时的路口,早晚高峰经常出现“一边空放、一边堵死”的尴尬。传统感应控制需要埋设线圈或安装微波雷达,改造成本高,维护也麻烦。用摄像头做视觉感知,再叠加一套简洁的配时决策逻辑,就能在不更换路口硬件的前提下,把绿灯时间分配给真正有需求的方向。适合手里有摄像头数据、想快速验证信号优化算法的同学参考,也适合做智慧城市课程设计或竞赛项目的团队借鉴。
1. 项目背景与整体设计思路
1.1 问题场景:为何一个信号灯要“智能”
先看一个非常典型的十字路口。东西向和南北向的车流量在一天内波动很大,早高峰东向西方向车流密集,晚高峰又变成西向东排队。固定配时方案是按历史流量统计出来的,比如东西直行 40 秒、南北直行 30 秒,这个方案在流量平稳时问题不大,一旦出现突发拥堵、事故占用车道或大型活动散场,就完全没有应变能力。
更麻烦的是,路口之间存在联动关系。一个路口绿灯放行后,车流会在几十秒后到达下一个路口,如果下游路口信号不配合,车队会被红灯截断,形成“绿灯空放”和“二次排队”叠加的恶性循环。但如果每个路口都能感知当前排队长度和到达流量,信号机就可以动态调整绿灯时长,甚至在下游拥堵时主动截流,整个路网的通行效率会明显改善。这就是我做这套系统的出发点:先用视觉把车流看清楚,再用简单可靠的规则去优化信号。
1.2 系统架构与模块划分
整套系统我拆成了五个模块:视频接入、目标检测与跟踪、车流特征提取、信号决策、模拟反馈。
视频接入层负责读取摄像头画面或者视频文件,统一转成帧序列。目标检测与跟踪层用 OpenCV 的背景建模算法把移动车辆从画面中分离出来,并通过轻量级跟踪给每辆车分配 ID。车流特征提取层负责统计检测区域内车辆数量、计算排队长度、估算到达率。信号决策层根据这些特征,按照预设的配时约束动态计算绿灯延长时间和相位切换顺序。最后模拟反馈层把决策结果送回一个交通仿真脚本里,用连续跑多轮的方式验证效果。
这种分层设计的好处是每个模块可以独立替换。比如你觉得背景建模效果不好,可以换成 YOLO 或者 MobileNet SSD,只要输出还是“车辆位置 + ID”,后面的特征提取和信号决策完全不用动。我在项目里特意把检测和决策解耦,就是考虑到不同路口的光照、视角差异很大,检测算法需要频繁调换,但决策逻辑相对稳定。
1.3 方案选型:为什么是OpenCV而不是重型深度学习
做视觉检测,最早的想法是上深度学习模型。实际上我也试过用现成的目标检测模型跑视频流,效果确实好,但问题也很明显:模型推理需要 GPU 或者较强的 CPU,路口摄像头动辄几十路,边缘设备的算力扛不住;模型输出的是“人、车、公交车”的分类框,但信号控制真正关心的是“车是否在排队、排队排到哪、车流是否在持续到达”,这些语义信息光靠检测框很难直接得到。
OpenCV 的背景建模方案就务实得多。它不区分车辆类型,不输出高精度分类框,但可以稳定地给出“这一块区域有没有运动目标”的布尔判断,这正是排队检测和到达检测最需要的。配合虚拟线圈或检测区域蒙版,计算量小、实时性高,在一台普通工控机上跑几十路视频毫无压力。项目原型阶段,我更看重健壮性和可解释性,OpenCV 这套减法模型完全够用。
2. 环境准备与关键依赖搭建
2.1 开发环境清单
我用的环境比较朴素:Python 3.10,OpenCV 4.8,NumPy 1.24,外加一个开源的交通仿真脚本用来做效果验证。没有用 GPU,纯 CPU 跑。系统是 Ubuntu 22.04,但代码跨平台没问题,Windows 下注意把视频解码库配好就行。
安装就三行命令:
pip install opencv-python==4.8.1.78 pip install numpy==1.24.3 pip install shapely # 用于定义检测区域,可选OpenCV 版本我建议锁定 4.8 系列。新版本对背景建模算法做了些改动,部分参数行为有细微差异,锁定版本可以避免“换台机器结果就变了”的问题。NumPy 的版本也需要留意,OpenCV 的 Python 绑定依赖 NumPy 的接口,版本太新可能导致二进制不兼容。
2.2 视频流输入与坐标映射
在实际路口场景里,摄像头通常架设在路口对角或灯杆横臂上,视角是俯视但带有明显透视畸变。直接拿原始画面做检测会有一个麻烦:相同大小的车,在近处看起来很大,在远处看起来很小,导致检测算法的阈值不好设。
我做的第一件事是建立像素坐标到路面的映射关系。简单做法是拍一张路口的俯视图,选四个参考点,比如停止线左端、右端、远端车道边缘的两个点,用 OpenCV 的透视变换函数把它矫正成鸟瞰图。矫正后再划定检测区域,长度、车距这些指标就接近物理世界尺度了。
import cv2 import numpy as np src_points = np.float32([[x1, y1], [x2, y2], [x3, y3], [x4, y4]]) dst_points = np.float32([[0, 0], [width, 0], [0, height], [width, height]]) matrix = cv2.getPerspectiveTransform(src_points, dst_points) warped = cv2.warpPerspective(frame, matrix, (width, height))注意标定时要选静止画面,最好选车道线清晰、车流稀少的时刻。如果参考点选错,矫正后的画面里道路中线就是歪的,后面的排队长度统计会偏差很大。矫正之后,我习惯把检测区域画在鸟瞰图上,方便后面定位断点和调试。
2.3 检测区划分与基础标定
信号优化的核心不是全画面目标检测,而是针对特定车道的定向感知。我把每条车道分成三个区域:
- 排队检测区:停止线往后 60 到 100 米,用来统计排队车辆数。
- 到达检测区:排队检测区再往后 50 米左右,用来判断是否有新车流持续到达。
- 出口检测区:路口对面车道出口,用来检测是否拥堵溢出。
每个区域是一个多边形,用 Shapely 库存成 Polygon 对象。每帧检测到车辆轮廓后,判断车辆中心点落在哪个区域里,就把这辆车归属到对应区域。这种区域划分方式比整个画面做统计要精准得多,而且天然支持按方向聚合。
基础标定还需要输入每个相位的车道组合。比如相位 A 是东向西直行,涉及东进口直行车道和西出口车道。配置写成 JSON,路口要调整相位时只改配置不改代码。
{ "phases": [ {"id": "A", "name": "东向西直行", "lanes": ["in_east_straight", "out_west_straight"]}, {"id": "B", "name": "东向西左转", "lanes": ["in_east_left", "out_west_left"]}, {"id": "C", "name": "西向东直行", "lanes": ["in_west_straight", "out_east_straight"]} ], "max_green": [50, 30, 50], "min_green": [15, 10, 15] }3. 核心实现:车流感知与特征提取
3.1 车辆检测:背景建模与形态学处理
这一节是整个系统最“视觉”的部分,也是坑最多的地方。我用了 OpenCV 自带的 MOG2 背景减除器,它会对每个像素建立高斯混合模型,把画面分成前景和背景。车是移动的,所以会被划成前景;路面静止,会被划成背景。这个算法最大的优点是计算量小、对缓慢光照变化有一定自适应能力。
实际处理流程是:先读帧,转灰度,做高斯模糊去掉噪点,再用背景减除器得到前景掩膜,接着做形态学开运算和闭运算。开运算去掉小的噪点,闭运算把车身上的空洞补上,最后找轮廓。
mog = cv2.createBackgroundSubtractorMOG2(history=500, varThreshold=16, detectShadows=True) def detect_vehicles(frame): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (5, 5), 0) fgmask = mog.apply(gray) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) fgmask = cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, kernel, iterations=2) fgmask = cv2.morphologyEx(fgmask, cv2.MORPH_CLOSE, kernel, iterations=2) contours, _ = cv2.findContours(fgmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) return contoursMOG2 的 history 参数决定背景更新的速度。history 设得太大,背景变化慢,早晚光照剧烈变化时,会把一大片阴影误判成车;设得太小,车在路口等红灯时又会被快速“融入”背景,变成消失。我实践下来,路口场景 500 比较合适,相当于 20 秒左右更新一次背景。varThreshold 控制前景判定的敏感度,默认 16,光线环境复杂的路口可以调到 20 到 25。
3.2 车辆跟踪:轻量级跟踪与计数
光有每帧的检测还不够,因为同一辆车在连续帧里会被反复检出,如果不做跟踪关联,排队长度统计会重复计算。跟踪的严格做法是用卡尔曼滤波加匈牙利匹配,但路口场景有个特殊性:车辆排队时几乎静止,此时背景建模往往把车当成背景抹掉了,跟踪目标就断了。
我的做法是分层处理:在车流到达区做常规跟踪,在排队区只做累计判断。到达区车速较快,前景检测稳定,用普通的中心点最近邻匹配就能跟踪。排队区则用另一个逻辑:先看检测区域内前景像素的覆盖率,超过一定阈值就认为存在排队车队,然后用队尾位置来计算排队长度,而不是逐车计数。
def match_tracks_to_detections(tracks, detections, max_distance=40): matched = [] for det in detections: best_track = None best_dist = max_distance det_cx, det_cy = centroid(det) for track in tracks: dist = np.sqrt((track.cx - det_cx) ** 2 + (track.cy - det_cy) ** 2) if dist < best_dist: best_dist = dist best_track = track if best_track is not None: best_track.update(det) matched.append(best_track) else: tracks.append(VehicleTrack(det)) return matched这个最近邻匹配看起来简陋,但胜在稳定。路口检测区域内车辆运动方向基本固定,不太会出现交叉轨迹,所以不用上匈牙利算法。跟踪目标超过检测区域或连续丢失超过 15 帧,就移除轨道。丢失少于 15 帧的短暂遮挡,比如大车挡住小车,给一个缓存状态,下次重新出现时还能接上 ID。
3.3 特征汇总:排队长度、到达率与绿灯需求
检测和跟踪的数据最终要变成信号决策能用的特征。我汇总了三个核心指标:
第一个是排队长度。排队长度不直接用车辆数表达,而是用“队尾位置距停止线的距离”。方法是在鸟瞰图上沿车道中心线等距采样点,找到最远一个被判定为“有车占据”的采样点,换算成距离。排队长度直接影响绿灯是否要延长,以及延长时间的长度。
第二个是到达率。统计最近 5 秒内进入到达检测区的车辆数,再除以时间,得到每秒到达车辆数。如果到达率持续大于某个门限,说明车流还在不断涌来,绿灯可以考虑继续延。
第三个是绿灯需求度。我把排队长度和到达率做加权组合,得到一个 0 到 100 的分值。这个分值不是必须的,但方便做可视化和调试。
def compute_green_demand(queue_len, arrive_rate, max_queue=60): queue_norm = min(queue_len / max_queue, 1.0) rate_norm = min(arrive_rate / 0.5, 1.0) return int(100 * (0.7 * queue_norm + 0.3 * rate_norm))权重为什么是 0.7 和 0.3?因为排队长度反映的是存量压力,到达率反映的是增量压力。信号控制首先要清存量,所以排队占比高;但也不能只看存量,如果车流持续到达,你现在放完也没用,还要继续延。实际使用时,这两个权重也是需要标定的,后面我会聊怎么调。
4. 信号配时优化逻辑
4.1 相位约束与安全保护
信号控制不能只想着效率,安全约束永远是第一位的。我在决策模块里写死了几道保护逻辑,任何优化都不能突破这些约束。
第一是最大绿灯时间。每个相位有最长的连续绿灯时间,一般直行 50 到 60 秒,左转 20 到 30 秒,超过这个时间必须强制切换,防止一个方向无限霸占绿灯。第二是最小绿灯时间。每个相位至少保证 10 到 15 秒,让已经过线的行人和非机动车能安全通过,也避免频繁切换导致驾驶员来不及反应。第三是黄灯和全红时间。切换相位前必须有黄灯过渡,黄灯时长按路口限速计算,常见公式是黄灯时间等于停车距离除以速度,路口限速 60 时黄灯一般 3 到 5 秒。全红时间保证路口完全清空,防止上一个相位的车和下一个相位的车在路口中间碰撞。
这些约束在代码里体现为决策上下界。绿灯延长时间只能在最小值和最大值之间浮动,任何检测结果都改变不了这条铁律。
4.2 绿灯延长时间计算
绿灯延长的逻辑有点像“脉冲累加”:当前相位每过一个检测周期,就判断是否满足继续延长的条件,如果满足,在基础绿灯时间上追加一个单位,直到达到最大绿灯。
判断条件有三个,缺一不可:
- 当前排队长度仍然大于阈值,说明队尾还没清空。
- 到达检测区最近几个周期内仍有车持续进入,说明不是零散的“漏网之车”。
- 绿灯已运行时间未超过最大绿灯。
追加单位时长我设为 5 秒。这个数值不能太小,否则决策噪声大,绿灯一会儿延一会儿停,驾驶员体验很差;也不能太大,否则容易超调,导致下一个相位积压。每轮决策只追加 5 秒,下一轮再判断,相当于一个渐进的逼近过程。
def should_extend_green(phase, state): if state.green_elapsed >= phase.max_green: return False if state.queue_len < 15 and state.arrive_rate < 0.1: return False if state.green_elapsed < phase.min_green: return True return state.green_demand >= 70注意最后一行,我加了绿灯需求度大于 70 的硬条件。这个条件能过滤掉“有车但不多、不值得延”的场景,避免每个相位都顶着最大绿灯跑,结果又退化成定时控制。
4.3 多相位协调与黄灯过渡
单路口优化只是第一步。实际项目里,我不会只做一个孤立的路口,而是把相邻几个路口组成协调子区。子区协调的思路是设置“绿波带参考点”:上游路口绿灯放行后,记录车队速度,预估到达下游路口的时间,然后调整下游路口的相位时间,让车队到达时刚好遇到绿灯。
实现上我用一个简单的偏移量模型。每个路口共享同一个信号周期,但相位起始时间相对参考路口有一个偏移量。优化器每隔 5 分钟计算一次各路口的排队压力,如果有路口压力明显大,就动态调整偏移量。这个调整必须是秒级、小幅度的,一次偏移调整不超过 6 秒,防止整个子区信号乱掉。
黄灯过渡阶段还有一个容易忽略的细节:在切换相位之前,必须先清空检测区域的目标队列。我的处理方法是,在黄灯开始前 3 秒锁定决策状态,不再允许当前相位延长,强制进入黄灯阶段。否则会出现一个诡异现象:黄灯都亮了,算法又判定“还有车来”要把绿灯捡回来,这在真实路口是不可接受的。
4.4 参数调试与保守策略
这套逻辑最需要调的就是权重和阈值。我调试的顺序是:
先把约束参数定死,包括最大绿灯、最小绿灯、黄灯和全红时间。然后调绿灯延长的触发阈值,从排队长度入手:找一段排队比较典型的高峰录像,调整“排队长度大于多少米才延长”的阈值,直到算法行为符合一个老交警的直觉。最后调到达率阈值,目标是区分“连续车流”和“零散车辆”。
调试中我犯过一个典型的错误:为了指标好看,把触发阈值调得很激进,导致绿灯频繁延长,南北向车辆等待时间暴涨。后来我加了一条保守策略:任何优化措施只有在“当前方向排队压力确实高于临界值”时才启用,否则保持原定时方案。换句话说,系统默认是保守的,只有证据充分时才动绿灯。这个策略在真实路口非常重要,因为公众对信号系统的信任很脆弱,误调一次,后面改正的成本极高。
5. 模拟测试与效果评估
5.1 数据集与测试环境
没有真实路口敢让我直接上手试,所以我建了一个模拟环境:用一段无人机俯拍的路口视频作为感知输入,同时写了一个简单的车辆动力学仿真脚本,负责按信号决策更新车辆位置。感知部分吃视频数据,决策部分吃感知结果,控制部分输出信号灯状态,三者组成闭环。
视频我准备了三种场景:早高峰连续拥堵、平峰稀疏车流、傍晚逆光车流。每个场景跑 30 分钟,采集排队长度、等待时间、通过车辆数三个指标。仿真脚本里我把车辆的加减速和跟车模型做了简化,用的只是恒加速度模型。这个精度对信号优化验证足够:我不需要精确知道每辆车多快,只需要知道车流是否连续、排队是否在累积。
5.2 指标定义与对比结果
评估指标我选了三个:
- 平均排队时间:所有车辆从进入排队区到通过停止线的平均时长。
- 最大排队长度:统计周期内排队长度峰值。
- 绿灯空放率:绿灯期间通过车辆数低于阈值的时间占比。
对比基准是固定配时方案。在我模拟的早高峰场景里,固定配时下平均排队时间是 68 秒,优化后降到 43 秒,降幅大约 37%。最大排队长度从 180 米降到 120 米,空放率从 22% 降到 9%。平峰车流下两者差别不大,因为流量不紧张时固定配时本身就很合理,优化系统基本不触发。这个结果符合预期:动态优化只有在流量波动大时才体现价值。
5.3 真实场景下的表现与局限
模拟环境里表现不错,但我知道距离真实落地还有不小差距。真实场景有几个模拟测不出来的问题:雨雪天气下视频画面模糊,背景建模容易失效;夜间车灯在画面上形成高亮光斑,轮廓会被拆成多个碎片;摄像头抖动和树叶摆动会引入大量误检。
针对夜间问题,我加了一个夜模式:检测到画面整体亮度低于阈值时,改用车灯光斑检测加配对逻辑,通过成对光斑推断车辆位置。针对雨天,我调大了形态学核的尺寸,并降低背景更新的阈值,让算法对雨滴噪点更钝感。这些处理不能说完全解决,但能把误检率压到可接受范围。
6. 实战排雷与优化技巧
6.1 图像处理层面的坑
背景建模最大的坑是“鬼影”。刚启动时,如果画面里已经有车停在排队区,模型会把车当成背景,车一旦启动离开,原来的位置会留下一个长时间不消失的“鬼影”区域,导致误检排队。我的做法是系统启动后先跑 3 到 5 秒,丢弃前几帧结果,并在这段时间内强制信号保持原定时,等背景模型收敛再参与决策。
阴影是另一个问题。MOG2 的 detectShadows 参数会单独标出阴影区域,但阴影的灰度特征和车身太像,开运算并不能完全清除。我是通过限制检测区域来规避的:检测区域只划在车道中央,车辆阴影一般投射在车道边线或者相邻车道,区域外的阴影不会被统计。
还有就是分辨率。路口摄像头通常分辨率很高,但传回的码流为了节省带宽会压缩得很厉害。压缩带来的块效应会让轮廓变成锯齿状,导致车辆计数不稳定。我在实际项目里会在前端做一次降采样,把 1920 宽的图像压缩到 960 再送进算法,计算量降了 50%,检测精度反而因为去除了细粒度噪点而提升。
6.2 跟踪与计数的坑
跟踪最容易犯的错是把“一辆车被挡住后重新出现”当成两辆车。路口场景里大车挡小车非常常见,公交车完全可能把旁边车道的小轿车遮住三五秒。我的解决方法是给每辆车一个“消失缓存时间”,在缓存时间内目标 ID 不会释放。这个时间在路口场景设为 2 秒比较合适,太短起不到作用,太长又会让已经驶离的车一直占着 ID,干扰新车的匹配。
计数还有个双计数问题。车辆在到达区被计了一次,在排队区又被计了一次,如果直接累加,会严重高估流量。我的方案是以跟踪 ID 为唯一键,一个 ID 在同一统计周期内只允许归属一次,进入排队区后就不再重复统计到达事件。
6.3 工程化部署的建议
如果这个系统要从前台脚本变成真正的路边设备,有几个点必须提前考虑:
视频源的同步问题。不同摄像头的帧率、延迟不一样,如果各路口的感知数据直接用时间戳对齐,会出现几十到几百毫秒的偏差。信号控制对时间误差非常敏感,我建议在感知模块里,把每路视频的帧都打上本地时间戳,并做时钟同步,而不是依赖设备自带时间。
回退机制。检测算法可能会死机、画面可能会中断,信号机必须能在 2 秒内自动切回固定配时方案。我写了一个看门狗模块,如果连续 3 个周期没有收到感知数据,就让信号机进入保守的定时控制模式,同时发出告警。
数据可解释性。真实路口的运营人员不会信任一个黑盒。我在系统的可视化界面里,把当前排队长度、检测区域覆盖情况、绿灯延长原因都用图形画出来,运营人员可以直观地看到“因为东口排队还有 80 米,所以绿灯继续延了 5 秒”。这个可视化功能比算法本身更重要,因为它是换取信任的关键。
最后分享一个我个人的体会。做信号优化,算法只占一半,另一半是对约束条件的敬畏。交通系统是开放系统,行人、非机动车、特种车辆的突发行为都不在你的模型里,任何优化都必须留足安全余量。我在整套系统里反复强调保守策略和回退机制,原因就在这:智慧交通的第一原则不是效率最大,而是可靠不添乱。这套基于 Python 和 OpenCV 的原型,让我把从感知到决策的完整链路跑通了,也积累了不少真实感很强的经验,后续我会把相邻路口协调部分做得更精细,再考虑把检测换回深度学习做一次对比评估。