PySide2とQt Quickで、OCRで郵便物を抽出し、宛先人に通知するアプリケーションを作る

## はじめに

この記事は以下の続きになります。

https://qiita.com/taiwa_tky/items/05eba3b276fbbea017cb

Google Cloud Vision APIとGiNZAで抽出した郵便物の宛先を使って、宛先人に通知するアプリケーションを作りましたので紹介です。
GUIにはPySide2とQt Quickを使用しました。

## PySide2について

Qt5をPythonにバインディングしたライブラリになります。
pipでインストールしておきましょう。pysideとpyside2がありますので注意してください。

```shell
pip install pyside2
```

インストールできたら、簡単な画面を表示してみましょう。
以下のコードだけで画面が表示されます。

```py:sample.py
import sys
from PySide2.QtWidgets import QApplication, QLabel

app = QApplication(sys.argv)
label = QLabel("Hello World!")
label.show()
app.exec_()
```

![PySide2でHello World](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248216/0235e82a-abbc-770f-2745-f4a65d484c15.png)

## Qt Quickについて

Qtでは、コードに直接書くか、.uiファイルというものを使用してUIを定義していましたが、Qt QuickではQML(Qt Meta-Object Language)というものを使用してUIを定義します。
CSSのようなシンタックスでUIを宣言的に記述できることと、JavaScriptを使うことができるのが特徴です。

詳細はこちらの記事が参考になります。

https://www.qt.io/ja-jp/blog/2010/07/09/what-is-the-qt-quick-2

### QML

PySide2でQMLを使用するには、以下のようにQMLとPythonを用意します。

```qml:sample.qml
import QtQuick 2.15
import QtQuick.Controls 2.4
import QtQuick.Layouts 1.13
import QtQuick.Controls.Material 2.12

ApplicationWindow {
    id: applicationWindow
    // Theme
    Material.theme: Material.Light
    Material.primary: Material.Red
    Material.accent: Material.Orange
    Material.foreground: '#000000'
    Material.background: Material.LightGrey

    width: 200
    height: 200
    visible: true
    visibility: "Windowed"
    minimumHeight: 200
    minimumWidth: 200

    Rectangle {
        Text {
            x: 66
            y: 93
            text: "Hello World"
        }
    }
}
```

```py:sample.py
import sys
from PySide2.QtGui import QGuiApplication
from PySide2.QtQml import QQmlApplicationEngine
from PySide2.QtCore import QUrl

app = QGuiApplication(sys.argv)
engine = QQmlApplicationEngine()
engine.load(QUrl("sample.qml"))

if not engine.rootObjects():
    sys.exit(-1)

sys.exit(app.exec_())
```

これを実行するとQMLで記述したUIが表示されました。
Qt Quickはマテリアルデザインが用意されていて、簡単におしゃれにできるところがいいなと思っています。

![Qt QuickでHelloWorld](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248216/033fcc26-66f5-0984-70a9-3b5eeee51611.png)


### QMLからPythonを使う

もちろんQMLからPython側のコードを使うこともできます。
クラスとして定義しておき、QMLエンジン作成時に以下のようにContextPropertyとして登録します。

```py:sample.py
import sys
from PySide2 import QtCore
from PySide2.QtGui import QGuiApplication
from PySide2.QtQml import QQmlApplicationEngine
from PySide2.QtCore import QUrl


class SampleViewModel(QtCore.QObject):
    pythonEvent = QtCore.Signal(int)

    def __init__(self):
        super(SampleViewModel, self).__init__()

    @QtCore.Slot(int, result='QVariant')
    def on_click_button(self, arg):
        self.pythonEvent.emit(f"on_click_button:{arg}")
        return f"on_click_button:{arg}"


app = QGuiApplication(sys.argv)
engine = QQmlApplicationEngine()
sample_view_model = SampleViewModel()
ctx = engine.rootContext()
ctx.setContextProperty("SampleViewModel", sample_view_model)
engine.load(QUrl("sample.qml"))

if not engine.rootObjects():
    sys.exit(-1)

sys.exit(app.exec_())
```

QML-Python間のイベント通知には、Qtのシグナル・スロットを使用します。シグナル・スロットについては[こちら](https://www.qt.io/ja-jp/blog/2010/06/17/signals-and-slots)を参照してください。  
QMLからPythonに通知する場合は、上記のように`@QtCore.Slot`でQMLに公開するメソッドを定義します。引数を受け取ったり、返り値を返すことも可能です。QML側からはsetContextPropertyで登録した名前を使用して、以下のように、`SampleViewModel.on_click_button(1)`とします。  
PythonからQMLに通知する場合は、`QtCore.Signal`でシグナルを定義します。このシグナルをQML側のメソッドにバインドします。

```qml:sample.qml
ApplicationWindow {
    id: applicationWindow

    width: 200
    height: 200
    visible: true
    visibility: "Windowed"
    minimumHeight: 200
    minimumWidth: 200

    Rectangle {
        Button {
            onClicked: {
                const ret = SampleViewModel.on_click_button(1);
                console.log(ret);
            }
        }
    }

    Component.onCompleted: {
        SampleViewModel.pythonEvent.connect(onPythonEvent);
    }

    function onPythonEvent(value) {
        console.log(value)
    }
}
```

上記の場合、ボタンがクリックされると、pythonの`on_click_button`を呼び出して値が返ってくるのと同時に、Signal経由でQMLの`onPythonEvent`も呼ばれます。

## 郵便物通知アプリケーション

本題のPySide2とQt Quickで作成した郵便物通知アプリですが、以下のような構成になります。

![構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248216/4a0d53c9-32e4-a902-fa81-0064aa62ca39.png)

ざっくり以下のような流れです。

1. ノートPCに接続したUSBカメラで郵便物を撮影する
2. Vision APIでOCRを行う
3. OCR結果からGiNZAで宛先人を抽出する
4. 宛先人にメール通知する

OCR結果が間違ったときのために、手動で宛先人を選択できるようにもしておきます。

ソースは以下になります。

https://github.com/JajaDoc/mail-notificator

上記からかいつまんで実装を紹介します。

### 撮影画面

撮影画面ではカメラの映像領域と撮影ボタンを配置します。
カメラ映像を表示するには以下のカスタムViewを作成し、映像を表示できるようにしました。カメラからの画像取得にはOpenCVを使用しています。

```python
import datetime
import os

import cv2
import qimage2ndarray
import numpy as np
from PySide2 import QtCore, QtQuick


class Singleton(type(QtCore.QObject), type):
    def __init__(cls, name, bases, dict):
        super().__init__(name, bases, dict)
        cls.instance = None

    def __call__(cls, *args, **kw):
        if cls.instance is None:
            cls.instance = super().__call__(*args, **kw)
        return cls.instance


class CameraView(QtQuick.QQuickPaintedItem, metaclass=Singleton):

    def __init__(self):
        super(CameraView, self).__init__()
        self.is_visible = True
        self.video_size = QtCore.QSize(1280, 720)
        self._canvas = np.zeros((720, 1280, 3), dtype=np.uint8)
        self.capture = None
        self.current_frame = None
        self.init_camera()

    def __del__(self):
        self.capture.release()

    def get_camera_capture(self):
        for num in range(0, 10):
            cap = cv2.VideoCapture(num, apiPreference=cv2.CAP_DSHOW)
            ret, _ = cap.read()
            if ret is True:
                print("camera_number", num, "Find!")
                return cap
            else:
                print("camera_number", num, "None")
                exec("1")

    def init_camera(self):
        self.capture = self.get_camera_capture()

    def paint(self, painter):
        _, self.current_frame = self.capture.read()
        self.current_frame = cv2.cvtColor(self.current_frame, cv2.COLOR_BGR2RGB)
        image = qimage2ndarray.array2qimage(self.current_frame)
        painter.drawImage(0, 0, image)

    @QtCore.Slot(result='QVariant')
    def save_image(self):
        now = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
        filename = f'data/history/{now}.jpg'
        os.makedirs(os.path.dirname(filename), exist_ok=True)
        cv2.imwrite(filename, cv2.cvtColor(self.current_frame, cv2.COLOR_RGB2BGR))
        return filename
```

これをmain.pyで登録しておきます。

```python
QtQml.qmlRegisterType(CameraView, 'CameraView', 1, 0, 'CameraView')
```

撮影ボタンが押されたら、所定のディレクトリに画像を保存しておき、後続の宛先抽出処理にて使用します。

### 宛先抽出

宛先抽出は冒頭で紹介した[前回記事](https://qiita.com/taiwa_tky/items/05eba3b276fbbea017cb)の通りです。
Vision APIでOCRした結果から、宛先人の名前を抽出します。
宛先人が抽出できたら、従業員名簿(CSV)と照合し、メールアドレスを取得します。お好みでチャット等への通知も可能ですので、この辺はお使いのシステムに変えていただければと思います。

### 手動宛先選択画面

OCRで正しく宛先が抽出できなかったときの画面になります。
QMLでのリストUIの記述方法は少し特殊に感じたので紹介しておきます。(長いので配置オプションは省略しています)

```qml
Rectangle {
    ListView {
        id: membersListView

        ScrollBar.vertical: ScrollBar {
            active: true
        }
        
        model: ListModel {
            id: model
        }

        delegate: Item {
            required property string identifier
            required property string name
            required property string kana
            required property string address

            ScrollBar.vertical: ScrollBar { visible: true }

            Pane {
                property bool pressed
                id: card
                pressed: false

                Text {
                    id: nameText
                    elide: Text.ElideRight
                    font.pixelSize: 28
                    text: name
                }

                Text {
                    id: emailText
                    color: "#888888"
                    horizontalAlignment: Text.AlignRight
                    elide: Text.ElideRight
                    font.pixelSize: 24
                    text: address
                }

                MouseArea {
                    onClicked: {
                        card.pressed = true;
                        ResultViewModel.set_manual_result(name, address);
                        MainViewModel.on_selected_member();
                    }
                }
            }
        }

    }
    
    Component.onCompleted: {
        init();
    }

    function init() {
        const result = MembersViewModel.get_sorted_members();
        const members = result[0].map((member, idx) => ({
            identifier: "" + idx,
            name: member[0],
            kana: member[1],
            address: member[2]
        }));

        for (const member of members) {
            membersListView.model.append(member);
        }
    }
}
```

リスト形式のUIは`ListView`を使用しますが、リストのセルは`model`と`delegate`というもので記述します。
`model: ListModel`でセルで表示するデータを定義し、`delegate: Item`でそのデータの表示方法を定義します。  
コンポーネントのインスタンスが生成されたタイミングで、CSVから従業員一覧を取得し、このmodelに追加することで従業員一覧が表示されます。

`onClicked`にセルがクリックされたときの処理を書き、メール通知を行います。

### まとめ

PySide2を使うことで、PythonでGUIアプリケーションを作ることができました。
マテリアルデザインにも対応していますし、公式サイトにはチュートリアルも豊富に用意されています。
機械学習の推論コードなど、お手持ちのPythonをGUI化したいときにぜひ使ってみてください。

---

* https://github.com/JajaDoc/mail-notificator