AIで生成・加工した人物動画を確認していると、再生と一時停止を何度も繰り返すことがあります。口元の輪郭が一瞬だけ崩れたのか、表情の切り替わりが不自然なのか。動画プレーヤーだけでは、前後のフレームを並べて確認しづらいからです。
この記事では、PythonとOpenCVを使い、動画から一定間隔でフレームを取り出して1枚のコンタクトシートにまとめます。目的は、気になる区間を見つけて元動画へ戻りやすくすることです。音声と口の動きのずれを自動計測したり、リップシンク品質を採点したりするプログラムではありません。
できることと、対象外のこと
このスクリプトで行う処理は次のとおりです。
- 指定した間隔に近いフレームを動画から抽出する
- 抽出したフレームを最大4列の一覧画像にする
- 各タイルにフレーム番号と概算時刻を表示する
- 保存先の親ディレクトリがなければ作成する
一方、音声解析、発話区間の検出、音画同期の自動測定には対応していません。コンタクトシートは視覚的な確認を補助する画像です。最終判断には、音声付きの元動画を通常速度で再生する作業が必要です。
再検証した環境
今回、以下の環境でコードを実行しました。
| 項目 | 再検証環境 |
|---|---|
| Python | 3.13.15 |
OpenCV (cv2) |
4.13.0 |
| NumPy | 2.3.5 |
| OS | Windows |
| 入力動画 | スクリプトで生成したMP4、640×360、30 FPS、90フレーム |
依存パッケージは次のようにインストールできます。
python -m pip install opencv-python==4.13.0.90 numpy==2.3.5
OpenCVが利用する動画バックエンドやコーデックは環境によって異なります。同じ拡張子でも、読み書きできる形式が必ず同じになるとは限りません。
コンタクトシートを作る
次のコードを Qiita_OpenCV_contact_sheet.py として保存します。
from __future__ import annotations
import argparse
import math
from pathlib import Path
import cv2
import numpy as np
TILE_WIDTH = 320
IMAGE_HEIGHT = 180
LABEL_HEIGHT = 40
TILE_HEIGHT = IMAGE_HEIGHT + LABEL_HEIGHT
def make_tile(frame: np.ndarray, frame_index: int, fps: float) -> np.ndarray:
tile = np.full(
(TILE_HEIGHT, TILE_WIDTH, 3), 245, dtype=np.uint8
)
height, width = frame.shape[:2]
if height == 0 or width == 0:
raise ValueError("Decoded frame has an invalid size")
scale = min(TILE_WIDTH / width, IMAGE_HEIGHT / height)
new_width = max(1, round(width * scale))
new_height = max(1, round(height * scale))
interpolation = cv2.INTER_AREA if scale < 1 else cv2.INTER_LINEAR
image = cv2.resize(
frame, (new_width, new_height), interpolation=interpolation
)
x = (TILE_WIDTH - new_width) // 2
y = (IMAGE_HEIGHT - new_height) // 2
tile[y : y + new_height, x : x + new_width] = image
label = f"frame={frame_index} t~{frame_index / fps:.2f}s"
cv2.putText(
tile,
label,
(10, IMAGE_HEIGHT + 26),
cv2.FONT_HERSHEY_SIMPLEX,
0.54,
(30, 30, 30),
1,
cv2.LINE_AA,
)
return tile
def build_contact_sheet(
input_path: Path,
output_path: Path,
interval: float,
max_samples: int,
) -> tuple[int, float, int]:
if interval <= 0 or not math.isfinite(interval):
raise ValueError("interval must be a finite number greater than 0")
if max_samples < 1:
raise ValueError("max-samples must be at least 1")
if not input_path.is_file():
raise FileNotFoundError(f"Input video not found: {input_path}")
capture = cv2.VideoCapture(str(input_path))
if not capture.isOpened():
raise RuntimeError(f"Could not open video: {input_path}")
tiles: list[np.ndarray] = []
try:
fps = capture.get(cv2.CAP_PROP_FPS)
if not math.isfinite(fps) or fps <= 0:
raise RuntimeError("Video backend reported an invalid FPS")
stride = max(1, round(fps * interval))
frame_index = 0
while len(tiles) < max_samples:
ok, frame = capture.read()
if not ok:
break
if frame_index % stride == 0:
tiles.append(make_tile(frame, frame_index, fps))
frame_index += 1
finally:
capture.release()
if not tiles:
raise RuntimeError("No frames could be decoded")
columns = min(4, len(tiles))
rows = math.ceil(len(tiles) / columns)
sheet = np.full(
(rows * TILE_HEIGHT, columns * TILE_WIDTH, 3),
245,
dtype=np.uint8,
)
for tile_index, tile in enumerate(tiles):
row, column = divmod(tile_index, columns)
y = row * TILE_HEIGHT
x = column * TILE_WIDTH
sheet[y : y + TILE_HEIGHT, x : x + TILE_WIDTH] = tile
output_path.parent.mkdir(parents=True, exist_ok=True)
if not cv2.imwrite(str(output_path), sheet):
raise RuntimeError(f"Could not write output image: {output_path}")
return len(tiles), fps, stride
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Create a contact sheet from sampled video frames."
)
parser.add_argument("video", type=Path, help="input video path")
parser.add_argument(
"--interval",
type=float,
default=0.5,
help="nominal sampling interval in seconds (default: 0.5)",
)
parser.add_argument(
"--max-samples",
type=int,
default=12,
help="maximum number of frames to include (default: 12)",
)
parser.add_argument(
"--out",
type=Path,
default=Path("Qiita_Contact_Sheet_Test.jpg"),
help="output image path",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
try:
count, fps, stride = build_contact_sheet(
args.video, args.out, args.interval, args.max_samples
)
except (OSError, RuntimeError, ValueError, cv2.error) as exc:
raise SystemExit(f"error: {exc}") from exc
print(f"saved {count} samples to {args.out}")
print(f"reported_fps={fps:.3f}, frame_stride={stride}")
if __name__ == "__main__":
main()
VideoCapture は try / finally で解放しています。--interval には有限の正数、--max-samples には1以上の整数が必要です。入力ファイル、FPS、デコード結果、画像の書き込みも確認し、問題があればエラーで終了します。
サンプリング間隔は次の式でフレーム数へ変換しています。
stride = max(1, round(fps * interval))
30 FPSの動画に --interval 0.5 を指定すると、round(30 × 0.5) = 15 なので15フレームごとに採用します。
合成テスト動画を作る
掲載可能な動画がなくても動作を再現できるよう、単純な図形だけで構成した動画を生成します。実在人物もAIモデルの出力も使いません。
次のコードを Qiita_make_sample_video.py として保存します。
from pathlib import Path
import cv2
import numpy as np
OUTPUT_PATH = Path("Qiita_Synthetic_Test.mp4")
FPS = 30
FRAME_SIZE = (640, 360)
FRAME_COUNT = 90
def main() -> None:
writer = cv2.VideoWriter(
str(OUTPUT_PATH),
cv2.VideoWriter_fourcc(*"mp4v"),
FPS,
FRAME_SIZE,
)
if not writer.isOpened():
raise SystemExit(
"error: the installed video backend could not create an MP4 "
"with the mp4v encoder"
)
try:
for frame_index in range(FRAME_COUNT):
frame = np.full((360, 640, 3), 235, dtype=np.uint8)
center_x = 200 + frame_index * 2
cv2.circle(frame, (center_x, 155), 75, (120, 140, 165), -1)
cv2.circle(frame, (center_x - 22, 138), 7, (25, 25, 25), -1)
cv2.circle(frame, (center_x + 22, 138), 7, (25, 25, 25), -1)
if (frame_index // 15) % 2:
cv2.ellipse(
frame,
(center_x, 187),
(25, 15),
0,
0,
360,
(15, 15, 15),
-1,
)
else:
cv2.line(
frame,
(center_x - 25, 190),
(center_x + 25, 190),
(25, 25, 25),
4,
)
cv2.putText(
frame,
f"SYNTHETIC frame={frame_index}",
(20, 320),
cv2.FONT_HERSHEY_SIMPLEX,
1,
(40, 40, 40),
2,
cv2.LINE_AA,
)
writer.write(frame)
finally:
writer.release()
print(
f"created {OUTPUT_PATH} "
f"({FPS} fps, {FRAME_COUNT} frames, {FRAME_COUNT / FPS:.1f} seconds)"
)
if __name__ == "__main__":
main()
mp4v を利用できない環境では VideoWriter.isOpened() が失敗し、処理を中止します。その場合は、利用中のOpenCVバックエンドで書き込めるコンテナとFourCCの組み合わせに変更してください。
実行結果
同じディレクトリで次のコマンドを実行します。
python Qiita_make_sample_video.py
python Qiita_OpenCV_contact_sheet.py Qiita_Synthetic_Test.mp4 --interval 0.5 --max-samples 12 --out Qiita_Contact_Sheet_Test.jpg
今回の再検証では、次の出力になりました。
created Qiita_Synthetic_Test.mp4 (30 fps, 90 frames, 3.0 seconds)
saved 6 samples to Qiita_Contact_Sheet_Test.jpg
reported_fps=30.000, frame_stride=15
生成した動画はメタデータ上も30 FPS、90フレームで、全90フレームをデコードできました。抽出対象は 0, 15, 30, 45, 60, 75 の6フレームです。出力画像は1280×440ピクセルになりました。
自作の合成テスト動画から抽出した6枚。実在人物やAIモデルのテスト結果ではありません。
無効な間隔、NaN、0以下のサンプル数、存在しない入力ファイル、書き込み非対応の画像拡張子についてもエラー終了することを確認しました。階層化した出力先を指定した場合は、親ディレクトリが自動で作成されます。
FPSから求めた時刻は概算
タイルに表示する t~0.50s は、フレーム番号をOpenCVが報告したFPSで割った値です。~ を付けているのは、実際の表示時刻そのものではないためです。
固定フレームレート(CFR)のテスト動画では扱いやすい方法ですが、可変フレームレート(VFR)ではフレーム間隔が一定ではありません。このスクリプトは fps × interval を丸めたフレーム間隔で抽出するため、VFR動画を実時間で正確に0.5秒ごとに切り出す用途には向きません。
OpenCVの動画プロパティは、コンテナ、ドライバ、動画バックエンドなど複数の層を経由します。公式ドキュメントにも、取得値や実際の挙動はバックエンドに依存すると記載されています。厳密な時刻が必要なら、コンテナ内のPTSを扱える方法を別途検討してください。
一覧画像をどう使うか
人物動画では、次のような変化を探すと、元動画へ戻る位置を絞りやすくなります。
- 前後のフレームで唇や歯の形が急に変わっていないか
- 横を向く途中で顎や頬の輪郭が飛んでいないか
- 眉、視線、口元の変化が不自然に途切れていないか
ただし、0.5秒間隔では、その間の数フレームだけに出る乱れを見逃します。気になる区間が見つかったら、--interval 0.1 などに変更して再度抽出します。低いFPSや端数を含むFPSでは、丸めのため指定秒数と完全には一致しません。
コンタクトシートを見ただけで「音ずれなし」とは判断できません。最後は音声付きの元動画を通常速度で再生し、必要に応じて波形や正確なタイムスタンプを扱える別のツールで確認します。
比較サイトとの役割を分ける
このスクリプトは、手元の動画をフレーム単位で見やすくするためのものです。試すサービスの候補を探す作業とは役割が異なります。
LipSync Atlas は、AIリップシンク関連ツールを用途、品質、価格などから探すための比較・案内サイトで、動画を直接生成するサービスではありません。現在の方法説明では、品質評価は提供された評価データに基づき、根拠となる動画と実行ログは未掲載とされています。ランキングを独立した実測結果として扱わず、候補を整理する資料として読む必要があります。
フレーム抽出の結果も比較サイトの数値も、それだけで音画同期の正確さを証明するものではありません。入力条件をそろえ、元動画を音声付きで確認する工程は別に残ります。
まとめ
コンタクトシートは、自動評価器ではありません。動画の前後関係を1枚に並べ、何フレーム目を元動画で見直すか共有しやすくするための補助資料です。
この用途に限れば、処理は単純なほうが追跡しやすくなります。FPSから求めた時刻の限界と、見落とすフレームがあることを理解したうえで使うのが前提です。

