关于前端播放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视频流

1
2
3
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

测试播放

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

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

1. websocket + opencv截图方案

1. 关于python中使用websocket

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

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

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

安装相关依赖

1
pip3 install aiohttp python-socketio opencv-python

2. 服务端原型代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!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参与处理。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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会根据视频关键帧自动设置切片时长。

1
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做兼容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<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 + 关键帧 -

大概是这样