简介:这是一套基于Python与OpenCV实现的红绿灯识别系统源代码,面向计算机视觉初学者、课程设计或自动驾驶入门开发者,帮助理解颜色空间转换、阈值分割与形状分析在交通灯识别中的落地方式。压缩包共10个文件,约23KB,包含5个py脚本(主程序、GUI界面与多个识别版本)、3个json配置、1个png预览图及gitignore,结构轻量便于快速运行与二次修改。系统提供图形界面,支持滤镜选择与色彩阈值调整,可适应不同光照与摄像头视角,并输出实时识别结果。已有651人学习下载。读者可从中获得完整的图像预处理、HSV色彩过滤、红绿灯状态判定与GUI交互实现思路,同时接触光照变化、实时性与错误处理等工程考量,适合作为计算机视觉项目练手与功能扩展的参考模板。
1. 红绿灯识别系统:从一段路口视频到能跑起来的 GUI
手头有一段行车记录仪拍的路口视频,想让它自动判断当前是红灯还是绿灯,这件事听起来像是个深度学习项目,实际上用 python-opencv 的传统图像处理思路就能做出一个能演示、能交互的版本。这套红绿灯识别系统源代码的核心逻辑并不复杂:把图像从 BGR 转到 HSV 空间,按颜色阈值把红、黄、绿三个区域抠出来,再做形态学去噪和轮廓筛选,最后根据亮起的灯位判断当前信号。配上 GUI 界面之后,你可以直接拖入图片或视频,实时看到识别结果和置信度。它适合两类人:一类是刚学完 opencv 图像处理、想找一个完整项目练手的入门者;另一类是需要快速搭一个交通灯智能识别演示原型、验证算法可行性的工程师。整套方案不依赖 GPU,普通笔记本就能跑,重点在于参数调优和工程细节。
2. 颜色阈值分割:HSV 空间为什么比 RGB 更适合红绿灯
2.1 从 BGR 到 HSV 的转换逻辑与参数含义
opencv 读进来的图像默认是 BGR 三通道,直接在这个空间里做颜色判断会非常痛苦,因为光照一变,R、G、B 三个值同时漂移,你很难画出一条固定的分界线。HSV 把颜色拆成色调(Hue)、饱和度(Saturation)、明度(Value)三个维度,红绿灯的颜色差异主要体现在色调上,光照强弱主要影响明度,这样就能把颜色判断和亮度变化解耦开。
转换本身只需要一行:
import cv2 import numpy as np # 读取图像,opencv 默认按 BGR 通道顺序读入 frame = cv2.imread("traffic_light.jpg") # 转换到 HSV 色彩空间,后续所有颜色判断都在这个空间做 hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)这里有个容易翻车的点:opencv 的 H 通道取值范围是 0 到 179,不是常见的 0 到 360。这是因为 8 位图像里 H 被压缩了一半,红色在标准色环上跨 0 度,在 opencv 里就变成了 H 接近 0 和接近 179 两段。所以红色阈值必须写两组,不能只写一组。
2.2 红黄绿三色的阈值范围与掩膜生成
下面是我一般会用的初始阈值,实际项目里要根据摄像头白平衡和路灯色温微调:
# 红色在 HSV 中跨越 0 度,需要两段阈值合并 lower_red1 = np.array([0, 100, 100]) upper_red1 = np.array([10, 255, 255]) lower_red2 = np.array([160, 100, 100]) upper_red2 = np.array([179, 255, 255]) # 黄色范围较窄,集中在 20 到 35 之间 lower_yellow = np.array([20, 100, 100]) upper_yellow = np.array([35, 255, 255]) # 绿色范围相对宽,但要注意避开青色干扰 lower_green = np.array([40, 80, 80]) upper_green = np.array([90, 255, 255]) # 生成掩膜,每个颜色得到一张二值图 mask_red1 = cv2.inRange(hsv, lower_red1, upper_red1) mask_red2 = cv2.inRange(hsv, lower_red2, upper_red2) mask_red = cv2.bitwise_or(mask_red1, mask_red2) mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow) mask_green = cv2.inRange(hsv, lower_green, upper_green)参数说明:每个 np.array 里的三个值依次是 H、S、V 的下限或上限。S 和 V 的下限设成 100 和 100 是为了过滤掉灰白色和暗色区域,避免把白色车身或灰色路面误判成灯。如果发现夜间识别不到,可以把 V 的下限降到 50 左右;如果白天误检多,把 S 的下限提到 120 以上。
2.3 形态学处理:去掉噪点、补上空洞
掩膜出来之后通常会有很多小噪点,灯的中心也可能因为反光出现空洞。这时候用开运算去噪、闭运算补洞:
# 定义一个 5x5 的椭圆结构元素,比矩形更贴合圆形灯体 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) # 开运算:先腐蚀后膨胀,去掉孤立噪点 mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_OPEN, kernel) # 闭运算:先膨胀后腐蚀,填补灯体内部空洞 mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, kernel) # 对黄色和绿色做同样处理 mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_OPEN, kernel) mask_yellow = cv2.morphologyEx(mask_yellow, cv2.MORPH_CLOSE, kernel) mask_green = cv2.morphologyEx(mask_green, cv2.MORPH_OPEN, kernel) mask_green = cv2.morphologyEx(mask_green, cv2.MORPH_CLOSE, kernel)kernel 大小是个经验值。5x5 适合 640x480 到 1280x720 的分辨率,如果图像更大,比如 1920x1080,建议用 7x7 或 9x9。kernel 太小去不掉噪点,太大会把远处的小灯也一起吃掉。这一步做完,掩膜上基本只剩下几个干净的色块。
3. 轮廓筛选与灯位判断:怎么从一堆色块里找到真正的信号灯
3.1 轮廓提取与面积、宽高比过滤
掩膜上可能同时存在多个色块,比如红色尾灯、红色广告牌、红色衣服。需要靠几何特征把真正的信号灯挑出来:
def find_candidates(mask, min_area=50, max_area=5000): """从掩膜中提取候选轮廓,按面积和宽高比过滤""" contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: area = cv2.contourArea(cnt) # 面积太小是噪点,太大可能是大面积红色物体 if area < min_area or area > max_area: continue x, y, w, h = cv2.boundingRect(cnt) aspect_ratio = w / float(h) # 信号灯近似圆形或略扁,宽高比在 0.5 到 2.0 之间 if aspect_ratio < 0.5 or aspect_ratio > 2.0: continue # 填充率:轮廓面积与外接矩形面积之比,圆形约 0.78 fill_ratio = area / (w * h) if fill_ratio < 0.5: continue candidates.append((x, y, w, h, area)) return candidatesmin_area 和 max_area 需要根据实际画面里灯的大小来定。如果摄像头离路口远,灯在画面里只有十几个像素,min_area 要降到 20 左右。fill_ratio 这个指标很实用,它能过滤掉细长条形的红色物体,比如红色栏杆或红色车身条纹。
3.2 用位置关系排除干扰:灯位排列的几何约束
真正的红绿灯三个灯是垂直或水平排列的,而且同一时刻通常只有一个灯亮。利用这个先验知识可以大幅降低误判:
def group_traffic_lights(red_cands, yellow_cands, green_cands): """将三个颜色的候选框按位置聚类,判断是否属于同一组信号灯""" all_cands = [] for c in red_cands: all_cands.append(("red", c)) for c in yellow_cands: all_cands.append(("yellow", c)) for c in green_cands: all_cands.append(("green", c)) # 按 x 坐标排序,水平排列的信号灯 x 依次增大 all_cands.sort(key=lambda item: item[1][0]) groups = [] used = [False] * len(all_cands) for i in range(len(all_cands)): if used[i]: continue group = [all_cands[i]] used[i] = True for j in range(i + 1, len(all_cands)): if used[j]: continue # 两个候选框中心点距离小于阈值,认为是同一组 xi, yi, wi, hi, _ = all_cands[i][1] xj, yj, wj, hj, _ = all_cands[j][1] cx1, cy1 = xi + wi / 2, yi + hi / 2 cx2, cy2 = xj + wj / 2, yj + hj / 2 dist = ((cx1 - cx2) ** 2 + (cy1 - cy2) ** 2) ** 0.5 if dist < max(wi, hi) * 3: group.append(all_cands[j]) used[j] = True groups.append(group) return groups这个聚类逻辑的核心是:同一组信号灯的候选框中心距离不会太远,大概是灯体尺寸的 2 到 3 倍。如果画面里同时出现多个路口的灯,这个分组能把它们分开,后续每组独立判断。
3.3 亮灯判定与置信度输出
一组信号灯里,哪个颜色面积最大、填充率最高,就判定为当前亮灯:
def judge_signal(group): """输入一组候选框,输出当前信号灯颜色和置信度""" if not group: return "unknown", 0.0 # 按面积排序,取最大的作为主候选 group.sort(key=lambda item: item[1][4], reverse=True) color, (x, y, w, h, area) = group[0] # 置信度用填充率表示,越接近 1 越像完整灯体 fill_ratio = area / (w * h) confidence = min(fill_ratio, 1.0) # 如果同组里有多个颜色面积接近,说明判断不可靠 if len(group) > 1: second_area = group[1][1][4] if second_area > area * 0.7: return "uncertain", confidence * 0.5 return color, confidence置信度低于 0.5 的时候,界面上应该显示“不确定”而不是硬给一个结果。实际路口视频里,黄灯和红灯切换的瞬间经常出现两个颜色同时可见的情况,这时候输出 uncertain 比输出错误答案更合理。
4. GUI 界面搭建:用 tkinter 把识别流程串起来
4.1 界面布局与控件选择
python 自带的 tkinter 足够做这个演示界面,不需要额外装 PyQt。布局上分三块:顶部是文件选择按钮和模式切换,中间是图像显示区,底部是识别结果和参数调节滑块。
import tkinter as tk from tkinter import filedialog, ttk from PIL import Image, ImageTk class TrafficLightGUI: def __init__(self, root): self.root = root self.root.title("红绿灯识别系统") self.root.geometry("900x700") # 顶部控制栏 self.top_frame = tk.Frame(root) self.top_frame.pack(fill=tk.X, padx=10, pady=5) self.btn_image = tk.Button(self.top_frame, text="选择图片", command=self.load_image) self.btn_image.pack(side=tk.LEFT, padx=5) self.btn_video = tk.Button(self.top_frame, text="选择视频", command=self.load_video) self.btn_video.pack(side=tk.LEFT, padx=5) self.btn_stop = tk.Button(self.top_frame, text="停止", command=self.stop_video, state=tk.DISABLED) self.btn_stop.pack(side=tk.LEFT, padx=5) # 图像显示区 self.canvas = tk.Canvas(root, width=860, height=500, bg="gray") self.canvas.pack(padx=10, pady=5) # 底部结果栏 self.bottom_frame = tk.Frame(root) self.bottom_frame.pack(fill=tk.X, padx=10, pady=5) self.result_label = tk.Label(self.bottom_frame, text="等待输入", font=("Arial", 16)) self.result_label.pack(side=tk.LEFT) self.confidence_label = tk.Label(self.bottom_frame, text="置信度: --", font=("Arial", 12)) self.confidence_label.pack(side=tk.RIGHT) self.video_capture = None self.is_playing = False这里用 PIL 的 ImageTk 来显示 opencv 图像,因为 tkinter 原生不支持直接显示 numpy 数组。转换的时候要注意 BGR 转 RGB,否则颜色会偏。
4.2 图片识别流程与结果回显
图片模式的流程很直接:读图、识别、画框、显示。
def load_image(self): path = filedialog.askopenfilename( filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp")]) if not path: return frame = cv2.imread(path) if frame is None: self.result_label.config(text="图像读取失败") return result_frame, color, confidence = self.process_frame(frame) self.show_frame(result_frame) self.result_label.config(text=f"识别结果: {color}") self.confidence_label.config(text=f"置信度: {confidence:.2f}") def process_frame(self, frame): """完整的识别流程,返回画了框的图像和识别结果""" hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) # 生成三个颜色的掩膜 mask_red = self.get_red_mask(hsv) mask_yellow = cv2.inRange(hsv, lower_yellow, upper_yellow) mask_green = cv2.inRange(hsv, lower_green, upper_green) # 形态学处理 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) for mask in [mask_red, mask_yellow, mask_green]: cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, dst=mask) cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, dst=mask) # 提取候选并分组 red_cands = find_candidates(mask_red) yellow_cands = find_candidates(mask_yellow) green_cands = find_candidates(mask_green) groups = group_traffic_lights(red_cands, yellow_cands, green_cands) # 取面积最大的一组作为主信号灯 if groups: groups.sort(key=lambda g: sum(c[1][4] for c in g), reverse=True) color, confidence = judge_signal(groups[0]) # 在图上画出所有候选框 for c in groups[0]: x, y, w, h, _ = c[1] cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2) else: color, confidence = "unknown", 0.0 return frame, color, confidenceprocess_frame 是整个系统的核心,它把前面几章讲的步骤串在一起。注意这里对每个掩膜都做了开闭运算,实际跑的时候如果发现视频卡顿,可以把形态学操作去掉,靠轮廓筛选来兜底。
4.3 视频模式与帧循环处理
视频模式需要在主线程之外跑一个循环,否则界面会卡死。tkinter 的 after 方法可以做到不阻塞界面:
def load_video(self): path = filedialog.askopenfilename( filetypes=[("Video files", "*.mp4 *.avi *.mov")]) if not path: return self.video_capture = cv2.VideoCapture(path) if not self.video_capture.isOpened(): self.result_label.config(text="视频打开失败") return self.is_playing = True self.btn_stop.config(state=tk.NORMAL) self.update_video() def update_video(self): if not self.is_playing or self.video_capture is None: return ret, frame = self.video_capture.read() if not ret: self.stop_video() return # 缩小分辨率加快处理速度 frame = cv2.resize(frame, (640, 360)) result_frame, color, confidence = self.process_frame(frame) self.show_frame(result_frame) self.result_label.config(text=f"识别结果: {color}") self.confidence_label.config(text=f"置信度: {confidence:.2f}") # 每 30 毫秒处理下一帧,约 33fps self.root.after(30, self.update_video) def stop_video(self): self.is_playing = False if self.video_capture: self.video_capture.release() self.video_capture = None self.btn_stop.config(state=tk.DISABLED)视频处理时把分辨率降到 640x360 是个实用技巧,识别精度损失不大,但速度能提升三到四倍。如果原始视频是 1080p,直接处理会非常卡。
5. 避坑与排查:红绿灯识别里最容易翻车的五个地方
5.1 红色总是识别不到或者识别成两段
现象:画面里红灯明明很亮,但掩膜上红色区域是断开的,或者干脆没有。
原因:opencv 的 H 通道红色跨越 0 和 179 两端,只写一组阈值只能覆盖一半。另外如果摄像头白平衡偏冷,红色会往品红方向偏,H 值可能跑到 170 以上。
解决:红色阈值必须写两组并用 bitwise_or 合并。如果还是断,把 H 的上限从 10 放宽到 15,下限从 160 降到 150。用 cv2.cvtColor 转成 HSV 后,可以先把 H 通道单独取出来看直方图,确认红灯的 H 值到底落在哪个区间。
5.2 白天正常、晚上全黑或者全是噪点
现象:白天跑得好好的,一到夜间视频,要么什么都识别不到,要么满屏都是红色噪点。
原因:夜间路灯和车灯造成大面积高亮区域,S 和 V 的分布跟白天完全不同。固定的 S、V 下限在夜间会把很多暗红色物体也框进来。
解决:加一个亮度判断分支。先算整帧的平均 V 值,如果低于 60,判定为夜间模式,把 S 下限降到 60,V 下限降到 40,同时把 min_area 提高到 100 以上,过滤掉小噪点。如果高于 180,判定为强光模式,把 S 下限提到 150,避免白色高光被误判。
5.3 视频处理越来越卡,最后界面无响应
现象:视频刚开始还能跑,几十帧之后越来越慢,最后整个窗口卡死。
原因:update_video 里每帧都创建新的 numpy 数组和 PIL Image 对象,python 的垃圾回收跟不上,内存持续增长。另外如果 process_frame 里做了太多形态学操作,单帧耗时超过 30 毫秒,after 队列会堆积。
解决:把 kernel 的创建提到循环外面,不要每帧都 getStructuringElement。PIL Image 对象用完手动 del 并调用 gc.collect() 不现实,更好的做法是复用同一个 PhotoImage 对象,用 paste 更新内容。如果还是卡,把形态学操作去掉,只保留轮廓面积过滤。
5.4 黄灯和红灯同时亮起时判断混乱
现象:黄灯亮的时候,系统有时候报红色,有时候报黄色,切换瞬间结果跳来跳去。
原因:黄灯的 H 范围是 20 到 35,红灯的上限是 10,中间有 10 个单位的间隔。但实际摄像头里黄灯边缘会有红色渐变,导致同一盏灯同时产生红色和黄色候选框。
解决:在 judge_signal 里加一个优先级规则:如果同一组里同时有红色和黄色候选,且黄色面积大于红色的 60%,判定为黄色。另外在时间维度上做平滑,连续 3 帧结果一致才更新界面显示,避免单帧误判导致结果闪烁。
5.5 绿色把路边的树和青色广告牌也框进来
现象:绿灯识别没问题,但画面边缘的绿色植物或青色招牌经常被误判成绿灯。
原因:绿色的 H 范围 40 到 90 太宽,把青色和部分黄绿色都包进来了。另外植物的纹理复杂,轮廓填充率可能刚好落在阈值边缘。
解决:把绿色的 H 上限从 90 降到 75,下限从 40 提到 45。同时提高 fill_ratio 的下限到 0.6,因为真正的绿灯是均匀发光的圆盘,填充率接近 0.78,而树叶的填充率通常低于 0.5。如果还有误检,加一个位置约束:只保留画面下半部分的绿色候选,因为信号灯通常不会出现在画面顶部。
6. 进阶技巧:用帧间差分和 ROI 把识别速度再提一倍
前面讲的方案是逐帧全图处理,在 640x360 分辨率下大概能跑到 15 到 20 帧。如果想让它在树莓派或者老笔记本上也能流畅跑,有两个技巧值得试。
第一个是 ROI 裁剪。信号灯在画面里的位置通常比较固定,尤其是固定摄像头拍路口。你可以先手动框一个感兴趣区域,后续只在这个区域里做颜色分割。ROI 一般取画面中间偏上的位置,宽度占全图的三分之一到一半。这样处理面积直接降到原来的四分之一,速度提升非常明显。
def crop_roi(frame, roi_ratio=(0.3, 0.1, 0.7, 0.6)): """按比例裁剪 ROI,默认取画面中间偏上区域""" h, w = frame.shape[:2] x1 = int(w * roi_ratio[0]) y1 = int(h * roi_ratio[1]) x2 = int(w * roi_ratio[2]) y2 = int(h * roi_ratio[3]) return frame[y1:y2, x1:x2], (x1, y1)roi_ratio 四个值依次是左、上、右、下的比例。这个参数需要根据你的摄像头安装位置调,没有万能值。调的时候先把 ROI 框画出来看一眼,确保信号灯始终在框内。
第二个是帧间差分。视频里相邻两帧的背景几乎不变,只有信号灯切换的时候对应区域才有明显变化。可以先算当前帧和上一帧的差值,只在差值大的区域做颜色识别。这样大部分帧只需要做一次减法,耗时可以忽略。
class FrameDiffDetector: def __init__(self, threshold=25, min_changed_ratio=0.01): self.prev_gray = None self.threshold = threshold self.min_changed_ratio = min_changed_ratio def need_process(self, frame): """判断当前帧是否需要做完整识别""" gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (5, 5), 0) if self.prev_gray is None: self.prev_gray = gray return True diff = cv2.absdiff(self.prev_gray, gray) _, thresh = cv2.threshold(diff, self.threshold, 255, cv2.THRESH_BINARY) changed_ratio = np.count_nonzero(thresh) / thresh.size self.prev_gray = gray # 变化面积超过阈值才做完整识别 return changed_ratio > self.min_changed_ratiothreshold 控制像素差多少算变化,25 是个比较稳的值,太低会把噪点当变化,太高会漏掉缓慢的灯色切换。min_changed_ratio 控制变化面积占比,0.01 表示画面 1% 的像素变了才触发识别。实际用的时候,信号灯切换那一两帧会触发识别,其余帧直接复用上一次结果,整体帧率能翻倍。
这两个技巧叠加使用的时候要注意顺序:先做 ROI 裁剪,再做帧间差分,最后在 ROI 内做颜色分割。ROI 裁剪减少处理面积,帧间差分减少处理频次,两者不冲突。
我自己调这套系统的时候,最大的教训是不要一上来就追求全自动。先把单张图片的阈值调稳,再跑视频,最后加 GUI。很多人反过来,界面先搭好,结果识别效果一塌糊涂,调参的时候连个稳定的测试基准都没有。另外 HSV 阈值没有一套参数能通吃所有摄像头,换一个设备就要重新标定,这个心理预期要有。希望帮到你。
本文还有配套的精品资源,点击获取