售货柜实战: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:拉流 + 硬解码
初始化代码
cpp
class StreamReader {
public:
bool open(const std::string& url) {
// 1. 打开 RTSP 流
AVDictionary* opts = NULL;
av_dict_set(&opts, "rtsp_transport", "tcp", 0);
av_dict_set(&opts, "stimeout", "10000000", 0); // 10秒超时
int ret = avformat_open_input(&fmt_ctx_, url.c_str(), NULL, &opts);
av_dict_free(&opts);
if (ret < 0) return false;
// 2. 找流信息
avformat_find_stream_info(fmt_ctx_, NULL);
// 3. 找视频流
video_idx_ = -1;
for (int i = 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) return false;
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. 打开解码器
const AVCodec* 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();
return true;
}
// 读取一帧,返回 0 成功,<0 失败/结束
int readFrame(AVFrame* out_frame) {
while (true) {
int ret = av_read_frame(fmt_ctx_, pkt_);
if (ret < 0) return ret; // 出错或结束
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_);
return 0;
}
}
av_packet_unref(pkt_);
}
}
void close() {
// 释放资源...
}
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;
int video_idx_ = -1;
};
关键点
- TCP 传输:稳定,不花屏
- 超时设置:10 秒连不上就退出,配合外层重连
- 硬件解码:RKMPP,CPU 占用低
- 硬件帧转软件帧:要处理像素数据必须转
四、FrameProcessor:图像预处理
YOLO 输入需要 640×640 的 RGB 图像,摄像头拍的是 1920×1080 的 YUV,需要:
- 裁剪出货架区域(只关心商品部分)
- 缩放到 640×640
- YUV 转 RGB
用 SwsContext 一步完成
cpp
class FrameProcessor {
public:
// 初始化:源 1920x1080 YUV420P,裁剪区域 (x,y,w,h),输出 640x640 RGB
bool init(int src_w, int src_h, AVPixelFormat src_fmt,
int crop_x, int crop_y, int crop_w, int crop_h,
int dst_w, int dst_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
);
// 分配输出 buffer
av_image_alloc(dst_data_, dst_linesize_, dst_w, dst_h, dst_fmt, 1);
return sws_ctx_ != NULL;
}
// 处理一帧
int process(AVFrame* in_frame, uint8_t* out_rgb) {
// 1. 裁剪:调整 data 指针和 linesize
uint8_t* cropped_data[4];
int cropped_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);
return 0;
}
private:
struct SwsContext* sws_ctx_ = nullptr;
uint8_t* dst_data_[4];
int dst_linesize_[4];
int src_w_, src_h_;
int crop_x_, crop_y_, crop_w_, crop_h_;
int dst_w_, dst_h_;
};
为什么先裁剪再缩放?
货架只占画面中间一部分,裁掉多余区域再缩放,处理的像素少,速度快。
1920×1080 → 裁 800×600 → 缩 640×640,比直接从 1920×1080 缩快很多。
五、Detector:YOLO 推理
这部分用 RKNN 或 OpenCV DNN,和 FFmpeg 关系不大,简单示意:
cpp
class Detector {
public:
bool init(const std::string& model_path) {
// 加载 RKNN 模型 / ONNX 模型
// ...
return true;
}
// 输入 RGB 图像,输出检测结果
std::vector<DetectResult> detect(const uint8_t* rgb_data, int w, int h) {
// 1. 预处理:归一化、NCHW 排布
// 2. 推理
// 3. 后处理:NMS、坐标还原
// ...
return results;
}
};
六、Pipeline:完整流水线
多线程 + 队列
cpp
class DetectionPipeline {
public:
bool start(const std::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);
return true;
}
void readLoop() {
while (running_) {
AVFrame* frame = av_frame_alloc();
if (reader_.readFrame(frame) == 0) {
frame_queue_.push(frame); // 入队
} else {
av_frame_free(&frame);
// 读失败,尝试重连
reconnect();
}
}
}
void processLoop() {
while (running_) {
AVFrame* frame = frame_queue_.pop(); // 出队
if (!frame) continue;
// 预处理
uint8_t* rgb = new uint8_t[640*640*3];
processor_.process(frame, rgb);
av_frame_free(&frame);
rgb_queue_.push(rgb);
}
}
void detectLoop() {
while (running_) {
uint8_t* rgb = rgb_queue_.pop();
if (!rgb) continue;
auto results = 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_;
bool running_ = false;
// 断线重连
void reconnect() {
reader_.close();
std::this_thread::sleep_for(std::chrono::seconds(3));
reader_.open(rtsp_url_);
}
};
队列长度控制
队列不能无限长,处理不过来的时候丢旧帧,保证延迟不会累积。
cpp
if (queue.size() > MAX_QUEUE_SIZE) {
// 丢最旧的一帧
auto old = 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->data0,数据全错。硬解帧是 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 是个很庞大的库,没人能全部掌握,但搞懂核心的编解码、滤镜、流媒体这几块,日常项目基本就够用了。剩下的遇到具体问题再查文档就行。
我是黒漂技术佬,这个系列就到这里。下个系列见。