关于前端播放RTSP直播流画面方案研究

最近参与一个项目,硬件设备从摄像头采集视频直播流程,分析结果,与画面一起展示在前端页面上。

环境: 硬件设备是一个集成了显卡的开发板,可以运行ubuntu,算法部分是用python写的,前端设备在同一个局域网。摄像头为海康威视的普通网络摄像头。

关于视频画面播放的问题, 查了一下,前端目前不能直接播放rtsp视频流。

调研了市面上有几种方案。

  1. ffmpeg视频转格式为frag_keyframe的mp4视频流,也就是视频流切片, 使用websocket转发流,前端使用Media Source Extensions渲染

    • 普通的mp4格式无法播放,需要转成fragment mp4即分片的mp4, 目前这种格式浏览器支持度差,这个方案也没测试成功。
  2. ffmpeg视频转格式为flv视频流, websocket转发流,前端使用flv.js渲染

    • 这个方案比较靠谱, 可以参考知乎这篇HTML5 播放 RTSP 视频,没有尝试。
    • 这个方案的底层也是使用Media Source Extensions渲染,只不过格式是使用flv
  3. ffmpeg视频流切片成m3u8存成文件, 前端video标签直接播放文件地址或hls.js渲染

    • 这个方案严重的问题是延迟太高,至少会延迟一个切片文件的时间,可以播放成功。
  4. 服务端视频流截图,保持连接,接口返回content-type: multipart/x-mixed-replace图片,前端使用img标签加载图片地址

    • 这个方案是意想不到的一个方案,很神奇,不清楚会不会造成内存泄露。
  5. rtmp视频流,浏览器使用flash插件

    • flash不行,没有尝试
  6. 使用webrtc,点对点直接播放视频

    • 方案没尝试,点对点应该是浏览器对浏览器之间,服务端这边需要启一个webrtc的客户端处理,

上述几个方案

m3u8Apple主推的技术方案,目前录播用多,直播用的少,应该以后会发展。

flv.js是目前最好的直播解决方案,延迟也非常好。

这两种需要系统依赖ffmpeg。但是结合项目实际,最终采用了另外一种方式:

服务端视频流截图,使用websocket转发图片帧二进制内容, 前端渲染到canvas

不需要安装系统依赖

一个问题是带宽占用高,另外是没有声音。但是实现起来简单,不需要系统级的依赖安装,对于当前项目只显示实时画面的场景已经够用了。

0. 先构造一个rtsp视频流

需要依赖dockerffmpeg

使用docker启动一个rtsp服务,再使用ffmpeg将视频./test.mp4往服务写入,就可以得到一个不停循环播放视频的rtsp视频流

    docker run --rm -it --network=host -e RTSP_PROTOCOLS=tcp  aler9/rtsp-simple-server

    ffmpeg -re -stream_loop -1  -i ./test.mp4 -c:v copy -f rtsp -rtsp_transport tcp rtsp://localhost:8554/mystream

测试播放

    ffplay  -rtsp_transport tcp rtsp://localhost:8554/mystream 

这样我们就构造了一个可以读取视频的直播流了

1. websocket + opencv截图方案

1. 关于python中使用websocket

要做到数据实时发送,最好的方式就是用websocket,结合的最好的语言是nodejs

可惜当前项目是用python写的,python-socketio由于性能的原因,已经不推荐使用flask之类的多线程http框架

经过非常多的试错(可坑死我了),最终选择了aiohttp + AsyncServer的模式

安装相关依赖

    pip3 install aiohttp python-socketio opencv-python

2. 服务端原型代码实现

import cv2, asyncio
from aiohttp import web
from threading import Thread
from socketio import AsyncServer, AsyncNamespace

class VideoCameraThread(Thread):
    """独立线程读取处理视频画面"""
    def __init__(self, sio_handler):
        super(VideoCameraThread, self).__init__()
        self.sio_handler = sio_handler
        self.daemon = True
    def run(self):
        video = cv2.VideoCapture("rtsp://localhost:8554/mystream") 
        print("start success")
        while True:
            success, image = video.read()
            ret, jpeg = cv2.imencode(".jpg", image)
            self.sio_handler.send_frame(jpeg.tobytes())

class SocketIOHandler(AsyncNamespace):
    """注册 websocket 消息事件 """
    ALL_CLIENT = "ALL_CLIENT"

    def __init__(self):
        super(SocketIOHandler, self).__init__()
        self.loop = asyncio.get_event_loop()
        self.video_camera_thread = VideoCameraThread(self)

    def send_frame(self, frame):
        # 跨线程不能直接调用异步方法
        # 函数放到主事件循环中执行
        emitcor = self.emit("data", frame, room = self.ALL_CLIENT)
        asyncio.run_coroutine_threadsafe(emitcor, self.loop)

    def on_connect(self, sid, environ):
        print(f"on_connect: {sid}")
        self.enter_room(sid, self.ALL_CLIENT)

    def on_disconnect(self, sid):
        print(f"on_disconnect: {sid}")
        self.leave_room(sid, self.ALL_CLIENT)

    def on_start(self, sid):
        if not self.video_camera_thread.is_alive():
            print("on_start")
            self.video_camera_thread.start()
        else:
            print("already start")

def start_server_loop():
    # create sio
    sio = AsyncServer(cors_allowed_origins = "*")
    sio.register_namespace(SocketIOHandler())

    # create app
    app = web.Application()
    sio.attach(app)
    app.router.add_get("/", lambda r: web.FileResponse("./index.html")) #返回前端页面

    # start loop
    # 这个方式在高版本python中已经被弃用了后续可以改一下看看
    loop = asyncio.get_event_loop()
    app_handler = loop.create_server(app.make_handler(), host="0.0.0.0", port=8080)
    loop.run_until_complete(app_handler)
    loop.run_forever()

if __name__ == "__main__":
    start_server_loop()

关于多进程

中间遇到很多坑,关于事件循环的,关于异步函数的。`socketio`要发送数据到客户端必须要在同一个事件循环中。跨线程的可以用`run_coroutine_threadsafe`将数据发送到主事件循环中,但是跨进程就没办法了。

由于python本身的限制,算法部分是多进程的。 于是最终的方案是再加一层进程的消息队列进行进程间的数据传递。

为了避免消费消息队列阻塞事件循环,又起了一个线程消费消息队列。

3. 前端部分实现

index.html:

    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <title>测试</title>
        <script type="text/javascript" src="https://unpkg.com/[email protected]/client-dist/socket.io.min.js"></script>
    </head>
    <body>
        <h1>hello</h1>
        <canvas id="canvas" controls width="480" height="330" />
    </body>
        <script>
            const canvas = document.getElementById("canvas");
            const ctx = canvas.getContext("2d");
            socket = io();
            socket.on("data", async function (data) {
                const imageblob = new Blob([data], {type : "image/jpg"});
                const imagebitmap = await createImageBitmap(imageblob)
                ctx.drawImage(imagebitmap, 0, 0, canvas.width, canvas.height);
            });
            socket.emit("start")
        </script>
    </html>

前端部分较为简单。有关canvas渲染的部分可以使用requestAnimationFrame进行优化。

浏览器打开http://localhost:8080/可以看到视频播放

2. 使用multipart/x-mixed-replace传输图片方案

这个方案很有意思, 相当于是将每帧画面用同一个连接返回给前端。后一帧会将前一帧的内容替换掉。前端无需js参与处理。

前端也比较简单,直接包含在代码里了。 服务端代码:

from flask import Flask, render_template, Response
import cv2

app = Flask(__name__)
@app.route("/")
def home():
    tmpl = """<html>
          <head>
            <title>Video Streaming Demonstration</title>
          </head>
          <body>
            <img src="/video_frame">
          </body>
        </html>
        """
    return Response(tmpl, mimetype="text/html")

video = cv2.VideoCapture("rtsp://localhost:8554/mystream") 
def gen():
    print("start success")
    while True:
        success, image = video.read()
        ret, jpeg = cv2.imencode(".jpg", image)
        frame = jpeg.tobytes()
        yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n\r\n")

@app.route("/video_frame")
def video_frame():
    return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame")

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

方案有个问题就是前端的GET请求会一直连接着,浏览器如果刷新或者中断,服务端会报错。另外不知道会不会有什么其他风险。

浏览器打开http://localhost:8080/可以看到视频播放

3. 使用ffmpeg切片m3u8, 前端video标签直接播放

这个方式应该是目前实际使用最多的方案,各大直播以及普通视频播放都使用了m3u8

直播延迟的长度和切片有关,可以做到几秒级的。

1. 创建m3u8文件及切片

仅保留最近5个文件,单个切片时长4秒。实际上这两个参数都不准确,ffmpeg会根据视频关键帧自动设置切片时长。

    ffmpeg -rtsp_transport tcp -i rtsp://localhost:8554/mystream -c:v copy -hls_time 4 -hls_list_size 5 -hls_flags delete_segments -f hls  ./tmp/index.m3u8 

2. 前端文件

实际上并不是所有浏览器都原生都支持m3u8格式, 可以使用hls.js做兼容。

<script src="https://unpkg.com/hls.js@latest"></script>
<video id="test_video" controls ></video>

<style type="text/css">video{width: 600px;}</style>
<script>
  const video = document.getElementById("test_video");
  const videoSrc = "./tmp/index.m3u8";
  if (video.canPlayType("application/vnd.apple.mpegurl")) {
    video.src = videoSrc;
  } else if (Hls.isSupported()) {
    const hls = new Hls();
    hls.loadSource(videoSrc);
    hls.attachMedia(video);
  } else {
    alert("浏览器不支持hls视频流");
  }
</script>

4. 总结

方式 系统依赖 延迟 视频声音
websocket + flv.js ffmpeg
websocket + 关键帧 -
m3u8 ffmpeg 中等
x-mixed-replace + 关键帧 -

大概是这样