前言最近在看 Rockchiprknpu2里面的 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 推理上下文 ctx2. 查询模型输入输出信息接着程序会查询模型的输入输出数量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 height640, width640, channel3这一步很重要因为程序要知道模型到底需要什么样的输入。比如模型要求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为什么要转 RGBRGB 的通道顺序是R G BBGR 的通道顺序是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×640YOLO 系列模型里letterbox 很常见。它的好处是尽量不破坏图像比例。但是因为补边了所以后处理时要根据scale和pads把检测框坐标映射回原图。6. 设置输入并执行 RKNN 推理预处理完成后把图像数据送给 RKNNrknn_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 里面画框比较方便因为它用的是 OpenCVrectangle(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 模型推理更具体一点模块作用MPPH264/H265 解码、编码RGAresize、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 中画框和文字一般用 OpenCVrectangle(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负责模型推理 utilsdemo 工程里对 MPP 编码/解码和画框的辅助封装 OpenCV图片 demo 里用于读图、转格式、画框、保存图片所以回答最开始的问题图片和视频的处理逻辑是不是一样我的理解是从模型角度看基本一样从工程角度看视频比图片多了 MPP 解码、RGA 格式转换、YUV 画框、MPP 编码这些流程。图片可以理解成只有一帧的输入视频可以理解成连续多帧输入。真正应该复用的是“单帧推理逻辑”而不是把图片强行走完整的视频解码编码流程。这也是后面想把图片检测和视频检测合并到一个程序时最重要的思路图片 / 视频 ↓ 统一变成 RGB888 单帧 ↓ 统一 RKNN 推理 ↓ 统一后处理 ↓ 分别用图片方式或视频方式输出这样整个程序结构会清晰很多也更容易继续适配自己的 YOLO 模型。