-
- 前言
- [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 动态控制与容错机制
- 配置化全局控制:从YAML配置文件加载线程数、全局FPS上限,无需修改代码;
- 指数退避访问:摄像头抓帧失败时,动态增加访问间隔,保护设备不被压垮;
- 超时安全读帧 :基于
select+os.read实现精确超时读,避免进程永久阻塞; - 编码自动探测:预探测摄像头编码格式,自动匹配硬解解码器。
2. 关键步骤介绍
我们按照插件生命周期拆解代码,逐模块讲解核心逻辑,快速掌握代码精髓。
2.1 配置文件设计
插件采用配置与代码解耦设计,支持两个核心配置文件:
cameraUrl.txt:存储RTSP摄像头地址,支持注释、空行过滤;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()
- 线程安全 :通过
threading.Lock保证连接池操作原子性; - 连接校验:检查FFmpeg子进程存活状态,失效则释放重建;
- LRU淘汰:连接数达上限时,淘汰最久未用的连接;
- 无锁创建: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 核心环境依赖
- 硬件平台:瑞芯微RK3588(必须支持RKMPP硬件解码);
- 软件依赖:RK3588定制版FFmpeg、ffprobe、Python3、pyyaml、numpy;
- 权限要求:具备硬件解码设备、进程创建的权限。
3.2 参数调优指南
capture_threads:建议设置为CPU核心数的1~2倍,匹配芯片算力;max_connections_per_thread:根据VPU解码能力调整,建议≤50;fps_upperlimit:嵌入式平台建议≤30,平衡性能与功耗;min_access_interval:最小访问间隔,默认0.04s,兼顾帧率与设备压力。
3.3 核心优势总结
- 极致低CPU占用:硬解替代软解,CPU占用降低80%以上;
- 配置化灵活控制:线程数、FPS全配置化,无需修改代码;
- 高并发稳定性:单设备支持32+路摄像头并发抓帧,7×24小时稳定运行;
- 全链路容错:自动重连、超时控制、异常回退,适配复杂网络环境。
3.4 常见问题排查
- 硬解失败:检查RKMPP环境脚本、FFmpeg是否为RK3588定制版;
- 抓帧超时:检查RTSP地址、网络连通性,开启TCP传输;
- 配置不生效:检查YAML配置格式、参数类型是否符合要求;
- 进程泄漏 :
stop()方法会自动销毁所有子进程,无需手动处理。
3.5 适用场景
- RK3588边缘计算平台多路安防监控;
- 机器人视觉多路摄像头实时采集;
- 工业视觉低功耗视频流硬解预处理;
- AI推理前的多路视频流标准化处理。
3.6 扩展方向
- 增加VPU硬件占用监控,实时感知解码资源负载;
- 支持动态增删摄像头,无需重启服务;
- 集成离线告警,摄像头离线时实时推送通知;
- 对接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平台视频流处理的工业级最佳实践。