poolcoach / src /poolcoach_cv /segmentation.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
23.7 kB
"""Shot segmentation — cắt video dài thành từng cú (lát A1, BRIEF 13/08/2026).
Tầng "định nghĩa cú" của design Analyzer đa cú (§4 PoolCoach_Analyzer_
DemoApp_Design.md): chạy trên TRACK THÔ toàn video (detect + nối NN, trước
verifier), cắt ranh giới cú theo đúng chu trình **mọi bi đứng yên → cue
chuyển động → mọi bi đứng yên**.
Hai tầng, cùng nếp ``broadcast.py``:
- ``segment_shots`` — lõi cắt ranh giới, THUẦN NUMPY (unit test chạy trên
venv app, không cv2/torch). Input là track cue (``track_cue`` của
broadcast) + detection KHÔNG-cue per frame.
- ``segment_video`` — front-end trọn gói cho MỘT video dài: decode PTS thật
+ img_diff, YOLO detect từng frame, track cue, rồi ``segment_shots``.
Import cv2 LƯỜI trong thân hàm (nếp analyze_clip).
Ngưỡng VẬT LÝ dùng NGUYÊN bộ đã chốt 11/08 (BRIEF A1: "dùng nguyên, ĐỪNG
chế ngưỡng mới") — tất cả import từ ``broadcast.py``, không định nghĩa lại:
đứng yên ``V_STILL_MPS`` 55 mm/s (p99 bi tĩnh P0); onset ``V_INIT_MPS``
0.25 m/s × ``V_INIT_RUN`` 3 bước (measure_p0); gating track ``VMAX_MPS``
11 m/s theo dt timestamp thật. Các hằng MỚI ở đây đều là hằng CẤU TRÚC
(cửa sổ thời gian, đệm biên) — không phải ngưỡng đo vật lý mới; nguồn ghi
tại chỗ từng hằng, khai trong HANDOFF A1 để Cowork duyệt.
Quy ước output: mỗi cú một dict, thời gian TUYỆT ĐỐI theo PTS video —
``t_start_s``/``t_end_s`` là biên ĐÃ đệm (cắt clip theo cặp này);
``t_onset_s``/``t_settle_s`` là mốc thô (cue bắt đầu chạy / mọi bi yên trở
lại) để đối chiếu đếm tay. ``status`` ∈ {"ok", "error"}; cú "error" vẫn
NẰM TRONG danh sách kèm ``reason`` tiếng Việt (scope §5 design: đổi cảnh
giữa cú phải báo tử tế, không nuốt im).
"""
from __future__ import annotations
import math
from pathlib import Path
import numpy as np
from poolcoach_cv.broadcast import (ANALYZE_MAX_DUR_S, BALL_CLASSES,
DETECT_BATCH, GATE_SLACK_M, IN_TOL_M,
TABLE_L_M, TABLE_W_M, V_INIT_MPS,
V_INIT_RUN, V_STILL_MPS, VMAX_MPS,
find_dup_frames, track_cue)
from poolcoach_cv.homography import TableHomography, orient_corners
# ---------------------------------------------------- hằng CẤU TRÚC (mới)
# Không hằng nào dưới đây là ngưỡng ĐO vật lý — ngưỡng đo dùng nguyên bộ
# chốt 11/08 import từ broadcast (xem docstring module).
STILL_WIN_S = 0.5 # cửa sổ CHORD đo "đứng yên": bi tĩnh jitter ~2mm/frame
# (p99 apparent speed 55mm/s ⇒ ~1.8mm ở dt 33ms) nên
# chord 2 đầu cửa sổ 0.5s của bi tĩnh ≪ 55mm/s ×
# 0.5s = 27.5mm — chord giết nhiễu per-step mà không
# cần ngưỡng mới; bi lăn chậm 0.1 m/s đi 50mm > 27.5mm
# vẫn bị bắt là đang chạy.
BLIND_MAX_S = 1.0 # không thấy BI NÀO (cue lẫn thường) liên tục quá mức
# này = mất cảnh (replay/đổi góc máy) — cú đang mở
# đóng lại thành "error", không cố nối track xuyên
# cảnh cắt (scope §5 design).
PAD_PRE_S = 0.5 # đệm TRƯỚC onset khi cắt clip: analyze cần cửa sổ
# tĩnh trước cú — ``_static_balls`` đòi ≥ 3 detection
# mỗi cụm (STATIC_MIN_DETS) và ``balls_init`` lấy
# frame đầu; 0.5s ≈ 15 frame @30fps là đủ dư. Clamp
# về t_settle cú trước nên không bao giờ nuốt đuôi
# chuyển động của cú trước.
PAD_POST_S = 0.5 # đệm SAU t_settle: giữ trọn pha lăn chậm cuối cú cho
# analyze; clamp về onset cú sau.
SHOT_MAX_S = ANALYZE_MAX_DUR_S
# cú mở quá mức trần clip analyze (30s) mà chưa đóng
# được = ranh giới hỏng (track đứt kéo dài / bi không
# bao giờ yên) → đóng thành "error" thay vì đẻ clip
# mà /api/analyze từ chối.
LY_DO_MAT_CANH = ("mất cảnh giữa cú — không thấy bi nào trên bàn quá "
f"{BLIND_MAX_S:g}s (video đổi góc/replay); không nối "
"track xuyên cảnh cắt")
LY_DO_QUA_DAI = (f"cú mở quá {SHOT_MAX_S:g}s mà mọi bi chưa đứng yên trở "
"lại — ranh giới không đóng được (track đứt kéo dài hoặc "
"cảnh không phải một cú trọn)")
LY_DO_CUT_CUOI_VIDEO = ("video kết thúc giữa cú — không thấy lúc mọi bi "
"đứng yên trở lại")
# ------------------------------------------------- tracklet bi (không-cue)
def _build_tracklets(frames: list[tuple[str, float]],
others_by_frame: dict[str, list[tuple[float, float]]]
) -> list[list[tuple[float, float, float]]]:
"""Nối detection KHÔNG-cue thành tracklet bằng NN per-step + gating
VMAX theo dt thật (đúng luật track_cue — không thêm luật mới). Greedy
theo khoảng cách tăng dần; det không nối được mở tracklet mới. Trả
list tracklet, mỗi cái là list (t, x, y).
Mục đích DUY NHẤT là đo "mọi bi đứng yên" — không phải BallID, không
cần bền qua che khuất: tracklet đứt thì mở cái mới, chord đo trên từng
mảnh vẫn nói đúng chuyện bi đang chạy hay đứng.
"""
tracklets: list[list[tuple[float, float, float]]] = []
open_idx: list[int] = [] # tracklet còn "sống" ở frame trước
prev_t: float | None = None
for name, t in frames:
dets = others_by_frame.get(name, [])
if not dets:
# frame không có det không-cue: tracklet nào cũng khép — bi
# mất dấu một frame sẽ mở tracklet mới, đủ cho mục đích đo.
open_idx = []
prev_t = t
continue
if not open_idx or prev_t is None:
open_idx = []
for x, y in dets:
tracklets.append([(t, x, y)])
open_idx.append(len(tracklets) - 1)
prev_t = t
continue
dt = max(t - prev_t, 1e-3)
gate = VMAX_MPS * dt + GATE_SLACK_M
pairs = [] # (dist, det_i, tracklet_j)
for i, (x, y) in enumerate(dets):
for j in open_idx:
lx, ly = tracklets[j][-1][1], tracklets[j][-1][2]
d = math.hypot(x - lx, y - ly)
if d <= gate:
pairs.append((d, i, j))
pairs.sort(key=lambda p: p[0])
used_det: set[int] = set()
used_trk: set[int] = set()
next_open: list[int] = []
for d, i, j in pairs:
if i in used_det or j in used_trk:
continue
used_det.add(i)
used_trk.add(j)
tracklets[j].append((t, dets[i][0], dets[i][1]))
next_open.append(j)
for i, (x, y) in enumerate(dets):
if i not in used_det:
tracklets.append([(t, x, y)])
next_open.append(len(tracklets) - 1)
open_idx = next_open
prev_t = t
return tracklets
def _moving_times(tracklets, win_s: float) -> np.ndarray:
"""Mốc thời gian mà MỘT bi không-cue nào đó đang CHẠY: điểm tracklet có
chord-speed nhìn lùi ``win_s`` vượt V_STILL. Trả mảng t đã sort."""
out: list[float] = []
for trk in tracklets:
if len(trk) < 2:
continue
ts = np.array([p[0] for p in trk])
xy = np.array([[p[1], p[2]] for p in trk])
for i in range(1, len(trk)):
j = int(np.searchsorted(ts, ts[i] - win_s, side="left"))
j = min(j, i - 1)
span = float(ts[i] - ts[j])
if span <= 1e-6:
continue
chord = float(np.hypot(*(xy[i] - xy[j])))
if chord / span > V_STILL_MPS:
out.append(float(ts[i]))
return np.asarray(sorted(out))
# ------------------------------------------------------------- lõi cắt cú
def segment_shots(rows: list[dict],
others: list[dict] | None = None) -> dict:
"""Cắt ranh giới cú trên track thô toàn video.
``rows``: track cue per frame (``track_cue`` broadcast — frame_file,
t_s, covered, table_x_m, table_y_m, img_diff; str từ CSV hay số đều
nhận). ``others``: detection KHÔNG-cue per frame ``{frame_file, t_s,
x_m, y_m}`` — cần cho vế "MỌI bi đứng yên"; None/[] thì chỉ còn cue
làm chứng (vẫn chạy, kèm warning).
Trả dict JSON-thuần::
{"shots": [{"idx", "t_start_s", "t_end_s", "t_onset_s",
"t_settle_s", "status", "reason"}...],
"warnings": [...], "n_frames", "n_dup_frames", "duration_s"}
Chu trình trạng thái: chờ MỌI bi đứng yên (ARMED) → cue chạy
``V_INIT_RUN`` bước liên tiếp > ``V_INIT_MPS`` (onset, đúng luật
``_find_motion_start``) → mở cú → mọi bi yên lại trọn cửa sổ
``STILL_WIN_S`` (settle) → đóng cú, quay lại ARMED. Mất cảnh
(``BLIND_MAX_S`` không thấy bi nào) hay cú mở quá ``SHOT_MAX_S`` →
đóng thành "error" kèm lý do, KHÔNG nuốt im.
"""
if not rows:
raise ValueError("Track rỗng — không có frame nào để cắt cú.")
names = [str(r["frame_file"]) for r in rows]
t_all = np.array([float(r["t_s"]) for r in rows])
covered = np.array([str(r.get("covered", "0")) in ("1", "True", "true")
for r in rows])
diffs = [float(r.get("img_diff", -1.0)) for r in rows]
dups = find_dup_frames(names, diffs)
keep = [i for i in range(len(rows)) if names[i] not in dups]
frames = [(names[i], float(t_all[i])) for i in keep]
t_frames = np.array([t for _n, t in frames])
# --- cue: chuỗi điểm covered (đã bỏ frame trùng) + speed per-step
cue_idx = [i for i in keep if covered[i]]
t_cue = t_all[cue_idx]
xy_cue = (np.array([[float(rows[i]["table_x_m"]),
float(rows[i]["table_y_m"])] for i in cue_idx])
if cue_idx else np.empty((0, 2)))
if len(t_cue) >= 2:
dt_c = np.diff(t_cue)
dxy_c = np.diff(xy_cue, axis=0)
v_cue = np.hypot(dxy_c[:, 0], dxy_c[:, 1]) / np.maximum(dt_c, 1e-6)
t_cue_step = t_cue[1:] # mốc CUỐI mỗi bước
else:
v_cue = np.empty(0)
t_cue_step = np.empty(0)
# chord-speed nhìn lùi của cue (cùng thước với bi thường) cho vế settle
cue_moving_t: list[float] = []
for i in range(1, len(t_cue)):
j = int(np.searchsorted(t_cue, t_cue[i] - STILL_WIN_S, side="left"))
j = min(j, i - 1)
span = float(t_cue[i] - t_cue[j])
if span <= 1e-6:
continue
if float(np.hypot(*(xy_cue[i] - xy_cue[j]))) / span > V_STILL_MPS:
cue_moving_t.append(float(t_cue[i]))
cue_moving = np.asarray(cue_moving_t)
# --- bi không-cue: tracklet + mốc "đang chạy"
others = others or []
others_by_frame: dict[str, list[tuple[float, float]]] = {}
for o in others:
fname = str(o["frame_file"])
if fname in dups:
continue
others_by_frame.setdefault(fname, []).append(
(float(o["x_m"]), float(o["y_m"])))
tracklets = _build_tracklets(frames, others_by_frame)
others_moving = _moving_times(tracklets, STILL_WIN_S)
# --- frame mù: không thấy bi NÀO (cue lẫn thường)
blind = np.array([not covered[i] and not others_by_frame.get(names[i])
for i in keep])
# mốc "có dữ liệu": mọi thời điểm một chord đánh giá được (điểm thứ ≥2
# của cue hoặc của một tracklet) — cửa sổ không có mốc nào thì "yên" là
# KHÔNG BIẾT, không phải yên. Precompute một mảng sort để still_at chạy
# O(log n) mỗi frame (quét video dài hàng nghìn frame).
data_list = [float(v) for v in t_cue[1:]]
for trk in tracklets:
data_list.extend(p[0] for p in trk[1:])
data_times = np.asarray(sorted(data_list))
def any_moving(t_a: float, t_b: float) -> bool:
"""Có bi nào (cue hay thường) mang cờ 'đang chạy' trong (t_a, t_b]?"""
for arr in (cue_moving, others_moving):
lo = int(np.searchsorted(arr, t_a, side="right"))
hi = int(np.searchsorted(arr, t_b, side="right"))
if hi > lo:
return True
return False
def cue_seen_moving_before(t: float) -> bool:
"""Cue mất dấu: bước cuối trước ``t`` còn > V_INIT? (đang bay thì
mất det — chưa được kết luận 'mọi bi yên' chỉ vì vắng mặt nó)."""
k = int(np.searchsorted(t_cue_step, t, side="right")) - 1
return k >= 0 and float(v_cue[k]) > V_INIT_MPS
def still_at(t_end_win: float, in_shot: bool) -> bool:
t_a = t_end_win - STILL_WIN_S
if t_a < float(t_frames[0]) - 1e-9:
return False
lo = int(np.searchsorted(data_times, t_a, side="left"))
hi = int(np.searchsorted(data_times, t_end_win, side="right"))
if hi <= lo:
return False
if any_moving(t_a, t_end_win):
return False
if in_shot:
# cue vắng mặt trọn cửa sổ mà lần cuối thấy nó còn đang bay →
# chưa yên (bài học shot_07: người che cue giữa cú)
c_lo = int(np.searchsorted(t_cue, t_a, side="left"))
c_hi = int(np.searchsorted(t_cue, t_end_win, side="right"))
if c_hi - c_lo < 2 and cue_seen_moving_before(t_a):
return False
return True
# --- onset ứng viên: V_INIT_RUN bước cue liên tiếp > V_INIT_MPS
# (đúng luật _find_motion_start của measure_p0/broadcast; mốc onset =
# điểm ĐẦU của run). Bước vắt qua đứt track dài hơn cửa sổ không được
# tính "liên tiếp" — re-capture sau coast dài cho speed chord to giả.
onsets: list[float] = []
run = 0
for k in range(len(v_cue)):
if (t_cue[k + 1] - t_cue[k]) > STILL_WIN_S:
run = 0
run = run + 1 if v_cue[k] > V_INIT_MPS else 0
if run == V_INIT_RUN:
onsets.append(float(t_cue[k + 1 - V_INIT_RUN]))
onsets_arr = np.asarray(onsets)
# --- máy trạng thái quét theo frame đã bỏ trùng
shots: list[dict] = []
warnings: list[str] = []
state = "idle" # idle → armed → open
armed_at: float | None = None # mốc "mọi bi đã yên" gần nhất
t_onset: float | None = None
blind_from: float | None = None
def close(t_settle: float, status: str, reason: str | None) -> None:
nonlocal state, armed_at, t_onset
shots.append({"t_onset_s": float(t_onset),
"t_settle_s": float(t_settle),
"status": status, "reason": reason})
if status == "ok":
# bàn ĐANG yên (chính là điều kiện vừa đóng cú) → armed luôn,
# không quay về idle: cú kế bắn nhanh ngay sau settle vẫn bắt.
state, armed_at = "armed", t_settle
else:
state, armed_at = "idle", None
t_onset = None
for fi, (name, t) in enumerate(frames):
if blind[fi]:
if blind_from is None:
blind_from = t
if t - blind_from > BLIND_MAX_S:
if state == "open":
close(blind_from, "error", LY_DO_MAT_CANH)
# mất cảnh xoá cả bằng chứng "đã yên" — phải yên lại từ đầu
state, armed_at = "idle", None
continue
blind_from = None
if state == "idle" and still_at(t, in_shot=False):
state = "armed"
armed_at = t - STILL_WIN_S
if state == "armed":
lo = int(np.searchsorted(onsets_arr, armed_at - 1e-9,
side="left"))
hi = int(np.searchsorted(onsets_arr, t + 1e-9, side="right"))
if hi > lo:
t_onset = float(onsets_arr[lo])
state = "open"
continue
if state == "open":
if t - t_onset > SHOT_MAX_S:
close(t, "error", LY_DO_QUA_DAI)
elif t - t_onset > STILL_WIN_S and still_at(t, in_shot=True):
close(t - STILL_WIN_S, "ok", None)
if state == "open":
close(float(t_frames[-1]), "error", LY_DO_CUT_CUOI_VIDEO)
# --- đệm biên + clamp vào video và cú lân cận
t0, t1 = float(t_frames[0]), float(t_frames[-1])
for i, s in enumerate(shots):
prev_end = shots[i - 1]["t_settle_s"] if i > 0 else t0
next_on = shots[i + 1]["t_onset_s"] if i + 1 < len(shots) else t1
s["idx"] = i + 1
s["t_start_s"] = round(max(s["t_onset_s"] - PAD_PRE_S, prev_end, t0), 3)
s["t_end_s"] = round(min(s["t_settle_s"] + PAD_POST_S, next_on, t1), 3)
s["t_onset_s"] = round(s["t_onset_s"], 3)
s["t_settle_s"] = round(s["t_settle_s"], 3)
if not others:
warnings.append("Không có detection bi thường nào — vế 'mọi bi đứng "
"yên' chỉ còn cue ball làm chứng.")
return {"shots": shots,
"warnings": warnings,
"n_frames": len(rows),
"n_dup_frames": len(dups),
"duration_s": round(float(t_all[-1] - t_all[0]), 3)
if len(t_all) > 1 else 0.0}
# ------------------------------------------ front-end video (cv2/YOLO lười)
def segment_video(video_path: str | Path, corners_px, model, *,
op_conf: float, imgsz: int = 640, device=None,
max_dur_s: float | None = None, progress=None) -> dict:
"""MỘT video dài → detect + track toàn video → danh sách cú.
Cùng giao ước ``analyze_clip`` (corners 4 pocket pixel, model YOLO đã
load, ``progress(stage, frac)``, ValueError cho lỗi input) nhưng KHÔNG
trần 30s — trần video do route quyết (``max_dur_s``). Decode trong bộ
nhớ, không ghi frame ra đĩa (ràng buộc bản quyền, nếp BRIEF 24 #7).
Trả dict của ``segment_shots`` cộng ``fps_nominal`` và
``t_first_s``/``t_last_s`` (PTS tuyệt đối — clip per cú cắt theo đúng
thước này).
"""
import cv2
corners = np.asarray(corners_px, dtype=np.float64)
if corners.shape != (4, 2):
raise ValueError("corners phải là 4 điểm [x, y] pixel — chấm đủ "
"4 pocket góc.")
th = TableHomography(orient_corners(corners),
table_w=TABLE_W_M, table_l=TABLE_L_M)
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
raise ValueError("Không mở được video — cần MP4/MOV (H.264) đọc được.")
try:
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
n_est = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0
if (max_dur_s and fps > 0 and n_est > 0
and n_est / fps > max_dur_s + 0.5):
raise ValueError(f"Video dài {n_est / fps / 60:.1f} phút — giới "
f"hạn {max_dur_s / 60:g} phút cho một lần quét.")
ci = th.corners_px.astype(int)
x0, x1 = ci[:, 0].min(), ci[:, 0].max()
y0, y1 = ci[:, 1].min(), ci[:, 1].max()
meta: list[tuple[str, float]] = []
diffs: dict[str, float] = {}
dets_by_frame: dict[str, list[dict]] = {}
other_dets: list[dict] = []
prev_crop = None
t_first = None
batch_imgs: list = []
batch_names: list[str] = []
total = int(n_est) if n_est > 0 else 0
def flush_batch() -> None:
if not batch_imgs:
return
kw = {"device": device} if device is not None else {}
results = model.predict(batch_imgs, conf=op_conf, imgsz=imgsz,
verbose=False, **kw)
for name, res in zip(batch_names, results):
names_map = res.names
for b in res.boxes:
cls = names_map[int(b.cls)]
if cls not in BALL_CLASSES:
continue
bx1, by1, bx2, by2 = (float(v) for v in b.xyxy[0])
cx, cy = (bx1 + bx2) / 2.0, (by1 + by2) / 2.0
tx, ty = th.px_to_table((cx, cy))
if not (-IN_TOL_M <= tx <= TABLE_W_M + IN_TOL_M
and -IN_TOL_M <= ty <= TABLE_L_M + IN_TOL_M):
continue
if cls == "Cue":
dets_by_frame.setdefault(name, []).append(
{"conf": float(b.conf), "table_x_m": float(tx),
"table_y_m": float(ty)})
else:
other_dets.append({"frame_file": name,
"t_s": meta[int(name)][1],
"x_m": float(tx),
"y_m": float(ty)})
batch_imgs.clear()
batch_names.clear()
while True:
ok = cap.grab()
if not ok:
break
t = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0
if t_first is None:
t_first = t
if max_dur_s and t - t_first > max_dur_s + 0.5:
raise ValueError(f"Video dài quá {max_dur_s / 60:g} phút "
f"(container khai sai số frame).")
ok, frame = cap.retrieve()
if not ok:
break
name = f"{len(meta):05d}"
crop = frame[max(0, y0):max(1, y1), max(0, x0):max(1, x1)]
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY).astype(np.int16)
diffs[name] = (float(np.abs(gray - prev_crop).mean())
if prev_crop is not None else -1.0)
prev_crop = gray
meta.append((name, t))
batch_imgs.append(frame)
batch_names.append(name)
if len(batch_imgs) >= DETECT_BATCH:
flush_batch()
if progress is not None:
frac = min(len(meta) / total, 1.0) if total else 0.0
progress("detect", 0.02 + 0.9 * frac)
flush_batch()
finally:
cap.release()
if len(meta) < 5:
raise ValueError("Video quá ngắn hoặc không đọc được frame nào.")
if progress is not None:
progress("segment", 0.95)
rows = track_cue(meta, dets_by_frame, diffs)
out = segment_shots(rows, others=other_dets)
out["fps_nominal"] = round(fps, 3)
out["t_first_s"] = round(float(meta[0][1]), 3)
out["t_last_s"] = round(float(meta[-1][1]), 3)
return out