最近参与一个项目,硬件设备从摄像头采集视频直播流程,分析结果,与画面一起展示在前端页面上。
环境: 硬件设备是一个集成了显卡的开发板,可以运行ubuntu,算法部分是用python写的,前端设备在同一个局域网。摄像头为海康威视的普通网络摄像头。
关于视频画面播放的问题, 查了一下,前端目前不能直接播放rtsp
视频流。
调研了市面上有几种方案。
ffmpeg
视频转格式为frag_keyframe
的mp4视频流,也就是视频流切片, 使用websocket
转发流,前端使用Media Source Extensions
渲染
- 普通的mp4格式无法播放,需要转成
fragment 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插件
使用webrtc
,点对点直接播放视频
- 方案没尝试,点对点应该是浏览器对浏览器之间,服务端这边需要启一个webrtc的客户端处理,
上述几个方案
m3u8
是Apple
主推的技术方案,目前录播用多,直播用的少,应该以后会发展。
flv.js
是目前最好的直播解决方案,延迟也非常好。
这两种需要系统依赖ffmpeg
。但是结合项目实际,最终采用了另外一种方式:
服务端视频流截图,使用websocket
转发图片帧二进制内容, 前端渲染到canvas
不需要安装系统依赖
一个问题是带宽占用高,另外是没有声音。但是实现起来简单,不需要系统级的依赖安装,对于当前项目只显示实时画面的场景已经够用了。
0. 先构造一个rtsp视频流
需要依赖docker
与ffmpeg
使用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(): sio = AsyncServer(cors_allowed_origins = "*") sio.register_namespace(SocketIOHandler())
app = web.Application() sio.attach(app) app.router.add_get("/", lambda r: web.FileResponse("./index.html"))
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/socket.io@4.4.1/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 + 关键帧 | - | 低 | 无 |
大概是这样