最近参与一个项目,硬件设备从摄像头采集视频直播流程,分析结果,与画面一起展示在前端页面上。
环境: 硬件设备是一个集成了显卡的开发板,可以运行ubuntu,算法部分是用python写的,前端设备在同一个局域网。摄像头为海康威视的普通网络摄像头。
关于视频画面播放的问题, 查了一下,前端目前不能直接播放rtsp视频流。
调研了市面上有几种方案。
-
ffmpeg视频转格式为frag_keyframe的mp4视频流,也就是视频流切片, 使用websocket转发流,前端使用Media Source Extensions渲染- 普通的mp4格式无法播放,需要转成
fragment mp4即分片的mp4, 目前这种格式浏览器支持度差,这个方案也没测试成功。
- 普通的mp4格式无法播放,需要转成
-
ffmpeg视频转格式为flv视频流,websocket转发流,前端使用flv.js渲染- 这个方案比较靠谱, 可以参考知乎这篇HTML5 播放 RTSP 视频,没有尝试。
- 这个方案的底层也是使用
Media Source Extensions渲染,只不过格式是使用flv
-
ffmpeg视频流切片成m3u8存成文件, 前端video标签直接播放文件地址或hls.js渲染- 这个方案严重的问题是延迟太高,至少会延迟一个切片文件的时间,可以播放成功。
-
服务端视频流截图,保持连接,接口返回
content-type: multipart/x-mixed-replace图片,前端使用img标签加载图片地址- 这个方案是意想不到的一个方案,很神奇,不清楚会不会造成内存泄露。
-
转
rtmp视频流,浏览器使用flash插件- flash不行,没有尝试
-
使用
webrtc,点对点直接播放视频- 方案没尝试,点对点应该是浏览器对浏览器之间,服务端这边需要启一个webrtc的客户端处理,
上述几个方案
m3u8是Apple主推的技术方案,目前录播用多,直播用的少,应该以后会发展。
flv.js是目前最好的直播解决方案,延迟也非常好。
这两种需要系统依赖ffmpeg。但是结合项目实际,最终采用了另外一种方式:
服务端视频流截图,使用
websocket转发图片帧二进制内容, 前端渲染到canvas
不需要安装系统依赖
一个问题是带宽占用高,另外是没有声音。但是实现起来简单,不需要系统级的依赖安装,对于当前项目只显示实时画面的场景已经够用了。
0. 先构造一个rtsp视频流
需要依赖docker与ffmpeg
使用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 + 关键帧 | - | 低 | 无 |
大概是这样