ラズパイ4BとUSBカメラのMJPGストリーミング中でもマルチプロセスでWeb上のボタン操作できるようにしてみた

ラズパイ4BとUSBカメラのMJPGストリーミング中でもマルチプロセスでWeb上のボタン操作できるようにしてみた Raspberry Pi (ラズパイ)

今回は前回記事に引き続きラズパイ4BとUSBカメラを使ったMJPG(Motion JPEG)のブラウザストリーミングですが、新たにマルチプロセスを使ってストリーミング中にWeb画面のボタンで画角サイズを変更したり、ストリーミングを一時停止したり、Bottleサーバーをシャットダウンさせるなど、双方向コントロールに挑戦してみたいと思います。

スポンサーリンク

ネット検索しても、なかなかPython Bottleサーバーを使ったMJPG(Motion JPEG)ストリーミングストリーミングで双方向通信の具体例が無く、かなりの時間を割いて試行錯誤しました。
そして、今回、ようやく納得のいく動作ができました。
まずは以下の動画をご覧ください。

いい感じでストリーミング中にボタン操作で画角変更できていますよね。
しかも、640×480 pixel で約30fps出せています。ここまでできれば十分です。

そして個人的に特に気に入っているのは、ボタン操作でBottleサーバーのシャットダウンができたことです。
後で詳しく述べますが、Python Bottleとmultiprocessingを使う時、Bottleサーバーの強制シャットダウンがとても面倒だったんですよね。
VSCodeのターミナルで強制終了用のキーを打っても、複数プロセスが裏で動いていて、killコマンドで強制終了させなければならなかったんです。

でも、今回、Web上のボタンで全てのプロセスを一気に強制終了できたのはウレシイですね。

ということで、今回やった実験を紹介してみようと思います。

なお、いつも言っていることですが、私はラズパイもプログラミングもド素人です。
何か誤り等がありましたら、コメント投稿でご連絡いただけると助かります。

    【目次】

  1. 使ったもの
  2. 自分のラズパイ4BおよびPython環境
  3. マルチプロセスとマルチスレッド
  4. multiprocessingを使ったプロセス間の値の共有
  5. ストリーミング中にBottleサーバーを強制終了させるには
  6. Pythonコード(shutdownボタンのみの場合)
  7. Pythonコード(画角サイズ変更、一時停止、shutdownボタン有り)
  8. まとめ

1.使ったもの

Raspberry Pi 4 model B

(図01-01)

Raspberry Pi 4 model B 写真

Raspberry Pi 4 model B 写真

以前、以下の記事で紹介した、OKdo製のRaspberry Pi 4 Model B 4GBの全部入りセットを使っています。

ラズパイ(Raspberry Pi 4 Model B)をSSDブートにする(備忘録)

USB-SSD

これも以前のこちらの記事で紹介した、スティック型のUSB-SSDを使っています。

BUFFALO (バッファロー)
【名称】 SSD 外付け 250GB 超小型 コンパクト ポータブル PS5/PS4対応(メーカー動作確認済) USB3.2Gen1 ブラック
【型式】 SSD-PUT250U3-B/N

USB3.0ハブ(外部電源供給)

外部電源供給可能なUSBハブを使ったのは以前のこちらの記事でも説明したように、ラズパイ4BのUSBポートでは電力供給に難がありそうだったからという理由です。

エレコム U3H-T410SBK

USBカメラ

Logicool製 C920n を使いました。
オートフォーカス、フルHD、デュアルマイク内蔵です。

このUSBカメラを選んだ理由は前回記事を参照してください。

パソコン環境

Windows 10
Visual Sutudio Code (VSCode) SSHリモート

ルーター環境

一般的な有線LAN環境です。
また、スマホとのストリーミングはローカルエリアのWiFiを使っています。

2.自分のラズパイ4BおよびPython環境

まずは現在の自分のラズパイ4BのブートローダーやOSの環境、そしてPythonの環境を示しておきます。

ラズパイ4Bブートローダーバージョン

ラズパイ4Bのブートローダーバージョンは以下です。

$ sudo rpi-eeprom-update
BOOTLOADER: up to date
   CURRENT: 2022年  1月 25日 火曜日 14:30:41 UTC (1643121041)
    LATEST: 2022年  1月 25日 火曜日 14:30:41 UTC (1643121041)
   RELEASE: default (/lib/firmware/raspberrypi/bootloader/default)
            Use raspi-config to change the release.

  VL805_FW: Dedicated VL805 EEPROM
     VL805: up to date
   CURRENT: 000138a1
    LATEST: 000138a1

OSバージョン

次に、OSのUbuntu Serverのバージョンは以下です。

$ cat /etc/os-release
PRETTY_NAME="Ubuntu 22.04.2 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.2 LTS (Jammy Jellyfish)"
VERSION_CODENAME=jammy
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=jammy

カーネルバージョン

次にカーネルのバージョンは以下です。

$ cat /proc/version
Linux version 5.15.0-1029-raspi (buildd@bos02-arm64-006) (gcc (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0, GNU ld (GNU Binutils for Ubuntu) 2.38) #31-Ubuntu SMP PREEMPT Sat Apr 22 12:26:40 UTC 2023

Pythonバージョン

pyenvを使っていて、Python 3.10.10 です。
Python環境は以前の以下の記事で紹介しています。

ラズパイ(Ubuntu Server)とVSCodeでPythonプログラミング環境構築する(備忘録)

pyenv バージョン

$ pyenv --version
pyenv 2.3.18

Python各パッケージバージョン

pipでインストールしたPythonパッケージのバージョンは以下です。

$ pip list --format=freeze
bottle==0.12.25
numpy==1.24.3
opencv-python-headless==4.7.0.72
pip==23.1.2
setuptools==67.8.0
somepackage==1.2.3
wheel==0.40.0

3.マルチプロセスとマルチスレッド

このブログでは以前、ESP32やM5Stackを扱っていた時、マルチタスク(マルチプロセス)でC/C++を走らせてカメラストリーミングしたことがありました。

その経験から、MJPGストリーミング中のブラウザからのボタン操作は、CPUを複数使って並列処理、つまりマルチプロセスを使うしかないと思っていました。
例えば以下の感じでプロセス1とプロセス2を並列処理します。

【プロセス1】
ポート番号8080で、ボタン操作で画角サイズ変更、一時停止、シャットダウンなど、常時ブラウザとのHTTP通信可能な状態にする。

【プロセス2】
ポート番号8081で Motion JPEG over HTTP を走らせる。

ただ、Pythonにはマルチプロセスとマルチスレッドという処理があって、どっちを使ったらよいのか最初はよく分かりませんでした。
マルチプロセスならば multiprocessing を使います。マルチスレッドならば threading です。
その違いについては以下のサイトが分かり易かったです。
参考: https://qiita.com/Jungle-King/items/1d332a91647a3d996b82

要はラズパイのマルチコアCPUを使って完全な並列処理させるなら、マルチプロセスを使うということです。
今回の実験についてはマルチスレッドでも可能だと思いますが、せっかくクアッドコアCPUのラズパイ4Bを使っているので、マルチプロセスを使うことにしました。

threading については一度試してみましたが、Bottleフレームワークと併用するとうまく動いてくれませんでした。結局、multiprocessing が今回の実験にマッチしていたということです。

ただ、Bottleフレームワーク自体がマルチプロセスで動いているっぽく、メイン処理でBottleの run 関数を走らせると、別プロセスで動かしているUSBカメラ制御と競合して、意味不明な挙動になるということがありました。
また、multiprocessingをよく理解しないまま、プロセス間で変数の共有をしようと、global変数を使いましたが、当然、意図したとおりに動かず、どっぷり沼にハマりました。
結局、プロセス間の変数の共有は、次で説明するValueQueueを使えばよいことがわかって解決できたんです。

4.multiprocessing を使ったプロセス間の値の共有

Pythonの multiprocessing を使うときに注意しなければならないのは、グローバル変数等でプロセス間の変数共有ができないということです。
Arduino core ESP32などのC/C++ならばグローバル変数で殆ど共有できたんですけど、Python の multiprocessing ではそう簡単に行きません。
例えば以下のようなコードです。

from multiprocessing import Process
import time

x1 = 1
x2 = 1

def test1():
    global x1
    while True:
        x1 += 1
        time.sleep(1)

def test2():
    global x2
    while True:
        x2 -= 1
        print("x1={}, x2={}".format(x1, x2))
        time.sleep(1)

p1 = Process(target=test1)
p2 = Process(target=test2)

p1.start()
p2.start()

これを実行すると、以下のようになります。

x1=1, x2=0
x1=1, x2=-1
x1=1, x2=-2
x1=1, x2=-3
x1=1, x2=-4
x1=1, x2=-5
x1=1, x2=-6
x1=1, x2=-7
x1=1, x2=-8
x1=1, x2=-9
.....etc

これだと、p2プロセスのx1の値が変化していません。
最初に定義した1だけが代入されている状態で、p1プロセスで加算しても反映されていないわけです。
これでは他のプロセスでボタンが押されたらメインプロセスが停止するという処理が実現できないです。
では、どうするかというと、multiprocessing のValueを使えば良いのです。

4-01. Value による共有

異なるプロセス間で変数を共有するには、Valueを使います。
公式の以下のサイトを参照ください。

https://docs.python.org/ja/3/library/multiprocessing.html#sharing-state-between-processes

例えば、以下のようなコードを組んでみます。

from multiprocessing import Process, Value
import time

x1 = Value("i", 1)
x2 = Value("i", 1)

def test1():
    while True:
        x1.value += 1
        time.sleep(1)

def test2():
    while True:
        x2.value -= 1
        print("x1={}, x2={}".format(x1.value, x2.value))
        time.sleep(1)

p1 = Process(target=test1)
p2 = Process(target=test2)

p1.start()
p2.start()

この実行結果は以下のようになります。

x1=2, x2=0
x1=3, x2=-1
x1=4, x2=-2
x1=5, x2=-3
x1=6, x2=-4
x1=7, x2=-5
x1=8, x2=-6
x1=9, x2=-7
x1=10, x2=-8
x1=11, x2=-9
x1=12, x2=-10
x1=13, x2=-11
x1=14, x2=-12
x1=15, x2=-13

見事、意図した結果になりましたね。
異なるプロセス間で変数が共有できています。
これを使えばいけそうです。

ただ、この場合気を付けなければならないのは、別のプロセスで値を書き込んでいる最中に別のプロセスで値を読み込むような状況が起きる可能性があります。それは出来るだけ避けたいですよね。
その解決策は、次で説明するQueueを使えばいいんです。

4-02. Queue による画像共有は動画ストリーミングに最適かもね

Queueを使うと、本プロセスでqueに値を書き込み終わるまで別プロセスからqueに入っている値の読み込みを開始できず、その手前でプログラムが自動停止(ブロック)してくれるんです。これは便利ですね。threadingの動作とちょっと似ていますね。

例えば、以下のコードです。

from multiprocessing import Process, Queue
import time

def func1():
    while True:
        a = que1.get()
        print("proc1 a = ", a)
        time.sleep(0.2)
        if a > 10:
            break

def func2():
    a = 0
    while True:
        que1.put(a)
        print("  proc2 a = ", a)
        a += 1
        time.sleep(1)
        if a > 10:
            break

que1 = Queue(maxsize=1)

proc1 = Process(target=func1)
proc2 = Process(target=func2)

proc1.start()
proc2.start()

proc1.join()
proc2.join()
proc1.terminate()
proc2.terminate()
proc1.close()
proc2.close()

これを実行すると、以下のようになります。

  proc2 a =  0
proc1 a =  0
  proc2 a =  1
proc1 a =  1
  proc2 a =  2
proc1 a =  2
  proc2 a =  3
proc1 a =  3
  proc2 a =  4
proc1 a =  4
  proc2 a =  5
proc1 a =  5
  proc2 a =  6
proc1 a =  6
  proc2 a =  7
proc1 a =  7
  proc2 a =  8
proc1 a =  8
  proc2 a =  9
proc1 a =  9
  proc2 a =  10
proc1 a =  10

このように、異なるプロセス間でもque1によって値は共有できていますね。
そして、マルチプロセスでfunc1とfunc2を並列動作させると、func1のループは0.2秒で繰り返していて速いのに対し、func2のループは1秒と遅いです。なのに、結果は1秒毎に表示されます。
つまり、func1の a = que1.get() のところで、func2の que1.put(a) に値が入力されるまで進行が停止しているということです。
この que1 には、que1 = Queue(maxsize=1) で定義されているので、データは1個分しか入れることが出来ません。よって、データを1個入れてしまったら、それを取り出す(読み出す)まではque1.put に書き込むことが出来ないのです。

逆に、func1とfunc2のtime.sleepの値を逆にしてみてください。
それを実行すると全く同じ結果になると思います。
つまり、que1.get()でデータを取り出す時間が遅いと、que1.put(a)に値が入力できないので、自動的に待たされることが分かると思います。

カメラストリーミングの場合はこの挙動を利用して、プロセス1でカメラを動かして画像を出力させ、プロセス2でWebへ出力するようにし、que1に画像1枚を書き込み終わるまでWebへ出力するのを自動的に抑制するようなプログラミングができるわけです。
このようなQueueの動作は、カメラ動画をブラウザでストリーミングするには個人的に好都合だと思いました。

ところで、動画ストリーミングの場合、que待ち状態のフレームは破棄して、次の画像を即出力したい場合ありますよね。
その場合、以下のように、put関数のオプションをFalseにして、try~exceptでくくるという方式がありました。

try:
    que1.put(a, False)
except:
    print(".", end="")

公式のこちらのサイトによると、Trueの場合、queに空きがない場合は延々と待たされますが、Falseにした場合、queに空きがないと queue.Full 例外が発生します。
try~exceptで括らずに、ただ単に que1.put(a, False) だけだとターミナルに例外メッセージが表示されてプログラムが停止してしまいますが、try~except で括ることによって、例外が発生してもプログラムの進行を止めずにターミナルに任意のメッセージを表示させて続行させることができるようになります。

例えば、先のコードを修正して以下のコードにしてみます。

from multiprocessing import Process, Queue
import time

def func1():
    while True:
        a = que1.get()
        print("proc1 a = ", a)
        time.sleep(1)
        if a > 10:
            break

def func2():
    a = 0
    while True:
        try:
            que1.put(a, False)
        except:
            print(".", end="")

        print("  proc2 a = ", a)
        a += 1
        time.sleep(0.2)
        if a > 10:
            break

que1 = Queue(maxsize=1)

proc1 = Process(target=func1)
proc2 = Process(target=func2)

proc1.start()
proc2.start()

proc1.join()
proc2.join()
proc1.terminate()
proc2.terminate()
proc1.close()
proc2.close()

実行結果は以下のようになります。

  proc2 a =  0
proc1 a =  0
  proc2 a =  1
.  proc2 a =  2
.  proc2 a =  3
.  proc2 a =  4
proc1 a =  1
  proc2 a =  5
.  proc2 a =  6
.  proc2 a =  7
.  proc2 a =  8
.  proc2 a =  9
proc1 a =  5
  proc2 a =  10
proc1 a =  10

このように、例外が発生したところは、ターミナルに”.”を表示させて、func2のWhileループを続行させていることがわかります。
これを使えば、動画ストリーミングの場合にfunc1の遅れによってqueの空きが無くなっても、フレーム画像を破棄してループ動作を進めることができますね。
これは使えますよ!!!

5.ストリーミング中にBottleサーバーを強制終了させるには

前回記事のPython-BottleサーバーでMJPG over HTTP ストリーミング中に走らせているプログラムを強制停止させるには、VSCodeターミナルで「Ctrl」+「C」キーを打って強制割り込みさせて終了させるしかありませんでした。
ならば、Web画面のボタンで終了させるにはどうしたら良いのでしょうか?

5-01. ターミナルでpsコマンドやkillコマンドを打って強制終了させるのは面倒

Python のmultiprocessing を使って並列処理をする時、「Ctrl」+「C」キーで強制終了しても、まだ裏で別のプロセスが動いていることがありました。
その場合は以下のコマンドを打って、裏で動いているプロセスのIDを調べます。

ps aux | grep python

すると、以下のように表示されます。

$ ps aux | grep python
root         801  0.2  0.4  33668 18064 ?        Ss   19:49   0:00 /usr/bin/python3 /usr/bin/networkd-dispatcher --run-startup-triggers
root         901  0.2  0.5 110632 20516 ?        Ssl  19:49   0:00 /usr/bin/python3 /usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal
xxxxxx     1842  2.5  0.7 187928 30668 ?        Sl   19:50   0:02 /home/xxxxxx/.pyenv/versions/3.10.10/bin/python3.10 /home/xxxxxx/.vscode-server/extensions/ms-python.isort-2022.8.0/bundled/tool/server.py
xxxxxx     1873 33.8  7.3 1048568 286872 ?      Sl   19:50   0:32 /home/xxxxxx/.vscode-server/bin/123456789abc/node /home/xxxxxx/.vscode-server/extensions/ms-python.vscode-pylance-2023.5.50/dist/server.bundle.js --cancellationReceive=file:277c5b55cb5ba5bc5b77 --node-ipc --clientProcessId=1361
xxxxxx     3167  4.1  1.3 341388 50460 pts/0    S    19:51   0:01 /home/xxxxxx/.pyenv/versions/3.10.10/bin/python3.10 /home/xxxxxx/py/test01.py
xxxxxx     3208 28.3  1.0 618996 40436 pts/0    Sl   19:51   0:07 /home/xxxxxx/.pyenv/versions/3.10.10/bin/python3.10 /home/xxxxxx/py/test01.py
xxxxxx     3475  0.0  0.0   7084  2088 pts/0    R+   19:52   0:00 grep --color=auto python

これで、ユーザー名xxxxxxが test01.py というPythonコードを動かしているので、ユーザー名のすぐ右隣の数値が裏で動いているプロセスID(PID)です。ここでは、3167と3208になります。
これを以下のようにkillコマンドで強制終了させます。

kill 3167 3208

multiprocessing を使ったプログラミングを試していると、このようなことを何度も行わねばならなかった訳です。とても面倒ですよね~。

そこで、せっかくブラウザのWeb表示でカメラストリーミングさせているのだから、Web画面の操作で停止したいですね。

その場合、先ほど説明したmultiprocessingを使って、ストリーミングとは別のプロセスで動かしているコントロール用のプロセスでブラウザからshutdownというURLにアクセスし、それを検知したら、それぞれのプロセスを停止してクローズさせます。例えば、以下の感じです。

proc1.terminate()
proc1.join()
proc1.close()

proc2.terminate()
proc2.join()
proc2.close()

これで裏で動いているプロセスを停止できますが、Pythonのメインプログラムは停止できません。
これをWeb画面から停止させるにはどうすれば良いでしょうか?

5-02. Pythonメインプログラムを終了させるには

では、Web画面の操作で自身のPython Bottleサーバーのメインプロセスを停止させるにはどうすれば良いかと言うと、以下のサイトに答えがありました。

https://kapibara-sos.net/archives/208

こんなコードです。

import os
import signal
os.kill(os.getpid(), signal.SIGTERM)

ブラウザからshutdownのアクセスが来たら、このコードを実行させれば良いわけです。これは便利ですね。

このコードの意味はまだよくわかっていませんが、たぶん、以下の公式記事
https://docs.python.org/ja/3/library/os.html
によれば、メインプロセスのPIDを取得して、そのPIDにterminate(プロセス終了)シグナルを送ってメインプロセスを終了させるということなのだと思われます。

でも、ちょっと納得いかないのは、普通のプログラムならメインの処理が終わると終了するのに、multiprocessing で複数のプロセスを起動させるとなぜかメインプロセスが終了しないことです。
これは謎です。

6.Pythonコード(shutdownボタンのみの場合)

では、以上を踏まえて、Python の multiprocessing を使って、MJPG(Motion JPEG)ストリーミング中にWeb画面のボタン操作で全プロセスを終了させるコードを作ってみました。
とりあえず、以下のコードです。

#!/usr/bin/env python3.10
# MJPGストリーミング中にShutdownボタンを押すとPythonサーバーが完全終了するコード

import cv2
from bottle import route, run, response
import time
from multiprocessing import Process, Queue, Value

# ※ufwファイアウォールで、ポート番号を8080と8081を開放しておくこと

def bt():
    host_url = "192.168.0.18"
    control_port = 8080
    stream_port = 8081
    proc1 = None
    proc2 = None
    que_img = Queue(maxsize=1)
    cam_fps = Value("i", 0)
    goShutdown = Value("b", False)

    @route('/', method="GET")
    def top():
        nonlocal proc1, proc2

        # USBカメラのハードウェア制御は、メインスレッドで動かすとBottleのrun関数と競合するっぽいので、Bottle関数内からUSBカメラプロセスを作動させるとうまくいく。
        proc1 = Process(target=camera_capture, args=(que_img, cam_fps, ))
        proc1.start()
        # MJPG over HTTP用の8081ポートは別プロセスでBottleフレームワークを動かしておく。
        proc2 = Process(target=run, kwargs={'host': host_url, 'port': stream_port, 'reloader': True, 'debug': True})
        proc2.start()

        return "<img src='http://{host_url}:{stream_port}/streaming'/><br>"\
               "<button type='button' onclick='location.href=\""\
               "http://{host_url}:{control_port}/shutdown\"'>Shutdown(サーバー停止)"\
               "</button>".format(host_url=host_url, control_port=control_port, stream_port=stream_port)
    
    @route('/streaming')
    def streaming():

        # カメラ画像にフレームレート値テキストを書き込む関数
        def put_txt(img, fps, col_r, col_g, col_b, tn):
            cv2.putText(img,
                        text=str(fps).rjust(3) + ' fps',
                        org=(0, 50),
                        fontFace=cv2.FONT_HERSHEY_SIMPLEX,
                        fontScale=2.0,
                        color=(col_r, col_g, col_b),
                        thickness=tn,
                        lineType=cv2.LINE_4)

        response.set_header('Content-type', 'multipart/x-mixed-replace;boundary=--frame')

        fps = 0
        fps_count = 0
        fps_time = time.time()

        while not goShutdown.value:
            img = que_img.get()

            # フレームレート値テキスト(黒縁取り)をカメラ画像に上書きする
            put_txt(img, fps, 0, 0, 0, 5)
            put_txt(img, fps, 255, 255, 255, 2)

            # JPEG最高品質の場合
            ret2, jpeg = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 100]) 
            # ret2, jpeg = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 50]) # JPEG品質50の場合

            if not ret2:
                continue

            frame_data = jpeg.tobytes()
            # ブラウザへMJPG送信
            yield (b'--frame\r\n' + b'Access-Control-Allow-Origin: *\r\n' + b'Content-Type: image/jpeg\r\n\r\n' + frame_data + b'\r\n\r\n')
            
            # フレームレート計算
            if (time.time() - fps_time) >= 1:
                fps = fps_count
                fps_count = 0
                fps_time = time.time()
            else:
                fps_count += 1

    @route('/shutdown', method="GET")
    def shutdown_server():
        nonlocal proc1, proc2

        goShutdown.value = True

        time.sleep(0.5) # 0.5秒以上無いと確実に停止できないかも

        # USBカメラプロセスの停止
        proc1.terminate()
        proc1.join()
        proc1.close()

        yield "USBカメラを停止しました<br>"

        # MJPG over HTTPプロセスの停止
        proc2.terminate()
        proc2.join()
        proc2.close()

        yield "BottleサーバーをShutdownしました"

        # メインプロセスのPython Bottleサーバーを完全終了させる
        import os
        import signal
        os.kill(os.getpid(), signal.SIGTERM)

    # USBカメラ制御
    def camera_capture(que_img, cam_fps):
        try:
            cap = cv2.VideoCapture(0, cv2.CAP_V4L2)
        except TypeError:
            cap = cv2.VideoCapture(0)

        if cap.isOpened() is False:
            raise IOError

        print("Capture Set OK!")
        print("default camera_fps=", cap.get(cv2.CAP_PROP_FPS))

        #予め次のコマンドでUSBカメラの使用可能な解像度とフレームレートを確認しておく
        # v4l2-ctl --list-formats-ext

        cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
        cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
        cap.set(cv2.CAP_PROP_FPS, 30)

        cam_fps.value = int(cap.get(cv2.CAP_PROP_FPS))
        print("now camera_fps=", cam_fps.value)

        while not goShutdown.value:
            ret, img = cap.read()
            if not ret:
                continue

            # que_img.put(img, False) のようにオプションがFalseの場合、queが満杯だと例外 queue.Full が発生する。その場合以下のようにする
            try:
                que_img.put(img, False)
            except:
                print(".", end="") # Queが満杯の場合に表示させる。

        print("Released VideoCapture")
        cap.release()

    run(host=host_url, port=control_port, reloader=True, debug=True)

if __name__ == '__main__':
    
    bt()

【ザッと解説】

まず、前回記事でも説明したように、予めファイアウォール(ufw)でポート8080と8081を開放しておきます。

メインプロセスでは8080ポートで通信し、Topページ表示や画角サイズ変更、一時停止等のコントロール用専用とし、プロセス1(proc1)ではUSBカメラ制御専用とし、プロセス2(proc2)では8081ポートを使ってMJPGストリーミング専用としました。

最初はメインプロセスでUSBカメラを動かして、Bottleフレームワークを全てmultiprocessingのProcessで動かそうと思ったのですが、なぜかUSBカメラ制御とBottleがメインプロセスで競合しているっぽく、うまく動いてくれませんでした。
個人的な予想ですが、おそらくBottle自身がマルチプロセスで動いているせいなのかな? と、思いました。

ということで、メインプロセスでBottleによる8080ポートでTopページを表示させてから、別プロセスのproc1でUSBカメラを動かし、そして8081のポートでMJPGストリーミングをプロセスproc2で走らせています。そうするとうまく動いてくれました。
Bottleによるこのマルチプロセス手法はネットの情報では見つけられず、いろいろ試行錯誤しました結果できた方法ですが、我ながらよくできたと思いました。この方法が最善かどうかわかりませんが、もし、もっと良い方法があればコメント投稿で教えてください。

また、先ほど説明したValueを使ってプロセス間の値を共有し、フレームレート値を表示させたり、ストリーミング中にshutdown指令をbool値で受け取ったりしています。

そして、17行目では先ほど説明した Queue を使っています。maxsize=1 としているので、queに画像データは1枚分しか貯められないようにしています。複数枚画像を貯めてしまうと、遅延が大きくなり過ぎるので今回は1枚としています。
USBカメラ制御プロセスproc1実行中では、140行目にあるように que_img.put(img, False) で画像データを受け取るのを待ってから、proc2の動作でブラウザに画像を出力するようにしています。

では、このコードを走らせる前に、自分のルーターのファイアウォール等の設定を確認しておき、ラズパイと通信できるようにしておきます。
そしたら、このPythonコードを実行し、パソコンやスマホなどのブラウザを開き、URL入力欄に以下のように入力します。

http://192.168.0.18:8080

すると以下のように表示されればOKです。

(図06-01)

Pythonコードを実行して、Google Chromeブラウザでアクセスした画面

Pythonコードを実行して、Google Chromeブラウザでアクセスした画面

カメラ画像の下に「Shutdown (サーバー停止)」というボタンがあって、そこをタッチまたはクリックすると、マルチプロセスも終了して、ブラウザには以下のように表示されます。

(図06-02)

Shutdownボタンを押した後のブラウザ表示画面

Shutdownボタンを押した後のブラウザ表示画面

そして、VSCode(Visual Studio Code)のターミナルには、
Released VideoCapture
と表示されて、コマンドプロンプト状態になると思います。
つまり、メインプロセスも終了できたことになります。
これは、前節で説明したように、106~108行目でkillを使ってメインプロセスを終了させたわけです。

では、次はボタンで画角サイズ変更や一時停止や再開などができるようなコードを組んでみます。

7.Pythonコード(画角サイズ変更、一時停止、shutdownボタン有り)

では、次は最初に紹介した動画のような動作をするコードを作ってみます。
MJPG(Motion JPEG)ストリーミング中にWeb画面のボタンで画角サイズを変更したり、ストリーミングを一時停止および再開したり、Pythonサーバーをシャットダウンさせたりします。

まず、viewsフォルダにTop画面用の以下のHTMLファイルを作成します。
ファイル名は mjpg_multi_top.html としておきます。

<style>
    button{font-size: 2em;}
</style>
<div>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/size?width=1920&height=1080'">1920 x 1080</button>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/size?width=1280&height=960'">1280 x 960</button>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/size?width=800&height=600'">800 x 600</button>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/size?width=800&height=448'">800 x 448</button>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/size?width=640&height=480'">640 x 480</button><br>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/size?width=640&height=360'">640 x 360</button>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/size?width=320&height=240'">320 x 240</button>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/size?width=320&height=176'">320 x 176</button>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/size?width=160&height=120'">160 x 120</button>
</div>
<div>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/pause'">Pause(一時停止)</button>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/restart'">Re-Start(再開)</button>
    <button type="button" onclick="location.href='http://{{host_url}}:{{control_port}}/shutdown'">Server Shutdown</button>
</div>
<div style="font-size: 1.5em;">
画像サイズ:{{width}} × {{height}}<br>
カメラデバイス側のフレームレート:{{frame_rate}} fps<br>
</div>

二重波括弧内の変数は次のPythonコードから返された値が反映されるようになります。

では次にPythonコードです。
multiprocessing のValueとQueueを使ってプロセス間のデータ共有をしています。

#!/usr/bin/env python3.10
# MJPGストリーミング中にボタンで画角サイズ変更、一時停止、Shutdownなどのコントロールが可能なコード

import cv2
from bottle import route, run, response, request, template
import time
from multiprocessing import Process, Queue, Value

# ※ufwファイアウォールで、ポート番号を8080と8081を開放しておくこと

def bt():
    host_url = "192.168.0.18"
    control_port = 8080
    stream_port = 8081
    proc1 = None
    proc2 = None
    que_img = Queue(maxsize=1)
    que_fps = Queue(maxsize=1)
    img_width = Value("i", 640)
    img_height = Value("i", 480)
    cam_fps = Value("i", 0)
    isPause = Value("b", False)
    isChange_size = Value("b", False)
    goShutdown = Value("b", False)

    @route('/', method="GET")
    def top():
        nonlocal proc1, proc2

        isPause.value = False

        # USBカメラのハードウェア制御は、メインスレッドで動かすとBottleのrun関数と競合するっぽいので、Bottle関数内からUSBカメラプロセスを作動させるとうまくいく。
        proc1 = Process(
            target=camera_capture,
            args=(que_img, que_fps, isPause, img_width, img_height, cam_fps, isChange_size, ))
        proc1.start()
        # MJPG over HTTP用の8081ポートは、別プロセスでBottleフレームワークを動かしておく。
        proc2 = Process(target=run, kwargs={'host': host_url, 'port': stream_port, 'reloader': True, 'debug': True})
        proc2.start()

        cam_fps.value = que_fps.get()

        return ret_template(False)
    
    @route('/streaming')
    def streaming():

        # カメラ画像にフレームレートテキストを書き込む関数
        def put_txt(img, fps, col_r, col_g, col_b, tn):
            cv2.putText(img,
                        text=str(fps).rjust(3) + ' fps',
                        org=(0, 50),
                        fontFace=cv2.FONT_HERSHEY_SIMPLEX,
                        fontScale=2.0,
                        color=(col_r, col_g, col_b),
                        thickness=tn,
                        lineType=cv2.LINE_4)

        response.set_header('Content-type', 'multipart/x-mixed-replace;boundary=--frame')

        fps = 0
        fps_count = 0
        fps_time = time.time()

        while not goShutdown.value:
            if isPause.value:
                time.sleep(0.3)
                continue

            img = que_img.get()

            # フレームレート値テキスト(黒縁取り)をカメラ画像に上書きする
            put_txt(img, fps, 0, 0, 0, 5)
            put_txt(img, fps, 255, 255, 255, 2)

            # ret2, jpeg = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 50]) # JPEG品質50の場合
            ret2, jpeg = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 100])

            if not ret2:
                continue

            frame_data = jpeg.tobytes()
            # ブラウザへMJPG送信
            yield (b'--frame\r\n' + b'Access-Control-Allow-Origin: *\r\n' + b'Content-Type: image/jpeg\r\n\r\n' + frame_data + b'\r\n\r\n')
            
            # フレームレート計算
            if (time.time() - fps_time) >= 1:
                fps = fps_count
                fps_count = 0
                fps_time = time.time()
            else:
                fps_count += 1

    @route('/size', method="GET")
    def c_size():
        # アクセス方法:http://192.168.0.18:8080/size?width=640&height=480
        isPause.value = False
        img_width.value = int(request.query.get("width"))
        img_height.value = int(request.query.get("height"))
        print("size w h = ", img_width.value, img_height.value)
        isChange_size.value = True
        cam_fps.value = que_fps.get()
        return ret_template(False)

    @route('/pause', method="GET")
    def pause_stream():
        isPause.value = True
        time.sleep(0.5)
        return ret_template(True)

    @route('/restart', method="GET")
    def restart():
        isPause.value = False
        time.sleep(0.5)
        return ret_template(False)

    @route('/shutdown', method="GET")
    def shutdown_server():
        nonlocal proc1, proc2

        isPause.value = True
        goShutdown.value = True

        time.sleep(0.5) # 0.5秒以上無いと確実に停止できないかも

        # USBカメラプロセスの停止
        proc1.terminate()
        proc1.join()
        proc1.close()

        yield "USBカメラを停止しました<br>"

        # MJPG over HTTPプロセスの停止
        proc2.terminate()
        proc2.join()
        proc2.close()

        yield "BottleサーバーをShutdownしました"

        # メインプロセスのPython Bottleサーバーを完全終了させる
        import os
        import signal
        os.kill(os.getpid(), signal.SIGTERM)

    # HTMLを返す関数。Pauseボタンが押され具合によって返すページを変える。
    def ret_template(isPause):
        img_tag = None
        if isPause:
            img_tag = "<div style='font-size:2.0em'>Pause(一時停止)</div>"
        else:
            img_tag = "<img src='http://" + host_url + ":" + str(stream_port) + "/streaming'/><br>"
        
        yield template("./mjpg_multi_top.html",
                        host_url=host_url,
                        control_port=str(control_port),
                        stream_port=str(stream_port),
                        width=str(img_width.value),
                        height=str(img_height.value),
                        frame_rate=str(cam_fps.value))
        
        yield img_tag

    # USBカメラ制御
    def camera_capture(que_img, que_fps, isPause, img_width, img_height, cam_fps, isChange_size):
        try:
            cap = cv2.VideoCapture(0, cv2.CAP_V4L2)
        except TypeError:
            cap = cv2.VideoCapture(0)

        if cap.isOpened() is False:
            raise IOError

        print("Capture Set OK!")
        print("default camera_fps=", cap.get(cv2.CAP_PROP_FPS))

        #予め次のコマンドでUSBカメラの使用可能な解像度とフレームレートを確認しておく
        # v4l2-ctl --list-formats-ext

        cap.set(cv2.CAP_PROP_FRAME_WIDTH, img_width.value)
        cap.set(cv2.CAP_PROP_FRAME_HEIGHT, img_height.value)
        cap.set(cv2.CAP_PROP_FPS, 30)

        cam_fps.value = int(cap.get(cv2.CAP_PROP_FPS))
        print("now camera_fps=", cam_fps.value)
        que_fps.put(cam_fps.value)

        while not goShutdown.value:
            if isPause.value:
                time.sleep(0.3)
                continue

            if isChange_size.value:
                cap.set(cv2.CAP_PROP_FRAME_WIDTH, img_width.value)
                cap.set(cv2.CAP_PROP_FRAME_HEIGHT, img_height.value)
                cap.set(cv2.CAP_PROP_FPS, 30) # ここでFPSを30としておけば、可能な限りの最速フレームレートを自動で設定してくれる
                print("change w h = ", img_width.value, img_height.value)
                cam_fps.value = int(cap.get(cv2.CAP_PROP_FPS))
                print("now camera_fps=", cam_fps.value)
                que_fps.put(cam_fps.value)
                isChange_size.value = False

            ret, img = cap.read()
            if not ret:
                continue

            # que_img.put(img, False) のようにオプションがFalseの場合、queが満杯だと例外 queue.Full が発生する。その場合以下のようにする
            try:
                que_img.put(img, False)
            except:
                print(".", end="") # Queが満杯の場合に表示させる。

        print("Released VideoCapture")
        cap.release()

    run(host=host_url, port=control_port, reloader=True, debug=True)

if __name__ == '__main__':
    
    bt()

【ザッと解説】

※先ほどのコードと同様、事前にファイアウォール(ufw)でポート番号8080と8081を開放しておきます。

このコードでは、Bottleの使い方として以前のこちらの記事で扱ったように、外部ファイルのHTMLコードにPythonコード内で取得したデータを反映させて表示させるという手法を使いました。
先ほど紹介したHTML内の二重波括弧内の変数値は、154~159行で返されて反映するようになっています。

ただ、MJPGストリーミングするための以下の様なimgタグ、
<img src='http://192.168.0.18:8080/streaming'/>
は、161行で送信して追加する方式にしました。
どうしてそうしたかというと、一時停止した場合にHTMLファイルを2つ表示させるよりもこちらの方法が簡略化できたからです。

その他、プロセス間のデータ共有のためのValueを使って、コード画角サイズやストリーミング一時停止、などのボタン操作で制御できるようにしました。
例えば、「640×480」ボタンを押すと、
http://192.168.0.18:8080/size?width=640&height=480
というようにブラウザからクエリパラメータの入ったGETリクエストが送られてきます。
それからrequest.query.get 関数で取得できるわけです。
これについては以前のこちらの記事を参照してみてください。

一時停止については、静止画像を表示させて停止させるところまで作り込みたかったんですが、今回は省いて、文字列出力だけにしました。やろうと思えば出来ないことは無いと思います。

では、このコードを走らせてみます。
その後、ブラウザのURL入力欄に

http://192.168.0.18:8080

と入力すると、以下のように表示されればOKです。

(図07-01)

Pythonコードを実行して、Google Chromeブラウザでアクセスした画面

Pythonコードを実行して、Google Chromeブラウザでアクセスした画面

あとは最初に紹介した動画のようにボタン操作で画角サイズを変更したり、一時停止したり、シャットダウンしてみてください。

これでラズパイ4BとUSBカメラのPythonによるストリーミング表示はバッチリですね。
ただ、何分素人なので、間違いやもっと簡単な方法があるよとかあればコメント投稿で教えていいただけると助かります。

8.まとめ

マルチタスク、マルチプロセスについては以前、ESP32やM5Stackでやったことはありましたが、ラズパイのPythonでここまでできれば何でもできそうな気がしましたね。

Bottleサーバーでmultiprocessingを使った例があまり見当たらなかった中で、MJPGストリーミング中に双方向通信ができたのは我ながら大満足です。
そして、プロセス間のデータ共有やQueueの理解が進んだことは個人的に進歩したかなと思います。
これでラズパイによるAI開発に取り組めそうな気がします。

ということで今回はここまでです。
ではまた・・・。

コメント

タイトルとURLをコピーしました