[譯] 再看 Flask 視頻流

大約三年前,我在這個名爲 Video Streaming with Flask 的博客上寫了一篇文章,其中我提出了一個很是實用的流媒體服務器,它使用 Flask 生成器視圖函數將 Motion-JPEG 流傳輸到 Web 瀏覽器。在那片文章中,個人意圖是展現簡單而實用的流式響應,這是 Flask 中一個鮮爲人知的特性。html

那篇文章很是受歡迎,倒並非由於它教會了讀者如何實現流式響應,而是由於不少人都但願實現流媒體視頻服務器。不幸的是,當我撰寫文章時,個人重點不在於建立一個強大的視頻服務器因此我常常收到讀者的提問及尋求建議的請求,他們想要將視頻服務器用於實際應用程序,但很快發現了它的侷限性。前端

回顧:使用 Flask 的視頻流

我建議您閱讀原始文章以熟悉個人項目。簡而言之,這是一個 Flask 服務器,它使用流式響應來提供從 Motion JPEG 格式的攝像機捕獲的視頻幀流。這種格式很是簡單,雖然並非最有效的,它具備如下優勢:全部瀏覽器都原生支持它,無需任何客戶端腳本。出於這個緣由,它是安防攝像機使用的一種至關常見的格式。爲了演示服務器,我使用相機模塊爲樹莓派編寫了一個相機驅動程序。對於那些沒有沒有樹莓派,只有手持相機的人,我還寫了一個模擬的相機驅動程序,它能夠傳輸存儲在磁盤上的一系列 jpeg 圖像。python

僅在有觀看者時運行相機

人們不喜歡的原始流媒體服務器的一個緣由是,當第一個客戶端鏈接到流時,從樹莓派的攝像頭捕獲視頻幀的後臺線程就開始了,但以後它永遠不會中止。處理此後臺線程的一種更有效的方法是僅在有查看者的狀況下使其運行,以便在沒有人鏈接時能夠關閉相機。android

我剛剛實施了這項改進。這個想法是,每次客戶端訪問視頻幀時,都會記錄該訪問的當前時間。相機線程檢查此時間戳,若是發現它超過十秒,則退出。經過此更改,當服務器在沒有任何客戶端的狀況下運行十秒鐘時,它將關閉其相機並中止全部後臺活動。一旦客戶端再次鏈接,線程就會從新啓動。ios

如下是對這項改進的簡要說明:git

class Camera(object):
    # ...
    last_access = 0  # 最後一個客戶端訪問相機的時間

    # ...

    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):
                # ...
                # 若是沒有任何客戶端訪問視屏幀
                # 10 秒鐘以後中止線程
                if time.time() - cls.last_access > 10:
                    break
        cls.thread = None
複製代碼

簡化相機類

不少人向我提到的一個常見問題是很難添加對其餘相機的支持。我爲樹莓派實現的 Camera 類至關複雜,由於它使用後臺捕獲線程與相機硬件通訊。github

爲了使它更容易,我決定將對於幀的全部後臺處理的通用功能移動到基類,只留下從相機獲取幀以在子類中實現的任務。模塊 base_camera.py 中的新 BaseCamera 類實現了這個基類。如下是這個通用線程的樣子:flask

class BaseCamera(object):
    thread = None  # 從攝像機讀取幀的後臺線程
    frame = None  # 後臺線程將當前幀存儲在此
    last_access = 0  # 最後一個客戶端訪問攝像機的時間
    # ...

    @staticmethod
    def frames():
        """Generator that returns frames from the camera."""
        raise RuntimeError('Must be implemented by subclasses.')

    @classmethod
    def _thread(cls):
        """Camera background thread."""
        print('Starting camera thread.')
        frames_iterator = cls.frames()
        for frame in frames_iterator:
            BaseCamera.frame = frame

            # 若是沒有任何客戶端訪問視屏幀
            # 10 秒鐘以後中止線程
            if time.time() - BaseCamera.last_access > 10:
                frames_iterator.close()
                print('Stopping camera thread due to inactivity.')
                break
        BaseCamera.thread = None
複製代碼

這個新版本的樹莓派的相機線程使用了另外一個生成器而變得通用了。線程指望 frames() 方法(這是一個靜態方法)成爲一個生成器,這個生成器在特定的不一樣攝像機的子類中實現。迭代器返回的每一個項目必須是 jpeg 格式的視頻幀。後端

如下展現的是返回靜態圖像的模擬攝像機如何適應此基類:瀏覽器

class Camera(BaseCamera):
    """模擬相機的實現過程,將      文件1.jpg,2.jpg和3.jpg造成的重複序列以每秒一幀的速度以流式文件的形式傳輸。"""
    imgs = [open(f + '.jpg', 'rb').read() for f in ['1', '2', '3']]

    @staticmethod
    def frames():
        while True:
            time.sleep(1)
            yield Camera.imgs[int(time.time()) % 3]
複製代碼

注意在這個版本中,frames()生成器如何經過簡單地在幀之間休眠來造成每秒一幀的速率。

經過從新設計,樹莓派相機的相機子類也變得更加簡單:

import io
import picamera
from base_camera import BaseCamera

class Camera(BaseCamera):
    @staticmethod
    def frames():
        with picamera.PiCamera() as camera:
            # let camera warm up
            time.sleep(2)

            stream = io.BytesIO()
            for foo in camera.capture_continuous(stream, 'jpeg', use_video_port=True):
                # return current frame
                stream.seek(0)
                yield stream.read()

                # reset stream for next frame
                stream.seek(0)
                stream.truncate()
複製代碼

OpenCV 相機驅動

不少用戶抱怨他們沒法訪問配備相機模塊的樹莓派,所以除了模擬相機以外,他們沒法嘗試使用此服務器。如今添加相機驅動程序要容易得多,我想要一個基於 OpenCV 的相機,它支持大多數 USB 網絡攝像頭和筆記本電腦相機。這是一個簡單的相機驅動程序:

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:
            # 讀取當前幀
            _, img = camera.read()

            # 編碼成一個 jpeg 圖片而且返回
            yield cv2.imencode('.jpg', img)[1].tobytes()
複製代碼

使用此類,將使用您系統檢測到的第一臺攝像機。若是您使用的是筆記本電腦,這多是您的內置攝像頭。若是要使用此驅動程序,則須要爲 Python 安裝 OpenCV 綁定:

$ pip install opencv-python
複製代碼

相機選擇

該項目如今支持三種不一樣的攝像頭驅動程序:模擬、樹莓派和 OpenCV。爲了更容易選擇使用哪一個驅動程序而沒必要編輯代碼,Flask 服務器查找 CAMERA 環境變量以瞭解要導入的類。此變量能夠設置爲 piopencv,若是未設置,則默認使用模擬攝像機。

實現它的方式很是通用。不管 CAMERA 環境變量的值是什麼,服務器都但願驅動程序位於名爲 camera_ $ CAMERA.py 的模塊中。服務器將導入該模塊,而後在其中查找 Camera類。邏輯實際上很是簡單:

from importlib import import_module
import os

# import camera driver
if os.environ.get('CAMERA'):
    Camera = import_module('camera_' + os.environ['CAMERA']).Camera
else:
    from camera import Camera
複製代碼

例如,要從 bash 啓動 OpenCV 會話,你能夠執行如下操做:

$ CAMERA=opencv python app.py
複製代碼

使用 Windows 命令提示符,你能夠執行如下操做:

$ set CAMERA=opencv
$ python app.py
複製代碼

性能優化

在另外幾回觀察中,咱們發現服務器消耗了大量的 CPU。其緣由在於後臺線程捕獲幀與將這些幀回送到客戶端的生成器之間沒有同步。二者都儘量快地運行,而不考慮另外一方的速度。

一般,後臺線程儘量快地運行是有道理的,由於你但願每一個客戶端的幀速率儘量高。可是你絕對不但願向客戶端提供幀的生成器以比生成幀的相機更快的速度運行,由於這意味着將重複的幀發送到客戶端。雖然這些重複項不會致使任何問題,但它們除了增長 CPU 和網絡負載以外沒有任何好處。

所以須要一種機制,經過該機制,生成器僅將原始幀傳遞給客戶端,而且若是生成器內的傳送回路比相機線程的幀速率快,則生成器應該等待直到新幀可用,因此它應該自行調整以匹配相機速率。另外一方面,若是傳送回路以比相機線程更慢的速率運行,那麼它在處理幀時永遠不該該落後,而應該跳過某些幀以始終傳遞最新的幀。聽起來很複雜吧?

我想要的解決方案是,當新幀可用時,讓相機線程信號通知生成器運行。而後,生成器能夠在它們傳送下一幀以前等待信號時阻塞。在查看同步單元時,我發現 threading.Event 是匹配此行爲的函數。因此,基本上每一個生成器都應該有一個事件對象,而後攝像機線程應該發出信號通知全部活動事件對象,以便在新幀可用時通知全部正在運行的生成器。生成器傳遞幀並重置其事件對象,而後等待它們再次進行下一幀。

爲了不在生成器中添加事件處理邏輯,我決定實現一個自定義事件類,該事件類使用調用者的線程 id 爲每一個客戶端線程自動建立和管理單獨的事件。說實話,這有點複雜,但這個想法來自於 Flask 的上下文局部變量是如何實現的。新的事件類稱爲 CameraEvent,並具備 wait()set()clear() 方法。在此類的支持下,能夠將速率控制機制添加到 BaseCamera 類:

class CameraEvent(object):
    # ...

class BaseCamera(object):
    # ...
    event = CameraEvent()

    # ...

    def get_frame(self):
        """返回相機的當前幀."""
        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 相關聯的事件,以便每一個生成器線程能夠以它本身的速度運行。相機線程調用的 set() 方法向分配給全部客戶端的事件對象發送信號,而且還將刪除未提供服務的任何事件,由於這意味着與這些事件關聯的客戶端已關閉,客戶端自己也不存在了。您能夠在 GitHub 倉庫中看到 CameraEvent 類的實現。

爲了讓您瞭解性能改進的程度,請看一下,模擬相機驅動程序在此更改以前消耗了大約 96% 的 CPU,由於它始終以遠高於每秒生成一幀的速率發送重複幀。在這些更改以後,相同的流消耗大約 3% 的CPU。在這兩種狀況下,都只有一個客戶端查看視頻流。OpenCV 驅動程序從單個客戶端的大約 45% CPU 下降到 12%,每一個新客戶端增長約 3%。

部署 Web 服務器

最後,我認爲若是您打算真正使用此服務器,您應該使用比 Flask 附帶的服務器更強大的 Web服務器。一個很好的選擇是使用 Gunicorn:

$ pip install gunicorn
複製代碼

有了 Gunicorn,您能夠按以下方式運行服務器(請記住首先將 CAMERA 環境變量設置爲所選的攝像頭驅動程序):

$ gunicorn --threads 5 --workers 1 --bind 0.0.0.0:5000 app:app
複製代碼

--threads 5 選項告訴 Gunicorn 最多處理五個併發請求。這意味着設置了這個值以後,您最多能夠同時擁有五個客戶端來觀看視頻流。--workers 1 選項將服務器限制爲單個進程。這是必需的,由於只有一個進程能夠鏈接到攝像頭以捕獲幀。

您能夠增長一些線程數,但若是您發現須要大量線程,則使用異步框架比使用線程可能會更有效。能夠將 Gunicorn 配置爲使用與 Flask 兼容的兩個框架:gevent 和 eventlet。爲了使視頻流服務器可以使用這些框架,相機後臺線程還有一個小的補充:

class BaseCamera(object):
    # ...
   @classmethod
    def _thread(cls):
        # ...
        for frame in frames_iterator:
            BaseCamera.frame = frame
            BaseCamera.event.set()  # send signal to clients
            time.sleep(0)
            # ...
複製代碼

這裏惟一的變化是在攝像頭捕獲循環中添加了 sleep(0)。這對於 eventlet 和 gevent ß都是必需的,由於它們使用協做式多任務處理。這些框架實現併發的方式是讓每一個任務經過調用執行網絡 I/O 的函數或顯式執行以釋放 CPU。因爲此處沒有 I/O,所以執行 sleep 函數以實現釋放 CPU 的目的。

如今您可使用 gevent 或 eventlet worker 運行 Gunicorn,以下所示:

$ CAMERA=opencv gunicorn --worker-class gevent --workers 1 --bind 0.0.0.0:5000 app:app
複製代碼

這裏的 --worker-class gevent 選項配置 Gunicorn 使用 gevent 框架(你必須用pip install gevent安裝它)。若是你願意,也可使用 --worker-class eventlet。如上所述,--workers 1 限制爲單個處理過程。Gunicorn 中的 eventlet 和 gevent workers 默認分配了一千個併發客戶端,因此這應該超過了這種服務器可以支持的客戶端數量。

結論

上述全部更改都包含在 GitHub倉庫 中。我但願你經過這些改進以得到更好的體驗。

在結束以前,我想提供有關此服務器的其餘問題的快速解答:

  • 如何設定服務器以固定的幀速率運行?配置您的相機以該速率傳送幀,而後在相機傳送回路的每次迭代期間休眠足夠的時間以便以該速率運行。

  • 如何提升幀速率?我在此描述的服務器,以儘量快的速率提供視頻幀。若是您須要更好的幀速率,能夠嘗試將相機配置成更小的視頻幀。

如何添加聲音?那真的很難。Motion JPEG 格式不支持音頻。你將須要使用單獨的流傳輸音頻,而後將音頻播放器添加到HTML頁面。即便你設法完成了全部的操做,音頻和視頻之間的同步也不會很是準確。

如何將流保存到服務器上的磁盤中?只需將 JPEG 文件的序列保存在相機線程中便可。爲此,你可能但願移除在沒有查看器時結束後臺線程的自動機制。

如何將播放控件添加到視頻播放器? Motion JPEG 不容許用戶進行交互式操做,但若是你想要這個功能,只須要一點點技巧就能夠實現播放控制。若是服務器保存全部 jpeg 圖像,則能夠經過讓服務器一遍又一遍地傳送相同的幀來實現暫停。當用戶恢復播放時,服務器將必須提供從磁盤加載的「舊」圖像,由於如今用戶處於 DVR 模式而不是實時觀看流。這多是一個很是有趣的項目!

以上就是本文的全部內容。若是你有其餘問題,請告訴咱們!

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索