見出し画像

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

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

Tkinterで作る画像編集ソフトの連載も9回目となりました。今回が最終回です。

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

ここまでで一通り実装とデバッグまで終えたので、今回は触れてなかった実装部分とその後のテスト結果について書きたいと思います。



◆はじめに

作りたい機能のイメージ

初期検討のメモです。

・動画再生/停止
・フレーム(特定位置)の切り出し
・フレームのキャプチャ(画像保存)
・編集機能(回転/反転/クリップ)
・編集した動画の保存

メモを元に仕様検討、設計、実装を進めてきました。以降、現時点の実装とテスト結果です。


◆仕様

画面構成と操作

動画編集の画面構成

ボタン操作
[1]再生/停止、 [2]コマ送り、 [3]画面キャプチャ、 [4]倍速再生、[5]再生位置移動、 [6] フレーム切出先頭/終端、 [7]編集操作、 [8]取消/保存

基本的な操作はファイルを選んでボタンを押すだけのよくある再生ソフトと同じです。

左上タブで以前実装した静止画編集と動画編集の画面を切り替えます。

編集は、上下のキャンバス画面に切り出したいフレームの先頭/終端を表示して行います。最初は上のキャンバスしか表示されませんが、停止状態で下のキャンバスをダブルクリックして再生を押すと下側も画面が表示されます。

切出したいフレーム位置に移動して目的の編集ができたら保存します。最初に選択したフォルダにファイルは保存されるので、再生して確認も可能です。

制限

前回も触れましたが動画フレームを読んでtkinterのキャンバスに表示するまで20~23ms程かかっています(処理時間ばらつきあり)。

そのため再生間隔を制限かけています。再生は実質30fpsがサポート範囲です(計算上は45fps程度まで行けそうですが、あまり聞かないフレームレートなので)。

仮に60fpsの動画を再生した場合は約20ms間隔の表示になります。これに関連して倍速再生も同じ仕様です。

編集自体には影響ないですが、再生は上記制限となります。


◆実装

クラス定義(役割分担)

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

クラス定義
クラス関係

クラスの関係は以前と変わってません。変更点はControlクラスにGUIの状態管理を集約したのと動画編集機能の実処理をModelクラスに追加したことです。


クラスメソッド

今回の動画編集機能で追加したメソッド(関数)一覧です。

赤字は新規追加、青字は今回の修正部分(名称のみ変更)です。

クラスメソッド一覧


ステートマシン(状態管理)

静止画編集は、状態を分けて管理するほど複雑でもなかったのでそのまま実装しましたが、動画編集は動作条件の組合せが多いので状態管理を追加しました。

状態管理表
縦が各状態、横が各コマンドを示します。

VideoとPhotoの状態管理表

これをコードに落としたのが以下です。

状態管理テーブル(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_videoloop_videoです。その他動画再生時のコマンドはVideoメソッドで処理します。

回転、反転、クリップの編集は静止画と共通にしたので、動画処理に依存する部分のみ追加しています。


◆テスト結果(動作テスト)

今回も実装とテストを少しずつ繰り返しながら問題を修正していきました。以下の環境で確認しています。

OS:Windows10
プログラム言語:Python3.8.5
IDE環境(実行・デバッグ):Spyder4.1.5


フォルダ設定→ファイル選択

Videoタブではmp4ファイルのリストが表示されます。ファイルを選択すると動画の最初のフレームが表示されます。

フォルダと動画ファイルの選択


再生位置移動→Canvas切替→Capture

スライドバーで再生位置を移動した後、下のキャンバスをダブルクリックして表示を切替えています。この場合、上の表示が切り出す先頭のフレームです。再生停止後、画面キャプチャもしています。

再生位置移動とキャンバス切替


編集画像(クリップ)→キャプチャ

下記は編集画像をキャプチャしています。
キャプチャ画像は最初に選択したフォルダ/output(自動生成)下に保存します。

編集とキャプチャ


Tab切替→Capture画像表示

静止画タブに切替えoutputフォルダに移動後、先ほどキャプチャした画像を表示しています。

キャプチャ画像の表示


編集フレーム切り出し

切り出し範囲まで、再生位置を移動します。

フレーム切り出し(再生位置移動)


編集(回転/クリップ)→保存

切り出す範囲を編集して動画を保存します。

編集動画の保存


編集動画再生

編集した動画を再生しています。

編集動画ファイルの再生


後半の動画は、先日旅行で立ち寄った東武ワールドスクウェアです。観光地をドローンで撮影している感を出したかったのですが、動画でみると意外にそれっぽく見えていいですね。旅行に行った気分に浸れる気がします。


最後に

本連載は今回でおしまいです。
長い間連載に付き合って下さった皆様、ありがとうございました。

もう少し作りこみや見直したい部分もありますが、最近急に忙しくなり余り時間も取れないのて、ここで区切りにしたいと思います。

少しだけ連載を振り返ると、週末はもちろん平日も仕事が終わってほぼ毎日実装を考えたり、コーディングしてました。

それ自体は好きなので全然苦にならないですが、まとまった時間がない中でテストやデバッグをやりつつ、noteに投稿するとなると後半はなかなかハードで休日もなんだか休んだ気がしない時もありました。

note でもプログラムに関する記事を頻繁に投稿されている方もいらっしゃいますが、改めてその大変さを実感した次第です。

また後半はtkinterよりは動画機能の実装やその問題に関する話が多かった気がします。

そこは当初意図してなかったですが、実際開発でも多かれ少なかれこういう事は起こるので結果的にリアルな話が書けて起承転結になったかもと思っています(もちろん最初から最後まで全て予定通り上手くいった話はそれはそれで素晴らしいですが…)。

とはいえ、当初作りたいとイメージしてた事はtkinterで実現できました。なんとか着地できて良かったです。

また機会があれば別の形で(今度は行き当たりばったりでなく、もう少し事前に企画を練って)こういう連載もやってみたいと思います。

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

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


ーーー

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

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

#連載記事の一覧

ーーー


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

やってみた

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