PythonでQRコード式ホームドア開閉システムを実装する

# 実装環境
・Windows 10 ver 20H2
・WinPython 3.8.7
・OpenCV 4.5.1
・iVCam（スマホのカメラをPCのWebカメラにして、ホームドア実験に使う）
　※PCの内蔵Webカメラでもできなくはないですが、調整しにくいのであまりお勧めできません
・Microsoft PowerPoint（電車のドアのようにQRコードをアニメーションで動かす）

---
# 背景
※QRコードはデンソーウェーブの登録商標です。
ここ数年で、いくつかの路線にQRコード式(専用のtQRというコード)のホームドア開閉システムが導入されています。
最近では小田急線が実証実験に乗り出し、登戸駅でその様子を見ることができます。

電車のドア両側に貼られたQRコードの位置と動き方向を検出してホームドアを制御しているようで、とてもおもしろいですね。

---
そこで、自分のPCでもそのシステムをPythonで実装できないかな～と思い簡単に実装してみました。
実際に物理的なホームドアを作ることはできないので、Python内でホームドアを描画して動きを模擬します。

---
# 参考
・opencvの新しい機能QRコード検出を使ってみた
https://qiita.com/yasuoka_dev/items/3849128f0d453e7cbfcd
・PCのWEBカメラの映像を顔認識する
https://qiita.com/watyanabe164/items/652617c7ad577daa38d0
・OpenCVでQRコードを複数同時検出する方法 [detectAndDecodeMulti]
https://tech-blog.optim.co.jp/entry/2020/12/15/100000?utm_source=feed

---
# 1. 環境構築
以下、インストールされていないライブラリがあればインストールしてください。
・opencv-python (cv2)

```python
pip install opencv-python
```
・playsound（ホームドアの開閉時に音を鳴らす）

```python
pip install playsound
```

---
# 2. QRコードを複数検出
まずは、OpenCVの機能を使って複数のQRコードを検出してみます。
検出したQRコードの位置に四角形とそのx座標を表示するサンプルを示します。
検出中、キーボードでESCキーを押すと終了します。

映像デバイスが複数ある場合は、使用したいデバイスの番号に合わせて`cv2.VideoCapture(0)`の引数`0`を`1`以上に変更してください。


```python:simple_qr.py
import sys
import cv2
import numpy as np

# テキストを描画する
def draw_text(img, text, pos, color=(255, 255, 255), font_face=cv2.FONT_HERSHEY_PLAIN, font_scale=2, font_thick=2, line_type=cv2.LINE_8):
    cv2.putText(img, text, pos, font_face, font_scale, color, font_thick, line_type)

# 四角形を描画する
def draw_polylines(img, pos, color=(0, 255, 0), thickness=5, lineType=cv2.LINE_8, shift=0):
    cv2.polylines(img, [pos], True, color, thickness=thickness, lineType=lineType, shift=shift)

def main():
    # =========パラメータ設定=========
    bin_thresh = 95 # 二値化の閾値
    window_scale = 1.0 # 表示画像のスケール
    # ==========================

    # 映像デバイスを開く
    cap = cv2.VideoCapture(0)
    if cap.isOpened() is False:
        print("can not open camera")
        sys.exit()

    # QRコード検出器を生成
    qr = cv2.QRCodeDetector()
    # 表示ウィンドウを拡大可能にする
    cv2.namedWindow('frame', cv2.WINDOW_NORMAL)

    # 中断操作がない限り繰り返し
    while True:
        # 1フレームを読み込む
        _, frame = cap.read()
        # 画像をリサイズ
        frame = cv2.resize(frame, (int(frame.shape[1] * window_scale), int(frame.shape[0] * window_scale)))

        # 画像の二値化処理
        bin_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        bin_frame = cv2.threshold(bin_frame, bin_thresh, 255, cv2.THRESH_BINARY)[1]

        # 検出の実行
        ret, *data = qr.detectAndDecodeMulti(bin_frame)

        # 検出されたQRコード数を表示
        draw_text(frame, f'n of QR: {len(data[0])}', (10, 30))
        
        # 検出されたQRコードの位置を強調表示
        if ret:
            for i, (text, pos, _) in enumerate(zip(*data)):
                pos = pos.astype(np.int32)
                draw_polylines(frame, pos)
                draw_text(frame, str(pos[0][0]), (pos[0][0], pos[0][1] - 10), (0, 255, 0))

        # 画像を表示
        cv2.imshow('frame', frame)

        # ESCキーを押した場合中断する
        k = cv2.waitKey(1)
        if k == 27:
            break
    
    # 映像デバイスを閉じて終了する
    cap.release()
    cv2.destroyAllWindows()

if __name__ == "__main__":
    main()
```

---

![QR_1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1373557/78d4842b-19f5-37c4-fdb6-6cb84a63abf7.jpeg)

適当なQRコード（textという文字列です）を電車のドア風に2つならべて検出してみたところです。（検出しやすいようにQRコードを大きくしています）
確かに2つ検出され、x座標も表示できました。
もし検出がうまくいかない、検出がちらつく場合は、二値化の閾値や、映像デバイス側での露出やシャッタースピードの設定を変更してみてください。

---
# 3. ホームドアの開閉アルゴリズム
## 3.1 ドア開閉状況の判定方法
検出された2つのQRコードの位置情報を使ってドアの開閉状況を判定するにはいくつか考えられますが、ここではQRコードのx座標がx方向にどのように動いているかで場合分けしてみます。
本家のホームドアシステムでもQRコードの横移動を検出しているとのことなので、同じような考え方になると思われます。

---
### A. 両方のQRコードが動いていない場合
2つのQRコードが検出され、両方とも動いていない場合は定位置で停車していることになります。（実際はtQRに埋め込まれた車両番号、ドア番号など細かく検出するものと思われます）
このときホームドアは開く準備に入り、電車のドアが開くのを待ちます。

---
### B. 両方のQRコードが同じ方向に動いている場合
ドア両側のQRコードが同じ方向に動くということは、電車そのものが到着／発車／通過で動いているということになります。この場合、危ないのでホームドアは閉じたままにしておきます。

---
### C. 左のQRコードが負方向、右のQRコードが正方向に移動している場合
電車のドアが開きかけていることを示します。列車が定位置に停止していて、かつ動き情報をもとに確実に開いていると判断されたときホームドアを開きます。

---
### D. 左のQRコードが正方向、右のQRコードが負方向に移動している場合
Cとは逆の動きなので、ドアが閉まりかけていることを示します。同じように、動き情報をもとに確実に閉じかけていると判断されたときホームドアを閉じます。

---
## 3.2 QRコードの動き方向を検出
動きと状況の対応付けができたので、実際にQRコードの動きを検出してみます。
ここでは、1フレーム前のx座標を記憶しておいて、現フレームの位置との差を取ってリストに追加します。
また、リストに格納するのは最大直近の10フレームまでにしておきます。
そのリストの平均値を求めて場合分けすると、3.1のような判定ができるようになります。

```python:QR_motion.py
import sys
import cv2
import numpy as np
from playsound import playsound
import subprocess

def draw_text(img, text, pos, color=(255, 255, 255), font_face=cv2.FONT_HERSHEY_PLAIN, font_scale=2, font_thick=2, line_type=cv2.LINE_8):
    cv2.putText(img, text, pos, font_face, font_scale, color, font_thick, line_type)

def draw_polylines(img, pos, color=(0, 255, 0), thickness=5, lineType=cv2.LINE_8, shift=0):
    cv2.polylines(img, [pos], True, color, thickness=thickness, lineType=lineType, shift=shift)

def main():
    # =========パラメータ設定=========
    bin_thresh = 98
    window_scale = 1.0
    # ==========================

    cap = cv2.VideoCapture(0)
    if cap.isOpened() is False:
        print("can not open camera")
        sys.exit()

    qr = cv2.QRCodeDetector()

    # x座標を初期化
    x_left_qr_previous = -1
    x_right_qr_previous = -1

    # x座標の座標差リスト
    x_diffs_left_qr = []
    x_diffs_right_qr = []

    while True:
        _, frame = cap.read()
        frame = cv2.resize(frame, (int(frame.shape[1] * window_scale), int(frame.shape[0] * window_scale)))

        bin_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        bin_frame = cv2.threshold(bin_frame, bin_thresh, 255, cv2.THRESH_BINARY)[1]

        ret, *data = qr.detectAndDecodeMulti(bin_frame)

        draw_text(frame, f'n of QR: {len(data[0])}', (10, 30))
        
        if ret:
            pos_list = []

            for i, (text, pos, _) in enumerate(zip(*data)):
                pos = pos.astype(np.int32)
                draw_polylines(frame, pos)
                draw_text(frame, str(pos[0][0]), (pos[0][0], pos[0][1] - 10), (0, 255, 0))
                pos_list.append(pos[0][0])

            if len(pos_list) == 2:
                pos_list = np.sort(pos_list)

                if x_left_qr_previous != -1:
                    x_diffs_left_qr.append(pos_list[0] - x_left_qr_previous)
                    x_diffs_right_qr.append(pos_list[1] - x_right_qr_previous)
                x_left_qr_previous = pos_list[0]
                x_right_qr_previous = pos_list[1]

                # リストの要素が10個を超えたときは初めのものを削除する
                if len(x_diffs_left_qr) > 10:
                    del(x_diffs_left_qr[0])
                    del(x_diffs_right_qr[0])
        
                # リストの要素が5個以上になったとき、座標差の平均値（＝動き方向）を求める
                if len(x_diffs_left_qr) > 5:
                    speed_average_left = np.average(x_diffs_left_qr)
                    draw_text(frame, f'L: {speed_average_left:.3f}', (10, 90))
                    speed_average_right = np.average(x_diffs_right_qr)
                    draw_text(frame, f'R: {speed_average_right:.3f}', (10, 120))
        
        cv2.namedWindow('frame', cv2.WINDOW_NORMAL)
        cv2.imshow('frame', frame)

        k = cv2.waitKey(1)
        if k == 27:
            break
    
    cap.release()
    cv2.destroyAllWindows()

if __name__ == "__main__":
    main()

```

---
![QR_2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1373557/bf097048-2d59-6d59-0280-82e6cbf49847.jpeg)

QRコードの動きは、数値として表示されます。この画像はQRコード表示に使っているノートPCをPCごと右に動かしたときの例です。実際には電車が右に動いているのと同等です。
この数値が正であれば右方向、負であれば左方向に動いていることが分かります。

---
# 4. ホームドア開閉システム全体を実装
これで動きを数値で求めることができました。この数値を場合分けすることで、実際にホームドアの開閉を模擬してみたいと思います。

---
## 4.1 バックグラウンドで音声を鳴らすためのプログラム
ホームドア開閉時にチャイムを鳴らしたいので、バックグラウンドで音が鳴るように別のプログラムを同じディレクトリに用意します。

```python:play_sound.py
from playsound import playsound
import sys

args = sys.argv
playsound(args[1])
```

---
## 4.2 ホームドアチャイムの音声ファイル
各自音声ファイルを用意し、同じディレクトリに保存してください。
・開く時のチャイム：`open.wav`
・閉じるときのチャイム：`close.wav`

---
## 4.3 ホームドア開閉メインプログラム
このメインプログラムをコマンドラインから実行するとホームドア開閉のデモができます。

```python:qr_homedoor_main.py
import sys
import cv2
import numpy as np
from playsound import playsound
import subprocess

def draw_text(img, text, pos, color=(255, 255, 255), font_face=cv2.FONT_HERSHEY_PLAIN, font_scale=2, font_thick=2, line_type=cv2.LINE_8):
    cv2.putText(img, text, pos, font_face, font_scale, color, font_thick, line_type)

def draw_polylines(img, pos, color=(0, 255, 0), thickness=5, lineType=cv2.LINE_8, shift=0):
    cv2.polylines(img, [pos], True, color, thickness=thickness, lineType=lineType, shift=shift)

def main():
    # =========パラメータ設定=========
    bin_thresh = 95
    window_scale = 1.0
    speed_thresh = 3.0 # ドア開閉判定の動き閾値
    speed_stop_thresh = 0.5 # 列車停止の動き閾値
    # ==========================

    cap = cv2.VideoCapture(0)
    if cap.isOpened() is False:
        print("can not open camera")
        sys.exit()

    qr = cv2.QRCodeDetector()

    x_left_qr_previous = -1
    x_right_qr_previous = -1

    x_diffs_left_qr = []
    x_diffs_right_qr = []

    # ホームドアの開き具合(0～100)
    x_homedoor = 0
    # ホームドアの移動速度
    is_homedoor_moving = 0

    status = "homedoor closed"

    while True:
        _, frame = cap.read()
        frame = cv2.resize(frame, (int(frame.shape[1] * window_scale), int(frame.shape[0] * window_scale)))

        bin_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        bin_frame = cv2.threshold(bin_frame, bin_thresh, 255, cv2.THRESH_BINARY)[1]

        ret, *data = qr.detectAndDecodeMulti(bin_frame)

        draw_text(frame, f'n of QR: {len(data[0])}', (10, 30))
        
        if ret:
            pos_list = []

            for i, (text, pos, _) in enumerate(zip(*data)):
                pos = pos.astype(np.int32)
                draw_polylines(frame, pos)
                draw_text(frame, str(pos[0][0]), (pos[0][0], pos[0][1] - 10), (0, 255, 0))
                pos_list.append(pos[0][0])

            if len(pos_list) == 2:
                pos_list = np.sort(pos_list)

                if x_left_qr_previous != -1:
                    x_diffs_left_qr.append(pos_list[0] - x_left_qr_previous)
                    x_diffs_right_qr.append(pos_list[1] - x_right_qr_previous)
                x_left_qr_previous = pos_list[0]
                x_right_qr_previous = pos_list[1]

                if len(x_diffs_left_qr) > 10:
                    del(x_diffs_left_qr[0])
                    del(x_diffs_right_qr[0])
        
                if len(x_diffs_left_qr) > 5:
                    speed_average_left = np.average(x_diffs_left_qr)
                    draw_text(frame, f'L: {speed_average_left:.3f}', (10, 90))
                    speed_average_right = np.average(x_diffs_right_qr)
                    draw_text(frame, f'R: {speed_average_right:.3f}', (10, 120))

                    # ドアの動き判定
                    if -speed_stop_thresh < speed_average_left < speed_stop_thresh and -speed_stop_thresh < speed_average_right < speed_stop_thresh:
                        # 列車が停止中
                        status = "homedoor ready"
                        
                    elif speed_average_left < -speed_stop_thresh and speed_average_right < -speed_stop_thresh:
                        # 列車が左に移動中
                        status = "train moving L"

                    elif speed_average_left > speed_stop_thresh and speed_average_right > speed_stop_thresh:
                        # 列車が右に移動中
                        status = "train moving R"

                    elif speed_average_left < -speed_thresh and speed_average_right > speed_thresh:
                        # ドアが開き始めている場合
                        if is_homedoor_moving <= 0 and x_homedoor != 100:
                            if status == "homedoor ready" or status == "homedoor closing":
                                # 既にホームドアが開き中でなければ、サウンドを再生
                                p = subprocess.Popen(['python',f'play_sound.py', "open.wav"])
                                is_homedoor_moving = 2
                                status = "homedoor opening"
                    
                    elif speed_average_left > speed_thresh and speed_average_right < -speed_thresh:
                        # ドアが閉じ始めている場合
                        if is_homedoor_moving >= 0 and x_homedoor != 0:
                            if status == "homedoor opened" or status == "homedoor opening" or status == "homedoor ready":
                                # 既にホームドアが閉じ中でなければ、サウンドを再生
                                p = subprocess.Popen(['python',f'play_sound.py', "close.wav"])
                                is_homedoor_moving = -2
                                status = "homedoor closing"
        
        # ホームドアの開閉速度を遅くする
        if x_homedoor > 70 and is_homedoor_moving == 2:
            is_homedoor_moving = 1
        if x_homedoor < 30 and is_homedoor_moving == -2:
            is_homedoor_moving = -1

        # ホームドアの位置更新
        x_homedoor += is_homedoor_moving

        # ホームドアが開ききったとき、パラメータをリセットする
        if x_homedoor > 100:
            x_homedoor = 100
            is_homedoor_moving = 0
            x_left_qr_previous = -1
            x_right_qr_previous = -1
            x_diffs_left_qr = []
            x_diffs_right_qr = []
            status = "homedoor opened"
        
        # ホームドアが閉じきったとき、パラメータをリセットする
        if x_homedoor < 0:
            x_homedoor = 0
            is_homedoor_moving = 0
            x_left_qr_previous = -1
            x_right_qr_previous = -1
            x_diffs_left_qr = []
            x_diffs_right_qr = []
            status = "homedoor ready"

        draw_text(frame, f'status: {status}', (10, 60))

        # ホームドアを画面に描画する
        x = frame.shape[1] / 2
        size = frame.shape[1] / 2
        y = frame.shape[0] / 4 * 3
        homedoor_x_l = x - x_homedoor / 100 * size
        homedoor_x_r = x + x_homedoor / 100 * size
        cv2.rectangle(frame, (0, int(y)), (int(homedoor_x_l), frame.shape[0]), (100,100,100), thickness=-1)
        cv2.rectangle(frame, (int(homedoor_x_r), int(y)), (frame.shape[1], frame.shape[0]), (100,100,100), thickness=-1)

        cv2.namedWindow('frame', cv2.WINDOW_NORMAL)
        cv2.imshow('frame', frame)

        k = cv2.waitKey(1)
        if k == 27:
            break
    
    cap.release()
    cv2.destroyAllWindows()

if __name__ == "__main__":
    main()
```

---
# デモ風景
表示される画像の下に、ホームドアの様子を灰色で描画しています。
音が鳴るとまさにホームドアっぽくなります。

---
## ホームドアが開くとき
![obs_2021-04-23_17-45-58.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1373557/5f95e987-e1e9-bbf9-65c0-1b08130f7221.gif)

うまく開く判定ができました。小田急線の実証実験を見て感じましたが、QRコード式の欠点として先に電車のドアが開いてしまうので、急いで電車を降りたい人が少しホームドアにぶつかりそうで危ないですね。

---
## ホームドアが閉じるとき
![obs_2021-04-23_17-54-08.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1373557/40b6dfb6-70e0-cad9-8c1c-a20c02e0130e.gif)
こちらもうまく閉じる判定できました。QRコード全体が写らないと判定できないので、少し閉じる判定が出るまでに時間がかかりますね。

---
## ドアの再開閉
![obs_2021-04-23_18-05-08.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1373557/c7d9ff12-a7d4-72bc-3862-67705d15e9b2.gif)

たまにドアに人やものが挟まってしまい再開閉するときがありますよね。ホームドアもそれに応じて開閉しなければいけませんが、なんとなく対応できています。

---
# まとめ

Pythonだけで簡単なホームドアの開閉制御を行ってみました。
Pythonなので他の機器との連携やライブラリを使った高度な制御もできそうです。

IoT機器と組み合わせて、物理的にドアを動かしてみるのも面白そうですね。