【编解码】基于CPU的高性能 RTSP 多路摄像头抓帧插件:设计与实现详解

    • 前言
    • [1. 核心原理介绍](#1. 核心原理介绍)
      • [1.1 RTSP视频流解码原理](#1.1 RTSP视频流解码原理)
      • [1.2 多线程并发抓帧架构](#1.2 多线程并发抓帧架构)
      • [1.3 连接池管理机制](#1.3 连接池管理机制)
      • [1.4 动态健康监控与退避策略](#1.4 动态健康监控与退避策略)
      • [1.5 无锁帧队列缓冲](#1.5 无锁帧队列缓冲)
    • [2. 关键步骤介绍](#2. 关键步骤介绍)
      • [2.1 全局环境配置(FFmpeg优化)](#2.1 全局环境配置(FFmpeg优化))
      • [2.2 类初始化与摄像头配置加载](#2.2 类初始化与摄像头配置加载)
      • [2.3 摄像头均匀分配(负载均衡)](#2.3 摄像头均匀分配(负载均衡))
      • [2.4 连接池核心管理(最关键模块)](#2.4 连接池核心管理(最关键模块))
      • [2.5 动态健康与访问控制](#2.5 动态健康与访问控制)
      • [2.6 核心抓帧逻辑](#2.6 核心抓帧逻辑)
      • [2.7 工作线程主循环](#2.7 工作线程主循环)
      • [2.8 公共业务接口](#2.8 公共业务接口)
    • [3. 补充讲解与最佳实践](#3. 补充讲解与最佳实践)
      • [3.1 核心优化点总结](#3.1 核心优化点总结)
      • [3.2 使用注意事项](#3.2 使用注意事项)
      • [3.3 扩展方向](#3.3 扩展方向)
      • [3.4 适用场景](#3.4 适用场景)
    • [4. 完整插件代码](#4. 完整插件代码)
    • [5. 测试脚本](#5. 测试脚本)
    • 总结

前言

在安防监控、机器视觉等场景中,多路RTSP摄像头并发抓帧是核心需求。本文基于OpenCV+FFmpeg实现了一款高可用、高并发的多线程抓帧插件,具备连接池管理、动态健康监控、自动重连等企业级特性,完美解决多路摄像头采集的性能与稳定性问题。


1. 核心原理介绍

本插件的核心围绕 RTSP视频流解码多线程并发采集连接池资源管理 三大技术点构建,理论原理如下:

1.1 RTSP视频流解码原理

  • RTSP(实时流传输协议)是安防摄像头主流的视频传输协议,视频流通常采用H.264/H.265(HEVC)编码;
  • 插件基于 OpenCV + FFmpeg 实现解码:OpenCV封装VideoCapture作为上层接口,FFmpeg作为底层解码库,支持硬解/软解;
  • 强制使用TCP传输:替代UDP避免网络丢包导致的解码失败、花屏问题;
  • 解码流程:建立连接 → 接收编码流 → FFmpeg解码 → 输出原始图像帧 → 尺寸缩放。

1.2 多线程并发抓帧架构

  • 采用线程池模型:将多路摄像头均匀分配到多个工作线程,避免单线程处理多路流导致的性能瓶颈;
  • 线程隔离:每个线程独立管理自己的摄像头连接,无跨线程资源竞争,提升并发效率;
  • 守护线程设计:后台异步抓帧,不阻塞主业务逻辑。

1.3 连接池管理机制

  • 连接复用 :避免频繁创建/销毁VideoCapture对象,降低系统开销;
  • LRU淘汰策略:线程连接数达上限时,自动释放最久未使用的连接,控制资源占用;
  • 超时自动重建:连接空闲超时/断开后,自动重建连接,保证可用性。

1.4 动态健康监控与退避策略

  • 摄像头状态监控:记录成功/失败次数、连续失败次数;
  • 指数退避访问:失败后动态增加访问间隔,避免频繁重试压垮摄像头;
  • 最大重试机制:多次抓帧失败后标记异常,防止死循环重试。

1.5 无锁帧队列缓冲

  • 使用线程安全队列解耦「抓帧生产者」和「业务消费者」;
  • 队列满时自动丢弃旧帧,保证始终获取最新帧,避免内存溢出。

2. 关键步骤介绍

我们将代码按执行流程拆解,逐模块讲解核心逻辑,快速掌握代码精髓。

2.1 全局环境配置(FFmpeg优化)

python 复制代码
os.environ.setdefault(
    'OPENCV_FFMPEG_CAPTURE_OPTIONS',
    'rtsp_transport;tcp|loglevel;error'
)
  • 核心作用:设置OpenCV调用FFmpeg的全局参数;
  • rtsp_transport;tcp:强制RTSP使用TCP传输,根治UDP丢包问题;
  • loglevel;error:抑制FFmpeg冗余警告,只输出错误日志,简化调试。

2.2 类初始化与摄像头配置加载

python 复制代码
def _load_camera_urls(self):
    # 从txt文件读取RTSP地址,正则提取摄像头IP
    # 过滤注释、空行,校验配置合法性
  • 读取配置文件cameraUrl.txt中的RTSP流地址;
  • 正则提取摄像头IP,用于唯一标识设备;
  • 无有效配置时直接抛出异常,快速失败。

2.3 摄像头均匀分配(负载均衡)

python 复制代码
def _assign_cameras_to_threads(self):
    # 计算每线程分配数量,余数线程多分1个,保证均匀分配
  • 核心算法:将N个摄像头平均分配给M个线程;
  • 若摄像头数 > 线程数:均分后,前余数个线程各多分配1个摄像头;
  • 若摄像头数 < 线程数:一对一分配,剩余线程空跑,保证资源最优利用。

2.4 连接池核心管理(最关键模块)

python 复制代码
def _get_connection(self, thread_id, url, camera_ip):
    # 1. 检查连接池已有连接,失效则释放重建
    # 2. 连接数达上限,LRU淘汰最久未用连接
    # 3. 锁外创建新连接(避免阻塞)
    # 4. 存入连接池,更新时间戳
  • 线程安全 :使用threading.Lock保证连接池操作原子性;
  • 非阻塞创建VideoCapture是阻塞操作,必须在锁外执行,防止死锁;
  • 自动容错:连接超时/断开后自动重建,保证业务不中断。

2.5 动态健康与访问控制

python 复制代码
def _get_dynamic_access_interval(self, camera_ip):
    # 连续失败次数越多,访问间隔越长(指数退避)
def _update_camera_health(self, camera_ip, success):
    # 更新摄像头状态:成功清零失败计数,失败累加
  • 自适应访问策略:正常时最小间隔(高帧率),失败时扩大间隔(保护设备);
  • 状态持久化:记录每个摄像头的成功/失败次数,用于监控告警。

2.6 核心抓帧逻辑

python 复制代码
def _capture_frame(self, thread_id, url, camera_ip):
    # 1. 等待动态访问间隔
    # 2. 获取连接池连接
    # 3. 重试机制(最大3次)
    # 4. 读取帧 + 尺寸缩放
    # 5. 封装为CameraFrame结构体返回
  • 最大3次重试:单次失败不直接放弃,提升成功率;
  • 帧标准化:统一缩放到640x384,适配后续算法处理;
  • 异常处理:读帧失败立即释放无效连接,避免占用资源。

2.7 工作线程主循环

python 复制代码
def _worker_thread(self, thread_id):
    # 轮询分配的摄像头,循环抓帧
    # 帧入队,队列满则丢弃旧帧
    # 异常捕获,保证线程不崩溃
  • 轮询机制:按顺序循环采集摄像头,公平分配资源;
  • 队列限流:队列积压时降低采集频率,防止内存暴涨;
  • 异常隔离:单个摄像头异常不影响整个线程运行。

2.8 公共业务接口

python 复制代码
# 启动/停止抓帧
start() / stop()
# 获取帧数据
get_frame() / get_latest_frames()
# 运行状态统计
get_statistics()
  • 封装极简API,业务层无需关注底层多线程、连接池细节;
  • 统计接口:支持监控连接数、成功率、队列大小等核心指标。

3. 补充讲解与最佳实践

3.1 核心优化点总结

  1. 资源优化:连接池复用+LRU淘汰,大幅降低系统资源占用;
  2. 稳定性优化:自动重连、重试机制、指数退避,适配复杂网络环境;
  3. 性能优化 :多线程负载均衡+无锁队列,支持几十路摄像头并发采集;
  4. 鲁棒性优化:全链路异常捕获,单个摄像头故障不影响整体服务。

3.2 使用注意事项

  1. 依赖要求 :必须安装支持FFmpeg的OpenCV(pip install opencv-python);
  2. 配置规范cameraUrl.txt中一行一个RTSP地址,支持#注释;
  3. 参数调优
    • num_threads:建议设置为CPU核心数的1~2倍;
    • max_connections_per_thread:根据内存大小调整,避免连接过多;
  4. 硬件适配 :代码支持RK3588等嵌入式平台的硬解(h264_rkmpp/hevc_rkmpp)。

3.3 扩展方向

  1. 增加硬解强制开启配置,提升嵌入式设备解码性能;
  2. 集成告警机制:摄像头连续失败时推送邮件/企业微信告警;
  3. 支持动态添加/删除摄像头,无需重启服务;
  4. 对接Redis/消息队列,实现分布式帧采集。

3.4 适用场景

  • 多路安防摄像头实时监控;
  • 机器视觉算法推理前的视频流采集;
  • 工业视觉、机器人视觉的多路视频输入;
  • 服务器/嵌入式设备(RK3588、Jetson)的视频流处理。

4. 完整插件代码

相机的url路径填写在cfg/cameraUrl.txt文件中,代码默认读取该文件。

可以自行创建该配置文件或者修改读取配置文件的路径/方式。

复制代码
import threading
import cv2
import numpy as np
import time
import queue
import re
import os
from collections import namedtuple
from logModule.log import Log

logger = Log.getLogger("task")

CameraFrame = namedtuple('CameraFrame', ['camera_ip', 'timestamp', 'image_data'])

# cv2.VideoCapture 内部 ffmpeg 选项 (进程级, 首次 import 时生效)
# rtsp_transport=tcp: 强制 TCP 传输, 避免 UDP 丢包导致解码错误
# loglevel=error: 只输出严重错误, 抑制非致命的 H264 解码警告
os.environ.setdefault(
    'OPENCV_FFMPEG_CAPTURE_OPTIONS',
    'rtsp_transport;tcp|loglevel;error'
)


class PluginCatchFrame:
    """基于 cv2.VideoCapture 的 RTSP 多线程抓帧插件"""

    OUTPUT_WIDTH = 640
    OUTPUT_HEIGHT = 384

    def __init__(self, camera_url_file="cfg/cameraUrl.txt", num_threads=3,
                 max_connections_per_thread=50, connection_timeout=120):
        self.camera_url_file = camera_url_file
        self.num_threads = num_threads
        self.max_connections_per_thread = max_connections_per_thread
        self.connection_timeout = connection_timeout

        self.camera_urls = []
        self.camera_ips = []
        self.frame_queue = queue.Queue(maxsize=100)
        self.threads = []
        self.running = False

        self._load_camera_urls()
        self.thread_camera_assignments = self._assign_cameras_to_threads()

        # 连接池: {thread_id: {camera_ip: (cap, last_used_time)}}
        self.connection_pool = {}
        self.connection_lock = threading.Lock()
        self.total_connections = 0
        self.connection_rebuilds = 0

        # 摄像头健康监控 & 访问间隔
        self.camera_last_access = {}
        self.min_access_interval = 0.04
        self.max_access_interval = 0.5
        self.camera_failure_count = {}
        self.camera_success_count = {}
        self.max_retry_attempts = 3
        self.connection_health = {}

        logger.info(
            f"PluginCatchFrame 初始化完成: {len(self.camera_urls)} 个摄像头, "
            f"{num_threads} 线程, 每线程最大 {max_connections_per_thread} 连接"
        )

    # ────────────────── 初始化 ──────────────────

    def _load_camera_urls(self):
        """从配置文件加载摄像头 URL 列表"""
        try:
            with open(self.camera_url_file, 'r', encoding='utf-8') as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith('#'):
                        continue
                    self.camera_urls.append(line)
                    ip_match = re.search(r'@(\d+\.\d+\.\d+\.\d+):', line)
                    self.camera_ips.append(ip_match.group(1) if ip_match else "unknown")

            if not self.camera_urls:
                raise RuntimeError(f"配置文件中无有效摄像头 URL: {self.camera_url_file}")

            logger.info(f"已加载 {len(self.camera_urls)} 个摄像头 URL")
        except Exception as e:
            logger.error(f"加载摄像头 URL 失败 ({self.camera_url_file}): {e}")
            raise

    def _assign_cameras_to_threads(self):
        """将摄像头均匀分配到各工作线程 (前 remainder 个线程多分一个)"""
        assignments = {}
        n_cams = len(self.camera_urls)

        if n_cams <= self.num_threads:
            for i in range(n_cams):
                assignments[i] = [i]
        else:
            per_thread = n_cams // self.num_threads
            remainder = n_cams % self.num_threads
            start = 0
            for tid in range(self.num_threads):
                count = per_thread + (1 if tid < remainder else 0)
                assignments[tid] = list(range(start, start + count))
                start += count

        for tid, indices in assignments.items():
            ips = [self.camera_ips[i] for i in indices]
            logger.info(f"线程 {tid} 分配摄像头: {ips}")

        return assignments

    # ────────────────── 连接管理 ──────────────────

    def _create_capture(self, url):
        """创建并配置 cv2.VideoCapture, 失败返回 None"""
        cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG, [
            cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000,
            cv2.CAP_PROP_READ_TIMEOUT_MSEC, 5000,
        ])
        if not cap.isOpened():
            cap.release()
            return None
        cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.OUTPUT_WIDTH)
        cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.OUTPUT_HEIGHT)
        cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
        return cap

    def _is_connection_alive(self, cap):
        """轻量连接存活检查 (不读帧, 避免在锁内阻塞 I/O)"""
        return cap is not None and cap.isOpened()

    def _release_cap(self, cap):
        """安全释放 VideoCapture"""
        try:
            if cap is not None:
                cap.release()
                self.total_connections -= 1
        except Exception as e:
            logger.debug(f"释放 VideoCapture 异常: {e}")

    def _get_connection(self, thread_id, url, camera_ip):
        """
        从连接池获取连接; 过期/失效时自动重建。
        超过 max_connections_per_thread 时 LRU 淘汰最久未用的连接。
        """
        with self.connection_lock:
            now = time.time()
            pool = self.connection_pool.setdefault(thread_id, {})

            # 已有连接: 检查是否过期或已断开
            if camera_ip in pool:
                cap, last_used = pool[camera_ip]
                if (now - last_used > self.connection_timeout
                        or not self._is_connection_alive(cap)):
                    self._release_cap(cap)
                    del pool[camera_ip]
                    self.connection_rebuilds += 1
                else:
                    pool[camera_ip] = (cap, now)
                    return cap

            # LRU 淘汰: 连接数达上限时移除最久未用的
            if len(pool) >= self.max_connections_per_thread:
                oldest_ip = min(pool, key=lambda ip: pool[ip][1])
                self._release_cap(pool[oldest_ip][0])
                del pool[oldest_ip]
                logger.warning(f"线程 {thread_id} 连接数达上限, 淘汰最久未用: {oldest_ip}")

        # 在锁外创建新连接 (cv2.VideoCapture 是阻塞操作, 不应持锁)
        cap = self._create_capture(url)
        if cap is None:
            logger.warning(f"线程 {thread_id} 创建连接失败: {camera_ip}")
            return None

        with self.connection_lock:
            pool = self.connection_pool.setdefault(thread_id, {})
            pool[camera_ip] = (cap, time.time())
            self.total_connections += 1

        return cap

    def _evict_connection(self, thread_id, camera_ip):
        """从连接池移除并释放指定连接 (读帧失败时调用)"""
        with self.connection_lock:
            pool = self.connection_pool.get(thread_id, {})
            if camera_ip in pool:
                self._release_cap(pool[camera_ip][0])
                del pool[camera_ip]
                self.connection_rebuilds += 1

    # ────────────────── 健康监控 & 访问间隔 ──────────────────

    def _get_dynamic_access_interval(self, camera_ip):
        """根据连续失败次数动态计算访问间隔 (退避策略)"""
        health = self.connection_health.get(camera_ip, {})
        fails = health.get('consecutive_failures', 0)
        if fails == 0:
            return self.min_access_interval
        elif fails <= 2:
            return self.min_access_interval * 2
        elif fails <= 4:
            return self.min_access_interval * 4
        else:
            return min(self.max_access_interval, self.min_access_interval * 8)

    def _update_camera_health(self, camera_ip, success):
        """更新摄像头连接健康状态"""
        now = time.time()
        if camera_ip not in self.connection_health:
            self.connection_health[camera_ip] = {
                'last_success': now, 'consecutive_failures': 0
            }
        health = self.connection_health[camera_ip]

        if success:
            health['consecutive_failures'] = 0
            health['last_success'] = now
            self.camera_success_count[camera_ip] = (
                self.camera_success_count.get(camera_ip, 0) + 1
            )
        else:
            health['consecutive_failures'] += 1
            self.camera_failure_count[camera_ip] = (
                self.camera_failure_count.get(camera_ip, 0) + 1
            )

    def _wait_for_access_interval(self, camera_ip):
        """按动态间隔等待, 防止过频访问同一摄像头"""
        interval = self._get_dynamic_access_interval(camera_ip)
        if camera_ip in self.camera_last_access:
            elapsed = time.time() - self.camera_last_access[camera_ip]
            if elapsed < interval:
                time.sleep(interval - elapsed)
        self.camera_last_access[camera_ip] = time.time()

    # ────────────────── 抓帧 ──────────────────

    def _capture_frame(self, thread_id, url, camera_ip):
        """
        从指定摄像头抓取一帧, 含重试机制。
        成功返回 CameraFrame, 全部重试失败返回 None。
        """
        for attempt in range(self.max_retry_attempts):
            try:
                self._wait_for_access_interval(camera_ip)
                cap = self._get_connection(thread_id, url, camera_ip)
                if cap is None:
                    logger.warning(f"获取连接失败: {camera_ip} (第 {attempt + 1} 次)")
                    time.sleep(0.05 * (attempt + 1))
                    continue

                ret, frame = cap.read()

                if not ret or frame is None or frame.size == 0:
                    logger.warning(
                        f"读取帧失败: {camera_ip} "
                        f"(第 {attempt + 1}/{self.max_retry_attempts} 次)"
                    )
                    self._evict_connection(thread_id, camera_ip)
                    time.sleep(0.05 * (attempt + 1))
                    continue

                if frame.shape[1] != self.OUTPUT_WIDTH or frame.shape[0] != self.OUTPUT_HEIGHT:
                    frame = cv2.resize(
                        frame, (self.OUTPUT_WIDTH, self.OUTPUT_HEIGHT),
                        interpolation=cv2.INTER_LINEAR
                    )

                self._update_camera_health(camera_ip, True)
                return CameraFrame(camera_ip, time.time(), frame)

            except Exception as e:
                logger.error(f"抓帧异常: {camera_ip} 第 {attempt + 1} 次: {e}")
                self._evict_connection(thread_id, camera_ip)
                time.sleep(0.05 * (attempt + 1))

        self._update_camera_health(camera_ip, False)
        logger.error(f"抓帧彻底失败: {camera_ip} (已重试 {self.max_retry_attempts} 次)")
        return None

    # ────────────────── 工作线程 ──────────────────

    def _worker_thread(self, thread_id):
        logger.info(f"工作线程 {thread_id} 启动")
        assigned_indices = self.thread_camera_assignments.get(thread_id, [])
        assigned_cameras = [
            {'index': i, 'url': self.camera_urls[i], 'ip': self.camera_ips[i]}
            for i in assigned_indices
        ]

        if not assigned_cameras:
            logger.warning(f"线程 {thread_id} 未分配到摄像头, 退出")
            return

        camera_stats = {cam['ip']: {'success': 0, 'fail': 0} for cam in assigned_cameras}
        current_pos = 0

        while self.running:
            try:
                cam = assigned_cameras[current_pos]
                camera_frame = self._capture_frame(thread_id, cam['url'], cam['ip'])

                if camera_frame:
                    camera_stats[cam['ip']]['success'] += 1
                    try:
                        self.frame_queue.put_nowait(camera_frame)
                    except queue.Full:
                        try:
                            self.frame_queue.get_nowait()
                        except queue.Empty:
                            pass
                        self.frame_queue.put_nowait(camera_frame)
                else:
                    camera_stats[cam['ip']]['fail'] += 1
                    if camera_stats[cam['ip']]['fail'] % 10 == 0:
                        logger.warning(
                            f"摄像头 {cam['ip']} 失败 {camera_stats[cam['ip']]['fail']} 次, "
                            f"成功 {camera_stats[cam['ip']]['success']} 次"
                        )

                current_pos = (current_pos + 1) % len(assigned_cameras)
                time.sleep(0.001 if self.frame_queue.qsize() < 80 else 0.01)

            except Exception as e:
                logger.error(f"工作线程 {thread_id} 异常: {e}")
                time.sleep(0.5)

        logger.info(f"工作线程 {thread_id} 已退出")

    # ────────────────── 公共接口 ──────────────────

    def start(self):
        """启动所有工作线程"""
        if self.running:
            logger.warning("PluginCatchFrame 已在运行中")
            return

        if not self.camera_urls:
            raise RuntimeError("未加载到任何摄像头 URL, 无法启动")

        self.running = True
        for i in range(self.num_threads):
            t = threading.Thread(
                target=self._worker_thread, args=(i,),
                name=f"CatchFrame-{i}", daemon=True
            )
            t.start()
            self.threads.append(t)
        logger.info(f"已启动 {self.num_threads} 个工作线程")

    def stop(self):
        """停止所有工作线程并释放连接资源"""
        if not self.running:
            return

        self.running = False
        for t in self.threads:
            try:
                t.join(timeout=5)
            except RuntimeError as e:
                logger.error(f"等待线程结束异常: {e}")

        while not self.frame_queue.empty():
            try:
                self.frame_queue.get_nowait()
            except queue.Empty:
                break

        with self.connection_lock:
            for tid, pool in self.connection_pool.items():
                for ip, (cap, _) in pool.items():
                    try:
                        cap.release()
                    except Exception as e:
                        logger.debug(f"释放连接异常 ({ip}): {e}")
            self.connection_pool.clear()
            self.total_connections = 0

        self.threads.clear()
        logger.info("PluginCatchFrame 已停止")

    def get_frame(self, timeout=1.0):
        """从队列取一帧, 超时返回 None"""
        try:
            return self.frame_queue.get(timeout=timeout)
        except queue.Empty:
            return None

    def get_latest_frames(self, count=1):
        """批量取帧 (最多 count 帧), 队列空时立即返回"""
        frames = []
        for _ in range(count):
            f = self.get_frame(timeout=0.1)
            if f is None:
                break
            frames.append(f)
        return frames

    def get_frame_count(self):
        """获取队列中待处理的帧数量"""
        return self.frame_queue.qsize()

    def is_running(self):
        """检查插件是否正在运行"""
        return self.running

    def get_statistics(self):
        """获取运行统计信息"""
        with self.connection_lock:
            pool_stats = {
                f"thread_{tid}": len(pool)
                for tid, pool in self.connection_pool.items()
            }

        total_success = sum(self.camera_success_count.values())
        total_fail = sum(self.camera_failure_count.values())
        total_attempts = total_success + total_fail
        success_rate = (total_success / total_attempts * 100) if total_attempts > 0 else 0

        camera_stats = {}
        for ip in self.camera_ips:
            camera_stats[ip] = {
                'success': self.camera_success_count.get(ip, 0),
                'fail': self.camera_failure_count.get(ip, 0),
                'health': self.connection_health.get(ip, {}).get('consecutive_failures', 0),
            }

        return {
            "total_cameras": len(self.camera_urls),
            "total_connections": self.total_connections,
            "connection_rebuilds": self.connection_rebuilds,
            "queue_size": self.frame_queue.qsize(),
            "pool_distribution": pool_stats,
            "total_success": total_success,
            "total_fail": total_fail,
            "success_rate": f"{success_rate:.2f}%",
            "camera_stats": camera_stats,
        }

    def __del__(self):
        try:
            self.stop()
        except Exception:
            pass

5. 测试脚本

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PluginCatchFrame (cv2.VideoCapture) 抓帧性能测试脚本
直接运行: python3 test_cv_plugin.py
"""

import os
import sys
import time
from collections import defaultdict

# ─────────────── 测试参数 (如需调整直接改这里) ───────────────
CAMERA_URL_FILE = os.path.join("cfg", "cameraUrl.txt")
DURATION_S = 30.0
WARMUP_S = 3.0
NUM_THREADS = 3
MAX_CONNS_PER_THREAD = 50
GET_FRAME_TIMEOUT_S = 0.5
PRINT_EVERY_S = 5.0
# ──────────────────────────────────────────────────────────────


def _setup_path():
    root = os.path.dirname(os.path.abspath(__file__))
    src = os.path.join(root, "src")
    for p in (root, src):
        if p not in sys.path:
            sys.path.insert(0, p)


def _load_urls(path: str) -> list[str]:
    if not os.path.exists(path):
        raise FileNotFoundError(f"摄像头配置文件不存在: {path}")
    urls = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            s = line.strip()
            if s and not s.startswith("#"):
                urls.append(s)
    if not urls:
        raise RuntimeError(f"配置文件中无有效 RTSP URL: {path}")
    return urls


def _print_header(n_cameras: int):
    print("========== PluginCatchFrame (CV2) 测试 ==========")
    print(f"工作目录       : {os.getcwd()}")
    print(f"配置文件       : {CAMERA_URL_FILE}")
    print(f"摄像头数量     : {n_cameras}")
    print(f"测试时长       : {DURATION_S:.1f} 秒")
    print(f"预热时长       : {WARMUP_S:.1f} 秒")
    print(f"工作线程数     : {NUM_THREADS}")
    print(f"每线程最大连接 : {MAX_CONNS_PER_THREAD}")
    print(f"取帧超时       : {GET_FRAME_TIMEOUT_S:.3f} 秒")
    print(f"统计打印间隔   : {PRINT_EVERY_S:.1f} 秒")
    print("=================================================")


def _print_live(elapsed, measured, frames, max_q):
    fps = frames / measured if measured > 0 else 0.0
    print(
        f"[统计] 已运行={elapsed:6.1f}s  有效={measured:6.1f}s  "
        f"帧数={frames:7d}  FPS={fps:6.2f}  队列峰值={max_q}"
    )


def _print_report(total_elapsed, frames_all, frames_ok, cam_all, cam_ok, max_q, stats):
    fps_all = frames_all / max(total_elapsed, 1e-6)
    fps_ok = frames_ok / max(total_elapsed - WARMUP_S, 1e-6)

    print("\n==================== 测试结果 ====================")
    print(f"总耗时         : {total_elapsed:.2f} 秒")
    print(f"总帧数         : {frames_all}")
    print(f"总 FPS         : {fps_all:.2f}")
    print("--------------------------------------------------")
    eff = max(total_elapsed - WARMUP_S, 0.0)
    print(f"有效耗时       : {eff:.2f} 秒 (去除预热)")
    print(f"有效帧数       : {frames_ok}")
    print(f"有效 FPS       : {fps_ok:.2f}")
    print("--------------------------------------------------")
    print(f"队列峰值       : {max_q}")

    if cam_all:
        print("\n各摄像头帧数 (全部):")
        for ip, cnt in sorted(cam_all.items()):
            print(f"  {ip:15s} -> {cnt:8d} 帧")

    if cam_ok:
        print("\n各摄像头帧数 (有效):")
        for ip, cnt in sorted(cam_ok.items()):
            print(f"  {ip:15s} -> {cnt:8d} 帧")

    if stats:
        sr = stats.get("success_rate", "N/A")
        rb = stats.get("connection_rebuilds", 0)
        print(f"\n连接重建次数   : {rb}")
        print(f"抓帧成功率     : {sr}")
        cs = stats.get("camera_stats", {})
        if cs:
            print("\n各摄像头健康:")
            for ip, info in sorted(cs.items()):
                print(
                    f"  {ip:15s}  成功={info['success']:6d}  "
                    f"失败={info['fail']:4d}  连续失败={info['health']}"
                )

    if not cam_all:
        print("\n[警告] 未采集到任何帧! 请检查 RTSP URL、网络连通性及插件日志.")

    print("==================================================")


def main():
    _setup_path()
    from plugins.PluginCatchFrame import PluginCatchFrame

    urls = _load_urls(CAMERA_URL_FILE)
    _print_header(len(urls))

    plugin = None
    frames_all = 0
    frames_ok = 0
    max_q = 0
    cam_all = defaultdict(int)
    cam_ok = defaultdict(int)

    try:
        plugin = PluginCatchFrame(
            camera_url_file=CAMERA_URL_FILE,
            num_threads=NUM_THREADS,
            max_connections_per_thread=MAX_CONNS_PER_THREAD,
        )
        plugin.start()
    except Exception as e:
        print(f"[致命] 初始化/启动失败: {e}")
        raise

    print("[信息] 已启动, 正在采集帧...\n")
    t0 = time.time()
    last_print = t0

    try:
        while True:
            now = time.time()
            elapsed = now - t0
            if elapsed >= DURATION_S:
                break

            frame = plugin.get_frame(timeout=GET_FRAME_TIMEOUT_S)
            if frame is None:
                q = plugin.get_frame_count()
                if q > max_q:
                    max_q = q
                continue

            frames_all += 1
            cam_all[frame.camera_ip] += 1

            if elapsed >= WARMUP_S:
                frames_ok += 1
                cam_ok[frame.camera_ip] += 1

            q = plugin.get_frame_count()
            if q > max_q:
                max_q = q

            if (now - last_print) >= PRINT_EVERY_S:
                _print_live(elapsed, max(0.0, elapsed - WARMUP_S), frames_ok, max_q)
                last_print = now

    except KeyboardInterrupt:
        print("\n[信息] 用户中断.")
    finally:
        stats = {}
        if plugin is not None:
            try:
                stats = plugin.get_statistics()
            except Exception:
                pass
            try:
                plugin.stop()
            except Exception as e:
                print(f"[警告] 停止插件异常: {e}")

    total_elapsed = max(time.time() - t0, 1e-6)
    _print_report(total_elapsed, frames_all, frames_ok, cam_all, cam_ok, max_q, stats)


if __name__ == "__main__":
    main()

运行方式:

复制代码
python3 test_cv_plugin.py

总结

本插件通过FFmpeg解码优化+多线程并发+连接池管理+健康监控 的组合设计,完美解决了多路RTSP摄像头抓帧的性能、稳定性、资源占用三大核心问题。代码结构清晰、模块化程度高,可直接集成到工业级视觉项目中,是多路视频流采集的最佳实践方案。

相关推荐
深念Y6 小时前
FFmpeg 480p 转码失败但 1080p/720p 正常的坑
ffmpeg·音视频·转码·流媒体·分辨率·hls·m3u8
七点半7707 小时前
FFmpeg C++ AI视觉开发核心手册 (整合版)适用场景:视频流接入、AI模型预处理(抽帧/缩放/格式转换)、高性能算法集成。
c++·人工智能·ffmpeg
hu55667982 天前
FFmpeg 如何合并字幕
ffmpeg
屋檐上的大修勾2 天前
使用ffmpeg本地发布rtmp/rtsp直播流
ffmpeg
雄哥0072 天前
Windows系统下FFmpeg的安装与环境配置指南
windows·ffmpeg
ALONE_WORK2 天前
ffmpeg-rk3568-mpp 硬件加速版本
ffmpeg·视频编解码·mpp·视频推流
紫金修道3 天前
【编解码】RK3588 平台基于 FFmpeg RKMPP 硬解的多路 RTSP 抓帧插件实战
ffmpeg·rkmpp
QMCY_jason3 天前
RK3588 交叉编译ffmpeg提示rockchip_mpp>=1.3.9 错误的问题
ffmpeg
Memory_荒年5 天前
FFmpeg:音视频界的“万能瑞士军刀”
ffmpeg