☰
基于OpenCV与YOLO的交通红灯违规检测:检测器与跟踪器联动实现精确识别
2026/10/13 4:08:11 网站建设 项目流程

简介:面向智慧城市交通管理场景的OpenCV交通红灯违规检测项目,适合计算机视觉方向的学生、工程师以及智能交通系统研究者。项目基于Python实现了由对象检测器与对象跟踪器构成的集成方案,核心任务是精确定位闯红灯车辆,并已在测试中做到所有违规车辆均被检测到且精确区分,输出结果准确。资源压缩包约76.31MB,内含项目运行代码与模型配置,其中掩膜部件运行精度达到85%,级联模型仍处于测试模式,留有明确的调优空间。目前已有302人学习下载,项目模块划分清晰,读者可参照检测与跟踪的协作逻辑,结合掩膜处理思路继续提升级联模型,并将整体方案迁移至其他车辆违规识别场景。适合作为课程设计、毕业设计或智慧交通原型验证的动手实践范本,能够帮助快速理解OpenCV在实时检测与跟踪任务中的实际用法。

1. 自动交通红灯违规检测:OpenCV联动检测器与跟踪器,把违章车辆精确框出来

做交通违规检测最容易翻车的点,不是模型检测不出车,而是帧与帧之间的身份对不上。这套基于 Python 与 OpenCV 实现的自动交通红灯违规检测系统,核心由目标检测器和目标跟踪器组成:检测器负责在当前画面里找出每辆车的边界框,跟踪器负责让边界框跨帧持有稳定 ID,两者集成后精确保持车辆位置,最终回答“红灯期间哪辆车压线了、车现在在哪”。系统在路况监控视频上能区分并报告全部违章车辆,结果不重不漏。适用人群:智慧城市视觉算法方向的开发与研究人员、交通管理监控软件从业者,以及想完整走通一个 CV 检测加跟踪落地项目的开发者。

2. 双引擎架构:检测器加跟踪器为什么是交通违规检测的及格组合

2.1 检测器选型:为什么用 OpenCV DNN 加载 YOLO,而不是纯边缘检测

交通场景里车辆密集、光照变化大,靠 Canny 边缘检测加轮廓提取这类传统方法,在空旷路面勉强能跑,一旦进入早晚高峰,车身互相遮挡、阴影叠在一起,轮廓就分不清谁是谁了,误检率会直接击穿项目验收线。所以检测器必须用深度模型,但也不必直接上 PyTorch 推理。

我一般选 YOLOv4 权重加 OpenCV DNN 模块加载。理由很实际:少装一个深度学习框架,部署环境干净很多;OpenCV 的cv2.dnn.readNet直接读权重的.weights与.cfg两个文件,内存占用低,在 CPU 上也能跑。YOLO 对小车、遮挡车辆的召回率在同等算力下比 SSD 稳,这是红灯路口最看重的指标。

import cv2 # 加载 YOLOv4 模型权重和网络配置 net = cv2.dnn.readNet('yolov4.weights', 'yolov4.cfg') layer_names = net.getLayerNames() output_layers = [layer_names[i - 1] for i in net.getUnconnectedOutLayers()] # 有 NVIDIA 显卡时优先启用 CUDA 后端,没有就注释掉下面两行 try: net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA) net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA) except cv2.error: print("CUDA 不可用,回退到 CPU 推理")

权重和配置加载后,真正要理解的是output_layers的取法。YOLO 的检测头不止一个尺度,getUnconnectedOutLayers()返回的是输出层索引,减 1 是因为这个版本的 OpenCV 从 0 开始编号;如果你用旧版本 OpenCV,索引从 1 开始,不减 1。这种版本差异是排查“预测结果全是乱框”时的第一嫌疑点。

2.2 跟踪器选型:IOU 匹配为什么比 KCF 更适合路口监控

检测器每帧都能给出车框,但两个相邻帧的框之间没有“我是同一辆车”的关联。如果没有跟踪器,第一帧检测出的红色轿车和第二帧检测出的红色轿车在逻辑上是两个独立物体,系统永远无法知道这辆车是停着还是开着,更无法判断压线。

主流选择有三种:KCF 类相关滤波、DeepSORT 类深度学习重识别、以及基于 IOU 的简单匈牙利匹配。我在这个项目里倾向第三种。原因不复杂:路口监控视角固定,车辆运动幅度在两帧之间有限,前后帧同一辆车的框一定有交叠,IOU 匹配在这种刚性场景下已经足够;KCF 对尺度变化敏感,车辆从远到近框会快速放大,容易跟丢;DeepSORT 要额外跑一个 ReID 网络,边缘设备算力扛不住。

class SimpleTracker: """基于 IOU 匹配的轻量多目标跟踪器,用历史轨迹与检测框做关联""" def __init__(self, iou_threshold=0.3, max_age=30): self.tracks = [] # 每条轨迹为 [id, bbox, age, history] self.next_id = 0 self.iou_threshold = iou_threshold self.max_age = max_age # 目标丢失后保留轨迹的最大帧数 def _iou(self, a, b): """计算两个框的交并比,用于判断是否为同一辆车""" x1, y1, x2, y2 = a[0], a[1], a[0] + a[2], a[1] + a[3] x1b, y1b, x2b, y2b = b[0], b[1], b[0] + b[2], b[1] + b[3] inter_x = max(0, min(x2, x2b) - max(x1, x1b)) inter_y = max(0, min(y2, y2b) - max(y1, y1b)) inter_area = inter_x * inter_y union_area = a[2] * a[3] + b[2] * b[3] - inter_area return inter_area / union_area if union_area > 0 else 0 def update(self, detections, frame_idx): """每帧调用 detections,返回更新后的轨迹列表""" matched_idx = set() updated = [] for tid, bbox, age, history in self.tracks: best_iou = 0 best_det = -1 for i, det in enumerate(detections): if i in matched_idx: continue iou_val = self._iou(bbox, det) if iou_val > best_iou: best_iou = iou_val best_det = i if best_det >= 0 and best_iou >= self.iou_threshold: matched_idx.add(best_det) new_bbox = detections[best_det] updated.append([tid, new_bbox, 0, history + [(frame_idx, new_bbox)]]) elif age < self.max_age: # 没有匹配到检测框,但轨迹还没过期,继续保留 updated.append([tid, bbox, age + 1, history]) # 超过 max_age 的轨迹直接丢弃 for i, det in enumerate(detections): if i not in matched_idx: updated.append([self.next_id, det, 0, [(frame_idx, det)]]) self.next_id += 1 self.tracks = updated return self.tracks

这段代码里history保存了每辆车从出现到当前帧的全部边框,后面违规判定要靠这段轨迹判断“是否压线”。max_age参数控制目标短暂被遮挡时轨迹能存活多久:设 30 表示 30 帧内没检测到就放弃;如果路口有大车遮挡后面小车,这个值不能太小。

2.3 检测与跟踪的节奏控制:检测频率不是越高越好

检测器虽准但贵,跟踪器便宜但会漂。常见的做法是每隔 N 帧跑一次完整检测,中间帧直接沿用上次检测结果,由跟踪器负责“记住”车的位置。N 的取值直接影响系统实时性:N 太大,检测框更新慢,车辆突然加速或变道,跟踪框会跟着错误位置漂移;N 太小,CPU 功耗和延迟直接上来。

DETECT_EVERY = 5 # 每 5 帧做一次完整检测 frame_idx = 0 while True: ret, frame = cap.read() if not ret: break if frame_idx % DETECT_EVERY == 0: detections = detect_vehicles(frame, net, output_layers, CONF_THRESH, NMS_THRESH) else: detections = [] # 中间帧不产生新检测,交给跟踪器维持状态 tracks = tracker.update(detections, frame_idx) frame_idx += 1

注意中间帧把detections置为空列表,不是丢了检测结果,而是让跟踪器用自己的预测继续存活。这样设计后,CPU 上推理 1080p 视频从不到 10 帧提高到 20 帧左右,代价是车辆快速并线时框的位置会有几帧滞后。要平衡这个滞后,可以在update里加一个卡尔曼滤波做位置预测,但这属于进阶优化,基础版先用 IOU 匹配撑住。

3. 完整实现:从视频帧到违规车辆定位的三段式流程

3.1 前置配置:停止线与信号灯区域的交互标注

检测和跟踪都就绪后,还有一个绕不开的问题:系统怎么知道停止线在哪?怎么知道当前红灯亮没亮?这两件事必须人工指定,因为不同路口摄像头角度不同,信号灯位置不同。我用cv2.selectROI直接在视频第一帧上框选,避免硬编码坐标。

import cv2 cap = cv2.VideoCapture('traffic.mp4') ret, first_frame = cap.read() if not ret: raise IOError("视频无法读取") # 框选停止线区域:一个细长的横向矩形,覆盖车道宽度 stop_line_roi = cv2.selectROI("Select STOP LINE", first_frame) cv2.destroyWindow("Select STOP LINE") # 框选信号灯区域:红灯亮起时这里会出现红色像素块 light_roi = cv2.selectROI("Select RED LIGHT", first_frame) cv2.destroyWindow("Select RED LIGHT") # 停止线的 y 坐标取矩形中心线的 y STOP_LINE_Y = int(stop_line_roi[1] + stop_line_roi[3] / 2) RED_LIGHT_ROI = light_roi

selectROI返回的是(x, y, w, h),在车道上横向拉一条覆盖所有车道的细长框,停止线位置取框的中线 y 坐标。这里最常见的错误是框选覆盖了人行横道或其他标志线,导致压线判定提前或滞后,所以框选后最好回放几帧确认。

3.2 主循环:检测、跟踪与红灯状态获取

整套系统的主循环在每一帧里做三件事:检测车辆、更新跟踪器、读取红灯状态。红灯状态我用一个 HSV 色彩过滤来识别,因为信号灯区域固定,红色像素占比超过阈值就可以判定为红灯。

def detect_red_light(frame, roi, red_ratio_threshold=0.05): """在信号灯 ROI 内统计红色像素比例,判断是否红灯""" x, y, w, h = roi crop = frame[y:y + h, x:x + w] hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV) # 红色在 HSV 色彩空间里分布在两个区间,必须分别取掩码再合并 mask_low = cv2.inRange(hsv, (0, 100, 100), (10, 255, 255)) mask_high = cv2.inRange(hsv, (160, 100, 100), (179, 255, 255)) mask = cv2.bitwise_or(mask_low, mask_high) red_pixels = cv2.countNonZero(mask) red_ratio = red_pixels / (w * h) return red_ratio > red_ratio_threshold

red_ratio_threshold取 0.05 是经验值。信号灯红点在 ROI 里占的面积通常小于 5%,但镜头里如果有红色尾灯或红色广告牌进入 ROI,这个值就需要调高到 0.1 以上。注意 HSV 中红色跨了两个区间,从 0 到 10 和从 160 到 179,只写一个会漏掉一半红灯状态。

3.3 违规判定逻辑:压线时机与红灯状态同步

违规判定的核心是压线检测。我取每辆车轨迹里最近两帧的 bbox 左上角 y 坐标(即车头边缘),如果上一帧车头在停止线上方、当前帧在停止线下方,说明车辆正在跨越停止线。此时若红灯状态为真,且红灯已经持续了一段时间(排除绿灯结束瞬间黄灯转红的争议场景),就记录为一次违规。

def check_violation(track, stop_line_y, is_red, red_duration): """根据轨迹与停止线位置判断是否发生违规""" history = track[3] # 从 track 结构里取出历史位置列表 if len(history) < 2: return False prev_bbox = history[-2][1] curr_bbox = history[-1][1] prev_front_y = prev_bbox[1] curr_front_y = curr_bbox[1] crossed_now = (prev_front_y < stop_line_y) and (curr_front_y >= stop_line_y) if not crossed_now: return False # 红灯必须持续超过 5 帧,避免信号灯刚切换时的误判 if not is_red or red_duration < 5: return False return True

这里有一个容易被忽略的细节:bbox的 y 坐标表示边框左上角,也就是车头方向那个边缘,用它的 y 值和停止线比较,代表的是车头是否经过停止线,而不是车身中心。如果拿 bbox 中心点做判断,车尾还没过线就会提前报违规,误报率会大幅上升。

violations = [] red_frames = 0 while True: ret, frame = cap.read() if not ret: break if frame_idx % DETECT_EVERY == 0: detections = detect_vehicles(frame, net, output_layers, CONF_THRESH, NMS_THRESH) else: detections = [] tracks = tracker.update(detections, frame_idx) # 红灯状态与持续时间 if detect_red_light(frame, RED_LIGHT_ROI): red_frames += 1 else: red_frames = 0 for track in tracks: if check_violation(track, STOP_LINE_Y, red_frames > 0, red_frames): car_id = track[0] violations.append({ 'car_id': car_id, 'frame': frame_idx, 'bbox': track[1] }) print(f"违规车辆 #{car_id} 在帧 {frame_idx} 被记录") frame_idx += 1

red_frames是连续红灯帧的计数器,用它替代单独的红灯状态判断,能滤掉信号灯刚切换瞬间的模糊帧。违规记录里同时保存了car_id、frame和bbox,这三项足够后续回溯定位违规车辆的位置。

4. 联调避坑:检测加跟踪最容易翻车的四个问题

4.1 同一辆车被当成两辆车,重复报违规

现象:系统在同一个路口对同一辆红色轿车连续输出两条违规记录,且两条记录的car_id不同。

原因:检测阶段 NMS 没有把高度重叠的两个框合并,跟踪器把两个框当成了两辆不同车辆,各自生成独立轨迹。常见诱发因素是把卡车和轿车两类都算作检测目标,两个类别的框同时输出,重叠度高但 NMS 只抑制同类框,跨类别不抑制。

解决:在跟踪器的 IOU 匹配阈值之外,对检测结果做一次额外的跨类别 NMS。也就是把所有车辆类别的框放在同一个集合里做 NMS,而不是按类别分别做。把cv2.dnn.NMSBoxes的nms_thresh从 0.4 调到 0.3,也能降低同类重复框,但对跨类别无效,跨类别必须统一处理。

4.2 车辆进入阴影区域后轨迹丢失,违规被漏报

现象:一辆车从树荫下穿过时,检测器突然检测不到它,跟踪器在max_age=30帧后删除轨迹,等车从阴影里出来时生成了新 ID,违规判定因为“轨迹断裂”直接漏掉。

原因:阴影降低了目标与背景的对比度,YOLO 在暗区域召回率下降。而max_age设得太短,车辆穿过阴影时长超过 30 帧,轨迹就断了。

解决:把max_age从 30 提到 60,同时把DETECT_EVERY从 5 降到 3,让检测更密集地刷新阴影区域的车辆位置。如果场景里阴影特别重,可以加一个简单的自适应亮度归一化:

def normalize_frame(frame): """对低对比度区域做自适应直方图均衡""" lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB) l, a, b = cv2.split(lab) l_eq = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(l) merged = cv2.merge([l_eq, a, b]) return cv2.cvtColor(merged, cv2.COLOR_LAB2BGR)

在把frame传入detect_vehicles之前先跑normalize_frame,阴影区车辆被召回的概率显著提高。注意这个函数只在检测分支里用,跟踪器的 IOU 匹配仍用原始帧,避免同时引入位置偏移。

4.3 固定矩形 ROI 在斜路口失效

现象:停止线不是水平横线,而是随车道带一定斜度,用矩形 ROI 的中心线做停止线后,左车道和右车道的实际停止线位置差了 20 个像素,导致同一时刻左车道车已过线、右车道车未过线,却同时被判违规。

原因:相机安装在杆侧而不是路口正上方时,透视会造成远处车道的停止线在画面中往上偏移,近处往下偏移,一条直线无法代表所有车道。

解决:把“一条停止线”改成“每个车道一条停止线”。先框选每个车道对应的停止线区域,分别记录STOP_LINE_Y,在违规判定时先判断车辆 x 中心落在哪个车道区间,再使用该区间对应的 y 值。多车道配置可以放在一个配置文件里,修改时不用改代码。

4.4 红灯判定受画面遮挡影响,信号状态丢失

现象:路口偶尔有大货车经过,车身短时间挡住信号灯 ROI,detect_red_light返回非红灯,red_frames清零,已经越过停止线的车辆没有触发违规记录。

原因:红色像素占比法只适用于信号灯一直可见的状态,一旦信号灯被遮挡,算法完全失去信息来源。

解决:红灯状态采用“保持”策略——red_frames归零的规则从“检测到非红灯就清零”改成“连续 10 帧非红灯才清零”。也就是说,短暂 2 到 3 帧的遮挡不会让红灯状态失效,超过 10 帧的真实绿灯切换才能中止红灯状态。这个策略牺牲对极短黄灯闪烁的敏感性,但极大减少了因遮挡导致的漏报。

4.5 车头方向与车辆行驶方向不匹配,压线判断反向

现象:系统把从画面下方往上行驶的车辆误判为压线违规,而这些车实际在远离停止线。

原因:我早期写判定逻辑时假设所有车辆都是自上而下行驶,直接用bbox[1] < stop_line_y作为“在线上方”的判断,没有考虑双向车道或相机安装角度导致的行驶方向反转。

解决:在配置里增加一个direction参数,正向行驶时用prev_front_y < stop_line_y and curr_front_y >= stop_line_y;反向行驶时用prev_front_y > stop_line_y and curr_front_y <= stop_line_y。方向参数在配置文件里统一维护,避免每个函数写死。

5. 参数边界与调优:一份可直接套用的调整清单

5.1 关键参数速查表

这套系统真正需要反复调整的参数并不多,但每个参数对结果的影响都很直接。我整理了一份在多个路口样本上调过的参数范围,可以直接作为起点:

参数推荐值取值范围调整依据
CONF_THRESH0.50.3 ~ 0.6车辆遮挡严重时下调,误检多时上调
NMS_THRESH0.40.2 ~ 0.5并排车辆多时上调,防止合并相邻车辆框
DETECT_EVERY51 ~ 20算力紧张时增大,车辆速度快时减小
TRACK_IOU0.30.2 ~ 0.5目标小、帧率低时降低,防止丢失
MAX_AGE3015 ~ 90场景遮挡频繁时增大
RED_RATIO_THRESH0.050.03 ~ 0.15ROI 面积大时下调,有其他红光干扰时上调

这些参数之间不是独立的。比如把DETECT_EVERY从 5 调整到 15,跟踪器两帧之间的车辆位移变大,IOU 匹配得分下降,这时必须同步把TRACK_IOU从 0.3 降到 0.2,否则会大面积跟丢。调整规则可以总结为:检测间隔越大,跟踪匹配阈值越要放宽。

5.2 用少量人工标注验证系统精度

参数调得好不好,得靠数据说话。准备一段 5 到 10 分钟的路口视频,人工标出所有违规车辆出现的帧号和位置,然后和系统输出做比对。

def evaluate(gt_path, pred_path): """比较人工标注与系统输出,计算精确率和召回率""" gt = set() with open(gt_path) as f: for line in f: frame_id, car_id = map(int, line.strip().split()) gt.add((frame_id, car_id)) pred = set() with open(pred_path) as f: for line in f: frame_id, car_id = map(int, line.strip().split()) pred.add((frame_id, car_id)) tp = len(gt & pred) fp = len(pred - gt) fn = len(gt - pred) precision = tp / (tp + fp) if (tp + fp) else 0 recall = tp / (tp + fn) if (tp + fn) else 0 print(f"精确率: {precision:.2f}, 召回率: {recall:.2f}, 误报: {fp}, 漏报: {fn}")

评估文件用frame_id car_id两列就够了。注意car_id在系统里是跟踪器生成的自增 ID,人工标注时不需要和系统 ID 一致,比对逻辑只看帧号是否匹配。我一般要求精确率和召回率都达到 0.9 以上才认为参数合格,达不到就先调CONF_THRESH和DETECT_EVERY,这两个对整体影响最大。

5.3 CPU 推理的性能瓶颈与取舍

实测在普通 CPU 上跑 1080p 视频,YOLOv4 单帧推理耗时约 200 到 400 毫秒,明显无法实时。加速手段无非三件套:降低输入分辨率、降低检测频率、缩小检测类别。

# 输入尺寸从 416 降到 320,推理耗时下降约 40% blob = cv2.dnn.blobFromImage(frame, 1/255.0, (320, 320), swapRB=True, crop=False)

降输入分辨率对小车召回影响最大,所以如果画面里车辆距离较远,不建议低于 320。更合理的做法是只检测car和truck两类,去掉bus、motorcycle等类别,减少输出层计算量。牺牲部分类别换来每帧几十毫秒的加速,在资源受限项目里值得。

6. 从检测结果到执法取证:把违规记录输出成证据链

6.1 自动裁剪违规车辆前后帧,拼接成可追溯的视频片段

记录下来的违规记录如果不能回溯,价值会大打折扣。我习惯在判定违规后,直接抓取违规车辆越过停止线前后的连续帧,压上车辆 ID 和时间戳,存成一段独立视频。这段视频就是完整的证据链。

def export_evidence(video_path, violation, before=30, after=60): """提取违规车辆前后共 90 帧,合成证据视频""" cap = cv2.VideoCapture(video_path) start = max(0, violation['frame'] - before) end = violation['frame'] + after writer = None for i in range(start, end): cap.set(cv2.CAP_PROP_POS_FRAMES, i) ret, frame = cap.read() if not ret: continue # 在车辆框上叠加 ID 和帧号 x, y, w, h = violation['bbox'] cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 0, 255), 2) cv2.putText(frame, f"Vehicle #{violation['car_id']}", (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) cv2.putText(frame, f"Frame {i}", (20, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) if writer is None: h_f, w_f = frame.shape[:2] writer = cv2.VideoWriter( f"violation_{violation['car_id']}.avi", cv2.VideoWriter_fourcc(*'XVID'), 25, (w_f, h_f)) writer.write(frame) writer.release()

把证据裁剪和主检测流程分开,好处是主流程只负责记录,不负责 I/O 写入,不会因为写视频拖慢检测循环。我一般会把这些证据统一放到evidence/目录,按日期和车辆 ID 命名,后续再批量归档。

6.2 配置外部化:换路口不换代码

每换一个路口,停止线位置、信号灯 ROI、车道方向、检测阈值全都要变。把这些参数抽到一个config.json里,主程序启动时读取,就不用每次改代码了。

{ "stop_line_rois": [{"x": 120, "y": 480, "w": 800, "h": 10}], "light_roi": {"x": 40, "y": 10, "w": 180, "h": 80}, "direction": "top_to_bottom", "conf_thresh": 0.5, "nms_thresh": 0.4, "detect_every": 5 }

我用cv2.selectROI框选完直接把这些值写入 JSON,下次启动直接加载,省掉了重复交互标注。后来我甚至写了一段自动校验逻辑,启动时读配置文件、用第一帧画一遍 ROI,确认配置没有过期。从那以后,每次部署到新路口,我都强制自己先跑一遍校验脚本,确认配置和当前视频画面匹配,再正式启用检测。这套流程帮我至少避免了两次因为 ROI 坐标过期导致的批量误报。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询