一、前言
接续上一篇文章,这个部分主要分析代码框架的实现细节和设计理念。
二、框架分析
在原作者的基础上,我增加了命令行的参数解析、多态视频读取引擎、硬件视频解码、RGA 硬件图像缩放,色彩空间转换,以及部分代码优化和内存管理调整。
1、命令行参数解析
使用 ConfigParser 类封装,便于移植:

头文件 parse_config.hpp:
cpp
#ifndef _PARSE_CONFIG_HPP_
#define _PARSE_CONFIG_HPP_
#include <iostream>
#include <string>
#include <SharedTypes.hpp>
/* 定义配置解析类 */
class ConfigParser {
public:
// 输入格式
int input_format;
// 显示帮助信息
void print_help(const std::string &program_name) const;
// 打印配置信息
void printConfig(const AppConfig &config) const;
// 解析命令行参数
AppConfig parse_arguments(int argc, char *argv[]) const;
private:
// 私有成员(如果有需要可以添加)
};
#endif
这里的 AppConfig 是参数列表结构体,定义在全项目的共享头文件 Shared_Types.hpp 中:
cpp
/* 定义命令行参数结构体 */
struct AppConfig {
// 在屏幕显示 FPS
bool screen_fps = false;
// 在终端打印 FPS
bool print_fps = false;
// 是否使用opencl
bool opencl = true;
// 是否打印命令行参数
bool verbose = false;
// 视频加载引擎,默认为 ffmpeg
int read_engine = READ_ENGINE::EN_FFMPEG;
// 输入格式,默认为视频
int input_format = INPUT_FORMAT::IN_VIDEO;
// 硬件加速,默认为 RGA
int accels_2d = ACCELS_2D::ACC_RGA;
// 线程数,默认为1
int threads = 1;
// rknn 模型路径
string model_path = "";
// 输入源
string input = "";
// 解码器,默认为 h264_rkmpp
string decodec = "h264_rkmpp";
};
源文件较大,这里仅放一个长短命令解析的部分截图:

各位可根据自己喜好,修改参数列表,我比较喜欢设置默认值,直接执行可执行文件时,只需要传递必要的参数。
2、多态视频读取引擎
原作者使用 OpenCV 进行视频读取和取帧操作,为了保留 OpenCV 的读取,我使用多态的方式可以灵活选择 OpenCV 和 FFmpeg 两种方式进行读取。本节均只介绍头文件中的接口,具体实现较长,还请读者移步 Github 。整体框架为:
(1)Reader(基类)
定义了视频读取操作的通用接口(如 open、close、readFrame 等)。
作为所有具体读取器(如 FFmpegReader、OpencvReader 等)的基类,利用多态性实现运行时动态选择具体的实现类。
cpp
#ifndef READER_H
#define READER_H
#include <string>
#include "opencv2/core.hpp"
/**
* @Description: 基类引擎
* @return {*}
*/
class Reader {
public:
// 析构虚函数
virtual ~Reader() = default;
/* 纯虚函数接口 */
virtual void openVideo(const std::string& filePath) = 0;
virtual bool readFrame(cv::Mat& frame) = 0;
virtual void closeVideo() = 0;
};
#endif // READER_H
(2)FFmpegReader 或 OpencvReader(Reader 的子类)
继承自 Reader 基类。
实现了基类中定义的虚函数,具体使用 FFmpeg 或 OpenCV 库提供的函数来处理视频操作。
在初始化时,可能配置和加载与读取器相关的资源或参数。
cpp
#ifndef FFMPEGREADER_H
#define FFMPEGREADER_H
#include <iostream>
#include "Reader.hpp"
#include "preprocess.h"
#include "SharedTypes.hpp"
#include <opencv2/opencv.hpp>
extern "C" {
#include <libavutil/frame.h>
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/avutil.h>
#include <libswscale/swscale.h>
#include <libavutil/imgutils.h>
}
/**
* @Description: FFmpeg 引擎
* @return {*}
*/
class FFmpegReader : public Reader {
public:
FFmpegReader(const string& decodec, const int& accels_2d);
~FFmpegReader() override;
void openVideo(const std::string& filePath) override;
bool readFrame(cv::Mat& frame) override;
void closeVideo() override;
// 获取视频信息
void print_video_info(const string& filePath);
int getWidth() const;
int getHeight() const;
AVRational getTimeBase() const;
double getFrameRate() const;
private:
string decodec; // 解码器
int accels_2d; // 2D 硬件加速类型
AVFormatContext *formatContext = nullptr; // 输入文件的上下文
AVCodecContext *codecContext = nullptr; // 解码器上下文
const AVCodec* codec = nullptr; // 解码器
int videoStreamIndex = -1; // 视频流的索引
AVStream *video_stream; // 视频流
AVFrame *tempFrame = nullptr; // 临时帧(用于解码)
AVPacket *packet = nullptr; // 数据包
int NV12_to_BGR(cv::Mat& bgr_frame);
int FFmpeg_yuv420sp_to_bgr(cv::Mat& bgr_frame);
void AV_Frame_To_CVMat(cv::Mat& nv12_mat);
};
#endif // FFMPEGREADER_H
cpp
#ifndef OPENCVREADER_H
#define OPENCVREADER_H
#include "Reader.hpp"
#include <iostream>
#include <opencv2/opencv.hpp>
/**
* @Description: Opencv 引擎
* @return {*}
*/
class OpencvReader : public Reader {
public:
OpencvReader();
~OpencvReader() override;
void openVideo(const std::string& filePath) override;
bool readFrame(cv::Mat& frame) override;
void closeVideo() override;
private:
cv::VideoCapture videoCapture; // OpenCV 视频捕获对象
};
#endif // OPENCVREADER_H
(3)VideoReader(中间件)
提供给 main 函数或其他上层模块使用的接口。
负责根据配置或输入动态选择并实例化合适的 Reader 子类(如 FFmpegReader 或 OpencvReader)。
封装了对具体 Reader 实例的管理,简化了上层模块对视频读取操作的调用。
cpp
#ifndef VIDEOREADER_H
#define VIDEOREADER_H
#include <memory>
#include <string>
#include "SharedTypes.hpp"
#include "Reader.hpp"
/**
* @Description: 视频读取器
* @return {*}
*/
class VideoReader {
public:
VideoReader(const AppConfig& config);
~VideoReader();
/* 以下禁止拷贝和允许移动两部分实现:
1、提高性能;
2、管理独占资源;
3、现代C++鼓励使用移动语义和智能指针等工具来管理资源。
*/
// 禁止拷贝构造和拷贝赋值
VideoReader(const VideoReader&) = delete;
VideoReader& operator=(const VideoReader&) = delete;
// 允许移动构造和移动赋值
VideoReader(VideoReader&&) = default;
VideoReader& operator=(VideoReader&&) = default;
/* 函数接口 */
bool readFrame(cv::Mat &frame); // 读取一帧
void Close_Video(); // 关闭视频
private:
// 使用智能指针管理资源,这里只是声明, 没有申请内存
std::unique_ptr<Reader> reader_ptr;
// 加载引擎
void Init_Load_Engine(const int& engine, const string& decodec, const int& accels_2d);
};
#endif // VIDEOREADER_H
(4)main 函数
使用 VideoReader 提供的统一接口来操作视频,无需关心底层使用了哪种具体的读取器实现。
创建 VideoReader:

读取帧:

3、硬件视频解码
这部分主要由 FFmpeg 实现,通过 FFmpeg 来调用 Rkmpp 解码器。这里需要注意,FFmpeg 不是官方源码,而是 rockchip 版本的 ffmpeg-rockchip,来自 nyanmisaka 大佬的项目:
nyanmisaka/ffmpeg-rockchip: FFmpeg with async and zero-copy Rockchip MPP & RGA supporthttps://github.com/nyanmisaka/ffmpeg-rockchip 专门针对瑞芯微的 Rockchip MPP & RGA 进行适配和优化,可以在编译时开启 rkmpp 解码支持和 RGA 过滤器支持。编译方法移步:
编译支持 RKmpp 和 RGA 的 ffmpeg 源码_ffmpeg支持mpp-CSDN博客https://blog.csdn.net/plmm__/article/details/146188927?spm=1001.2014.3001.5501 代码部分就是常规的 FFmpeg 进行视频解码,我这里分为了两部分:打开视频文件和读取视频帧。
打开视频文件
cpp
/**
* @Description: 打开视频文件
* @param {string} &filePath:
* @return {*}
*/
void FFmpegReader::openVideo(const std::string& filePath) {
/* 分配一个 AVFormatContext */
formatContext = avformat_alloc_context();
if (!formatContext)
throw std::runtime_error("Couldn't allocate format context");
/* 打开视频文件 */
// 并读取头部信息,此时编解码器尚未开启
if (avformat_open_input(&formatContext, filePath.c_str(), nullptr, nullptr) != 0)
throw std::runtime_error("Couldn't open video file");
/* 读取媒体文件的数据包以获取流信息 */
if (avformat_find_stream_info(formatContext, nullptr) < 0)
throw std::runtime_error("Couldn't find stream information");
/* 查找视频流 AVMEDIA_TYPE_VIDEO */
// -1, -1,意味着没有额外的选择条件,返回值是流索引
videoStreamIndex = av_find_best_stream(formatContext, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
if (videoStreamIndex < 0)
throw std::runtime_error("Couldn't find a video stream");
/* 查找解码器 */
codec = avcodec_find_decoder_by_name(this->decodec.c_str());
if (!codec)
throw std::runtime_error("Decoder not found");
/* 初始化编解码器上下文 */
codecContext = avcodec_alloc_context3(codec);
if (!codecContext)
throw std::runtime_error("Couldn't allocate decoder context");
/* 获取视频流,它包含了视频流的元数据和参数 */
video_stream = formatContext->streams[videoStreamIndex];
/* 复制视频参数到解码器上下文 */
if (avcodec_parameters_to_context(codecContext, video_stream->codecpar) < 0)
throw std::runtime_error("Couldn't copy decoder context");
/* 自动选择线程数 */
codecContext->thread_count = 0;
/* 打开编解码器 */
if (avcodec_open2(codecContext, codec, nullptr) < 0)
throw std::runtime_error("Couldn't open decoder");
/* 分配 AVPacket 和 AVFrame */
tempFrame = av_frame_alloc();
packet = av_packet_alloc();
if (!tempFrame || !packet)
throw std::runtime_error("Couldn't allocate frame or packet");
}
其中下面的代码需要注意:
cpp
/* 自动选择线程数 */
codecContext->thread_count = 0;
这个变量主要用于设置 FFmpeg 工作线程数量,0 代表自动选择,具体的实验可以看这篇文章:
读取视频帧
cpp
/**
* @Description: 读取一帧
* @param {Mat&} frame: 取出的帧
* @return {*}
*/
bool FFmpegReader::readFrame(cv::Mat& frame) {
// 读取帧
/*if (av_read_frame(formatContext, packet) < 0) {
return false; // 没有更多帧
}*/
while (av_read_frame(formatContext, packet) >= 0) {
if (packet->stream_index != videoStreamIndex) {
av_packet_unref(packet);
continue;
}
break;
}
// 如果是视频流
if (packet->stream_index != videoStreamIndex) {
cerr << "Not a video stream: " << packet->stream_index << " != " << videoStreamIndex << endl;
av_packet_unref(packet);
return false; // 不是视频流
}
// 发送数据包到解码器
if (avcodec_send_packet(codecContext, packet) < 0) {
std::cerr << "Failed to send packet to decoder" << std::endl;
av_packet_unref(packet);
return false; // 发送数据包失败
}
// 接收解码后的帧
if (avcodec_receive_frame(codecContext, tempFrame) < 0) {
std::cerr << "Failed to receive frame from decoder" << std::endl;
av_packet_unref(packet);
return false;
}
// 成功读取一帧,保存在 tempFrame 中
// 将帧数据转换为 cv::Mat BGR 格式
if (this->NV12_to_BGR(frame) != 0) {
std::cerr << "Failed to convert YUV420SP to BGR" << std::endl;
av_packet_unref(packet);
return false;
}
// 释放数据包
av_packet_unref(packet);
return true; // 处理完成
}
av_read_frame 函数在实测过程中发现开头几帧取出后不是视频流,因此直接使用 while 跳过。在成功取出帧后,会保存在 tempFrame 中,为 AVFrame 格式,色彩空间为 NV12,由解码器决定,我使用 h264_rkmpp 解码器,默认输出是 NV12。
4、RGA 硬件加速
目前主要有三个地方使用到了图像的缩放和格式转换的操作,并且三个操作是前后关系,分别是上一节取出视频帧后要将 NV12 转为 BGR888,转为 YOLO 输入的 RGB888,以及输入尺寸的修改。
NV12 转为 BGR888
由于需要保持接口的通用性,与 OpenCV 取帧保持一致(OpenCV 解码后为 BGR888 格式), 并且数据传输使用 OpenCV 的 cv::Mat 对象进行图像传输,所以在取出帧后进行了颜色空间的转换,并改用 cv::Mat 进行保存:
cpp
/**
* @Description: 转换格式,NV12 转 BGR
* 该函数内有三种转换方式:
* 1. FFmpeg SwsContext 软件转换
* 2. OpenCV 软件转换,可启用 opencl(目前区别不大)
* 3. RGA 硬件加速转换
* @param {Mat&} frame:
* @return {*}
*/
int FFmpegReader::NV12_to_BGR(cv::Mat& bgr_frame) {
if (tempFrame->format != AV_PIX_FMT_NV12) {
return -EXIT_FAILURE; // 格式错误
}
// 设置输出帧的尺寸和格式,防止地址无法访问
bgr_frame.create(tempFrame->height, tempFrame->width, CV_8UC3);
#if 0
// 方式1:使用 FFmpeg SwsContext 软件转换
return this->FFmpeg_yuv420sp_to_bgr(bgr_frame);
#endif
// 创建一个完整的 NV12 数据块(Y + UV 交错)
cv::Mat nv12_mat(tempFrame->height + tempFrame->height / 2, tempFrame->width, CV_8UC1);
// 将 AVFrame 内的数据,转换为 OpenCV Mat 格式保存
this->AV_Frame_To_CVMat(nv12_mat);
// 硬件加速
if (this->accels_2d == ACCELS_2D::ACC_OPENCV) {
// 方式2:使用 OpenCV 软件转换
cv::cvtColor(nv12_mat, bgr_frame, cv::COLOR_YUV2BGR_NV12);
return EXIT_SUCCESS;
} else if (this->accels_2d == ACCELS_2D::ACC_RGA) {
// 方式3:使用 RGA 硬件加速转换
return RGA_yuv420sp_to_bgr((uint8_t *)nv12_mat.data, tempFrame->width, tempFrame->height, bgr_frame);
}
else
return -EXIT_FAILURE;
}
这个函数可以使用三种方式进行转换,分别是:
-
FFmpeg SwsContext 软件转换
-
OpenCV 软件转换
-
RGA 硬件转换
三种转换方式的源码较多,可在项目源码中查看。根据目前实测的结果(只针对当前转换函数),SwsContext 转换一次耗时约 20ms,RGA 约 2-5ms,OpenCV 约 2-4ms。RGA 转换接口可能和我的接口调用方式有关,还有优化的空间,平均值甚至不如 OpenCV。
转为 YOLO 输入的 RGB888
这里的转换操作是放在了推理线程中,理论上是在多线程进行:
cpp
// YOLO 推理需要 RGB 格式,后处理需要 BGR 格式
// 即使前处理时提前转换为 RGB,后处理部分任然需要转换为 BGR,需要在本函数中保留两种格式
if (this->config.accels_2d == ACCELS_2D::ACC_OPENCV) {
cv::cvtColor(orig_img, rgb_img, cv::COLOR_BGR2RGB);
}
else if (this->config.accels_2d == ACCELS_2D::ACC_RGA) {
if (RGA_bgr_to_rgb(orig_img, rgb_img) != 0) {
cout << "RGA_bgr_to_rgb error" << endl;
return cv::Mat();
}
}
else {
cout << "Unsupported 2D acceleration" << endl;
return cv::Mat();
}
在原作者转换逻辑的基础上,我增加了 OpenCV 和 RGA 的选择。注释中也说明了为什么需要 BGR 转 RGB 这一步,这也和 cv::Mat 对象的默认格式有关,cv::imshow 显示时也是需要数据为 BGR,与 YOLO 的输入格式相反。
输入尺寸的修改
即输入图像的 resize:
cpp
// 图像缩放
if (orig_img.cols != width || orig_img.rows != height)
{
// 如果需要缩放,再对 resized_img 申请大小,节约内存开销
resized_img.create(height, width, CV_8UC3);
if (this->config.accels_2d == ACCELS_2D::ACC_OPENCV)
{
// 打包模型输入尺寸
cv::Size target_size(width, height);
float min_scale = std::min(scale_w, scale_h);
scale_w = min_scale;
scale_h = min_scale;
letterbox(rgb_img, resized_img, pads, min_scale, target_size, this->config.opencl);
}
else if (this->config.accels_2d == ACCELS_2D::ACC_RGA)
{
ret = RGA_resize(rgb_img, resized_img);
if (ret != 0) {
cout << "resize_rga error" << endl;
}
}
else {
cout << "Unsupported 2D acceleration" << endl;
return cv::Mat();
}
inputs[0].buf = resized_img.data;
}
else
{
inputs[0].buf = rgb_img.data;
}
上面与瑞芯微官方的 YOLO demo 是一样的,我对 letterbox 函数内部做了 OpenCL 的一个修改:
cpp
void letterbox_with_opencl(const cv::Mat &image, cv::UMat &padded_image, BOX_RECT &pads, const float scale, const cv::Size &target_size, const cv::Scalar &pad_color) {
// 将输入图像转换为 UMat
cv::UMat uImage = image.getUMat(cv::ACCESS_READ);
// 调整图像大小
cv::UMat resized_image;
cv::resize(uImage, resized_image, cv::Size(), scale, scale);
if (uImage.empty()) {
std::cerr << "Error: uImage is empty." << std::endl;
return;
}
if (resized_image.empty()) {
std::cerr << "Error: resized_image is empty." << std::endl;
return;
}
// 计算填充大小
int pad_width = target_size.width - resized_image.cols;
int pad_height = target_size.height - resized_image.rows;
pads.left = pad_width / 2;
pads.right = pad_width - pads.left;
pads.top = pad_height / 2;
pads.bottom = pad_height - pads.top;
// 在图像周围添加填充
cv::copyMakeBorder(resized_image, padded_image, pads.top, pads.bottom, pads.left, pads.right, cv::BORDER_CONSTANT, pad_color);
}
/**
* @Description: OpenCV 图像预处理
* @return {*}
*/
void letterbox(const cv::Mat &image, cv::Mat &padded_image, BOX_RECT &pads, const float scale, const cv::Size &target_size, bool Use_opencl, const cv::Scalar &pad_color)
{
// 图像数据检查
if (image.empty()) {
std::cerr << "Error: Input image is empty." << std::endl;
return;
}
// 调整图像大小
cv::Mat resized_image;
if (Use_opencl)
{
// 预处理图像
cv::UMat U_padded_image;
letterbox_with_opencl(image, U_padded_image, pads, scale, target_size, pad_color);
// 将处理后的图像从 GPU 内存复制回 CPU 内存(如果需要显示)
// padded_image = U_padded_image.getMat(cv::ACCESS_READ);
// padded_image = std::move(U_padded_image.getMat(cv::ACCESS_READ));
padded_image = U_padded_image.getMat(cv::ACCESS_READ).clone(); // 深拷贝
return ;
}
cv::resize(image, resized_image, cv::Size(), scale, scale);
// 计算填充大小
int pad_width = target_size.width - resized_image.cols;
int pad_height = target_size.height - resized_image.rows;
pads.left = pad_width / 2;
pads.right = pad_width - pads.left;
pads.top = pad_height / 2;
pads.bottom = pad_height - pads.top;
// 在图像周围添加填充
cv::copyMakeBorder(resized_image, padded_image, pads.top, pads.bottom, pads.left, pads.right, cv::BORDER_CONSTANT, pad_color);
}
使用 cv::UMat 对象来调用 OpenCL 进行 resize 的并行计算。
5、其他
还有一些 C 语言的接口,我封装为了类的形式,虽然牺牲了一些性能,不过为了项目的通用性和可维护性,很多都使用 C++ 的语法替换掉了,比如加载模型的函数:
原始的 C 函数:
cpp
static unsigned char *load_data(FILE *fp, size_t ofst, size_t sz)
{
unsigned char *data;
int ret;
data = NULL;
if (NULL == fp)
{
return NULL;
}
ret = fseek(fp, ofst, SEEK_SET);
if (ret != 0)
{
printf("blob seek failure.\n");
return NULL;
}
data = (unsigned char *)malloc(sz);
if (data == NULL)
{
printf("buffer malloc failure.\n");
return NULL;
}
ret = fread(data, 1, sz, fp);
return data;
}
static unsigned char *load_model(const char *filename, int *model_size)
{
FILE *fp;
unsigned char *data;
fp = fopen(filename, "rb");
if (NULL == fp)
{
printf("Open file %s failed.\n", filename);
return NULL;
}
fseek(fp, 0, SEEK_END);
int size = ftell(fp);
data = load_data(fp, 0, size);
fclose(fp);
*model_size = size;
return data;
}
改用更便捷的方式,并且内存的申请放到了函数外,由调用者进行管理,提高内存维护的便捷性:
cpp
/**
* @Description: 获取文件大小
* @param {string&} filename:
* @return {size_t}: 返回字节数,失败返回0
*/
static size_t get_file_size(const std::string& filename) {
// std::ios::ate:打开文件后立即将文件指针移动到文件末尾(at end)
std::ifstream ifs(filename, std::ios::binary | std::ios::ate);
if (!ifs.is_open())
return 0;
// 通过文件尾定位获取大小
size_t size = ifs.tellg();
ifs.close();
return size;
}
/**
* @Description: 加载文件数据
* @param {ifstream&} ifs:
* @param {size_t} offset:
* @param {unsigned char*} buffer:
* @param {size_t} size:
* @return {*}
*/
static bool load_data(std::ifstream& ifs, size_t offset, unsigned char* buffer, size_t size) {
if (!ifs.is_open()) {
std::cerr << "File stream not open" << std::endl;
return false;
}
// 定位到指定位置
ifs.seekg(offset, std::ios::beg);
if (ifs.fail()) {
std::cerr << "Seek failed at offset " << offset << std::endl;
return false;
}
ifs.read(reinterpret_cast<char*>(buffer), size);
// ifs.gcount():返回实际读取的字节数
if (ifs.gcount() != static_cast<std::streamsize>(size)) {
std::cerr << "Read failed, expected " << size << " bytes, got " << ifs.gcount() << std::endl;
return false;
}
return true;
}
/**
* @Description: 加载模型
* @param {string} &filename:
* @param {unsigned char} *buffer:
* @param {size_t&} buffer_size:
* @return {*}
*/
static bool load_model(const std::string &filename, unsigned char *buffer, const size_t& buffer_size) {
// std::ios::binary:以二进制模式打开文件
std::ifstream ifs(filename, std::ios::binary);
if (!ifs)
{
std::cerr << "Failed to open: " << filename << std::endl;
return false;
}
if (buffer_size == 0)
{
std::cerr << "Failed to open: " << filename << std::endl;
return false;
}
return load_data(ifs, 0, buffer, buffer_size);
}
三、总结
以上就是我做的一些修改的粗略描述,具体细节我也都在代码中做了注释。希望这个项目可以帮到有需要硬件解码,以及正在学习 RGA 接口的小伙伴。各位读者有任何修改意见,欢迎与我联系,代码会放至Gitee 和 Github,我有空也会持续完善优化:
Gitee: