用netty和opencv从摄像头抓取视频在前端播放

0. 描述

这个demo实现了利用opencv从摄像头抓取视频,并使用netty框架通过websocket将视频传到前端,前端不断将图像写入canvas实现播放,但只有图像没有声音。

1. 基本思路

摄像头:笔记本的摄像头

抽帧:opencv

队列:线程安全的java.util.concurrent.ConcurrentLinkedQueue

消息分发:自己实现。用来支持多个连接,主要逻辑就是将生产者生产的数据拷贝多份分发给各个websocket连接。

websocket服务:netty

帧数据的传输路径如下图所示:

netty+websocket视频传输demo
2. 使用opencv抽帧

利用opencv的videoCapture从摄像头抽帧,并将帧数据存入队列。

import org.opencv.core.Mat;
import org.opencv.videoio.VideoCapture;

import java.util.concurrent.ConcurrentLinkedQueue;

public class FrameProducer extends Thread {
    private static final int MAX_QUEUE_SIZE = 30;

    private final VideoCapture videoCapture = new VideoCapture(0);
    private final ConcurrentLinkedQueue<Mat> queue = new ConcurrentLinkedQueue<>();

    public void close() {
        this.videoCapture.release();
    }

    public ConcurrentLinkedQueue<Mat> queue() {
        return queue;
    }

    @Override
    public void run() {
        videoCapture.open(0);
        while (videoCapture.isOpened()) {
            if (queue.size() < MAX_QUEUE_SIZE) {
                Mat mat = new Mat();
                videoCapture.read(mat);
                queue.offer(mat);
            }
        }
    }
}
2. 消息分发
import org.opencv.core.Mat;

public class FrameDistributor extends Thread {
    private static final int MAX_QUEUE_SIZE = 30;

    FrameProducer producer;
    ConcurrentLinkedQueue<Mat> queue;
    Map<String, Queue<Mat>> matQueueMap = new ConcurrentHashMap<>();
    public boolean running = true;

    public FrameDistributor(FrameProducer producer) {
        this.producer = producer;
        this.queue = producer.queue();
    }

    synchronized public void register(String key, Queue<Mat> matQueue) {
        matQueueMap.put(key, matQueue);
        System.out.println("alive matQueue num: " + matQueueMap.entrySet().size());
    }

    synchronized public void unregister(String key) {
        matQueueMap.remove(key);
        System.out.println("alive matQueue num: " + matQueueMap.entrySet().size());
    }

    @Override
    public void run() {
        while (running) {
            Mat mat = queue.poll();
            if (mat != null) {
                matQueueMap.forEach((key, value) -> {
                    if (value.size() < MAX_QUEUE_SIZE) {
                        // 取到的mat复制一份
                        value.offer(mat.clone());
                    } else {
                        mat.release();
                    }
                });
            }
        }
    }
}
3. 使用netty实现websocket服务器

Websocket服务器:

import org.opencv.core.Mat;

public class WebsocketServer {
    private int port = 9999;
    private String address = "127.0.0.1";

    ConcurrentLinkedQueue<Mat> queue;

    public void run(ConcurrentLinkedQueue<Mat> queue) {
        this.queue = queue;

        ServerBootstrap bootstrap = new ServerBootstrap();
        EventLoopGroup boss = new NioEventLoopGroup();
        EventLoopGroup worker = new NioEventLoopGroup();

        try {
            bootstrap.group(boss, worker);
            bootstrap.channel(NioServerSocketChannel.class);
            bootstrap.handler(new LoggingHandler(LogLevel.INFO));
            bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline()
                            .addLast(new HttpServerCodec())
                            .addLast(new ChunkedWriteHandler())
                            .addLast(new HttpObjectAggregator(65536))
                            .addLast(new WebSocketServerProtocolHandler("/", null, true))//websocket协议处理器
                            .addLast(new WebSocketServerHandler(queue));//自定义websocket处理器,在此实现消息推送
                }
            });
            bootstrap.bind(address, port).sync().channel().closeFuture().sync();
        } catch (Exception ex) {
            ex.printStackTrace();
        } finally {
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }
}

WebSocket消息处理器:

import org.opencv.core.Mat;

public class WebSocketServerHandler extends SimpleChannelInboundHandler<WebSocketFrame> {

    private final ConcurrentLinkedQueue<Mat> queue = new ConcurrentLinkedQueue<>();
    private final FrameDistributor distributor;
    private ScheduledFuture<?> future;

    public WebSocketServerHandler(FrameDistributor distributor) {
        super();
        this.distributor = distributor;
    }
  
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        String id = ctx.channel().id().asLongText();
        System.out.println("channelActive: " + id);
        distributor.register(id, queue);
        // 周期任务
        ctx.executor().scheduleAtFixedRate(() -> {
            Mat mat = queue.poll();
            if (mat != null) {
                // 这里用到了一个工具函数,将opencv的Mat对象转为BufferedImage
                BufferedImage image = Utils.matToBufferedImage(mat);
                ByteBuf buffer = ByteBufAllocator.DEFAULT.heapBuffer(1024 * 1024);
                ByteBufOutputStream stream = new ByteBufOutputStream(buffer);

                ImageIO.write(image, "JPEG", stream);

                ctx.channel().writeAndFlush(new BinaryWebSocketFrame(buffer));
                // 由于消息分发的时候我没有将数据拷贝,而是直接将mat对象的引用分发出去,因此这里的mat用完还不能释放,我索性将
                // 其交给JVM的GC来释放了。如果这里直接释放,那么第一个连接将数据用完之后直接释放,其他连接拿到的同样的数据就
                // 不可用了。
                // mat.release();
            }
            // 第三个参数period不能填0,否则直接出错,而且还没报错信息。
            // period尽量调整到合适的大小。(也许可以动态调整适应来环境?
            // 我的电脑摄像头帧率30fps,最慢每33ms就得处理完一帧,不然就会丢帧。这里平均每12ms就能处理完一帧送出去。
            // 因此每20ms调用一次差不多刚好。
        }, 100, 20, TimeUnit.MILLISECONDS);
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        String id = ctx.channel().id().asLongText();
        if (future != null) {
            future.cancel(true);
        }
        distributor.unregister(id);
        System.out.println("channelInactive: " + id);
    }
}

工具类

public class Utils {
    // Mat转BufferedImage
    public static BufferedImage matToBufferedImage(Mat mat) {
        int dataSize = mat.cols() * mat.rows() * (int) mat.elemSize();
        byte[] data = new byte[dataSize];
        mat.get(0, 0, data);
        int type = mat.channels() == 1 ? BufferedImage.TYPE_BYTE_GRAY : BufferedImage.TYPE_3BYTE_BGR;

        if (type == BufferedImage.TYPE_3BYTE_BGR) {
            for (int i = 0; i < dataSize; i += 3) {
                byte blue = data[i];
                data[i] = data[i + 2];
                data[i + 2] = blue;
            }
        }
        BufferedImage image = new BufferedImage(mat.cols(), mat.rows(), type);
        image.getRaster().setDataElements(0, 0, mat.cols(), mat.rows(), data);

        return image;
    }
}
4. 启动类
import org.opencv.core.Core;
import org.opencv.core.Mat;

public class Application {
    static {
        // 这里加载opencv的动态库
        System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
    }

    public static void main(String[] args) {
        FrameProducer producer = new FrameProducer();
        producer.setDaemon(true);
        producer.start();

        FrameDistributor distributor = new FrameDistributor(producer);
        distributor.setDaemon(true);
        distributor.start();

        new WebsocketServer().run(distributor);
    }
}
5. 前端页面

页面需要代理一下。可以用python的http server做代理,切到需要代理的目录,执行下面的脚本即可。

python -m http.server 8080 --bind 127.0.0.1

ws-index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>ws</title>
</head>

<body>
<div style="text-align: center; margin-top: 100px;">
    <button onclick='stop()'>stop</button>
    <h4>服务端消息</h4>
    <div id="msg">...</div>
    <div>
        <canvas id="canvas" width="640" height="480"></canvas>
    </div>
</div>

<script>
    const ws = new WebSocket("ws://127.0.0.1:9999/");
    const canvas = document.getElementById("canvas");
    const context = canvas.getContext("2d");
    const msg = document.getElementById("msg");
    let count = 0;

    ws.onopen = function (evt) {
        console.log("Connection open ...");
    };
    const image = new Image();

    ws.onmessage = function (evt) {
        if (evt?.data != undefined && evt.data instanceof Blob) {
            msg.innerHTML = 'Got a Frame '+ (count++);
            let frame = evt.data;
            frame.type = 'image/jpeg';
            blobToDataURI(frame, (url) => {
                image.src = url;
                image.onload = function (e) {
                    context.drawImage(image, 0, 0, 640, 480);
                }
            });
        }
    };

    ws.onclose = function (evt) {
        console.log("Connection closed.");
    };

    function stop() {
        ws.close();
    }

    function blobToDataURI(blob, callback) {
        let reader = new FileReader();
        reader.onload = function (e) {
            callback(e.target.result);
        }

        reader.readAsDataURL(blob);
    }
</script>
</body>
</html>
6. 总结

我发现Java确实不太适合做图像这方面的处理。因为图像数据本身就大,处理过程中难免产生中间数据,而且中间数据都跟原数据一个量级,累积起来太吃内存了。如果使用C++的话,可以在对象使用过后就及时手动释放,Java里面得等GC去释放。

我遇到的几个疑点和坑:

  1. 为什么要加个消息分发?

    我一开始是没有加消息分发的,直到我打开了三个页面,发现事情不太对劲。打开的连接越多,视频就越卡。

    原来,抽帧线程拿到的帧数据都存放在那一个队列里面,websocket的连接是被并发处理的,多个连接同时从一个队列里面抢数据,结果就是你一个我一个,连续的帧序列被瓜分,每个连接拿到的都是原序列的子序列,播放出来当然就卡成PPT了。

  2. mat什么时候释放?

    其实mat可以不释放,虽然它是个native对象,但由于其实现了JNI,它也在GC的管理范围内。

  3. 为什么消息分发的时候非得复制一份?复用同一份对象可以吗?

    消息分发的时候,可以不用复制(事实上连接多的时候,这么做会更好),直接将对象的引用分发给各个channel,由于消息分发之后没有再对这个mat对象进行修改,因此也不存在线程安全问题,这个对象用完之后会被GC回收掉。

    我之前在WebSocketServerHandler中的周期任务里面,将用完的mat释放掉了,后面matToBufferedImage这个方法疯狂报错。冷静下来思考之后发现,这是个线程安全问题,同一份mat被多个channel引用,先来的线程用完mat跑完任务之后就把mat释放了,后到的线程用到mat的时候发现数据没了 (😂),然后给我抛异常:

    java.lang.IllegalArgumentException: Width (0) and height (0) must be > 0
    	at java.awt.image.SampleModel.<init>(SampleModel.java:126)
    	at java.awt.image.ComponentSampleModel.<init>(ComponentSampleModel.java:146)
    	at java.awt.image.PixelInterleavedSampleModel.<init>(PixelInterleavedSampleModel.java:87)
    	at java.awt.image.Raster.createInterleavedRaster(Raster.java:641)
    	at java.awt.image.Raster.createInterleavedRaster(Raster.java:278)
    	at java.awt.image.BufferedImage.<init>(BufferedImage.java:376)
    	at cn.kui.app.opencv.Utils.matToBufferedImage(Utils.java:27)
    	at cn.kui.app.websocket.WebSocketServerHandler.lambda$channelActive$0(WebSocketServerHandler.java:66)
    
posted @   烟酒忆长安  阅读(1744)  评论(0编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示