上个月帮客户改造了一套园区安防的实时视频流检测系统,之前他们用OpenCV的VideoCapture拉RTSP流,1080P@30fps的画面,延迟经常飙到200ms以上,网络一波动还花屏、卡顿。客户要求很简单:延迟必须<50ms,画面不能花,10路流并发要稳定。折腾了两周,换了FFmpeg Java绑定,加了帧率自适应,终于搞定。今天把整个过程分享给大家。

一、为什么弃用OpenCV拉流,转投FFmpeg?

先说说OpenCV VideoCapture的痛点:它内部虽然也用了FFmpeg,但封装得太“黑盒”了,很多低延迟参数调不了,比如nobufferlow_delay这些关键选项根本没法设置。而且网络波动时,它的内部队列会无限积压,延迟越堆越高,最后直接卡死。

FFmpeg就不一样了,参数灵活,解封装、解码全流程可控,想怎么调就怎么调。但Java里用FFmpeg是个麻烦事——之前试过JAVE、Xuggler,要么年久失修不支持新版本FFmpeg,要么性能太差。最后选了JavaCPP Presets的FFmpeg,这玩意儿是真的香:直接绑定FFmpeg原生API,性能接近C++,还支持最新的FFmpeg 6.0,社区也活跃。

二、系统架构:低延迟的核心是“快进快出”

架构还是经典的生产者-消费者,但这次把重点放在了“拉流层”和“帧率自适应层”,核心原则是“能丢的帧尽早丢,不要留到检测层”:

  • 拉流层:用JavaCPP FFmpeg直接拉RTSP流,解封装、解码,只留视频流(音频直接丢),解码后的AVFrame快速转成OpenCV Mat。
  • 帧率自适应层:监控拉流帧率、检测帧率和环形缓冲区积压情况,动态决定丢B帧、丢非关键P帧还是跳帧,保证队列不积压。
  • 检测层:YOLOv8n(TensorRT FP16推理),只处理送过来的帧,不做任何队列等待。
  • 结果层:画框、推流(可选)、存告警日志。

三、核心模块实现:每一步都在抠延迟

3.1 JavaCPP FFmpeg拉流:参数调对,延迟减半

JavaCPP FFmpeg的API和原生C++几乎一模一样,上手很快,但低延迟参数的设置是关键。先给大家看核心代码,里面的每一个参数都是我踩坑踩出来的:

import org.bytedeco.ffmpeg.global.avcodec;
import org.bytedeco.ffmpeg.global.avformat;
import org.bytedeco.ffmpeg.global.avutil;
import org.bytedeco.ffmpeg.avformat.AVFormatContext;
import org.bytedeco.ffmpeg.avcodec.AVCodecContext;
import org.bytedeco.ffmpeg.avcodec.AVCodec;
import org.bytedeco.ffmpeg.avutil.AVFrame;
import org.bytedeco.ffmpeg.avutil.AVPacket;
import org.bytedeco.opencv.global.opencv_core;
import org.bytedeco.opencv.opencv_core.Mat;

public class FFmpegStreamer implements Runnable {
    private final String rtspUrl;
    private final CircularBuffer<Mat> buffer;
    private volatile boolean running = true;

    public FFmpegStreamer(String rtspUrl, CircularBuffer<Mat> buffer) {
        this.rtspUrl = rtspUrl;
        this.buffer = buffer;
    }

    @Override
    public void run() {
        AVFormatContext formatContext = null;
        AVCodecContext codecContext = null;
        AVPacket packet = null;
        AVFrame frame = null;
        AVFrame rgbFrame = null;

        try {
            // 1. 初始化AVFormatContext,设置低延迟参数
            formatContext = avformat.avformat_alloc_context();
            avformat.av_dict_set(formatContext.metadata(), "rtsp_transport", "tcp", 0); // 用TCP,避免UDP丢包花屏
            avformat.av_dict_set(formatContext.metadata(), "fflags", "nobuffer", 0); // 不缓冲
            avformat.av_dict_set(formatContext.metadata(), "flags", "low_delay", 0); // 低延迟模式
            avformat.av_dict_set(formatContext.metadata(), "probesize", "32", 0); // 减少探测时间
            avformat.av_dict_set(formatContext.metadata(), "analyzeduration", "0", 0); // 不分析流时长

            // 2. 打开RTSP流
            if (avformat.avformat_open_input(formatContext, rtspUrl, null, null) != 0) {
                throw new RuntimeException("无法打开RTSP流:" + rtspUrl);
            }
            if (avformat.avformat_find_stream_info(formatContext, (avutil.AVDictionary) null) < 0) {
                throw new RuntimeException("无法获取流信息");
            }

            // 3. 找到视频流索引
            int videoStreamIndex = -1;
            for (int i = 0; i < formatContext.nb_streams(); i++) {
                if (formatContext.streams(i).codecpar().codec_type() == avutil.AVMEDIA_TYPE_VIDEO) {
                    videoStreamIndex = i;
                    break;
                }
            }
            if (videoStreamIndex == -1) {
                throw new RuntimeException("未找到视频流");
            }

            // 4. 初始化解码器
            AVCodec codec = avcodec.avcodec_find_decoder(formatContext.streams(videoStreamIndex).codecpar().codec_id());
            codecContext = avcodec.avcodec_alloc_context3(codec);
            avcodec.avcodec_parameters_to_context(codecContext, formatContext.streams(videoStreamIndex).codecpar());
            codecContext.thread_count(1); // 单线程解码,减少延迟(多线程会有帧等待)
            codecContext.flags2(codecContext.flags2() | avcodec.AV_CODEC_FLAG2_FAST); // 快速解码
            if (avcodec.avcodec_open2(codecContext, codec, (avutil.AVDictionary) null) != 0) {
                throw new RuntimeException("无法打开解码器");
            }

            // 5. 初始化packet和frame
            packet = avcodec.av_packet_alloc();
            frame = avutil.av_frame_alloc();
            rgbFrame = avutil.av_frame_alloc();

            // 6. 分配RGB帧的内存(用于转OpenCV Mat)
            int width = codecContext.width();
            int height = codecContext.height();
            int rgbSize = avutil.av_image_get_buffer_size(avutil.AV_PIX_FMT_BGR24, width, height, 1);
            BytePointer rgbData = new BytePointer(avutil.av_malloc(rgbSize));
            avutil.av_image_fill_arrays(rgbFrame.data(), rgbFrame.linesize(), rgbData, 
                    avutil.AV_PIX_FMT_BGR24, width, height, 1);

            // 7. 循环拉流、解码
            while (running && avformat.av_read_frame(formatContext, packet) >= 0) {
                if (packet.stream_index() == videoStreamIndex) {
                    // 帧率自适应:这里先判断是否丢包,后面详细讲
                    if (shouldDropPacket(packet, formatContext.streams(videoStreamIndex))) {
                        avcodec.av_packet_unref(packet);
                        continue;
                    }

                    // 送包给解码器
                    if (avcodec.avcodec_send_packet(codecContext, packet) == 0) {
                        // 接收解码后的帧
                        while (avcodec.avcodec_receive_frame(codecContext, frame) == 0) {
                            // YUV转RGB
                            SwsContext swsCtx = swscale.sws_getContext(width, height, codecContext.pix_fmt(),
                                    width, height, avutil.AV_PIX_FMT_BGR24, swscale.SWS_BILINEAR,
                                    null, null, (DoublePointer) null);
                            swscale.sws_scale(swsCtx, frame.data(), frame.linesize(), 0, height,
                                    rgbFrame.data(), rgbFrame.linesize());
                            swscale.sws_freeContext(swsCtx);

                            // 转OpenCV Mat,复制一份(避免内存复用问题)
                            Mat mat = new Mat(height, width, opencv_core.CV_8UC3);
                            mat.data().put(rgbData.getStringBytes(0, rgbSize));
                            Mat copyMat = mat.clone();
                            mat.release();

                            // 丢给缓冲区,满了直接丢(不等待,保证低延迟)
                            if (!buffer.offer(copyMat)) {
                                copyMat.release();
                            }
                        }
                    }
                }
                avcodec.av_packet_unref(packet);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 释放资源(一定要全,不然内存泄漏)
            if (rgbFrame != null) avutil.av_frame_free(rgbFrame);
            if (frame != null) avutil.av_frame_free(frame);
            if (packet != null) avcodec.av_packet_free(packet);
            if (codecContext != null) avcodec.avcodec_free_context(codecContext);
            if (formatContext != null) {
                avformat.avformat_close_input(formatContext);
                avformat.avformat_free_context(formatContext);
            }
        }
    }

    // 帧率自适应丢包逻辑,后面详细讲
    private boolean shouldDropPacket(AVPacket packet, AVStream stream) {
        // 先留空,下一节填
        return false;
    }

    public void stop() {
        running = false;
    }
}

这里划几个低延迟的重点:

  • rtsp_transport tcp:UDP虽然快,但丢包会花屏,园区网络偶尔丢包,用TCP更稳,延迟增加不多。
  • thread_count(1):多线程解码会等几帧一起解,延迟反而高,单线程解码最快。
  • buffer.offer():不用put()(会阻塞),缓冲区满了直接丢帧,保证拉流线程不卡。

3.2 帧率自适应:网络波动时,丢帧要“聪明”

帧率自适应的核心逻辑是:监控三个指标——拉流帧率(pullFps)、检测帧率(detectFps)、环形缓冲区当前大小(bufferSize),然后分情况处理

  1. bufferSize < 30(低积压):只丢B帧(双向预测帧,不影响画面连续性),保留I帧(关键帧)和P帧(前向预测帧)。
  2. 30 ≤ bufferSize < 60(中积压):丢B帧 + 每2个P帧丢1个,减少输入帧率。
  3. bufferSize ≥ 60(高积压):直接跳帧,每3帧只留1帧(优先留I帧),快速降低队列压力。

怎么判断I/P/B帧?FFmpeg的AVPacket里有flags字段,AV_PKT_FLAG_KEY是I帧,其他的可以通过pict_type判断(需要解码后看AVFrame,不过为了低延迟,我们可以在拉流层通过H.264的NALU类型简单判断,或者直接信任解码器的延迟,优先丢非关键帧)。

给大家补全shouldDropPacket和帧率统计的代码:

public class FFmpegStreamer implements Runnable {
    // ... 之前的变量 ...
    private final AtomicLong pullFrameCount = new AtomicLong(0);
    private final AtomicLong detectFrameCount = new AtomicLong(0);
    private volatile double pullFps = 0;
    private volatile double detectFps = 0;
    private final ScheduledExecutorService fpsScheduler = Executors.newSingleThreadScheduledExecutor();

    public FFmpegStreamer(String rtspUrl, CircularBuffer<Mat> buffer) {
        this.rtspUrl = rtspUrl;
        this.buffer = buffer;
        // 启动帧率统计线程,每秒统计一次
        fpsScheduler.scheduleAtFixedRate(() -> {
            long currentPull = pullFrameCount.getAndSet(0);
            long currentDetect = detectFrameCount.getAndSet(0);
            pullFps = currentPull;
            detectFps = currentDetect;
        }, 1, 1, TimeUnit.SECONDS);
    }

    // ... 之前的run()方法,在转Mat成功后加:
    // pullFrameCount.incrementAndGet();

    // 给消费者调用的方法,消费者处理完一帧后调用
    public void incrementDetectCount() {
        detectFrameCount.incrementAndGet();
    }

    private boolean shouldDropPacket(AVPacket packet, AVStream stream) {
        int bufferSize = buffer.size();
        boolean isKeyFrame = (packet.flags() & avcodec.AV_PKT_FLAG_KEY) != 0;

        // 关键帧永远不丢(除非队列满到溢出,但offer()会处理)
        if (isKeyFrame) return false;

        // 分情况丢帧
        if (bufferSize >= 60) {
            // 高积压:每3帧丢2帧(只留1帧)
            return pullFrameCount.get() % 3 != 0;
        } else if (bufferSize >= 30) {
            // 中积压:丢B帧 + 每2个P帧丢1个
            // 简单判断:非关键帧里,每2帧丢1帧
            return pullFrameCount.get() % 2 == 0;
        } else {
            // 低积压:只丢B帧(这里简化处理,非关键帧都丢一部分,实际可以通过NALU类型精确判断B帧)
            return false; // 低积压时不丢非关键帧,保证画面流畅
        }
    }

    // ... stop()方法里要关闭fpsScheduler ...
    public void stop() {
        running = false;
        fpsScheduler.shutdownNow();
    }
}

3.3 YOLOv8低延迟优化:TensorRT FP16 + Batch Size=1

检测层的低延迟优化也很重要,之前用PyTorch引擎推理YOLOv8n要30ms,换成TensorRT FP16后直接降到18ms,提升明显。用DJL的话,设置很简单:

Criteria<Image, DetectedObjects> criteria = Criteria.builder()
        .setTypes(Image.class, DetectedObjects.class)
        .optModelPath(Paths.get("yolov8n.trt")) // 提前转好的TensorRT模型
        .optTranslator(new YoloV8Translator(0.5f, 0.4f))
        .optEngine("TensorRT")
        .optDevice(Device.gpu(0))
        .build();

另外,Batch Size一定要设为1——虽然批量推理GPU利用率高,但为了等batch会增加延迟,低延迟场景下Batch Size=1是最优解。

四、踩过的坑,每一个都让我头大

4.1 JavaCPP内存泄漏:AVFrame、AVPacket一定要unreffree

JavaCPP虽然有垃圾回收,但FFmpeg的原生对象(AVFrame、AVPacket、AVFormatContext)是在堆外分配的,GC管不了。一开始我忘了av_packet_unrefav_frame_free,跑10分钟内存就涨了2GB,最后OOM。大家一定要记住:每一个alloc的对象都要free,每一个send_packet对应的receive_frame都要处理完,packet用完一定要unref

4.2 RTSP流花屏:关键帧被我丢了

一开始写帧率自适应的时候,没判断关键帧,高积压时连I帧一起丢,结果画面花得没法看。后来加了isKeyFrame判断,关键帧永远不丢,花屏问题立刻解决。

4.3 YUV转RGB太慢:用SWS_BILINEAR,别用SWS_FAST_BILINEAR

一开始为了快,用了SWS_FAST_BILINEAR,结果转出来的画面边缘有锯齿,检测框不准。换成SWS_BILINEAR后,画面质量好了,速度只慢了1ms,完全可以接受。

五、测试结果:延迟终于<50ms了

测试环境:

  • 硬件:Intel i7-13700K CPU,RTX 4070 Ti GPU,千兆局域网
  • 软件:Java 17,JavaCPP FFmpeg 6.0,DJL 0.26.0,YOLOv8n TensorRT FP16
  • 视频流:1080P@30fps H.264 RTSP流,来自海康威视摄像头

测试结果(10路并发):

  • 单路延迟:拉流解码10ms + 预处理2ms + 推理18ms + 结果处理2ms = 32ms(远低于50ms)
  • 网络波动测试:用工具模拟5%丢包,帧率自适应启动,丢少量非关键帧,延迟稳定在40ms左右,画面无明显花屏
  • 稳定性:连续跑24小时,内存稳定在4GB左右,无泄漏,无卡顿

六、总结:低延迟是“抠”出来的

这次项目做完,我最大的感受是:Java做实时视频流检测,完全可以达到C++的性能——关键是选对工具(JavaCPP FFmpeg)、调对参数(低延迟选项)、做好帧率自适应(聪明丢帧)。不要觉得Java天生慢,只要把原生绑定用好,把细节抠到位,<50ms的延迟完全可以实现。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐