poolcoach / scripts /broadcast /extract_shots.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
6.72 kB
"""Bước 1 P0 (BRIEF 11/08/2026): cắt frame từng cú từ video broadcast.
Đọc ``datasets/bb9_pilot/shots.json`` (Danh chọn tay 10 cú full-table), cắt
frame bằng OpenCV theo [start, end] của mỗi cú, GIỮ TIMESTAMP THẬT của từng
frame (``CAP_PROP_POS_MSEC`` — PTS container, không phải số frame × 1/fps):
backend §6 sau này ăn RoPE theo timestamp thật để chịu VFR YouTube, nên lưu
ngay từ P0 khỏi phải trích lại (BRIEF bối cảnh #7).
Frame ra ``datasets/bb9_pilot/shot_XX/frames/*.jpg`` + ``frames_meta.csv``
(frame_file, frame_idx_video, t_video_s) + ``meta.json`` per shot. Thư mục
``datasets/`` nằm ngoài git (repo public, bản quyền video) — chỉ commit
script + shots.json KHÔNG commit frame.
``shots.json`` (Danh đưa timestamp, format tự quyết P0):
{"video": "pFV7sdBJ42M.mp4", # tuỳ chọn, mặc định dò *.mp4
"shots": [{"id": 1, "start": "1:23:45.5", "end": "1:23:58",
"note": "ghi chu tuy y"}, ...]}
start/end nhận giây (số) hoặc chuỗi "HH:MM:SS(.f)" / "MM:SS(.f)".
Chạy (venv CV — chỉ cần cv2/numpy, nhưng nếp P0 dồn hết vào cv-env):
D:\\Khoa luan\\poolcoach-cv-env\\Scripts\\python.exe \
scripts/broadcast/extract_shots.py
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] # poolcoach-rl/
sys.path.insert(0, str(ROOT / "src"))
import cv2 # noqa: E402
PILOT_ROOT = ROOT / "datasets" / "bb9_pilot"
# Cảnh báo VFR: dt giữa 2 frame lệch quá 25% so với 1/fps danh định thì đếm.
DT_JITTER_TOL = 0.25
def parse_ts(v) -> float:
"""Giây (float) hoặc "HH:MM:SS(.f)" / "MM:SS(.f)" -> giây."""
if isinstance(v, (int, float)):
return float(v)
parts = str(v).strip().split(":")
if not 1 <= len(parts) <= 3 or any(p == "" for p in parts):
raise ValueError(f"Timestamp khong hop le: {v!r}")
sec = 0.0
for p in parts:
sec = sec * 60.0 + float(p)
return sec
def extract_shot(cap: cv2.VideoCapture, shot: dict, out_dir: Path,
fps_nominal: float, jpg_quality: int) -> dict:
"""Cắt 1 cú: seek tới start, đọc tới end, lưu jpg + timestamp thật."""
t0, t1 = parse_ts(shot["start"]), parse_ts(shot["end"])
if t1 <= t0:
raise ValueError(f"Shot {shot['id']}: end ({t1}) <= start ({t0}).")
frames_dir = out_dir / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
cap.set(cv2.CAP_PROP_POS_MSEC, t0 * 1000.0)
rows: list[tuple[str, int, float]] = []
n_jitter = 0
prev_t = None
while True:
ok = cap.grab()
if not ok:
break
t = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0
# Seek theo msec có thể rơi về keyframe trước start — bỏ qua tới t0.
if t < t0 - 1e-6:
continue
if t > t1 + 1e-6:
break
ok, frame = cap.retrieve()
if not ok:
break
idx_video = int(cap.get(cv2.CAP_PROP_POS_FRAMES)) - 1
name = f"{len(rows):05d}.jpg"
cv2.imwrite(str(frames_dir / name), frame,
[cv2.IMWRITE_JPEG_QUALITY, jpg_quality])
rows.append((name, idx_video, round(t, 6)))
if prev_t is not None and fps_nominal > 0:
dt_nom = 1.0 / fps_nominal
if abs((t - prev_t) - dt_nom) > DT_JITTER_TOL * dt_nom:
n_jitter += 1
prev_t = t
if not rows:
raise RuntimeError(
f"Shot {shot['id']}: khong doc duoc frame nao trong "
f"[{t0}, {t1}]s — kiem lai timestamp/video.")
with open(out_dir / "frames_meta.csv", "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["frame_file", "frame_idx_video", "t_video_s"])
w.writerows(rows)
meta = {
"shot_id": shot["id"],
"note": shot.get("note", ""),
"start_requested_s": t0,
"end_requested_s": t1,
"n_frames": len(rows),
"t_first_s": rows[0][2],
"t_last_s": rows[-1][2],
"fps_nominal": fps_nominal,
"n_dt_jitter": n_jitter,
"extracted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
with open(out_dir / "meta.json", "w", encoding="utf-8") as f:
json.dump(meta, f, indent=2, ensure_ascii=False)
return meta
def main() -> None:
ap = argparse.ArgumentParser(description="Cat frame tung cu theo shots.json (P0)")
ap.add_argument("--root", type=Path, default=PILOT_ROOT)
ap.add_argument("--shots", type=Path, default=None,
help="mac dinh <root>/shots.json")
ap.add_argument("--video", type=Path, default=None,
help="mac dinh: 'video' trong shots.json, roi *.mp4 duy nhat trong root")
ap.add_argument("--only", type=int, nargs="*", default=None,
help="chi cat cac shot id nay")
ap.add_argument("--jpg-quality", type=int, default=95)
args = ap.parse_args()
shots_path = args.shots or args.root / "shots.json"
spec = json.loads(shots_path.read_text(encoding="utf-8"))
shots = spec["shots"]
if args.only:
shots = [s for s in shots if s["id"] in set(args.only)]
if not shots:
sys.exit("[ERROR] Khong co shot nao de cat.")
video = args.video
if video is None and spec.get("video"):
video = args.root / spec["video"]
if video is None:
mp4s = sorted(args.root.glob("*.mp4"))
if len(mp4s) != 1:
sys.exit(f"[ERROR] Can --video: thay {len(mp4s)} file *.mp4 trong {args.root}")
video = mp4s[0]
cap = cv2.VideoCapture(str(video))
if not cap.isOpened():
sys.exit(f"[ERROR] Khong mo duoc video: {video}")
fps_nominal = cap.get(cv2.CAP_PROP_FPS)
print(f"video: {video.name} fps_nominal={fps_nominal:.3f}")
summaries = []
for shot in shots:
out_dir = args.root / f"shot_{shot['id']:02d}"
m = extract_shot(cap, shot, out_dir, fps_nominal, args.jpg_quality)
summaries.append(m)
print(f"shot_{shot['id']:02d}: {m['n_frames']} frames "
f"t=[{m['t_first_s']:.3f}, {m['t_last_s']:.3f}]s "
f"jitter={m['n_dt_jitter']}")
cap.release()
total = sum(m["n_frames"] for m in summaries)
jit = sum(m["n_dt_jitter"] for m in summaries)
print(f"\n[DONE] {len(summaries)} shots, {total} frames, dt-jitter {jit} frame-gaps")
if jit:
print("[WARN] Co dt jitter — video VFR that, timestamp da luu la PTS container.")
if __name__ == "__main__":
main()