【Python】退屈な Zoom / Meet を自動退出するデスクトップアプリを開発してみた

#はじめに
新型コロナウイルスの影響で、昨年度から学校・企業に<font color="Red">オンライン授業・会議が普及</font>し始めました。
感染リスクを減らせるというメリットがある一方、
**「なかなか集中できず眠くなってしまう」**
という意見も……。

私もずっと同じ悩みを抱えていたので、<font color="Red">上手くオンライン講義をサボれる方法</font>を模索していました。
そこで思い付いたのが、オンライン会議の**「自動退出」**です！


|第１弾（設計～テスト実行）|第２弾（アプリ化・配布～使用上の注意）|
|---|---|
|<iframe width="560" height="315" src="https://www.youtube.com/embed/m62d0Yqtca4" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>|<iframe width="560" height="315" src="https://www.youtube.com/embed/G6qdD7962H0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>|
|[【全人類待望】寝落ちが合法に\!? オンライン授業を自動退出してくれるアプリを作ってみた \- YouTube](https://www.youtube.com/watch?v=m62d0Yqtca4&t=194s)|[【歓喜】ついに、Zoom / Meet 自動退出アプリを無料配布します！！Z世代にふさわしい最強のオンライン攻略法とは…！？ \- YouTube](https://www.youtube.com/watch?v=G6qdD7962H0)|


私の大学では **Google Meet** を使用することが多いですが、**Zoom** を普段利用している人も多いと思うので、両方に対応できるアプリを開発しました。

<font color="Red">**「デスクトップアプリだけ使ってみたい！」**</font>という人は

|ダウンロードページ (  [SkiMee【自動退出支援アプリ】](https://sites.google.com/view/skimee/home))|
|---|
|![スクリーンショット 2021-10-12 144731.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/63f0cfe5-d485-3bf3-c18e-c9460c01dd74.png)|

をご覧ください。

ローカル環境で動くアプリを開発するのにそれほど時間はかからなかったのですが、<font color="Red">exe 化のステップで２ヵ月以上詰まってしまった</font>ので、注意点も合わせて記事にできればと思います。

#0.  UI を観察しながら作戦を練る
<font color="Red">【ポイント】**オンライン会議（授業）は、決まった時間に終わらない！**</font>

そこで今回は、**参加者数をリアルタイムで監視**して自動退出するプログラミングを考えます。

<font color="Blue">「 Web サイトから特定の情報（参加者数）だけを抽出する」</font>という目標を達成するために真っ先に思いつくのは**スクレイピング**です。
しかし、リアルタイム（<font color="Red">３秒に１回くらい</font>）で参加者数を知りたい場合にスクレイピングを用いると、<font color="Red">相手サーバーに大きな負荷がかかってしまう</font>ため、**別の安全な方法**を用います。

![96AFA1C1-24CC-4605-BA35-067F9D6BF9D7.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/42d2d415-c640-3211-cf57-6b655a46ec8d.jpeg)


<font color="Red">**画像認識**</font>によって、参加者数を画面上のアイコンから読み取り、退出時は退出ボタンの位置をスクリーンから見つけ出して、自動でマウスを操作させる作戦を立てました。

(以下、Google Meet の UI を例に説明しますが、Zoom でも基本的な原理は同じです。)



#1. 参加者数アイコンの位置を探り、キャプチャする

<font color="Red">PyAutoGUI</font> というライブラリの画像認識は、一言で言うと**「ハンパない」**です。
あらかじめ保存しておいた参加者数アイコンの画像から、その位置を探し出して座標を取得してくれるのです。
さらに、認識した「👥」の位置から、その右肩にある数字の範囲だけのスクリーンショットを撮って保存します。


```bash
pip install pyautogui
```

```python
import pyautogui as gui
import cv2  # pip install cv2

# 保存したアイコン画像の座標（位置と大きさ）を取得
# オプションのconfidenceは、画面上の画像の位置を特定する精度を指定
X,Y,W,H = gui.locateOnScreen('{参加者数アイコン画像のパス}', confidence=0.6)

# アイコン右肩の数字の範囲を指定してスクリーンショットを撮る
cropped_img = gui.screenshot(region=(X+W/2,Y-2*H/3,W,2*H/3))

# 撮ったスクリーンショットを保存する
cropped_img.save('.\\sankasha.png')
```

【参考にしたサイト】
[PyAutoGUIで画像認識（locateOnScreen）【Python】 \| ジコログ](https://self-development.info/pyautogui%E3%81%A7%E7%94%BB%E5%83%8F%E8%AA%8D%E8%AD%98%EF%BC%88locateonscreen%EF%BC%89%E3%80%90python%E3%80%91/)
[Screenshot Functions — PyAutoGUI documentation](https://pyautogui.readthedocs.io/en/latest/screenshot.html)



#2. OCR で画像から参加者数を読み取る
Python と <font color="Red">Tesseract OCR</font> を使用して参加者数アイコンから数字を読み取ります。

<details><summary>Tesseract OCR とは</summary><div>
<font color="Red">Google</font> が公開したオープンソースの文字認識エンジンです。
</div></details>

※ Tesseract OCR は Python のモジュールではないため、通常の pip コマンドではなく、以下のサイトに沿ってインストールしてください。
[Python＋Tesseractによる画像処理でOCRを試してみた！ – 株式会社ライトコード](https://rightcode.co.jp/blog/information-technology/python-tesseract-image-processing-ocr)

また、Pythonで Tesseract OCRを扱うためには、<font color="Blue">PyOCR</font> というライブラリを使用します。

```bash
pip install pyocr
```

```python
import pyocr

# 保存した参加者数の画像をグレースケールで読み込み
img = cv2.imread('.\\sankasha.png', cv2.IMREAD_GRAYSCALE)

# グレースケール画像の二値化（閾値処理）
ret, img_thresh = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)
# print(f"ret:{ret}")

# 画像の色反転
img2 = cv2.bitwise_not(img_thresh)

# 参加者数の画像を上書き保存
cv2.imwrite('.\\sankasha.png', img2)

# PyOCR へ利用する OCR エンジンを Tesseract に指定する
tools = pyocr.get_available_tools()
if len(tools) == 0:
    print("No OCR tool found")
    sys.exit(1)
tool = tools[0]
# print("Will use tool '%s'" % (tool.get_name()))

# 画像から数字を読み取る（文字列型であることに注意）
str_num = tool.image_to_string(
    Image.open('.\\sankasha.png'),
    lang='eng',
    builder = pyocr.builders.TextBuilder(tesseract_layout = 6)
)

# 読み取った参加者数を整数型に変換して出力
print(int(str_num))
```

#3. リアルタイムでグラフに描画する
ステップ２を継続的に実行することにより、リアルタイムで参加者数を得ることができます。
今回は、**人数の変化を折れ線グラフで可視化**して、見ていて楽しいアプリを作ることにしました。
![Videotogif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/393d2177-b181-d404-d919-16af1f4d61c5.gif)
Python で GUI アプリケーションを作れるライブラリとして、「Tkinter」「Kivy」「PyQt」「wxPython」などがよく紹介されます。

この中で、標準ライブラリである <font color="Blue">Tkinter</font> を一度は使ってみましたが、
[Tkinterを使うのであればPySimpleGUIを使ってみたらという話 \- Qiita](https://qiita.com/dario_okazaki/items/656de21cab5c81cabe59)
という記事にすごく影響されて、結局 <font color="Red">**PySimpleGUI**</font> でアプリを作りました。

```bash
pip install pysimplegui
```
グラフの描画には <font color="Red">Matplotlib</font> という定番のライブラリを使いました。
詳しいコードは、「全体コード」の章をご覧ください。


#4. 退出条件を満たしたらボタンを押す
再び、<font color="Red">PyAutoGUI</font> というライブラリを使って、画像認識を行います。
あらかじめ保存しておいた退出ボタンの画像から、その位置を探し出して中心座標を取得します。
ボタンの中心までマウスを移動させ、（Google Meet が最前面でない場合も考慮して）ダブルクリックをさせれば退出完了です。

```python
# 自分で設定した退出条件を満たしたら以下のコードを実行する

# 保存した退出ボタン画像の中心座標を取得
X,Y = gui.locateCenterOnScreen('{退出ボタン画像のパス}', confidence=0.9)

# 指定したスクリーン上の位置までマウスカーソルを移動させる
gui.moveTo(X,Y)

# 指定した位置をダブルクリックする
gui.doubleClick(X,Y)
```

（Zoom の場合はミーティング退出ボタンが２段階あるため、２段階目は Enter キーを押させることによって実現します）

【参考にしたサイト】
[【自動化】Pythonでマウスとキーボードを操る \- Qiita](https://qiita.com/konitech913/items/301bb63c8a69c3fcb1bd)

#5. 配布用にアプリを exe 化する（沼りポイント）
Python コードを <font color="Red">**exe ファイル**（実行ファイル）</font>に変換します。
これによって、Python がインストールされていない PC でもデスクトップアプリを実行することができます。
しかし私は、この exe 化のステップを達成するのに**２ヵ月以上**かかってしまいました…。

通常であれば <font color="Red">Pyinstaller</font> で以下のように実行するだけで、Python コードを exe ファイルに変換できます。

```bash
pip install pyinstaller
pyinstaller main.py
```
しかし、完成した exe ファイルを実行しても、次のようなエラーが無限に出てくるはずです。
```Running from container, but no tessdata found !```

これは Tesseract OCR が ```pip install``` ではないので、<font color="Red">通常の exe 化だけではユーザー自身に Tesseract をインストールさせる必要があるため</font>です。
ユーザーに迷惑をかけないように、以下のサイトを参考にして２つの変更を加えます。

#### ① 環境変数 PATH に Tesseract OCR のパスを通す
> 【参考】 [pythonとOCRでyoutube広告自動スキップツール作成 \- Qiita](https://qiita.com/chuntaweb/items/998ec0da7988a860c747)

```python
os.environ["PATH"] += os.pathsep + os.path.dirname(os.path.abspath(__file__)) + os.sep + '{<フォルダ名> Tesseract (ここにTesseract-OCRの中身を丸ごと入れる)}'
```

#### ② 配布用フォルダ dist 内のディレクトリ構造を変更する
> 【参考】 [pyocr を使っていて pyinstaller でパッケージングすると WARNING が出てくる件について \- Qiita](https://qiita.com/bass_clef_/items/1d0f7b987223f9ddc9f6)

この記事の摩訶不思議なディレクトリ構造がなければ、一歩も前に進めなかったところですが、少しだけ補足説明を付け足しておきます。
![8M2miGy1p5lxiRm1634024459_1634024477.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/832523/a9a46e8b-ce00-4143-87fb-879891ffb0d8.jpeg)

<details><summary>変更後の配布用フォルダのディレクトリ構造</summary><div>

```
├─build
│  └─main
├─dist
│  └─main
│      ├─data
│      │  ├─configs
│      │  ├─script
│      │  ├─tessconfigs
│      │  └─tessdata
│      │      ├─configs
│      │      ├─script
│      │      └─tessconfigs
│      ├─Tesseract
│      │  ├─data
│      │  │  ├─configs
│      │  │  ├─script
│      │  │  ├─tessconfigs
│      │  │  └─tessdata
│      │  │      ├─configs
│      │  │      ├─script
│      │  │      └─tessconfigs
│      │  ├─doc
│      │  └─tessdata
│      :
│ 
└─__pycache__
```
</div></details>

依然として複雑であることに変わりはないので、分からないことがあったら連絡してください。

# 全体コード
```main.py
import PySimpleGUI as sg
import cv2
import pyocr
import datetime
import numpy as np
import pyautogui as gui
import matplotlib.pyplot as plt
from matplotlib.ticker import MultipleLocator
from PIL import Image
from time import sleep
import io
import os
import sys

# Tesseract のモジュールを格納するフォルダ
RESORSES_FOLDER_NAME = 'Tesseract' 
# Tesseract-OCRのパスを環境変数「PATH」へ追記する
os.environ["PATH"] += os.pathsep + os.path.dirname(os.path.abspath(__file__)) + os.sep + RESORSES_FOLDER_NAME

def resource_path(relative_path):
    if hasattr(sys, '_MEIPASS'):
        return os.path.join(sys._MEIPASS, relative_path)
    return os.path.join(os.path.abspath("."), relative_path)

def ocr_binarized_image(image_path, threshold=0, threshold_type=cv2.THRESH_BINARY):
    img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
    ret, img_thresh = cv2.threshold(img, threshold, 255, threshold_type)
    # print(f"ret:{ret}")
    img2 = cv2.bitwise_not(img_thresh)
    thresh_path = image_path
    cv2.imwrite(thresh_path, img2)

    tools = pyocr.get_available_tools()
    if len(tools) == 0:
        print("No OCR tool found")
        sys.exit(1)
    tool = tools[0]
    # print("Will use tool '%s'" % (tool.get_name()))

    str_num = tool.image_to_string(
        Image.open(thresh_path),
        lang='eng',
        builder = pyocr.builders.TextBuilder(tesseract_layout = 6)
    )
    return int(str_num)


# ボタン押下により会議を退出する関数
def leave_meeting(image_path, app):
    X,Y = gui.locateCenterOnScreen(image_path, confidence=0.9)
    gui.moveTo(X,Y)
    if app == 'Google Meet':
        gui.doubleClick(X,Y)
    elif app == 'Zoom':
        gui.click(X,Y)
        gui.press('enter')

x = []
y = []
exit_cnt = 0
max_num = 0

def make_data_fig():
    global x,y
    fig = plt.figure()
    ax = fig.add_subplot(111)
    ax.set_xlabel('Time')
    ax.set_ylabel('Number of People')
    ax.yaxis.set_major_locator(MultipleLocator(1))
    ax.grid(color='black', linestyle=':', linewidth=0.3, alpha=0.5)
    ax.set_title('Real-Time Line Graph')
    ax.plot(x, y, marker='.')
    return fig

def draw_plot_image(fig):
    item = io.BytesIO()
    plt.savefig(item, format='png')
    plt.clf()
    return item.getvalue()


def display_main(participant_path, exit_path, alpha, app, denominator, times):
    global x,y,max_num,exit_cnt
    layout = [[sg.Checkbox('グラフ表示',key='-display-', default=True)],
                [sg.Image(filename='', key='-image-')],
                [sg.Text('繰り返し間隔（s）:', size=(20,1)), sg.Slider((0,10), default_value=0, orientation='h', key='-delay-')],
                [sg.Button('終了'), sg.Button('ホーム')], 
                ]

    window = sg.Window('SkiMee 【 リアルタイムビュー 】', layout=layout,  font='メイリオ', alpha_channel=alpha, icon=resource_path('SkiMee.ico'))

    while True:
        event, values = window.read(timeout=0)
        if event in (None, '終了'):
            break
        elif event == 'ホーム':
            window.close()
            return True
        
        try:
            X,Y,W,H = gui.locateOnScreen(participant_path, confidence=0.8)
            if app == 'Google Meet':
                cropped_img = gui.screenshot(region=(X+W/2,Y-2*H/3,W,2*H/3))
            elif app == 'Zoom':
                cropped_img = gui.screenshot(region=(X+W,Y,2*W/3,4*H/5))
            cropped_img.save('.\\sankasha.png')
            num = ocr_binarized_image('.\\sankasha.png',127,cv2.THRESH_BINARY)

            if num:
                if num < max_num/denominator:
                    exit_cnt += 1
                elif exit_cnt > 0:
                    exit_cnt = 0
                
                max_num = max(num, max_num)
                x.append(datetime.datetime.now())
                y.append(num)
            
        except Exception as e:
            print(e)

        if values['-display-']:
            fig_ = make_data_fig()
            fig_bytes = draw_plot_image(fig_)
            window['-image-'].update(data=fig_bytes)
        else:
            window['-image-'].update(filename='')

        if exit_cnt >= times:
            exit_cnt = 0
            leave_meeting(exit_path, app)
            break
        sleep(values['-delay-'])

    window.close()


# ステップ2. デザインテーマの設定
sg.theme('BlueMono')

# ステップ3. ウィンドウの部品とレイアウト
layout = [
    [sg.Text('※ Zoom / Google Meet の「参加者数アイコン」と「退出ボタン」のスクリーンショットを撮ってアップロードしてください。')],
    [sg.Text('▶ 使用するオンライン会議アプリを選択：'), sg.Combo(('Google Meet', 'Zoom'), default_value='Google Meet', key='-app-'), sg.Button('更新', key='-refresh-')],
    [sg.Text('Google Meet の場合は、スクリーンショット時に Chrome のズームを 200% にするのがオススメです。', key='-caution-')],
    [sg.Text('▼ アップロード画像(1) 参加者数アイコン'), sg.Text('※ ファイルパスに日本語を含まないようにしてください', text_color='#ff0000')],
    [sg.Image(filename=resource_path('406-203.png'), size=(406,203), key='-img1-')],
    [sg.Input(), sg.FileBrowse('ファイルを選択', key='inputFilePath1')],
    [sg.Text('▼ アップロード画像(2) 退出ボタン'), sg.Text('※ ファイルパスに日本語を含まないようにしてください', text_color='#ff0000')],
    [sg.Image(filename=resource_path('239-203.png'), size=(239,203), key='-img2-')],
    [sg.Input(), sg.FileBrowse('ファイルを選択', key='inputFilePath2')],
    [sg.Text('▶ ウィンドウ不透明度(0=非表示,1=完全に表示)：'), sg.Slider((0.0,1.0), default_value=1.0, resolution=0.1, orientation='h', key='-alpha-')],
    [sg.Text('▶ 自動退出する条件を選択：'), sg.Text('最大参加人数の'), sg.Combo(('1/2未満 ','1/3未満 '), default_value='1/2未満 ', key='-denominator-'), sg.Text('が'), sg.Combo(('1回連続 ','2回連続 ','3回連続 '), default_value='2回連続 ', key='-times-')],
    [sg.Button('はじめる', key='-start-')],
]

# ステップ4. ウィンドウの生成
window = sg.Window('SkiMee 【 自動退出支援プログラム 】', layout=layout, font='メイリオ', icon=resource_path('SkiMee.ico'))

# ステップ5. イベントループ
while True:
    event, values = window.read()

    if event == '-refresh-':
        if values['-app-'] == 'Zoom':
            window['-caution-'].update('Zoom の場合は、全画面表示にするのがオススメです。')
            window['-img1-'].update(filename=resource_path('591-203.png'))
            window['-img2-'].update(filename=resource_path('332-203.png'))
        elif values['-app-'] == 'Google Meet':
            window['-caution-'].update('Google Meet の場合は、スクリーンショット時に Chrome のズームを 200% にするのがオススメです。')
            window['-img1-'].update(filename=resource_path('406-203.png'))
            window['-img2-'].update(filename=resource_path('239-203.png'))

    if event == sg.WIN_CLOSED:
        break

    elif event == "-start-":
        denominator = 2 if values['-denominator-'].startswith('1/2') else 3
        times = 2 if values['-times-'].startswith('2') else 3
         
        window.Hide() # アップロード画面を隠す
        # メイン画面を表示する
        main_return = display_main(values['inputFilePath1'], values['inputFilePath2'], values['-alpha-'], values['-app-'], denominator, times)
        # もしNoneが返ってきたらアップロード画面も終了させる
        if main_return is None:
            break
        elif main_return == True:
            window.UnHide() # アップロード画面を再表示する

window.close()
```

<details><summary>main.spec</summary><div>

```main.spec
# -*- mode: python ; coding: utf-8 -*-


block_cipher = None


a = Analysis(['main.py'],
             pathex=['{main.py があるフォルダまでのパス}'],
             binaries=[],
             datas=[],
             hiddenimports=[],
             hookspath=[],
             hooksconfig={},
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=block_cipher,
             noarchive=False)
a.datas += [('239-203.png', '.\\239-203.png', 'DATA'),
('406-203.png', '.\\406-203.png', 'DATA'),
('332-203.png', '.\\332-203.png', 'DATA'),
('591-203.png', '.\\591-203.png', 'DATA'),
('SkiMee.ico', '.\\SkiMee.ico', 'DATA')
]
pyz = PYZ(a.pure, a.zipped_data,
             cipher=block_cipher)

exe = EXE(pyz,
          a.scripts, 
          [],
          exclude_binaries=True,
          name='main',
          debug=False,
          bootloader_ignore_signals=False,
          strip=False,
          upx=True,
          console=True,
          icon='SkiMee.ico',
          disable_windowed_traceback=False,
          target_arch=None,
          codesign_identity=None,
          entitlements_file=None )
coll = COLLECT(exe,
               a.binaries,
               a.zipfiles,
               a.datas, 
               strip=False,
               upx=True,
               upx_exclude=[],
               name='main')

```
</div></details>


# おわりに
初めて Python & PySimpleGUI でデスクトップアプリを作り、主に Tesseract-OCR 絡みで多数のエラーに悩まされましたが、なんとかリリースすることができました。
先人たちの知恵をかき集めて作ったアプリなので、よければ使ってみてください！！
この記事が少しでも誰かのお役に立てれば幸いです。