"""Bước 2 P0 (BRIEF 11/08/2026): Danh click 4 pocket góc → homography metric. Với mỗi ``shot_XX`` chưa có ``homography.json``: hiện frame ĐẦU của cú, Danh click 4 pocket GÓC theo đúng quy ước ``table_corners()`` — đi vòng quanh bàn, 2 click đầu nằm trên cùng một BĂNG NGẮN (vd: dưới-trái → dưới-phải → trên-phải → trên-trái). Click ngược chiều cũng được — ``orient_corners`` tự đảo (06/08). Điểm click = giao 2 mép băng ở miệng pocket (góc mặt chơi). Mặt chơi bàn 9ft theo BRIEF: 2.54 × 1.27 m → truyền ``table_w=1.27, table_l=2.54`` cho ``TableHomography`` (tái dụng module, KHÔNG sửa src; mặc định pooltool 0.9906 × 1.9812 KHÔNG dùng ở đây). Camera có thể đổi giữa các cú → mỗi cú một bộ click riêng; trong một cảnh coi như camera tĩnh (design §4.3) nên chỉ cần frame đầu. Phím: click trái = chấm điểm · u = xoá điểm cuối · r = chấm lại từ đầu · s = bỏ qua cú (pocket bị che → hỏi Danh chọn cú thay, đừng đoán) · Enter/Space = nhận (khi đủ 4 điểm, có preview top-view để mắt kiểm) · q/Esc = thoát. PHẢI chạy foreground có màn hình (Danh thao tác) — đừng chạy từ shell nền (bẫy BG21). Launcher: run_bb9_click.bat ở D:\\Khoa luan. D:\\Khoa luan\\poolcoach-cv-env\\Scripts\\python.exe \ scripts/broadcast/click_homography.py """ from __future__ import annotations import argparse 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 import numpy as np # noqa: E402 from poolcoach_cv.homography import TableHomography, orient_corners # noqa: E402 PILOT_ROOT = ROOT / "datasets" / "bb9_pilot" # Mặt chơi 9ft chuẩn giải (BRIEF #8): W = chiều NGẮN, L = chiều DÀI. TABLE_W_M = 1.27 TABLE_L_M = 2.54 WIN = "bb9 click homography" ZOOM = 4 # magnifier phong to quanh con tro ZOOM_R = 40 # ban kinh vung goc (px anh goc) dua vao magnifier HELP = ("Click 4 pocket GOC vong quanh ban, 2 click dau = mot BANG NGAN | " "u=undo r=redo s=skip Enter=OK q=thoat") class ClickState: def __init__(self) -> None: self.pts: list[tuple[float, float]] = [] # toa do ANH GOC self.cursor: tuple[int, int] = (0, 0) # toa do anh HIEN THI def _draw(base_disp: np.ndarray, img_full: np.ndarray, st: ClickState, scale: float, msg: str) -> np.ndarray: vis = base_disp.copy() for i, (x, y) in enumerate(st.pts): p = (int(round(x * scale)), int(round(y * scale))) cv2.circle(vis, p, 5, (0, 255, 255), -1) cv2.putText(vis, str(i + 1), (p[0] + 8, p[1] - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2) if len(st.pts) >= 2: poly = np.asarray([[int(round(x * scale)), int(round(y * scale))] for x, y in st.pts], np.int32) cv2.polylines(vis, [poly], len(st.pts) == 4, (0, 255, 255), 1) # Magnifier: crop anh GOC quanh vi tri con tro -> phong to ZOOM lan. cx, cy = st.cursor fx, fy = int(round(cx / scale)), int(round(cy / scale)) h, w = img_full.shape[:2] x0, x1 = max(0, fx - ZOOM_R), min(w, fx + ZOOM_R) y0, y1 = max(0, fy - ZOOM_R), min(h, fy + ZOOM_R) if x1 > x0 and y1 > y0: mag = cv2.resize(img_full[y0:y1, x0:x1], None, fx=ZOOM, fy=ZOOM, interpolation=cv2.INTER_NEAREST) mh, mw = mag.shape[:2] ccx, ccy = int((fx - x0) * ZOOM), int((fy - y0) * ZOOM) cv2.line(mag, (ccx - 12, ccy), (ccx + 12, ccy), (0, 0, 255), 1) cv2.line(mag, (ccx, ccy - 12), (ccx, ccy + 12), (0, 0, 255), 1) cv2.rectangle(mag, (0, 0), (mw - 1, mh - 1), (255, 255, 255), 1) vis[10:10 + mh, vis.shape[1] - mw - 10:vis.shape[1] - 10] = mag cv2.rectangle(vis, (0, 0), (vis.shape[1], 28), (0, 0, 0), -1) cv2.putText(vis, msg, (8, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1) return vis def _preview_topview(img_full: np.ndarray, th: TableHomography) -> np.ndarray: """Warp frame về top-view metric để mắt kiểm 4 click có ra hình bàn không.""" px_per_m = 220 margin = 30 w_px = int(TABLE_W_M * px_per_m) l_px = int(TABLE_L_M * px_per_m) # anh_goc -> ban (m) -> pixel top-view (x sang phai, y XUONG nhu anh) S = np.array([[px_per_m, 0, margin], [0, px_per_m, margin], [0, 0, 1]], dtype=np.float64) M = S @ th.H out = cv2.warpPerspective(img_full, M, (w_px + 2 * margin, l_px + 2 * margin)) cv2.rectangle(out, (margin, margin), (margin + w_px, margin + l_px), (0, 255, 255), 2) cv2.putText(out, "Top-view: mat ban phai vua khit khung vang. Enter=OK r=cham lai", (8, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 255), 1) return out def click_one(shot_dir: Path, display_h: int) -> str: """Trả 'saved' | 'skipped' | 'quit'.""" frames = sorted((shot_dir / "frames").glob("*.jpg")) if not frames: print(f"[WARN] {shot_dir.name}: chua co frames/ — chay extract_shots.py truoc.") return "skipped" img_full = cv2.imread(str(frames[0])) if img_full is None: print(f"[WARN] {shot_dir.name}: khong doc duoc {frames[0].name}.") return "skipped" scale = min(1.0, display_h / img_full.shape[0]) base_disp = cv2.resize(img_full, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) if scale < 1.0 else img_full st = ClickState() def on_mouse(event, x, y, flags, param): st.cursor = (x, y) if event == cv2.EVENT_LBUTTONDOWN and len(st.pts) < 4: st.pts.append((x / scale, y / scale)) cv2.namedWindow(WIN) cv2.setMouseCallback(WIN, on_mouse) mode = "click" # click | preview th = None while True: if mode == "click": msg = f"{shot_dir.name} [{len(st.pts)}/4] {HELP}" cv2.imshow(WIN, _draw(base_disp, img_full, st, scale, msg)) else: cv2.imshow(WIN, _preview_topview(img_full, th)) k = cv2.waitKey(16) & 0xFF if k in (ord("q"), 27): return "quit" if k == ord("s"): print(f"[SKIP] {shot_dir.name} — pocket bi che? Hoi Danh chon cu thay.") return "skipped" if k == ord("u") and mode == "click" and st.pts: st.pts.pop() if k == ord("r"): st.pts.clear() mode = "click" if k in (13, 32): if mode == "click" and len(st.pts) == 4: oriented = orient_corners(np.asarray(st.pts, dtype=np.float64)) try: th = TableHomography(oriented, table_w=TABLE_W_M, table_l=TABLE_L_M) except ValueError as e: print(f"[ERROR] {shot_dir.name}: homography loi: {e}") st.pts.clear() continue mode = "preview" elif mode == "preview": data = { "shot": shot_dir.name, "image": frames[0].name, "clicked_px": [list(p) for p in st.pts], "oriented_px": th.corners_px.tolist(), "table_w_m": TABLE_W_M, "table_l_m": TABLE_L_M, "H": th.H.tolist(), "created_at": datetime.now(timezone.utc).isoformat( timespec="seconds"), } out = shot_dir / "homography.json" out.write_text(json.dumps(data, indent=2), encoding="utf-8") print(f"[OK] {shot_dir.name}: luu {out.name}") return "saved" def main() -> None: ap = argparse.ArgumentParser(description="Click 4 pocket goc -> homography (P0)") ap.add_argument("--root", type=Path, default=PILOT_ROOT) ap.add_argument("--only", type=int, nargs="*", default=None) ap.add_argument("--redo", action="store_true", help="lam lai ca cu da co homography.json") ap.add_argument("--display-height", type=int, default=950, help="chieu cao cua so hien thi (px)") args = ap.parse_args() shots = sorted(d for d in args.root.glob("shot_*") if d.is_dir()) if args.only: want = {f"shot_{i:02d}" for i in args.only} shots = [d for d in shots if d.name in want] todo = [d for d in shots if args.redo or not (d / "homography.json").exists()] if not todo: print("[DONE] Moi cu deu da co homography.json (dung --redo de lam lai).") return print(f"{len(todo)} cu can click. {HELP}") n_saved = n_skip = 0 for d in todo: r = click_one(d, args.display_height) if r == "quit": print("[QUIT] Dung theo yeu cau.") break n_saved += r == "saved" n_skip += r == "skipped" cv2.destroyAllWindows() print(f"[SUMMARY] saved={n_saved} skipped={n_skip} / todo={len(todo)}") if __name__ == "__main__": main()