Python+OpenCV红绿灯检测实战:HSV阈值、候选筛选与状态防抖

发布时间:2026/9/28 16:45:20
Python+OpenCV红绿灯检测实战:HSV阈值、候选筛选与状态防抖 简介这份资源面向计算机视觉初学者与智能交通方向开发者提供一套基于Python与OpenCV的红绿灯检测完整实现可用于自动驾驶感知、交通监控等场景的入门实践。压缩包共12个文件约343KB包含6个png与1个jpg示例图片、2个py核心脚本、2个md说明文档及1个pyc缓存文件图片覆盖红、绿、黄及箭头灯等多种信号状态便于直接运行验证。代码围绕预处理、颜色空间转换、特征提取与目标检测展开涉及灰度化、直方图均衡、Canny边缘检测、HSV颜色分离及分类器识别等关键环节并附带状态管理脚本可支撑实时视频流处理思路的落地。目前已有4037人学习下载适合希望快速理解红绿灯识别流程、对照示例图调试参数并搭建基础检测系统的读者参考。1. 红绿灯检测到底难在哪从一张过曝路口图说起白天正午路口监控画面里红灯亮得像一团白斑绿灯边缘被阳光糊掉黄灯只有三帧。你拿cv2.inRange按 HSV 卡红色结果刹车灯、红色广告牌、外卖箱全被框进来。这就是 python 红绿灯检测最真实的起点不是能不能识别颜色而是怎么在颜色失真、目标极小、状态会跳变的条件下稳定输出红/黄/绿三态。用 OpenCV 做红绿灯信号灯检测本质是一条轻量流水线定位画面里的灯体候选区域过滤掉非灯体干扰再对候选区域做颜色判决最后用状态机压住抖动。它适合三类人想用 python 入门计算机视觉的开发者、需要给嵌入式或边缘盒子做低成本信号灯识别的工程师、以及拿它当 opencv 图像处理项目练手的学生。整套方案不依赖深度学习框架一台普通笔记本就能跑但边界也很清楚——它吃的是灯体在画面里足够大、颜色通道没被完全压死的场景。我见过太多人一上来就cv2.HoughCircles找圆然后在真实路口图上翻车。原因后面几章会拆开讲。先把这条流水线的每个环节立住再谈参数和踩坑。2. 从 BGR 到 HSV颜色阈值为什么总在真实路口失效2.1 红绿灯的颜色空间选择与阈值推导OpenCV 读进来是 BGR直接对 B 通道卡阈值基本没意义因为亮度一变三个通道一起漂。红绿灯检测的常见做法是转 HSV把颜色和亮度解耦H 管色调S 管饱和度V 管明度。红灯的 H 在 0 附近和 180 附近各有一段HSV 的 H 是环形绿灯在 60 上下黄灯在 25 到 35 之间。但真实画面里红灯过曝时 S 会掉、V 会顶到 255H 反而变得不稳定。所以阈值不能只卡 H要 H、S、V 三个一起给区间并且对红灯做两段 H 合并。下面是我一般会先跑的探查脚本用来在一批图上统计候选像素的 HSV 分布而不是拍脑袋定阈值。import cv2 import numpy as np def hsv_probe(image_path): img cv2.imread(image_path) if img is None: raise FileNotFoundError(image_path) hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 只统计高饱和、够亮的像素避开灰暗背景 mask (hsv[:, :, 1] 80) (hsv[:, :, 2] 120) samples hsv[mask] if samples.size 0: print(no bright saturated pixel) return h samples[:, 0] # 红灯跨 0 和 180用直方图看两段分布 hist, _ np.histogram(h, bins36, range(0, 180)) for i, v in enumerate(hist): if v 0: print(fH {i*5:3d}-{i*55:3d}: {v})逻辑说明先转 HSV再用 S80、V120 把明显灰暗的像素剔掉剩下的才是可能是灯的候选。直方图按 5 度一档统计 H能直观看到红灯是不是同时出现在 0 附近和 175 附近。参数上S 和 V 的下限是经验值夜间场景要往下调正午过曝场景 V 可以保留但 S 要放宽。2.2 用 inRange 生成三态掩膜并做形态学清理拿到分布后就可以写三态掩膜。红灯要两段 H 合并这是新手最容易漏的点——只卡 0 到 10傍晚偏紫的红灯就丢了。import cv2 import numpy as np def build_masks(hsv): # 红灯H 两段S/V 给宽区间抗过曝 red1 cv2.inRange(hsv, (0, 70, 90), (10, 255, 255)) red2 cv2.inRange(hsv, (170, 70, 90), (180, 255, 255)) red cv2.bitwise_or(red1, red2) # 黄灯H 集中在 25-35 yellow cv2.inRange(hsv, (20, 80, 120), (35, 255, 255)) # 绿灯注意有些灯偏青H 上限放到 90 green cv2.inRange(hsv, (45, 60, 80), (90, 255, 255)) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) result {} for name, m in ((red, red), (yellow, yellow), (green, green)): # 先开运算去噪点再闭运算补灯体内部空洞 m cv2.morphologyEx(m, cv2.MORPH_OPEN, kernel, iterations1) m cv2.morphologyEx(m, cv2.MORPH_CLOSE, kernel, iterations2) result[name] m return result逻辑说明inRange对每个通道给上下限红灯用两次再按位或。形态学先开后闭是固定套路——开运算干掉孤立噪点闭运算把灯体被反光切碎的区域连起来。kernel 用椭圆 3x3是因为灯体近似圆方形 kernel 会把边缘磨成直角影响后续圆度判断。参数上闭运算迭代 2 次是权衡迭代多了相邻灯会粘连迭代少了灯体内部还有洞。提示阈值不要一次定死。先跑探查脚本看分布再回来调 S/V 下限比反复试 inRange 快得多。3. 候选区域筛选轮廓、圆度与面积三道闸3.1 用 findContours 拿候选并算圆度掩膜出来是一堆白块里面既有灯也有红色车尾灯、广告牌。下一步是findContours拿外轮廓然后用面积和圆度过滤。圆度公式是 4π·面积/周长²理想圆接近 1细长条接近 0。import cv2 import numpy as np def filter_candidates(mask, min_area40, max_area5000, min_circularity0.55): contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes [] for c in contours: area cv2.contourArea(c) if area min_area or area max_area: continue peri cv2.arcLength(c, True) if peri 0: continue circularity 4 * np.pi * area / (peri * peri) if circularity min_circularity: continue x, y, w, h cv2.boundingRect(c) # 长宽比也卡一下灯体接近正方形外接框 aspect w / float(h) if aspect 0.6 or aspect 1.6: continue boxes.append((x, y, w, h, area, circularity)) return boxes逻辑说明RETR_EXTERNAL只取最外层避免灯体内部反光形成嵌套轮廓。面积上下限按分辨率定1080p 画面里远处灯可能只有几十像素近处灯能到几千。圆度阈值 0.55 是偏松的因为过曝会让轮廓变形如果误检多往上提到 0.7。长宽比卡 0.6 到 1.6能干掉大部分车灯条和广告牌。3.2 用位置先验和亮度峰值做二次确认光靠形状还不够。红绿灯有个强先验同一组灯的三个灯体在垂直方向排列间距接近。另外灯体中心亮度通常高于周围。可以取候选框中心区域的平均 V 值低于阈值的丢掉。import cv2 import numpy as np def confirm_by_brightness(img, boxes, v_thresh180): hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) kept [] for (x, y, w, h, area, circ) in boxes: cx, cy x w // 2, y h // 2 r max(2, min(w, h) // 4) patch hsv[max(0, cy-r):cyr, max(0, cx-r):cxr, 2] if patch.size 0: continue if float(patch.mean()) v_thresh: kept.append((x, y, w, h)) return kept逻辑说明取候选框中心四分之一区域算 V 均值灯体发光中心通常最亮。v_thresh 白天可以设 200 以上夜间设 150 左右。这一步能干掉被阳光照亮的红色物体——它们形状可能像圆但中心亮度不够集中。注意位置先验三灯垂直排列建议放在状态判决阶段做不要在这一步硬卡否则单灯画面会全丢。4. 状态判决与防抖别让红灯闪成绿灯4.1 单帧三态判决的优先级一帧里可能同时检出红和绿比如画面里有两组灯或者掩膜误检。单帧判决要定优先级先看有没有红灯候选再看绿灯最后黄灯。因为红灯误判成绿灯的代价远大于漏检。判决时取面积最大的候选作为该状态的代表。def decide_state(masks, img): order [red, yellow, green] best {} for name in order: boxes filter_candidates(masks[name]) boxes confirm_by_brightness(img, boxes) if boxes: # 取面积最大的候选 boxes.sort(keylambda b: b[2] * b[3], reverseTrue) best[name] boxes[0] for name in order: if name in best: return name, best[name] return unknown, None逻辑说明按红、黄、绿顺序返回第一个有候选的状态。黄灯放中间是因为它持续时间短优先级低于红高于绿。返回 unknown 时不要硬给状态交给防抖逻辑处理。4.2 用滑动窗口投票压住状态抖动逐帧判决会抖红灯边缘反光可能让某一帧判成黄。常见做法是维护一个长度 N 的队列取众数作为输出并且要求连续 M 帧一致才切换状态。from collections import deque, Counter class StateStabilizer: def __init__(self, window7, confirm3): self.buf deque(maxlenwindow) self.confirm confirm self.current unknown self.pending None self.pending_count 0 def update(self, state): self.buf.append(state) vote Counter(self.buf).most_common(1)[0][0] if vote self.current: self.pending None self.pending_count 0 return self.current if vote self.pending: self.pending_count 1 else: self.pending vote self.pending_count 1 if self.pending_count self.confirm: self.current vote self.pending None self.pending_count 0 return self.current逻辑说明window 是投票窗口confirm 是切换所需连续帧数。window7、confirm3 在 25fps 下大约 0.12 秒确认能压住单帧噪声又不会太迟钝。如果画面抖动大window 加到 11如果要快速响应confirm 降到 2但误切换会变多。提示unknown 也进投票队列。如果 unknown 占多数输出保持上一稳定状态不要切到 unknown否则界面会闪。5. 避坑与排查红绿灯检测最常见的五个翻车现场5.1 现象红灯完全检不到绿灯正常原因红灯 H 跨 0 和 180 两段只写了(0,70,90)-(10,255,255)一段傍晚偏紫的红灯 H 落在 175 附近被漏掉。解决红灯必须两段 inRange 再按位或并且用探查脚本确认两段都有像素。5.2 现象刹车灯、红色广告牌被当成红灯原因只卡了颜色没卡形状和亮度。解决加圆度、长宽比、中心亮度三道过滤如果还误检加位置先验——红灯候选上方或下方应有同组灯体孤立红色块降权。5.3 现象ModuleNotFoundError: No module named opencv原因包名不是 opencvpip 装的是opencv-python。解决pip install opencv-python如果要用 contrib 模块装opencv-contrib-python。装完在 python 里import cv2; print(cv2.__version__)验证。vscode 里要确认选中的解释器和 pip 是同一个环境这是 vscode python 环境配置最常见的坑。5.4 现象白天正常夜间全乱原因夜间灯体过曝S 掉到很低V 顶满H 不稳定同时环境光弱形态学开运算把灯体也当噪点删了。解决夜间单独一套阈值S 下限降到 40 左右V 下限提高形态学开运算迭代降到 1 或去掉只保留闭运算。5.5 现象视频流跑一会儿就卡死或报错原因cv2.VideoCapture读流时没有检查返回值流中断后继续 read 返回空帧后续 cvtColor 直接抛异常。解决每次 read 后判断ret失败时重连或退出处理循环里加帧率控制别让 CPU 空转。这也是 opencv python 拉流中断的典型表现。6. 把检测跑成可复现的流水线参数表与验证习惯6.1 一份可直接抄的参数起点表下面这张表是我在 1080p 路口画面上的起点参数不是万能值但能让你第一次跑就有输出再按场景微调。环节参数白天起点夜间起点调整方向红灯掩膜H 两段0-10 / 170-1800-12 / 168-180偏紫红灯扩第二段红灯掩膜S 下限7040过曝降 S红灯掩膜V 下限90120夜间提 V绿灯掩膜H 区间45-9045-95偏青绿灯扩上限候选面积min_area4025远灯降下限圆度min_circularity0.550.45过曝降阈值亮度确认v_thresh200150夜间降阈值防抖window / confirm7 / 39 / 3抖动大加 window6.2 用一段视频做回归验证而不是单张图单张图调好的参数换一段视频就崩这是血泪经验。验证要固定一段包含白天、夜间、红灯、绿灯、黄灯过渡的视频跑完整流水线统计三态各自的检出帧数和误切换次数。误切换次数比准确率更能说明防抖有没有用。import cv2 def run_pipeline(video_path, stabilizer): cap cv2.VideoCapture(video_path) if not cap.isOpened(): raise RuntimeError(cannot open video) stats {red: 0, yellow: 0, green: 0, unknown: 0} switches 0 last None while True: ret, frame cap.read() if not ret: break hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) masks build_masks(hsv) state, box decide_state(masks, frame) stable stabilizer.update(state) stats[stable] stats.get(stable, 0) 1 if last is not None and stable ! last: switches 1 last stable cap.release() print(stats:, stats, switches:, switches)逻辑说明把整条流水线串起来跑视频统计每个稳定状态的帧数和切换次数。切换次数是防抖效果的直接指标——如果一段 30 秒视频切换几十次说明 window 太小或 confirm 太低。参数上先固定阈值跑一遍看 stats再针对性调。6.3 我自己的习惯先存中间掩膜再谈调参调红绿灯检测最忌讳盯着最终框调。我一般会在流水线里加一个开关把三态掩膜和候选框分别存成图出问题时先看掩膜——是颜色没卡到还是形状过滤太狠一眼就能定位。这个习惯让我少走了很多弯路颜色问题回 HSV 探查脚本形状问题回圆度阈值亮度问题回确认环节各管各的不互相甩锅。这套 python opencv 的红绿灯检测方案胜在轻、可解释、每一环都能单独验证。它不追求在极端场景下通吃但只要你把颜色空间、候选筛选、状态防抖这三段拆清楚就能在自己的场景里稳定跑起来。希望帮到你。本文还有配套的精品资源点击获取