【PythonでGUIを作りたい】Tkinterで作る画像編集ソフト#9:動画編集の実装とテスト
こんにちは。すうちです。
Tkinterで作る画像編集ソフトの連載も9回目となりました。今回が最終回です。
前回の記事はこちらです。
ここまでで一通り実装とデバッグまで終えたので、今回は触れてなかった実装部分とその後のテスト結果について書きたいと思います。
◆はじめに
作りたい機能のイメージ
初期検討のメモです。
メモを元に仕様検討、設計、実装を進めてきました。以降、現時点の実装とテスト結果です。
◆仕様
画面構成と操作
ボタン操作
[1]再生/停止、 [2]コマ送り、 [3]画面キャプチャ、 [4]倍速再生、[5]再生位置移動、 [6] フレーム切出先頭/終端、 [7]編集操作、 [8]取消/保存
基本的な操作はファイルを選んでボタンを押すだけのよくある再生ソフトと同じです。
左上タブで以前実装した静止画編集と動画編集の画面を切り替えます。
編集は、上下のキャンバス画面に切り出したいフレームの先頭/終端を表示して行います。最初は上のキャンバスしか表示されませんが、停止状態で下のキャンバスをダブルクリックして再生を押すと下側も画面が表示されます。
切出したいフレーム位置に移動して目的の編集ができたら保存します。最初に選択したフォルダにファイルは保存されるので、再生して確認も可能です。
制限
前回も触れましたが動画フレームを読んでtkinterのキャンバスに表示するまで20~23ms程かかっています(処理時間ばらつきあり)。
そのため再生間隔を制限かけています。再生は実質30fpsがサポート範囲です(計算上は45fps程度まで行けそうですが、あまり聞かないフレームレートなので)。
仮に60fpsの動画を再生した場合は約20ms間隔の表示になります。これに関連して倍速再生も同じ仕様です。
編集自体には影響ないですが、再生は上記制限となります。
◆実装
クラス定義(役割分担)
クラスは、ViewGUI、ControlGUI、ModelImage3つを定義してます。
クラスの関係は以前と変わってません。変更点はControlクラスにGUIの状態管理を集約したのと動画編集機能の実処理をModelクラスに追加したことです。
クラスメソッド
今回の動画編集機能で追加したメソッド(関数)一覧です。
赤字は新規追加、青字は今回の修正部分(名称のみ変更)です。
ステートマシン(状態管理)
静止画編集は、状態を分けて管理するほど複雑でもなかったのでそのまま実装しましたが、動画編集は動作条件の組合せが多いので状態管理を追加しました。
状態管理表
縦が各状態、横が各コマンドを示します。
これをコードに落としたのが以下です。
状態管理テーブル(ControlGUI.py)
def InitStateMachine(self):
# (1/0:有効/無効, 0-5:遷移先)
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),'save|undo':(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),'save|undo':(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),'save|undo':(1,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),'save|undo':(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),'save|undo':(1,2)},
#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),'save|undo':(1,2)},
#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),'save|undo':(1,2)},
]
# (1/0:有効/無効, 0-3:遷移先)
stm_photo = [
#0:IDLE
{'dir':(1,1),'set':(0,0),'prev':(0,0),'next':(0,0),'edit':(0,0),'clip':(0,0),'rect':(0,0),
'done':(0,0),'save|undo':(0,0)},
#1:SET
{'dir':(1,1),'set':(1,1),'prev':(1,1),'next':(1,1),'edit':(1,2),'clip':(1,3),'rect':(0,1),
'done':(0,1),'save|undo':(0,1)},
#2:EDIT
{'dir':(0,2),'set':(1,1),'prev':(1,1),'next':(1,1),'edit':(1,2),'clip':(1,3),'rect':(0,2),
'done':(0,2),'save|undo':(1,1)},
#3:EDIT_CLIP
{'dir':(0,2),'set':(1,1),'prev':(1,1),'next':(1,1),'edit':(1,3),'clip':(1,3),'rect':(1,3),
'done':(1,2),'save|undo':(1,1)},
]
self.state_table = {'[Photo]':stm_photo, '[Video]':stm_video}
self.cur_state = {'[Photo]':0, '[Video]':0}
stm_video, stm_photoリストのインデックス(0~N)が各状態。リスト内の辞書キーは各コマンドを意味しており、それに紐づくタプルはコマンドの実行可否とその条件の状態遷移先です。
例えば、Video再生状態(3)の時にstopコマンドが実行された場合は(1,2)なので実行可能かつ停止状態(2)に変更。editコマンドの場合は(0,3)なので実行不可かつ再生状態(3)のまま変わらず、という意味です。
以下は現在の状態取得と設定のメソッドです。
タブ、ステート、コマンドを引数にして前述の管理テーブルから値を取り出します。
def GetEventState(self, command):
tab = self.select_tab
cur_state = self.cur_state[tab]
is_valid, next_state = self.state_table[tab][cur_state][command]
self.cur_state[tab] = next_state
res = True if is_valid == 1 else False
return res
def SetEventState(self, next_state):
tab = self.select_tab
self.cur_state[tab] = next_state
動画編集(各クラス)の実装
全体のコードは、量が増えてきたので別途公開するつもりです。ここでは動画編集の新しく追加した部分を記載します。
-------
ViewGUI.py
# Video Event
def event_update_bar(self, val):
if self.control.GetEventState('speed|bar'):
self.bar_position.set(int(val))
pos = self.bar_position.get()
command = 'setpos-' + str(pos)
self.control.Video(command)
def event_mouse_select1(self, event):
if self.control.GetEventState('dclick'):
self.select_canvas = 'Video1'
x, y = event.x, event.y
self.control.SetCanvas('set_canvas', self.select_canvas)
def event_mouse_select2(self, event):
if self.control.GetEventState('dclick'):
self.select_canvas = 'Video2'
x, y = event.x, event.y
self.control.SetCanvas('set_canvas', self.select_canvas)
def event_play(self):
if self.control.GetEventState('play'):
self.control.Video('play')
self.button_play['text'] = 'Stop'
elif self.control.GetEventState('stop'):
self.control.Video('stop')
self.button_play['text'] = 'Play'
def event_step(self):
if self.control.GetEventState('step'):
self.control.Video('step')
def event_capture(self):
if self.control.GetEventState('cap'):
self.control.Video('capture')
def event_speed(self):
if self.control.GetEventState('speed|bar'):
self.button_speed['text'] = self.control.UpdateSpeed(self.speed_text)
command = 'speed-' + self.button_speed['text']
self.control.Video(command)
Viewクラスの各イベントの先頭では必ず現在の状態を取得して、その実行可否を判断します。実行不可の場合は処理を抜けてイベントは無視されます。
# Mouse Event
self.window_video_canvas1.bind('<Double-Button-1>', self.event_mouse_select1)
self.window_video_canvas2.bind('<Double-Button-1>', self.event_mouse_select2)
追加したmouseイベントは、動画編集時のキャンバスを切り替える目的です。クリップのイベントと区別するため、ダブルクリックでイベントを取るようにしました。
-------
ControlGUI.py
def Set(self, select_tab, set_pos, callback):
if select_tab == '[Photo]':
fname = self.get_file('set', set_pos)
self.model.DrawPhoto(fname, self.photo_canvas, 'None')
else:
fname = self.get_file('set', set_pos)
self.model.DeleteRectangle(self.canvas['Video1'])
self.model.DeleteRectangle(self.canvas['Video2'])
self.video_tag = 'Video1'
self.video_canvas = self.canvas[self.video_tag]
self.model.SetVideo(fname, self.video_canvas, self.video_tag, 'set', callback)
_, self.frame['Video1'] = self.model.GetVideo('status')
_, self.frame['Video2'] = self.model.GetVideo('status')
def Edit(self, select_tab, 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
if select_tab == '[Photo]':
fname = self.get_file('current')
self.model.DrawPhoto(fname, self.photo_canvas, command, args=args)
else:
self.play_status, self.frame[self.video_tag] = self.model.GetVideo('status')
self.model.EditVideo(self.canvas['Video1'], 'Video1', command, self.frame['Video1'], args=args, update=False)
self.model.EditVideo(self.canvas['Video2'], 'Video2', command, self.frame['Video2'], args=args, update=True)
def Save(self, select_tab):
if select_tab == '[Photo]':
fname = self.get_file('current')
self.model.SavePhoto(fname)
else:
_, self.frame[self.video_tag] = self.model.GetVideo('status')
fname = self.get_file('current')
self.model.SaveVideo(fname, self.frame['Video1'], self.frame['Video2'])
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'])
def Undo(self, select_tab, command):
if select_tab == '[Photo]':
fname = self.get_file('current')
self.model.DrawPhoto(fname, self.photo_canvas, command)
else:
_, self.frame[self.video_tag] = self.model.GetVideo('status')
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'])
前述の状態管理以外の変更は、Viewクラスから呼ぶメソッドを共通化したり、動画編集の実装を追加しています。
-------
ModelImage.py
def draw_video(self, canvas, target_img, canvas_tag):
canvas_w_video = canvas.winfo_width()
canvas_h_video = canvas.winfo_height()
img_conv = cv2.cvtColor(target_img, cv2.COLOR_BGR2RGB)
self.img_conv = Image.fromarray(img_conv)
pil_img = ImageOps.pad(self.img_conv, (canvas_w_video, canvas_h_video))
self.tk_video[canvas_tag] = ImageTk.PhotoImage(image=pil_img)
if canvas.gettags(canvas_tag):
canvas.delete(canvas_tag)
canvas.create_image(canvas_w_video/2, canvas_h_video/2, image=self.tk_video[canvas_tag], tag=canvas_tag)
def loop_video(self, loop=True):
if loop and self.ret:
self.vid = self.canvas_video.after(self.interval, self.loop_video)
self.ret, self.video_img = self.cap.read()
if self.ret:
self.draw_video(self.canvas_video, self.video_img, self.canvas_tag)
self.play_status = True
self.cur_frame += 1
h,m,s = self.get_cur_time(self.cur_frame/self.fps)
self.callback(self.play_status, self.cur_frame, h,m,s)
else:
self.cur_frame = 0
self.play_status = False
self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.cur_frame)
h,m,s = self.get_cur_time(self.cur_frame/self.fps)
self.callback(self.play_status, self.cur_frame, h,m,s)
return self.ret
def set_interval(self, speed):
with self.lock:
self.interval = int(self.base_tick*speed)
if self.interval < self.time_limit:
self.interval = self.time_limit
def save_capture(self):
file_path = '{}/{:05}.png'.format(self.output_path, self.cur_frame)
self.img_conv.save(file_path)
def edit_video(self, frame, command_list, args):
edit_np_video = np.array(frame).copy()
for cmd in command_list:
edit_np_video = self.edit_image_proc(edit_np_video, cmd, args=args)
return edit_np_video
def SetVideo(self, fname, canvas, canvas_tag, command, callback):
if command == 'set':
if self.cap != None:
self.cap.release()
self.cap = cv2.VideoCapture(fname)
self.frame_num = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
self.fps = int(self.cap.get(cv2.CAP_PROP_FPS))
self.base_tick = int(1000/self.fps)
self.cur_frame = 0
self.speed = 1.0
self.set_interval(self.speed)
self.canvas_video = canvas
self.canvas_tag = canvas_tag
self.tk_video = {}
self.edit_frames = {}
self.edit_canvas = {}
self.video_edit_imgs = {'Video1':None, 'Video2':None}
self.clear_command_list()
self.ret, self.video_img = self.cap.read()
if self.ret:
self.set_image_layout(self.canvas_video, Image.fromarray(self.video_img))
self.draw_video(self.canvas_video, self.video_img, self.canvas_tag)
self.cur_frame += 1
self.callback = callback
self.edit_h = self.h
self.edit_w = self.w
def GetVideo(self, command):
if command == 'status':
info1, info2 = self.play_status, self.cur_frame
elif command == 'property':
info1, info2 = self.frame_num, self.fps
return info1, info2
def Video(self, canvas, canvas_tag, command, args=None):
self.canvas_video = canvas
self.canvas_tag = canvas_tag
if command == 'play':
self.loop_video()
elif command == 'step':
self.loop_video(loop=False)
elif command == 'stop':
self.canvas_video.after_cancel(self.vid)
self.play_status = False
elif 'setpos' in command:
num = command.replace('setpos-','')
with self.lock:
self.cur_frame = int(self.frame_num*(int(num)/100))
self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.cur_frame)
elif 'speed' in command:
val = command.replace('speed-x','')
self.speed = 1/float(val)
self.set_interval(self.speed)
elif command == 'capture':
self.save_capture()
else:
print('Video/None')
def EditVideo(self, canvas, canvas_tag, command, edit_frame, args=None, update=False):
self.cap.set(cv2.CAP_PROP_POS_FRAMES, edit_frame)
ret, frame = self.cap.read()
if ret:
if command != 'Undo':
if self.video_edit_imgs[canvas_tag] != None:
edit_img = np.array(self.video_edit_imgs[canvas_tag])
else:
edit_img = frame
edit_img = self.edit_video(edit_img, [command], args)
self.draw_video(canvas, edit_img, canvas_tag)
self.video_edit_imgs[canvas_tag] = Image.fromarray(edit_img)
if update:
pil_img = self.video_edit_imgs[canvas_tag]
self.set_image_layout(canvas, pil_img)
self.edit_h = pil_img.height
self.edit_w = pil_img.width
self.temp_command_list.append(command)
self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.cur_frame)
else: # Undo
self.draw_video(canvas, frame, canvas_tag)
self.set_image_layout(canvas, Image.fromarray(frame))
self.video_edit_imgs[canvas_tag] = None
self.temp_command_list = []
今回メインとなる動画再生はdraw_videoとloop_videoです。その他動画再生時のコマンドはVideoメソッドで処理します。
回転、反転、クリップの編集は静止画と共通にしたので、動画処理に依存する部分のみ追加しています。
◆テスト結果(動作テスト)
今回も実装とテストを少しずつ繰り返しながら問題を修正していきました。以下の環境で確認しています。
フォルダ設定→ファイル選択
Videoタブではmp4ファイルのリストが表示されます。ファイルを選択すると動画の最初のフレームが表示されます。
再生位置移動→Canvas切替→Capture
スライドバーで再生位置を移動した後、下のキャンバスをダブルクリックして表示を切替えています。この場合、上の表示が切り出す先頭のフレームです。再生停止後、画面キャプチャもしています。
編集画像(クリップ)→キャプチャ
下記は編集画像をキャプチャしています。
キャプチャ画像は最初に選択したフォルダ/output(自動生成)下に保存します。
Tab切替→Capture画像表示
静止画タブに切替えoutputフォルダに移動後、先ほどキャプチャした画像を表示しています。
編集フレーム切り出し
切り出し範囲まで、再生位置を移動します。
編集(回転/クリップ)→保存
切り出す範囲を編集して動画を保存します。
編集動画再生
編集した動画を再生しています。
後半の動画は、先日旅行で立ち寄った東武ワールドスクウェアです。観光地をドローンで撮影している感を出したかったのですが、動画でみると意外にそれっぽく見えていいですね。旅行に行った気分に浸れる気がします。
最後に
本連載は今回でおしまいです。
長い間連載に付き合って下さった皆様、ありがとうございました。
もう少し作りこみや見直したい部分もありますが、最近急に忙しくなり余り時間も取れないのて、ここで区切りにしたいと思います。
少しだけ連載を振り返ると、週末はもちろん平日も仕事が終わってほぼ毎日実装を考えたり、コーディングしてました。
それ自体は好きなので全然苦にならないですが、まとまった時間がない中でテストやデバッグをやりつつ、noteに投稿するとなると後半はなかなかハードで休日もなんだか休んだ気がしない時もありました。
note でもプログラムに関する記事を頻繁に投稿されている方もいらっしゃいますが、改めてその大変さを実感した次第です。
また後半はtkinterよりは動画機能の実装やその問題に関する話が多かった気がします。
そこは当初意図してなかったですが、実際開発でも多かれ少なかれこういう事は起こるので結果的にリアルな話が書けて起承転結になったかもと思っています(もちろん最初から最後まで全て予定通り上手くいった話はそれはそれで素晴らしいですが…)。
とはいえ、当初作りたいとイメージしてた事はtkinterで実現できました。なんとか着地できて良かったです。
また機会があれば別の形で(今度は行き当たりばったりでなく、もう少し事前に企画を練って)こういう連載もやってみたいと思います。
GUI構築の流れを大まかに把握したい方、Tkinterを使うことを検討されている方、そして仕様検討から実装まで開発やプログラミングに興味ある方に何か参考になれば幸いです。
最後まで読んで頂き、ありがとうございました。
ーーー
ソフトウェアを作る過程を体系的に学びたい方向けの本を書きました(連載記事に大幅に加筆・修正)
Kindle Unlimited 会員の方は無料で読めます。
#連載記事の一覧
ーーー
この記事が参加している募集
この記事が気に入ったらサポートをしてみませんか?