![見出し画像](https://assets.st-note.com/production/uploads/images/80622190/rectangle_large_type_2_8e8aad2641fa09dff0fcccebfbdc983d.png?width=1200)
【PythonでGUIを作りたい】Tkinterで作る画像編集ソフト#4:実装とテスト
こんにちは。すうちです。
少し前から新しい試みで連載記事を投稿しています。4回目となりますが実装と動作確認までできたので一区切りです。
前回の記事はこちらです。
今回はTkinterを使ったGUI(画像編集ソフト)構築の実装とテストに関する話を書きたいと思います。
◆はじめに
再び最初に書いたメモです。
・ユーザ操作はGUI(今回はTkinter)
・画像の簡単な加工(クリップ、回転等)
・動画の簡単な編集(画像加工も可)
・動画や画像ファイルの読み書き可
できるだけPythonの簡単なプログラムやTkinterの基本機能で実現することが目標です。
◆クラス仕様見直し(クリップ対応)
クラス定義(役割分担)
クラスは、ViewGUI、ControlGUI、ModelImage3つを定義してます。
![画像](https://assets.st-note.com/img/1654354449129-wT5BBUgo10.png?width=1200)
![](https://assets.st-note.com/img/1655000308085-rwGdCmMEEk.png?width=1200)
クラスメソッド(関数)追加
各クラスのメソッドに以下定義(赤字)を追加しています。
※記載漏れも一部追記
![](https://assets.st-note.com/img/1659716209864-bSz9u9kJrx.png?width=1200)
◆内部設計の補足(クリップ対応)
前回クリップは方針が固まってなかったので少し補足します。
クリップ位置補正(始点、終点)
クリップ位置を決めるマウス操作は、動作によって以下の始点(x0,y0)、終点(x1,y1)の4つのパターンが考えられます。
今回クリップ処理は、画像の左上を始点にしてクリップする幅と高さを決めたいので始点と終点の位置関係から以下のように補正します。
![](https://assets.st-note.com/img/1655046532796-NQy3hMuPU0.png?width=1200)
クリップ位置反映(Canvas画像と元画像)
以下、縦長画像の例です。
Canvas画像の表示はPillowのImageOps.padを使ってCanvasサイズにあわせて幅・高さを調整(リサイズ)します。そのためCanvas画像と元画像は画像サイズが違うのでClip領域の位置合わせが必要です。
この場合、Canvas HeightとImage Heightから倍率はわかるので、これを使ってキャンバス上で取得したクリップ位置を元画像に反映します(横長画像の場合、Canvas WidthとImage Widthから倍率計算)。
ただし、Canvasで取得したクリップ位置は表示調整で非表示領域(Pad)が含まれるため、元画像に反映する際はPad分を減算しています。
![](https://assets.st-note.com/img/1655046581867-F9hh18MY5d.png?width=1200)
クリップイベントの登録
今回クリップで使用しているイベントは以下です。Canvas内のマウスイベントを取りたいので、window_canvas.bind('マウスイベント', ’イベント処理')で実行したい処理を紐づけます。
![](https://assets.st-note.com/img/1655010237978-eLAkEIZFZ6.png)
◆実装
本日時点の各クラスのコードです。
ViewGUIクラス(ViewGUI.py)
# -*- coding: utf-8 -*-
import sys
import tkinter as tk
from tkinter import ttk, filedialog
from ControlGUI import ControlGUI
class ViewGUI():
def __init__(self, window_root, default_path):
# Controller Class生成
self.control = ControlGUI(default_path)
# 初期化
self.dir_path = default_path
self.file_list = ['..[select file]']
self.clip_enable = False
# メインウィンドウ
self.window_root = window_root
# メインウィンドウサイズ指定
self.window_root.geometry("800x600")
# メインウィンドウタイトル
self.window_root.title('GUI Image Editor v0.90')
# サブウィンドウ
self.window_sub_ctrl1 = tk.Frame(self.window_root, height=300, width=300)
self.window_sub_ctrl2 = tk.Frame(self.window_root, height=500, width=300)
self.window_sub_ctrl3 = tk.Frame(self.window_root, height=150, width=400)
self.window_sub_canvas = tk.Canvas(self.window_root, height=450, width=400, bg='gray')
# オブジェクト
# StringVar(ストリング)生成
self.str_dir = tk.StringVar()
# IntVar生成
self.radio_intvar1 = tk.IntVar()
self.radio_intvar2 = tk.IntVar()
# GUIウィジェット・イベント登録
# ラベル
label_s2_blk1 = tk.Label(self.window_sub_ctrl2, text='')
label_s3_blk1 = tk.Label(self.window_sub_ctrl3, text='')
label_s3_blk2 = tk.Label(self.window_sub_ctrl3, text='')
label_target = tk.Label(self.window_sub_ctrl1, text='[Files]')
label_rotate = tk.Label(self.window_sub_ctrl2, text='[Rotate]')
label_flip = tk.Label(self.window_sub_ctrl2, text='[Flip]')
label_clip = tk.Label(self.window_sub_ctrl2, text='[Clip]')
label_run = tk.Label(self.window_sub_ctrl2, text='[Final Edit]')
# フォルダ選択ボタン生成
self.button_setdir = tk.Button(self.window_sub_ctrl1, text = 'Set Folder', width=10, command=self.event_set_folder)
# テキストエントリ生成
self.entry_dir = tk.Entry(self.window_sub_ctrl1, text = 'entry_dir', textvariable=self.str_dir, width=40)
self.str_dir.set(self.dir_path)
# コンボBOX生成
self.combo_file = ttk.Combobox(self.window_sub_ctrl1, text = 'combo_file', value=self.file_list, state='readonly', width=30, postcommand=self.event_updatefile)
self.combo_file.set(self.file_list[0])
self.combo_file.bind('<<ComboboxSelected>>', self.event_selectfile)
# 切替ボタン生成
button_next = tk.Button(self.window_sub_ctrl3, text = '>>Next', width=10, command=self.event_next)
button_prev = tk.Button(self.window_sub_ctrl3, text = 'Prev<<', width=10, command=self.event_prev)
# クリップボタン生成
button_clip_start = tk.Button(self.window_sub_ctrl2, text = 'Try', width=5, command=self.event_clip_try)
button_clip_done = tk.Button(self.window_sub_ctrl2, text = 'Done', width=5, command=self.event_clip_done)
# Save/Undoボタン生成
button_save = tk.Button(self.window_sub_ctrl2, text = 'Save', width=5, command=self.event_save)
button_undo = tk.Button(self.window_sub_ctrl2, text = 'Undo', width=5, command=self.event_undo)
# ラジオボタン生成
radio_rotate = []
for val, text in enumerate(['90°','180°','270°']): # 1:rot90 2:rot180 3:rot270
radio_rotate.append(tk.Radiobutton(self.window_sub_ctrl2, text=text, value=val+1, variable=self.radio_intvar1, command=self.event_rotate))
self.radio_intvar1.set(0) # 0:No select
radio_flip = []
for val, text in enumerate(['U/D','L/R']): # 1:Flip U/L 2:Flip L/R
radio_flip.append(tk.Radiobutton(self.window_sub_ctrl2, text=text, value=val+1, variable=self.radio_intvar2, command=self.event_flip))
self.radio_intvar2.set(0) # 0:No select
self.window_sub_canvas.bind('<ButtonPress-1>', self.event_clip_start)
self.window_sub_canvas.bind('<Button1-Motion>', self.event_clip_keep)
self.window_sub_canvas.bind('<ButtonRelease-1>', self.event_clip_end)
## ウィジェット配置
# サブウィンドウ
self.window_sub_ctrl1.place(relx=0.65, rely=0.05)
self.window_sub_ctrl2.place(relx=0.65, rely=0.25)
self.window_sub_ctrl3.place(relx=0.15, rely=0.9)
self.window_sub_canvas.place(relx=0.05, rely=0.05)
# window_sub_ctrl1
self.button_setdir.grid(row=1, column=1, padx=5, pady=5, sticky=tk.W)
self.entry_dir.grid(row=2, column=1, padx=5, pady=5, sticky=tk.W)
label_target.grid(row=3, column=1, padx=5, pady=5, sticky=tk.W)
self.combo_file.grid(row=4, column=1, padx=5, pady=5, sticky=tk.W)
# window_sub_ctrl2
label_s2_blk1.grid(row=1, column=1, padx=5, pady=5, sticky=tk.W)
label_rotate.grid(row=2, column=1, padx=5, pady=5, sticky=tk.W)
radio_rotate[0].grid(row=3, column=1, padx=5, pady=5, sticky=tk.W)
radio_rotate[1].grid(row=3, column=2, padx=5, pady=5, sticky=tk.W)
radio_rotate[2].grid(row=3, column=3, padx=5, pady=5, sticky=tk.W)
label_flip.grid(row=4, column=1, padx=5, pady=5, sticky=tk.W)
radio_flip[0].grid(row=5, column=1, padx=5, pady=5, sticky=tk.W)
radio_flip[1].grid(row=5, column=2, padx=5, pady=5, sticky=tk.W)
label_clip.grid(row=6, column=1, padx=5, pady=5, sticky=tk.W)
button_clip_start.grid(row=7, column=1, padx=5, pady=5, sticky=tk.W)
button_clip_done.grid(row=7, column=2, padx=5, pady=5, sticky=tk.W)
label_run.grid(row=8, column=1, columnspan=2, padx=5, pady=5, sticky=tk.W)
button_undo.grid(row=9, column=1, padx=5, pady=5, sticky=tk.W)
button_save.grid(row=9, column=2, padx=5, pady=5, sticky=tk.W)
# window_sub_ctrl3
label_s3_blk1.grid(row=1, column=1, columnspan=2, padx=5, pady=5, sticky=tk.EW)
button_prev.grid(row=1, column=3, padx=5, pady=5, sticky=tk.E)
label_s3_blk2.grid(row=1, column=4, columnspan=2, padx=5, pady=5, sticky=tk.EW)
button_next.grid(row=1, column=6, padx=5, pady=5, sticky=tk.W)
# Set Canvas
self.control.SetCanvas(self.window_sub_canvas)
# Event Callback
def event_set_folder(self):
print(sys._getframe().f_code.co_name)
self.dir_path = filedialog.askdirectory(initialdir=self.dir_path, mustexist=True)
self.str_dir.set(self.dir_path)
self.file_list = self.control.SetDirlist(self.dir_path)
self.combo_file['value'] = self.file_list
def event_updatefile(self):
print(sys._getframe().f_code.co_name)
self.file_list = self.control.SetDirlist(self.dir_path)
self.combo_file['value'] = self.file_list
def event_selectfile(self, event):
print(sys._getframe().f_code.co_name)
set_pos = self.combo_file.current()
self.control.DrawImage('set', set_pos=set_pos)
def event_prev(self):
print(sys._getframe().f_code.co_name)
pos = self.control.DrawImage('prev')
self.combo_file.set(self.file_list[pos])
def event_next(self):
print(sys._getframe().f_code.co_name)
pos = self.control.DrawImage('next')
self.combo_file.set(self.file_list[pos])
def event_rotate(self):
val = self.radio_intvar1.get()
cmd = 'rotate-' + str(val)
self.control.EditImage(cmd)
print('{} {} {}'.format(sys._getframe().f_code.co_name, val, cmd))
def event_flip(self):
val = self.radio_intvar2.get()
cmd = 'flip-' + str(val)
self.control.EditImage(cmd)
print('{} {} {}'.format(sys._getframe().f_code.co_name, val, cmd))
def event_clip_try(self):
print(sys._getframe().f_code.co_name)
self.clip_enable = True
def event_clip_done(self):
print(sys._getframe().f_code.co_name)
if self.clip_enable:
self.control.EditImage('clip_done')
self.clip_enable = False
def event_clip_start(self, event):
print(sys._getframe().f_code.co_name, event.x, event.y)
if self.clip_enable:
self.control.DrawRectangle('clip_start', event.y, event.x)
def event_clip_keep(self, event):
#print(sys._getframe().f_code.co_name)
if self.clip_enable:
self.control.DrawRectangle('clip_keep', event.y, event.x)
def event_clip_end(self, event):
print(sys._getframe().f_code.co_name, event.x, event.y)
if self.clip_enable:
self.control.DrawRectangle('clip_end', event.y, event.x)
def event_save(self):
print(sys._getframe().f_code.co_name)
self.control.SaveImage()
def event_undo(self):
print(sys._getframe().f_code.co_name)
self.control.UndoImage('None')
self.radio_intvar1.set(0)
self.radio_intvar2.set(0)
if __name__ == '__main__':
# Tk MainWindow 生成
main_window = tk.Tk()
# Viewクラス生成
ViewGUI(main_window, './')
# フレームループ処理
main_window.mainloop()
ControlGUIクラス(ControlGUI.py)
# -*- coding: utf-8 -*-
import os
from ModelImage import ModelImage
class ControlGUI():
def __init__(self, default_path):
# Model Class生成
self.model = ModelImage()
self.dir_path = default_path
self.ext_keys = ['.png', '.jpg', '.jpeg', '.JPG', '.PNG']
self.target_files = []
self.file_pos = 0
self.clip_sx = 0
self.clip_sy = 0
self.clip_ex = 0
self.clip_ey = 0
self.canvas = None
def is_target(self, name, key_list):
valid = False
for ks in key_list:
if ks in name:
valid = True
return valid
def get_file(self, command, set_pos=-1):
if command == 'prev':
self.file_pos = self.file_pos - 1
elif command == 'next':
self.file_pos = self.file_pos + 1
elif command == 'set':
self.file_pos = set_pos
else: # current
self.file_pos = self.file_pos
num = len(self.target_files)
if self.file_pos < 0:
self.file_pos = num -1
elif self.file_pos >= num:
self.file_pos = 0
cur_file = os.path.join(self.dir_path, self.target_files[self.file_pos])
print('{}/{} {} '.format(self.file_pos, num-1, cur_file))
return cur_file
# Public
def SetDirlist(self, dir_path):
self.dir_path = dir_path
self.target_files = []
file_list = os.listdir(self.dir_path)
for fname in file_list:
if self.is_target(fname, self.ext_keys):
self.target_files.append(fname)
print(fname)
self.file_pos = 0
if len(self.target_files) > 0:
cur_file = self.get_file('current')
print(cur_file)
return self.target_files
def SetCanvas(self, window_canvas):
self.canvas = window_canvas
def DrawImage(self, command, set_pos=-1):
fname = self.get_file(command, set_pos)
self.model.DrawImage(fname, self.canvas, 'None')
return self.file_pos
def DrawRectangle(self, command, pos_y, pos_x):
if command == 'clip_start':
self.clip_sy, self.clip_sx = pos_y, pos_x
self.clip_ey, self.clip_ex = pos_y+1, pos_x+1
elif command == 'clip_keep':
self.clip_ey, self.clip_ex = pos_y, pos_x
elif command == 'clip_end':
self.clip_ey, self.clip_ex = pos_y, pos_x
self.clip_sy, self.clip_sx = self.model.GetValidPos(self.clip_sy, self.clip_sx)
self.clip_ey, self.clip_ex = self.model.GetValidPos(self.clip_ey, self.clip_ex)
self.model.DrawRectangle(self.canvas, self.clip_sy, self.clip_sx, self.clip_ey, self.clip_ex)
def EditImage(self, command):
args = {}
if command == 'clip_done':
args['sx'], args['sy'] = self.clip_sx, self.clip_sy
args['ex'], args['ey'] = self.clip_ex, self.clip_ey
fname = self.get_file('current')
self.model.DrawImage(fname, self.canvas, command, args=args)
def SaveImage(self):
fname = self.get_file('current')
self.model.SaveImage(fname)
def UndoImage(self, command):
fname = self.get_file('current')
self.model.DrawImage(fname, self.canvas, command)
ModelImageクラス(ModelImage.py)
# -*- coding: utf-8 -*-
import os
import numpy as np
from datetime import datetime
from PIL import Image, ImageTk, ImageOps
class ModelImage():
def __init__(self, ImageType='Photo'):
self.ImageType = ImageType
self.edit_img = None
self.original_img = None
self.canvas_w = 0
self.canvas_h = 0
def set_image_layout(self, canvas, image):
self.canvas_w = canvas.winfo_width()
self.canvas_h = canvas.winfo_height()
h, w = image.height, image.width
if h > w:
self.resize_h = self.canvas_h
self.resize_w = int(w * (self.canvas_h/h))
self.pad_x = (self.canvas_w - self.resize_w) // 2
self.pad_y = 0
else:
self.resize_w = self.canvas_w
self.resize_h = int(h * (self.canvas_w/w))
self.pad_y = (self.canvas_h - self.resize_h) // 2
self.pad_x = 0
print(h, w, self.resize_h, self.resize_w, self.pad_y, self.pad_x)
def get_correct_values(self, rate, sy, sx, ey, ex):
mod_sx = int(np.min((sx, ex))*rate)
mod_sy = int(np.min((sy, ey))*rate)
mod_ex = int(np.max((sx, ex))*rate)
mod_ey = int(np.max((sy, ey))*rate)
ch, cw = mod_ey - mod_sy, mod_ex - mod_sx
return mod_sy, mod_sx, ch, cw
def get_original_coords(self, h, w, args):
print(args, h, w)
sy, sx, ey, ex = args['sy'], args['sx'], args['ey'], args['ex']
if h > w:
rate = h/self.canvas_h
x_spc = self.pad_x*rate
sy, sx, ch, cw = self.get_correct_values(rate, sy, sx, ey, ex)
sx = sx - x_spc
sx = int(np.max((sx, 0)))
sx = int(np.min((sx, w)))
else:
rate = w/self.canvas_w
y_spc = self.pad_y*rate
sy, sx, ch, cw = self.get_correct_values(rate, sy, sx, ey, ex)
sy = sy - y_spc
sy = int(np.max((sy, 0)))
sy = int(np.min((sy, h)))
return sy, sx, ch, cw
def edit_image_command(self, orginal_image, edit_image, command, args={}):
if edit_image != None:
img = edit_image
else:
img = orginal_image.copy()
np_img = np.array(img)
if 'flip-1' in command: # U/L
np_img = np.flip(np_img, axis=0)
elif 'flip-2' in command: # L/R
np_img = np.flip(np_img, axis=1)
elif 'rotate-' in command: # 1:rot90 2:rot180 3:rot270
cmd = int(command.replace('rotate-', ''))
np_img = np.rot90(np_img, cmd)
elif 'clip_done' in command:
h, w = np_img[:,:,0].shape
sy, sx, ch, cw = self.get_original_coords(h, w, args)
np_img = np_img[sy:sy+ch, sx:sx+cw,:]
return Image.fromarray(np_img)
# Public
def GetValidPos(self, pos_y, pos_x):
if self.resize_h > self.resize_w:
valid_pos_y = pos_y
valid_pos_x = np.max((pos_x, self.pad_x))
valid_pos_x = np.min((valid_pos_x, self.canvas_w - self.pad_x))
else:
valid_pos_x = pos_x
valid_pos_y = np.max((pos_y, self.pad_y))
valid_pos_y = np.min((valid_pos_y, self.canvas_h - self.pad_y))
return valid_pos_y, valid_pos_x
def DrawImage(self, fpath, canvas, command, args={}):
if canvas.gettags('Photo'):
canvas.delete('Photo')
if self.edit_img != None and command != 'None':
img = self.edit_img
else:
img = Image.open(fpath)
self.original_img = img
self.edit_img = None
self.set_image_layout(canvas, self.original_img)
if command != 'None':
img = self.edit_image_command(self.original_img, self.edit_img, command, args=args)
self.edit_img = img
self.set_image_layout(canvas, self.edit_img)
pil_img = ImageOps.pad(img, (self.canvas_w, self.canvas_h))
self.tk_img = ImageTk.PhotoImage(image=pil_img)
canvas.create_image(self.canvas_w/2, self.canvas_h/2, image=self.tk_img, tag='Photo')
def DrawRectangle(self, canvas, clip_sy, clip_sx, clip_ey, clip_ex):
if canvas.gettags('clip_rect'):
canvas.delete('clip_rect')
canvas.create_rectangle(clip_sx, clip_sy, clip_ex, clip_ey, outline='red', tag='clip_rect')
def SaveImage(self, fname):
if self.edit_img != None:
name, ext = os.path.splitext(fname)
dt = datetime.now()
fpath = name + '_' + dt.strftime('%H%M%S') + '.png'
self.edit_img.save(fpath)
print("Saved: {}".format(fpath))
◆テスト
以下の環境で確認しています。
OS:Windows10
プログラム言語:Python3.8.5
IDE環境(実行・デバッグ):Spyder4.1.5
本来は単体から結合、総合テストの流れがありますが、今回実装と並行してテストと問題あれば修正してたので基本的な動作は確認済です。
以降、ユーザ視点の確認になりますが、テスト内容を参考に示します。
フォルダ選択→ファイル選択→表示切替
フォルダ選択後、フォルダ配下のファイル(jpg、pngなど)を切替可。
![](https://assets.st-note.com/production/uploads/images/80550247/picture_pc_7ac09512473c420d2caaccb17b4e7b4b.gif)
クリップ→保存
画像クリップ後に編集ファイルを保存。
![](https://assets.st-note.com/production/uploads/images/80549419/picture_pc_3d55516148504bdbb1899d2765d01393.gif)
回転→反転(途中Undo)
クリップ編集後に保存した画像を回転、反転。
![](https://assets.st-note.com/production/uploads/images/80549560/picture_pc_efa3899c35b5112d8ae7b8383eeaf8a7.gif)
静止画編集は、当初目標にしていた動作を確認できました。
今後の予定
ユーザ仕様:
GUI実現機能などの仕様検討
内部設計:
目標仕様の実現に向けた設計検討
実装・コーディング:
設計方針に基づく実装やコーディング
テスト・デバッグ:
作成したプログラムの評価
仕様変更・機能追加:
一旦作成したGUIに機能追加など検討
次回以降は、動画編集の機能追加の話を予定しています。
最後に
Clipの処理が思ったより時間かかりましたが、静止画の編集機能は当初の予定まで実現できました。基本的な操作は問題ないことを確認してますが異常系のテストは十分にできてない所もあります。
一通りコードを見返すともう少し考えた方がよい箇所も正直ありますが、その辺は動画編集の実装時やリファクタリング(コード整理)をやる機会があれば検討したいと思います。
本連載は少し間をあけて、動画編集機能の実装までは続ける予定です。
GUI構築の流れを大まかに把握したい方、Tkinterを使うことを検討されている方に、何か参考になれば幸いです。
最後まで読んで頂き、ありがとうございました。
ーーー
ソフトウェアを作る過程を体系的に学びたい方向けの本を書きました(連載記事に大幅に加筆・修正)
Kindle Unlimited 会員の方は無料で読めます。
#連載記事の一覧
ーーー
この記事が気に入ったらサポートをしてみませんか?