【Python】スト6の対戦リプレイから「ヒットシーン」を自動抽出するツールを作ってみた
格闘ゲームの動画編集でヒットシーンを抜き出す需要があるらしい。
今回は、OpenCVを使って体力バーの変化を検知し、Pythonで自動カットするツールを開発しました。
1. 開発の背景と仕組み
格ゲーの動画解析で最も信頼できるデータは「体力バー」です。スト6では、ダメージを受けると体力バーが特定の黄色や白に光ります。このダメージ表現の色を監視することで、正確な検出が可能になります。
2. 「検出範囲調整」
「動画のどこを検出範囲として設定するのか?」という問題を解決するために、マウスで指定できるようにしました。
(矢印のある青い枠が指定領域)
「スペースキー」で指定して終了。
今回は、体力バーを指定してダメージで色が変わったら検出したいという設定にしています。
3. 「色範囲調整ツール」
「動画によって照明やエフェクトが違うから検知が安定しない」という問題を解決するため、リアルタイム調整ツールを内蔵しました。
- ① スライダー機能: H(色相)、S(彩度)、V(明度)のしきい値を動かしながら、検知範囲を追い込めます。
- ② 検出結果確認機能:それを見ながらスライダーで色を指定してください。ピクセルの色が、L-H 以上 U-H 以下になった時、検出します。
- ③ 検出ピクセルカウント機能 : 検出したピクセルの個数を表示。何個以上をヒットとするか?という設定に使用
GUI連携: 調整が終わって「Q」キーを押すと、その数値が自動でメイン画面に反映される仕組みにしました。
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()


