PythonとPySimpleGUIで画像変換ソフトを作ってみた話

PythonとPySimpleGUIで画像の拡張子やサイズ変換、ノイズ除去ができるソフトを作ってみました。

コードがちょっと長いですが興味のある方は参考にしてください。

プログラムの特徴

  1. 言語はPythonを使用(ver 3.6.12)
  2. GUIはPySimpleGUIを使用(ver 4.19.0)
  3. 画像の拡張子は、JPG, PNG, GIF, TIFF, BMP へ変換が可能
  4. 画像サイズは、アスペクト比を保ったまま任意のサイズに変更可能
  5. アスペクト比を保たないでサイズ変更も可能
  6. 変更前後の画像が確認できるよう二画面構成
  7. 画像サイズを棒グラフで比較表示
  8. 画像情報、変換情報を別ウィンドウに表示

動作映像

コード

import os
import io
import sys
import glob
import time
import datetime
import numpy as np
import cv2.cv2 as cv
import tkinter as tk
import matplotlib.style
import PySimpleGUI as sg
import tkinter.filedialog as fd
import matplotlib.pyplot as plt
from PIL import Image, ImageTk
from natsort import natsorted

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

# グラフスタイルの設定
plt.style.use('ggplot')

# パラメータ設定
e = []
fn = []
s = []
z = []
d = []
d1 = []

# 画像の保存先を設定
fig_path = './cnn_act/mov/capture/convert/fig.png'

### 画像サイズのグラフ作成 ###
def plot(x, y):
    fig, ax = plt.subplots(figsize=(7, 4))
    fig.subplots_adjust(left = 0.2)
    ax.barh(x, y, tick_label=x)
    ax.set_ylabel('image size[KB]', size=15)
    ax.set_title('Size comparison', size=15)
    fig.savefig(fig_path)  # グラフの保存先
    plt.close(fig)

### 初回画像の読み込み用 ###
def get_img_data_0(f, maxsize=(928, 522), first=False):
    global e

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

    _, ext = os.path.splitext(os.path.basename(f))

    # 拡張子だけeリストに保存(後で使う)
    e.append(ext)       
    print('e0=', e[:])

    # グラフを作成(初期状態)
    plot(0, 0)
    
    if w < 928 or h < 522:
        img_back = Image.new('RGBA', (928, 522), (128, 128, 128, 128))
        img_back.paste(img, ((928 - w)//2, (522 - h)//2))
        img = img_back.resize((928, 522), Image.LANCZOS)
    
    img.thumbnail(maxsize)  # 画像のサムネイルを作成

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

### 2回目以降の画像読み込み用 ###
def get_img_data(f, maxsize=(928, 522), first=False):
    global e, s, d, d1

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

    # 画像のファイル名と拡張子名を取得
    """ basenameでファイル名を取得。
        splitextで名前と拡張子に分割。
        file_nameとextにアンパックして代入。
    """
    file_name, ext = os.path.splitext(os.path.basename(f))

    print('■', f, ':ファイルパス')
    print('■', file_name, ':ファイル名')
    print('■', w, ':w')
    print('■', h, ':h')
    print('■', f'{ext[1:]} :拡張子')
    print('■', os.path.getsize(f)//1000, 'KB', ':ファイルサイズ')
    print('-' * 80)

    # ファイル名('JPEG'など)とファイルサイズ(KB)の情報をパックしてリストに保存
    d.append([file_name, os.path.getsize(f)//1000])

    # dリストの二番目の値(ファイルサイズ)でデータサイズが大きい順にソート
    d1= sorted(d, key=lambda b:b[1], reverse=True)
    
    # d1リストの値をファイル名とファイルサイズに分離(内包表記)
    fn = [d1[i][0] for i in range(len(d1))]
    s = [d1[i][1] for i in range(len(d1))]

    # グラフを作成
    plot(fn, s)

    if w < 928 or h < 522:
        img_back = Image.new('RGBA', (928, 522), (128, 128, 128, 128))
        img_back.paste(img, ((928 - w)//2, (522 - h)//2))
        img = img_back.resize((928, 522), 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_fig(f, maxsize=(800, 384), first=False):

    img_1 = Image.open(f)      # 指定フォルダのファイルを開く
    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)

### 画像ファイルの読み込み ###
if len(sys.argv) == 1:
    fname = sg.popup_get_file('file to open')
else:
    fname = sys.argv[1]

if not fname:
    sg.popup("Cancel", "No filename supplied")
    raise SystemExit("Cancelling: no filename supplied")
else:
    sg.popup('The filename you chose was', fname)

# 初回画像の読み込み
x = glob.glob(fname)

# グラフ画像の読み込み
plot(0, 0)
z = glob.glob(fig_path)

### メイン画面のレイアウト作成 ###
# 実行ボタン設定
size_set = (15, 3)  # ボタンサイズ一括設定
s_button_0 = sg.Submit(button_text='Open file', size=(22, 3), button_color=('black', '#FFFACD'))
s_button_1 = sg.Submit(button_text='jpg', size = size_set)
s_button_2 = sg.Submit(button_text='png', size = size_set)
s_button_3 = sg.Submit(button_text='gif', size = size_set)
s_button_4 = sg.Submit(button_text='tif', size = size_set)
s_button_5 = sg.Submit(button_text='bmp', size = size_set)
s_button_6 = sg.Submit(button_text='Conversion', size=(22, 3), button_color=('black', '#F0E68C'))

# 画像サイズを指定するインプットボックス
s_input_text_1 = sg.InputText(key='in_w', size=(15, 3))
s_input_text_2 = sg.InputText(key='in_h', size=(15, 3))

# 画像のアスペクト比を固定するチェックボックス
s_check_1 = sg.Checkbox('aspect ratio', key='ar_check', size=(10,1), default=True) 

# 文字
s_moji_1 = sg.Text('Noise Filter', size=(25, 1), font=('メイリオ', 10), justification='center')
s_moji_2 = sg.Text('Width', text_color='Yellow', size=(15, 1), font=('メイリオ', 10), justification='left')
s_moji_3 = sg.Text('Height', size=(15, 1), font=('メイリオ', 10), justification='left')

# ノイズフィルタ選択(最後のNoneはスルー用)
choices_1 = ['GaussianBlur', 'MedianBlur', 'FastNlMeansDenoising', 'FastNlMeanDenoisingColored', 'None']

# 隠れボタン設定
s_choice_1 = sg.InputCombo(choices_1,
                            default_value='------------------- select -------------------',
                            key= 'noise_filter',
                            size=(30, len(choices_1)),
                            enable_events=True)

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

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

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

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

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

layout_2 = sg.Frame(layout=[[s_button_0, s_button_1, s_button_2, s_button_3, s_button_4, s_button_5]],
                             title='',
                             title_color='white',
                             font=('メイリオ', 10),
                             relief=sg.RELIEF_SUNKEN,
                             element_justification='left')                           

layout_3_1 = sg.Frame(layout=[[s_moji_1],
                              [s_choice_1]],
                             title='',
                             title_color='white',
                             font=('メイリオ', 10),
                             relief=sg.RELIEF_SUNKEN,
                             element_justification='center')

layout_3_2 = sg.Frame(layout=[[s_moji_2, s_moji_3],
                            [s_input_text_1, s_input_text_2, s_check_1]],
                             title='',
                             title_color='white',
                             font=('メイリオ', 10),
                             relief=sg.RELIEF_SUNKEN,
                             element_justification='left')

layout_3_3 = sg.Frame(layout=[[s_button_6]],
                             title='',
                             title_color='white',
                             font=('メイリオ', 10),
                             relief=sg.RELIEF_SUNKEN,
                             element_justification='left')

layout_4 = sg.Frame(layout=[[ae_3, sg.Output(size=(127,20), key='-OUTPUT-'), s_button_7]],
                             title='',
                             title_color='white',
                             font=('メイリオ', 10),
                             relief=sg.RELIEF_SUNKEN,
                             element_justification='left')

### レイアウトを作成 ###
layout = [
          [layout_0, layout_1],
          [layout_2, layout_3_1, layout_3_2, layout_3_3],
          [layout_4]
         ]

### 画面表示の設定 ###
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()

# 新規画像読み込みの際の関数
def conv_0(out_path):
    global e, fn, s, ae_1, ae_3, d, d1

    e = []   # リスト初期化(以下同じ)
    fn = []
    s = []
    d =[]
    d1 = []

    plot(0, 0)  # グラフ初期化

    y = glob.glob(out_path)
    ae_1.update(data=get_img_data(y[0], first=True))
    z = glob.glob(fig_path)
    ae_3.update(data=get_img_data_fig(z[0], first=True))

# 画像の拡張子変更の際の関数
def conv_1(out_path, m):
    global ae_2, ae_3
    
    w1, h1 = Image.open(x[0]).size
    img_m = Image.open(x[0]).resize((int(w1*m), int(h1*m)), Image.LANCZOS)

    img_m.save(out_path)
    y = glob.glob(out_path)
    
    ae_2.update(data=get_img_data(y[0], first=True))
    z = glob.glob(fig_path)
    ae_3.update(data=get_img_data_fig(z[0], first=True))

# 画像サイズ変更の際の関数
def conv_2(out_path, w, h):
    global ae_2, ae_3
    
    img_m = Image.open(x[0]).resize((int(w), int(h)), Image.LANCZOS)
    img_m.save(out_path)
    y = glob.glob(out_path)
    
    ae_2.update(data=get_img_data(y[0], first=True))
    z = glob.glob(fig_path)
    ae_3.update(data=get_img_data_fig(z[0], first=True))

# ノイズフィルタ適用の際の関数
def noise_less(out_path, no):
    global ae_2, ae_3
    
    img_m = Image.open(x[0])
    img_m = np.asarray(img_m)  # 一旦Imageデータをndarrayデータに変換(cv2でノイズフィルタかけるため)

    if no == 0:
        img_m = cv.GaussianBlur(img_m, (9, 9), 5)
    elif no == 1:
        img_m = cv.medianBlur(img_m, 9)
    elif no == 2:
        img_m = cv.fastNlMeansDenoising(img_m, None, 10, 3, searchWindowSize=17)
    elif no == 3:
        img_m = cv.fastNlMeansDenoisingColored(img_m, None, 10, 10, 3, searchWindowSize=17)

    img_m = Image.fromarray(img_m)  # ノイズフィルタかけ終わったらまたImageデータに変換しておく

    img_m.save(out_path)
    y = glob.glob(out_path)
    
    ae_2.update(data=get_img_data(y[0], first=True))
    z = glob.glob(fig_path)
    ae_3.update(data=get_img_data_fig(z[0], first=True))

##### イベントループ ###########################################################
while True:
    event, values = window.read()  # event と values の二つのトリガーを持っている

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

    ### 新規画像読み込みのときのループ ###
    if event == 'Open file':
        if len(sys.argv) == 1:
            fname = sg.popup_get_file('file to open')
        else:
            fname = sys.argv[1]

        if not fname:
            sg.popup("Cancel", "No filename supplied")
            continue
        else:
            sg.popup('The filename you chose was', fname)

        x = glob.glob(fname)
    
        conv_0(x[0])
        file_name, ext = os.path.splitext(os.path.basename(x[0]))
        w, h = Image.open(x[0]).size
        e.append(ext)
        
        print('新しいファイルを読み込みました。')
        print('拡張子は', ext[1:], 'です。')
        print('-' * 80)
        print('■', x[0], ':ファイルパス')
        print('■', file_name, ':ファイル名')
        print('■', w, ':w')
        print('■', h, ':h')
        print('■', f'{ext[1:]} :拡張子')
        print('■', os.path.getsize(x[0])//1000, 'KB', ':ファイルサイズ')
        print('-' * 80)

    ### ボタンを押したときの画像の更新 ###
    if event == 'jpg':
        conv_1('./cnn_act/mov/capture/convert/JPEG.jpg', 1.0)
        
    elif event == 'png':
        conv_1('./cnn_act/mov/capture/convert/PNG.png', 1.0)
        
    elif event == 'gif':
        conv_1('./cnn_act/mov/capture/convert/GIF.gif', 1.0)
        
    elif event == 'tif':
        conv_1('./cnn_act/mov/capture/convert/TIFF.tif', 1.0)
        
    elif event == 'bmp':
        conv_1('./cnn_act/mov/capture/convert/BMP.bmp', 1.0)
        
    elif event == 'Conversion':
        if values['ar_check']:
            w, h = Image.open(x[0]).size
            input_width = int(values['in_w'])
            input_height = int(input_width*(h / w))
        else:
            input_width = int(values['in_w'])
            input_height = int(values['in_h'])

        conv_2('./cnn_act/mov/capture/convert/'+ str(input_width) + 'x' + str(input_height) + str(e[0]), input_width, input_height)
    
    if event == 'noise_filter':
        if choices_1[0] == values['noise_filter'][:]:
            noise_less('./cnn_act/mov/capture/convert/GaussianBlur' + str(e[0]), 0)

        elif choices_1[1] == values['noise_filter'][:]:
            noise_less('./cnn_act/mov/capture/convert/MedianBlur' + str(e[0]), 1)
            
        elif choices_1[2] == values['noise_filter'][:]:
            noise_less('./cnn_act/mov/capture/convert/FMD' + str(e[0]), 2)

        elif choices_1[3] == values['noise_filter'][:]:
            noise_less('./cnn_act/mov/capture/convert/FMDC' + str(e[0]), 3)

        elif choices_1[4] == values['noise_filter'][:]:
            continue   

    if event == 'Quit':
        print('Quit')
        break

window.close()

ポイント解説

GUIのボタン類について

PySimpleGUIで画面を作るときは、ボタン類は先にまとめて定義しておく方が便利です。

今回のコードでは、実行ボタン、インプットボックス、チェックボックス、文字、隠れボタン等は、まとめて定義しておきました。

機能追加する際も似たようなコードをコピペして使えばいいので楽です。

GUIのレイアウトについて

PySimpleGUIは画面構成をリストで作ります。

このため、まずはリストに入れるレイアウト部分を作る必要があります。

例えば、以下のコードをみてください。

layout_2 = sg.Frame(layout=[[s_button_0, s_button_1, s_button_2, s_button_3, s_button_4, s_button_5]],
                             title='',
                             title_color='white',
                             font=('メイリオ', 10),
                             relief=sg.RELIEF_SUNKEN,
                             element_justification='left') 

このコードでは、ボタンを横に6個並べるレイアウトを作っています。

title = ' ' とするとレイアウト画面にはタイトル文字が表示されません。

レイアウト枠だけ作りその中にボタン類を配置する場合、このように記載しておくと良いと思います。

画像の読み込みについて

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

x = glob.glob(fname)
conv_0(x[0])

fnameは画像ファイルのパスです。

パスとは、' ./cnn_act/mov/capture/convert/xxx.jpg ' など画像ファイルが置かれている場所を指します。

glob.glob( ) はカッコの中にパスを記載します。

すると、パスのデータを読み込んでxに代入する動作をします。

ここで、xはリストになっています。

x[0] とするとリストの一番最初のデータを示すことになります。

なお、conv_0( ) は先に関数化しておいた部分です。

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

# 新規画像読み込みの際の関数
def conv_0(out_path):
    global e, fn, s, ae_1, ae_3, d, d1

    e = []   # リスト初期化(以下同じ)
    fn = []
    s = []
    d =[]
    d1 = []

    plot(0, 0)  # グラフ初期化

    y = glob.glob(out_path)
    ae_1.update(data=get_img_data(y[0], first=True))

conv_0( ) のカッコの中に out_path と記載されています。

この out_path という部分に x[0] が入ります。

なので、y にはglob.glob( ) で読み込まれた x[0] が代入されます。

yはリストなので、一番最初の値である y[0] のデータで ae_1 のデータが更新されるということになります。

event と values の使い分け

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

### ボタンを押したときの画像の更新 ###
    if event == 'jpg':
        conv_1('./cnn_act/mov/capture/convert/JPEG.jpg', 1.0)

このコードは、' jpg ' というボタンが押されたとき一回だけconv_1( ) が実行されるという意味です。

conv_1( )の第一引数はファイルを保存するパスで第二引数は画像の倍率です。

ここでは倍率はあまり意味がありません。

後から画像の倍率変更ができる余地を残しておいただけです、あまり気にしないでください。

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

if event == 'noise_filter':
        if choices_1[0] == values['noise_filter'][:]:
            noise_less('./cnn_act/mov/capture/convert/GaussianBlur' + str(e[0]), 0)

このコードは、' noise_filter ' という key が1回選択されたとき(noise_filter は隠れボタン)、choices_1 の全リストの中でリストの一番最初の文字 ' GaussianBlur ' が選択された場合、noise_less( ) 関数を適用しなさいという動作をします。

noise_less( ) 関数の第一引数は画像を保存するパスです。

第二引数は適用するノイズフィルタの番号です。

なお str( e[0] ) は保存する画像の拡張子です。

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

file_name, ext = os.path.splitext(os.path.basename(x[0]))
e.append(ext)

このコードは、x[0] のパスにある画像データを読み込んで、splitext( ) でファイル名と拡張子に切り分けて、左側のfile_name と ext に代入する動作をします。

三行目の e.append( ext ) で、拡張子を e というリストに保存しています。

この e[0] は e のリストの一番最初のデータで拡張子が入っています。

str( e[0] ) とすることで、文字データとして拡張子が取り出せます。

まとめ

  • PySimpleGUIでボタン類を作る際は先にまとめて定義しておくと便利
  • PySimpleGUIは画面構成をリストで作る
  • event は一回限りの実行、values は継続的に実行する