售货柜实战:IPC 拉流 → 抽帧 → YOLO 识别完整流水线

售货柜实战: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;
};

关键点

  1. TCP 传输:稳定,不花屏
  2. 超时设置:10 秒连不上就退出,配合外层重连
  3. 硬件解码:RKMPP,CPU 占用低
  4. 硬件帧转软件帧:要处理像素数据必须转

四、FrameProcessor:图像预处理

YOLO 输入需要 640×640 的 RGB 图像,摄像头拍的是 1920×1080 的 YUV,需要:

  1. 裁剪出货架区域(只关心商品部分)
  2. 缩放到 640×640
  3. 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 是个很庞大的库,没人能全部掌握,但搞懂核心的编解码、滤镜、流媒体这几块,日常项目基本就够用了。剩下的遇到具体问题再查文档就行。

我是黒漂技术佬,这个系列就到这里。下个系列见。

相关推荐
Auv开心2 小时前
Ubuntu 20.04 默认视频播放器(Totem)无法播放 MP4 文件,通常是因为缺少多媒体编解码器。以下是几种解决方法:
linux·ubuntu·音视频
chnyi6_ya2 小时前
论文阅读笔记:VideoWeaver — 面向智能体长视频生成的技能评估与进化
论文阅读·笔记·音视频
懂AI的老郑4 小时前
面对小众场景的目标检测联合智能体零样本检测可行性分析
人工智能·yolo·架构
明哥聊AI4 小时前
AI视频生成技术全景:Sora2、Veo3、可灵3.0背后的Diffusion架构深度解析
人工智能·架构·音视频
阿拉斯攀登4 小时前
RTSP 拉流与录制:IPC 摄像头本地录像完整方案
ffmpeg·音视频·webrtc·实时音视频·视频编解码
SRET15 小时前
Mineradio 沙盒隔离安装完全指南 | 一键安全运行 Electron 应用!!!
javascript·经验分享·安全·electron·aigc·音视频·ai编程
Niuguangshuo16 小时前
音频 MOS 演进:从 PESQ 到 SpeechQualityLLM
音视频
2501_9302969920 小时前
模型驱动创作全链路梳理:2026上半年图像与视频生成AI模型全景盘点
人工智能·音视频
断眉的派大星20 小时前
YOLO实例分割详细解析
人工智能·yolo·计算机视觉