我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

从YOLO到实时视频AI:RTSP流取流解码推理与集成实践全解析

从YOLO到实时视频AI:RTSP流取流解码推理与集成实践全解析 做实时视频AI这件事最尴尬的阶段不是模型训练不出来而是模型已经能稳定检测了结果一接到视频流里就各种掉帧、串台、内存疯涨。我把YOLO从单张图片检测挪到实时视频流里前前后后折腾了大半年中间沉淀出一套叫SmartMediaKit的集成方案。这套东西不神秘核心就是把取流、解码、推理、结果分发这几件事拆干净再让检测模型只干检测该干的活。这篇文章就把我的集成思路和踩过的坑摊开讲给正在做类似事情的你一个可以少走弯路的参考。1. 从单帧 YOLO 到实时视频 AI我先踩了一遍坑1.1 单张图片检测跑通了为什么到视频就崩我最早用YOLO做检测流程非常朴素读一张图resize到640乘640放进模型推理画框结束。这套流程跑图片、跑离线视频集挺好用准确率看着也还行。但一旦把输入换成摄像头RTSP流问题马上冒出来。第一个问题是处理速度。离线图片你慢一点没关系但视频是连续帧要求每秒处理25帧甚至30帧。一张图推理耗时50毫秒听着不慢可一加上解码、预处理、后处理这些环节单帧总耗时很容易超过100毫秒帧率直接掉到10以下。画面里一个物体从出现到被识别延迟可能拉到一秒多这在实时监控里基本没法用。第二个问题是资源管理。图片处理是一次性的处理完内存释放就行。视频流是持续不断的每一帧都要经过解码、拷贝、推理、结果回调。只要有一个环节没有控制好生命周期内存和显存就会像漏水一样往上走。我早期在Python里用OpenCV读流又叠加各种numpy数组拷贝跑半小时后内存能翻一倍最后进程被系统杀掉没有任何日志。第三个问题是场景差异。单张图片通常是经过挑选的、构图完整的画面。视频流里充满了运动模糊、遮挡、光照突变、远景小目标。用同一套检测参数去跑视频漏检率会比图片高出一大截。这不是模型变笨了而是数据分布变了你需要针对视频场景重新调整置信度阈值、NMS参数甚至考虑是否要做目标跟踪来补帧间缺失。1.2 SmartMediaKit 到底解决什么问题我当时的处境是算法代码写了一大堆训练脚本、检测脚本、画框工具散落在各个文件夹换一个摄像头要改一堆路径和参数想加一个新检测模型又要把流程重写一遍。这种状态越往后越难维护。SmartMediaKit不是重新发明一个检测算法它解决的问题是工程化的“封装”和“编排”。你依然用YOLO作为检测核心但YOLO不再直接面对摄像头、视频文件、图像数组这些五花八门的输入。SmartMediaKit在输入和检测模型之间加了一层标准化的管道把取流、解码、缩放、推理、后处理、结果推送封装成一个个独立模块。这样设计带来的好处非常实际换检测模型时不需要动取流和推流部分只替换模型加载和推理接口。加一个视频源时不需要改代码逻辑只需加一条配置。检测结果要做Webhook告警、MQ推送、数据库落库时各写一个输出插件就行互不干扰。打个比方YOLO是一台发动机SmartMediaKit是底盘和传动系统。没有底盘发动机只能放在地上空转有了底盘你才能把它装上车在各种路况下稳定跑。1.3 这套方案的模块设计原则我在设计SmartMediaKit的时候给自己定了三条硬规矩后面所有环节都是围绕这三条展开的。第一每个模块必须有明确的输入和输出模块之间不共享内部状态。比如取流模块只负责输出一帧解码后的图像它不关心图像之后是去检测还是去存储推理模块只接收标准尺寸的张量不关心数据是从摄像头还是视频文件里来的。这样一来任何模块坏了单独替换都不会影响其他模块。第二数据在模块之间流动时尽量用统一的帧对象。这个帧对象里除了图像数据还带着时间戳、来源标识、原始宽度高度这些元信息。后期做FPS统计、结果对齐、多路视频拼接时这些元信息能救命。第三所有可能变化的参数都外置到配置文件里。模型路径、输入尺寸、置信度阈值、IOU阈值、推理设备、队列长度、回调地址全部可配置。表面上看只是把参数抽出来实际效果是部署时不需要改任何代码改完配置重启就能跑。2. 实时视频AI链路里的核心细节2.1 取流与解码环节源头不稳定后面全是白费实时视频AI的第一步是拿到连续帧。RTSP是摄像头最常见的协议我最早直接用OpenCV的VideoCapture去读RTSP流图省事。写起来确实简单但用起来问题一堆断流之后不会自动重连网络波动时画面卡在最后一帧CPU占用随着运行时间越变越高。后来我把取流这块拆出来独立做。底层支持两种方式一种仍是OpenCV VideoCapture适合快速验证和稳定性要求不高的场景另一种是GStreamer管道适合生产环境支持硬件解码和更精细的参数控制。在实践里我发现几个很关键的点一定要做断线重连机制。RTSP流因网络抖动断掉是常态不重连的话视频源就等于死了。我做的方案是每5秒检测一次帧间隔如果超过阈值就销毁当前解码器重新连接。解码器要复用不要每帧创建一个。创建解码器是重开销逐帧创建会把CPU吃满。如果摄像头数量多优先用硬件解码。NVIDIA系环境里用GStreamer的nvv4l2decoder能显著降低CPU压力。我最后一版取流模块跑四路1080p摄像头CPU占用大概占一个核心大部分解码工作交给了GPU稳定运行一周没有再出现断流卡死的情况。2.2 预处理letterbox 和归一化不是小事情预处理这块看起来就是把图像resize到640乘640然后归一化但真做起来坑也不少。直接resize会改变物体的宽高比尤其是细长物体检测框会明显偏移。YOLO官方训练时用的是letterbox方式等比缩放图像长边缩放到640短边保持比例剩余区域用灰色填充。推理时也必须这么做否则图像分布和训练时不一致精度会掉。具体参数我给大家列一份可以直接抄的配置输入尺寸640乘640填充颜色114114114这是YOLO训练时用的默认灰值归一化像素值除以255转换到0到1区间通道顺序RGB如果训练时用的是RGB。很多人会在这里翻车OpenCV默认读进来是BGR直接送进模型检测效果会奇怪必须在预处理时转换。数据布局CHW或NHWC对应不同推理框架和模型导出格式配置里必须标明否则推理会报shape错误。还有一个很多人忽略的细节——预处理一定要在推理前单独做并尽量放在队列之外。我的意思是取流线程拿到的原始帧先送入预处理模块预处理完成后把张量放进推理队列。这样模型推理线程永远不会被图像传输卡住队头堵了丢帧也不会阻塞取流。2.3 推理后端选型ONNX、TensorRT 还是自研模型训练完通常得到的是PyTorch权重文件。但这个权重文件没法直接被生产环境使用需要导出成推理框架能加载的格式。我在这块把常见路线都试了一遍。最省事的是ONNX Runtime。PyTorch模型导出ONNX然后用ONNX Runtime推理代码量少支持CPU和GPU跨平台性好。缺点是推理速度不如TensorRT优化过的版本。追求极限性能就用TensorRT。TensorRT会对模型做层融合、精度校准、内核自动调优实测比ONNX Runtime在GPU上快1.5倍到2倍显存占用也更低。但TensorRT生成的engine跟GPU型号和驱动版本强绑定换一台机器要重新生成部署时比较麻烦。如果场景对延迟极其敏感比如工业质检可以考虑用DeepStream这类更底层的方案把解码、推理、跟踪全部串在GPU流水线里。但对大多数视频监控项目来说ONNX Runtime加一个可选的TensorRT加速模式已经足够。我给一个选型倾向表推理后端部署难度推理速度灵活性适用场景PyTorch最低最慢最高实验和模型调优ONNX Runtime低中等中大多数生产项目TensorRT FP16中快较低高并发低延迟场景TensorRT INT8高最快低极致性能、需校准集2.4 后处理与坐标映射最容易出错的环节模型输出的不是坐标框而是一组预测结果后处理要干两件事做NMS去掉重复框以及把坐标映射回原图尺度。NMS的参数需要按场景微调。默认的置信度阈值0.25适用于通用场景但监控画面里目标远、模糊可以适当下调到0.15到0.2再用IOU阈值0.45到0.5过滤重叠框。注意置信度调低后误检会增加建议同时限制最小检测框尺寸把过小的噪点过滤掉。坐标映射是新手最容易踩坑的地方。模型输出的框坐标是基于640乘640的letterbox图算出来的格式化输出往往是归一化的中心点加宽高比如(cx, cy, w, h)值在0到1之间。要还原到原始1080p图像的坐标必须记录letterbox时用到的缩放比例和填充偏移然后做反算。公式不复杂# 原始图像宽高 orig_w, orig_h 1920, 1080 # letterbox后的宽高这里假设长边缩放到640 scale min(640 / orig_w, 640 / orig_h) new_w, new_h int(orig_w * scale), int(orig_h * scale) # 填充偏移居中填充 pad_x (640 - new_w) / 2 pad_y (640 - new_h) / 2 # 假设推理输出的中心点坐标是cx_norm, cy_norm0到1 cx cx_norm * 640 cy cy_norm * 640 w w_norm * 640 h h_norm * 640 # 映射回原图坐标 orig_cx (cx - pad_x) / scale orig_cy (cy - pad_y) / scale orig_w w / scale orig_h h / scale这段逻辑如果写错最典型的症状就是检测框位置正确但大小不对或者框整体偏移。我建议把坐标映射单独抽成函数并做单测验证输入一个已知框确认映射结果无误后再接进主流程。3. 手把手把 YOLO 集成进 SmartMediaKit3.1 目录结构与模块划分SmartMediaKit我最终采用的是按职责分包的目录结构每个模块一个目录模块之间通过明确的接口交互。当前项目结构大致是这样的smart_media_kit/ ├── config/ │ └── config.yaml ├── source/ │ ├── rtsp_source.py │ └── video_file_source.py ├── processor/ │ ├── preprocess.py │ ├── detector.py │ └── postprocess.py ├── sink/ │ ├── console_sink.py │ ├── http_sink.py │ └── mq_sink.py ├── pipeline.py └── main.pysource目录负责取流和解码输出标准帧对象processor目录负责预处理、模型推理、后处理sink目录负责结果输出可以同时输出到多个目标pipeline.py负责把source、processor、sink串起来管理数据流和线程。这种结构下新增一个视频源只需要在source目录加一个类新增一个输出渠道只需要在sink目录加一个类原代码不需要动扩展成本非常可控。3.2 模型导出与配置文件模型训练完成后我习惯的导出流程是先从PyTorch导出ONNX再从ONNX决定是否需要转TensorRT。PyTorch转ONNX的脚本很常规我提醒一个关键点导出时固定输入尺寸。虽然ONNX支持动态输入但动态尺寸会降低推理效率而且TensorRT转换时动态尺寸限制更多。如果没有特殊需求直接固定成1乘3乘640乘640。导出后用ONNX Runtime跑一遍对比PyTorch的检测结果确保输出张量形状和数值范围一致。这时别忘了把所有运行参数写进配置文件。我的config.yaml里常年躺着这些关键项model: path: models/yolov8n.onnx input_size: 640 conf_threshold: 0.25 iou_threshold: 0.45 device: cuda source: rtsp_url: rtsp://192.168.1.64:554/stream1 reconnect_interval: 5 pipeline: queue_size: 64 skip_frames: 0skip_frames表示每几帧检测一次。当算力不足时可以设成1或2来跳过部分帧换取更低的延迟。这个参数在实时场景里很实用因为很多视频内容相邻帧之间的差异极小全量检测对结果影响不大却在疯狂消耗GPU。3.3 核心代码骨架一条流水线怎么跑起来我用一个简化的Python代码来展示整个流水线的核心思想。实际生产环境可能要用C或者Cython来规避Python GIL的限制但代码思路是一致的。import threading import queue import cv2 import numpy as np class Frame: def __init__(self, image, timestamp, source_id): self.image image self.timestamp timestamp self.source_id source_id class RtspSource: def __init__(self, url, queue, reconnect_interval5): self.url url self.queue queue self.reconnect_interval reconnect_interval self.running True def run(self): while self.running: cap cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) while self.running: ret, frame cap.read() if not ret: break if self.queue.qsize() self.queue.maxsize: self.queue.put(Frame(frame, time.time(), self.url)) cap.release() time.sleep(self.reconnect_interval) class Detector: def __init__(self, model_path, input_size, conf_threshold, iou_threshold, device): self.model self._load_model(model_path, device) self.input_size input_size self.conf_threshold conf_threshold self.iou_threshold iou_threshold def preprocess(self, frame, pad): image frame.image orig_h, orig_w image.shape[:2] scale min(self.input_size / orig_w, self.input_size / orig_h) new_w, new_h int(orig_w * scale), int(orig_h * scale) resized cv2.resize(image, (new_w, new_h)) canvas np.full((self.input_size, self.input_size, 3), 114, dtypenp.uint8) pad_x (self.input_size - new_w) // 2 pad_y (self.input_size - new_h) // 2 canvas[pad_y:pad_y new_h, pad_x:pad_x new_w] resized rgb cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB) rgb rgb.astype(np.float32) / 255.0 tensor np.transpose(rgb, (2, 0, 1)).astype(np.float32) return tensor, scale, pad_x, pad_y, orig_w, orig_h def postprocess(self, output, scale, pad_x, pad_y, orig_w, orig_h): boxes, scores, class_ids [], [], [] for det in output: if det[4] self.conf_threshold: continue cx, cy, w, h det[:4] score det[4] class_id int(det[5]) # 坐标映射回原图 orig_cx (cx * self.input_size - pad_x) / scale orig_cy (cy * self.input_size - pad_y) / scale orig_w w * self.input_size / scale orig_h h * self.input_size / scale boxes.append([orig_cx - orig_w / 2, orig_cy - orig_h / 2, orig_w, orig_h]) scores.append(score) class_ids.append(class_id) # 这里用某种NMS实现过滤重叠框 keep nms(boxes, scores, self.iou_threshold) return [boxes[i] for i in keep], [scores[i] for i in keep], [class_ids[i] for i in keep] class Pipeline: def __init__(self, config): self.frame_queue queue.Queue(maxsizeconfig[pipeline][queue_size]) self.result_queue queue.Queue() self.source RtspSource(config[source][rtsp_url], self.frame_queue) self.detector Detector( config[model][path], config[model][input_size], config[model][conf_threshold], config[model][iou_threshold], config[model][device] ) def start(self): threading.Thread(targetself.source.run, daemonTrue).start() threading.Thread(targetself.process, daemonTrue).start() def process(self): while True: frame self.frame_queue.get() tensor, scale, pad_x, pad_y, orig_w, orig_h self.detector.preprocess(frame) output self.detector.model.infer(tensor) boxes, scores, class_ids self.detector.postprocess(output, scale, pad_x, pad_y, orig_w, orig_h) self.result_queue.put((frame.timestamp, boxes, scores, class_ids))这段代码不是我项目里的完整版删掉了很多异常处理但结构是准的。你可以看到每个模块之间的关系非常清楚数据从Queue进入经过预处理、推理、后处理进入结果队列。每一块都可以单独替换。3.4 训练数据标注与格式转换的注意点聊集成之前我提一下数据准备因为热词里很多人都在搜“KITTI标注转YOLO”。如果你拿到的公开数据集是KITTI格式要转成YOLO训练格式有几个地方特别容易错。KITTI标注是直接把3D框投影到2D图像上的转YOLO时要用截断、遮挡和类别重新映射。YOLO格式要求的是归一化后的中心点坐标和宽高形如class_id cx cy w h所有值都在0到1之间。转换脚本的核心逻辑是# KITTI的一行标注class truncated occluded x1 y1 x2 y2 ... x1, y1, x2, y2 map(float, parts[4:8]) img_w, img_h 1920, 1080 box_w (x2 - x1) / img_w box_h (y2 - y1) / img_h cx ((x1 x2) / 2) / img_w cy ((y1 y2) / 2) / img_h # 输出class_id cx cy box_w box_h注意KITTI里有些类别YOLO训练用不到转换之前要有一个类别映射表不用的直接跳过。很多人容易在“类别索引从0开始”上出错尤其是从别的数据集格式转过来时类别ID经常错位最坑的是这种错位不会让训练报错只会让模型学出来的检测结果和类别对不上。数据清洗也是重要一环。标注文件里如果有负坐标、宽高为0的框训练时会直接导致loss变成nan。我处理公开数据集时都会预先跑一遍脚本过滤掉这些坏标注并把超出图像边界的框做裁剪确保所有标注框都在图像范围内。4. 实战案例监控画面里的吸烟行为检测4.1 需求与方案选型一个实际落地的项目是在办公园区监控画面里检测吸烟行为。传统摄像头加人工盯屏的方式一个人盯四路画面超过十五分钟注意力就下降漏看是必然的。客户要求做实时自动检测发现疑似吸烟行为马上告警。这个场景的难度有几个吸烟者通常只占据画面很小一块区域烟雾是半透明的特征不明显光照变化非常大白天窗口逆光和晚上灯光下的烟头差异悬殊。直接用通用YOLO模型跑肯定不行需要收集场景数据做微调。数据收集阶段我从公开的吸烟检测数据集找了一批又在自己园区摄像头下录制了连续一周的视频抽帧后人工筛选出包含吸烟动作的帧。因为视频是从实际场景里抽出来的环境光照和摄像头角度跟部署场景高度一致模型微调后的泛化能力比只用公开数据集好了不少。4.2 集成过程中的性能调优模型训练完后进入集成阶段。最初部署时我用YOLOv8n加ONNX Runtime在GPU上跑单路1080p视频帧率大约18帧每秒勉强能看但有掉帧。客户要求四路视频同时分析需要更多算力。我采取的优化措施按效果排序把模型从YOLOv8n换成更轻量的YOLOv5s或自蒸馏的模型单帧推理时间降了大约30%精度损失在可接受范围。使用TensorRT FP16替换ONNX Runtime推理速度又提升约40%。设置skip_frames为1即每两帧检测一次画面检测频率仍然可以接受但让GPU负载大幅下降。缩小推理输入尺寸到512乘512。对监控画面里的吸烟检测来说640到512的精度损失不明显速度提升却很明显。调优后四路视频在单张GPU上稳定跑25 FPS以上告警延迟控制在800毫秒以内客户侧基本感觉不到延迟。关于告警逻辑我还做了个防抖处理不是检测到一次就告警而是连续3帧检测到才触发。这样可以过滤掉误检和瞬时遮挡实际效果是误报率下降了一大截客户不再天天被假告警烦。4.3 把检测结果送到业务侧回调、MQ 与日志管道检测结果如果只显示在终端里价值有限。要让实时视频AI真正接入业务流程集成的最后一步是把结果“送出去”。我在SmartMediaKit里实现了三种结果sink实际按场景按需启用HTTP回调。检测到目标时向指定的webhook地址POST一条JSON消息包含时间戳、摄像头ID、检测类别、置信度、检测框坐标。适合客户已有的业务系统通过接口接收告警。MQ推送。通过Kafka或RabbitMQ把检测结果推送到消息队列下游的数据分析、存储、可视化服务自己订阅消费。适合内部有多套系统同时消费检测结果的场景。日志管道。把结构化日志写到文件或标准输出再由Logstash或Filebeat采集进日志系统。这跟Logstash集成自定义插件有点类似检测结果作为自定义数据源输出后续在Kibana里做可视化检索。当时这个吸烟检测项目的联动是检测结果推送到HTTP回调接口触发短信和邮件告警同时写入ELK日志系统方便事后回溯。整个链路从摄像头画面到告警触发端到端延迟在2秒以内客户很满意。5. 高频问题与排查经验实录5.1 一张表看懂常见问题与解法这一路下来有一批问题是反复出现的我整理成了一张速查表方便你直接对照排查。现象根本原因解决方案推理首帧特别慢模型第一次加载和预热不足启动时预热随机帧跑1至2次后再正式处理显存随运行时间持续增长TensorRT上下文或推理session未正确释放检查对象生命周期避免循环创建session定期监控显存检测框位置正确但大小偏移letterbox坐标映射公式错误单测验证scale和pad运算确认反算公式RTSP流跑一段时间黑屏卡死断流后没有重连机制增加断线检测和自动重连帧间隔超时即重连多路视频CPU占用暴涨每路单独解码且未用硬件解码启用GStreamer硬件解码或限制每路解码帧率视频画面检测频繁漏检skip_frames设得过高或置信度阈值偏高实际测量漏检率调节skip_frames和conf_thresholdCPU内存缓慢上涨帧队列溢出或图像数据未释放队列采用有界队列并丢弃旧帧用弱引用或显式释放这张表不是万能的但覆盖了我遇到的80%问题。很多问题表面上看是代码bug实际上是对模块职责边界不清晰导致状态没有管理好。5.2 值得单独说的几次“翻车”经历有一段时间我的检测结果在可视化大屏上框是框、图是图但框明显比物体大一圈。查了很久最后发现是坐标映射时把缩放比例算反了letterbox算出2倍缩放我在反算时却用了0.5导致所有坐标放大了一倍。这种低级错误单靠肉眼很难发现一定要用已知框写单元测试输入一个固定框断言输出坐标在合理范围内。还有一次排查显存泄漏排查了两天最后定位到是TensorRT的predictor在每次推理时都隐式创建了一个新context旧的没有释放。换成复用context后显存曲线直接变成一条直线。这类问题你说它难也不难就是内存分配习惯不对排查时用nvidia-smi定时抓取显存变化能快速定位泄漏发生的阶段。还有一次意外是训练标签类别顺序弄错训练时类别映射和标注文件里的类别索引对不上模型跑出来的结果把猫识别成狗整个推理逻辑没问题但输出就是不对。后来我把数据集的类别映射表抽成了公共配置训练和推理共用同一份再也没出现过类别错位。5.3 关于“集成”的最终体会我做了这么多集成工作之后最大的体会是“集成”这个词远不是把两个软件接在一起那么简单。它真正的难点在于让原本独立的系统愿意被拆开、被编排愿意遵循一套公共的约定。YOLO算法本身已经很成熟难的是怎么让它稳定地为业务服务。SmartMediaKit这套方案目前还在持续迭代我自己是受益者它让我从低效的“改代码换场景”里解放出来。如果你也在做类似的事情我的建议是从一开始就控制好模块边界哪怕前期多写一点封装也比你后期在看似能跑的程序里修bug要省力得多。最后再分享一个经验实时视频AI的可用性指标里除了准确率延迟和稳定性比想象中更重要。用户不在意你的模型提升了0.5个百分点但会在意告警慢了3秒也会在意系统跑两天就挂。先把稳定性做扎实再去谈模型效果这个顺序不要搞反。
返回列表