参考资料
在 Flask 里产生流式响应使用 multipart/x-mixed-replace 实现 http 实时视频流使用 Flask 进行视频流传输重新审视 Flask 视频流
目录
- 流媒体
- Flask实现流传输
- multipart Response
- 构建实时视频流服务器
- 从相机中获取帧
- 有线程时才运行照相机
- 相机基类BaseCamera
- 上一个版本存在的问题
流媒体
流式传输是一种技术,其中服务器以块的形式提供对请求的响应。流媒体有两大特点:
1、large response(即数据量大) :对于非常大的响应,只需要在内存中组装响应才能将其返回给客户端可能效率低下。另一种方法是将响应写入磁盘,然后使用 返回文件flask.send_file(),但这会增加 I/O。假设数据可以分块生成,提供小部分响应是一个更好的解决方案。
2、实时数据 :对于某些应用程序,请求可能需要返回来自实时源的数据。一个很好的例子是实时视频或音频馈送。许多安全摄像头使用这种技术将视频流式传输到网络浏览器。
Flask实现流传输
流式传输的实现是采用 生成器函数(yeild关键字) ,那么 HTTP 客户端将会得到这个迭代器每次迭代的结果一部分,迭代器产生多少客户端收到多少,就像流一样 。将一个生成器包装在Response类中返回。下面就展示了一个简单的流式输出,当输入http://localhost:5000/foo地址时,页面会分别输出两个字符串。
返回流式响应的路由需要返回一个Response用生成器函数初始化的对象。然后 Flask 负责调用生成器并将所有部分结果作为块发送给客户端。
@app.route('/foo')
def foo():
def generate():
yield 'first part'
sleep(3)
yield 'second part'
return Response(generate(), direct_passthrough=True)
这样,当从数据库中查询大量的数据并返回时,我们不需要消耗大量的内存来存储数据并返回,而是生成一个迭代器来流式传输,这样Python 进程中的内存消耗将不会因为必须组装一个大的响应字符串而变得越来越大。
multipart Response
上面的示例生成一小部分的传统页面,所有部分连接到最终文档中。这是如何生成大量响应的一个很好的例子,但更令人兴奋的是使用实时数据。
流式传输的一个有趣用途是让每个块替换页面中的前一个块,因为这使流能够在浏览器窗口中“播放”或动画。 使用这种技术,您可以让流中的每个块都成为一个图像 ,从而为您提供在浏览器中运行的酷炫视频源!
有了视频帧之后,接下来的问题就是如何传输到客户端,这里有很多成熟的传输技术,包括: HLS、RTSP、RTMP等。这些技术有一定的复杂性,各自有其适用场景,如果业务场景对实时性、性能没有太高要求,那显得有点牛刀杀鸡了。有一个更简单,对前端更友好的方案: http 的 multipart 类型。
multipart 通过 content-type 头定义。这里稍微解释一下,content-type 用于声明资源的媒体类型,浏览器会根据媒体类型的值做出不同动作。 比如,通常来说,chrome 遇到application/zip会下载资源;遇到application/pdf会启动预览,正是通过判断这个头部做出的分支选择。
而 multipart 类型值声明服务器会将 多份数据 合并成当个请求。比较常见的例子是 form 表单提交,浏览器默认的 form 表单提交行为就是通过指定 content-type: multipart/form-data; boundary=xxx 头,服务器接收到后会根据 boundary 分割内容,提取多个字段。 规范文档 rfc1341 指定了四种子类型:multipart/mixed、multipart/alternative、multipart/digest、multipart/parallel,主流浏览器则扩展了一种新的类型: multipart/x-mixed-replace (不过由于很少用到这个特性,而且实现上容易出安全问题,MDN 已经标志为过期特性),该类型声明 body 内容由多份 XML 文档按序组合组合而成,每次到来的文档都会被用于创建新的 dom 文档对象,并触发文档对象 onload 事件。
下面是一个multipart的响应实例:
HTTP/1.0 200 OK
Content-Type: multipart/x-mixed-replace; boundary=gc0p4Jq0M2Yt08jU534c0p
X-Request-ID: bcd9f083-af7a-4419-94bd-0e47851a542d
Date: Tue, 12 Mar 2019 05:04:39 GMT
--gc0p4Jq0M2Yt08jU534c0p
Content-Type: text/html
<html><body>0</body></html>
--gc0p4Jq0M2Yt08jU534c0p
Content-Type: text/html
<html><body>1</body></html>
...
与常见的 http 响应相比,上例有两个特点。第一,在 header 中并没有指明 content-length 头,客户端无法预知资源大小 ,按规范,在这条 TCP 连接中断之前所传输过来的数据都是本次响应的内容,这个特性可以用于构建一个持久、可扩展的响应流,非常契合实时视频传输场景 。第二点是,response 的 body 部分由多份资源按序排列 而成,并使用 boundary 字符串标志资源的分割点 ,客户端可以使用 boundary 字符串抽取、解析出每一份资源的内容。
简单的HTTP响应传输视频帧,存在一些问题
- OpenCV 编解码效率并不高,替代方案是 FFMPEG
- multipart/x-mixed-replace 是单次 http 请求-响应模型,如果网络中断,会导致视频流异常终止,必须重新连接
- 无法同时输出音频
- 针对专业、高性能要求的场景,建议还是使用专用协议,如 HLS、RTSP 等
浏览器处理multipart/x-mixed-replace请求时,会使用当前的块数据替换之前的块数据。这刚好就是我们想要的流媒体的效果。我们可以把媒体的一帧数据打包为一个数据块,每块数据有自己的Content-Type和可选的Content-Length。浏览器逐帧替换,就实现了视频的播放功能。
构建实时视频流服务器
将视频流式传输到浏览器的方法有很多,每种方法都存在其优缺点。与Flask的流式传输功能配合良好的方法是流式传输一系列独立的JPEG图片,这种成为Motion JPEG ,被许多 IP 安全摄像机使用。这种方法延迟低,但质量不是最好的,因为 JPEG 压缩对于运动视频不是很有效。
下面就是一个非常简单完整的web推流服务器,它可以提供Motion JPEG流:
from flask import Flask, render_template, Response
from camera import Camera
app = Flask(__name__)
@app.route('/')
def index():
return render_template('index.html')
def gen(camera):
while True:
frame = camera.get_frame()
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')
@app.route('/video_feed')
def video_feed():
return Response(gen(Camera()),
mimetype='multipart/x-mixed-replace; boundary=frame')
if __name__ == '__main__':
app.run(host='0.0.0.0', debug=True)
此应用程序导入一个Camera负责提供帧序列的类。在这种情况下,将相机控制部分放在一个单独的模块中是一个好主意,这样 Web 应用程序保持干净、简单和通用。
该应用程序有两条路线。该 / 路由服务于index.html模板中定义的主页。它的代码如下:
<html>
<head>
<title>Video Streaming Demonstration</title>
</head>
<body>
<h1>Video Streaming Demonstration</h1>
<img src="{{ url_for('video_feed') }}">
</body>
</html>
这是一个简单的 HTML 页面,只有一个标题和一个图像标签。请注意,图像标签的src属性指向此应用程序的第二条路径,这就是魔术发生的地方。
该/video_feed路线返回流响应。由于此流返回要在网页中显示的图像,因此此路由的 URLsrc位于图像标记的属性中。浏览器将通过在其中显示 JPEG 图像流来自动更新图像元素,因为大多数/所有浏览器都支持multipart response。
/video_feed路由中使用的生成器函数称为gen(),并将Camera类的实例作为参数。在mimetype如上述所示,用参数设定multipart/x-mixed-replace的内容类型和边界设置为字符串"frame"。
该gen()函数进入一个循环,在该循环中它不断从相机返回帧作为响应块。该函数通过调用该camera.get_frame()方法来要求相机提供一个帧,然后它会将该帧格式化为内容类型为 的响应块image/jpeg,如上所示。
从相机中获取帧
有线程时才运行照相机
当第一个客户端连接到流时,从摄像头捕获视频帧的后台线程就开始了,但它永远不会停止。处理此后台线程的更有效方法是仅在有线程时运行它,以便在没有人连接时关闭相机。
具体做法是:这个想法是每次客户端访问帧时,都会记录该访问的当前时间。相机线程检查这个时间戳,如果它发现它超过十秒就退出。通过此更改,当服务器在没有任何客户端的情况下运行十秒钟时,它将关闭其摄像头并停止所有后台活动。一旦客户端再次连接,线程就会重新启动。
下面是程序部分实现:
class Camera(object):
# ...
last_access = 0 # time of last client access to the camera
# ...
def get_frame(self):
Camera.last_access = time.time()
# ...
@classmethod
def _thread(cls):
with picamera.PiCamera() as camera:
# ...
for foo in camera.capture_continuous(stream, 'jpeg', use_video_port=True):
# ...
# if there hasn't been any clients asking for frames in
# the last 10 seconds stop the thread
if time.time() - cls.last_access > 10:
break
cls.thread = None
相机基类BaseCamera
我们需要使这个流服务器能够适用于不同的流(图片,视频,相机,树莓派等),可以将执行所有帧后台处理的通用功能移至基类,只留下从相机获取帧的任务以在子类中实现 ,下面是该基类的实现:
class BaseCamera(object):
thread = None # background thread that reads frames from camera
frame = None # current frame is stored here by background thread
last_access = 0 # time of last client access to the camera
# ...
@staticmethod
def frames():
"""Generator that returns frames from the camera."""
raise RuntimeError('Must be implemented by subclasses.')
def get_frame(self):
"""Return the current camera frame."""
BaseCamera.last_access = time.time()
# wait for a signal from the camera thread
BaseCamera.event.wait()
BaseCamera.event.clear()
return BaseCamera.frame
@classmethod
def _thread(cls):
"""Camera background thread."""
print('Starting camera thread.')
frames_iterator = cls.frames()
for frame in frames_iterator:
BaseCamera.frame = frame
# if there hasn't been any clients asking for frames in
# the last 10 seconds then stop the thread
if time.time() - BaseCamera.last_access > 10:
frames_iterator.close()
print('Stopping camera thread due to inactivity.')
break
BaseCamera.thread = None
这样 我们可以在不同子类中实现具体的获取帧的方法frames(),该方法就是一个生成器,里面含有yield关键字,将获取的帧数据转化为byte进行返回,如果需要进行图片的检测,也是在这一方法中进行 。
例如从笔记本自带摄像头获取帧
import cv2
from base_camera import BaseCamera
class Camera(BaseCamera):
@staticmethod
def frames():
camera = cv2.VideoCapture(0)
if not camera.isOpened():
raise RuntimeError('Could not start camera.')
while True:
# read current frame
_, img = camera.read()
# encode as a jpeg image and return it
yield cv2.imencode('.jpg', img)[1].tobytes()
其中cv2.VideoCapture(0)中的0就表示从本地摄像头获取cap。
上一个版本存在的问题
多次观察到的另一个观察结果是服务器消耗大量 CPU。原因是后台线程捕获帧和生成器将这些帧提供给客户端之间没有同步。两者都尽可能快地跑,而不考虑对方的速度。
通常,后台线程尽可能快地运行是有意义的,因为您希望每个客户端的帧速率尽可能高。但是您绝对不希望向客户端传送帧的生成器以比相机生成帧更快的速度运行,因为这意味着将向客户端发送重复的帧。(因为从上面BaseCamera的get_frame()函数可以看出,每次get_frame都是从一个类变量frame中获取的,而该frame的生成是在生成器中,如果客户端那边调用get_frame的速度大于生成器的生成速度,就会返回重复的图片) 虽然这些重复不会造成任何问题,但它们会增加 CPU 和网络使用率而没有任何好处。
所以需要有一种机制,生成器只将原始帧传递给客户端,如果生成器内部的传递循环比相机线程的帧速率快,那么生成器应该等待新帧可用,以便它自己调整速度以匹配相机速率。另一方面,如果传递循环的运行速度比相机线程慢,那么它在处理帧时永远不会落后,而是应该跳过帧以始终传递最新的帧。
我想要的解决方案是让相机线程在新帧可用时向正在运行的生成器发出信号。然后,生成器可以在等待信号时阻塞,然后再传送下一帧。在查看同步原语时,我发现threading.Event是与这种行为相匹配的。所以基本上,每个生成器都应该有一个事件对象,然后相机线程应该向所有活动的事件对象发出信号,以在新帧可用时通知所有正在运行的生成器。生成器传递帧并重置它们的事件对象,然后返回以再次等待下一帧。
为了避免在生成器中添加事件处理逻辑,我决定实现一个定制的事件类,它使用调用者的线程 id 为每个客户端线程自动创建和管理一个单独的事件。老实说,这有点复杂,但这个想法来自 Flask 的上下文局部变量是如何实现的。新的事件类叫做CameraEvent,并拥有wait(),set()和clear()方法。有了这个类的支持,可以在BaseCamera类中加入速率控制机制:
class CameraEvent(object):
# ...
class BaseCamera(object):
# ...
event = CameraEvent()
# ...
def get_frame(self):
"""Return the current camera frame."""
BaseCamera.last_access = time.time()
# wait for a signal from the camera thread
BaseCamera.event.wait()
BaseCamera.event.clear()
return BaseCamera.frame
@classmethod
def _thread(cls):
# ...
for frame in frames_iterator:
BaseCamera.frame = frame
BaseCamera.event.set() # send signal to clients
# ...
在CameraEvent类中完成的函数使多个客户端能够单独等待新帧。该wait()方法使用当前线程 id 为每个客户端分配一个单独的事件对象并等待它。该clear()方法将重置与调用者线程 id 关联的event,以便每个生成器线程可以以自己的速度运行。set()相机线程调用的方法向为所有客户端分配的事件对象发送一个信号,并且还将删除其所有者未提供服务的任何event,因为这意味着与这些event关联的客户端已关闭连接并且消失了。
在BaseCamera的_thread()函数中,当执行一次BaseCamera.frame = frame后,说明出现了一张新的帧,这时调用set()方法发送信号,此时在get_frame()函数中的wait()阻塞的线程继续执行,将该新的帧return回去,这样就能将保证在get_frame()快的时候,先让他等待知道有新的帧出现。如果是_thread中相机线程更快,那么每次get_frame()都是最新的帧,而不必担心重复问题。而在get_frame()方法中的clear()表示将信号清除,这样每次在event.set()时判断是否event有信号isset()来判断客户端连接是否断开,如果没有信号,说明get_frame()中执行了wait()和clear(),表示客户端正在等待新的帧出现;如果是有信号isset()的状态,说明要么是客户端那边处理上一张帧的速度慢还没有执行get_frame(),要么是该客户端已经断开连接,如果这个时间超过了阈值5秒,那么就remove掉这个event。
为了让您了解性能改进的幅度,请考虑模拟相机驱动程序在此更改之前消耗了大约 96% 的 CPU,因为它不断以远高于每秒生成一帧的速率发送重复帧。经过这些改动后,同样的流消耗了大约 3% 的 CPU。在这两种情况下,都有一个客户端查看流。对于单个客户端,OpenCV 驱动程序的 CPU 使用率从大约 45% 下降到 12%,每个新客户端增加了大约 3%。