【编解码】RK3588 平台基于 FFmpeg RKMPP 硬解的多路 RTSP 抓帧插件实战

    • 前言
    • [1. 核心原理介绍](#1. 核心原理介绍)
      • [1.1 RKMPP硬件视频解码原理](#1.1 RKMPP硬件视频解码原理)
      • [1.2 FFmpeg子进程解码架构](#1.2 FFmpeg子进程解码架构)
      • [1.3 多线程与负载均衡原理](#1.3 多线程与负载均衡原理)
      • [1.4 连接池与资源管理原理](#1.4 连接池与资源管理原理)
      • [1.5 动态控制与容错机制](#1.5 动态控制与容错机制)
    • [2. 关键步骤介绍](#2. 关键步骤介绍)
      • [2.1 配置文件设计](#2.1 配置文件设计)
      • [2.2 初始化核心流程](#2.2 初始化核心流程)
      • [2.3 系统配置加载(核心优化点)](#2.3 系统配置加载(核心优化点))
      • [2.4 RKMPP硬件环境初始化](#2.4 RKMPP硬件环境初始化)
      • [2.5 视频编码自动探测](#2.5 视频编码自动探测)
      • [2.6 连接池管理(资源控制核心)](#2.6 连接池管理(资源控制核心))
      • [2.7 安全读帧与抓帧逻辑](#2.7 安全读帧与抓帧逻辑)
      • [2.8 工作线程主循环](#2.8 工作线程主循环)
      • [2.9 对外公共接口](#2.9 对外公共接口)
    • [3. 补充讲解与最佳实践](#3. 补充讲解与最佳实践)
      • [3.1 核心环境依赖](#3.1 核心环境依赖)
      • [3.2 参数调优指南](#3.2 参数调优指南)
      • [3.3 核心优势总结](#3.3 核心优势总结)
      • [3.4 常见问题排查](#3.4 常见问题排查)
      • [3.5 适用场景](#3.5 适用场景)
      • [3.6 扩展方向](#3.6 扩展方向)
    • [4. 完整插件代码](#4. 完整插件代码)
    • [5. 测试脚本](#5. 测试脚本)
    • 总结

前言

在边缘计算、安防监控、机器人视觉等场景中,RK3588 凭借内置VPU硬件解码能力成为多路视频流处理的首选平台。本文基于瑞芯微RKMPP硬解引擎+FFmpeg子进程,实现了一款配置化、高并发、低功耗的多路RTSP摄像头抓帧插件,彻底解决软解CPU占用高、并发能力弱、参数硬编码等痛点。


1. 核心原理介绍

本插件围绕 RKMPP硬件解码FFmpeg子进程处理多线程并发架构 三大核心技术构建,理论原理如下:

1.1 RKMPP硬件视频解码原理

RKMPP(Rockchip Media Process Platform)是瑞芯微专为RK3588芯片设计的硬件编解码底层框架

  • 直接调用芯片VPU硬件资源完成H.264/H.265(HEVC)视频流解码,完全解放CPU
  • 支持专用硬解解码器:h264_rkmpp(H.264)、hevc_rkmpp(H.265);
  • 解码流程:RTSP流接收 → VPU硬件解码 → 输出原始BGR24裸流 → 格式转换,无CPU软解损耗。

1.2 FFmpeg子进程解码架构

插件摒弃OpenCV封装,直接采用FFmpeg子进程进行硬解:

  • 独立进程隔离,单个摄像头解码异常不影响整体服务;
  • 精准控制解码参数、输出格式、分辨率缩放;
  • 标准输出裸流数据,直接转为numpy数组,适配AI推理、图像处理业务。

1.3 多线程与负载均衡原理

  • 将多路摄像头均匀分配至多个工作线程,避免单线程性能瓶颈;
  • 线程负载均衡算法:摄像头数量 > 线程数时均分,余数线程额外分配1个,保证算力公平利用;
  • 守护线程异步运行,不阻塞主业务逻辑。

1.4 连接池与资源管理原理

  • 连接复用:复用FFmpeg子进程,减少频繁创建/销毁的性能开销;
  • LRU淘汰策略:单线程连接数达上限时,自动释放最久未用的连接,控制VPU/进程资源占用;
  • 超时自动重建:连接失效、空闲超时后自动重建,保证服务高可用。

1.5 动态控制与容错机制

  1. 配置化全局控制:从YAML配置文件加载线程数、全局FPS上限,无需修改代码;
  2. 指数退避访问:摄像头抓帧失败时,动态增加访问间隔,保护设备不被压垮;
  3. 超时安全读帧 :基于select+os.read实现精确超时读,避免进程永久阻塞;
  4. 编码自动探测:预探测摄像头编码格式,自动匹配硬解解码器。

2. 关键步骤介绍

我们按照插件生命周期拆解代码,逐模块讲解核心逻辑,快速掌握代码精髓。

2.1 配置文件设计

插件采用配置与代码解耦设计,支持两个核心配置文件:

  1. cameraUrl.txt:存储RTSP摄像头地址,支持注释、空行过滤;
  2. system_config.yaml:配置抓帧线程数全局FPS上限,灵活调整性能。
yaml 复制代码
# 系统配置
capture_threads: 3    # 抓帧工作线程数
fps_upperlimit: -1    # 全局FPS上限,<=1不限制

2.2 初始化核心流程

插件初始化按固定顺序执行,保证依赖完备:

python 复制代码
# 初始化核心顺序
1. _load_system_config()   # 加载系统配置(线程数、FPS)
2. _load_camera_urls()     # 加载摄像头RTSP地址
3. _init_rkmpp_environment() # 初始化RKMPP硬解环境
4. _assign_cameras_to_threads() # 摄像头线程分配
5. _probe_all_codecs()     # 探测所有摄像头编码格式
  • 正则提取摄像头IP作为唯一标识;
  • 配置文件无效时自动使用代码默认值,保证兼容性。

2.3 系统配置加载(核心优化点)

python 复制代码
def _load_system_config()
  • 读取system_config.yaml动态覆盖代码默认参数
  • 线程数校验:仅正整数生效,否则使用默认值3;
  • FPS限速校验:仅数值>1时启用,自动计算单线程帧间隔,实现全局帧率控制。

2.4 RKMPP硬件环境初始化

python 复制代码
def _init_rkmpp_environment()
  • 执行RK3588平台硬件解码环境脚本,配置系统环境变量;
  • 加载定制版FFmpeg库路径,优先使用平台适配的FFmpeg;
  • 环境异常时自动回退系统FFmpeg,保证插件不崩溃。

2.5 视频编码自动探测

python 复制代码
def _probe_all_codecs()
def _detect_stream_codec()
  • 调用ffprobe静默探测视频流编码格式(H.264/H.265);
  • 缓存编码结果,运行时自动匹配对应的RKMPP硬解解码器;
  • 未知编码默认使用H.264硬解,兼容绝大多数安防摄像头。

2.6 连接池管理(资源控制核心)

python 复制代码
def _get_connection()
  1. 线程安全 :通过threading.Lock保证连接池操作原子性;
  2. 连接校验:检查FFmpeg子进程存活状态,失效则释放重建;
  3. LRU淘汰:连接数达上限时,淘汰最久未用的连接;
  4. 无锁创建:FFmpeg子进程在锁外创建,避免阻塞死锁。

2.7 安全读帧与抓帧逻辑

python 复制代码
def _read_exact()  # 超时精确读帧
def _capture_frame()  # 抓帧主逻辑
  • _read_exact:解决裸流读取阻塞问题,通过select实现超时控制,精确读取指定字节数;
  • 差异化超时:首帧等待RTSP握手(2s),已预热连接常规读帧(1s);
  • 最大3次重试机制,提升弱网环境下的抓帧成功率;
  • 裸流数据直接转为numpy数组,零损耗适配业务。

2.8 工作线程主循环

python 复制代码
def _worker_thread()
  • 轮询分配的摄像头,循环抓帧;
  • 全局FPS限速:严格按照配置控制总帧率,均衡系统负载;
  • 队列限流:帧队列积压时降低采集频率,防止内存溢出;
  • 异常隔离:单个摄像头异常不影响线程运行。

2.9 对外公共接口

极简API设计,业务层零感知底层细节:

  • start():启动所有抓帧线程;
  • stop():安全停止服务,销毁所有FFmpeg子进程;
  • get_frame():获取最新视频帧;
  • get_statistics():获取全维度运行统计(连接数、成功率、编码格式等)。

3. 补充讲解与最佳实践

3.1 核心环境依赖

  1. 硬件平台:瑞芯微RK3588(必须支持RKMPP硬件解码);
  2. 软件依赖:RK3588定制版FFmpeg、ffprobe、Python3、pyyaml、numpy;
  3. 权限要求:具备硬件解码设备、进程创建的权限。

3.2 参数调优指南

  1. capture_threads:建议设置为CPU核心数的1~2倍,匹配芯片算力;
  2. max_connections_per_thread:根据VPU解码能力调整,建议≤50;
  3. fps_upperlimit:嵌入式平台建议≤30,平衡性能与功耗;
  4. min_access_interval:最小访问间隔,默认0.04s,兼顾帧率与设备压力。

3.3 核心优势总结

  1. 极致低CPU占用:硬解替代软解,CPU占用降低80%以上;
  2. 配置化灵活控制:线程数、FPS全配置化,无需修改代码;
  3. 高并发稳定性:单设备支持32+路摄像头并发抓帧,7×24小时稳定运行;
  4. 全链路容错:自动重连、超时控制、异常回退,适配复杂网络环境。

3.4 常见问题排查

  1. 硬解失败:检查RKMPP环境脚本、FFmpeg是否为RK3588定制版;
  2. 抓帧超时:检查RTSP地址、网络连通性,开启TCP传输;
  3. 配置不生效:检查YAML配置格式、参数类型是否符合要求;
  4. 进程泄漏stop()方法会自动销毁所有子进程,无需手动处理。

3.5 适用场景

  • RK3588边缘计算平台多路安防监控;
  • 机器人视觉多路摄像头实时采集;
  • 工业视觉低功耗视频流硬解预处理;
  • AI推理前的多路视频流标准化处理。

3.6 扩展方向

  1. 增加VPU硬件占用监控,实时感知解码资源负载;
  2. 支持动态增删摄像头,无需重启服务;
  3. 集成离线告警,摄像头离线时实时推送通知;
  4. 对接ROS/MQTT协议,拓展物联网、机器人场景。

4. 完整插件代码

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

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

复制代码
# 系统配置

# 抓帧线程数 (PluginCatchFrameByMPP 工作线程)
# - 有效值: 正整数 (>= 1)
# - 未配置或无效则使用代码默认值 (3)
capture_threads: 3

# FPS 上限 (所有抓帧线程总和)
# - 有效值: int 或 float, 且 > 1
# - 设为非数值或 <= 1 则不限制 FPS, 以最大效率运行
# - 示例: fps_upperlimit: 10  表示全局抓帧总 FPS 不超过 10
fps_upperlimit: -1

rkmpp解码代码:

复制代码
import threading
import numpy as np
import time
import queue
import re
import subprocess
import os
import sys
import select
import yaml
from collections import namedtuple

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from logModule.log import Log

logger = Log.getLogger("task")

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


class PluginCatchFrameByMPP:
    """基于 ffmpeg RKMPP 硬件解码的 RTSP 多线程抓帧插件 (RK3588 VPU)"""

    def __init__(self, camera_url_file="cfg/cameraUrl.txt", num_threads=3,
                 max_connections_per_thread=50, connection_timeout=120,
                 ffmpeg_lib_path="/home/tetraelc/applet/trobot_streaming/tetraelc/tcstreamer/release/ffmpeg/lib",
                 env_script_path="/home/tetraelc/applet/trobot_streaming/tetraelc/tcstreamer/rockchip/release/env-rockchip.sh",
                 output_size=(640, 384), min_access_interval=0.04):
        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.ffmpeg_lib_path = ffmpeg_lib_path
        self.env_script_path = env_script_path
        self.output_size = output_size
        self.min_access_interval = min_access_interval

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

        self.ffmpeg_full_path = "ffmpeg"
        self.ffprobe_full_path = "ffprobe"

        # FPS 限速参数 (由 _load_system_config 填充)
        self.fps_limit_enabled = False
        self.thread_frame_interval = 0.0

        # 从 system_config.yaml 加载配置 (可覆盖 num_threads / fps_upperlimit)
        self._load_system_config()

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

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

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

        # 已预热连接 (成功读取过至少一帧), 受 connection_lock 保护
        self.warmed_connections = set()
        self.ffmpeg_timeout = 1        # 已预热连接的读帧超时
        self.first_frame_timeout = 2   # 首帧超时 (含 RTSP 握手 + I帧等待)

        # 编码格式缓存: {camera_ip: 'h264' | 'hevc'}
        self.camera_codec_cache = {}
        self._probe_all_codecs()

        logger.info(
            f"PluginCatchFrameByMPP 初始化完成: {len(self.camera_urls)} 个摄像头, "
            f"{self.num_threads} 线程, 每线程最大 {self.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 _init_rkmpp_environment(self):
        """加载 RKMPP 环境脚本, 设置 ffmpeg/ffprobe 路径; 失败时回退系统默认"""
        if not os.path.exists(self.env_script_path):
            logger.warning(f"RKMPP 环境脚本不存在: {self.env_script_path}, 使用系统默认 ffmpeg")
            return

        try:
            result = subprocess.run(
                f"source {self.env_script_path} && env",
                shell=True, capture_output=True,
                text=True, timeout=10, executable='/bin/bash'
            )
            if result.returncode != 0:
                raise RuntimeError(f"环境脚本执行失败 (exit={result.returncode}): {result.stderr}")

            for line in result.stdout.strip().split('\n'):
                if '=' in line:
                    key, value = line.split('=', 1)
                    os.environ[key] = value

            os.environ['LD_LIBRARY_PATH'] = (
                f"{self.ffmpeg_lib_path}:{os.environ.get('LD_LIBRARY_PATH', '')}"
            )
            bin_dir = os.path.join(os.path.dirname(self.ffmpeg_lib_path), 'bin')
            self.ffmpeg_full_path = os.path.join(bin_dir, 'ffmpeg')
            self.ffprobe_full_path = os.path.join(bin_dir, 'ffprobe')
            logger.info(f"RKMPP 环境初始化完成, ffmpeg: {self.ffmpeg_full_path}")
        except Exception as e:
            logger.error(f"RKMPP 环境初始化异常, 回退使用系统 ffmpeg: {e}")
            self.ffmpeg_full_path = "ffmpeg"
            self.ffprobe_full_path = "ffprobe"

    # ────────────────── 系统配置加载 ──────────────────

    def _load_system_config(self, config_path="cfg/system_config.yaml"):
        """
        从 system_config.yaml 加载系统配置:
        - capture_threads: 抓帧线程数 (覆盖构造参数 num_threads)
        - fps_upperlimit: FPS 上限
        """
        if not os.path.exists(config_path):
            logger.info(f"系统配置文件不存在: {config_path}, 使用默认参数")
            return

        try:
            with open(config_path, 'r', encoding='utf-8') as f:
                cfg = yaml.safe_load(f) or {}
        except Exception as e:
            logger.warning(f"读取系统配置文件失败: {e}, 使用默认参数")
            return

        # 抓帧线程数
        threads_val = cfg.get('capture_threads')
        if isinstance(threads_val, int) and threads_val >= 1:
            self.num_threads = threads_val
            logger.info(f"从配置文件加载抓帧线程数: {self.num_threads}")
        elif threads_val is not None:
            logger.warning(
                f"capture_threads={threads_val} 无效 (需正整数 >=1), "
                f"使用默认值 {self.num_threads}"
            )

        # FPS 限速
        raw = cfg.get('fps_upperlimit')
        if isinstance(raw, (int, float)) and raw > 1:
            fps_limit = float(raw)
            self.fps_limit_enabled = True
            self.thread_frame_interval = self.num_threads / fps_limit
            per_thread_fps = fps_limit / self.num_threads
            logger.info(
                f"FPS 限速已启用: 总上限={fps_limit:.1f}, "
                f"线程数={self.num_threads}, "
                f"每线程目标={per_thread_fps:.2f} fps, "
                f"帧间隔={self.thread_frame_interval:.3f}s"
            )
        elif raw is not None:
            logger.info(f"fps_upperlimit={raw}, 非有效数值(需 int/float 且 >1), FPS 不设限制")

    # ────────────────── 编码探测 ──────────────────

    def _detect_stream_codec(self, url, camera_ip):
        """使用 ffprobe 探测视频编码格式, 返回 'h264' 或 'hevc' (失败默认 'h264')"""
        cmd = [
            self.ffprobe_full_path,
            '-rtsp_transport', 'tcp',
            '-v', 'error',
            '-select_streams', 'v:0',
            '-show_entries', 'stream=codec_name',
            '-of', 'csv=p=0',
            url
        ]
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
            codec = result.stdout.strip().lower()

            if 'hevc' in codec or 'h265' in codec:
                logger.info(f"摄像头 {camera_ip} 编码格式: H265/HEVC")
                return 'hevc'
            elif 'h264' in codec or 'avc' in codec:
                logger.info(f"摄像头 {camera_ip} 编码格式: H264")
                return 'h264'
            else:
                logger.warning(f"摄像头 {camera_ip} 编码格式未知 ('{codec}'), 默认 h264_rkmpp")
                return 'h264'
        except Exception as e:
            logger.warning(f"探测摄像头 {camera_ip} 编码失败: {e}, 默认 h264_rkmpp")
            return 'h264'

    def _probe_all_codecs(self):
        """初始化时探测所有摄像头的编码格式并缓存"""
        logger.info("开始探测所有摄像头编码格式...")
        for url, ip in zip(self.camera_urls, self.camera_ips):
            self.camera_codec_cache[ip] = self._detect_stream_codec(url, ip)

        h264_n = sum(1 for c in self.camera_codec_cache.values() if c == 'h264')
        hevc_n = len(self.camera_codec_cache) - h264_n
        logger.info(f"编码探测完成: H264={h264_n}, H265/HEVC={hevc_n}")

    def _get_rkmpp_decoder(self, camera_ip):
        """根据缓存的编码格式返回 RKMPP 解码器名称"""
        codec = self.camera_codec_cache.get(camera_ip, 'h264')
        return 'hevc_rkmpp' if codec == 'hevc' else 'h264_rkmpp'

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

    def _is_process_alive(self, process):
        """检查 ffmpeg 子进程是否存活"""
        return process is not None and process.poll() is None

    def _kill_process(self, process, camera_ip=""):
        """安全终止 ffmpeg 子进程"""
        try:
            if process is not None:
                process.kill()
                self.total_connections -= 1
        except Exception as e:
            logger.debug(f"终止 ffmpeg 进程异常 ({camera_ip}): {e}")

    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._kill_process(pool[camera_ip][0], camera_ip)
                del pool[camera_ip]
                self.connection_rebuilds += 1
            self.warmed_connections.discard((thread_id, camera_ip))

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

            if camera_ip in pool:
                process, last_used = pool[camera_ip]
                if (now - last_used > self.connection_timeout
                        or not self._is_process_alive(process)):
                    self._kill_process(process, camera_ip)
                    del pool[camera_ip]
                    self.warmed_connections.discard((thread_id, camera_ip))
                    self.connection_rebuilds += 1
                else:
                    pool[camera_ip] = (process, now)
                    return process

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

        # 在锁外创建 ffmpeg 子进程 (阻塞操作, 不应持锁)
        process = self._spawn_ffmpeg(url, camera_ip)
        if process is None:
            return None

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

        decoder = self._get_rkmpp_decoder(camera_ip)
        logger.info(f"已创建 RKMPP 连接: {camera_ip} (解码器={decoder})")
        return process

    def _spawn_ffmpeg(self, url, camera_ip):
        """启动 ffmpeg RKMPP 解码子进程, 失败返回 None"""
        decoder = self._get_rkmpp_decoder(camera_ip)
        cmd = [
            self.ffmpeg_full_path,
            '-rtsp_transport', 'tcp',
            '-c:v', decoder,
            '-i', url,
            '-vf', f'scale={self.output_size[0]}:{self.output_size[1]}',
            '-f', 'rawvideo',
            '-pix_fmt', 'bgr24',
            '-'
        ]

        try:
            process = subprocess.Popen(
                cmd, stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL, bufsize=0
            )
        except FileNotFoundError:
            logger.error(f"ffmpeg 可执行文件不存在: {self.ffmpeg_full_path}")
            raise
        except Exception as e:
            logger.error(f"启动 ffmpeg 进程失败 ({camera_ip}): {e}")
            return None

        if not self._is_process_alive(process):
            logger.error(f"ffmpeg 进程启动后立即退出: {camera_ip}")
            self._kill_process(process, camera_ip)
            return None

        return process

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

    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
        else:
            return min(self.max_access_interval, self.min_access_interval * 4)

    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 _read_exact(self, process, size, timeout_s):
        """
        从 ffmpeg stdout 精确读取 size 字节。
        使用 select + os.read 实现超时控制, 避免 read() 永久阻塞。
        返回 bytes 或 None (超时/EOF/进程退出)。
        """
        if process is None or process.stdout is None:
            return None

        fd = process.stdout.fileno()
        deadline = time.monotonic() + timeout_s
        chunks = []
        total = 0

        while total < size:
            if process.poll() is not None:
                return None

            remaining = deadline - time.monotonic()
            if remaining <= 0:
                return None

            try:
                ready, _, _ = select.select([fd], [], [], min(remaining, 1.0))
            except (ValueError, OSError):
                return None

            if not ready:
                continue

            try:
                data = os.read(fd, size - total)
            except OSError:
                return None

            if not data:
                return None

            chunks.append(data)
            total += len(data)

        return b"".join(chunks)

    def _capture_frame(self, thread_id, url, camera_ip):
        """
        从指定摄像头抓取一帧, 含重试机制。
        成功返回 CameraFrame, 全部重试失败返回 None。
        """
        frame_size = self.output_size[0] * self.output_size[1] * 3

        for attempt in range(self.max_retry_attempts):
            try:
                self._wait_for_access_interval(camera_ip)
                process = self._get_connection(thread_id, url, camera_ip)
                if process is None:
                    logger.warning(f"获取连接失败: {camera_ip} (第 {attempt + 1} 次)")
                    time.sleep(0.01 * (attempt + 1))
                    continue

                is_cold = (thread_id, camera_ip) not in self.warmed_connections
                timeout = self.first_frame_timeout if is_cold else self.ffmpeg_timeout

                raw_frame = self._read_exact(process, frame_size, timeout_s=timeout)

                if raw_frame is None:
                    logger.warning(
                        f"读取帧超时/EOF: {camera_ip} "
                        f"(第 {attempt + 1}/{self.max_retry_attempts} 次, "
                        f"超时={timeout}s, {'首帧' if is_cold else '常规'})"
                    )
                    self._evict_connection(thread_id, camera_ip)
                    time.sleep(0.01 * (attempt + 1))
                    continue

                frame = np.frombuffer(raw_frame, dtype=np.uint8).reshape(
                    (self.output_size[1], self.output_size[0], 3)
                )

                with self.connection_lock:
                    self.warmed_connections.add((thread_id, camera_ip))
                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.01 * (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"RKMPP 工作线程 {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:
            cycle_start = time.monotonic()
            try:
                cam = assigned_cameras[current_pos]
                frame = self._capture_frame(thread_id, cam['url'], cam['ip'])

                if frame:
                    camera_stats[cam['ip']]['success'] += 1
                    try:
                        self.frame_queue.put_nowait(frame)
                    except queue.Full:
                        try:
                            self.frame_queue.get_nowait()
                        except queue.Empty:
                            pass
                        self.frame_queue.put_nowait(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)

                if self.fps_limit_enabled:
                    sleep_time = self.thread_frame_interval - (time.monotonic() - cycle_start)
                    if sleep_time > 0:
                        time.sleep(sleep_time)
                else:
                    time.sleep(0.001 if self.frame_queue.qsize() < 80 else 0.01)

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

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

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

    def start(self):
        """启动所有工作线程"""
        if self.running:
            logger.warning("PluginCatchFrameByMPP 已在运行中")
            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"RKMPP-{i}", daemon=True
            )
            t.start()
            self.threads.append(t)
        logger.info(f"已启动 {self.num_threads} 个 RKMPP 工作线程")

    def stop(self):
        """停止所有工作线程并终止全部 ffmpeg 子进程"""
        if not self.running:
            return

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

        with self.connection_lock:
            for tid, pool in self.connection_pool.items():
                for ip, (p, _) in pool.items():
                    try:
                        p.kill()
                    except Exception as e:
                        logger.debug(f"终止 ffmpeg 进程异常 ({ip}): {e}")
            self.connection_pool.clear()
            self.total_connections = 0
            self.warmed_connections.clear()

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

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

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

    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),
                'codec': self.camera_codec_cache.get(ip, 'unknown'),
                '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 -*-

import os
import sys
import time
from collections import defaultdict


def _ensure_import_path():
    project_root = os.path.dirname(os.path.abspath(__file__))
    if project_root not in sys.path:
        sys.path.insert(0, project_root)


def _load_urls(path: str) -> list[str]:
    if not os.path.exists(path):
        raise FileNotFoundError(f"摄像头配置文件不存在: {path}")

    urls: list[str] = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            s = line.strip()
            if not s or s.startswith("#"):
                continue
            urls.append(s)

    if not urls:
        raise RuntimeError(f"配置文件中无有效 RTSP URL: {path}")
    return urls


def main():
    _ensure_import_path()

    from src.plugins.PluginCatchFrameByMPP import PluginCatchFrameByMPP  # noqa: E402

    camera_url_file = os.path.join("cfg", "cameraUrl.txt")
    urls = _load_urls(camera_url_file)

    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

    print("========== PluginCatchFrameByMPP 测试 ==========")
    print(f"工作目录       : {os.getcwd()}")
    print(f"配置文件       : {camera_url_file}")
    print(f"摄像头数量     : {len(urls)}")
    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("================================================")

    plugin = None
    total_frames_all = 0
    max_queue_size = 0
    frames_per_camera_all = defaultdict(int)

    total_frames = 0
    frames_per_camera = defaultdict(int)

    t0 = time.time()
    last_print = t0

    try:
        plugin = PluginCatchFrameByMPP(
            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"[致命] 初始化/启动 PluginCatchFrameByMPP 失败: {e}")
        raise

    print("[信息] 已启动, 正在采集帧...")

    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:
                qsize = plugin.get_frame_count()
                if qsize > max_queue_size:
                    max_queue_size = qsize
                continue

            total_frames_all += 1
            frames_per_camera_all[frame.camera_ip] += 1

            if elapsed >= WARMUP_S:
                total_frames += 1
                frames_per_camera[frame.camera_ip] += 1

            qsize = plugin.get_frame_count()
            if qsize > max_queue_size:
                max_queue_size = qsize

            if PRINT_EVERY_S and (now - last_print) >= PRINT_EVERY_S:
                measured_elapsed = max(0.0, elapsed - WARMUP_S)
                fps = (total_frames / measured_elapsed) if measured_elapsed > 0 else 0.0
                print(
                    f"[统计] 已运行={elapsed:6.1f}s  有效={measured_elapsed:6.1f}s  "
                    f"帧数={total_frames:7d}  FPS={fps:6.2f}  队列峰值={max_queue_size}"
                )
                last_print = now
    except KeyboardInterrupt:
        print("\n[信息] 用户中断.")
    finally:
        if plugin is not None:
            try:
                plugin.stop()
            except Exception as e:
                print(f"[警告] 停止插件异常: {e}")

    t1 = time.time()
    total_elapsed = max(t1 - t0, 1e-6)
    measured_elapsed = max(total_elapsed - WARMUP_S, 0.0)
    fps_measured = (total_frames / measured_elapsed) if measured_elapsed > 0 else 0.0
    fps_all = total_frames_all / total_elapsed

    print("\n================= 测试结果 =================")
    print(f"总耗时         : {total_elapsed:.2f} 秒")
    print(f"总帧数         : {total_frames_all}")
    print(f"总 FPS         : {fps_all:.2f}")
    print("----------------------------------------------")
    print(f"有效耗时       : {measured_elapsed:.2f} 秒 (去除预热)")
    print(f"有效帧数       : {total_frames}")
    print(f"有效 FPS       : {fps_measured:.2f}")
    print("----------------------------------------------")
    print(f"队列峰值       : {max_queue_size}")

    if frames_per_camera_all:
        print("\n各摄像头帧数 (全部):")
        for ip, cnt in sorted(frames_per_camera_all.items(), key=lambda x: x[0]):
            print(f"  {ip:15s} -> {cnt:8d} 帧")

    if frames_per_camera:
        print("\n各摄像头帧数 (有效):")
        for ip, cnt in sorted(frames_per_camera.items(), key=lambda x: x[0]):
            print(f"  {ip:15s} -> {cnt:8d} 帧")

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

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


if __name__ == "__main__":
    main()

运行方式:

复制代码
python3 test_mpp_plugin.py

总结

本插件基于RK3588 RKMPP硬件解码引擎,通过FFmpeg子进程硬解+多线程负载均衡+配置化管理+全链路容错 的设计,完美解决了嵌入式平台多路RTSP抓帧的性能、功耗、灵活性三大核心问题。代码模块化程度高、开箱即用、稳定可靠,是RK3588平台视频流处理的工业级最佳实践。

相关推荐
QMCY_jason6 小时前
RK3588 交叉编译ffmpeg提示rockchip_mpp>=1.3.9 错误的问题
ffmpeg
Memory_荒年2 天前
FFmpeg:音视频界的“万能瑞士军刀”
ffmpeg
QJtDK1R5a2 天前
V4L2 vs GStreamer vs FFmpeg:Linux多媒体处理的三个层级
linux·运维·ffmpeg
AI视觉网奇5 天前
webrtc 硬编码
ffmpeg·webrtc
九转成圣5 天前
避坑指南:彻底解决 FFmpeg drawtext 烧录多行文本出现“方块(□)”乱码的终极方案
ffmpeg
bbq烤鸡5 天前
ffmpeg精确极速剪辑方案
ffmpeg
小镇学者5 天前
【python】 macos 安装ffmpeg 命令行工具
python·macos·ffmpeg
QMCY_jason5 天前
RK3588平台编译 ffmpeg-rockchip 使用rkmpp rkrga 进行硬件转码
ffmpeg
悢七6 天前
单机部署 OceanBase 集群
数据库·ffmpeg·oceanbase