售货柜实战:IPC 拉流 → 抽帧 → YOLO 识别完整流水线
最后一篇实战,把前面 11 篇的知识串起来。用 FFmpeg SDK 实现一个完整的售货柜视觉识别流水线:RTSP 拉流 → 硬件解码 → 帧预处理 → YOLO 商品识别 → 结果输出。从架构设计到核心代码,再到性能优化,完整讲一遍。
大家好,我是黒漂技术佬。
这是 FFmpeg 系列的最后一篇,也是实战篇——把前面讲的 RTSP 拉流、硬件解码、像素格式转换、性能优化,全部整合到售货柜的商品识别流水线里。
整个流程:IPC 摄像头 RTSP 流 → FFmpeg 硬解码 → YUV 转 RGB → YOLOv8 推理 → 输出识别结果。
一、整体架构
流水线设计
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ RTSP 拉流 │ → │ 硬件解码 │ → │ 图像预处理 │ → │ YOLO 推理 │ │ (FFmpeg) │ │ (RKMPP) │ │ (缩放/裁剪) │ │ (RKNN) │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ ↓ 识别结果输出四个阶段,用队列串起来,流水线并行处理。
为什么用 FFmpeg 不用 OpenCV VideoCapture?
很多人做视频处理直接用 OpenCV 的 VideoCapture,简单是简单,但问题不少:
- 不支持硬件解码,CPU 占用高
- RTSP 不稳定,断线不会自动重连
- 缓冲不可控,延迟高
- 滤镜、格式转换能力弱
FFmpeg 虽然代码多一点,但可控性强太多,生产环境必须用 FFmpeg。
二、核心模块设计
模块拆分
| 模块 | 职责 |
|---|---|
| StreamReader | RTSP 拉流 + 硬解码,输出 YUV 帧 |
| FrameProcessor | 图像预处理:裁剪、缩放、格式转换 |
| Detector | YOLO 推理,输出检测框 |
| Pipeline | 串起整个流水线,管理队列和线程 |
线程模型
三个线程,两个队列:
拉流解码线程 → [帧队列] → 预处理线程 → [推理队列] → 推理线程流水线并行,三路同时跑,吞吐量最高。
三、StreamReader:拉流 + 硬解码
初始化代码
classStreamReader{public:boolopen(conststd::string&url){// 1. 打开 RTSP 流AVDictionary*opts=NULL;av_dict_set(&opts,"rtsp_transport","tcp",0);av_dict_set(&opts,"stimeout","10000000",0);// 10秒超时intret=avformat_open_input(&fmt_ctx_,url.c_str(),NULL,&opts);av_dict_free(&opts);if(ret<0)returnfalse;// 2. 找流信息avformat_find_stream_info(fmt_ctx_,NULL);// 3. 找视频流video_idx_=-1;for(inti=0;i<fmt_ctx_->nb_streams;i++){if(fmt_ctx_->streams[i]->codecpar->codec_type==AVMEDIA_TYPE_VIDEO){video_idx_=i;break;}}if(video_idx_==-1)returnfalse;AVCodecParameters*codecpar=fmt_ctx_->streams[video_idx_]->codecpar;// 4. 初始化硬件设备(RKMPP)av_hwdevice_ctx_create(&hw_device_ctx_,AV_HWDEVICE_TYPE_DRM_PRIME,NULL,NULL,0);// 5. 打开解码器constAVCodec*decoder=avcodec_find_decoder(codecpar->codec_id);codec_ctx_=avcodec_alloc_context3(decoder);avcodec_parameters_to_context(codec_ctx_,codecpar);codec_ctx_->hw_device_ctx=av_buffer_ref(hw_device_ctx_);avcodec_open2(codec_ctx_,decoder,NULL);pkt_=av_packet_alloc();hw_frame_=av_frame_alloc();sw_frame_=av_frame_alloc();returntrue;}// 读取一帧,返回 0 成功,<0 失败/结束intreadFrame(AVFrame*out_frame){while(true){intret=av_read_frame(fmt_ctx_,pkt_);if(ret<0)returnret;// 出错或结束if(pkt_->stream_index==video_idx_){avcodec_send_packet(codec_ctx_,pkt_);ret=avcodec_receive_frame(codec_ctx_,hw_frame_);if(ret==0){// 硬件帧转 CPU 内存帧if(hw_frame_->format==AV_PIX_FMT_DRM_PRIME){sw_frame_->format=AV_PIX_FMT_YUV420P;sw_frame_->width=hw_frame_->width;sw_frame_->height=hw_frame_->height;av_frame_get_buffer(sw_frame_,0);av_hwframe_transfer_data(sw_frame_,hw_frame_,0);av_frame_ref(out_frame,sw_frame_);}else{av_frame_ref(out_frame,hw_frame_);}av_packet_unref(pkt_);return0;}}av_packet_unref(pkt_);}}voidclose(){// 释放资源...}private:AVFormatContext*fmt_ctx_=nullptr;AVCodecContext*codec_ctx_=nullptr;AVBufferRef*hw_device_ctx_=nullptr;AVPacket*pkt_=nullptr;AVFrame*hw_frame_=nullptr;AVFrame*sw_frame_=nullptr;intvideo_idx_=-1;};关键点
- TCP 传输:稳定,不花屏
- 超时设置:10 秒连不上就退出,配合外层重连
- 硬件解码:RKMPP,CPU 占用低
- 硬件帧转软件帧:要处理像素数据必须转
四、FrameProcessor:图像预处理
YOLO 输入需要 640×640 的 RGB 图像,摄像头拍的是 1920×1080 的 YUV,需要:
- 裁剪出货架区域(只关心商品部分)
- 缩放到 640×640
- YUV 转 RGB
用 SwsContext 一步完成
classFrameProcessor{public:// 初始化:源 1920x1080 YUV420P,裁剪区域 (x,y,w,h),输出 640x640 RGBboolinit(intsrc_w,intsrc_h,AVPixelFormat src_fmt,intcrop_x,intcrop_y,intcrop_w,intcrop_h,intdst_w,intdst_h,AVPixelFormat dst_fmt){src_w_=src_w;src_h_=src_h;crop_x_=crop_x;crop_y_=crop_y;crop_w_=crop_w;crop_h_=crop_h;dst_w_=dst_w;dst_h_=dst_h;// 先裁剪再缩放,用 sws 处理// 注意:swscale 不直接支持裁剪,需要先手动裁// 简单做法:用 crop 滤镜,或者手动处理 data 指针// 这里用滤镜方案更优雅initFilter(crop_w,crop_h,src_fmt,"crop=...");sws_ctx_=sws_getContext(crop_w,crop_h,src_fmt,dst_w,dst_h,dst_fmt,SWS_BILINEAR,NULL,NULL,NULL);// 分配输出 bufferav_image_alloc(dst_data_,dst_linesize_,dst_w,dst_h,dst_fmt,1);returnsws_ctx_!=NULL;}// 处理一帧intprocess(AVFrame*in_frame,uint8_t*out_rgb){// 1. 裁剪:调整 data 指针和 linesizeuint8_t*cropped_data[4];intcropped_linesize[4];cropped_data[0]=in_frame->data[0]+crop_y_*in_frame->linesize[0]+crop_x_;cropped_linesize[0]=in_frame->linesize[0];// UV 分量也要对应裁剪(YUV420P 的 UV 是 1/2 大小)cropped_data[1]=in_frame->data[1]+(crop_y_/2)*in_frame->linesize[1]+(crop_x_/2);cropped_linesize[1]=in_frame->linesize[1];cropped_data[2]=in_frame->data[2]+(crop_y_/2)*in_frame->linesize[2]+(crop_x_/2);cropped_linesize[2]=in_frame->linesize[2];// 2. 缩放 + 格式转换sws_scale(sws_ctx_,cropped_data,cropped_linesize,0,crop_h_,dst_data_,dst_linesize_);// 3. 拷贝到输出(连续内存,方便推理)memcpy(out_rgb,dst_data_[0],dst_w_*dst_h_*3);return0;}private:structSwsContext*sws_ctx_=nullptr;uint8_t*dst_data_[4];intdst_linesize_[4];intsrc_w_,src_h_;intcrop_x_,crop_y_,crop_w_,crop_h_;intdst_w_,dst_h_;};为什么先裁剪再缩放?
货架只占画面中间一部分,裁掉多余区域再缩放,处理的像素少,速度快。
1920×1080 → 裁 800×600 → 缩 640×640,比直接从 1920×1080 缩快很多。
五、Detector:YOLO 推理
这部分用 RKNN 或 OpenCV DNN,和 FFmpeg 关系不大,简单示意:
classDetector{public:boolinit(conststd::string&model_path){// 加载 RKNN 模型 / ONNX 模型// ...returntrue;}// 输入 RGB 图像,输出检测结果std::vector<DetectResult>detect(constuint8_t*rgb_data,intw,inth){// 1. 预处理:归一化、NCHW 排布// 2. 推理// 3. 后处理:NMS、坐标还原// ...returnresults;}};六、Pipeline:完整流水线
多线程 + 队列
classDetectionPipeline{public:boolstart(conststd::string&rtsp_url){// 初始化各模块reader_.open(rtsp_url);processor_.init(1920,1080,AV_PIX_FMT_YUV420P,560,200,800,600,// 裁剪区域640,640,AV_PIX_FMT_RGB24);detector_.init("yolov8n.rknn");// 启动线程running_=true;read_thread_=std::thread(&DetectionPipeline::readLoop,this);process_thread_=std::thread(&DetectionPipeline::processLoop,this);detect_thread_=std::thread(&DetectionPipeline::detectLoop,this);returntrue;}voidreadLoop(){while(running_){AVFrame*frame=av_frame_alloc();if(reader_.readFrame(frame)==0){frame_queue_.push(frame);// 入队}else{av_frame_free(&frame);// 读失败,尝试重连reconnect();}}}voidprocessLoop(){while(running_){AVFrame*frame=frame_queue_.pop();// 出队if(!frame)continue;// 预处理uint8_t*rgb=newuint8_t[640*640*3];processor_.process(frame,rgb);av_frame_free(&frame);rgb_queue_.push(rgb);}}voiddetectLoop(){while(running_){uint8_t*rgb=rgb_queue_.pop();if(!rgb)continue;autoresults=detector_.detect(rgb,640,640);delete[]rgb;// 输出结果(回调/队列)if(callback_)callback_(results);}}private:StreamReader reader_;FrameProcessor processor_;Detector detector_;ThreadSafeQueue<AVFrame*>frame_queue_;ThreadSafeQueue<uint8_t*>rgb_queue_;std::thread read_thread_;std::thread process_thread_;std::thread detect_thread_;boolrunning_=false;// 断线重连voidreconnect(){reader_.close();std::this_thread::sleep_for(std::chrono::seconds(3));reader_.open(rtsp_url_);}};队列长度控制
队列不能无限长,处理不过来的时候丢旧帧,保证延迟不会累积。
if(queue.size()>MAX_QUEUE_SIZE){// 丢最旧的一帧autoold=queue.front();queue.pop();release(old);}七、性能优化记录
第一版(全软)
- 软解码 + sws 转换 + CPU 推理
- 速度:~8fps
- CPU:90%+
- 问题:卡,实时性差
第二版(硬解)
- RKMPP 硬解码 + sws 转换 + CPU 推理
- 速度:~15fps
- CPU:~60%
- 提升:解码压力释放了
第三版(全硬件)
- 硬解码 + RGA 硬件缩放 + RKNN NPU 推理
- 速度:30fps+(实时)
- CPU:~15%
- 提升:NPU 接管推理,CPU 只做控制
第四版(按需识别)
- 待机状态:每秒 1 帧,检测是否有人
- 开门状态:全帧率识别
- 平时 CPU:<5%,功耗低
- 优化:事件驱动,不是一直满负荷跑
八、踩过的坑
坑 1:硬解出来的帧不能直接访问像素
一开始直接读 hw_frame->data[0],数据全错。硬解帧是 DRM_PRIME 格式,数据在硬件内存,必须av_hwframe_transfer_data转到 CPU 才能访问。
坑 2:RTSP 断线不重连
FFmpeg 本身不会自动重连,必须外层包循环。加上超时检测 + 3 秒重试,稳定很多。
坑 3:队列越积越多延迟变大
处理速度跟不上的时候,队列越来越长,延迟几秒甚至几十秒。加队列长度限制,满了丢旧帧。
坑 4:YUV 裁剪 UV 分量没对齐
YUV420P 的 UV 分量宽高是 Y 的一半,裁剪的时候 x 和 y 要除以 2,不然颜色错位。
坑 5:sws_getContext 反复创建
一开始每帧都创建 SwsContext,巨慢。创建一次复用,速度差十倍。
九、完整项目文件结构
cabinet_vision/ ├── src/ │ ├── stream_reader.h/cpp # FFmpeg 拉流解码 │ ├── frame_processor.h/cpp # 图像预处理 │ ├── detector.h/cpp # YOLO 推理 │ ├── pipeline.h/cpp # 流水线调度 │ └── main.cpp ├── model/ │ └── yolov8n.rknn # 模型文件 └── CMakeLists.txt依赖:FFmpeg(带 RKMPP)、RKNN、pthread。
十、系列总结
12 篇 FFmpeg 系列到此结束,回顾一下我们覆盖的内容:
基础篇(1-2):命令行入门、转码参数详解
处理篇(3-5):视频滤镜、音频处理、抽帧截图
流媒体篇(6-7):RTSP 拉流录制、HLS/DASH 切片
SDK 开发篇(8-9):核心结构体、解码、编码、滤镜、完整转码
进阶篇(10-11):硬件加速、性能优化
实战篇(12):售货柜完整流水线
从命令行到 SDK 开发,从基础用法到硬件加速和性能优化,覆盖了 FFmpeg 80% 的常用场景。
FFmpeg 是个很庞大的库,没人能全部掌握,但搞懂核心的编解码、滤镜、流媒体这几块,日常项目基本就够用了。剩下的遇到具体问题再查文档就行。
我是黒漂技术佬,这个系列就到这里。下个系列见。