見出し画像

【PythonでGUIを作りたい】Tkinterで作る画像編集ソフト#8:動画編集のデバッグ

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

今年は早くから真夏日かと思えば、梅雨がぶり返したような天気が続いたり、快晴から急にゲリラ豪雨になったり変化が激しいですが、皆さん如何お過ごしでしょうか。

私は先週末から一足早い夏休みが取れたので、今まで時間なくてできなかった事を済ませたり、GW以来の小旅行に出掛けながら隙間時間に先週の続きのデバッグや足らない実装を進めていました(これもひとつのワーケーション!?でしょうか。。。)

本題です。今回でTkinterで作る画像編集ソフトの連載も8回目となります。

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

ここまでで一通り実装は終わりましたが不具合が残った状況でした。今週は時間取れて問題の原因もわかり、その対策と簡単なテストは終えた所です。

そんな訳で前回から更新遅くなりましたが、今回は不具合解析とデバッグの話を中心に書きたいと思います。



◆はじめに

作りたい機能のイメージ

初期検討のメモです。

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

メモを元にこれまで設計と実装を進めてきました。以降、前回残っていた不具合の解析と対策です。


◆主な不具合(バグ)と対策

期待のフレームレートで表示できない

例えば、フレームレート24fpsの場合、1秒/24回=42ms周期で動画表示が必要ですが、前回はこれより長い間隔だったため再生が遅い状況でした。

根本原因

以下、動画表示の実装部分です。

def loop_video(self, loop=True):
        
        ret, self.video_img = self.cap.read()
        if ret:
            self.draw_video(self.canvas_video, self.video_img)
            self.play_status = True
            self.cur_frame += 1
            #コールバック実行(表示更新処理)
            self.callback(self.play_status, self.cur_frame)
            if loop:
                self.vid = self.canvas_video.after(self.interval, self.loop_video)

先頭からフレーム読み込み、フレーム画像表示、状態更新、コールバック実行の後に次のフレーム処理を設定(loop_video起動)しています。この実装は処理順序が不適切でした。

青:フレーム画像更新周期
橙:1フレームの処理時間

上記はフレーム更新周期と1フレーム毎の処理時間の計測結果です。縦軸は処理時間、横軸はフレーム番号(時間変化)です。1フレームの処理時間が長いほどフレーム更新周期も大きく変動しています。

つまり、上記が更新周期に影響してました。

1フレーム更新周期 = 1フレーム処理時間(変動あり)+ 42ms(表示更新間隔@24fps)

対策方針(afterメソッド実行順番変更)

まず単純に、次のフレーム処理(loop_video)の設定を先頭に変更します。

def loop_video(self, loop=True):
 
        if loop:
             self.vid = self.canvas_video.after(self.interval, self.loop_video)
                
        ret, self.video_img = self.cap.read()
        if ret:
            self.draw_video(self.canvas_video, self.video_img)
            self.play_status = True
            self.cur_frame += 1
            #コールバック実行(表示更新処理)
            self.callback(self.play_status, self.cur_frame)

これにより1フレームの処理と並行して次のタイミング調整が走るので、期待の周期で更新されるはずです。以下、計測結果です。

青:フレーム画像更新周期
橙:1フレームの処理時間

フレーム処理時間のばらつきで、たまに表示周期が延びる時はありますが、以前と比べるとだいぶ良くなりました(平均46ms程度)。

実際はこの実装も期待値より更新間隔は長いですが、更に周期を少し小さめに補正すると期待のフレームレートで表示できるようになりました。

厳密には誤差ありますが、今回の動画編集の目的には影響ない範囲なのでこれで進めることにします。

ちなみに時間計測は、time.perf_counter()を使用しました。

import time

t1 = time.perf_counter()
## 測定対象の処理 ##
t2 = time.perf_counter()
 :
print(t2-t1)


動画再生のスレッド化(ThreadPool)検証

今回動画再生のスレッド実装も試しました。私の環境で測定した範囲ではafterの実装と同等かそれより悪い結果となりました。

スレッド実装

from concurrent.futures import ThreadPoolExecutor

self.th_pool = ThreadPoolExecutor(max_workers=1)
self.th_video = self.th_pool.submit(self.loop_video_thread)
def loop_video_thread(self):
        
        self.playing = True
        while self.playing:
            self.playing = self.is_playing()
def is_playing(self):
        
        ret, self.video_img = self.cap.read()
        if ret:
            self.draw_video(self.canvas_video, self.video_img)
            self.cur_frame += 1
            h,m,s = self.get_cur_time(self.cur_frame/self.fps)
            self.callback(self.play_status, h,m,s)
            sleep_t = self.interval/1000
            time.sleep(sleep_t)
        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, h,m,s)
        
    return self.play_status

スレッド処理の計測結果

表示更新周期:平均56.7ms
# コード
thread_list = threading.enumerate()
print(thread_list)

# ログ
[<_MainThread(MainThread, started 8432)>, <Thread(Thread-4, started daemon 18304)>,
 <Heartbeat(Thread-5, started daemon 19232)>,
 <HistorySavingThread(IPythonHistorySavingThread, started 7996)>,
 <Thread(Thread-6, started 12084)>, <ParentPollerWindows(Thread-3, started daemon 6860)>,
 <GarbageCollectorThread(Thread-7, started daemon 4980)>]

ちなみに上記は動画再生前に動作中のスレッドを表示したものです(Spyderは起動した状態です)。かなり多くの別スレッドが動いていることがわかります。

ここからは推測ですが動画再生以外のスレッド動作が影響して表示周期のばらつきが起きているかもしれません。

pythonはスレッドの優先度は設定できないようなので、現時点ではこれ以上の調整は難しそうです。


クリップ時の編集動画の保存

opencvVideoWriterは事前に動画サイズ(幅、高さ)やフレームレートを設定必要ですが、入力フレームのサイズが設定と一致しない場合、ファイル保存できません。

前回は編集にクリップが入ると保存できない事がありました。以下原因と対策です。

クリップ時アスペクト比の考慮漏れ

クリップは切取る範囲によってアスペクト比(幅・高さの比)が変わります。これまでクリップ範囲の計算は幅・高さの大小関係で判定してました。

ImageOpsは指定サイズ(アスペクト比)がCanvas範囲に収まらない場合、幅または高さが大きい方を基準に上下左右のPadを埋める仕様のようです。前回はここが原因でクリップ範囲の計算が間違っていました。

今はリサイズする幅・高さの比からPadの無効領域とクリップ範囲を計算する様にしました。

def get_original_coords(self, h, w, args):
    
    sy, sx, ey, ex = args['sy'], args['sx'], args['ey'], args['ex']
    rate_wh = w / h
    # if h > w: # 以前のコード
    if rate_wh < self.rate_wh:
        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 get_cmdpack(self, cmd_dict, cmd):
    
    flip_UB_keys = ['None''flip-1']
    flip_LR_keys = ['None''flip-2']
    ROT_keys = ['None''rotate-1''rotate-2''rotate-3']
    
    cmd_pack = cmd
    for ks, val in cmd_dict.items():
        if ks == 'rotate':
            idx = val % len(ROT_keys)
            cmd_pack = ROT_keys[idx]
        elif ks == 'flip-1':
            idx = val % len(flip_UB_keys)
            cmd_pack = flip_UB_keys[idx]
        elif ks == 'flip-2':
            idx = val % len(flip_LR_keys) 
            cmd_pack = flip_LR_keys[idx]
        elif ks == 'clip':
            cmd_pack = 'clip_save'
        
    return cmd_pack

    
def create_command_list(self):

    cont_dict = {}
    edit_command_list = []
    self.temp_command_list.append('None')
    for idx, cmd in enumerate(self.temp_command_list):
        
        if idx == 0:
            ks_cmd, val = self.get_cmd_keyval(cmd)
            cont_dict[ks_cmd] = val
            last_cmd = cmd
            
        else:         
            if self.is_equel(cmd, last_cmd):
                 ks_cmd, val = self.get_cmd_keyval(cmd)
                 cont_dict[ks_cmd] += val
                 last_cmd = cmd
            else:
                pack_cmd = self.get_cmdpack(cont_dict, cmd)
                edit_command_list.append(pack_cmd)
                cont_dict = {}
                ks_cmd, val = self.get_cmd_keyval(cmd)
                cont_dict[ks_cmd] = val
                last_cmd = cmd
   
    self.edit_command_list = [cmd for cmd in edit_command_list if cmd != 'None']

    return self.edit_command_list, self.edit_args

編集コマンドログ

# 全実行コマンド
['rotate-1''rotate-1''flip-1''flip-2''None']

# 動画保存時のコマンド
['rotate-2''flip-1''flip-2']

これらの修正により、クリップ含めた動画編集後もファイルに保存できるようになりました。

◆今後の予定

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

だいぶ順番前後しましたが、今回で実装とデバッグはほぼ終わったので、次回は残りのテスト結果をまとめて本連載は終わる予定です。


最後に

本日はここまでです。

夏休み期間でサボってた訳ではないですが、noteに投稿するまとまった時間が取れず更新の間があいてしまいました。デバッグを経て動画編集もなんとか終われそうで少し安堵しています。

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

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


ーーー

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

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


#連載記事の一覧

ーーー


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