ffmpegでカット(シーンの切り替わり)を検出する方法は、検索すればすぐ見つかります。select='gt(scene,0.4)',showinfo を付けて実行し、ログから時刻を拾うだけです。ただ、実際に編集済みの動画をカットごとのファイルに分けようとすると、その先で2回ハマります。しきい値をいくつにすればカメラの揺れを拾わずに済むのか。そして、拾った時刻で分割したはずのファイルが、なぜカットの位置から始まらないのか。
デスクトップアプリ osFoundry の動画エディターは、前半の検出をRustで実装しています(canvas_operations.rs の canvas_scene_split)。この記事では、検出と分割の両方をPythonとffmpegで組み直し、1本の動画で数字を測りながら、どこで何を決めればいいかを確かめます。
使う動画と環境
NASAが公開しているArtemis II試験飛行の打ち上げまとめ動画(パブリックドメイン)を使います。92秒、H.264、640×360、30fpsで、全2,760コマ。19ショットをつないだ編集済みの動画です。計測はApple M1、ffmpeg 7.1、Python 3.9で行いました。
まずはカットを検出する
コマンド自体はよく見るものと同じです。落とし穴は出力先で、showinfo の情報はffmpegのログ、つまり標準エラー出力に出ます。標準出力を読むと、検出は成功しているのに結果が空になります。
import re, subprocess
def detect(video, threshold):
vf = f"select='gt(scene,{threshold})',showinfo"
p = subprocess.run(["ffmpeg", "-hide_banner", "-i", video, "-an",
"-filter_complex", vf, "-f", "null", "-"],
capture_output=True, text=True)
return [round(float(t) * 1000)
for t in re.findall(r"pts_time:([0-9.]+)", p.stderr)]
アプリの初期値と同じ0.27で実行すると、3,133ミリ秒から73,033ミリ秒まで、18か所のカットが返ってきます。所要時間は0.80秒(5回の中央値)なので、しきい値を変えて何度やり直しても気になりません。
しきい値は、全コマのスコアを見てから決める
よく紹介されている0.3〜0.4という目安を、そのまま信じる必要はありません。全コマのスコアを一度書き出せば、その動画に合う範囲が見えます。
ffmpeg -i launch.mp4 -an -vf "select='gte(scene,0)',metadata=print:file=-" -f null - \
| grep -o 'scene_score=[0-9.]*'
この動画のスコアの中央値は0.0015です。0.5を超えるコマが17個、その下に0.337が1個あり、次に高いのは0.161でした。つまり、しきい値を0.162〜0.337のどこに置いても、同じ18か所が見つかります。しきい値を変えたときの検出数と、500ミリ秒より近い検出をまとめたあとのカット数は次のとおりです。
| しきい値 | 超えたコマ数 | 500msでまとめた後のカット数 |
|---|---|---|
| 0.08 | 27 | 20 |
| 0.10 | 24 | 20 |
| 0.12 | 20 | 19 |
| 0.15 | 19 | 19 |
| 0.20 | 18 | 18 |
| 0.27 | 18 | 18 |
| 0.35 | 17 | 17 |
| 0.40 | 17 | 17 |
| 0.50 | 17 | 17 |
0.35以上では、スコア0.337のカットを取りこぼします。発射台から、淡い空を昇るロケットの引きの画に切り替わるところです。逆に0.15以下では、エンジン点火の揺れがカットとして混ざり始めます。動きの激しい素材ほど安全な範囲は狭くなるので、ほかの人の動画で決まった数字を使う前に、一度分布を見ておくのがおすすめです。
sceneスコアの中身
0.337のカットと0.161の揺れは、見た目の印象とは逆の点数に見えます。理由は計算式にあります。libavfilter/f_select.c の get_scene_score は、前のコマとの差の平均(mafd)を出したうえで、1つ前のコマのmafdとの差を取ります。
score = min(mafd, |mafd - prev_mafd|) / 100
YUVの入力では、比べるのは輝度(Y)の面だけです。Pythonでも数行で再現できます。
def luma(video, n):
p = subprocess.run(["ffmpeg", "-v", "error", "-i", video,
"-vf", f"select='eq(n,{n})'", "-frames:v", "1",
"-f", "rawvideo", "-pix_fmt", "yuv420p", "-"],
capture_output=True)
return p.stdout[:640 * 360] # Y面だけ
def mafd(a, b):
return sum(abs(x - y) for x, y in zip(a, b)) / len(a)
f0, f1, f2 = (luma(VIDEO, k) for k in (n - 2, n - 1, n))
prev, cur = mafd(f0, f1), mafd(f1, f2)
score = min(cur, abs(cur - prev)) / 100
ここで -pix_fmt gray を使うと、変換で値の範囲が引き伸ばされて、スコアが約16%高く出ました。yuv420p の先頭のY面をそのまま使うと、ffmpegのスコアと一致します。
| コマ | 何が起きているか | mafd | 1つ前のmafd | スコア |
|---|---|---|---|---|
| 33.300秒 | 観客の画へのハードカット | 57.12 | 1.31 | 0.558 |
| 33.333秒 | そのカットの次のコマ | 13.75 | 57.12 | 0.138 |
| 58.567秒 | エンジン点火で画面が揺れる | 27.03 | 10.88 | 0.161 |
| 63.667秒 | 発射台から空の引きの画へのカット | 34.64 | 0.90 | 0.337 |
点火のコマは、前のコマと平均で27も違います。ところが、その前のコマもすでに10.9変化していたので、効くのは増えた分だけで、スコアは0.16に収まります。発射台から空へのカットは差としては大きくありませんが、ほぼ静止したコマの直後なので0.34になります。sceneスコアが見ているのは「変化の量」ではなく「変化の急な増え方」です。
最短シーンで重複をまとめる
表の0.08〜0.12の行は、近すぎる検出を捨てる処理が必要な理由です(アプリでは parse_scene_times、初期値500ミリ秒)。0.12では、33.300秒のカットの次のコマ(33.333秒)も反応します。直前の変化が大きすぎたせいで、次のコマの13.75の差がそのまま残るからです。0.08では、点火の57.7〜59.0秒のあいだに8回反応しました。
def min_gap(hits, gap_ms=500):
out = []
for ms in hits:
if not out or ms - out[-1] >= gap_ms:
out.append(ms)
return out
これで消えるのは重複だけです。0.08のままだと、まとめたあとも点火のところに偽のカットが2つ残ります。しきい値の代わりにはなりません。
-c copyで分割すると、カットの位置からずれる
時刻が取れたら、セグメントmuxerにストリームコピーで渡したくなります。
ffmpeg -i launch.mp4 -c copy -f segment -segment_times 3.133,6.333,... \
-reset_timestamps 1 scene%02d.mp4
0.05秒で終わりますが、できたファイルは19個ではなく18個で、長さはどれも3.3〜6.0秒でした。実際のシーンの長さは1.5〜19.0秒なので、まったく合っていません。ストリームコピーはキーフレームからしかファイルを始められず、この動画のキーフレームは18個、間隔は最大6秒です。キーフレームの位置はffprobeで確認できます。
ffprobe -v error -select_streams v -skip_frame nokey \
-show_entries frame=pts_time -of csv=p=0 launch.mp4
18か所のカットのうち、キーフレームと重なっていたのは5か所だけでした。残りはすべて次のキーフレームまで後ろにずれ、いちばん大きいところで約4秒。次のキーフレームが同じ2つのカットは、1つの境界にまとまってしまいます。
再エンコードしても、ミリ秒で丸めた時刻だと1コマずれる
カットの位置にキーフレームを強制的に打って再エンコードすれば、境界はどこにでも置けます。
ffmpeg -i launch.mp4 -c:v libx264 -crf 18 -preset fast -c:a aac \
-force_key_frames "$T" -f segment -segment_times "$T" \
-reset_timestamps 1 scene%02d.mp4
ところが、$T に検出した時刻をミリ秒で丸めたまま渡すと、19ファイルはできたものの、18か所のうち4か所が1コマ遅れました。1か所は分割されずに2シーンが12.3秒の1ファイルになり、1コマだけのファイルも1つできています。原因は丸めです。269コマ目のカットは8.9666…秒ですが、「8.967」はその少し後なので、比較で次のコマが選ばれます。遅れた4か所はどれも末尾が6の循環小数になる時刻で、1コマだけのファイルができた44.067秒も同じでした。
対策は、すべての時刻を半コマ(30fpsなら1/60秒)前にずらすことです。
T = ",".join(f"{ms / 1000 - 0.5 / 30:.4f}" for ms in cuts)
これで18か所すべてがカットのコマちょうどで分かれ、19ファイルの長さもシーンの長さとコマ単位で一致しました。92秒の動画の再エンコードにかかったのは6.9秒、CRF 18で合計15.2MB(元の動画は18.2MB)です。
そもそもファイルに分けない
アプリが取っているのは3つ目のやり方です。「分割位置」ボタンは canvas_layers.rs の canvas_split_clip を呼び、選択中のクリップを短くして、同じ素材を後ろの位置から読む新しいクリップを足すだけです。デコードも書き込みもしないので、キーフレームの問題も画質の劣化も起きず、エンコードは最後に書き出すときの1回で済みます。19個のファイルを誰かに渡すのではなく、動画を編集し直すのが目的なら、カット位置はイン点・アウト点として持っておき、コマ単位の正確さは最後のレンダリングに任せるのがいちばん素直です。
ここで使った計測のスクリプトは、ffmpegとffprobeを呼ぶ標準ライブラリだけのPythonで、120行ほどです。osFoundryのデスクトップアプリでは、同じ検出が動画エディターの「Find the cuts」プリセットとして入っていて、ローカルファーストの考え方どおり自分のパソコンの中で動きます。アプリはosFoundryのダウンロードページから無料で入手できます。
