[譯] 再看 Flask 視訊流

趙小生發表於2019-01-07

[譯] 再看 Flask 視訊流

大約三年前,我在這個名為 Video Streaming with Flask 的部落格上寫了一篇文章,其中我提出了一個非常實用的流媒體伺服器,它使用 Flask 生成器檢視函式將 Motion-JPEG 流傳輸到 Web 瀏覽器。在那片文章中,我的意圖是展示簡單而實用的流式響應,這是 Flask 中一個不為人知的特性。

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

回顧:使用 Flask 的視訊流

我建議您閱讀原始文章以熟悉我的專案。簡而言之,這是一個 Flask 伺服器,它使用流式響應來提供從 Motion JPEG 格式的攝像機捕獲的視訊幀流。這種格式非常簡單,雖然並不是最有效的,它具有以下優點:所有瀏覽器都原生支援它,無需任何客戶端指令碼。出於這個原因,它是安防攝像機使用的一種相當常見的格式。為了演示伺服器,我使用相機模組為樹莓派編寫了一個相機驅動程式。對於那些沒有沒有樹莓派,只有手持相機的人,我還寫了一個模擬的相機驅動程式,它可以傳輸儲存在磁碟上的一系列 jpeg 影象。

僅在有觀看者時執行相機

人們不喜歡的原始流媒體伺服器的一個原因是,當第一個客戶端連線到流時,從樹莓派的攝像頭捕獲視訊幀的後臺執行緒就開始了,但之後它永遠不會停止。處理此後臺執行緒的一種更有效的方法是僅在有檢視者的情況下使其執行,以便在沒有人連線時可以關閉相機。

我剛剛實施了這項改進。這個想法是,每次客戶端訪問視訊幀時,都會記錄該訪問的當前時間。相機執行緒檢查此時間戳,如果發現它超過十秒,則退出。通過此更改,當伺服器在沒有任何客戶端的情況下執行十秒鐘時,它將關閉其相機並停止所有後臺活動。一旦客戶端再次連線,執行緒就會重新啟動。

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

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 類相當複雜,因為它使用後臺捕獲執行緒與相機硬體通訊。

為了使它更容易,我決定將對於幀的所有後臺處理的通用功能移動到基類,只留下從相機獲取幀以在子類中實現的任務。模組 base_camera.py 中的新 BaseCamera 類實現了這個基類。以下是這個通用執行緒的樣子:

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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章