Pythonでディスプレイ上のテキストをOCRで定期的に抽出するの巻
はじめに
今回は、ディスプレイ上に表示されている時間/数字のテキストを定期的にOCRで抽出し、簡単な計算を施すプログラムをPythonを使って作りました、という話です。
背景としまして、ワタシは現在とあるクラウドソーシングのお仕事を受けているのですが、1つのタスクに費やしている時間が一定の基準を超えると契約解除の対象になってしまうので、、、タスクごとの作業時間と、その平均時間を作業中に秒単位で把握しながら作業しないといけない状態なのです。クビになりたく無いのよ。。。
その時間管理のためにClockifyというウェブアプリケーションのストップウォッチ機能でタスクの作業時間を追跡・記録しています。
Clockifyによって現在のタスクの作業時間を把握することに加えて、日・週・月に何を何時間作業したか確認することができるのでとても便利なのですが、単位作業あたりの平均時間をリアルタイムで表示することはできない模様(知らないだけかも)。なので手作業で計算していました。これが案外面倒だなと。
そこで、上記の計算作業を自動化する簡易ツールをPythonで作ったのでした。
こんな感じで(小さくて見にくいかも)。
これにより、本来の作業により集中できるようになりましたよ。
というわけで前置きが長くなりましたが、
「Pythonでディスプレイ上のテキストをOCRで定期的に抽出する方法」
とでも言いましょうか、メモとして残しておくことにします。
個人用途で使うためにざっくり作っていますし、もっといい他の方法もあると思いますので、ご参考までに。
コードは記事内に載せてありますので、もしご興味がありましたらライセンス遵守下でご自由にお使いください。部分的にでも、うっかりどなたかの役に立つことがあれば嬉しいです。
それでは紹介していきたいと思います。
ざっくり説明
とりあえず概要をば。2つのPythonスクリプトで構成しています。get_sec_per_task.pyをターミナルで実行することでプログラムを開始します。
get_sec_per_task.py : Tkinter GUI
- Tkinterを使用して描画ウィンドウ(半透明)を表示する
- ユーザーが描画ウィンドウの上からストップウォッチの時間表示領域を囲む四角形を描画し、Stopwatchボタンを押すことでその座標情報をファイルに保存する
- ユーザーが描画ウィンドウの上からタスク数表示領域を囲む四角形を描画し、Tasksボタンを押すことでその座標情報をファイルに保存する
- OCRと取得情報表示スクリプトであるcapture_and_ocr.pyを、Get Sec/Taskボタンを押すことで実行する
capture_and_ocr.py : OCRと情報表示スクリプト
- get_sec_per_task.pyにより保存された各座標情報ファイルを読み込み、各座標に対応した画面領域をキャプチャする
- 光学文字認識(OCR)のモジュールであるpytesseractを使用してテキストを抽出する
- 抽出されたテキストからストップウォッチの時間とタスク数を取得し、1タスクあたりの平均時間を計算する
- 定期的にこれらの情報を取得し、ターミナルに表示する
*プログラムはControl + Cで終了します。
*キャプチャ対象部分が他のウィンドウに隠れると計算値は出せません。再び最前面に戻せば計算できます。
*キャプチャ対象部分を動かしてしまうと、座標設定をやりなおさないといけません。
実施環境など
MacBook Pro 2021 (Apple M1 Pro)
macOS Sonoma 14.0
Python 3.11.5
Pillow 10.0.1 (画像処理ライブラリ)
pynput 1.7.6 (キーボードとマウスの制御を行うためのライブラリ)
pytesseract 0.3.10 (光学文字認識(OCR)ライブラリ)
tkinter 0.1.0 (PythonのGUIを作成するためのライブラリ)
詳しく説明
get_sec_per_task.py
このPythonスクリプトは、Tkinterを使用して半透明のGUIウィンドウを作成し、ユーザーがそのウィンドウ上でOCRでのキャプチャ位置を指定するための四角形を描画できるようにするものです。描画された四角形の座標情報は、StopwatchボタンまたはTasksボタンを押すことでそれぞれjsonファイルに保存されます。最後にGet Sec/Taskボタンを押すことで、capture_and_ocr.pyを呼び出して実行します。
import tkinter as tk
import json
import subprocess
# グローバル変数を初期化
drawing = False
start_x, start_y = None, None
rectangle_coordinates = None # 描画された四角形の座標情報を格納
def run_another_script():
# original
subprocess.Popen(['/opt/homebrew/bin/python3', 'capture_and_ocr.py'])
def start_drawing(event):
global drawing, start_x, start_y
drawing = True
start_x, start_y = event.x, event.y
def draw_rectangle(event):
global start_x, start_y, rectangle_coordinates
if drawing:
current_x, current_y = event.x, event.y
canvas.delete("temp_rectangle") # 一時的な四角形を削除
canvas.create_rectangle(start_x, start_y, current_x, current_y, outline="red", width=2, tags="temp_rectangle")
rectangle_coordinates = (start_x, start_y, current_x, current_y)
def stop_drawing(event):
global drawing, rectangle_coordinates, start_x, start_y
if drawing:
current_x, current_y = event.x, event.y
canvas.delete("temp_rectangle") # 一時的な四角形を削除
canvas.create_rectangle(start_x, start_y, current_x, current_y, outline="red", width=2)
drawing = False
# ウィンドウ座標をディスプレイ座標に変換
start_x, start_y = canvas.winfo_rootx() + start_x, canvas.winfo_rooty() + start_y
current_x, current_y = canvas.winfo_rootx() + current_x, canvas.winfo_rooty() + current_y
rectangle_coordinates = start_x, start_y, current_x, current_y
# 四角形の座標情報を示
print("描画された四角形の座標情報:", rectangle_coordinates)
# print("描画された四角形の座標情報:", start_x, start_y, current_x, current_y)
def save_to_stopwatch_file():
global rectangle_coordinates
if rectangle_coordinates:
print("保存された四角形の座標情報 stopwatch用:", rectangle_coordinates)
with open('stopwatch_time_position.json', 'w') as json_file:
json.dump(rectangle_coordinates, json_file)
def save_to_tasks_file():
global rectangle_coordinates
if rectangle_coordinates:
print("保存された四角形の座標情報 tasks用:", rectangle_coordinates)
with open('numeric_value_position.json', 'w') as json_file:
json.dump(rectangle_coordinates, json_file)
root = tk.Tk()
root.attributes('-alpha', 0.5) # ウィンドウの透明度を設定
# ディスプレイの高さと幅を取得
screen_height = root.winfo_screenheight()
window_width = root.winfo_screenwidth()
# ウィンドウの幅と高さをディスプレイの高さに合わせる
root.geometry(f"{window_width}x{screen_height}")
canvas = tk.Canvas(root, width=window_width, height=screen_height)
canvas.grid(row=1, column=0, columnspan=3)
canvas.bind("<ButtonPress-1>", start_drawing)
canvas.bind("<B1-Motion>", draw_rectangle)
canvas.bind("<ButtonRelease-1>", stop_drawing)
# "Stopwatch" ボタンを追加
stopwatch_button = tk.Button(root, text="Stopwatch", command=save_to_stopwatch_file)
stopwatch_button.grid(row=0, column=0, padx=10, pady=10)
# "Tasks" ボタンを追加
tasks_button = tk.Button(root, text="Tasks", command=save_to_tasks_file)
tasks_button.grid(row=0, column=1, padx=10, pady=10)
# Get Sec/Taskボタンを追加
get_sec_per_task_button = tk.Button(root, text="Get Sec/Task", command=run_another_script)
get_sec_per_task_button.grid(row=0, column=2, padx=10, pady=10)
root.mainloop()
以下、説明です。
# グローバル変数を初期化
drawing = False
start_x, start_y = None, None
rectangle_coordinates = None # 描画された四角形の座標情報を格納
これらの行では、いくつかのグローバル変数を初期化しています。これらの変数は、描画中の情報や座標情報を追跡するために使用しています。
def run_another_script():
# original
subprocess.Popen(['/opt/homebrew/bin/python3', 'capture_and_ocr.py'])
この関数 run_another_script
は、別のPythonスクリプト capture_and_ocr.py
を実行するためのものです。このスクリプトは後で呼び出されます。
def start_drawing(event):
global drawing, start_x, start_y
drawing = True
start_x, start_y = event.x, event.y
start_drawing
関数は、ユーザーが四角形の描画を開始すると呼び出されます。描画フラグを有効にし、描画を開始する座標を記録します。
def draw_rectangle(event):
global start_x, start_y, rectangle_coordinates
if drawing:
current_x, current_y = event.x, event.y
canvas.delete("temp_rectangle") # 一時的な四角形を削除
canvas.create_rectangle(start_x, start_y, current_x, current_y, outline="red", width=2, tags="temp_rectangle")
rectangle_coordinates = (start_x, start_y, current_x, current_y)
draw_rectangle
関数は、四角形を描画する処理を行います。描画中の場合、一時的な四角形を描画し、その座標情報を rectangle_coordinates
に格納します。
def stop_drawing(event):
global drawing, rectangle_coordinates, start_x, start_y
if drawing:
current_x, current_y = event.x, event.y
canvas.delete("temp_rectangle") # 一時的な四角形を削除
canvas.create_rectangle(start_x, start_y, current_x, current_y, outline="red", width=2)
drawing = False
start_x, start_y = canvas.winfo_rootx() + start_x, canvas.winfo_rooty() + start_y
current_x, current_y = canvas.winfo_rootx() + current_x, canvas.winfo_rooty() + current_y
rectangle_coordinates = start_x, start_y, current_x, current_y
print("描画された四角形の座標情報:", rectangle_coordinates)
stop_drawing
関数は、四角形の描画を終了する際に呼び出されます。一時的な四角形を削除し、描画された四角形の座標情報を計算し、表示します。
def save_to_stopwatch_file():
global rectangle_coordinates
if rectangle_coordinates:
print("保存された四角形の座標情報 stopwatch用:", rectangle_coordinates)
with open('stopwatch_time_position.json', 'w') as json_file:
json.dump(rectangle_coordinates, json_file)
save_to_stopwatch_file
関数は、”Stopwatch” ボタンが押されたときに呼び出され、ストップウォッチの時間表示場所を囲った四角形の座標情報を JSON ファイルに保存します。
def save_to_tasks_file():
global rectangle_coordinates
if rectangle_coordinates:
print("保存された四角形の座標情報 tasks用:", rectangle_coordinates)
with open('numeric_value_position.json', 'w') as json_file:
json.dump(rectangle_coordinates, json_file)
save_to_tasks_file
関数は、”Tasks” ボタンが押されたときに呼び出され、タスク数の表示場所を囲った四角形の座標情報を別の JSON ファイルに保存します。
root = tk.Tk()
root.attributes('-alpha', 0.5) # ウィンドウの透明度を設定
Tkinterを使用してGUIウィンドウを作成し、ウィンドウの透明度を設定しています。
screen_height = root.winfo_screenheight()
window_width = root.winfo_screenwidth()
root.geometry(f"{window_width}x{screen_height}")
ディスプレイの高さと幅を取得し、ウィンドウをディスプレイの高さと幅に合わせるように設定しています。
canvas = tk.Canvas(root, width=window_width, height=screen_height)
canvas.grid(row=1, column=0, columnspan=3)
tk.Canvas
ウィジェットを作成し、ウィンドウ内に配置しています。このキャンバス上で四角形を描画します。
canvas.bind("<ButtonPress-1>", start_drawing)
canvas.bind("<B1-Motion>", draw_rectangle)
canvas.bind("<ButtonRelease-1>", stop_drawing)
キャンバス上のマウスイベントをハンドリングするためのイベントバインディングを設定しています。マウスボタンを押したとき、ドラッグ中、ボタンを離したときにそれぞれ対応する関数が呼び出されます。
stopwatch_button = tk.Button(root, text="Stopwatch", command=save_to_stopwatch_file)
stopwatch_button.grid(row=0, column=0, padx=10, pady=10)
tasks_button = tk.Button(root, text="Tasks", command=save_to_tasks_file)
tasks_button.grid(row=0, column=1, padx=10, pady=10)
get_sec_per_task_button = tk.Button(root, text="Get Sec/Task", command=run_another_script)
get_sec_per_task_button.grid(row=0, column=2, padx=10, pady=10)
最後に、3つのボタンを作成し、それぞれのボタンに対するクリック時のコマンドを設定しています。
capture_and_ocr.py
このスクリプトは、マウスクリックイベントをリッスンし、クリックイベントをトリガーとして、指定された座標領域のスクリーンショットを取得し、OCRを使用してテキストを抽出します。抽出されたテキストからストップウォッチの時間情報と数値情報を取得し、1タスクあたりの作業時間の平均値を計算して定期的に表示します。一旦スクリプトを開始したらクリックイベントを無効にし、通常の作業に影響が出ないようにしています。
from pynput import mouse
import pyautogui
from PIL import Image
import pytesseract
import re
import time
import threading
import json
# 指定した時間間隔(秒)ごとに結果を取得して表示
interval = 5 # 5秒ごと
# クリックを有効化するかどうかのフラグ
click_enabled = True
# scaling factor; MacBookPro:2
scaling_factor = 2
total_seconds = None # 初期値をNoneに設定
stopwatch_time_position = [None, None, None, None]
numeric_value_position = [None, None, None, None]
def load_stopwatch_time_position(filename):
try:
with open(filename, 'r') as json_file:
position_list = json.load(json_file)
#ディスプレイの座標に変換するために各座標の数値をscaling factorで乗算して置換する
# position_list = [x * 2 for x in position_list]
position_list = [x * scaling_factor for x in position_list]
return position_list
except FileNotFoundError:
print(f"File '{filename}' not found.")
return None
except json.JSONDecodeError as e:
print(f"Error decoding JSON in '{filename}': {e}")
return None
# JSONファイルを読み込んで変数に格納
stopwatch_time_position = load_stopwatch_time_position('stopwatch_time_position.json')
numeric_value_position = load_stopwatch_time_position('numeric_value_position.json')
def on_click(x, y, button, pressed):
global click_enabled, stopwatch_time_position, numeric_value_position
global click_down_x, click_down_y, click_up_x, click_up_y
global total_seconds
if not click_enabled:
print("click event 無効中")
return # クリックイベントが無効な場合、処理をスキップし、通常の作業ができるようにする。
if pressed:
# 押したときは何もしない
pass
else:
# クリックのリリースが検出された場合、メッセージを表示
print('Click Released.')
# 指定領域をキャプチャしてOCRを実行
captured_text1 = capture_and_ocr(stopwatch_time_position[0],stopwatch_time_position[1],stopwatch_time_position[2],stopwatch_time_position[3])
# OCR結果から時間情報または数値情報を抽出し、変数に格納
stopwatch_time = extract_stopwatch_time(captured_text1)
if stopwatch_time:
# 秒に変換
total_seconds = convert_time_to_seconds(stopwatch_time)
print('Stopwatch Time:', stopwatch_time)
print('Stopwatch Time (Seconds):', total_seconds)
else:
print('No valid data detected for stopwatch_time.')
# 指定領域をキャプチャしてOCRを実行
captured_text2 = capture_and_ocr(numeric_value_position[0],numeric_value_position[1],numeric_value_position[2],numeric_value_position[3])
numeric_value = extract_and_convert_to_numeric(captured_text2)
if numeric_value is not None:
# 数値の場合
print('Numeric Value:', numeric_value)
# total_secondsをnumeric_valueで割って平均値を算出し表示
# result = total_seconds / numeric_value
result = round(total_seconds / numeric_value, 1)
print('Sec/Task: ', result, " = ", total_seconds, " / ", numeric_value)
click_enabled = False # クリックイベントを無効にする
else:
print('No valid data detected for numeric_value.')
def capture_and_ocr(start_x, start_y, end_x, end_y):
# 領域をキャプチャ
screenshot = pyautogui.screenshot(region=(start_x, start_y, end_x - start_x, end_y - start_y))
# 画像を保存 (必要に応じて)
# screenshot.save('screenshot.png')
# 画像をOCRでテキストに変換
# ページセグメンテーションオプション--psm 7を指定すると1桁の数値も認識できるようになった
text = pytesseract.image_to_string(screenshot, config='--psm 7')
# 抽出されたテキストを表示
print('Captured Text: ', text)
return text
def extract_stopwatch_time(text):
# テキストから時間情報を抽出する正規表現パターン
# hh:mm:ss形式の時間を抽出
pattern = r'(\d{1,2}:\d{2}:\d{2})'
# 正規表現パターンに一致する部分を抽出
match = re.search(pattern, text)
if match:
# 正規表現に一致する部分があれば時間情報を返す
return match.group(0)
else:
# 一致する部分がない場合はpass
pass
def convert_time_to_seconds(time_str):
# 時間情報を時間、分、秒に分割
hours, minutes, seconds = map(int, time_str.split(':'))
# 合計秒数に変換
total_seconds = hours * 3600 + minutes * 60 + seconds
return total_seconds
def extract_and_convert_to_numeric(text):
# テキストから数値情報を抽出する正規表現パターン
pattern = r'(\d+\.\d+|\d+|\d)'
# 正規表現パターンに一致する部分を抽出
matches = re.findall(pattern, text)
if matches:
# 正規表現に一致する部分があれば、最初の部分を取り出して数値に変換
numeric_str = matches[0]
try:
numeric_value = float(numeric_str) # 数値に変換
return numeric_value
except ValueError:
return None # 数値に変換できない場合はNoneを返す
else:
return None # 一致する部分がない場合はNoneを返す
def get_timer_result():
if stopwatch_time_position is None and numeric_value_position is None:
return None # どちらかの保存された座標がない場合はNoneを返す
# スクリーンショットを取得してOCRを実行
captured_text_stop_watch = capture_and_ocr(stopwatch_time_position[0], stopwatch_time_position[1], stopwatch_time_position[2], stopwatch_time_position[3])
captured_text_numeric_value = capture_and_ocr(numeric_value_position[0], numeric_value_position[1], numeric_value_position[2], numeric_value_position[3])
# OCR結果から時間情報または数値情報を抽出
stopwatch_time = extract_stopwatch_time(captured_text_stop_watch)
numeric_value = extract_and_convert_to_numeric(captured_text_numeric_value)
if stopwatch_time is not None and numeric_value is not None:
# 時間形式の場合
total_seconds = convert_time_to_seconds(stopwatch_time)
result = round(total_seconds / numeric_value, 1)
current_result = f'Sec/Task: {result} = {total_seconds} / {numeric_value}'
return current_result
else:
return None # 適切な情報が取得できない場合はNoneを返す
def constant_result_get():
global click_enabled
print("")
print("クリックすることでループ処理を開始します")
while True:
if click_enabled!= True:
# result, total_seconds, numeric_value = get_timer_result()
current_result = get_timer_result()
if current_result is not None:
print(current_result)
print("-------------------------------")
print("")
else:
print('No result available now.')
print("---------------------")
# 指定された時間間隔待機
time.sleep(interval)
# スレッドを作成して関数を実行
thread = threading.Thread(target=constant_result_get)
thread.daemon = True # メインプログラムが終了したらスレッドも終了
thread.start()# スレッドを作成して関数を実行
# イベントを受け取るためのリスナーを作成
listener = mouse.Listener(
on_click=on_click)
# リスナーを開始
listener.start()
# リスナーが終了するまでプログラムを実行
listener.join()
root.mainloop()
以下は説明です。
# 指定した時間間隔(秒)ごとに結果を取得して表示
interval = 5 # 5秒ごと
# クリックを有効化するかどうかのフラグ
click_enabled = True
# scaling factor; MacBookProは2
scaling_factor = 2
total_seconds = None # 初期値をNoneに設定
stopwatch_time_position = [None, None, None, None]
numeric_value_position = [None, None, None, None]
この部分では、スクリプトの設定と、結果の取得間隔、クリックを有効にするかどうかのフラグ、ディスプレイのスケーリングファクター、および各種情報の初期化を行っています。
注;ディスプレイのスケーリングファクターが合っていないと座標がずれてしまいます。
def load_stopwatch_time_position(filename):
# ...
load_stopwatch_time_position
関数は、スクリーン上の特定の座標情報を含むJSONファイルを読み込みます。
extract_stopwatch_time
関数は、OCRから抽出されたテキストからストップウォッチの時間情報を抽出します。
# JSONファイルを読み込んで変数に格納
stopwatch_time_position = load_stopwatch_time_position('stopwatch_time_position.json')
numeric_value_position = load_stopwatch_time_position('numeric_value_position.json')
JSONファイルから読み込んだ座標情報を変数に格納します。
def on_click(x, y, button, pressed):
# ...
on_click
関数は、マウスクリックイベントが発生した際に呼び出されます。クリックイベントを検出し、指定された領域のスクリーンショットを取得し、OCRを実行して情報を抽出します。
def capture_and_ocr(start_x, start_y, end_x, end_y):
# ...
capture_and_ocr
関数は、指定された座標範囲のスクリーンショットを取得し、OCRを実行してテキストを抽出します。
def extract_stopwatch_time(text):
# ...
extract_stopwatch_time
関数は、OCRから抽出されたテキストからストップウォッチの時間情報を抽出します。
def convert_time_to_seconds(time_str):
# ...
convert_time_to_seconds
関数は、時間情報を秒に変換します。
def extract_and_convert_to_numeric(text):
# ...
extract_and_convert_to_numeric
関数は、OCRから抽出されたテキストから数値情報を抽出し、数値に変換します。
def get_timer_result():
# ...
get_timer_result
関数は、スクリーンショットを取得し、OCRを実行して時間情報と数値情報を取得し、それを元に Sec/Task
を計算して返します。
def constant_result_get():
# ...
constant_result_get
関数は、定期的に結果を取得し表示するループを実行します。この関数は別のスレッドで実行され、メインプログラムと並行して動作します。
# スレッドを作成して関数を実行
thread = threading.Thread(target=constant_result_get)
thread.daemon = True # メインプログラムが終了したらスレッドも終了
thread.start() # スレッドを作成して関数を実行
constant_result_get
関数を実行するためのスレッドを作成し、開始します。
# イベントを受け取るためのリスナーを作成
listener = mouse.Listener(
on_click=on_click)
# リスナーを開始
listener.start()
# リスナーが終了するまでプログラムを実行
listener.join()
マウスクリックイベントをリッスンするリスナーを作成し、プログラムを実行します。このリスナーはプログラムが終了するまで実行し続けます。
おわりに
はい、今回はPythonで光学文字認識(OCR)を使ってディスプレイ上に表示されたテキスト(ストップウォッチの時間情報と数値情報)を抽出、取得して、単位数あたりの平均時間を表示するためのプログラムメモでした。
今まではタスクをいくつか終わらせたらその都度、電卓アプリを起動しては平均時間を計算していましたから、随分と楽になりました。
せっかく作ったし、これを応用して他にも何かできないかしらと思う今日この頃。。。
記事はこれでおしまいです。最後までお読みいただきありがとうございました。
コードのライセンス
ライセンスはMITライセンスとさせてください
Copyright (c) 2023 bu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
ちょっと広告です
https://business.xserver.ne.jp/
https://www.xdomain.ne.jp/
★LOLIPOP★
.tokyo
MuuMuu Domain!