Spaces:
Sleeping
Sleeping
File size: 11,965 Bytes
78738de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | """Render overlay.mp4 per cú — lát A2 phần 3 (13/08/2026, BRIEF A2).
Tái dụng cách vẽ ``render_overlay`` của ``scripts/broadcast/track_p0.py``
(bản Danh đã xem và chốt 13/08 vì trực quan — đuôi track + header trạng
thái), nâng cấp theo design §3.2: vẽ MỌI bi (không chỉ cue), màu theo số
bi (BallID hai tầng chạy per-frame trên chính crop bbox), chấm sự kiện
va chạm suy ra. Script P0 giữ NGUYÊN không sửa — đây là code path mới
cho app (nếp broadcast.py).
Render chạy TRONG job analyze, TRƯỚC khi worker xoá clip (bài học lần đo
A1: clip đã mất thì overlay không render nổi hồi tố). Detection per frame
KHÔNG detect lại: ``RecordingModel`` bọc YOLO quanh MỘT lần gọi
``analyze_clip`` và ghi lại đúng những bbox mà tầng số đã dùng (nếp
"adapter quanh một lần gọi, không sửa broadcast.py" — BG26/BG28/BG31);
frame đọc lại từ chính clip còn trên đĩa.
Chữ trên frame là ASCII không dấu — cv2.putText (font Hershey) không vẽ
được tiếng Việt có dấu; phần chữ có dấu sống ở FE/JSON.
Encode hai bước: cv2 VideoWriter mp4v (như P0) rồi ffmpeg re-encode H.264
yuv420p +faststart — mp4v mở được bằng player thường nhưng <video> của
trình duyệt KHÔNG phát được; ffmpeg đã là điều kiện sẵn của luồng video
đa cú (cut_clip). ffmpeg vắng → giữ bản mp4v + warning (player thường
vẫn mở — gate A2.2 vế "ngoài app" vẫn sống).
Import cv2 LƯỜI trong hàm (nếp poolcoach_cv) — venv app import module
này được cho unit test phần thuần.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import numpy as np
from poolcoach_cv.broadcast import BALL_CLASSES, BALL_R_M
TRAIL_N = 90 # số điểm track vẽ đuôi cue ball — như P0
# Màu bi theo số (BGR) — khớp bảng COLORS của FE (app.js) để hai nơi kể
# cùng một câu chuyện màu; None/không nhận ra → xám.
BALL_BGR = {
1: (24, 197, 245), 2: (225, 105, 65), 3: (47, 52, 224),
4: (191, 63, 125), 5: (19, 127, 240), 6: (70, 158, 46),
7: (32, 32, 139), 8: (40, 40, 40), 9: (24, 197, 245),
}
CUE_BGR = (0, 220, 220) # như P0
UNKNOWN_BGR = (180, 180, 180)
TRAIL_BGR = (0, 220, 0)
COLLISION_BGR = (0, 215, 255) # vàng gold — khớp dấu ✕ trên FE
class RecordingModel:
"""Bọc YOLO model: forward nguyên ``predict``, ghi lại per-frame
``[(cls_name, conf, (x1, y1, x2, y2)), ...]`` theo ĐÚNG thứ tự frame
mà analyze_clip đưa vào (batch tuần tự). Chỉ dùng quanh MỘT lần gọi
analyze_clip — model gốc không đổi gì."""
def __init__(self, model) -> None:
self._model = model
self.frames: list[list[tuple]] = []
def predict(self, imgs, **kw):
results = self._model.predict(imgs, **kw)
seq = imgs if isinstance(imgs, (list, tuple)) else [imgs]
for res in results[:len(seq)]:
names = res.names
dets = []
for b in res.boxes:
x1, y1, x2, y2 = (float(v) for v in b.xyxy[0])
dets.append((names[int(b.cls)], float(b.conf),
(x1, y1, x2, y2)))
self.frames.append(dets)
return results
def _uncomp_xy(pts: np.ndarray, height_comp: dict | None) -> np.ndarray:
"""Đảo bù độ cao tâm bi: toạ độ P trong JSON (đã bù) → X' vị trí map
mặt vải — chính là chỗ bi HIỆN RA trên frame khi chiếu ngược qua
homography. Nghịch đảo đóng của camera.HeightCompensation.apply:
X' = P·(1+k) − k·C, k = R/(h−R). Cờ off/thiếu camera → giữ nguyên."""
if not height_comp or not height_comp.get("on"):
return pts
h = height_comp.get("h_m")
cx, cy = height_comp.get("cx_m"), height_comp.get("cy_m")
if h is None or cx is None or cy is None:
return pts
k = BALL_R_M / (h - BALL_R_M)
return np.asarray(pts, dtype=np.float64) * (1.0 + k) \
- k * np.array([cx, cy])
def _reencode_h264(src: Path, dst: Path) -> str | None:
"""mp4v → H.264 yuv420p +faststart để <video> trình duyệt phát được.
Trả None khi OK; message warning khi ffmpeg vắng/hỏng (giữ bản mp4v)."""
exe = shutil.which("ffmpeg")
if exe is None:
os.replace(src, dst)
return ("máy không có ffmpeg — overlay giữ codec mp4v (player "
"thường mở được, trình duyệt có thể không phát)")
try:
r = subprocess.run(
[exe, "-y", "-v", "error", "-i", str(src), "-c:v", "libx264",
"-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
"-movflags", "+faststart", str(dst)],
capture_output=True, text=True, timeout=300)
except subprocess.TimeoutExpired:
os.replace(src, dst)
return "ffmpeg re-encode overlay quá 300s — overlay giữ codec mp4v"
if r.returncode != 0 or not dst.exists():
os.replace(src, dst)
err = (r.stderr or "?").strip().splitlines()[-1][:120]
return f"ffmpeg re-encode overlay lỗi ({err}) — giữ codec mp4v"
src.unlink(missing_ok=True)
return None
def render_shot_overlay(clip_path, corners_px, result: dict,
frames_dets: list[list[tuple]], out_path,
label: str = "", beat=None) -> str | None:
"""Render overlay MỘT cú từ clip còn trên đĩa + detection đã ghi.
``result``: JSON kết quả analyze (track/collisions/height_comp/
suspect_not_shot — toạ độ bàn, t_s tương đối frame đầu clip).
``frames_dets``: từ RecordingModel — index = thứ tự frame.
``label``: chữ đầu header (ASCII), ví dụ "shot 03". ``beat``: callback
gọi định kỳ để giữ heartbeat trong job dài. Trả None khi trơn, message
warning (tiếng Việt) khi có chuyện nhưng file vẫn ra được; ném khi
không ra nổi file.
"""
import cv2
from poolcoach_cv.ballid import identify_balls
from poolcoach_cv.broadcast import TABLE_L_M, TABLE_W_M
from poolcoach_cv.homography import TableHomography, orient_corners
out_path = Path(out_path)
th = TableHomography(orient_corners(np.asarray(corners_px,
dtype=np.float64)),
table_w=TABLE_W_M, table_l=TABLE_L_M)
hc = result.get("height_comp")
track = result.get("track") or []
trail_t = np.array([float(p["t_s"]) for p in track])
trail_px = (th.table_to_px(_uncomp_xy(
np.array([[float(p["x_m"]), float(p["y_m"])] for p in track]), hc))
if track else np.empty((0, 2)))
cols = result.get("collisions") or []
col_t = [float(c["t_s"]) for c in cols]
col_px = (th.table_to_px(_uncomp_xy(
np.array([[float(c["x_m"]), float(c["y_m"])] for c in cols]), hc))
if cols else np.empty((0, 2)))
suspect = bool((result.get("suspect_not_shot") or {}).get("flagged"))
cap = cv2.VideoCapture(str(clip_path))
if not cap.isOpened():
raise ValueError("Không mở lại được clip để render overlay.")
# temp CẠNH file đích (không phải system temp): os.replace cần cùng ổ
# đĩa, và mkstemp giữ fd mở làm Windows khoá file không xoá được
out_path.parent.mkdir(parents=True, exist_ok=True)
tmp = out_path.with_name(out_path.stem + ".raw.mp4")
vw = None
try:
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
n_total = len(frames_dets)
i = -1
t_first = None
while True:
ok, frame = cap.read()
if not ok:
break
i += 1
t = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0
# POS_MSEC đọc SAU read() là PTS frame KẾ — xấp xỉ đủ cho vẽ;
# số đo thật vẫn nằm ở JSON, overlay là lớp mắt người kiểm
if t_first is None:
t_first = t
t_rel = t - t_first
if vw is None:
h, w = frame.shape[:2]
vw = cv2.VideoWriter(str(tmp),
cv2.VideoWriter_fourcc(*"mp4v"),
fps, (w, h))
if beat is not None and i % 32 == 0:
beat()
dets = frames_dets[i] if i < n_total else []
balls = [(cls, conf, box) for cls, conf, box in dets
if cls in BALL_CLASSES]
others = [b for b in balls if b[0] != "Cue"]
cue = next((b for b in balls if b[0] == "Cue"), None)
assigns = []
if others:
try:
assigns, _wb = identify_balls(
frame, [b[2] for b in others],
cue[2] if cue else None)
except Exception: # noqa: BLE001 — màu hỏng ≠ mất overlay
assigns = [(None, 0.0)] * len(others)
# mọi bi: khung màu theo số + nhãn số (design §3.2)
for (cls, _conf, (x1, y1, x2, y2)), (num, _nc) in zip(
others, assigns or [(None, 0.0)] * len(others)):
col = BALL_BGR.get(num, UNKNOWN_BGR)
cv2.rectangle(frame, (int(x1), int(y1)),
(int(x2), int(y2)), col, 2)
if num is not None:
cv2.putText(frame, str(num), (int(x1), int(y1) - 4),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, col, 2)
if cue is not None:
x1, y1, x2, y2 = cue[2]
cv2.rectangle(frame, (int(x1), int(y1)),
(int(x2), int(y2)), CUE_BGR, 2)
# đuôi track cue ball (như P0) — điểm đã đo tới thời điểm này
m = trail_t <= t_rel + 1e-6
pts = trail_px[m][-TRAIL_N:]
if len(pts) >= 2:
cv2.polylines(frame, [pts.astype(np.int32)], False,
TRAIL_BGR, 2)
if len(pts):
cv2.circle(frame, tuple(pts[-1].astype(int)), 14,
TRAIL_BGR, 2)
# chấm sự kiện va chạm — hiện từ lúc xảy ra tới hết clip
for tc, (px, py), c in zip(col_t, col_px, cols):
if t_rel + 1e-6 < tc:
continue
p = (int(px), int(py))
s = 9
cv2.line(frame, (p[0] - s, p[1] - s), (p[0] + s, p[1] + s),
COLLISION_BGR, 2)
cv2.line(frame, (p[0] - s, p[1] + s), (p[0] + s, p[1] - s),
COLLISION_BGR, 2)
cv2.putText(frame, f"{tc:.2f}s {c.get('contact') or ''}",
(p[0] + s + 3, p[1] - 3),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, COLLISION_BGR, 1)
# header trạng thái (ASCII — Hershey không có dấu)
n_seen = int((trail_t <= t_rel + 1e-6).sum())
head = (f"{label} t={t_rel:6.2f}s frame {i + 1}"
f" track {n_seen}/{len(trail_t)}")
if suspect:
head += " [NGHI KHONG PHAI CU DANH]"
cv2.rectangle(frame, (0, 0), (frame.shape[1], 30), (0, 0, 0), -1)
cv2.putText(frame, head, (8, 22), cv2.FONT_HERSHEY_SIMPLEX,
0.6, (0, 0, 255) if suspect else (0, 220, 0), 2)
vw.write(frame)
finally:
if vw is not None:
vw.release()
cap.release()
if vw is None:
tmp.unlink(missing_ok=True)
raise ValueError("Clip không đọc được frame nào để render overlay.")
return _reencode_h264(tmp, out_path)
|