【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フレーム処理時間(変動あり)+ 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フレームの処理と並行して次のタイミング調整が走るので、期待の周期で更新されるはずです。以下、計測結果です。
フレーム処理時間のばらつきで、たまに表示周期が延びる時はありますが、以前と比べるとだいぶ良くなりました(平均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
スレッド処理の計測結果
# コード
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はスレッドの優先度は設定できないようなので、現時点ではこれ以上の調整は難しそうです。
クリップ時の編集動画の保存
opencvのVideoWriterは事前に動画サイズ(幅、高さ)やフレームレートを設定必要ですが、入力フレームのサイズが設定と一致しない場合、ファイル保存できません。
前回は編集にクリップが入ると保存できない事がありました。以下原因と対策です。
クリップ時アスペクト比の考慮漏れ
クリップは切取る範囲によってアスペクト比(幅・高さの比)が変わります。これまでクリップ範囲の計算は幅・高さの大小関係で判定してました。
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']
これらの修正により、クリップ含めた動画編集後もファイルに保存できるようになりました。
◆今後の予定
だいぶ順番前後しましたが、今回で実装とデバッグはほぼ終わったので、次回は残りのテスト結果をまとめて本連載は終わる予定です。
最後に
本日はここまでです。
夏休み期間でサボってた訳ではないですが、noteに投稿するまとまった時間が取れず更新の間があいてしまいました。デバッグを経て動画編集もなんとか終われそうで少し安堵しています。
GUI構築の流れを大まかに把握したい方、Tkinterを使うことを検討されている方に、何か参考になれば幸いです。
最後まで読んで頂き、ありがとうございました。
ーーー
ソフトウェアを作る過程を体系的に学びたい方向けの本を書きました(連載記事に大幅に加筆・修正)
Kindle Unlimited 会員の方は無料で読めます。
#連載記事の一覧
ーーー