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.h264test.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 里有 formatfdvirt_addrwidth_strideheight_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 更复杂的主要原因。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐