PySimpleGUIで二画面ビューワーを作ってみた

2021年6月19日

tomo viewer

今日はPySimpleGUIで二画面ビューワーを作ったのでその作り方をご紹介します。

みなさん、似たような写真がたくさんあってベストな一枚を選びたいときってないでしょうか?

例えば、連射した写真からベストな一枚を選びたいとか、ディテールがちょっとだけ違う写真の中からベストな一枚を選びたいとか。

そんなとき、二画面で比較しながら選別できたら便利だと思います。

そう思って作ってみました。これ中々便利です。

これからプログラムを始める人や勉強中の方の参考になればと思います。

本プログラムの特徴

  • 左右二画面で画像を比較できる
  • 気に入った画像があればその画像をCaptureボタンで保存できる
  • Captureできる画像サイズはオリジナルサイズ
  • 所定のフォルダ内の画像をすべて読み込んでサムネイル表示できる
  • サムネイル部分も送りボタン(<<, >>)で更新できる

実行画面

以下の映像をご覧ください。

プログラム

以下のコードをみてください。今回のコードはかなり長くなってしまいました。

import io
import os
import cv2
import sys
import glob
import numpy as np
import PySimpleGUI as sg
from PIL import Image, ImageTk


# window の色を設定
sg.theme('Dark Blue 3')

# パラメータ設定
count = 0
count_2 = 0
a = 0
a1 = 0
pp = []

### メイン画面用の画像を作成する関数 ###
def get_img_data(f, maxsize=(695, 695), first=False):
    global count

    print('ファイルパス:', f)
    print('Number of Current image 1:', count+1)
    print('Number of Current image 2:', count_2+1)

    img = Image.open(f)      # 指定フォルダのファイルを開く
    w, h = img.size          # 画像サイズを取得
    print('w=', w, 'h=', h)

    if w > h:
        img_back = Image.new(img.mode, (w, w), (128, 128, 128))
        img_back.paste(img, (0, (w - h)//2))
        img = img_back.resize((695, 695), Image.LANCZOS)

    elif h > w:
        img_back = Image.new(img.mode, (h, h), (128, 128, 128))
        img_back.paste(img, ((h - w)//2, 0))
        img = img_back.resize((695, 695), Image.LANCZOS)  # 画像をリサイズ

    else:
        img = img.resize((695, 695), Image.LANCZOS)

    img.thumbnail(maxsize)  # 画像のサムネイルを作成

    if first:
        bio = io.BytesIO()
        img.save(bio, format="PNG")
        del img
        return bio.getvalue()
    return ImageTk.PhotoImage(img)


### サムネイル用の画像を作成する関数 ###
def get_img_data_1(f1, maxsize=(127, 127), first=False):
    
    img_1 = Image.open(f1)
    sw, sh = img_1.size
    print('sw=', sw, 'sh=', sh)

    if sw > sh:
        img_back_1 = Image.new(img_1.mode, (sw, sw), (128, 128, 128))
        img_back_1.paste(img_1, (0, (sw - sh)//2))
        img_1 = img_back_1.resize((127, 127), Image.LANCZOS)

    elif sh > sw:
        img_back_1 = Image.new(img_1.mode, (sh, sh), (128, 128, 128))
        img_back_1.paste(img_1, ((sh - sw)//2, 0))
        img_1 = img_back_1.resize((127, 127), Image.LANCZOS)

    else:
        img_1 = img_1.resize((695, 695), Image.LANCZOS)

    img_1.thumbnail(maxsize)

    if first:
        bio = io.BytesIO()
        img_1.save(bio, format="PNG")
        del img_1
        return bio.getvalue()
    return ImageTk.PhotoImage(img_1)


### サムネイル画像をリスト化 ###
def cr_pp(x):
    global pp
    pp = [sg.Image(data=get_img_data_1(x[i], first=True)) for i in range(15)]
    return pp


# フォルダーを開く
fname = sg.popup_get_folder('フォルダーを選択してください')

print('folder=', fname)
files = glob.glob(fname + '/*')


# 読み込んだ画像をリスト化
x = []
num = 0
for f in files:
    title, ext = os.path.splitext(f)
    if ext in ['.jpg', '.png', '.JPG']:
        if num >= len(files):
            break
        num += 1
        x.append(f)

xn = len(x)
print('len(x) = ', len(x))
print('xn = ', xn)


### gray画像を作成(空欄を埋める画像) ###
""" もしgray画像がなければ以下のコードで作成
    img_gray = np.zeros((180, 180, 3), np.uint8)
    img_gray[:,:,:] = 128
    cv2.imwrite('./cnn_act/capture/gray/img_gray.png', img_gray)
"""
y = [i for i in glob.glob('./cnn_act/capture/gray/img_gray.png')]
print('y =', y)

#### サムネイル画面の空欄数を判定し、空欄にgray画像を追加 ###
""" 3×5枚のサムネイル画像表示枠があるため、
    まずは読み込んだ画像枚数len(x)を15で割ってあまりを出し、
    その数を判定に用いて足りない枚数をリストに足す。
"""
x1 = []
if len(x) == 1 or len(x) % 15 == 1: x1 = [y[0] for i in range(14)]
elif len(x) == 2 or len(x) % 15 == 2: x1 = [y[0] for i in range(13)]
elif len(x) == 3 or len(x) % 15 == 3: x1 = [y[0] for i in range(12)]
elif len(x) == 4 or len(x) % 15 == 4: x1 = [y[0] for i in range(11)]
elif len(x) == 5 or len(x) % 15 == 5: x1 = [y[0] for i in range(10)]
elif len(x) == 6 or len(x) % 15 == 6: x1 = [y[0] for i in range(9)]
elif len(x) == 7 or len(x) % 15 == 7: x1 = [y[0] for i in range(8)]
elif len(x) == 8 or len(x) % 15 == 8: x1 = [y[0] for i in range(7)]
elif len(x) == 9 or len(x) % 15 == 9: x1 = [y[0] for i in range(6)]
elif len(x) == 10 or len(x) % 15 == 10: x1 = [y[0] for i in range(5)]
elif len(x) == 11 or len(x) % 15 == 11: x1 = [y[0] for i in range(4)]
elif len(x) == 12 or len(x) % 15 == 12: x1 = [y[0] for i in range(3)]
elif len(x) == 13 or len(x) % 15 == 13: x1 = [y[0] for i in range(2)]
elif len(x) == 14 or len(x) % 15 == 14: x1 = [y[0] for i in range(1)]

### リストの結合 ###
x += x1
print('結合後の画像数 =', len(x))

### メイン画面のレイアウト作成 ###
# 実行ボタン設定
s_button_1 = sg.Submit(button_text='<< image 1', size=(15, 2))   # Previous image
s_button_2 = sg.Submit(button_text='image 1 >>', size=(15, 2))   # Next image
s_button_3 = sg.Submit(button_text='<< image 2', size=(15, 2))   # Previous image
s_button_4 = sg.Submit(button_text='image 2 >>', size=(15, 2))   # Next image
s_button_5 = sg.Submit(button_text='<< thumbnail', size=(15, 2)) # Previous thumbnail
s_button_6 = sg.Submit(button_text='thumbnail >>', size=(15, 2)) # Next thumbnail

# ボタンの色はタプルで引数二つで指定する
# 第一引数は文字の色(英語で指定)、第二引数は背景の色(16進数で指定)
s_button_7 = sg.Submit(button_text='Quit', size=(11, 10), button_color=('black', '#4adcd6'))

# 画像Captureボタン
s_button_8 = sg.Submit(button_text='Capture 1', size=(15, 2), button_color=('black', '#F0E68C')) # Capture 1
s_button_9 = sg.Submit(button_text='Capture 2', size=(15, 2), button_color=('black', '#F0E68C')) # Capture 2

### メイン画面用の画像を準備 ###
ae_1 = sg.Image(data=get_img_data(x[0], first=True))
ae_2 = sg.Image(data=get_img_data(x[0], first=True))

### メイン画面のレイアウト設定 ###
layout_0 = sg.Frame(layout=[[ae_1],
                            [s_button_1, s_button_8, s_button_2]
                           ],
                             title='Current image 1',
                             title_color='white',
                             font=('メイリオ', 10),
                             relief=sg.RELIEF_SUNKEN,
                             element_justification='center')

layout_1 = sg.Frame(layout=[[ae_2],
                            [s_button_3, s_button_9, s_button_4]
                           ],
                             title='Current image 2',
                             title_color='white',
                             font=('メイリオ', 10),
                             relief=sg.RELIEF_SUNKEN,
                             element_justification='center')

### 画像のリスト X からサムネイル画像を作成(関数使用)###
pp = cr_pp(x)

### レイアウト枠を作成 ###
""" sg.Frame()を使うときtitle=''までは必要、title=を省略するとエラーになる
    今は、3×5枚のサムネイル画像が表示できるように15枚分の枠を作成
    内包表記が便利
"""
lay = [sg.Frame(layout=[[pp[0+3*i], pp[1+3*i], pp[2+3*i]]], title='') for i in range(5)]

### 画面全体のレイアウト ###
# テキスト設定
s_moji = sg.Text('Display', size=(20, 1), font=('メイリオ', 13))

### 文字列の作成にリテラル文字列補完(Python3.6以降)の機能を使用 ###
""" これは、文字列定数内でPythonの埋め込み式を使用するもの
    f'Total ~:{}' がその部分で、任意のPython式を埋め込むことができる
"""
img_n_text = sg.Text(f'Total number:{xn}', size=(15, 1), font=('メイリオ', 12), justification='center')

# 画像数の文字を表示
ml = (f'{i+1}' for i in range(15))  # ジェネレーター式を使用
sm = [sg.Text(i, size=(14, 1), font=('メイリオ', 10), justification='center') for i in ml]
sm_3p = [[sm[0+i*3], sm[1+i*3], sm[2+i*3]] for i in range(5)]

# 画面にラインを表示
s_sen = sg.Text('_'  * 179)

### サムネイルのレイアウトを作成 ###
""" PySimpleGUIでは画面レイアウトを[]で作成する。
    []の中に長い構文を書くと読みにくくなる。
    このため、外部で構文を定義し、
    それを[]で呼び出す形とした方がわかりやすい。
"""
layout_x0 = sg.Frame(layout=[[s_moji, img_n_text],
                             [layout_0, layout_1],
                             [s_sen],
                             [sg.Output(size=(162,10), key='-OUTPUT-'), s_button_7]],
                             title='',
                             title_color='white',
                             font=('メイリオ', 10),
                             relief=sg.RELIEF_SUNKEN, element_justification='left'
                             )

layout_x1 = sg.Frame(layout=[sm_3p[0],
                             [lay[0]],
                             sm_3p[1],
                             [lay[1]],
                             sm_3p[2],
                             [lay[2]],
                             sm_3p[3],
                             [lay[3]],
                             sm_3p[4],
                             [lay[4]],
                             [s_button_5, s_button_6]],
                             title='thumbnail',
                             title_color='white',
                             font=('メイリオ', 10),
                             relief=sg.RELIEF_SUNKEN, element_justification='center'
                             )

### レイアウトを作成 ###
layout = [
          [layout_x0, layout_x1]
         ]

### 画面表示の設定 ###
window = sg.Window('tomo viewer', layout, size=(1900, 980),
                                  location=(0, 10),
                                  alpha_channel=1.0,
                                  no_titlebar=False,
                                  grab_anywhere=False).Finalize()
window.Maximize()

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

    if event is None:
        print('exit')
        break

    ### image 1の更新 ###
    if event == 'image 1 >>':
        if len(x) == count + 1:
            count = 0
            ae_1.update(data=get_img_data(x[0], first=True))
        else:
            count += 1
            ae_1.update(data=get_img_data(x[count], first=True))

    if event == '<< image 1':
        if count <= 0:
            count = 0
            ae_1.update(data=get_img_data(x[0], first=True))
        else:
            count -= 1
            ae_1.update(data=get_img_data(x[count], first=True))
    
    if event == 'Capture 1':
        if count == 0:
            Image.open(x[0]).save('./cnn_act/' + str('cap_1_') +str(1) + '.jpg')
        else:
            Image.open(x[count]).save('./cnn_act/' + str('cap_1_') + str(count+1) + '.jpg')


    ### image 2 の更新 ###
    if event == 'image 2 >>':
        if len(x) == count_2+1:
            count_2 = 0
            ae_2.update(data=get_img_data(x[0], first=True))
        else:   
            count_2 += 1
            ae_2.update(data=get_img_data(x[count_2], first=True))
            
    if event == '<< image 2':
        if count_2 <= 0:
            count_2 = 0
            ae_2.update(data=get_img_data(x[0], first=True))
        else:   
            count_2 -= 1
            ae_2.update(data=get_img_data(x[count_2], first=True))

    if event == 'Capture 2':
        if count == 0:
            Image.open(x[0]).save('./cnn_act/' + str('cap_2_') +str(1) + '.jpg')
        else:
            Image.open(x[count_2]).save('./cnn_act/' + str('cap_2_') + str(count_2+1) + '.jpg')


    ### サムネイル画像の更新 ###
    # Next thumbnail
    if event == 'thumbnail >>':

        a += 1
        a1 = 15*a
        print('a1=', a1)

        if len(x) > a1:
            
            pp[0].update(data=get_img_data_1(x[0+a1], first=True))
            pp[1].update(data=get_img_data_1(x[1+a1], first=True))
            pp[2].update(data=get_img_data_1(x[2+a1], first=True))

            pp[3].update(data=get_img_data_1(x[3+a1], first=True))
            pp[4].update(data=get_img_data_1(x[4+a1], first=True))
            pp[5].update(data=get_img_data_1(x[5+a1], first=True))

            pp[6].update(data=get_img_data_1(x[6+a1], first=True))
            pp[7].update(data=get_img_data_1(x[7+a1], first=True))
            pp[8].update(data=get_img_data_1(x[8+a1], first=True))

            pp[9].update(data=get_img_data_1(x[9+a1], first=True))
            pp[10].update(data=get_img_data_1(x[10+a1], first=True))
            pp[11].update(data=get_img_data_1(x[11+a1], first=True))

            pp[12].update(data=get_img_data_1(x[12+a1], first=True))
            pp[13].update(data=get_img_data_1(x[13+a1], first=True))
            pp[14].update(data=get_img_data_1(x[14+a1], first=True))
            

        else:
            pp[0].update(data=get_img_data_1(x[0], first=True))
            pp[1].update(data=get_img_data_1(x[1], first=True))
            pp[2].update(data=get_img_data_1(x[2], first=True))

            pp[3].update(data=get_img_data_1(x[3], first=True))
            pp[4].update(data=get_img_data_1(x[4], first=True))
            pp[5].update(data=get_img_data_1(x[5], first=True))

            pp[6].update(data=get_img_data_1(x[6], first=True))
            pp[7].update(data=get_img_data_1(x[7], first=True))
            pp[8].update(data=get_img_data_1(x[8], first=True))

            pp[9].update(data=get_img_data_1(x[9], first=True))
            pp[10].update(data=get_img_data_1(x[10], first=True))
            pp[11].update(data=get_img_data_1(x[11], first=True))

            pp[12].update(data=get_img_data_1(x[12], first=True))
            pp[13].update(data=get_img_data_1(x[13], first=True))
            pp[14].update(data=get_img_data_1(x[14], first=True))

            # a、a1 を初期化
            a = 0
            a1 = 0

    # Previous thumbnail
    if event == '<< thumbnail':

        print('pre_a1=', a1)

        if a1 > 0:
            a -= 1
            a1 = 15*a

            pp[0].update(data=get_img_data_1(x[0+a1], first=True))
            pp[1].update(data=get_img_data_1(x[1+a1], first=True))
            pp[2].update(data=get_img_data_1(x[2+a1], first=True))

            pp[3].update(data=get_img_data_1(x[3+a1], first=True))
            pp[4].update(data=get_img_data_1(x[4+a1], first=True))
            pp[5].update(data=get_img_data_1(x[5+a1], first=True))

            pp[6].update(data=get_img_data_1(x[6+a1], first=True))
            pp[7].update(data=get_img_data_1(x[7+a1], first=True))
            pp[8].update(data=get_img_data_1(x[8+a1], first=True))

            pp[9].update(data=get_img_data_1(x[9+a1], first=True))
            pp[10].update(data=get_img_data_1(x[10+a1], first=True))
            pp[11].update(data=get_img_data_1(x[11+a1], first=True))

            pp[12].update(data=get_img_data_1(x[12+a1], first=True))
            pp[13].update(data=get_img_data_1(x[13+a1], first=True))
            pp[14].update(data=get_img_data_1(x[14+a1], first=True))

        else:
            pp[0].update(data=get_img_data_1(x[0], first=True))
            pp[1].update(data=get_img_data_1(x[1], first=True))
            pp[2].update(data=get_img_data_1(x[2], first=True))

            pp[3].update(data=get_img_data_1(x[3], first=True))
            pp[4].update(data=get_img_data_1(x[4], first=True))
            pp[5].update(data=get_img_data_1(x[5], first=True))

            pp[6].update(data=get_img_data_1(x[6], first=True))
            pp[7].update(data=get_img_data_1(x[7], first=True))
            pp[8].update(data=get_img_data_1(x[8], first=True))

            pp[9].update(data=get_img_data_1(x[9], first=True))
            pp[10].update(data=get_img_data_1(x[10], first=True))
            pp[11].update(data=get_img_data_1(x[11], first=True))

            pp[12].update(data=get_img_data_1(x[12], first=True))
            pp[13].update(data=get_img_data_1(x[13], first=True))
            pp[14].update(data=get_img_data_1(x[14], first=True))

            # a、a1 を初期化
            a = 0
            a1 = 0        

    if event == 'Quit':
        print('Quit')
        break
        
window.close()

ポイント解説

1.画像の更新方法

PySimpleGUIで画像を更新する方法を説明します。

以下のコードをみてください。

### メイン画面用の画像を準備 ###
ae_1 = sg.Image(data=get_img_data(x[0], first=True))
ae_2 = sg.Image(data=get_img_data(x[0], first=True))

コードの意味を説明します。

プログラムが実行されると、x[ ] のリストに読み込んだ一番最初のパス x[0] が先にdef 文で定義しておいた get_img_data( ) 関数に入力されます。

その関数からImageデータ が出力され sg.Image( ) のdataに入力されます。

次に sg.Image( ) から出力される Imageデータが ae_1とae_2 に代入されます。

プログラムを実行したときにその画像データが画面に表示されることになります。

次に以下の部分をみてください

### image 1の更新 ###
 if event == 'image 1 >>':
     if len(x) == count + 1:
         count = 0
         ae_1.update(data=get_img_data(x[0], first=True))
     else:
         count += 1
         ae_1.update(data=get_img_data(x[count], first=True))

ここで、ae_1.update( ) の部分に注目してください。

このコードの意味は、x[0] を get_img_data( ) 関数に入力し、その関数から出力された Imageデータで ae_1 を updata する(更新する)という意味です。

これが「image 1 >>」のボタンが押されたときに、ae_1 の画像を更新できるようにする方法です。

2.実行速度を上げるため内包表記を使いまくる

例えば、レイアウトを作るときに以下のような書き方ができます。

lay = [sg.Frame(layout=[[pp[0+3*i], pp[1+3*i], pp[2+3*i]]], title='') for i in range(5)]

これは、サムネイルのウィンドウを作るときに、画像3枚づつを5行作るリストです。

画面右側に15枚の画像が表示できるのはこれがあるからです。

PySimpleGUIの流儀に従って、リストを重ねてレイアウトを作ることも可能です。

しかし、非常に複雑で見にくいコードになってしまいます。

見にくいコードは間違いやすいです。

また、間違いに気づき難いためメンテナンスが大変です。オススメしません。

上記のようにコードをなるべくシンプルにした方がよいと思います。

ちなみに、この lay もリストなので、lay[0] には画像3枚分のレイアウトが作られ、lay[1] にはまた画像3枚分のレイアウトが作られます。これが5行分一気に作れます。

3.画像のアスペクト比を考慮し背景を準備しておく

画像には横長や縦長、正方形と色々な形のものがあります。

変にリサイズすると画像がつぶれたり歪んだりするので、あらかじめ正方形のグレー画像を準備しておき、画像の縦横サイズを判定してから、背景が必要な場合は重ねて表示するようにしています。

サムネイル用のグレー画像は以下のコードで作成します。

img_gray = np.zeros((180, 180, 3), np.uint8)
img_gray[:,:,:] = 128
cv2.imwrite('./cnn_act/capture/gray/img_gray.png', img_gray)

なお、すでにグレー画像が存在する場合はこのコードは必要ありません。

本コード中ではコメントアウトしています。必要に応じて復活させてください。

ちなみに、cv2.imwrite( ) のカッコの中のパスは好きなパスに修正してください。

また、メイン画面の場合には以下のコードでグレー画像を重ねています。

img = Image.open(f)      # 指定フォルダのファイルを開く
w, h = img.size          # 画像サイズを取得
print('w=', w, 'h=', h)

if w > h:
    img_back = Image.new(img.mode, (w, w), (128, 128, 128))
    img_back.paste(img, (0, (w - h)//2))
    img = img_back.resize((695, 695), Image.LANCZOS)

elif h > w:
    img_back = Image.new(img.mode, (h, h), (128, 128, 128))
    img_back.paste(img, ((h - w)//2, 0))
    img = img_back.resize((695, 695), Image.LANCZOS)  # 画像をリサイズ

else:
    img = img.resize((695, 695), Image.LANCZOS)

まず、一行目の Image.open( f ) で指定フォルダのファイルを開きます。

次に、img.size で画像の横サイズ w と縦サイズ h を取得します。

得られた w と h の値を使って if 文で判定に使います。

もし、横長の画像なら(w>h)長い辺を基準にグレー画像を作ります。

それが、Image.new( img.mode, (w, w), (128, 128, 128)) の部分です。

カッコの中の第二引数(w, w)は作る画像のサイズ、第三引数(128, 128, 128)はRGBの順で、各R、G、Bは0~255までの値を取り得ますが 128はその中間値です。全てを128とするとグレー画像になります。

作ったグレー画像を、img_back.paste( img, (0, (w – h) // 2)) の部分で 読み込んだ画像である img に重ねます。

ここで、カッコの中の第二引数の部分について説明します。

カッコの中は(y, x)の座標となります。y = 0 で x = (w – h) // 2 ということになります。

グレー画像は、w × w のサイズで作成されています。

グレー画像の中央に横長の画像を貼るためには、以下のように考えます。

それを数式で表したものが上記コードになります。

ちなみに、コードでは x = (w – h) // 2 と記載しており / を二回書いていますが、これは割り算した値を常に整数にしておきたいためです。

まとめ

  • 画像の更新したいときは1.項で説明したやり方を使う
  • 実行速度を上げるには内包表記を使いまくる
  • 画像のアスペクト比を考慮し背景を準備しておく
  • 何度も使う部分は関数化(def 化)して効率化を図る