0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

動画の監視スクリプト

0
Posted at

【Python】スト6の対戦リプレイから「ヒットシーン」を自動抽出するツールを作ってみた

格闘ゲームの動画編集でヒットシーンを抜き出す需要があるらしい。
今回は、OpenCVを使って体力バーの変化を検知し、Pythonで自動カットするツールを開発しました。

1. 開発の背景と仕組み

格ゲーの動画解析で最も信頼できるデータは「体力バー」です。スト6では、ダメージを受けると体力バーが特定の黄色や白に光ります。このダメージ表現の色を監視することで、正確な検出が可能になります。

image.png

2. 「検出範囲調整」

「動画のどこを検出範囲として設定するのか?」という問題を解決するために、マウスで指定できるようにしました。
(矢印のある青い枠が指定領域)
スペースキー」で指定して終了。
今回は、体力バーを指定してダメージで色が変わったら検出したいという設定にしています。

image.png

3. 「色範囲調整ツール」

「動画によって照明やエフェクトが違うから検知が安定しない」という問題を解決するため、リアルタイム調整ツールを内蔵しました。

  • ① スライダー機能: H(色相)、S(彩度)、V(明度)のしきい値を動かしながら、検知範囲を追い込めます。
  • ② 検出結果確認機能:それを見ながらスライダーで色を指定してください。ピクセルの色が、L-H 以上 U-H 以下になった時、検出します。
  • ③ 検出ピクセルカウント機能 : 検出したピクセルの個数を表示。何個以上をヒットとするか?という設定に使用
    GUI連携: 調整が終わって「Q」キーを押すと、その数値が自動でメイン画面に反映される仕組みにしました。

image.png

4. まとめ

正直なところ、300行くらいのpythonスクリプトでこのようなことが可能なのは驚き。
ただ、あんまり探してないけど、こういうソフトはあるだろうなぁ。
もうちょっと検出精度の高いやり方はあるかもしれん

ともかく、動画編集を自動化することで、練習や対戦に割く時間を増やすことができます。皆さんもぜひ、OpenCVを使った画像解析に挑戦してみてください!

使い方

以下のスクリプトを、 tool.pyとして保存後。
tkinterのインストールは別途必要かもしれない。

pip install numpy==2.2.6 opencv-python==4.12.0.88  moviepy==2.2.1
pyton3 tool.py
import tkinter as tk
from tkinter import filedialog, messagebox
import cv2
import numpy as np
import moviepy as mp
from moviepy.video.io.VideoFileClip import VideoFileClip
from moviepy.video.compositing.CompositeVideoClip import concatenate_videoclips


class HighlightCreatorGUI(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Replay Highlight Creator")
        self.geometry("850x400")
        self.configure(bg="white")  # 背景を白にg設定

        # 共通スタイル
        lbl_cfg = {"bg": "white", "fg": "black", "font": ("MS Gothic", 10)}
        ent_cfg = {"bg": "#f0f0f0", "fg": "black",
                   "relief": "flat", "insertbackground": "black"}

        # --- レイアウト配置 ---

        # 1. ファイル選択
        tk.Label(self, text="( 入力ファイル名 : )", **lbl_cfg).place(x=30, y=30)
        self.ent_input = tk.Entry(self, width=30, **ent_cfg)
        self.ent_input.insert(0, "AKANE.mp4")
        self.ent_input.place(x=30, y=55)
        tk.Button(self, text="参照", command=self.select_file,
                  bg="#e1e1e1", relief="groove").place(x=290, y=55)

        # 2. 出力ファイル名
        tk.Label(self, text="( 出力ファイル名 : )", **lbl_cfg).place(x=380, y=30)
        self.ent_output = tk.Entry(self, width=25, **ent_cfg)
        self.ent_output.insert(0, "highlight.mp4")
        self.ent_output.place(x=530, y=55)

        # 3. 領域設定
        tk.Label(self, text="( 領域設定 : )", **lbl_cfg).place(x=30, y=100)
        self.ent_roi = tk.Entry(self, width=50, **ent_cfg)
        self.ent_roi.insert(0, "182, 97, 1571, 28")
        self.ent_roi.place(x=350, y=103)

        # 4. 色範囲 LOWER
        tk.Label(self, text="( 色 範囲 調整 : LOWER_HIT_COLOR )",
                 **lbl_cfg).place(x=30, y=140)
        self.ent_lower = tk.Entry(self, width=50, **ent_cfg)
        self.ent_lower.insert(0, "17, 145, 60")
        self.ent_lower.place(x=350, y=143)

        # 5. 色範囲 UPPER
        tk.Label(self, text="( 色 範囲 調整 : UPPER_HIT_COLOR )",
                 **lbl_cfg).place(x=30, y=180)
        self.ent_upper = tk.Entry(self, width=50, **ent_cfg)
        self.ent_upper.insert(0, "101, 255,  172")
        self.ent_upper.place(x=350, y=183)

        # 6. しきい値
        tk.Label(self, text="( ダメージ 領域 しきい値 : )", **lbl_cfg).place(x=30, y=220)
        self.ent_thresh = tk.Entry(self, width=50, **ent_cfg)
        self.ent_thresh.insert(0, "20")
        self.ent_thresh.place(x=350, y=223)

        # 7. 前後秒数
        tk.Label(self, text="( 切り抜き 前後の 秒数 指定 : )",
                 **lbl_cfg).place(x=30, y=260)
        self.ent_margin = tk.Entry(self, width=50, **ent_cfg)
        self.ent_margin.insert(0, "0.5")
        self.ent_margin.place(x=350, y=263)

        # 領域調整用ボタン
        self.area_button = tk.Button(self, text="領域調整",
                                     command=lambda: self.roi_adjuster(
                                         self.ent_input.get()),
                                     width=30, bg="#2196F3", fg="white",
                                     relief="flat", font=("MS Gothic", 10))
        self.area_button.place(x=40, y=300)

        # 色調整用ボタン
        self.color_button = tk.Button(self, text="色範囲調整",
                                      command=lambda: self.color_adjuster(
                                          self.ent_input.get(),
                                          self.ent_roi.get()),
                                      width=30, bg="#2196F3", fg="white",
                                      relief="flat", font=("MS Gothic", 10))
        self.color_button.place(x=440, y=300)

        # 実行ボタン
        self.btn_run = tk.Button(self, text="作成開始", command=self.run_process,
                                 width=30, bg="#2196F3", fg="white",
                                 relief="flat", font=("MS Gothic", 12, "bold"))
        self.btn_run.place(x=240, y=350)

    def select_file(self):
        file_path = filedialog.askopenfilename()
        if file_path:
            self.ent_input.delete(0, tk.END)
            self.ent_input.insert(0, file_path)

    def roi_adjuster(self, video_path):
        cap = cv2.VideoCapture(video_path)
        ret, frame = cap.read()
        cap.release()

        if not ret:
            print("動画を読み込めませんでした。")
            return

        # ウィンドウの名前
        window_name = "Select HP Bar ROI (Drag and Press ENTER)"

        # マウスで範囲を選択 (左上から右下へドラッグ)
        # 選択後、EnterキーまたはSpaceキーを押すと確定
        roi = cv2.selectROI(window_name, frame,
                            showCrosshair=True, fromCenter=False)

        cv2.destroyAllWindows()

        if roi[2] > 0 and roi[3] > 0:
            print("\n--- 取得した座標 ---")
            print(f"HP_BAR_ROI = ({roi[0]}, {roi[1]}, {roi[2]}, {roi[3]})")
            print("-------------------\n")
            self.ent_roi.delete(0, tk.END)
            self.ent_roi.insert(0, f"{roi[0]}, {roi[1]}, {roi[2]}, {roi[3]}")
        else:
            print("選択がキャンセルされました。")

    def color_adjuster(self, video_path, roi):
        HP_BAR_ROI = [int(x.strip()) for x in roi.split(",")]
        print(video_path, HP_BAR_ROI)

        def nothing(x):
            pass

        cv2.namedWindow("Trackbars")
        cv2.resizeWindow("Trackbars", 400, 300)

        # スライダー作成
        cv2.createTrackbar("L-H", "Trackbars", 20, 180, nothing)
        cv2.createTrackbar("L-S", "Trackbars", 50, 255, nothing)
        cv2.createTrackbar("L-V", "Trackbars", 40, 255, nothing)
        cv2.createTrackbar("U-H", "Trackbars", 45, 180, nothing)
        cv2.createTrackbar("U-S", "Trackbars", 255, 255, nothing)
        cv2.createTrackbar("U-V", "Trackbars", 255, 255, nothing)

        cap = cv2.VideoCapture(video_path)
        paused = False

        while True:
            if not paused:
                ret, frame = cap.read()
                if not ret:
                    cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
                    continue

            # 現在のHSV設定取得
            l_h = cv2.getTrackbarPos("L-H", "Trackbars")
            l_s = cv2.getTrackbarPos("L-S", "Trackbars")
            l_v = cv2.getTrackbarPos("L-V", "Trackbars")
            u_h = cv2.getTrackbarPos("U-H", "Trackbars")
            u_s = cv2.getTrackbarPos("U-S", "Trackbars")
            u_v = cv2.getTrackbarPos("U-V", "Trackbars")

            lower_color = np.array([l_h, l_s, l_v])
            upper_color = np.array([u_h, u_s, u_v])

            # 処理
            x, y, w, h = HP_BAR_ROI
            roi = frame[y:y+h, x:x+w]
            hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
            mask = cv2.inRange(hsv, lower_color, upper_color)

            # --- ヒットピクセル数の計算 ---
            white_pixels = np.sum(mask > 0)

            # 表示用画像の作成
            display_roi = roi.copy()
            display_mask = cv2.cvtColor(
                mask, cv2.COLOR_GRAY2BGR)  # 文字を色付きにするためBGR変換

            status = "PAUSED" if paused else "PLAYING"
            # 画面上にピクセル数を表示
            info_text = f"{status} | Pixels: {white_pixels}"
            cv2.putText(display_roi, info_text, (5, 15),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 0), 1)

            # マスク側にもピクセル数を表示(これが見やすい)
            cv2.putText(display_mask, f"Count: {white_pixels}", (5, 15),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 0, 255), 1)

            cv2.imshow("ROI (Original)", display_roi)
            cv2.imshow("Mask (White = Detected)", display_mask)

            key = cv2.waitKey(33) & 0xFF
            if key == ord('q'):
                print(f"\n--- 最終設定値 ---")
                print(f"LOWER_HIT_COLOR = np.array([{l_h}, {l_s}, {l_v}])")
                print(f"UPPER_HIT_COLOR = np.array([{u_h}, {u_s}, {u_v}])")
                print(f"推奨しきい値 (Threshold): {white_pixels} 前後")
                self.ent_lower.delete(0, tk.END)
                self.ent_lower.insert(0, f"{l_h}, {l_s}, {l_v}")
                self.ent_upper.delete(0, tk.END)
                self.ent_upper.insert(0, f"{u_h}, {u_s}, {u_v}")
                break
            elif key == ord(' '):
                paused = not paused

        cap.release()
        cv2.destroyAllWindows()

    def update_gui_values(self, roi, low, up, thresh):
        """各Entryの内容を書き換える"""
        data = {
            "roi_val": roi,
            "low_val": low,
            "up_val": up,
            "thresh_val": str(thresh)
        }
        for key, value in data.items():
            self.entries[key].delete(0, tk.END)
            self.entries[key].insert(0, value)
        messagebox.showinfo("完了", "設定値をGUIに反映しました。")

    def run_process(self):
        try:
            # 入力情報の取得
            video_in = self.ent_input.get()
            video_out = self.ent_output.get()
            roi = [int(x.strip()) for x in self.ent_roi.get().split(",")]
            lower = np.array([int(x.strip())
                             for x in self.ent_lower.get().split(",")])
            upper = np.array([int(x.strip())
                             for x in self.ent_upper.get().split(",")])
            thresh = int(self.ent_thresh.get())
            margin = float(self.ent_margin.get())

            if not video_in:
                messagebox.showerror("エラー", "入力ファイルを選択してください。")
                return

            # 解析処理
            cap = cv2.VideoCapture(video_in)
            fps = cap.get(cv2.CAP_PROP_FPS)
            hit_timestamps = []
            frame_count = 0

            while cap.isOpened():
                ret, frame = cap.read()
                if not ret:
                    break

                # ROI抽出とマスク
                x, y, w, h = roi
                hsv = cv2.cvtColor(frame[y:y+h, x:x+w], cv2.COLOR_BGR2HSV)
                mask = cv2.inRange(hsv, lower, upper)

                # 【デバッグ用】今の検知状態を画面に出す
                cv2.imshow("Detection ROI (Normal)", frame[y:y+h, x:x+w])
                cv2.imshow("Detection Mask (White = Detected)", mask)

                if np.sum(mask > 0) > thresh:
                    hit_timestamps.append(frame_count / fps)

                if cv2.waitKey(1) & 0xFF == ord('q'):
                    break

                frame_count += 1
            cap.release()

            # 動画切り出し・結合
            if hit_timestamps:
                segments = []
                start = hit_timestamps[0]
                prev = hit_timestamps[0]
                for t in hit_timestamps[1:]:
                    if t - prev > (margin * 2):
                        segments.append((start - margin, prev + margin))
                        start = t
                    prev = t
                segments.append((start - margin, prev + margin))

                full_video = VideoFileClip(video_in)
                clips = [full_video.subclipped(max(0, s), min(
                    full_video.duration, e)) for s, e in segments]

                final_clip = concatenate_videoclips(clips)
                final_clip.write_videofile(video_out, codec="libx264")

                # 解放
                for c in clips:
                    c.close()
                full_video.close()
                messagebox.showinfo("成功", f"ハイライト動画を保存しました:\n{video_out}")
            else:
                messagebox.showwarning("通知", "ヒットシーンが検出されませんでした。")

        except Exception as e:
            messagebox.showerror("エラー", f"予期せぬエラーが発生しました:\n{str(e)}")


if __name__ == "__main__":
    app = HighlightCreatorGUI()
    app.mainloop()

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?