OpenCV+YOLO+Streamlit 交通监控系统实战指南
2026/9/14 4:35:54 网站建设 项目流程

简介:基于OpenCV、YOLO与Streamlit的智能交通监控系统是一套完整代码包,适合计算机视觉初学者、数据应用开发者以及智慧城市相关项目人员学习参考。项目中通过OpenCV完成视频流捕获与预处理,调用YOLO模型对车辆、行人等目标进行实时检测与计数,并借助Streamlit搭建可交互的监控展示界面,能帮助理解目标检测模型在真实交通场景中的落地流程。包体共11个文件,包含4个Python脚本、1个YAML配置、2个模型权重(.pt)、说明文档及运行依赖清单等,整体大小约11.1MB,结构清晰、便于本地运行调试。目前已有125人学习,适合想要快速上手目标检测与Web可视化结合开发的学习者。

1. 为什么用 OpenCV、YOLO 和 Streamlit 搭交通监控

交通监控系统最容易被低估的地方,是它根本不是一个纯目标检测问题。摄像头画面里同时存在运动车辆、静止车辆、行人、阴影、光照突变,还要在长周期里回答“这条路现在堵不堵、过去五分钟过了多少辆车”这类统计问题。只用 YOLO 做单帧检测,得到的是散落的框;只用 OpenCV 做背景建模,得到的是缺少语义的轮廓。这套三件套组合的合理性在于:OpenCV 负责视频采集和图像预处理,YOLO 提供带类别语义的目标框,Streamlit 把实时推理结果变成可交互的 Web 界面。对需要快速验证算法效果、又不想先写一个前端的团队来说,这是一个性价比很高的技术栈。本文会从模型选型、采样策略、车道虚拟线和参数调优几个层面,把这套方案掰开讲清楚,适合正在做智慧交通 Demo、毕设、园区安防原型,或者想把已有 YOLO 模型快速 Web 化的开发者。

2. 先定技术底座:YOLO 版本选择、OpenCV 与 Streamlit 的职责边界

2.1 YOLO 第几代了:当前选型该怎么看

YOLO 已经迭代了很多版本,YOLOv5、YOLOv8、YOLO11 是实际工程里最常被讨论的几个。很多人纠结“yolo第几代了”这个问题,其实在交通监控场景里更值得关注的是两个维度:一是模型的部署成熟度,二是训练自定义数据集的资料丰富度。YOLOv8 在 Ultralytics 框架下同时支持检测、实例分割和姿态估计,生态完整;YOLO11 在精度上有进一步提升,但对部分旧显卡的部署体验不如 v8 顺畅。我的建议是:新项目首选 YOLOv8,因为它在 COCO 上的预训练权重覆盖了 car、bus、truck、bicycle、person 等交通场景高频类别,且导出 ONNX、TensorRT 的资料最多。如果对特定场景(比如远距离小目标车辆)有精度要求,再考虑升级到 YOLO11 并在自建数据集上微调。

从损失函数角度看,YOLOv8 使用 anchor-free 检测头,分类损失用 BCE,回归损失用 CIoU。理解这一点对调参很有帮助:当画面里车辆框偏大或偏小,你需要关注的是回归损失对尺度变化的敏感度,而不是盲目调整 anchor 尺寸。交通监控中常见的“车尾对车尾”重叠、夜间灯光造成的漏检,往往通过调整置信度阈值和 NMS 的 IoU 阈值就能改善,不一定需要改网络结构。

2.2 OpenCV 在管线里的三个角色:采集、预处理、可视化后处理

OpenCV 在交通监控系统里承担三个不可或缺的工作。第一是视频采集,通过cv2.VideoCapture打开本地视频文件或摄像头;第二是图像处理,包括缩放到模型输入尺寸、BGR 与 RGB 色彩空间转换、图像增强;第三是可视化后处理,把 YOLO 检测出的框、类别和置信度绘制到原始帧上。很多人会忽略一个细节:YOLO 训练时用的是 RGB 图像,而 OpenCV 读取的图像默认是 BGR 顺序。如果不做转换直接送入模型,检测结果会有明显的精度下降。这是新手最容易踩的坑,后面代码示例里会反复强调。

import cv2 cap = cv2.VideoCapture("traffic.mp4") if not cap.isOpened(): raise IOError("无法打开视频源,请检查路径或摄像头索引") ret, frame = cap.read() rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)

这段代码完成的是最基础的采集 + 色彩转换流程。cap.read()同时返回读取状态和图像数据,读取失败时ret为 False。实际项目中要特别注意:摄像头断连后read()会持续返回 False,需要在循环里做重连处理,否则 Streamlit 页面会白屏卡死。

2.3 Streamlit 的定位:它不负责算法,只负责把状态变成界面

Streamlit 是一个 Python Web 框架,核心价值在于“用脚本方式构建交互界面”。在交通监控系统里,Streamlit 负责三件事:展示实时视频流、提供参数调节控件、呈现统计数据。需要明确的是,Streamlit 默认是请求-响应模型,每次交互都会重跑整个脚本。这对视频流处理是个巨大挑战——如果每一帧都触发脚本重跑,系统会立刻崩溃。所以正确的架构是在 Streamlit 外部维护一个视频处理线程,把最新帧放入缓存,Streamlit 界面通过读取缓存来刷新画面。

2.4 环境搭建与依赖安装

创建虚拟环境并安装依赖时,有一个顺序问题:必须先装 PyTorch,再装 Ultralytics,最后装 OpenCV。因为 Ultralytics 会自动检测已安装的 PyTorch 版本来决定是否启用 GPU 加速。如果反过来先装 Ultralytics,它会拉取默认的 CPU 版本 PyTorch,后续想要切换 CUDA 版本就得重新安装。

conda create -n traffic python=3.10 -y conda activate traffic pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install ultralytics opencv-python streamlit pillow numpy

安装完成后可以用一行命令验证环境是否正常:

import cv2, torch, streamlit print("OpenCV:", cv2.__version__) print("PyTorch CUDA:", torch.cuda.is_available()) print("Streamlit:", streamlit.__version__)

环境问题中最常见的是ModuleNotFoundError: No module named 'cv2',这通常是因为装在了错误的 Python 环境里。另外必须提一句:如果使用 AMD 显卡跑 YOLO,PyTorch 的 CUDA 支持默认不可用,需要在 Linux 下安装 ROCm 版本的 PyTorch。在实际项目里,我一般会先在服务器或本机 CPU 环境下跑通整个流程,再切换到 GPU 优化推理速度。

3. 核心实现:YOLO 推理封装与 OpenCV 视频流处理

3.1 设计一个可复用的检测器类

交通监控系统中,YOLO 推理不应该和业务逻辑混在一起。我一般会定义一个TrafficDetector类,把模型加载、推理、结果转换封装成统一接口。这样后续无论是接入新的 YOLO 版本,还是切换到 TensorRT 加速,都只需要改动这一个类。

import cv2 import numpy as np from ultralytics import YOLO class TrafficDetector: def __init__(self, model_path="yolov8n.pt", conf_thres=0.35, device="cpu"): self.model = YOLO(model_path) self.conf_thres = conf_thres self.device = device self.target_classes = [0, 1, 2, 3, 5, 7] # COCO类别ID: person=0, bicycle=1, car=2, motorcycle=3, bus=5, truck=7 def predict(self, frame_bgr): rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) results = self.model.predict(rgb, conf=self.conf_thres, device=self.device, verbose=False) return self._parse_results(results[0]) def _parse_results(self, result): boxes = result.boxes.xyxy.cpu().numpy() confs = result.boxes.conf.cpu().numpy() cls_ids = result.boxes.cls.cpu().numpy().astype(int) return boxes, confs, cls_ids

这里有几个关键参数需要解释。conf_thres是置信度阈值,低于这个值的检测结果会被过滤掉。交通场景里我习惯设置在 0.3 到 0.45 之间:设得太低会有大量误检,太高则漏检远距离的小车。target_classes限定了我们关注的类别——COCO 数据集有 80 个类别,但交通监控只需要其中几个。这样过滤可以避免把路边的猫狗识别成障碍物。.cpu().numpy()的作用是把 YOLO 输出的张量从 GPU 或 CPU 设备转移到 numpy 数组,以便传给 OpenCV 进行后续绘制和处理。

3.2 视频帧读取与帧率控制:为什么不能逐帧推理

交通监控实际部署中,YOLO 推理速度往往跟不上视频帧率。假设摄像头输出 30 FPS,而 YOLOv8s 在 GPU 上推理需要 35 毫秒,每秒只能处理约 28 帧,这就形成了瓶颈。更严重的是,相邻帧之间的车辆位置变化很小,逐帧推理的增益极其有限。我的做法是采用“跳帧检测 + 全帧显示”的策略:视频流全速播放,但只每隔 N 帧做一次 YOLO 检测;中间未检测的帧直接使用上一帧的检测结果绘制。

class VideoStreamHandler: def __init__(self, source_path, detect_interval=2): self.cap = cv2.VideoCapture(source_path) self.detect_interval = detect_interval self.frame_count = 0 def read_and_detect(self, detector): ret, frame = self.cap.read() if not ret: return None, None self.frame_count += 1 boxes, confs, cls_ids = None, None, None if self.frame_count % self.detect_interval == 0: boxes, confs, cls_ids = detector.predict(frame) return frame, (boxes, confs, cls_ids)

detect_interval是一个需要根据硬件条件调整的参数。GPU 推理能力强时设为 1 或 2,CPU 环境下建议设为 3 到 5。这个参数的另一个作用是控制车辆的“轨迹连续性”:间隔太大会导致同一辆车在相邻两次检测中位置跳变明显,影响后续的测速和统计。

3.3 OpenCV 坐标系与画框:Rect 函数的正确用法

OpenCV 中所有图像操作都基于像素坐标系,原点在左上角,x 轴向右,y 轴向下。YOLO 输出的xyxy格式是[x1, y1, x2, y2],分别代表左上角和右下角的坐标。很多从目标检测入门的人会混淆colsrowsframe.shape[0]是高度(rows),frame.shape[1]是宽度(cols)。在画框、裁剪、计算车辆中心点时,这个坐标系理解必须准确。

def draw_detections(frame, boxes, confs, cls_ids, class_names): for box, conf, cls_id in zip(boxes, confs, cls_ids): x1, y1, x2, y2 = [int(v) for v in box] label = f"{class_names[cls_id]} {conf:.2f}" cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, label, (x1, y1 - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) return frame

画框逻辑中,cv2.rectangle接收的是左上角和右下角坐标,颜色使用 BGR 格式,(0, 255, 0)是绿色。cv2.putText的文字位置使用了y1 - 8,这是为了让文字避开检测框的上边框,避免遮挡。当车辆在画面边缘时,y1 - 8可能变成负值,导致文字绘制报错。稳妥的做法是加一个max(0, y1 - 8)保护。

4. 交通统计实战:虚拟线计数、车速估计与拥堵判定

4.1 基于虚拟线的车流量统计

交通监控最基础的需求是统计“单位时间内通过了多少辆车”。常见做法是“区域检测”,即只要车辆检测框出现在画面中就计数。这种方案误差极大——同一辆车如果连续被检测到 20 帧,会被计数 20 次。标准解法是定义一条虚拟线(virtual line),只有车辆中心点跨过这条线时才计数一次,且同一辆车只计一次。

class CrossingCounter: def __init__(self, line_y, min_gap=0.5): self.line_y = line_y # 虚拟线的y坐标 self.min_gap = min_gap # 两次计数的最小间隔(秒) self.last_count_time = {} self.total_count = 0 def update(self, boxes, frame_time): count_now = 0 for box in boxes: x1, y1, x2, y2 = box center_y = (y1 + y2) / 2 center_x = (x1 + x2) / 2 if abs(center_y - self.line_y) < 10: car_key = f"{int(center_x // 50)}" last_time = self.last_count_time.get(car_key, 0) if frame_time - last_time >= self.min_gap: self.total_count += 1 self.last_count_time[car_key] = frame_time count_now += 1 return count_now

这个实现里有两个值得注意的设计。第一,判断条件用的是abs(center_y - self.line_y) < 10,而不是单纯判断是否大于或小于。这样即使车辆在一帧内从线的上方跳到下方,也能捕捉到跨线事件。第二,car_keycenter_x // 50做了粗略的空间分桶,是为了避免画面中两辆并行的车在几乎相同时间通过时被误判为同一辆。这个分桶粒度需要根据摄像头的视角调整:摄像头视野越宽,桶的长度应该越大。

4.2 车速估计的最小可行方案

没有多个摄像头标定信息,单目视觉测速很难做到精准。但如果只是做相对车速展示,可以用“位移/时间”的近似方法:记录同一辆车在前后两次检测框的位置和时间,用中心点位移除以时间差。

class SpeedEstimator: def __init__(self, pixels_per_meter=50): self.tracks = {} self.ppm = pixels_per_meter def estimate(self, box, current_time): x1, y1, x2, y2 = box center = ((x1 + x2) / 2, (y1 + y2) / 2) track = self.tracks.get(id(box), None) if track is None: self.tracks[id(box)] = (center, current_time) return None prev_center, prev_time = track dt = current_time - prev_time if dt <= 0: return None dx = center[0] - prev_center[0] dy = center[1] - prev_center[1] distance_px = (dx ** 2 + dy ** 2) ** 0.5 speed_kmh = (distance_px / self.ppm) / dt * 3.6 return max(speed_kmh, 0)

这里的pixels_per_meter是像素与实际距离的换算比例,需要根据监控场景手动标定。比如知道一段路面实际长 10 米,在画面中占 500 像素,则比例是 50 像素/米。这种测速方法存在的固有问题是:车辆从远处驶向摄像头时,位移速度被高估;车辆横穿画面时相对准确。所以它适合做趋势展示,不适合做罚单依据。

4.3 拥堵判定指标:占有率比数量更可靠

单纯统计画面里有多少辆车,无法准确反映拥堵程度。同样 5 辆车,分散在 200 米长的路段中央通畅,挤在摄像头正下方就是拥堵。更可靠的指标是“空间占有率”——所有车辆检测框面积之和与画面面积的比值。

def compute_occupancy(boxes, frame_area): if len(boxes) == 0: return 0.0 overlap_area = 0.0 for box in boxes: x1, y1, x2, y2 = box area = (x2 - x1) * (y2 - y1) overlap_area += area return min(overlap_area / frame_area, 1.0) def classify_traffic(occupancy): if occupancy < 0.15: return "畅通" elif occupancy < 0.35: return "缓行" else: return "拥堵"

这个方案存在小的误差:近处车辆框面积大,远距离车辆框面积小,导致近处车辆对占有率贡献过高。如果摄像头角度固定,可以通过给不同画面区域设置不同的权重系数来修正。实际部署时,我们还会结合多个时间窗口的占有率均值来消除检测抖动的影响。

4.4 数据存储:轻量级方案用 CSV 或 SQLite

统计结果需要持久化才能做历史趋势分析。轻量场景下用 SQLite 足够,不需要额外安装数据库服务。写入操作放在独立线程中完成,避免阻塞推理主循环。

import sqlite3 class TrafficStore: def __init__(self, db_path="traffic.db"): self.conn = sqlite3.connect(db_path, check_same_thread=False) self.conn.execute(""" CREATE TABLE IF NOT EXISTS traffic_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT, vehicle_count INTEGER, occupancy REAL, status TEXT ) """) def insert_record(self, timestamp, count, occupancy, status): self.conn.execute( "INSERT INTO traffic_log (timestamp, vehicle_count, occupancy, status) VALUES (?, ?, ?, ?)", (timestamp, count, occupancy, status) ) self.conn.commit()

SQLite 的默认线程模式对多线程写入有限制,所以连接时要传入check_same_thread=False,并将每个写操作通过锁或队列进行串行化。如果系统部署时长达到数周,还需要定期归档和清理旧数据。

5. Streamlit 界面设计:实时视频流展示与交互控件

5.1 使用 st.image 而不是 st.video:实时流的核心技巧

Streamlit 原生提供的st.video组件只能播放完整的视频文件或 URL,它无法接收 Python 端的逐帧图像数据。所以实时监控界面的标准做法是使用st.image,放在一个循环里逐帧刷新。

import streamlit as st import cv2 st.set_page_config(page_title="交通监控系统", layout="wide") st.title("实时交通监控") conf_threshold = st.sidebar.slider("置信度阈值", 0.20, 0.80, 0.40) video_source = st.sidebar.text_input("视频源路径", value="traffic.mp4") frame_placeholder = st.empty() status_text = st.empty() detector = TrafficDetector(conf_thres=conf_threshold) cap = cv2.VideoCapture(video_source) stop_button = st.sidebar.button("停止检测") while cap.isOpened() and not stop_button: ret, frame = cap.read() if not ret: break boxes, confs, cls_ids = detector.predict(frame) annotated = draw_detections(frame, boxes, confs, cls_ids) annotated_rgb = cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB) frame_placeholder.image(annotated_rgb, channels="RGB") status_text.text(f"检测到 {len(boxes)} 个目标")

这个代码可以跑通,但有一个明显缺陷:slider改变置信度阈值会触发整个脚本重跑,摄像头会重新打开,视频会从头播放。解决这个问题需要借助st.session_state来缓存检测器和视频捕获对象,只在首次运行时初始化。

5.2 用 session_state 避免重复初始化

if "detector" not in st.session_state: st.session_state.detector = TrafficDetector(conf_thres=0.4) detector = st.session_state.detector detector.conf_thres = conf_threshold # 每次重跑时更新阈值

通过将检测器实例存入st.session_state,模型只需加载一次,后续页面重跑时复用同一实例,避免了反复加载权重文件的时间开销。同样的机制也适用于视频捕获对象——初始化后放到 session 里,这样调整滑块时,视频播放进度不会因为重跑而重置。

5.3 多列布局展示实时数据

监控界面除了视频,还需要展示检测到的车辆数量、当前道路状态和拥堵程度。使用st.columns可以把这些指标排列在视频周围。

col1, col2, col3 = st.columns(3) with col1: st.metric("当前车辆数", current_vehicle_count) with col2: st.metric("累计通过车辆", st.session_state.total_count) with col3: st.metric("道路状态", traffic_status)

st.metric组件支持显示数值和上下浮动幅度。对于“当前车辆数”这个指标,最好同时展示相对前一秒的变化量,例如“+2 或 -1”,这能从侧面反映流量变化趋势。

5.4 在 Streamlit 中展示统计图表

用纯 OpenCV 画统计图很麻烦,但 Streamlit 直接集成了图表组件。每 10 秒聚合一次检测数据,用st.line_chart展示车流量走势。

import time import pandas as pd history = [] chart_placeholder = st.empty() for _ in range(60): sample = {"time": time.strftime("%H:%M:%S"), "count": current_vehicle_count} history.append(sample) df = pd.DataFrame(history) chart_placeholder.line_chart(df.set_index("time")) time.sleep(1)

5.5 Streamlit 中文手册里最容易忽略的坑

社区里有人维护了 Streamlit 中文手册,里面有一条非常容易踩的坑:st.image默认会压缩图像的显示尺寸,当图像较大时,界面会出现明显的卡顿。解决办法是设置width参数或使用use_container_width=True。另一个坑是 Streamlit 的刷新机制——如果大循环里执行了st.sidebar.button且按钮状态发生变化,循环会中断并重跑整个脚本。所以在循环中要避免动态创建控件,所有控件都应该放在循环开始之前定义。

6. 部署与验证:从运行日志到参数调优的落地技巧

6.1 用录制视频代替实时摄像头验证

调试阶段千万不要直接连着摄像头开发。真实摄像头的画面不稳定、光照多变,一旦检测效果异常,很难判断是算法问题还是画面问题。正确做法是先用手机或监控软件录制一段 10 分钟以上的典型场景视频,反复在本地回放验证。这样每次运行结果可复现,参数调整的效果能直观对比。摄像头接入时,注意检查cv2.VideoCapture的摄像头索引:笔记本自带摄像头通常是 0,USB 外接摄像头可能是 1 或 2,可以通过循环尝试找出正确的索引。

6.2 在页面上显示 FPS 和每帧耗时

评估系统性能最直接的方式就是显示实时推理耗时。YOLO 推理耗时可以通过time.time()前后插桩计算,视频显示帧率用 OpenCV 的cv2.getTickCount()cv2.getTickFrequency()计算。

tick1 = cv2.getTickCount() boxes, confs, cls_ids = detector.predict(frame) tick2 = cv2.getTickCount() infer_time_ms = (tick2 - tick1) / cv2.getTickFrequency() * 1000 fps = 1.0 / max(infer_time_ms / 1000.0, 0.001)

在实际案例中,YOLOv8n 在 CPU 上的推理时间大约是 80 到 120 毫秒,YOLOv8s 大约是 200 到 300 毫秒。如果推理时间超过 200 毫秒,说明需要开启跳帧检测或换更小的模型。将这两个指标显示在界面上,可以直观看到瓶颈来自哪个环节。

6.3 关键参数速查表与实际调优建议

这里整理了一份交通监控场景下的参数推荐表,覆盖置信度、NMS IoU 阈值、跳帧间隔和画面缩放四个最常调整的维度。

参数推荐范围调小的影响调大的影响
conf_thres0.30 - 0.45更多误检、漏检减少漏检增加、误检减少
NMS IoU0.40 - 0.60重叠目标分离更严格重叠目标可能合并
detect_interval1 - 5更耗算力、轨迹更平滑省算力、轨迹跳变
输入分辨率640 - 1280速度快、小目标漏检速度慢、小目标召回提升

夜间场景下,建议把 conf_thres 调低到 0.25,因为夜间车辆特征不明显,预训练模型的置信度普遍偏低。白天光线充足时则可以调高到 0.45,减少行道树阴影和路灯造成的误检。

6.4 用置信度热力图快速定位漏检原因

这个技巧很少被提及但非常实用。当画面里明显有车但系统没检测到时,可以在调试界面中显示模型对所有可能目标的高置信度区域热力图。具体做法是让 YOLO 返回所有候选框(不经过 conf_thres 过滤),然后绘制一个半透明的矩形叠加层,颜色深浅代表置信度高低。

def debug_heatmap(frame, all_boxes, all_confs): overlay = frame.copy() for box, conf in zip(all_boxes, all_confs): x1, y1, x2, y2 = [int(v) for v in box] alpha = min(max(conf, 0), 1) * 0.6 cv2.rectangle(overlay, (x1, y1), (x2, y2), (0, 0, 255), -1) cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame) return frame

通过观察漏检车辆位置在热力图上是否有较浅的红色区域,可以判断是置信度阈值设置过高,还是该车在模型特征层面就没能被识别出来。如果是前者,直接调低阈值即可;如果是后者,就需要考虑使用更大的模型或在自建数据集上微调。

6.5 摄像头断线重连与长时间运行的稳定性保障

交通监控系统通常需要 7x24 小时运行,摄像头断线是常态。重连逻辑不能是简单的死循环,要考虑退避策略,否则摄像头刚恢复就被高频重连请求再次压垮。

class AutoReconnectCapture: def __init__(self, source, retry_interval=5): self.source = source self.retry_interval = retry_interval self.cap = None self._connect() def _connect(self): self.cap = cv2.VideoCapture(self.source) if not self.cap.isOpened(): self.cap.release() self.cap = None def read(self): if self.cap is None: time.sleep(self.retry_interval) self._connect() return False, None ret, frame = self.cap.read() if not ret: self.cap.release() self.cap = None return ret, frame

这个类的核心思想是:读帧失败时立刻释放资源并置空,下一次调用时尝试重新连接,每次重连之间至少间隔 5 秒。在长时间运行场景下,还需要定期检查内存占用——OpenCV 的VideoCapture偶发内存泄漏,建议每 4 小时重启一次视频捕获对象。

以上这套从模型封装、视频流处理到 Streamlit 界面和参数调优的完整流程,已经可以支撑一个真实可用的交通监控原型系统。下一步建议按实际摄像头视角手动标定pixels_per_metter,并在正式上线前采集至少一周的数据验证检测和统计的稳定性。

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

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

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

立即咨询