Rockchip RK3576 上 YOLO26 离线视频检测 Demo 的实现思路:从 H264/H265 码流到 RKNN 推理再到 out.h264 输出
1. 前言
最近在做 YOLO 26模型从图片检测 demo 到离线视频检测 demo 的适配。最开始我也有一个疑问:既然图片 demo 已经能检测了,视频不就是一帧帧图片吗?是不是直接在图片检测外面套一个 while 循环就可以?
从算法角度看,这句话没错,视频最终确实可以看成连续图像帧。但在 Rockchip 板端原生视频处理流程里,事情没有这么简单。因为我们面对的离线视频文件通常不是一张张 RGB 图片,而是 H264/H265 压缩码流。程序不能直接对压缩码流做目标检测,必须先把码流解码成 YUV/NV12 原始图像帧,再送给模型推理。
这篇文章记录的是我对 YOLO 离线视频检测 demo 的理解和适配过程。我的实际思路是:复用 RK2 YOLOv5 视频 demo 的 MPP/RGA 视频处理链路,再把中间的 YOLOv5 推理部分替换成自己的 YOLO26 模型推理和 36 类后处理。
最终流程可以概括成:
H264/H265 离线码流
↓
MPP Decoder 硬件解码
↓
YUV420SP/NV12 原始帧
↓
RGA 转 RGB888
↓
YOLO26 推理和后处理
↓
RGA 拷贝原始 YUV 帧到编码器 buffer
↓
在 YUV420SP 帧上画框
↓
MPP Encoder 硬件编码
↓
输出 out.h264
这里需要特别说明:视频链路里用到了 YOLOv5 视频 demo 的 MPP 解码、MPP 编码、RGA 拷贝和 YUV 画框,但模型推理和后处理仍然是 YOLO26 自己的,并不是使用 YOLOv5 后处理。
2. 图片检测 demo 和视频检测 demo 的区别
图片检测 demo 的流程比较直接:
读取 jpg/png 图片
↓
resize / letterbox
↓
RKNN 推理
↓
YOLO 后处理
↓
画框
↓
保存 out.png
但是离线视频 demo 面对的是:
test.h264
test.hevc
这类文件本质上是压缩码流,不是 RGB 图片数组。H264/H265 里面有 I 帧、P 帧、参考帧、NALU、slice 等概念,程序不能直接把文件里的某一段字节当成一帧图像。
所以视频 demo 比图片 demo 多了几个步骤:
码流读取
MPP 硬件解码
YUV/NV12 图像帧处理
stride 对齐
RGA 格式转换和拷贝
YUV 图像上画框
MPP 硬件编码输出
这也是为什么板端原生视频 demo 看起来比图片 demo 复杂很多。
3. 整体改造思路
这次适配采用的是“视频外壳 + 模型核心”的方式。
视频外壳来自 RK2 YOLOv5 视频 demo,主要包括:
process_video_file()
MppDecoder
mpp_decoder_frame_callback()
MppEncoder
draw_rectangle_yuv420sp()
RGA imcopy()
out.h264 输出
这些模块只负责视频码流、YUV 图像帧、硬件 buffer 和编码输出,和具体 YOLO 模型没有强绑定关系。
模型核心来自 YOLO26 图片 demo,主要包括:
init_yolo26_model()
inference_yolo26_model()
post_process()
release_yolo26_model()
object_detect_result_list
36 类标签文件
所以最终的适配原则是:
视频处理链路复用 YOLOv5 video demo
模型推理和后处理使用 YOLO26 自己的代码
4. process_video_file:读的是码流包,不是一帧图片
离线视频文件处理函数一般是 process_video_file()。它会先把整个 test.h264 或 test.hevc 读入内存,然后每次取一段数据送入解码器。
典型逻辑如下:
const int SIZE = 8192;
do {
int pkt_eos = 0;
int size = SIZE;
if (video_data_ptr + size >= video_data_end) {
pkt_eos = 1;
size = video_data_end - video_data_ptr;
}
ctx->decoder->Decode((uint8_t *)video_data_ptr, size, pkt_eos);
video_data_ptr += size;
if (video_data_ptr >= video_data_end) {
break;
}
} while (1);
这里的 8192 字节不是一帧图像,只是一段压缩码流 packet。H264/H265 的帧边界不一定刚好落在 8192 字节边界上,所以不能理解成“一次读取一帧”。
正确理解应该是:
process_video_file() 每次读一包压缩码流
↓
decoder->Decode() 把码流包送给 MPP
↓
MPP 内部解析、缓存、解码
↓
解出完整一帧后才触发回调
所以:
Decode 调用次数 ≠ 视频帧数
callback 调用次数 ≈ 解码出来的帧数
5. MppDecoder:把 H264/H265 码流解成 YUV/NV12 帧
MppDecoder 是对 Rockchip MPP 解码器的一层封装。
它的输入是:
H264/H265 压缩码流 packet
它的输出是:
YUV420SP/NV12 原始图像帧
初始化时,main 函数中会创建解码器:
MppDecoder *decoder = new MppDecoder();
decoder->Init(video_type, 30, &app_ctx);
decoder->SetCallback(mpp_decoder_frame_callback);
app_ctx.decoder = decoder;
其中 video_type 用来告诉解码器输入码流类型:
264 → H264 / AVC
265 → H265 / HEVC
SetCallback() 的作用是注册一个回调函数。MPP 解码器本身并不知道 YOLO26,也不知道怎么推理、怎么画框。它只负责解码。当它解出一帧 YUV/NV12 图像后,就调用 mpp_decoder_frame_callback(),把这一帧的宽高、stride、fd、虚拟地址等信息传给上层。
回调函数一般是这样的形式:
void mpp_decoder_frame_callback(
void *userdata,
int width_stride,
int height_stride,
int width,
int height,
int format,
int fd,
void *data
)
其中:
| 参数 | 含义 |
|---|---|
userdata | 之前传入的 &app_ctx |
width / height | 图像实际宽高 |
width_stride / height_stride | 硬件内存对齐后的宽高 |
format | 解码帧格式,通常是 YUV420SP/NV12 |
fd | 当前帧的 dma-buf 文件描述符 |
data | 当前帧映射到 CPU 的虚拟地址 |
这里的“吐出一帧”,不是函数 return 一张图片,而是 MPP Decoder 在内部解码得到完整一帧后,主动调用回调函数。
6. 最关键的修复:先把 MPP YUV 帧转成 RGB888,再推理
最开始我尝试过一种看起来更直接的做法:把 MPP 解码出来的 YUV420SP/NV12 帧直接封装成 image_buffer_t,再调用 inference_yolo26_model()。
从接口上看,这样似乎没问题,因为 image_buffer_t 里有 format、fd、virt_addr、width_stride、height_stride 等字段。但实际测试中,这种方式不能正常识别和画框。
后来我改成了更稳的方式:在 mpp_decoder_frame_callback() 里先用 RGA 把 MPP 解码出来的 YUV420SP/NV12 帧转换成 RGB888,然后再调用已经在图片 demo 中验证成功的 inference_yolo26_model()。
核心函数如下:
static int convert_mpp_yuv420sp_to_rgb888(void *yuv_data,
int width,
int height,
int width_stride,
int height_stride,
image_buffer_t *rgb_img)
{
int rgb_size = width * height * 3;
unsigned char *rgb_buf = (unsigned char *)malloc(rgb_size);
rga_buffer_t src = wrapbuffer_virtualaddr(
(void *)yuv_data,
width,
height,
RK_FORMAT_YCbCr_420_SP,
width_stride,
height_stride
);
rga_buffer_t dst = wrapbuffer_virtualaddr(
(void *)rgb_buf,
width,
height,
RK_FORMAT_RGB_888
);
IM_STATUS status = imresize(src, dst);
rgb_img->width = width;
rgb_img->height = height;
rgb_img->width_stride = width;
rgb_img->height_stride = height;
rgb_img->format = IMAGE_FORMAT_RGB888;
rgb_img->virt_addr = rgb_buf;
rgb_img->fd = 0;
rgb_img->size = rgb_size;
return 0;
}
这里的 imresize() 实际完成了:
YUV420SP/NV12 → RGB888
尺寸保持原视频宽高不变
后续 inference_yolo26_model() 内部还会继续做 YOLO26 图片 demo 原来的预处理,例如 letterbox 到模型输入尺寸,然后执行 RKNN 推理和 YOLO26 后处理。
所以最终的推理输入路径是:
MPP 解码得到 YUV420SP/NV12
↓
main_video.cc 中 RGA 转 RGB888
↓
构造 IMAGE_FORMAT_RGB888 的 image_buffer_t
↓
inference_yolo26_model()
↓
内部继续 letterbox + RKNN 推理 + YOLO26 后处理
这个修改是最终能正常识别和画框的关键。
7. 一帧进入回调后如何处理
每当 MPP 解码器解出一帧,就会进入 mpp_decoder_frame_callback()。
回调里的主要流程是:
1. frame_index++
2. 第一帧初始化 MppEncoder
3. 每帧申请 enc_data 编码 buffer
4. MPP YUV420SP/NV12 → RGA 转 RGB888
5. 调用 inference_yolo26_model()
6. 从 encoder 获取输入 buffer
7. RGA imcopy(decoder buffer → encoder buffer)
8. 在 encoder buffer 上画框
9. MppEncoder 编码
10. fwrite 写入 out.h264
11. 释放 rgb_buf 和 enc_data
第一帧才初始化编码器,是因为只有真正解出第一帧后,才能知道视频的真实宽高和 stride:
enc_params.width = width;
enc_params.height = height;
enc_params.hor_stride = width_stride;
enc_params.ver_stride = height_stride;
enc_params.fmt = MPP_FMT_YUV420SP;
enc_params.type = MPP_VIDEO_CodingAVC;
这里 MPP_VIDEO_CodingAVC 表示输出编码为 H264,所以即使输入是 H265,当前版本输出仍然是 out.h264。
8. 为什么不能直接在 decoder buffer 上画框
MPP Decoder 解码出来的 buffer 是解码器管理的。H264/H265 可能存在参考帧机制,解码器内部可能还会复用某些 buffer。如果直接在 decoder buffer 上画框,可能影响后续解码或破坏解码器管理的原始帧。
所以当前做法是:
decoder buffer
↓
RGA imcopy
↓
encoder input buffer
↓
在 encoder input buffer 上画框
对应代码大概是:
mpp_frame = ctx->encoder->GetInputFrameBuffer();
mpp_frame_fd = ctx->encoder->GetInputFrameBufferFd(mpp_frame);
mpp_frame_addr = ctx->encoder->GetInputFrameBufferAddr(mpp_frame);
origin = wrapbuffer_fd(fd,
width,
height,
RK_FORMAT_YCbCr_420_SP,
width_stride,
height_stride);
dst_frame = wrapbuffer_fd(mpp_frame_fd,
width,
height,
RK_FORMAT_YCbCr_420_SP,
width_stride,
height_stride);
ret = imcopy(origin, dst_frame);
然后画框时使用:
draw_rectangle_yuv420sp((unsigned char *)mpp_frame_addr,
width_stride,
height_stride,
x1,
y1,
x2 - x1 + 1,
y2 - y1 + 1,
0x00FF0000,
4);
也就是说,检测用的是 RGB888 图像,画框和编码用的是拷贝后的 YUV420SP 图像。
9. MppEncoder 如何输出 out.h264
MppEncoder 负责把带框的 YUV420SP 帧重新编码成 H264。
第一帧时需要写入 H264 header:
enc_data_size = ctx->encoder->GetHeader(enc_data, enc_buf_size);
fwrite(enc_data, 1, enc_data_size, ctx->out_fp);
这个 header 里包含 SPS/PPS 等参数信息,播放器或解码器需要它来正确解析后面的 H264 码流。
之后每一帧调用:
enc_data_size = ctx->encoder->Encode(mpp_frame, enc_data, enc_buf_size);
fwrite(enc_data, 1, enc_data_size, ctx->out_fp);
这里写入 out.h264 的不是 YUV 原始数据,而是重新编码后的 H264 压缩码流。
10. enc_data 为什么改回每帧 malloc/free
之前有一版把 enc_data 做成了持久化 buffer,也就是第一帧分配一次,后面重复使用。理论上这样可以减少每帧 malloc/free,但实际测试中容易出现堆内存破坏,例如类似 malloc(): mismatching next->prev_size 的问题。
所以最终版本改回了更接近 YOLOv5 官方视频 demo 的方式:
每帧根据 encoder->GetFrameSize() 获取 enc_buf_size
每帧 malloc enc_data
当前帧编码和 fwrite 完成后,在 RET 中 free enc_data
这样虽然每帧多了一次内存分配和释放,但逻辑更稳,也避免了持久 buffer 带来的内存问题。
11. 当前版本是不是零拷贝?
严格来说,不能说整个流程是完整零拷贝。
当前版本中,视频帧在 decoder buffer 到 encoder buffer 的过程中使用了 dma-buf fd 和 RGA imcopy(),这部分是硬件加速的:
decoder fd → RGA imcopy → encoder fd
但是 YOLO26 推理输入阶段,为了保证和图片 demo 的输入格式一致,先把 MPP 解码出来的 YUV420SP/NV12 转成 RGB888,并使用 malloc 出来的 rgb_buf 传给 inference_yolo26_model():
YUV420SP/NV12
↓
RGA 转 RGB888
↓
普通内存 rgb_buf
↓
YOLO26 推理
所以更准确的说法是:
视频编解码和画框链路使用了 MPP/RGA 硬件加速;
但当前 YOLO26 推理输入不是完整 zero-copy,而是先转换成 RGB888 后再复用图片 demo 推理接口。
12. 一帧一检、一帧一输出怎么理解
要分两个层面说。
从文件读取层面看,不是一帧一读:
process_video_file() 每次读 8192 字节压缩码流
Decode() 次数和视频帧数不一定相等
从解码输出层面看,是一帧一检:
MPP 每解出一帧
↓
触发 mpp_decoder_frame_callback()
↓
RGA 转 RGB888
↓
YOLO26 推理
↓
画框
↓
编码输出
假设一个视频有 300 帧,process_video_file() 可能调用 Decode() 几百次,也可能上千次,这取决于文件大小和 8192 字节切分。但 MPP 最终大约会触发 300 次回调,当前代码也会大约执行 300 次 YOLO26 推理,并编码写入约 300 帧到 out.h264。
所以准确说:
不是“一次 read = 一帧”
而是“一次 callback = 一帧”
13. 最终完整流程图
最终能正常使用的版本可以总结为:
main()
init_post_process()
init_yolo26_model()
new MppDecoder()
decoder->Init(video_type, 30, &app_ctx)
decoder->SetCallback(mpp_decoder_frame_callback)
fopen("out.h264")
process_video_file()
read_file_data("test.h264")
每 8192 字节取一个 packet
decoder->Decode(packet)
MPP Decoder 内部
解析 H264/H265 码流
硬件解码
解出完整 YUV420SP/NV12 帧
触发 mpp_decoder_frame_callback()
mpp_decoder_frame_callback()
第一帧初始化 MppEncoder
convert_mpp_yuv420sp_to_rgb888()
RGA: YUV420SP/NV12 → RGB888
inference_yolo26_model()
letterbox
rknn_inputs_set
rknn_run
rknn_outputs_get
YOLO26 post_process
获取 encoder input buffer
imcopy(decoder buffer → encoder buffer)
draw_rectangle_yuv420sp()
MppEncoder::Encode()
fwrite 到 out.h264
结束
fclose(out_fp)
delete decoder
delete encoder
release_yolo26_model()
deinit_post_process()
14. 和 YOLOv5 视频 demo 的关系
这次适配不是重新写了一个视频框架,而是复用了 YOLOv5 视频 demo 的视频外壳。
保留/复用的部分:
MppDecoder
MppEncoder
process_video_file()
mpp_decoder_frame_callback() 的整体结构
RGA imcopy
draw_rectangle_yuv420sp()
out.h264 输出
替换成 YOLO26 的部分:
init_model() → init_yolo26_model()
inference_model() → inference_yolo26_model()
YOLOv5 post_process → YOLO26 post_process
detect_result_group_t → object_detect_result_list
COCO 80 类 → 自定义 36 类
没有使用的 YOLOv5 模型逻辑包括:
YOLOv5 anchor 解码
YOLOv5 NMS 后处理
COCO 80 类解析
15. 总结
本次工作基于已适配好的 YOLO26 图片检测 demo,参考 RK2 YOLOv5 视频检测 demo 的 MPP/RGA 视频处理链路,实现了 YOLO26 离线视频检测 demo。整体思路是复用 YOLOv5 视频 demo 中与模型无关的 MPP 解码、RGA 拷贝、YUV 画框和 MPP 编码输出流程,同时保留 YOLO26 自己的 RKNN 推理和 36 类后处理逻辑。
在实际调试过程中,直接将 MPP 解码得到的 YUV420SP/NV12 帧传入 inference_yolo26_model() 并不能正常识别。最终采用了更稳的做法:在视频回调函数中先通过 RGA 将 YUV420SP/NV12 转换成 RGB888,再调用已经在图片 demo 中验证可用的 YOLO26 推理接口。检测完成后,再把原始 YUV 解码帧拷贝到编码器输入 buffer,在 YUV420SP 图像上直接画框,最后通过 MPP Encoder 编码输出 out.h264。
因此,当前 demo 从解码输出帧角度看是一帧一检、一帧一编码输出;但从文件读取角度看,并不是一次读取一帧,而是分包读取 H264/H265 压缩码流,由 MPP 解码器解析并吐出完整帧。这也是板端原生视频检测 demo 相比图片检测 demo 更复杂的主要原因。
更多推荐



所有评论(0)