hirojinblog
記事一覧へ

FPSの録画からキルシーンを自動で切り出すPythonツールを作った

Delta ForceなどのFPS録画から、画面上の日本語OCRを使ってキルシーンを検知し、FFmpegで前後の動画を自動抽出する仕組みを作ったときの実装メモです。

プログラミング PythonOpenCVOCRFFmpegPodman

ゲームの録画を見返していると、「このキルシーンだけ切り出したい」と思うことがあります。

ただ、1本ずつ動画を開いて、タイムラインを動かして、良さそうな場面を探して、前後を切り出して……という作業はかなり面倒です。

そこで、FPSの録画ファイルからキルシーンを自動で検知し、その前後だけを動画として保存するツールを作りました。

対象にしたのは、Delta ForceなどのFPS録画です。仕組みとしては、画面中央付近に出る「キル」「ロングショットキル」「ヘッドショットキル」などの日本語テキストをOCRで読み取り、検知できたタイミングの前後をFFmpegで切り出します。

作ったもの

構成はシンプルで、中心になるのはPythonスクリプトです。

video_analyzer/
├── input/
│   └── videos/
├── output/
├── config.json
├── compose.yaml
├── Dockerfile
└── detect_kills.py

実行はPodman Composeから行います。

podman compose run --rm video-analyzer

動画ファイルや出力先は config.json に書いておけるようにしました。

{
  "input_dir": "/app/input",
  "output_dir": "/app/output",
  "videos": [
    "/app/input/videos"
  ],
  "frame_skip": 5,
  "ocr_engine": "onnxocr",
  "ocr_use_gpu": false,
  "ocr_lang": "japan",
  "ocr_interval_sec": 1.5,
  "ocr_scale": 1.5,
  "ocr_preprocess_mode": "color",
  "kill_group_gap_sec": 4.0,
  "kill_offset_sec": 7.5,
  "post_kill_sec": 7.5
}

videos には動画ファイルそのものも、動画が入ったディレクトリも指定できます。Windows側のパスをWSL上で扱うこともあるので、C:\Users\... のようなパスは /mnt/c/... に変換する処理も入れています。

なぜOCRで検知するのか

最初に考えたのは、音や映像の変化でキルシーンを検知する方法です。

ただ、FPSの画面は常に動いています。銃声、爆発、UI、味方の通知、目標占領の表示など、キル以外でも派手な変化がたくさんあります。そのため、画面全体の差分や音量だけで判定すると誤検知が増えそうでした。

一方で、キルが発生した瞬間には画面上に「キル」系のテキストが出ます。

つまり、動画を直接理解しようとするより、ゲームが表示している結果テキストを読むほうがシンプルです。

今回のツールでは、OpenCVで動画フレームを読み込み、画面中央下あたりのROIだけを切り出し、そこにOCRをかけています。

ROIを絞ってOCRを軽くする

動画の全画面にOCRをかけると重くなりますし、余計なUIまで読んでしまいます。

そこで、まずは画面中央から下にかけて大きめのROIを作ります。

def get_roi_rect(frame_width, frame_height):
    center_x = frame_width // 2
    center_y = frame_height // 2

    x1 = center_x - 500
    x2 = center_x + 500
    y1 = center_y - 100
    y2 = frame_height

    return x1, y1, x2, y2

さらに、そのROIの中でもOCRにかける範囲を比率で指定できるようにしました。

"ocr_subroi_x1_ratio": 0.2,
"ocr_subroi_x2_ratio": 0.8,
"ocr_subroi_y1_ratio": 0.3,
"ocr_subroi_y2_ratio": 0.76

ゲームの解像度やUI位置が少し変わっても、設定値を調整すれば対応しやすくなります。

OCR前処理はカラー寄りにした

OCR前処理では、二値化する方法も試せます。

ただ、ゲーム画面のテキストは背景が動いたり、エフェクトが重なったりします。強く二値化すると文字の一部が欠けて、逆に読み取りづらくなることがありました。

そのため、現在の既定値は color にしています。

if preprocess_mode == "color":
    lab = cv2.cvtColor(scaled, cv2.COLOR_BGR2LAB)
    l_channel, a_channel, b_channel = cv2.split(lab)
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
    enhanced_l = clahe.apply(l_channel)
    enhanced = cv2.merge((enhanced_l, a_channel, b_channel))
    return cv2.cvtColor(enhanced, cv2.COLOR_LAB2BGR)

色は残したまま、明度だけを軽く補正する形です。

OCRエンジンには onnxocr を使い、保険として tesseract も選べるようにしています。GPUなしでも動かしたかったので、既定ではCPU推論です。

「キル」を含むテキストを拾う

検知ルールはかなり素直です。

def is_kill_scene_ocr_text(text):
    text = normalize_ocr_text(text)

    if not text:
        return False

    if "キル" not in text:
        return False

    if "アシストキル" in text:
        return False

    if "スキル" in text:
        return False

    return True

ダブルキルロングショットキル のような固定語を全部リストアップするのではなく、「キル」を含むかどうかで広めに拾います。

ただし、アシストキルスキル はキルシーンとして扱いたくないので除外しています。

OCRでは文字が微妙に崩れることがあるので、先に表記ゆれも少し補正しています。たとえば半角カナの に寄せるような処理です。

フレームを全部読まない

動画の全フレームにOCRをかけると、かなり時間がかかります。

そこで、ocr_interval_sec で指定した間隔ごとにOCRを実行するようにしました。

if fps > 0 and ocr_interval_sec > 0:
    effective_frame_skip = max(frame_skip, int(round(fps * ocr_interval_sec)))
else:
    effective_frame_skip = frame_skip

たとえば60fpsの動画で ocr_interval_sec1.5 なら、おおよそ90フレームごとにOCRを実行します。

精度を上げたいときは間隔を短く、速度を優先したいときは間隔を長くできます。

連続キルは1本の動画にまとめる

キルを1回検知するたびに即切り出すと、連続キルの場面が細かい動画に分かれてしまいます。

そこで、最後に検知したキルから一定時間以内に次のキルが来た場合は、同じシーンとして扱います。

設定値は kill_group_gap_sec です。

"kill_group_gap_sec": 4.0

内部では、最初にキルを検知した時刻と、最後にキルを検知した時刻を持っておきます。

キルが途切れて kill_group_gap_sec を超えたら、そこではじめて1本のシーンとして切り出します。

start = max(0, first_kill_ts - kill_offset_sec)
end = last_kill_ts + post_kill_sec

この設計にすると、単発キルは前後だけを短く保存でき、連続キルは流れを残したまま1本にできます。

切り出しはFFmpegに任せる

動画の切り出しはPythonで頑張らず、FFmpegを呼び出しています。

cmd = [
    "ffmpeg",
    "-y",
    "-ss", str(start_time),
    "-t", str(duration),
    "-i", video_path,
    "-c:v", "libx264",
    "-preset", "fast",
    "-crf", "14",
    "-c:a", "copy",
    out,
]

動画は libx264 で再エンコードし、音声はそのままコピーします。

録画ファイルの形式によっては完全コピーだけだと切り出し位置がずれることがあるので、画質をある程度保ちながら安定して切り出せる設定にしました。

デバッグ画像を残す

OCR系の処理で一番困るのは、「なぜ検知できなかったのか」が見えづらいことです。

そのため、キルシーンを検知したタイミングでは、次の画像を debug/ に保存します。

  • ROI全体
  • OCRにかけたsubROI
  • OCR前処理後の画像

ファイル名には検知時刻とOCR結果の一部も含めるようにしています。

これにより、あとから「ROIがずれている」「二値化で文字が消えている」「OCRは読めているけど除外ルールに引っかかっている」などを確認できます。

Podmanで動かす理由

このツールはOpenCV、OCR、FFmpegを使います。

ローカル環境に直接入れていくと、OSやPython環境によって動作差が出やすくなります。特にOCRやFFmpegまわりは、あとから再現するのが面倒になりがちです。

そこで、実行環境はPodmanでまとめています。

FROM python:3.11-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    libgl1 \
    libglib2.0-0 \
    ffmpeg \
    tesseract-ocr \
    tesseract-ocr-jpn

compose.yaml では、設定ファイル、入力ディレクトリ、出力ディレクトリをコンテナにマウントしています。

services:
  video-analyzer:
    build: .
    entrypoint: ["python", "detect_kills.py"]
    volumes:
      - ./config.json:/app/config.json
      - ./input:/app/input
      - ./output:/app/output
      - /mnt:/mnt:ro
      - /home:/home:ro

WSL上でWindows側の録画ファイルを読むこともあるので、/mnt/home は読み取り専用でマウントしています。

実装してみて感じたこと

今回のような動画解析では、最初からAIで全部を理解させるより、「画面に出ている確定情報を読む」ほうが実装しやすい場面があります。

キルシーンの検知も、映像の意味を推測するのではなく、ゲーム側が表示した「キル」という結果を拾うだけです。

その代わり、OCRの読み取り位置、前処理、検知間隔、除外語、連続キルのまとめ方など、小さい調整ポイントが効いてきます。

今後改善するなら、次のあたりを入れたいです。

  • キル確定に必要な検出回数を設定から切り替える
  • OCR結果のログをCSVやJSONで残す
  • 検知したシーンを一覧するHTMLレポートを生成する
  • ゲームごとにROIプリセットを切り替える
  • 抽出したキルシーンを自動で1本のモンタージュにまとめる

手作業で動画を探す時間を減らせるだけでもかなり便利なので、録画をよく残す人にはこういう自動化は相性が良いと思いました。