【PythonでGUIを作りたい】Tkinterで作る画像編集ソフト#12:gif保存の機能追加とリファクタリング(後編)
こんにちは。すうちです。
昨年『Tkinterで作る画像編集ソフト』というお題でnoteに連載してました。
先週この画像編集ソフトに機能追加とリファクタリング(gif保存機能の追加とコード整理)の概要を書きましたが、今回はその続きで実装の詳細とテストの話です。
前回の記事
はじめに
作りたい機能のイメージ
前回のおさらいでやりたいことメモです。
上記に関連して、以下も見直します。
このメモを元に、仕様検討と実装を進めました。
◆機能追加の実装詳細
以降、主な修正点です。
ウィンドウ(Tkinterフレーム)表示切替
タブやgifの選択状態を分岐にしてplace()とplace_forget()を使ってウィンドウの表示/非表示を切り替えてます。
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()
動画保存のスレッド化(並列処理)
修正前の動画保存
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))
上記は保存範囲を、opencvのVideoWriterでフレームの書き込みを繰り返しています。
修正後の動画保存
並列処理の実装(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()を定期的に実行)。またmp4とgif保存の分岐は、prepare_save_video()、
save_video_frame()、complete_save_video()に纏めて
save_video_thread()からは直接意識しない様にしました。
gifリサイズと間引きの実装
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()
(省略)
リファクタリング(その他)
他に冗長な処理や変数も削除しましたが、長くなるので割愛します。以下、概要です。
◆動作確認とテスト
gif保存結果
以下のファイルをgifに保存しました。
gif保存の結果(ファイル容量)
ファイル容量は、調整なし条件と比較すると、画像サイズ、間引きフレームから試算した結果に近い値になっています(ちなみにgifの中身は、都合でMacで確認しましたが動作テストはWindows上でやりました)。
上記の画像からは分かりにくいですが、画像サイズを削るほど見栄えは悪くなってます。また合計フレーム数も間引きした1/4、1/8の割合でした。
◆今回の実装(コード一式)
全体のコードは、量が多いのでGitHubにアップしました。
GitHub - suti-hub/GUI_Image_Editor: Tkinterを使った静止画と動画の簡易編集ソフト
本コードは、Tkinterの学習用に作成したものです。基本動作は確認してますが、テストが十分できてない所もあります。その点を考慮頂き、興味ある方は参照頂ければと思います。
最後に
gif保存の機能追加のためコード見直しや動作確認しましたが、実際やり始めると機能追加よりも元の不具合や動作が気になり、その対策に時間かかってました…。
昨年の振り返りで触れた下記の記事。
今もPV数が一定数あり、冒頭で触れた中途半端に止めた所が前から気になってましたが、この機会に最低限修正できたので少しスッキリしました。
他にも見直したい所もありますが、更に時間が必要だし際限ないのでこの辺で終りにしたいと思います。
GUI構築の流れを把握したい方。Tkinterを使うことを検討されている方。そして、開発やプログラミングに興味ある方に何か参考になれば幸いです。
最後まで読んで頂き、ありがとうございました。
ソフトウェアを作る過程を体系的に学びたい方向けの本を書きました(連載記事に大幅に加筆・修正)
Kindle Unlimited 会員の方は無料で読めます。