尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

OpenCV交通信号灯检测实战:HSV颜色分割与轮廓分析全解析

OpenCV交通信号灯检测实战:HSV颜色分割与轮廓分析全解析 简介资源为基于OpenCV与Python实现的交通信号灯检测完整项目面向计算机视觉初学者、智能交通方向学习者及需要快速搭建检测原型的开发者。项目围绕红绿灯识别这一实际场景涵盖图像预处理、颜色空间分析、目标定位与状态判定等关键环节可帮助读者理解从图像采集到结果输出的完整算法流程。压缩包共143个文件包含140张JPG测试图像、2个Python源文件与1个Markdown说明文档包体仅19.84MB其中JPG图片用于算法验证与效果展示Python脚本为核心检测实现Markdown文档则对项目结构与运行方式进行说明。目前已有114人学习下载。通过源码研读与本地运行读者可掌握基于颜色特征的信号灯检测方法并进一步调整参数以适应不同光照与拍摄角度配套图像便于逐帧对照检测效果适合作为课程设计或工程实践的参考。1. 交通信号灯检测为什么值得自己用 OpenCV 写一遍交通信号灯检测是计算机视觉里少有的“约束足够多、适合练手、又不至于简单到无聊”的项目。红灯、黄灯、绿灯的颜色在 HSV 空间里分布非常集中灯体又是圆形或箭头形检测目标的数量固定场景相对封闭。正因如此它不需要上深度学习用 OpenCV 的颜色分割加轮廓分析就能跑出一个可用的结果这也是这个项目在 OpenCVPython 学习路径上被反复拿来当实战题的原因。我见过不少直接套 YOLO 做信号灯检测的方案效果确实好但模型训练、数据标注、部署环境一套下来新手很容易迷失在环境配置里。而这个项目用纯 OpenCV 实现整条链路只有图像预处理、颜色分割、轮廓提取、形态学验证四个环节每一行代码都能看到中间结果。对于想理解“图像处理到底在算什么”的人来说这是性价比极高的切入点。你不需要 GPU、不需要标注数据集一台普通电脑加一个摄像头或几张街景图就能跑通。适合的人群也很明确学过 Python 基础、想接触 OpenCV 但不知道从哪里下手的初学者或者想快速做一个能演示的视觉项目的在校学生。本文会把颜色空间选型、HSV 阈值标定、轮廓筛选、状态机判定这四步完整拆开每一步给出可直接运行的代码和参数说明最后集中讲调试过程中最容易翻车的几个坑。2. 颜色分割为什么选 HSV 而不是 RGB以及阈值怎么标定2.1 RGB 在光照变化下的表现有多差很多人第一次写颜色检测习惯性用 RGB 通道直接做范围判断比如“红色就是 R200 且 G100 且 B100”。这句话在实验室均匀光照下勉强成立一旦换到户外问题立刻暴露阳光直射下红色信号灯的 RGB 值可能整体被抬高R、G、B 三个通道同时增大固定阈值就会漏检黄昏时分色温偏暖白色灯罩映出来的 RGB 已经不是原色了。RGB 的三个通道是高度耦合的任何一个通道变化都会拉扯另外两个没法单独对“颜色”本身做稳定刻画。HSV 把颜色拆成色调Hue、饱和度Saturation、明度Value三个独立维度其中 H 通道描述的是什么颜色S 描述的是颜色有多纯V 描述的是亮度。信号灯的灯体颜色是高度饱和的红色灯的 H 值稳定落在一个小区间内不受亮度变化影响。这意味着我们只需要对 H 通道做阈值再配合 S 和 V 做一个下限过滤来排除过暗和过灰的区域就能稳定地分割出灯体。代价是 H 通道是一个环形空间——0 和 180 在 OpenCV 的 0~180 范围内是相邻的红色恰好横跨这个边界后面会讲到怎么处理。2.2 HSV 阈值标定的标准流程标定阈值不要靠猜。做法是先用cv2.cvtColor把 BGR 图像转到 HSV然后用鼠标在图像上点击灯体区域把该像素的 H、S、V 值打印出来。多采几个不同时刻、不同角度的灯体像素点观察 H 的波动范围。import cv2 import numpy as np # 读取一张包含信号灯的图像 img_bgr cv2.imread(traffic_light.jpg) img_hsv cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) # 鼠标回调点击图像任意位置打印该点的 HSV 值 def on_mouse(event, x, y, flags, param): if event cv2.EVENT_LBUTTONDOWN: h, s, v img_hsv[y, x] print(f像素坐标 ({x}, {y}) - H{h}, S{s}, V{v}) cv2.namedWindow(pick_hsv) cv2.setMouseCallback(pick_hsv, on_mouse) cv2.imshow(pick_hsv, img_bgr) cv2.waitKey(0) cv2.destroyAllWindows()这段代码的作用是把“经验”变成“数据”。我在标定时一般会采 20 个以上的点覆盖灯体的亮部、暗部、边缘过渡区以及同颜色不同拍摄距离下的表现。打印出来的 H 值如果集中在某个区间比如红灯在 0~10 和 170~180 两端那阈值就按这个分布来写。需要注意 OpenCV 中 H 的范围是 0~180而不是很多图像处理教材里写的 0~360。S 和 V 的范围是 0~255。如果你在网上找阈值参考看到 H 范围写的是 0~360 的版本代入 OpenCV 时必须除以 2否则分割结果会完全错乱。2.3 用 inRange 做初次分割观察掩膜的噪声形态拿到阈值之后用cv2.inRange生成二值掩膜这是整个检测流程的地基。掩膜质量直接决定后续轮廓检测的成功率。# 红灯在 OpenCV HSV 中横跨 0 和 180 边界需要拆成两段分别做 lower_red_1 np.array([0, 80, 100]) upper_red_1 np.array([10, 255, 255]) lower_red_2 np.array([170, 80, 100]) upper_red_2 np.array([180, 255, 255]) mask_red_1 cv2.inRange(img_hsv, lower_red_1, upper_red_1) mask_red_2 cv2.inRange(img_hsv, lower_red_2, upper_red_2) mask_red cv2.bitwise_or(mask_red_1, mask_red_2) cv2.imshow(mask_red, mask_red) cv2.waitKey(0)两个边界段的拆法是红色检测最常见的写法OpenCV 的色相环从 0 开始是红色到 180 又绕回红色所以纯红区域会同时出现在色相环的两端。把两段掩膜做按位或合并才能把完整的红色区域捞出来。S 下限取值 80 是经验值目的是过滤掉低饱和度的灰白色区域。如果场景里有很多浅色物体可以调高到 100如果信号灯本身颜色偏暗调低到 60。V 下限 100 是亮度地板把阴影和夜间过暗的区域直接排除。这两个参数是后续调优时最先要动的旋钮。分割完之后一定要亲眼看一下掩膜上除了信号灯之外还残留了什么。最常见的干扰源是红色车尾灯、红色招牌、晚霞反射。接下来要用形态学操作和轮廓筛选把这些杂质去掉。3. 从掩膜到灯体轮廓筛选、形态学处理和圆形度验证3.1 形态学操作先做还是先找轮廓掩膜上的噪声通常是零散的小像素团而信号灯灯体是一个连通的、面积可预估的块。两条路线都能走先做腐蚀膨胀再找轮廓或者先找轮廓再按面积和形状筛。实践中先做一轮开运算更稳。# 开运算先腐蚀后膨胀断开细小噪声连接同时保持灯体面积基本不变 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask_red_clean cv2.morphologyEx(mask_red, cv2.MORPH_OPEN, kernel) # 再做一次闭运算把灯体内部的细小孔洞填上 mask_red_clean cv2.morphologyEx(mask_red_clean, cv2.MORPH_CLOSE, kernel) # 找到所有连通域的外轮廓 contours, _ cv2.findContours( mask_red_clean, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) print(f检测到 {len(contours)} 个候选轮廓)开运算的核大小直接影响噪声去除效果。核太大小尺寸的灯体也会被抹掉核太小噪声去不干净。5x5 的椭圆核是针对分辨率 1080p 左右的图设的如果输入是 720p可以用 3x3如果是从远处拍摄、灯体只占几十个像素核还要更小或者干脆跳过这一步。cv2.RETR_EXTERNAL只取最外层轮廓因为灯体是实心圆不需要内部层级。如果用了RETR_TREE反而可能把灯体内部的数字或图案也当成轮廓加进来徒增筛选工作量。3.2 面积过滤用真实世界尺度估算像素面积面积过滤的目标是去掉两类东西比灯体小的散点噪声以及比灯体大的红色广告牌、红色墙面。不能写死一个固定像素值因为摄像头距离信号灯的距离是不确定的。我一般会按图像尺寸比例来设置上下限。h_img, w_img img_bgr.shape[:2] img_area h_img * w_img min_area img_area * 0.0005 # 0.05% 的图像面积 max_area img_area * 0.02 # 2% 的图像面积 valid_contours [] for c in contours: area cv2.contourArea(c) if min_area area max_area: valid_contours.append(c) print(f面积过滤后剩余 {len(valid_contours)} 个候选)0.05% 和 2% 这两个比例是从实际测试里来的。以 1920x1080 画面为例整体面积约 200 万像素0.05% 是 1000 像素对应一个直径约 35 像素的圆——这差不多是信号灯在 100 米外拍出来的大小2% 是 4 万像素对应直径约 220 像素的圆大约是信号灯在 10 米内占满视野的大小。如果你的应用场景是固定的路口监控建议直接拍几帧标注距离反推面积范围比用经验比例更准。3.3 圆形度与宽高比把矩形招牌挡在门外面积过滤只能挡掉大小离谱的目标红色招牌、红色车辆和信号灯在面积上可能非常接近需要几何形状来区分。信号灯灯体是圆形或近圆形宽高比接近 1招牌和车身通常偏离这个特征。def is_circle_like(contour): perimeter cv2.arcLength(contour, True) if perimeter 0: return False area cv2.contourArea(contour) circularity 4 * np.pi * area / (perimeter * perimeter) return circularity 0.7 def has_valid_aspect_ratio(contour): x, y, w, h cv2.boundingRect(contour) aspect w / h return 0.6 aspect 1.6 final_contours [] for c in valid_contours: if is_circle_like(c) and has_valid_aspect_ratio(c): final_contours.append(c) print(f形状验证后剩余 {len(final_contours)} 个候选)圆形度的原理是相同周长下圆的面积最大所以面积与周长的平方之比接近 4π。正方形算出来约 0.78三角形只有约 0.6。阈值为 0.7 意味着接受了圆形和稍微有点形变的正方形排除了明显的拉长形状。宽高比 0.6~1.6 是给鱼眼镜头或斜视角预留的余量正对路口的摄像头可以收紧到 0.8~1.2。这一轮筛完误检通常已经压到很低。如果场景里确实有圆形红色物体比如红色交通锥或红色自行车轮那就需要下一章的时序逻辑来兜底。4. 框选与状态判定单帧检测如何升级为稳定的信号灯识别4.1 在原始图上画出检测框和颜色标签前面三步的结果如果直接画在图上你会发现检测框不只是框住了灯体还框住了整个灯头甚至旁边的灯罩。原因是掩膜上的连通域可能把相邻颜色相近的区域连在一起。所以画框时不要直接用原始轮廓的 boundingRect而是先做外接圆再取圆心和半径扩一个比例系数作为显示框。def draw_detection(frame_bgr, contours, color_name, box_scale1.2): result frame_bgr.copy() for c in contours: (cx, cy), radius cv2.minEnclosingCircle(c) radius int(radius * box_scale) cv2.circle(result, (int(cx), int(cy)), radius, (0, 255, 0), 2) label f{color_name} light cv2.putText( result, label, (int(cx) - 30, int(cy) - radius - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2 ) return resultminEnclosingCircle可以绕开轮廓在边缘不够光滑的问题直接给出灯体最紧的外接圆。box_scale1.2是给框留一点余量视觉上更舒服但如果要做像素级别的精度测量这个系数要设成 1.0 并配合boundingRect使用。4.2 一帧只有颜色不够要加“状态持续时间”作为置信度单帧检测最常见的翻车场景红灯变绿的一瞬间画面中可能同时出现红和黄两个灯体被点亮算法在同一帧里既报红灯又报黄灯。或者公交车尾灯闪了一下连续两三帧被误检为红灯。解决这类问题要靠状态机——不允许跳变只看连续稳定状态。from collections import deque class TrafficLightState: def __init__(self, history_len10, min_confirm6): self.history deque(maxlenhistory_len) self.min_confirm min_confirm self.current_state unknown def update(self, detected_color): self.history.append(detected_color) counts { red: self.history.count(red), yellow: self.history.count(yellow), green: self.history.count(green), } max_color max(counts, keycounts.get) if counts[max_color] self.min_confirm: self.current_state max_color return self.current_state state_machine TrafficLightState() # 每帧调用一次detected_color 为上一步识别出的颜色无目标时为 None # state state_machine.update(detected_color)这个状态机的核心思路是“少数服从多数且要过半数”。10 帧的历史窗口里同一颜色出现 6 次及以上才确认切换。这样单帧误检会被淹没在多数帧里而真实的灯色切换因为有硬件电路保证点亮时序是稳定的6 帧的确认延迟在人眼感知上不超过 200 毫秒按 30fps 算完全可以接受。history_len和min_confirm是性能旋钮路口场景车流密集、对实时性要求高可以缩到 8 帧和 5 帧夜间场景噪声大可以放大到 15 帧和 10 帧。要注意的是maxlen的 deque 在 Python 里是线程安全的但如果检测逻辑跑在多线程里给update加一把锁会更稳妥。4.3 把三种颜色串起来完整流程的最小可运行代码把颜色分割、形态学处理、轮廓验证、状态机串成一个完整的帧处理函数。这里我以红灯检测为例黄灯和绿灯的阈值按同样的方法标定后重复调用即可。def process_frame(frame_bgr, state_machine): img_hsv cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2HSV) # 红、黄、绿分别做掩膜阈值按实际标定结果替换 masks { red: red_mask(img_hsv), yellow: yellow_mask(img_hsv), green: green_mask(img_hsv), } detected None for color_name, mask in masks.items(): kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for c in contours: area cv2.contourArea(c) if area 1000 or area 40000: continue if not is_circle_like(c): continue detected color_name break if detected: break current_state state_machine.update(detected) return current_state这段代码把前面所有逻辑压缩成了一个函数。注意它的设计原则三种颜色在同一帧里理论上只有一个会被点亮箭头灯和黄灯是例外所以检测到第一个有效目标就跳出循环。实际项目中如果遇到箭头灯和圆盘灯同时亮的场景这个假设不成立需要改为分别追踪每个灯位本文不展开。5. 避坑手册交通信号灯检测最常见的 5 个翻车现场5.1 阈值标定完换张图就检测不到现象同一套 HSV 阈值在标定图上效果完美换一段新视频后红灯直接漏检。原因标定采样点太少只覆盖了同一种光照条件或者摄像头白平衡在自动切换。解决至少要采三组不同场景的图像——晴天顺光、晴天逆光、阴天——把三组阈值合并成并集或者做一个简单的光照估计动态调整 V 通道下限。# 动态调整的简化版按画面整体亮度缩放 V 的下限 brightness np.mean(img_hsv[:, :, 2]) v_lower int(np.clip(100 * brightness / 128, 60, 160))这段代码的思路是把 V 下限从固定值改成跟画面平均亮度联动。画面暗时降低下限防止漏检画面亮时升高下限压制阴影噪声。参数100和128是参考基线的经验值要按自己的场景重新标定。5.2 把绿色信号灯和树叶搞混现象绿树背景里绿灯检测框疯狂抖动树叶被当成绿色信号灯。原因绿色在 HSV 的 H 区间覆盖范围广树叶的绿色和信号灯的绿色在 H 通道上大概率重叠。解决增加一个“灯体必须是圆形”的强约束这要比调 HSV 阈值有效得多如果还不行加上位置先验——信号灯一般在画面上半部分可以通过 ROI 限制检测区域。5.3 findContours 版本差异导致代码直接报错现象代码在 OpenCV 4.x 上正常换到 OpenCV 3.x 就报too many values to unpack。原因OpenCV 3.4 之前findContours返回 3 个值3.4 之后返回 2 个值。解决用contours, _ cv2.findContours(...)这种写法不要写_, contours, hierarchy ...。这个报错在搜索引擎里出现频率极高属于换环境必踩的坑。5.4 黄灯总是漏检现象红灯和绿灯检测稳定黄灯时好时坏。原因黄色灯体的亮度高在 HSV 中 S 值可能偏低尤其是白色灯罩的 LED 黄灯色饱和度不如红灯和绿灯。解决单独给黄色降低 S 下限或者把黄色检测的 V 下限提高。另外黄色和红色在 H 通道上相邻阈值边界如果划得太靠近会出现红黄互报的情况排查时把掩膜可视化出来看边界像素分布。# 黄灯的 S 下限单独放低适配白色灯罩的 LED 黄灯 lower_yellow np.array([15, 60, 120]) upper_yellow np.array([35, 255, 255])S 下限 60 比红灯的 80 低了一个档位这是对黄色灯珠饱和度偏低的妥协。代价是背景里黄色的路牌、车牌、车身也可能被带进来所以黄灯的圆形度阈值建议收紧到 0.75 以上。5.5 夜间灯光出现星芒灯体轮廓被切断现象夜间拍摄时信号灯周围出现放射状的星芒掩膜上灯体被星芒连接成不规则的形状圆形度验证失败。原因镜头衍射和感光元件过曝。解决在形态学处理前先做一次高斯模糊把星芒的光晕压下去或者把圆形度阈值放宽到 0.6并对多帧结果做中值滤波。夜间场景不要追求单帧完美靠状态机的时间累积效果更可靠。6. 参数标定技巧用 ROI 和视频回放把误检率再压一个量级整个项目跑通后提升稳定性的空间基本不在算法结构上而在参数标定和验证方法上。这里分享三个我在调试过程中觉得最值得投入的做法。第一个做法是固定 ROI。如果你检测的是固定位置的路口监控画面完全没有必要对全图做颜色分割。把摄像机朝向信号灯的区域裁出来做一个多边形 ROI 掩膜只在这个区域内跑检测。这能直接砍掉车尾灯、行人红色衣物、路边招牌等绝大部分干扰源。实现上先用cv2.selectROI选一次矩形区域或者是手动标几个点用cv2.fillPoly生成掩膜。import cv2 import numpy as np frame cv2.imread(intersection.jpg) # 手动选择信号灯所在区域x, y, w, h 是 ROI 的坐标和宽高 x, y, w, h cv2.selectROI(select roi, frame, showCrosshairTrue) roi_mask np.zeros(frame.shape[:2], dtypenp.uint8) roi_mask[y:yh, x:xw] 255 cv2.destroyAllWindows()ROI 掩膜生成后每次处理帧之前先把图像和掩膜做一次cv2.bitwise_and。程序运行后如果信号灯位置因为摄像机抖动偏出 ROI检测会突然全部消失——这不是算法坏了是 ROI 设置得太紧。预留 10% 的边距能规避这个问题。第二个做法是录一段视频做回归测试。不要每次调参数都对着实时画面看效果那是靠玄学调试。取一段 30 秒到 1 分钟的路口视频里面包含红灯、绿灯、黄灯、无灯四种状态每调整一次阈值就跑一遍全视频统计每种状态的帧数和误检数。我一般把检测结果写进一个 CSV最后一列是当前帧的状态然后用脚本对比标签判断改动是变好还是变坏。这个过程看起来很笨但比肉眼回放效率高得多。第三个做法是画一条“置信度曲线”来验证状态机的参数是否合理。做法是把history.count(max_color)的值输出到日志连续运行一段时间后画出折线图。正常情况下真灯切换时曲线应该有一个快速爬升到满分的上升沿误检发生时曲线应该只出现一个短暂的小尖峰然后回落。如果曲线频繁出现“半高平台”说明最小确认帧数设得太低或者误检源的形状和颜色太接近真灯需要回头调整圆形度阈值。import matplotlib.pyplot as plt # 假设日志里记录了每一帧的最高颜色计数读取到列表 confirm_counts plt.plot(confirm_counts) plt.axhline(y6, colorr, linestyle--, labelmin_confirm) plt.xlabel(frame) plt.ylabel(max color count) plt.legend() plt.show()这张图的价值在于把“感觉上误检变多了”变成“数字上到底哪个环节出了问题”。你会清晰地看到大多数误检并未超过确认阈值真正被误判的只有少数连续噪声帧。最后说一个我个人的习惯每次调完参数一定把阈值、核大小、确认帧数写进一个配置文件或者代码顶部的注释里并记下采集视频时的天气和时段。否则两周后回来你会完全不记得当时为什么设 S 下限为 80 而不是 90。这个项目本身就是为了理解图像处理的各个环节而存在的保留调试记录就是保留你排除错误判断的完整链路。希望这些踩坑经验能帮你少走两步弯路也希望你能在这个项目上找到自己调试参数的手感。本文还有配套的精品资源点击获取
返回列表