見出し画像

【PythonでGUIを作りたい】Tkinterで作る画像編集ソフト#12:gif保存の機能追加とリファクタリング(後編)

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

昨年『Tkinterで作る画像編集ソフト』というお題でnoteに連載してました。

先週この画像編集ソフトに機能追加とリファクタリング(gif保存機能の追加とコード整理)の概要を書きましたが、今回はその続きで実装の詳細とテストの話です。

前回の記事



はじめに

作りたい機能のイメージ

前回のおさらいでやりたいことメモです。

・動画編集の保存でmp4とgif選択可
・gifは保存設定も選択可(画像サイズ、スキップ数)

上記に関連して、以下も見直します。

・動画保存の処理スレッド化(並列処理)
・動画保存時の進捗状況表示

このメモを元に、仕様検討と実装を進めました。


◆機能追加の実装詳細

以降、主な修正点です。

ウィンドウ(Tkinterフレーム)表示切替

タブやgifの選択状態を分岐にしてplace()place_forget()を使ってウィンドウの表示/非表示を切り替えてます。

新規ウィンドウ(Tkinterフレーム)と部品配置
def sub_frame5_display(self):
    
    if self.select_tab == '[Video]':
        self.window_sub_ctrl5.place(relx=0.68, rely=0.68)
        self.button_drop.grid(row=9, column=3, padx=5, pady=5, sticky=tk.W)
        # [EXT] option: 'gif' or 'mp4'
        arg0 = self.ftype[self.radio_intvar[0].get()]
        if arg0 == 'gif':
            self.window_sub_ctrl6.place(relx=0.68, rely=0.78)
    else:
        self.window_sub_ctrl5.place_forget()
        self.window_sub_ctrl6.place_forget()
        self.button_drop.grid_forget()


動画保存のスレッド化(並列処理)

修正前の動画保存

図1.従来の動画保存処理
def SaveVideo(self, fname, frame_1, frame_2):
    
    fno_sp = min(frame_1, frame_2)
    fno_ep = max(frame_1, frame_2)
    if fno_sp == fno_ep:
        fno_ep += 1
        
    self.save_images = []
    
    name, ext = os.path.splitext(fname)
    fpath = '{}_frame{}_{}.mp4'.format(name, fno_sp, fno_ep)
    
    edit_command_list, args = self.create_command_list()
    
    video_format = cv2.VideoWriter_fourcc(*'mp4v') 
    self.video = cv2.VideoWriter(fpath, video_format, self.fps, (self.edit_w, self.edit_h))
    
    self.cap.set(cv2.CAP_PROP_POS_FRAMES, fno_sp)
    self.edit_num = fno_ep - fno_sp
    
    for n in range(self.edit_num):
        ret, frame = self.cap.read()
        if ret:
            img_cnv = self.edit_video(frame, edit_command_list, args)
            self.video.write(img_cnv)
            self.save_images.append(img_cnv)
            if n % 100 == 0:
                print('{}/{}'.format(n, self.edit_num))
            
    self.video.release()
    self.clear_command_list()
    print("Saved: {}".format(fpath))

上記は保存範囲を、opencvVideoWriterでフレームの書き込みを繰り返しています。


修正後の動画保存

図2.修正した動画保存処理

並列処理の実装(ModelImage.py)

def SaveVideo(self, fname, frame_1, frame_2, save_args):          
    # Set frames to save
    fno_sp = min(frame_1, frame_2)
    fno_ep = max(frame_1, frame_2)
    if fno_sp == fno_ep:
        fno_ep += 1
    
    self.cap_save = cv2.VideoCapture(fname)
    self.cap_save.set(cv2.CAP_PROP_POS_FRAMES, fno_sp)
    self.edit_num = fno_ep - fno_sp
    self.save_num = 0
    print(f'fno_sp:{fno_sp} fno_ep:{fno_ep} save_num:{self.save_num}')

    # Create file_path
    name, ext = os.path.splitext(fname)
    self.file_path = '{}_frame{}_{}.{}'.format(name, fno_sp, fno_ep, save_args[0])
    # Create edit commands(Minimal)
    self.create_command_list()
    # Pre-Process for save video
    self.save_ftype = save_args[0]
    self.prepare_save_video(save_args)
        
    self.save_status = True
    print(self.save_ftype, self.resz_rate, self.skip_rate)
    # start save_video_thread
    self.sid = self.canvas_video.after(20, self.save_video_thread)


def save_video_thread(self):
    
    if self.save_num < self.edit_num:
        ret, frame = self.cap_save.read()
        if ret:
            # Edit frame
            img_cnv = self.edit_video(frame, self.edit_command_list, None)
            # Save 1frame
            self.save_video_frame(img_cnv)
            # View callback
            self.cb_saving(self.save_status, self.save_num, self.edit_num)
            # update save frame
            self.save_num += 1
            
            if self.save_status:
                # start save_video_thread
                self.sid = self.canvas_video.after(20, self.save_video_thread)
    
    else:
        # Complete saving
        self.complete_save_video()
        self.clear_save_video()
        self.clear_command_list()
        self.save_status = False
        self.cb_saving(self.save_status, self.save_num, self.edit_num)


def prepare_save_video(self, save_args):

    if self.save_ftype == 'mp4':
        # Open Video Writer
        video_format = cv2.VideoWriter_fourcc(*'mp4v') 
        self.video   = cv2.VideoWriter(self.file_path, video_format, self.fps, (self.edit_w, self.edit_h))
        self.skip_rate  = 1
        self.resz_rate  = 1

    else: # gif
        # For gif save
        self.pil_images = []
        self.resz_rate  = int(save_args[1][2])  # '1/X' -> int('X')
        self.skip_rate  = int(save_args[2][2])  #' 1/X' -> int('X')


def save_video_frame(self, img_cnv):
   
    if self.save_ftype == 'mp4':
        self.video.write(img_cnv)
    
    else: # Save gif list
        if self.save_num % self.skip_rate == 0:
            img_cnv = cv2.cvtColor(img_cnv, cv2.COLOR_BGR2RGB)
            img_cnv = Image.fromarray(img_cnv).resize((self.edit_w//self.resz_rate, self.edit_h//self.resz_rate), resample=Image.BICUBIC)
            self.pil_images.append(img_cnv)


def complete_save_video(self):

    if self.save_ftype == 'mp4':
        self.video.release()
        
        if self.save_status:
            print("Saved: {}".format(self.file_path))
        else:
            # remove file if choosed to drop
            os.remove(self.file_path)

    else: # gif
        if self.save_status:
            self.pil_images[0].save(self.file_path, save_all = True, append_images=self.pil_images[1:], duration=200, loop=0)
            print("Saved: {}".format(self.file_path))

前述のループ処理を1フレーム単位に分割しています(save_video_thread()を定期的に実行)。またmp4gif保存の分岐は、prepare_save_video()
save_video_frame()complete_save_video()まとめて
save_video_thread()からは直接意識しない様にしました。


gifリサイズと間引きの実装

self.resz_rate
 = int(save_args[1][2]) # '1/X' -> int('X')
self.skip_rate
 = int(save_args[2][2]) #' 1/X' -> int('X')

例:save_args[1]='1/2'(テキスト3文字)の場合
3文字目('1’、'/'、'2'  ※コードは0から数えて2番目)を使う。

GUIから渡す設定値(テキスト)は、prepare_save_video()resz_rate
skip_rate に保存。この設定を元にリサイズ(画像の縦横の縮小率)フレームの間引き(save_num % skip_rate == 0 のフレーム保存)しています。


gif保存処理
コードは、save_video_frame()complete_save_video()になりますが、以下の様にopencvで読み出したフレームをRGB変換とリサイズ後リストに保存。最後にPillowイメージのsave()append_imagesに保存したリスト設定してgif作成してます。

//1フレーム単位のループ処理
if self.save_num % self.skip_rate == 0:
  img_cnv = cv2.cvtColor(img_cnv, cv2.COLOR_BGR2RGB)
  img_cnv = Image.fromarray(img_cnv).resize((self.edit_w//self.resz_rate, self.edit_h//self.resz_rate), resample=Image.BICUBIC)
  self.pil_images.append(img_cnv)

//gif保存(Pillowイメージのsave)
self.pil_images[0].save(self.file_path, save_all = True, append_images=self.pil_images[1:], duration=200, loop=0)


並列処理の実装(ControlGUI.py)

元のコードは、保存処理のスレッド化によりSaveVideo()を抜けて保存後の処理が実行されてしまうため、こちらも処理を分割して保存終了後(save_video_threadのコールバック関数内)に実行するようにしました。

修正後(ControlGUI.py)

def Save(self, args=None):
    
    tab = self.select_tab
    if tab == '[Photo]':        
        file_path = self.get_file('current')
        self.model.SavePhoto(file_path)
        
    else: # '[Video]'
        _, self.frame[self.video_tag] = self.model.GetVideo('status')
        file_path = self.get_file('current')
        self.model.SaveVideo(file_path, self.frame['Video1'], self.frame['Video2'], args)
        print('tag, fno1, fno2',self.video_tag, self.frame['Video1'], self.frame['Video2'])
  

def ClearCanvas(self):
    
    tab = self.select_tab
    if tab == '[Photo]':
        self.model.DeleteRectangle(self.canvas['Photo'])
        
    else: # '[Video]'
        self.model.EditVideo(self.canvas['Video1'], 'Video1', 'Undo', self.frame['Video1'])
        self.model.EditVideo(self.canvas['Video2'], 'Video2', 'Undo', self.frame['Video2'])
        self.model.DeleteRectangle(self.canvas['Video1'])
        self.model.DeleteRectangle(self.canvas['Video2'])

修正後(ModelImage.py)

def save_video_thread(self):
    
    if self.save_num < self.edit_num:
        ret, frame = self.cap_save.read()
            (省略)
    else:
        (省略)
        self.save_status = False
        # View callback
        self.cb_saving(self.save_status, self.save_num, self.edit_num) # <-ココ


# View callback
def update_savestat(self, is_save_status, cur_num, total_num):
    
    if is_save_status:
        progress = (cur_num/total_num)*100
        self.label_msgtxt['text'] = '{}/{} Saving.. {:.0f} %'.format(cur_num, total_num, progress)
        
    else:
        self.control.ClearCanvas() # <=ココ 
        self.control.ForceToState('STOP')
        self.clear_message()
        self.display_tab()


状態管理の追加修正

保存処理の並列化で動画保存中の操作を扱う必要が出てきたので、動画保存中(SAVING)の状態と、保存中断(Drop)のコマンドを追加しました。

状態管理表(修正後)

修正後(ControlGUI.py)

def InitStateMachine(self):
    # (1/0:有効/無効, 0-7:遷移先)
    stm_video = [
        #0:IDLE
        {'dir':(1,1),'set':(0,0),'play':(0,0),'stop':(0,0),'step':(0,0),'speed|bar':(0,0),'cap':(0,0),
         'edit':(0,0),'clip':(0,0),'rect':(0,0),'done':(0,0),'dclick':(0,0),'undo':(0,0),
         'save':(0,0),'drop':(0,0)},
        #1:SET
        {'dir':(1,1),'set':(1,2),'play':(0,1),'stop':(0,1),'step':(0,1),'speed|bar':(0,1),'cap':(0,1),
         'edit':(0,1),'clip':(0,1),'rect':(0,1),'done':(0,1),'dclick':(0,1),'undo':(0,1),
         'save':(0,1),'drop':(0,1)}, 
        #2:STOP    
        {'dir':(1,1),'set':(1,2),'play':(1,3),'stop':(0,2),'step':(1,2),'speed|bar':(1,2),'cap':(1,2),
         'edit':(1,4),'clip':(1,5),'rect':(0,2),'done':(0,2),'dclick':(1,2),'undo':(1,2),
         'save':(1,7),'drop':(0,2)},   
        #3:PLAY    
        {'dir':(0,3),'set':(0,3),'play':(0,3),'stop':(1,2),'step':(0,3),'speed|bar':(1,3),'cap':(0,3),
         'edit':(0,3),'clip':(0,3),'rect':(0,3),'done':(0,3),'dclick':(0,3),'undo':(0,3),
         'save':(0,3),'drop':(0,3)},
        #4:EDIT    
        {'dir':(0,4),'set':(0,4),'play':(0,4),'stop':(0,4),'step':(0,4),'speed|bar':(0,4),'cap':(1,4),
         'edit':(1,4),'clip':(1,5),'rect':(0,4),'done':(0,4),'dclick':(0,4),'undo':(1,2),
         'save':(1,7),'drop':(0,4)},   
        #5:EDIT_CLIP    
        {'dir':(0,5),'set':(0,5),'play':(0,5),'stop':(0,5),'step':(0,5),'speed|bar':(0,5),'cap':(1,5),
         'edit':(0,5),'clip':(1,5),'rect':(1,5),'done':(1,6),'dclick':(0,5),'undo':(1,2),
         'save':(1,7),'drop':(0,5)},    
        #6:EDIT_LOCK    
        {'dir':(0,6),'set':(0,6),'play':(0,6),'stop':(0,6),'step':(0,6),'speed|bar':(0,6),'cap':(1,6),
         'edit':(0,6),'clip':(0,6),'rect':(0,6),'done':(0,6),'dclick':(0,6),'undo':(1,2),
         'save':(1,7),'drop':(0,6)},
        #7:SAVING    
        {'dir':(0,7),'set':(0,7),'play':(0,7),'stop':(0,7),'step':(0,7),'speed|bar':(0,7),'cap':(0,7),
         'edit':(0,7),'clip':(0,7),'rect':(0,7),'done':(0,7),'dclick':(0,7),'undo':(0,7),
         'save':(0,7),'drop':(1,2)},
    ]


◆不具合対策とリファクタリング

今回の機能追加とコード見直し中に不具合も修正しました。以降、その対策です。

タブ切替の不具合(編集状態)

例えば、動画編集中(回転、クリップ等)に静止画タブに切替えて編集すると、正常に編集できない問題がありました。

原因は、動画編集と静止画編集の関数(変数)を共有してたからです(前の編集情報が後の編集で上書き)。

根本対策は動画と静止画の編集処理を独立させることですが、設計の見直しやテスト工数も結構かかると予想されます。

当初の想定は動画と静止画で編集完了後にタブを切替える前提だったので、今回の対策は、編集中はタブ切替不可(Disable)にしました。

修正後(ViewGUI.py)

タブの表示切替は、notebook.tab()state='normal'(通常表示)state='disabled'(グレー表示)を使用しました。

タブ表示切替の実装

def display_tab(self):
    self.notebook.tab(self.tab1, state='normal')
    self.notebook.tab(self.tab2, state='normal')
    
def disable_tab(self):
    # Disable tab which is not selected
    tab = self.tab2 if self.select_tab == '[Photo]' else self.tab1
    self.notebook.tab(tab, state='disabled')

タブ表示制御の例

# Tabグレー表示(選択以外)
def event_clip_try(self):
    
    if self.control.IsTransferToState('clip'):
        self.disable_tab() # <-ココ
        print(sys._getframe().f_code.co_name)

# Tab表示(選択以外)
def event_undo(self):
    
    if self.control.IsTransferToState('undo'):
        print(sys._getframe().f_code.co_name)
        self.control.Undo('None')
        self.display_tab() # <-ココ


その他の対策と動作仕様見直し

詳細割愛しますが、以下も修正しました。

・タブ切替え時のディクレトリ不一致
 →静止画タブと動画タブのディクレトリを別管理に修正

・ディレクトリ選択時に対象ファイルあれば自動的に表示
 →ファイル選択なしで先頭ファイル表示する動作に修正


リファクタリング(名称とルール見直し)

下記、指定状態に移行するForceToState()は再生終了後、停止状態移行に使ってましたが、他の条件は期待動作になってませんでした(実際は動かない条件)。

そもそもForceToState()は状態遷移の処理ですが、その管理情報(状態の定義値)がなかったため、今回新たに定義して名称も見直しました。

また内部ルールとして、状態は大文字、コマンドは小文字にコード統一しました。

修正前/修正後(ControlGUI.py)

# 修正前
def ForceToState(self, command):
    
    tab = self.select_tab
    cur_state = self.cur_state[tab]
    _, next_state = self.state_table[tab][cur_state][command]
    print('state_change:{}, {}->{}'.format('True', cur_state, next_state))
    self.cur_state[tab] = next_state


# 修正後
def ForceToState(self, state):
    
    tab = self.select_tab
    next_state = self.state_table[tab][state]
    self.cur_state[tab] = next_state

def InitStateMachine(self):
     
     (省略)
    
    # State Machine table
    self.state_machine_table = {'[Photo]':stm_photo, '[Video]':stm_video}

    # State table
    state_video      = {'IDLE':0,'SET':1,'STOP':2,'PLAY':3,'EDIT':4,'EDIT_CLIP':5,'EDIT_LOCK':6,'SAVING':7}
    state_photo      = {'IDLE':0,'SET':1,'EDIT':2,'EDIT_CLIP':3}
    self.state_table = {'[Photo]':state_photo, '[Video]':state_video}

    # Initial state
    self.cur_state   = {'[Photo]':state_photo['IDLE'], '[Video]':state_video['IDLE']}


リファクタリング(関数名修正)

昨年投稿した際に気づいた方もいたかもしれませんが、(恥ずかしながら)ViewGUIで状態遷移を判定するIsTransferToState()にスペルミスありました。こちらも修正(今回見直しを思い立った経緯の一つ…)。

IsTransferToState使用例(event_selectfile)

# 修正前
def event_selectfile(self, event):
    
    if self.control.IsTranferToState('set'):
        print(sys._getframe().f_code.co_name)

        (省略)

# 修正後   
def event_selectfile(self, event):
    
    if self.control.IsTransferToState('set'):
        print(sys._getframe().f_code.co_name)
        self.display_tab()

        (省略)


リファクタリング(その他)

他に冗長な処理や変数も削除しましたが、長くなるので割愛します。以下、概要です。

・Controlメソッド(関数)のselect_tab引数削除
 →タブ切替え時にSetTab()にて更新、各メソッドに不要

・Modelのcreate_command_list関数の戻り値削除
 →関数内でself.edit_command_listに設定保存、戻り値不要



◆動作確認とテスト

gif保存結果

以下のファイルをgifに保存しました。

動画データ:mp4
画像サイズ:1920x1080
ファイル容量:2.2MB

gif保存の例(H,W:1/4、FPS:1/8)

gif保存の結果(ファイル容量)

H,W:1/1、FPS:1/1(調整なし)
 容量:170MB
H,W:1/2、FPS:1/4 
 容量:10.8MB(=170/4/4=10.7MB)
H,W:1/4、FPS:1/8 
 容量:1.4MB(=170/8/16=1.32MB)

gif保存の結果(ファイル容量)

ファイル容量は、調整なし条件と比較すると、画像サイズ、間引きフレームから試算した結果に近い値になっています(ちなみにgifの中身は、都合でMacで確認しましたが動作テストはWindows上でやりました)。


合計フレーム数:79(H,W:1/1、FPS:1/1)
合計フレーム数:20(H,W:1/2、FPS:1/4)
合計フレーム数:10(H,W:1/4、FPS:1/8)

上記の画像からは分かりにくいですが、画像サイズを削るほど見栄えは悪くなってます。また合計フレーム数も間引きした1/4、1/8の割合でした。


◆今回の実装(コード一式)

全体のコードは、量が多いのでGitHubにアップしました。

GitHub - suti-hub/GUI_Image_Editor: Tkinterを使った静止画と動画の簡易編集ソフト

本コードは、Tkinterの学習用に作成したものです。基本動作は確認してますが、テストが十分できてない所もあります。その点を考慮頂き、興味ある方は参照頂ければと思います。


最後に

gif保存の機能追加のためコード見直しや動作確認しましたが、実際やり始めると機能追加よりも元の不具合や動作が気になり、その対策に時間かかってました…。

昨年の振り返りで触れた下記の記事。

今もPV数が一定数あり、冒頭で触れた中途半端に止めた所が前から気になってましたが、この機会に最低限修正できたので少しスッキリしました。

他にも見直したい所もありますが、更に時間が必要だし際限ないのでこの辺で終りにしたいと思います。

GUI構築の流れを把握したい方。Tkinterを使うことを検討されている方。そして、開発やプログラミングに興味ある方に何か参考になれば幸いです。

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



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

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


#連載記事の一覧



この記事が参加している募集

やってみた

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