#はじめに
新型コロナウイルスの影響で、昨年度から学校・企業にオンライン授業・会議が普及し始めました。
感染リスクを減らせるというメリットがある一方、
「なかなか集中できず眠くなってしまう」
という意見も……。
私もずっと同じ悩みを抱えていたので、上手くオンライン講義をサボれる方法を模索していました。
そこで思い付いたのが、オンライン会議の**「自動退出」**です!
第1弾(設計~テスト実行) | 第2弾(アプリ化・配布~使用上の注意) |
---|---|
【全人類待望】寝落ちが合法に!? オンライン授業を自動退出してくれるアプリを作ってみた - YouTube | 【歓喜】ついに、Zoom / Meet 自動退出アプリを無料配布します!!Z世代にふさわしい最強のオンライン攻略法とは…!? - YouTube |
私の大学では Google Meet を使用することが多いですが、Zoom を普段利用している人も多いと思うので、両方に対応できるアプリを開発しました。
「デスクトップアプリだけ使ってみたい!」という人は
ダウンロードページ ( SkiMee【自動退出支援アプリ】) |
---|
をご覧ください。
ローカル環境で動くアプリを開発するのにそれほど時間はかからなかったのですが、exe 化のステップで2ヵ月以上詰まってしまったので、注意点も合わせて記事にできればと思います。
#0. UI を観察しながら作戦を練る
【ポイント】オンライン会議(授業)は、決まった時間に終わらない!
そこで今回は、参加者数をリアルタイムで監視して自動退出するプログラミングを考えます。
「 Web サイトから特定の情報(参加者数)だけを抽出する」という目標を達成するために真っ先に思いつくのはスクレイピングです。
しかし、リアルタイム(3秒に1回くらい)で参加者数を知りたい場合にスクレイピングを用いると、相手サーバーに大きな負荷がかかってしまうため、別の安全な方法を用います。
画像認識によって、参加者数を画面上のアイコンから読み取り、退出時は退出ボタンの位置をスクリーンから見つけ出して、自動でマウスを操作させる作戦を立てました。
(以下、Google Meet の UI を例に説明しますが、Zoom でも基本的な原理は同じです。)
#1. 参加者数アイコンの位置を探り、キャプチャする
PyAutoGUI というライブラリの画像認識は、一言で言うと**「ハンパない」**です。
あらかじめ保存しておいた参加者数アイコンの画像から、その位置を探し出して座標を取得してくれるのです。
さらに、認識した「👥」の位置から、その右肩にある数字の範囲だけのスクリーンショットを撮って保存します。
pip install pyautogui
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】 | ジコログ
Screenshot Functions — PyAutoGUI documentation
#2. OCR で画像から参加者数を読み取る
Python と Tesseract OCR を使用して参加者数アイコンから数字を読み取ります。
Tesseract OCR とは
※ Tesseract OCR は Python のモジュールではないため、通常の pip コマンドではなく、以下のサイトに沿ってインストールしてください。
Python+Tesseractによる画像処理でOCRを試してみた! – 株式会社ライトコード
また、Pythonで Tesseract OCRを扱うためには、PyOCR というライブラリを使用します。
pip install pyocr
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. リアルタイムでグラフに描画する
ステップ2を継続的に実行することにより、リアルタイムで参加者数を得ることができます。
今回は、人数の変化を折れ線グラフで可視化して、見ていて楽しいアプリを作ることにしました。
Python で GUI アプリケーションを作れるライブラリとして、「Tkinter」「Kivy」「PyQt」「wxPython」などがよく紹介されます。
この中で、標準ライブラリである Tkinter を一度は使ってみましたが、
Tkinterを使うのであればPySimpleGUIを使ってみたらという話 - Qiita
という記事にすごく影響されて、結局 PySimpleGUI でアプリを作りました。
pip install pysimplegui
グラフの描画には Matplotlib という定番のライブラリを使いました。
詳しいコードは、「全体コード」の章をご覧ください。
#4. 退出条件を満たしたらボタンを押す
再び、PyAutoGUI というライブラリを使って、画像認識を行います。
あらかじめ保存しておいた退出ボタンの画像から、その位置を探し出して中心座標を取得します。
ボタンの中心までマウスを移動させ、(Google Meet が最前面でない場合も考慮して)ダブルクリックをさせれば退出完了です。
# 自分で設定した退出条件を満たしたら以下のコードを実行する
# 保存した退出ボタン画像の中心座標を取得
X,Y = gui.locateCenterOnScreen('{退出ボタン画像のパス}', confidence=0.9)
# 指定したスクリーン上の位置までマウスカーソルを移動させる
gui.moveTo(X,Y)
# 指定した位置をダブルクリックする
gui.doubleClick(X,Y)
(Zoom の場合はミーティング退出ボタンが2段階あるため、2段階目は Enter キーを押させることによって実現します)
【参考にしたサイト】
【自動化】Pythonでマウスとキーボードを操る - Qiita
#5. 配布用にアプリを exe 化する(沼りポイント)
Python コードを exe ファイル(実行ファイル)に変換します。
これによって、Python がインストールされていない PC でもデスクトップアプリを実行することができます。
しかし私は、この exe 化のステップを達成するのに2ヵ月以上かかってしまいました…。
通常であれば Pyinstaller で以下のように実行するだけで、Python コードを exe ファイルに変換できます。
pip install pyinstaller
pyinstaller main.py
しかし、完成した exe ファイルを実行しても、次のようなエラーが無限に出てくるはずです。
Running from container, but no tessdata found !
これは Tesseract OCR が pip install
ではないので、通常の exe 化だけではユーザー自身に Tesseract をインストールさせる必要があるためです。
ユーザーに迷惑をかけないように、以下のサイトを参考にして2つの変更を加えます。
① 環境変数 PATH に Tesseract OCR のパスを通す
os.environ["PATH"] += os.pathsep + os.path.dirname(os.path.abspath(__file__)) + os.sep + '{<フォルダ名> Tesseract (ここにTesseract-OCRの中身を丸ごと入れる)}'
② 配布用フォルダ dist 内のディレクトリ構造を変更する
【参考】 pyocr を使っていて pyinstaller でパッケージングすると WARNING が出てくる件について - Qiita
この記事の摩訶不思議なディレクトリ構造がなければ、一歩も前に進めなかったところですが、少しだけ補足説明を付け足しておきます。
変更後の配布用フォルダのディレクトリ構造
├─build
│ └─main
├─dist
│ └─main
│ ├─data
│ │ ├─configs
│ │ ├─script
│ │ ├─tessconfigs
│ │ └─tessdata
│ │ ├─configs
│ │ ├─script
│ │ └─tessconfigs
│ ├─Tesseract
│ │ ├─data
│ │ │ ├─configs
│ │ │ ├─script
│ │ │ ├─tessconfigs
│ │ │ └─tessdata
│ │ │ ├─configs
│ │ │ ├─script
│ │ │ └─tessconfigs
│ │ ├─doc
│ │ └─tessdata
│ :
│
└─__pycache__
依然として複雑であることに変わりはないので、分からないことがあったら連絡してください。
全体コード
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()
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')
おわりに
初めて Python & PySimpleGUI でデスクトップアプリを作り、主に Tesseract-OCR 絡みで多数のエラーに悩まされましたが、なんとかリリースすることができました。
先人たちの知恵をかき集めて作ったアプリなので、よければ使ってみてください!!
この記事が少しでも誰かのお役に立てれば幸いです。