PySimpleGUIでシンプルなフォトフレームアプリを作る

PySimpleGUIで画像ビュアー機能を実装するTipsの備忘録(シンプルなサンプルコード付)。
画像コンポーネントをウィンドウ全体に広げて配置する方法や、クリック位置の取得方法を日本語で見つけたい人向け。

# できるもの

とてもシンプルなフォトフレーム的画像ビュアー。
カレントフォルダのimagesフォルダから画像を取得し、タイトルバーのないウィンドウに表示して3秒ごとに画像を切り替える。

![2021-08-29 (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81851/ee7b7ea4-c4d2-dec9-403a-54430315d23f.png)

画面の左半分をクリックすると前の画像を表示、右半分をクリックすると次の画像を表示する。
画面上部(ウィンドウ高さの1/10まで)をクリックすると終了ダイアログを表示する。
ESCキーで即時終了する。

ラズパイとかをタッチスクリーンにつないでフォトフレームにする時の参考になるかも。

# シンプルなサンプルコード

```python:viewer.py
import PySimpleGUI as sg
from PIL import Image
import io
from glob import glob

image_dir = "images"         # 画像フォルダのパス
interval = 1000 * 3          # 画像切り替えインターバル(3秒)
exts = ["jpg", "png", "bmp"] # 表示する画像の拡張子
sg.theme("Black")            # カラーテーマ(背景色を黒にするため)
# 画面を最大化する場合
from screeninfo import get_monitors
monitor = get_monitors()[0]
window_size = (monitor.width, monitor.height)
# 画面を最大化しない場合(下の行の#を削除すること)
#window_size = (800, 600)

# リストの画像を表示し、indexにループ補正した値を返す
def show_image(window, images, index):
    image_size = (window_size[0] - 20, window_size[1] - 20) # 画像が若干見切れる対策
    # indexがマイナスならリストの上限値、indexがリスト上限より上なら0を設定する
    index = len(images) - 1 if index < 0 else 0 if index >= len(images) else index
    path = images[index]
    # 画像を縮小表示
    image = Image.open(path)
    image.thumbnail(image_size)
    bio = io.BytesIO()
    image.save(bio, format="PNG")
    window["-IMAGE-"].update(data=bio.getvalue())
    return index

def main():
    # 画像リスト取得
    images = [x for ext in exts for x in glob(f"{image_dir}/*.{ext}")] # 内包表記2回で二次元配列を一次元に平坦化
    # ウィンドウ初期化
    layout = [
        [sg.Image(key="-IMAGE-", enable_events=True, expand_x=True, expand_y=True)],
    ]
    window = sg.Window("Image Viewer", layout, size=window_size, return_keyboard_events=True, finalize=True, no_titlebar=True)
    # メインループ
    index = show_image(window, images, 0) # ループ前に先頭の画像表示
    while True:
        event, values = window.read(timeout=interval)
        if event == "Exit" or event == sg.WIN_CLOSED or event.startswith("Escape"):
            break
        elif event == "__TIMEOUT__":
            index = show_image(window, images, index + 1)
        elif event == "-IMAGE-":
            # 画像コンポーネントクリックイベント
            widget = window["-IMAGE-"].Widget
            y = widget.winfo_pointery() - widget.winfo_rooty()
            if y < (window_size[1] // 10):
                if sg.popup_ok_cancel("終了しますか？") == "OK":
                    break
            x = widget.winfo_pointerx() - widget.winfo_rootx()
            if x < (window_size[0] // 2):
                index = show_image(window, images, index - 1)
            else:
                index = show_image(window, images, index + 1)
    window.close()
    
if __name__ == "__main__":
    main()
```

# 事前準備

Python 3.7以上に下記のパッケージをpipインストールすること

+ [PySimpleGUI](https://pysimplegui.readthedocs.io/en/latest/)
  - GUIパッケージ。これがないと記事が始まらない
+ [Pillow](https://pillow.readthedocs.io/en/stable/)
  - 画像の縮小表示に必要
+ [screeninfo](https://pypi.org/project/screeninfo/)
  - 画面解像度を楽に取得できる
  - 最大化しない場合やサイズ決め打ちで指定する場合はサンプルコードの`# 画面を最大化する場合`以下4行を削除して良い

実行時のフォルダに`images`サブフォルダを作成し、その中に任意の`.jpg`/`.png`/`.bmp`画像いずれかを1個以上入れること

# 備忘録

## モニタの解像度を取得する

[screeninfo](https://pypi.org/project/screeninfo/)の`getmonitors`から`width`と`height`を取得できる。

```python
from screeninfo import get_monitors
monitor = get_monitors()[0]
window_size = (monitor.width, monitor.height)
```

## ウィンドウをフォトフレーム向けに設定する

[Window](https://pysimplegui.readthedocs.io/en/latest/call%20reference/#window)の`return_keyboard_events`でキーボードイベントを有効にし、初期表示で画像を表示するために`finalize`を設定し、`no_titlebar`でタイトルバーを消す。

`return_keyboard_events=True`にすると`ESC`キーを打った時に`Escape:27`の文字が`event`変数に格納される。
`finalize=True`でないと`window.read()`する前に`show_image`する処理がエラーになる。

```python
window = sg.Window("Image Viewer", layout, size=window_size, return_keyboard_events=True, finalize=True, no_titlebar=True)
# メインループ
index = show_image(window, images, 0) # ループ前に先頭の画像表示
while True:
    event, values = window.read(timeout=interval)
    if event == "Exit" or event == sg.WIN_CLOSED or event.startswith("Escape"):
```

## 画像を縮小表示する

原寸で表示するだけなら[PySimpleGUI.Image](https://pysimplegui.readthedocs.io/en/latest/call%20reference/#image-element)にファイルパスを送るだけでいいけど、アスペクト比を保ったまま縮小表示するならPillowの[Image.thumbnail](https://pillow.readthedocs.io/en/stable/reference/Image.html?highlight=thumbnail#PIL.Image.Image.thumbnail)を使うのが便利。
記事執筆時点では`Image`はGIFとPNGしか表示できないから下記のコードで`JPEG`や`BMP`を`PNG`化する処理も兼ねている。
このコードではアニメーションGIFを考慮していない。

```python
    image = Image.open(path)
    image.thumbnail(image_size)
    bio = io.BytesIO()
    image.save(bio, format="PNG")
    window["-IMAGE-"].update(data=bio.getvalue())
```

## PySimpleGUIのコンポーネントを広げて表示する

[`Image`](https://pysimplegui.readthedocs.io/en/latest/call%20reference/#image-element)コンポーネントは左上に画像サイズで表示され、クリックイベントも発生しない。

`expand_x`と`expand_y`を指定することでコンポーネントのサイズをウィンドウ内で広げて配置することができる。
なお、`Image`コンストラクタの`expand_x`や`expand_y`引数は数か月前に追加されたので、古いバージョンだとエラーになる。

```python
sg.Image(key="-IMAGE-", enable_events=True, expand_x=True, expand_y=True)
```

## `Image`コンポーネントのクリック座標を取得する

今回書きたかったことはこれ。
クリック座標を取得する方法を`pysimplegui click position`とか`pysimplegui get mouse location`とかでWeb検索しても全然見つからなかった。
でも[Widget](https://pysimplegui.readthedocs.io/en/latest/#widget-access)で裏側のTkinterにアクセスできる。

クリックしたときに[`winfo pointerx`](https://www.tcl.tk/man/tcl8.4/TkCmd/winfo.html#M25)などでマウスの位置を取得すればクリック座標が分かる。

```python
widget = window["-IMAGE-"].Widget
x = widget.winfo_pointerx() - widget.winfo_rootx()
y = widget.winfo_pointery() - widget.winfo_rooty()
```

## 今回のオチ

記事を書く調べものして気づく。

あれ？
公式サンプルの[PySimpleGUI/DemoPrograms/Demo_Img_Viewer.py](https://github.com/PySimpleGUI/PySimpleGUI/blob/master/DemoPrograms/Demo_Img_Viewer.py)で大体似たようなことやってる…。

マウスホイールスクロールで切り替えたい人はそちらを参考にどうぞ。

# 参考資料

+ [PYTHONを使って、ディスプレイの解像度・サイズを取得しよう](https://aoshimasan.com/python-screeninfo/)
+ [PySimpleGUI/DemoPrograms/Demo_Img_Viewer.py](https://github.com/PySimpleGUI/PySimpleGUI/blob/master/DemoPrograms/Demo_Img_Viewer.py)
+ [Answer: Is there an pysimplegui function to get the the loction of the input cursor?](https://stackoverflow.com/a/58846151/8248751)
+ [Answer: Mouse Position Python Tkinter](https://stackoverflow.com/a/22943296/8248751)
+ [PySimpleGUI.Window](https://pysimplegui.readthedocs.io/en/latest/call%20reference/#window)
+ [PySimpleGUI.Image](https://pysimplegui.readthedocs.io/en/latest/call%20reference/#image-element)
+ [PySimpleGUI.Widget](https://pysimplegui.readthedocs.io/en/latest/#widget-access)
+ [Image.thumbnail](https://pillow.readthedocs.io/en/stable/reference/Image.html?highlight=thumbnail#PIL.Image.Image.thumbnail)
+ [winfo pointerx](https://www.tcl.tk/man/tcl8.4/TkCmd/winfo.html#M25)