Pythonでスクリーンショット自動化（指定秒数毎に指定範囲のスクショを撮って、画面変化があれば保存）

作ったプログラムの備忘録

# はじめに
* 指定秒数毎に指定した範囲のスクリーンショットを撮って、画面に変化があった場合に保存するプログラム
* 動画でキャプチャするほどでもないけど、何度もスクリーンショットボタンを押すのは面倒だったので作成

## 動作テスト環境
OS: Windows 10 Pro 64bit
言語： Python 3.9.13
ライブラリ：OpenCV(opencv-contrib-python) 4.5.5.64
ライブラリ：Pillow 9.1.0
ライブラリ：numpy 1.21.6

# ソースコード
```Python:AutoScreenShot.py
import os
import cv2
import time
import ctypes
from ctypes import windll, wintypes
import numpy as np
from PIL import ImageGrab
from datetime import datetime as dt


def screenshot_position():
    try:
        windll.shcore.SetProcessDpiAwareness(True)
    except:
        pass

    def position_get():
        point = wintypes.POINT()
        windll.user32.GetCursorPos(ctypes.byref(point))
        return point.x, point.y

    input('取得したい箇所の"左上"にカーソルを当てEnterキー押してください')
    x1, y1 = position_get()
    print(f'X:{str(x1)}, Y:{str(y1)}')
    input('取得したい箇所の"右下"にカーソルを当てEnterキー押してください')
    x2, y2 = position_get()
    print(f'X:{str(x2)}, Y:{str(y2)}')
    with open('screenshot_position.txt', 'w') as f:
        f.write(f'{str(x1)}, {str(y1)}, {str(x2)}, {str(y2)}')


def screenshot_loop(stop_time, dir_path, x1, y1, x2, y2):
    try:
        while True:
            time.sleep(stop_time)
            img = ImageGrab.grab(bbox=(x1, y1, x2, y2), all_screens=True)
            img_ima = np.array(img)
            if 'img_mae' in locals():
                backSub = cv2.createBackgroundSubtractorMOG2()
                fgmask = backSub.apply(img_mae)
                fgmask = backSub.apply(img_ima)
                if np.count_nonzero(fgmask) / fgmask.size * 100 < 0.5:
                    continue
            img.save(f'{dir_path}/{dt.now().strftime("%y%m%d%H%M%S")}.png')
            img_mae = img_ima
    except KeyboardInterrupt:
        print('\n■■キャプチャ終了■■')


def main():
    if not os.path.exists('screenshot_position.txt'):
        print('スクリーンショット位置を設定してください')
        screenshot_position()
    else:
        screenshot_set = input("スクリーンショットする位置を設定しますか？[y/any]")
        if screenshot_set == "y" or screenshot_set == "Y":
            screenshot_position()
    x1, y1, x2, y2 = np.loadtxt('screenshot_position.txt', delimiter=',')
    stop_time = input('撮影間隔(秒)を入力して、ENTERで開始(デフォルト値：2秒、最低値：1秒)：')
    stop_time = 2 if not stop_time else 1 if float(stop_time) < 1 else float(stop_time)
    dir_path = dt.now().strftime('%y%m%d_%H%M')
    if not os.path.exists(dir_path):
        os.mkdir(dir_path)
    print('\nスクリーンショットを開始しました\nCtl+cで終了できます')
    screenshot_loop(stop_time, dir_path, x1, y1, x2, y2)


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

# 解説

## モジュール

```Python:import
import os
import cv2
import time
import ctypes
import numpy as np
from PIL import ImageGrab
from datetime import datetime as dt
```
* **`numpy`** : <br>画像の背景差分を取得する際にnumpy形式に変換する必要がある。インストールは`pip install numpy`
* **`ImageGrab`** : <br>スクリーンショットを取得するモジュール。インストールは`pip install Pillow`
* **`cv2`** : <br>OpenCV。画面変化を検出するために使用。`createBackgroundSubtractorMOG2()`を使用するため、インストールは`pip install opencv-contrib-python`とする必要がある
* あとは標準モジュール

## 指定秒数間隔でスクリーンショットを取って、画面変化があれば保存する関数

```Python:def screenshot_loop()
def screenshot_loop(stop_time, dir_path, x1, y1, x2, y2):
    try:
        while True:
            time.sleep(stop_time)
            img = ImageGrab.grab(bbox=(x1, y1, x2, y2), all_screens=True)
            img_ima = np.array(img)
            if 'img_mae' in locals():
                backSub = cv2.createBackgroundSubtractorMOG2()
                fgmask = backSub.apply(img_mae)
                fgmask = backSub.apply(img_ima)
                if np.count_nonzero(fgmask) / fgmask.size * 100 < 0.5:
                    continue
            img.save(f'{dir_path}/{dt.now().strftime("%y%m%d%H%M%S")}.png')
            img_mae = img_ima
    except KeyboardInterrupt:
        print('\n■■キャプチャ終了■■')
```
* メインとなる機能。自動でスクリーンショットして、画面変化があれば保存する関数
* 引数
  - **`stop_time`** : スクリーンショット間隔（秒）
  - **`dir_path`** : 保存するディレクトリ名
  - **`x1, y1, x2, y2`** : スクリーンショットを取得する画面の左上座標(x1, y1)と右下座標（x2, y2）

* **`ImageGrab.grab`** : <br>スクリーンショットを撮影。引数`all_screens=True`とすることでデュアルディスプレイ環境でも撮影可能となります（PyAutoGUIでもスクリーンショット可能だが、中身はImageGrabらしいので速度的にこちらを使用）
* **`np.array(img)`** : <br>画像比較時に画像をnumpy配列とする必要があるので変換
* **`if 'img_mae' in locals()`** : <br>ループ初回時は`img_mae`が未定義なので画像比較をしないための条件分岐。初回は比較せずに保存して、`img_mae = img_ima`で次の比較用として代入
* **`cv2.createBackgroundSubtractorMOG2()`** : <br>背景差分用のインスタンス生成。OpenCVの背景差分を取得する関数。背景差分を取得する関数は色々あるので、他の関数でも問題はないはず<br>https://pystyle.info/opencv-background-substraction/
* **`fgmask`** : <br>生成したインスタンスに比較する画像を2つ適用すると差分がある領域が白(127)、差分がない領域が黒(0)の2値化データになります。
* **`if np.count_nonzero(fgmask) / fgmask.size * 100 < 0.5`** : <br>画面変化検知。背景差分の0(黒)ではない領域(=白)が全体の0.5%以下であれば、画面変化がないとして画像を保存せずにループを戻る。なぜ0.5%かというと、1ピクセルでも検知すると、マウスやテキストボックスの選択時のバー点滅など微妙な変化でもスクリーンショットを取り続けてしまうので、実用的にほぼ変化がないとできるのが0.5%だったため。
* **`time.sleep`** : <br>待機処理を一番最初に入れているのは`time.sleep`を1箇所のみにしたかったから（背景差分で保存しないときは`continue`でループが戻るため）。PC環境によって異なるが、スクリーンショット→背景差分の処理で0.2～0.5秒ほどかかるので、もし指定秒数毎で厳密に待機させたい場合は処理中の時間を計って`stop_time`から差し引く必要があります

## マウスカーソル位置の取得関数

```Python:def position_get()
try:
    windll.shcore.SetProcessDpiAwareness(True)
except Exception:
    pass


def position_get():
    point = wintypes.POINT()
    windll.user32.GetCursorPos(ctypes.byref(point))
    return point.x, point.y
```
* `ctypes`でWindowsのAPIからマウスカーソル位置を取得する部分
* ネット上の情報では`POINT()`を独自クラスとして自分で定義する形がよく見ましたが、この`ctypes`で実装する前に使っていた`pynput`の内部では上記のように`ctypes.wintypes.POINT()`でインスタンス生成していたので、このコードとしています。
* 事前に読み込んでいる`windll.shcore.SetProcessDpiAwareness(True)`は高DPIディスプレイでスケール変更があった際の対応として入れておく必要があるらしいですが、現状私の環境では挙動を確認しきっていません。
* `wintypes`と`windll`は`import ctypes`単独ではインポートできず、`from ctypes import windll, wintypes`と別途インポートする必要があります。


```Python:def screenshot_position()
def screenshot_position():
    input('取得したい箇所の"左上"にカーソルを当てEnterキー押してください')
    x1, y1 = position_get()
    print(f'X:{str(x1)}, Y:{str(y1)}')
    input('取得したい箇所の"右下"にカーソルを当てEnterキー押してください')
    x2, y2 = position_get()
    print(f'X:{str(x2)}, Y:{str(y2)}')
    with open('screenshot_position.txt', 'w') as f:
        f.write(f'{str(x1)}, {str(y1)}, {str(x2)}, {str(y2)}')
```
* スクリーンショット範囲を座標で設定するのは、ツールがないと実質不可能なので実装した関数

* 1度取得した座標は`screenshot_position.txt`に保存して、2回目以降に同じ範囲をスクリーンショットする場合には設定しなくてよい状態にしている


```Python:def main()
def main():
    if not os.path.exists('screenshot_position.txt'):
        print('スクリーンショット位置を設定してください')
        screenshot_position()
    else:
        screenshot_set = input("スクリーンショットする位置を設定しますか？[y/any]")
        if screenshot_set == "y" or screenshot_set == "Y":
            screenshot_position()
    x1, y1, x2, y2 = np.loadtxt('screenshot_position.txt', delimiter=',')
    stop_time = input('撮影間隔(秒)を入力して、ENTERで開始(デフォルト値：2秒、最低値：1秒)：')
    stop_time = 2 if not stop_time else 1 if float(stop_time) < 1 else float(stop_time)
    dir_path = dt.now().strftime('%y%m%d_%H%M')
    if not os.path.exists(dir_path):
        os.mkdir(dir_path)
    print('\nスクリーンショットを開始しました\nCtl+cで終了できます')
    screenshot_loop(stop_time, dir_path, x1, y1, x2, y2)
```
* `screenshot_position.txt`が存在しなければ`screenshot_position()`を呼び出して設定→すでに設定済みなら、`x1, y1, x2, y2`にtxtから読み込む

* `stop_time` : <br>指定秒数設定は保存する画像の名前がスクリーンショットした時間で管理していることから、最低値を1秒として、条件に合わない場合などは補正するようにしている

* `dir_path` : <br>保存するディレクトリは時間を名前に自動生成