前言

最近在看 Rockchip rknpu2 里面的 YOLO 检测 demo,刚开始有一个问题一直没想明白:

图片检测 demo 和视频检测 demo 本质上是不是一回事?
视频不就是一帧一帧的图片吗?为什么官方还要把图片 demo 和视频 demo 分成两个程序?
视频 demo 里面的 MPP、RGA、RKNN、utils 又分别是什么关系?

刚开始看的时候,很容易把这些东西混在一起。尤其是看到图片 demo 里面用了 OpenCV,而视频 demo 里面又是 MPP 解码、RGA 转换、MPP 编码,看起来像是两套完全不同的逻辑。

实际分析下来以后,我的理解是:

图片检测和视频检测的模型推理核心基本一样,区别主要在输入来源、图像格式转换、画框方式和输出方式。

换句话说,视频检测确实可以理解成“对每一帧图片做检测”,但是在 RK 板端工程里,视频的输入输出链路比图片复杂很多,所以官方把它们拆成了两个 demo。

本文主要记录一下我对这两个 demo 的理解。


一、先说结论

图片 demo 和视频 demo 的核心检测流程是一样的,都可以抽象成:

原始图像
→ 转成模型需要的 RGB888
→ resize / letterbox 到模型输入尺寸
→ rknn_inputs_set
→ rknn_run
→ rknn_outputs_get
→ post_process
→ 得到检测结果
→ 画框输出

但是二者的外围流程不一样。

图片 demo 是:

jpg/png 图片
→ OpenCV 读取并解码成 BGR
→ BGR 转 RGB
→ resize / letterbox
→ RKNN 推理
→ 后处理
→ OpenCV 画框和文字
→ 保存 out.jpg

视频 demo 是:

h264/h265 码流
→ MPP 解码成 YUV420SP 视频帧
→ RGA 把 YUV420SP 转成 RGB888,并 resize
→ RKNN 推理
→ 后处理
→ 在 YUV420SP 帧上画框
→ MPP 编码成 out.h264

所以不能简单说它们完全一样,但也不能说它们是两套算法。
更准确地说:

模型推理和后处理基本一样,输入输出工程链路不同。


二、图片 demo 的处理流程

rknpu2/examples/rknn_yolov5_demo/src/main.cc 这种图片检测 demo 为例,它的运行方式一般是:

./rknn_yolov5_demo model.rknn input.jpg letterbox output.jpg

主流程可以理解成下面几步。


1. 加载 RKNN 模型

程序首先会把 .rknn 模型文件读进内存,然后调用:

rknn_init(&ctx, model_data, model_data_size, 0, NULL);

这里的 ctx 就是 RKNN 的推理上下文。
后面设置输入、执行推理、获取输出,都要靠这个 ctx

可以简单理解成:

.rknn 模型文件
→ rknn_init
→ 得到 RKNN 推理上下文 ctx

2. 查询模型输入输出信息

接着程序会查询模型的输入输出数量:

rknn_query(ctx, RKNN_QUERY_IN_OUT_NUM, &io_num, sizeof(io_num));

然后分别查询输入 tensor 和输出 tensor 的属性:

rknn_query(ctx, RKNN_QUERY_INPUT_ATTR, ...);
rknn_query(ctx, RKNN_QUERY_OUTPUT_ATTR, ...);

这里能得到模型输入的尺寸、格式、量化参数等信息。比如常见的输出是:

model input num: 1, output num: 3
model is NHWC input fmt
model input height=640, width=640, channel=3

这一步很重要,因为程序要知道模型到底需要什么样的输入。

比如模型要求:

640 × 640 × 3
NHWC
UINT8
RGB888

那么后面的图片预处理就必须把原始图片处理成这个样子。


3. OpenCV 读取图片

图片 demo 中通常会用 OpenCV 读取图片:

cv::Mat orig_img = cv::imread(input_path, 1);

这里要注意一个容易混淆的点:

jpg/png 是图片文件格式,BGR/RGB 是图片解码后的像素排列格式。

也就是说,test.jpg 不能直接丢给模型。
jpg/png 本质上是压缩后的图片文件,模型需要的是解码后的原始像素数据。

OpenCV 的 imread() 会帮我们做两件事:

读取 jpg/png 文件
→ 解码成一张像素图

但是 OpenCV 默认解码出来的格式是 BGR,不是 RGB。


4. 什么是 BGR?为什么要转 RGB?

RGB 的通道顺序是:

R G B

BGR 的通道顺序是:

B G R

比如一个红色像素,在 RGB 里是:

255, 0, 0

但是在 BGR 里就是:

0, 0, 255

如果模型训练和导出时使用的是 RGB 输入,而我们把 OpenCV 读出来的 BGR 直接送进去,模型看到的颜色通道就是反的,检测效果可能会变差。

所以图片 demo 中会有:

cv::cvtColor(orig_img, img, cv::COLOR_BGR2RGB);

这一步就是:

OpenCV BGR
→ RGB888

这里的 RGB888 可以理解为:

每个像素 3 个通道
R 占 8 bit
G 占 8 bit
B 占 8 bit

也就是一个像素 3 字节。


5. resize 或 letterbox

模型一般要求固定输入尺寸,比如 640×640。
但是原始图片可能是 1920×1080、1280×720 或者其他尺寸。

所以要做预处理。

图片 demo 中一般有两种方式:

方式一:resize
原图直接拉伸成 640×640

这种方式简单,但是可能改变原图比例。

比如:

1920×1080
→ 640×640

原来是宽屏图,直接拉成正方形,目标会有一定形变。

方式二:letterbox
保持原图比例缩放
不足的地方补边

比如:

1920×1080
→ 等比例缩放成 640×360
→ 上下补边到 640×640

YOLO 系列模型里,letterbox 很常见。
它的好处是尽量不破坏图像比例。

但是因为补边了,所以后处理时要根据 scalepads 把检测框坐标映射回原图。


6. 设置输入并执行 RKNN 推理

预处理完成后,把图像数据送给 RKNN:

rknn_inputs_set(ctx, io_num.n_input, inputs);
rknn_run(ctx, NULL);
rknn_outputs_get(ctx, io_num.n_output, outputs, NULL);

大致逻辑是:

inputs[0].buf = 预处理后的 RGB 图像
→ rknn_inputs_set
→ rknn_run
→ rknn_outputs_get
→ 得到模型输出

7. 后处理 post_process

模型的原始输出不能直接当最终检测框使用,还需要后处理:

post_process(...);

后处理一般包括:

反量化
解码框坐标
置信度过滤
NMS
类别名映射
坐标映射回原图

对于 YOLOv5 这类模型,通常有 3 个输出层:

outputs[0]
outputs[1]
outputs[2]

所以图片 demo 里经常能看到这种调用方式:

post_process(
    (int8_t *)outputs[0].buf,
    (int8_t *)outputs[1].buf,
    (int8_t *)outputs[2].buf,
    ...
);

后处理完成后,结果会放进类似这样的结构体:

detect_result_group_t detect_result_group;

里面包含检测框、类别名、置信度等信息。


8. OpenCV 画框和置信度

图片 demo 里面画框比较方便,因为它用的是 OpenCV:

rectangle(orig_img, ...);
putText(orig_img, text, ...);
imwrite(out_path, orig_img);

所以图片 demo 不仅能画框,还能显示类别名和置信度,比如:

face 88.5%

这也是为什么很多图片 demo 输出结果看起来比较完整。


三、视频 demo 的处理流程

视频 demo 通常不是用 OpenCV 的 VideoCapture,而是使用 Rockchip 板端原生链路:

MPP 解码
RGA 图像处理
RKNN 推理
MPP 编码

运行方式一般类似:

./rknn_yolov5_video_demo model.rknn input.h264 264

或者:

./rknn_yolov5_video_demo model.rknn input.h265 265

需要注意的是,这类 demo 通常处理的是裸 H264/H265 码流,而不是普通 mp4 封装文件。


1. 初始化模型

视频 demo 中也会调用类似:

init_model(model_name, &app_ctx);

这个函数内部其实和图片 demo 很像:

读取 rknn 模型
→ rknn_init
→ 查询输入输出数量
→ 查询 tensor 属性
→ 判断模型输入格式
→ 保存模型宽高通道

只不过视频 demo 会把这些信息保存到一个结构体里面:

typedef struct
{
  rknn_context rknn_ctx;
  rknn_input_output_num io_num;
  rknn_tensor_attr *input_attrs;
  rknn_tensor_attr *output_attrs;
  int model_channel;
  int model_width;
  int model_height;
  FILE *out_fp;
  MppDecoder *decoder;
  MppEncoder *encoder;
} rknn_app_context_t;

这个结构体可以理解成整个视频检测程序的上下文。
里面既保存 RKNN 模型信息,也保存解码器、编码器、输出文件等信息。


2. 初始化 MPP 解码器

视频 demo 里会创建 MPP 解码器:

MppDecoder *decoder = new MppDecoder();
decoder->Init(video_type, 30, &app_ctx);
decoder->SetCallback(mpp_decoder_frame_callback);
app_ctx.decoder = decoder;

这里最关键的是:

decoder->SetCallback(mpp_decoder_frame_callback);

它的意思是:

MPP 每解码出一帧图像,就自动调用一次 mpp_decoder_frame_callback()

所以视频 demo 不是在 main() 里面直接一帧一帧手动检测,而是通过回调机制处理每一帧。

整体逻辑是:

读取 H264/H265 压缩码流
→ 送给 MPP 解码器
→ MPP 解出一帧 YUV 图像
→ 自动进入回调函数
→ 在回调函数里做推理、画框、编码

3. process_video_file 负责喂码流

视频文件会先被读进内存,然后分块送给解码器:

ctx->decoder->Decode((uint8_t *)video_data_ptr, size, pkt_eos);

这里的 Decode() 不是 RKNN 的函数,而是 MppDecoder 这个类封装出来的函数。

它的作用是:

把一段 H264/H265 压缩数据送给 MPP 解码器

如果 MPP 解出了完整的一帧,就会触发前面设置的回调函数:

mpp_decoder_frame_callback(...)

4. 每解码出一帧,进入回调函数

回调函数大概长这样:

void mpp_decoder_frame_callback(
    void *userdata,
    int width_stride,
    int height_stride,
    int width,
    int height,
    int format,
    int fd,
    void *data)

这里传进来的 data 就是解码后的图像帧数据。

但是这帧图像不是 RGB,而是常见的视频格式:

YUV420SP / NV12

在代码里通常表现为:

img.format = RK_FORMAT_YCbCr_420_SP;

也就是说:

MPP 解码出来的是 YUV420SP
但 RKNN 模型一般需要 RGB888

所以中间必须有格式转换。


四、视频帧为什么要用 RGA 转 RGB?

在视频 demo 的 inference_model() 中,一般会看到类似代码:

src = wrapbuffer_virtualaddr(
    (void *)img->virt_addr,
    img->width,
    img->height,
    img->format,
    img->width_stride,
    img->height_stride
);

dst = wrapbuffer_virtualaddr(
    (void *)resize_buf,
    model_width,
    model_height,
    RK_FORMAT_RGB_888
);

imresize(src, dst);

这里虽然函数名叫 imresize(),但它实际做的不只是 resize。

因为源图像格式是:

RK_FORMAT_YCbCr_420_SP

目标图像格式是:

RK_FORMAT_RGB_888

所以 RGA 会同时完成:

YUV420SP → RGB888
原图尺寸 → 模型输入尺寸

也就是:

MPP 解码出来的 YUV 视频帧
→ RGA 转 RGB888 并 resize
→ 送入 RKNN 模型

这里要注意:

视频 demo 并不是直接把 YUV 送进 RKNN 模型。
RKNN 模型实际吃的仍然是 RGB888,只是 YUV 到 RGB 的转换被 RGA 封装在图像处理流程里了。

这点和图片 demo 很像:

图片 demo:
BGR → RGB888

视频 demo:
YUV420SP → RGB888

只是二者原始格式不同。


五、MPP、RGA、RKNN 分别负责什么?

这三个东西刚开始很容易混。

可以这样记:

MPP:负责视频解码和编码
RGA:负责图像缩放、格式转换、图像拷贝
RKNN:负责 NPU 模型推理

更具体一点:

模块 作用
MPP H264/H265 解码、编码
RGA resize、YUV转RGB、图像拷贝
RKNN 加载 .rknn 模型并调用 NPU 推理

所以完整视频链路是:

H264/H265 文件
→ MPP 解码
→ 得到 YUV420SP 帧
→ RGA 转 RGB888 + resize
→ RKNN 推理
→ post_process
→ 检测框
→ 在 YUV 帧上画框
→ MPP 编码
→ out.h264

六、utils 是什么?它是不是 RKNN 的一部分?

视频 demo 里面经常会看到:

#include "utils/mpp_decoder.h"
#include "utils/mpp_encoder.h"
#include "utils/drawing.h"

这时候容易误以为:

MPP 解码是不是封装在 RKNN 里面?
utils 是不是 RKNN 的一部分?

其实不是。

更准确地说:

utils 是 Rockchip 官方视频 demo 工程自己带的一层工具封装代码,不属于 rknn_api,也不属于 librknnrt.so。

一般目录结构类似:

rknn_yolov5_video_demo/
├── src/
│   └── main.cc
├── utils/
│   ├── mpp_decoder.h
│   ├── mpp_decoder.cpp
│   ├── mpp_encoder.h
│   ├── mpp_encoder.cpp
│   ├── drawing.h
│   └── drawing.cpp

其中:

mpp_decoder.cpp
→ 封装 MPP 解码流程

mpp_encoder.cpp
→ 封装 MPP 编码流程

drawing.cpp
→ 封装 YUV 图像画框

真正的底层硬件解码能力来自 Rockchip 的 MPP 库,而不是 RKNN。

可以理解成三层:

main.cc
→ 调用 utils 里的 MppDecoder / MppEncoder
→ utils 内部调用 Rockchip MPP 库
→ MPP 使用硬件解码/编码

RKNN 只负责:

rknn_init()
rknn_inputs_set()
rknn_run()
rknn_outputs_get()
rknn_destroy()

也就是模型推理相关的事情。

所以准确关系是:

视频 demo 工程
├── main.cc              负责整体调度
├── utils                demo 自带工具封装
│   ├── mpp_decoder      封装 MPP 解码
│   ├── mpp_encoder      封装 MPP 编码
│   └── drawing          封装 YUV 画框
├── RKNN Runtime         负责模型推理
├── MPP 库               负责视频编解码
└── RGA 库               负责图像处理

七、为什么图片 demo 用 OpenCV,视频 demo 用 MPP/RGA?

这个问题一开始我也很疑惑。
为什么图片 demo 不也像视频 demo 一样,全都走板端原生流程?

后面想明白了,主要是因为二者目标不同。


1. 图片 demo 主要是为了验证模型

图片 demo 只处理一张图。

它的目标是快速验证:

模型能不能加载?
输入输出对不对?
后处理有没有问题?
检测框能不能画出来?

所以用 OpenCV 很合适:

cv::imread()
cv::cvtColor()
cv::rectangle()
cv::putText()
cv::imwrite()

OpenCV 的优点是代码简单、好理解、跨平台。
对一张图片来说,即使用 CPU 处理也没有太大压力。


2. 视频 demo 主要是为了板端实时部署

视频就不一样了。

如果是 30 FPS 的视频:

1 秒 = 30 帧
10 秒 = 300 帧
1 分钟 = 1800 帧

如果每一帧都用 CPU 做视频解码、格式转换、resize、编码,板端压力会很大。

所以 Rockchip 视频 demo 更偏向真实部署:

MPP 硬件解码
RGA 硬件图像处理
RKNN NPU 推理
MPP 硬件编码

这样才能更接近板端实时检测的要求。

所以官方把图片和视频分开,并不是因为检测算法不同,而是因为工程目标不同:

demo 目标
图片 demo 简单验证模型
视频 demo 板端视频流实时处理

八、视频 demo 为什么默认只有框,没有置信度?

图片 demo 中画框和文字一般用 OpenCV:

rectangle(orig_img, ...);
putText(orig_img, text, ...);

所以它可以很方便地显示:

类别名 + 置信度

但是视频 demo 通常是在 YUV420SP 图像上画框:

draw_rectangle_yuv420sp(...);

这个函数一般只负责画矩形框。
如果没有额外实现 YUV 上画文字的函数,就不会显示类别名和置信度。

所以有些视频 demo 输出结果看起来只有检测框,没有文字,并不是后处理没有置信度,而是画图阶段没有把文字画上去。

也就是说:

后处理结果里有置信度
但视频画图函数只画了框
没有画文字

如果想显示置信度,需要额外实现文字绘制,或者把 YUV 转成 BGR/RGB 后用 OpenCV 画文字,再转回去。不过这样会增加处理开销。


九、图片和视频能不能合并成一个程序?

可以。

但是不建议把两个 main.cc 简单硬拼在一起。
更合理的方式是把公共逻辑抽出来。

我理解比较清楚的一种结构是:

main()
├── init_model()
│
├── 判断输入类型
│   ├── 如果是 jpg/png
│   │   └── process_image()
│   │       ├── OpenCV 读取图片
│   │       ├── BGR → RGB888
│   │       ├── inference_one_frame()
│   │       ├── OpenCV 画框和置信度
│   │       └── 保存图片
│   │
│   └── 如果是 h264/h265/rtsp
│       └── process_video()
│           ├── MPP 解码
│           ├── YUV420SP → RGB888
│           ├── inference_one_frame()
│           ├── YUV 上画框
│           ├── MPP 编码
│           └── 输出视频
│
└── release_model()

其中最核心的是抽出一个统一的单帧推理函数:

int inference_one_frame(
    rknn_app_context_t* app_ctx,
    unsigned char* rgb_data,
    int img_width,
    int img_height,
    detect_result_group_t* detect_result
);

这个函数只负责:

RGB888 单帧
→ resize / letterbox
→ rknn_inputs_set
→ rknn_run
→ rknn_outputs_get
→ post_process
→ 返回检测结果

这样图片和视频都可以复用这套推理逻辑。

图片分支负责把 jpg/png 变成 RGB888;
视频分支负责把 YUV420SP 帧变成 RGB888。

最终送进模型之前,二者都变成统一格式:

RGB888
模型输入尺寸
NHWC
UINT8

十、合并时要注意 resize 和 letterbox 的统一

有一个细节很容易忽略:图片 demo 和视频 demo 的预处理方式可能不一样。

图片 demo 默认可能是:

letterbox

视频 demo 里很多时候是:

直接 resize

这两者不完全一样。

如果训练或导出模型时使用的是 YOLO 常见的 letterbox 逻辑,那么视频端也最好保持一致。否则可能出现:

图片检测效果正常
视频检测框位置有偏差
小目标置信度降低
目标被拉伸后不容易识别

尤其是做小目标检测、无人机检测、航天器检测这类任务时,目标本来就小,预处理方式不一致带来的影响会更明显。

所以如果要合并程序,我觉得应该尽量让图片和视频使用统一的预处理策略。


十一、最后总结

经过这次分析,我觉得可以这样理解 Rockchip RKNN 图片 demo 和视频 demo:

图片 demo:
jpg/png → OpenCV解码成BGR → BGR转RGB888 → RKNN推理 → OpenCV画框 → 保存图片

视频 demo:
h264/h265 → MPP解码成YUV420SP → RGA转RGB888 → RKNN推理 → YUV画框 → MPP编码 → 输出视频

它们的区别主要不在模型推理,而在输入输出链路。

核心关系可以总结成:

MPP:负责视频解码和编码
RGA:负责图像格式转换、resize、拷贝
RKNN:负责模型推理
utils:demo 工程里对 MPP 编码/解码和画框的辅助封装
OpenCV:图片 demo 里用于读图、转格式、画框、保存图片

所以回答最开始的问题:

图片和视频的处理逻辑是不是一样?

我的理解是:

从模型角度看,基本一样;从工程角度看,视频比图片多了 MPP 解码、RGA 格式转换、YUV 画框、MPP 编码这些流程。

图片可以理解成只有一帧的输入,视频可以理解成连续多帧输入。真正应该复用的是“单帧推理逻辑”,而不是把图片强行走完整的视频解码编码流程。

这也是后面想把图片检测和视频检测合并到一个程序时最重要的思路:

图片 / 视频
    ↓
统一变成 RGB888 单帧
    ↓
统一 RKNN 推理
    ↓
统一后处理
    ↓
分别用图片方式或视频方式输出

这样整个程序结构会清晰很多,也更容易继续适配自己的 YOLO 模型。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐