見出し画像

【PythonでGUIを作りたい】Tkinterで作る画像編集ソフト#4:実装とテスト

こんにちは。すうちです。

少し前から新しい試みで連載記事を投稿しています。4回目となりますが実装と動作確認までできたので一区切りです。

前回の記事はこちらです。

今回はTkinterを使ったGUI(画像編集ソフト)構築の実装とテストに関する話を書きたいと思います。


◆はじめに

再び最初に書いたメモです。

・ユーザ操作はGUI(今回はTkinter)
・画像の簡単な加工(クリップ、回転等)
・動画の簡単な編集(画像加工も可)
・動画や画像ファイルの読み書き可

できるだけPythonの簡単なプログラムやTkinterの基本機能で実現することが目標です。

◆クラス仕様見直し(クリップ対応)

クラス定義(役割分担)

クラスは、ViewGUIControlGUIModelImage3つを定義してます。

画像
表1. クラス定義
図1. クラス関係

クラスメソッド(関数)追加

各クラスのメソッドに以下定義(赤字)を追加しています。
※記載漏れも一部追記

表2. クラスメソッド


◆内部設計の補足(クリップ対応)

前回クリップは方針が固まってなかったので少し補足します。

クリップ位置補正(始点、終点)

クリップ位置を決めるマウス操作は、動作によって以下の始点(x0,y0)、終点(x1,y1)の4つのパターンが考えられます。

今回クリップ処理は、画像の左上を始点にしてクリップする幅と高さを決めたいので始点と終点の位置関係から以下のように補正します。

図2.クリップ位置補正:補正前(左)補正後(右)


クリップ位置反映(Canvas画像と元画像)

以下、縦長画像の例です。
Canvas画像の表示はPillowのImageOps.padを使ってCanvasサイズにあわせて幅・高さを調整(リサイズ)します。そのためCanvas画像と元画像は画像サイズが違うのでClip領域の位置合わせが必要です。

この場合、Canvas HeightとImage Heightから倍率はわかるので、これを使ってキャンバス上で取得したクリップ位置を元画像に反映します(横長画像の場合、Canvas WidthとImage Widthから倍率計算)。

ただし、Canvasで取得したクリップ位置は表示調整で非表示領域(Pad)が含まれるため、元画像に反映する際はPad分を減算しています。

図3.元画像(左)とキャンバス画像(右)のイメージ


クリップイベントの登録

今回クリップで使用しているイベントは以下です。Canvas内のマウスイベントを取りたいので、window_canvas.bind('マウスイベント', ’イベント処理')で実行したい処理を紐づけます。

表3. クリップのマウスイベント

◆実装

本日時点の各クラスのコードです。

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-1in command: # U/L
            np_img = np.flip(np_img, axis=0)
            
        elif 'flip-2in 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_donein 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/2self.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など)を切替可。

フォルダとファイル選択

クリップ→保存

画像クリップ後に編集ファイルを保存。

クリップとファイル保存

回転→反転(途中Undo)

クリップ編集後に保存した画像を回転、反転。

画像回転と反転

静止画編集は、当初目標にしていた動作を確認できました。

今後の予定

ユーザ仕様:
 GUI実現機能などの仕様検討
内部設計:
 目標仕様の実現に向けた設計検討
実装・コーディング:
 設計方針に基づく実装やコーディング
テスト・デバッグ:
 作成したプログラムの評価
仕様変更・機能追加:
 一旦作成したGUIに機能追加など検討

次回以降は、動画編集の機能追加の話を予定しています。

最後に

Clipの処理が思ったより時間かかりましたが、静止画の編集機能は当初の予定まで実現できました。基本的な操作は問題ないことを確認してますが異常系のテストは十分にできてない所もあります。

一通りコードを見返すともう少し考えた方がよい箇所も正直ありますが、その辺は動画編集の実装時やリファクタリング(コード整理)をやる機会があれば検討したいと思います。

本連載は少し間をあけて、動画編集機能の実装までは続ける予定です。

GUI構築の流れを大まかに把握したい方、Tkinterを使うことを検討されている方に、何か参考になれば幸いです。

最後まで読んで頂き、ありがとうございました。


ーーー

ソフトウェアを作る過程を体系的に学びたい方向けの本を書きました(連載記事に大幅に加筆・修正)

Kindle Unlimited 会員の方は無料で読めます。


#連載記事の一覧

ーーー

この記事が気に入ったらサポートをしてみませんか?