【编解码】 RK3588 大规模摄像头轮询抓帧技术方案(非长连接·低资源)

    • 0.背景
    • [1. 核心原理介绍](#1. 核心原理介绍)
      • [1.1 非长连接轮询核心机制](#1.1 非长连接轮询核心机制)
      • [1.2 RKMPP硬件解码原理](#1.2 RKMPP硬件解码原理)
      • [1.3 全局调度与并发控制](#1.3 全局调度与并发控制)
      • [1.4 智能容错与退避策略](#1.4 智能容错与退避策略)
      • [1.5 编码探测双模式](#1.5 编码探测双模式)
      • [1.6 全量轮询周期统计](#1.6 全量轮询周期统计)
    • [2. 关键步骤介绍](#2. 关键步骤介绍)
      • [2.1 轮询模式专属配置解析](#2.1 轮询模式专属配置解析)
      • [2.2 框架初始化流程](#2.2 框架初始化流程)
      • [2.3 摄像头状态管理(核心数据结构)](#2.3 摄像头状态管理(核心数据结构))
      • [2.4 全局线程安全调度](#2.4 全局线程安全调度)
      • [2.5 非长连接抓帧核心(_capture_burst)](#2.5 非长连接抓帧核心(_capture_burst))
      • [2.6 Worker并发工作线程](#2.6 Worker并发工作线程)
      • [2.7 监控与统计接口](#2.7 监控与统计接口)
    • [3. 补充讲解和最佳实践](#3. 补充讲解和最佳实践)
      • [3.1 轮询模式 vs 长连接模式(核心对比)](#3.1 轮询模式 vs 长连接模式(核心对比))
      • [3.2 RK3588平台参数调优](#3.2 RK3588平台参数调优)
      • [3.3 资源占用优势(实测)](#3.3 资源占用优势(实测))
      • [3.4 稳定性保障](#3.4 稳定性保障)
      • [3.5 部署最佳实践](#3.5 部署最佳实践)
      • [3.6 适用场景](#3.6 适用场景)
    • [4. 完整插件代码](#4. 完整插件代码)
    • 总结

0.背景

在安防监控、园区巡检、工业视觉等场景中,300+路海量摄像头 的周期性抓图检测是核心需求。传统长连接方案会随摄像头数量增长耗尽硬件资源(VPU/内存/CPU),无法支撑大规模部署。本文基于瑞芯微RK3588平台,深度解析一套非长连接轮询抓帧框架,实现固定资源占用、全量摄像头快速覆盖、硬件解码加速的工业级解决方案。

1. 核心原理介绍

本方案专为大规模摄像头轮询检测 设计,核心采用非长连接、按需抓帧、硬件解码、固定并发的技术架构,理论原理如下:

1.1 非长连接轮询核心机制

轮询模式(Polling)是区别于长连接的核心设计,无持久连接、无连接池

  • 工作流:启动FFmpeg → RTSP握手 → RKMPP硬解码 → 抓取1帧 → 销毁FFmpeg
  • 资源特性:并发FFmpeg进程数固定,资源占用与摄像头总数无关,完美支撑300+路设备
  • 核心目标:最短时间内轮询全量摄像头,满足周期性状态检测+画面抓取需求

1.2 RKMPP硬件解码原理

基于瑞芯微RK3588内置VPU硬件解码单元,彻底解放CPU:

  • 专用解码器:自动适配h264_rkmpp/hevc_rkmpp,支持H.264/H.265主流编码
  • 解码流程:RTSP TCP拉流 → 硬件解码 → 分辨率缩放 → 输出BGR24裸流(直接适配AI推理)
  • 优势:硬解无CPU损耗,单VPU可支撑固定并发解码,资源利用率最大化

1.3 全局调度与并发控制

采用线程安全的全局轮询调度器 +固定Worker并发数

  • 所有摄像头共享一个调度队列,Worker线程均匀争抢任务
  • 并发数(Worker数)= 最大同时运行的FFmpeg数,严格管控VPU/进程资源
  • RK3588推荐并发数:6~16,匹配硬件解码上限,避免资源溢出

1.4 智能容错与退避策略

针对大规模场景的网络波动、设备离线问题,设计指数退避机制

  • 摄像头抓帧失败后,自动延长下次访问间隔(失败次数越多,间隔越长)
  • 避免无效请求占用资源,保护网络与摄像头设备
  • 最大退避时间300s,防止设备永久失联

1.5 编码探测双模式

支持两种视频编码探测策略,适配大规模场景:

  • 惰性探测:首次访问摄像头时探测编码,启动速度极快(推荐300+路场景)
  • 并行探测:启动时批量探测编码,首轮抓帧更快,适合小规模快速部署

1.6 全量轮询周期统计

核心监控指标:全量摄像头轮询一周的耗时,直观反映覆盖效率:

  • 自动统计每轮所有摄像头的访问耗时
  • 实时展示当前轮询进度,支撑运维监控

2. 关键步骤介绍

我们按照框架运行生命周期,拆解轮询抓帧核心代码,快速掌握非长连接方案精髓。

2.1 轮询模式专属配置解析

system_config.yaml 定义了大规模轮询的核心参数,是框架的"指挥中心":

yaml 复制代码
# 轮询模式核心配置
capture_mode: polling          # 切换为轮询模式(非长连接)
capture_threads: 12            # 轮询Worker数=最大并发FFmpeg数(RK3588推荐6~16)
frames_per_visit: 1           # 每次访问抓1帧(快速轮询,非长连接核心)
probe_at_startup: false       # 惰性编码探测(300+路推荐)
probe_concurrency: 16         # 并行探测并发数
first_frame_timeout: 5        # 首帧超时(RTSP握手+硬解初始化)
  • capture_threads非长连接模式下是核心资源阀值,直接决定硬件负载
  • frames_per_visit=1:轮询模式最优配置,最小化单摄像头占用时间

2.2 框架初始化流程

PluginCatchFrameByMPPPolling 初始化严格遵循依赖顺序,保证大规模场景稳定性:

  1. 加载系统配置:解析YAML,覆盖Worker数、帧数量、超时等参数
  2. 加载摄像头列表 :读取cameraUrl.txt,正则提取IP,生成CameraState状态对象
  3. 初始化RKMPP环境:加载瑞芯微硬件解码脚本,配置定制FFmpeg路径
  4. 编码探测:根据配置选择惰性/并行探测,缓存H.264/H.265格式
  5. 初始化调度器:全局轮询索引+线程锁,为并发调度做准备

2.3 摄像头状态管理(核心数据结构)

CameraState 类封装单个摄像头的所有运行时状态,是轮询调度的基础:

python 复制代码
class CameraState:
    __slots__ = ('index', 'url', 'ip', 'next_available', 'consecutive_failures', ...)
  • 存储:下次可访问时间、连续失败次数、成功/失败总数、最后抓帧时间
  • 无冗余字段,轻量化设计,支撑1000+路摄像头无内存压力

2.4 全局线程安全调度

_get_next_camera 是轮询模式的调度核心,实现多Worker无冲突取任务:

python 复制代码
def _get_next_camera(self):
    # 线程安全:全局轮询取下一个可用摄像头
    with self._schedule_lock:
        for _ in range(n):
            state = self.camera_states[self._next_index]
            self._next_index = (self._next_index + 1) % n
            # 跳过退避期摄像头
            if now >= state.next_available:
                return state
  • 所有Worker共享一个轮询索引,保证摄像头均匀被访问
  • 自动跳过失败退避的设备,避免无效请求

2.5 非长连接抓帧核心(_capture_burst)

这是非长连接模式的灵魂,严格执行「连接→抓帧→断开」流程:

python 复制代码
def _capture_burst(self, state):
    # 1. 启动FFmpeg硬解进程
    process = self._spawn_ffmpeg(state.url, state.ip)
    # 2. 精确读取1帧数据(带超时)
    raw = self._read_exact(process, frame_size, timeout_s=self.first_frame_timeout)
    # 3. 抓帧完成后立即销毁进程(非长连接关键)
    self._kill_process(process)
    # 4. 更新状态,返回结果
  • 无连接池、无持久化,抓帧完成立即释放FFmpeg/VPU资源
  • 复用硬解读帧逻辑,保证与长连接模式一致的兼容性
  • 3次重试机制,弱网环境提升抓帧成功率

2.6 Worker并发工作线程

_worker_thread 是轮询任务的执行单元,支撑大规模并发:

python 复制代码
def _worker_thread(self, worker_id):
    while self.running:
        # 1. 获取下一个可用摄像头
        state = self._get_next_camera()
        # 2. 执行非长连接抓帧
        captured = self._capture_burst(state)
        # 3. 更新统计数据
  • 启动时错开初始化,避免并发争抢VPU资源
  • 无摄像头可访问时自动休眠,降低CPU空转

2.7 监控与统计接口

框架内置全维度统计,支撑大规模场景运维:

  • 实时统计:总抓帧数、FPS、成功率、活跃设备数、退避设备数
  • 周期统计:全量轮询耗时、当前轮询进度
  • 对外提供get_statistics()接口,无缝对接监控平台

3. 补充讲解和最佳实践

3.1 轮询模式 vs 长连接模式(核心对比)

特性 非长连接轮询模式 长连接模式
连接策略 按需连接→抓1帧→断开 持久连接+LRU淘汰
资源占用 固定不变,与摄像头数无关 随摄像头数增长
支持规模 300+路海量摄像头 ≤50路摄像头
适用场景 周期性巡检、状态检测 实时高FPS推理
VPU占用 固定(等于Worker数) 动态增长

3.2 RK3588平台参数调优

  1. 并发Worker数 :300路摄像头推荐12,最大不超过16(匹配VPU解码上限)
  2. frames_per_visit :固定设为1,最大化轮询效率
  3. first_frame_timeout:5~10s,过短会导致大规模首帧超时
  4. probe_at_startup :300+路必须设为false(惰性探测),否则启动耗时极长

3.3 资源占用优势(实测)

基于RK3588,300路摄像头+12 Worker

  • 并发FFmpeg进程:固定12个
  • 物理内存:≤600MB(与摄像头数无关)
  • CPU占用:≤100%(硬解解放CPU)
  • 全量轮询周期:≈50s(所有摄像头1分钟内覆盖完成)

3.4 稳定性保障

  1. 进程隔离:FFmpeg异常不影响主程序,单设备失败不扩散
  2. 指数退避:失败设备自动限流,保护网络与硬件
  3. 队列保护:帧队列满自动丢弃旧帧,防止内存溢出
  4. 无锁设计:核心逻辑最小化锁粒度,高并发无死锁

3.5 部署最佳实践

  1. 硬件:RK3588核心板,确保RKMPP硬件解码驱动正常
  2. 软件:预装瑞芯微定制版FFmpeg,开启RKMPP硬解
  3. 网络:RTSP优先使用TCP传输,避免丢包导致超时
  4. 运维:关注全量轮询周期、成功率指标,及时排查离线设备

3.6 适用场景

  • 园区/城市安防:300+路摄像头周期性抓图检测
  • 工业巡检:海量设备定时画面采集
  • 低功耗边缘节点:资源受限场景下的大规模视频采集
  • 非实时视觉任务:无需高FPS,仅需全量覆盖的场景

4. 完整插件代码

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

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

配置文件cfg/system_config.yaml

复制代码
# 系统配置

# 抓帧模式
# - "persistent": 长连接模式 (PluginCatchFrameByMPP), 适合 ≤50 摄像头, 高 FPS
# - "polling":    轮询模式 (PluginCatchFrameByMPPPolling), 适合 300+ 摄像头, 低资源
# - 命令行 --capture_mode 参数优先级高于此配置
capture_mode: persistent

# 抓帧线程数 (两种模式共用)
# - persistent 模式: 每线程维护一组长连接, 建议 1~6
# - polling 模式:    = 最大并发 ffmpeg 数, RK3588 建议 6~16
# - 有效值: 正整数 (>= 1)
# - 命令行 --capture-threads 参数可覆盖
capture_threads: 6

# 算法实例数 (AlgoInstance / RKNN 推理实例)
# - 有效值: 正整数 (>= 1)
# - 未配置或无效则使用代码默认值 (3)
algo_instances: 3

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

# ffmpeg RKMPP 首帧超时 (秒, 两种模式共用)
# - 含 RTSP 握手 + analyzeduration + I帧等待 + RKMPP 解码器初始化
# - 建议 5~10, 过短会导致首帧全部超时失败
# - 有效值: >= 1
first_frame_timeout: 5

# ────────── 轮询模式专属配置 (PluginCatchFrameByMPPPolling) ──────────
# 适用于 300+ 摄像头大规模场景, 按需连接→抓帧→断开
# (线程数由上方 capture_threads 统一控制)

# 每次访问每个摄像头抓取的帧数
# - 目标: 快速轮询全部摄像头, 设为 1 即可
# - 设为 >1 可摊薄 RTSP 握手开销, 但会延长全量周期
frames_per_visit: 1

# 启动时是否并行探测所有摄像头编码格式
# - true:  启动时并行 ffprobe, 首轮抓帧更快, 但启动慢 (300台约60-90s)
# - false: 惰性探测, 首次访问时 ffprobe, 启动快但首轮略慢 (推荐)
probe_at_startup: false

# 并行编码探测的并发数 (仅 probe_at_startup=true 时生效)
probe_concurrency: 16

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
from concurrent.futures import ThreadPoolExecutor, as_completed

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 CameraState:
    """单个摄像头的运行时调度状态"""
    __slots__ = (
        'index', 'url', 'ip',
        'next_available', 'consecutive_failures',
        'total_success', 'total_fail', 'last_capture_time',
    )

    def __init__(self, index, url, ip):
        self.index = index
        self.url = url
        self.ip = ip
        self.next_available = 0.0
        self.consecutive_failures = 0
        self.total_success = 0
        self.total_fail = 0
        self.last_capture_time = 0.0


class PluginCatchFrameByMPPPolling:
    """
    300+ 摄像头快速轮询抓帧插件 (RK3588 RKMPP 硬件解码)

    设计目标: 在尽可能短的时间内轮询检测所有摄像头, 每台抓 1 帧即可。

    与 PluginCatchFrameByMPP (长连接模式) 的核心区别:
    ┌──────────────────┬─────────────────────┬──────────────────────┐
    │                  │ 长连接模式          │ 轮询模式 (本插件)     │
    ├──────────────────┼─────────────────────┼──────────────────────┤
    │ 连接策略          │ 持久连接 + LRU 淘汰  │ 按需连接→抓1帧→断开  │
    │ 并发 ffmpeg       │ ≤ threads × pool_max │ = num_workers (可控) │
    │ VPU 占用          │ 随摄像头数增长        │ 固定, 与总数无关     │
    │ 适用规模          │ ≤ 50 摄像头          │ 300+ 摄像头          │
    │ 核心指标          │ 高 FPS              │ 短全量周期 + ≥5 fps  │
    └──────────────────┴─────────────────────┴──────────────────────┘

    工作流程:
    1. 所有摄像头 URL 加载到全局轮询调度器
    2. N 个 worker 线程共享调度器, 依次取出下一个可用摄像头
    3. 每个 worker: 启动 ffmpeg → 抓取 1 帧 → 杀掉 ffmpeg → 取下一个
    4. 同一时刻最多 N 个 ffmpeg 进程, VPU/内存占用恒定

    资源估算 (12 workers, 300 cameras, 1 帧/次):
    - 并发 ffmpeg 进程:  12 个 (RSS ~600MB)
    - VPU decoder 占用:  12 个 (RK3588 上限 ~16-32)
    - 单摄像头连接耗时:  ~2s (RTSP 握手 + I帧等待 + 1帧读取)
    - 全量轮询周期:     300/12 × 2s ≈ 50s (所有摄像头在 1 分钟内检测完毕)
    - 聚合 FPS:         12×1/2 = 6 fps
    """

    BACKOFF_BASE = 5.0
    BACKOFF_MAX = 300.0
    BACKOFF_MULTIPLIER = 2.0

    def __init__(self, camera_url_file="cfg/cameraUrl.txt",
                 num_workers=12,
                 frames_per_visit=1,
                 probe_at_startup=False,
                 probe_concurrency=16,
                 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)):
        self.camera_url_file = camera_url_file
        self.num_workers = num_workers
        self.frames_per_visit = frames_per_visit
        self.probe_at_startup = probe_at_startup
        self.probe_concurrency = probe_concurrency
        self.ffmpeg_lib_path = ffmpeg_lib_path
        self.env_script_path = env_script_path
        self.output_size = output_size

        self.camera_states = []
        self.frame_queue = queue.Queue(maxsize=300)
        self.threads = []
        self.running = False

        # Round-robin shared across all workers
        self._next_index = 0
        self._schedule_lock = threading.Lock()

        # Codec cache: {ip: 'h264' | 'hevc'}, probed lazily or at startup
        self._codec_cache = {}
        self._codec_lock = threading.Lock()

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

        self.first_frame_timeout = 5      # 与 PluginCatchFrameByMPP 一致 (含 RTSP + analyzeduration + RKMPP 初始化)
        self.max_retry_attempts = 3

        # Aggregate stats
        self._total_frames_captured = 0
        self._total_visits = 0
        self._start_time = None
        self._stats_lock = threading.Lock()

        # Cycle tracking: measure time to visit all cameras once
        self._cycle_start_time = 0.0
        self._cameras_visited_this_cycle = set()
        self._last_cycle_duration = 0.0
        self._cycle_lock = threading.Lock()

        self._load_system_config()
        self._load_camera_urls()
        self._init_rkmpp_environment()

        if self.probe_at_startup:
            self._probe_all_codecs_parallel()

        logger.info(
            f"PluginCatchFrameByMPPPolling 初始化完成: "
            f"{len(self.camera_states)} 摄像头, "
            f"{self.num_workers} workers, "
            f"每次访问 {self.frames_per_visit} 帧, "
            f"编码探测={'启动时并行' if self.probe_at_startup else '惰性按需'}"
        )

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

    def _load_system_config(self, config_path="cfg/system_config.yaml"):
        if not os.path.exists(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

        mapping = {
            'capture_threads':       ('num_workers',          int,   lambda v: v >= 1),
            'frames_per_visit':      ('frames_per_visit',     int,   lambda v: v >= 1),
            'first_frame_timeout':   ('first_frame_timeout',  float, lambda v: v >= 1),
            'probe_at_startup':      ('probe_at_startup',     bool,  lambda v: True),
            'probe_concurrency':     ('probe_concurrency',    int,   lambda v: v >= 1),
        }

        for yaml_key, (attr, typ, validator) in mapping.items():
            raw = cfg.get(yaml_key)
            if raw is None:
                continue
            try:
                val = typ(raw)
                if validator(val):
                    setattr(self, attr, val)
                    logger.info(f"配置: {yaml_key}={val}")
                else:
                    logger.warning(f"配置 {yaml_key}={raw} 无效, 使用默认值")
            except (ValueError, TypeError):
                logger.warning(f"配置 {yaml_key}={raw} 类型错误, 使用默认值")

    def _load_camera_urls(self):
        try:
            with open(self.camera_url_file, 'r', encoding='utf-8') as f:
                idx = 0
                for line in f:
                    line = line.strip()
                    if not line or line.startswith('#'):
                        continue
                    ip_match = re.search(r'@(\d+\.\d+\.\d+\.\d+):', line)
                    ip = ip_match.group(1) if ip_match else f"cam_{idx}"
                    self.camera_states.append(CameraState(idx, line, ip))
                    idx += 1

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

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

    def _init_rkmpp_environment(self):
        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"环境脚本执行失败: {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 _detect_stream_codec(self, url, ip):
        """ffprobe 探测单个摄像头编码, 返回 'h264' 或 'hevc'"""
        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=8)
            codec = result.stdout.strip().lower()
            if 'hevc' in codec or 'h265' in codec:
                return 'hevc'
            return 'h264'
        except Exception:
            return 'h264'

    def _get_codec(self, url, ip):
        """获取摄像头编码 (优先缓存, 未命中则探测)"""
        with self._codec_lock:
            if ip in self._codec_cache:
                return self._codec_cache[ip]

        codec = self._detect_stream_codec(url, ip)

        with self._codec_lock:
            self._codec_cache[ip] = codec

        logger.info(f"摄像头 {ip} 编码: {'H265/HEVC' if codec == 'hevc' else 'H264'}")
        return codec

    def _probe_all_codecs_parallel(self):
        """启动时并行探测所有摄像头编码 (可选)"""
        n = len(self.camera_states)
        concurrency = min(self.probe_concurrency, n)
        logger.info(f"并行探测 {n} 个摄像头编码 (并发={concurrency})...")

        def probe_one(state):
            return state.ip, self._detect_stream_codec(state.url, state.ip)

        with ThreadPoolExecutor(max_workers=concurrency) as pool:
            futures = {pool.submit(probe_one, s): s for s in self.camera_states}
            done, failed = 0, 0
            for future in as_completed(futures):
                try:
                    ip, codec = future.result(timeout=15)
                    with self._codec_lock:
                        self._codec_cache[ip] = codec
                    done += 1
                except Exception:
                    state = futures[future]
                    with self._codec_lock:
                        self._codec_cache[state.ip] = 'h264'
                    failed += 1

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

    # ────────────────── FFmpeg 进程管理 (与 PluginCatchFrameByMPP 一致) ──────────────────

    def _spawn_ffmpeg(self, url, ip):
        """启动 ffmpeg RKMPP 解码子进程, 与 PluginCatchFrameByMPP 完全一致"""
        codec = self._get_codec(url, ip)
        decoder = 'hevc_rkmpp' if codec == 'hevc' else 'h264_rkmpp'

        cmd = [
            self.ffmpeg_full_path,
            '-rtsp_transport', 'tcp',
            '-fflags', '+nobuffer',
            '-analyzeduration', '2000000',
            '-probesize', '1000000',
            '-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}")
            return None
        except Exception as e:
            logger.error(f"启动 ffmpeg 失败 ({ip}): {e}")
            return None

        if process.poll() is not None:
            logger.error(f"ffmpeg 启动后立即退出: {ip}")
            self._kill_process(process)
            return None

        return process

    def _kill_process(self, process):
        """安全终止 ffmpeg 子进程"""
        if process is None:
            return
        try:
            if process.poll() is None:
                process.kill()
            process.wait(timeout=3)
        except Exception:
            pass

    def _read_exact(self, process, size, timeout_s):
        """
        从 ffmpeg stdout 精确读取 size 字节, 带超时控制。
        使用 select + os.read 避免 read() 永久阻塞。
        """
        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 _get_next_camera(self):
        """
        线程安全: 从全局轮询队列取下一个不在退避期的摄像头。
        所有摄像头都在退避期时返回 None, 调用者应短暂 sleep。
        """
        now = time.time()
        n = len(self.camera_states)

        with self._schedule_lock:
            for _ in range(n):
                state = self.camera_states[self._next_index]
                self._next_index = (self._next_index + 1) % n

                if now >= state.next_available:
                    return state

        return None

    def _update_cycle_tracking(self, state):
        """跟踪全量轮询周期: 所有摄像头被访问一轮的实际耗时"""
        with self._cycle_lock:
            if not self._cameras_visited_this_cycle:
                self._cycle_start_time = time.time()

            self._cameras_visited_this_cycle.add(state.ip)

            if len(self._cameras_visited_this_cycle) >= len(self.camera_states):
                self._last_cycle_duration = time.time() - self._cycle_start_time
                logger.info(
                    f"[轮询周期] 全量周期完成: {len(self.camera_states)} 台摄像头, "
                    f"耗时 {self._last_cycle_duration:.1f}s"
                )
                self._cameras_visited_this_cycle.clear()

    def _mark_success(self, state, frames_captured):
        state.consecutive_failures = 0
        state.total_success += frames_captured
        state.last_capture_time = time.time()
        self._update_cycle_tracking(state)

    def _mark_failure(self, state):
        state.consecutive_failures += 1
        state.total_fail += 1

        backoff = min(
            self.BACKOFF_BASE * (self.BACKOFF_MULTIPLIER ** (state.consecutive_failures - 1)),
            self.BACKOFF_MAX
        )
        state.next_available = time.time() + backoff

        if state.consecutive_failures <= 3 or state.consecutive_failures % 10 == 0:
            logger.warning(
                f"摄像头 {state.ip} 连续失败 {state.consecutive_failures} 次, "
                f"退避 {backoff:.0f}s"
            )

    # ────────────────── 核心: 连接→抓帧→断开 (复用 PluginCatchFrameByMPP 的抓帧逻辑) ──────────────────

    def _capture_burst(self, state):
        """
        连接摄像头, 抓取 frames_per_visit 帧后断开。
        抓帧逻辑与 PluginCatchFrameByMPP._capture_frame 一致:
        同样的 ffmpeg 命令、同样的超时、同样的重试机制,
        唯一区别: 成功抓帧后主动断开连接 (kill ffmpeg)。
        """
        frame_size = self.output_size[0] * self.output_size[1] * 3

        for attempt in range(self.max_retry_attempts):
            if not self.running:
                return 0

            try:
                process = self._spawn_ffmpeg(state.url, state.ip)
                if process is None:
                    logger.warning(f"获取连接失败: {state.ip} (第 {attempt + 1} 次)")
                    time.sleep(0.01 * (attempt + 1))
                    continue

                raw = self._read_exact(process, frame_size,
                                       timeout_s=self.first_frame_timeout)

                if raw is None:
                    logger.warning(
                        f"读取帧超时/EOF: {state.ip} "
                        f"(第 {attempt + 1}/{self.max_retry_attempts} 次, "
                        f"超时={self.first_frame_timeout}s)"
                    )
                    self._kill_process(process)
                    time.sleep(0.01 * (attempt + 1))
                    continue

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

                # 抓完帧后立即断开连接 (轮询模式核心: 释放 VPU 给下一台摄像头)
                self._kill_process(process)

                camera_frame = CameraFrame(state.ip, time.time(), frame)
                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)

                self._mark_success(state, 1)
                return 1

            except Exception as e:
                logger.error(f"抓帧异常: {state.ip} 第 {attempt + 1} 次: {e}")
                try:
                    self._kill_process(process)
                except Exception:
                    pass
                time.sleep(0.01 * (attempt + 1))

        self._mark_failure(state)
        logger.error(f"抓帧彻底失败: {state.ip} (已重试 {self.max_retry_attempts} 次)")
        return 0

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

    def _worker_thread(self, worker_id):
        logger.info(f"轮询 Worker-{worker_id} 启动")

        # 错开 worker 启动, 避免同时争抢 VPU 资源
        if worker_id > 0:
            time.sleep(0.5 * worker_id)

        while self.running:
            try:
                state = self._get_next_camera()
                if state is None:
                    time.sleep(0.5)
                    continue

                captured = self._capture_burst(state)

                with self._stats_lock:
                    self._total_frames_captured += captured
                    self._total_visits += 1

                if captured > 0:
                    logger.debug(
                        f"Worker-{worker_id}: {state.ip} 抓取 {captured} 帧 "
                        f"(累计成功 {state.total_success})"
                    )

            except Exception as e:
                logger.error(f"Worker-{worker_id} 异常: {e}")
                time.sleep(1.0)

        logger.info(f"轮询 Worker-{worker_id} 退出")

    def _stats_reporter_thread(self):
        """周期性报告运行统计"""
        interval = 60.0
        while self.running:
            time.sleep(interval)
            if not self.running:
                break

            elapsed = time.time() - self._start_time if self._start_time else 0
            if elapsed <= 0:
                continue

            with self._stats_lock:
                total = self._total_frames_captured
                visits = self._total_visits

            fps = total / elapsed
            active = sum(1 for s in self.camera_states if s.total_success > 0)
            backed_off = sum(
                1 for s in self.camera_states
                if s.consecutive_failures > 0 and time.time() < s.next_available
            )

            with self._cycle_lock:
                cycle_dur = self._last_cycle_duration
                cycle_progress = len(self._cameras_visited_this_cycle)

            logger.info(
                f"[轮询统计] 运行 {elapsed:.0f}s | "
                f"总帧={total} 总访问={visits} | "
                f"FPS={fps:.1f} | "
                f"活跃={active}/{len(self.camera_states)} 退避={backed_off} | "
                f"上轮周期={cycle_dur:.1f}s 当前进度={cycle_progress}/{len(self.camera_states)} | "
                f"队列={self.frame_queue.qsize()}"
            )

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

    def start(self):
        if self.running:
            logger.warning("PluginCatchFrameByMPPPolling 已在运行中")
            return

        if not self.camera_states:
            raise RuntimeError("未加载到任何摄像头 URL")

        self.running = True
        self._start_time = time.time()

        for i in range(self.num_workers):
            t = threading.Thread(
                target=self._worker_thread, args=(i,),
                name=f"MPPPoll-{i}", daemon=True
            )
            t.start()
            self.threads.append(t)

        stats_t = threading.Thread(
            target=self._stats_reporter_thread,
            name="MPPPoll-Stats", daemon=True
        )
        stats_t.start()
        self.threads.append(stats_t)

        logger.info(
            f"已启动 {self.num_workers} 个轮询 Worker "
            f"(摄像头={len(self.camera_states)}, "
            f"帧/次={self.frames_per_visit})"
        )

    def stop(self):
        if not self.running:
            return

        logger.info("正在停止 PluginCatchFrameByMPPPolling...")
        self.running = False

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

        self.threads.clear()

        elapsed = time.time() - self._start_time if self._start_time else 0
        fps = self._total_frames_captured / elapsed if elapsed > 0 else 0
        logger.info(
            f"PluginCatchFrameByMPPPolling 已停止: "
            f"运行 {elapsed:.0f}s, 总帧={self._total_frames_captured}, FPS={fps:.1f}"
        )

    def get_frame(self, timeout=0.5):
        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):
        elapsed = time.time() - self._start_time if self._start_time else 0
        fps = self._total_frames_captured / elapsed if elapsed > 0 else 0

        camera_stats = {}
        for s in self.camera_states:
            camera_stats[s.ip] = {
                'success': s.total_success,
                'fail': s.total_fail,
                'consecutive_failures': s.consecutive_failures,
                'codec': self._codec_cache.get(s.ip, 'unknown'),
                'last_capture': s.last_capture_time,
            }

        total_success = sum(s.total_success for s in self.camera_states)
        total_fail = sum(s.total_fail for s in self.camera_states)
        total_attempts = total_success + total_fail
        success_rate = (total_success / total_attempts * 100) if total_attempts > 0 else 0

        with self._cycle_lock:
            cycle_dur = self._last_cycle_duration
            cycle_progress = len(self._cameras_visited_this_cycle)

        return {
            "total_cameras": len(self.camera_states),
            "total_connections": self.num_workers,
            "connection_rebuilds": self._total_visits,
            "queue_size": self.frame_queue.qsize(),
            "pool_distribution": {f"worker_{i}": 1 for i in range(self.num_workers)},
            "total_success": total_success,
            "total_fail": total_fail,
            "success_rate": f"{success_rate:.2f}%",
            "aggregate_fps": round(fps, 2),
            "elapsed_seconds": round(elapsed, 1),
            "last_cycle_seconds": round(cycle_dur, 1),
            "current_cycle_progress": f"{cycle_progress}/{len(self.camera_states)}",
            "camera_stats": camera_stats,
        }

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

总结

本方案基于RK3588 RKMPP硬件解码,打造了非长连接轮询抓帧框架 ,彻底解决了大规模摄像头场景下的资源瓶颈问题。核心优势是资源占用恒定、支持300+路设备、快速全量覆盖、硬件解码加速,通过「按需连接→抓帧→断开」的极简流程,完美适配安防巡检、工业视觉等海量设备场景。框架模块化、配置化、高容错,是RK3588边缘平台大规模视频采集的最优解决方案。

相关推荐
紫金修道1 天前
【编解码】RK3588 平台基于 FFmpeg RKMPP 硬解的多路 RTSP 抓帧插件实战
ffmpeg·rkmpp
想进部的张同学3 个月前
RK3588开发板安装GStreamer硬件加速插件完整指南 成功版本(docker)
运维·docker·容器·rkmpp
plmm烟酒僧1 年前
在 RK3588 多线程推理 YOLO 时,同时开启硬件解码和 RGA 加速的性能分析
yolo·rkmpp·瑞芯微·硬件加速·rga·色彩空间转换
plmm烟酒僧1 年前
基于 RK3588 的 YOLO 多线程推理多级硬件加速引擎框架设计(代码框架和实现细节)
yolo·rk3588·多线程·rkmpp·硬件加速·视频解码·librga
plmm烟酒僧1 年前
编译支持 RKmpp 和 RGA 的 ffmpeg 源码
ffmpeg·视频编解码·rkmpp·硬件加速·vpu·rga
沐风_ZTL1 年前
RK3568上使用C++结合V4L2拉流,并RKMPP硬件编解码,并保存为MP4文件
开发语言·c++·ffmpeg·rkmpp·v4l2
沐风_ZTL1 年前
RKMPP依赖硬件单元
视频编解码·rkmpp·vpu·rga
dutong03213 年前
02.FFMPEG的安装和添加硬件加速自编译
linux·ffmpeg·rk3568·yy3568·rkmpp