"""Demo mini end-to-end F2 (Bước 4, BRIEF 05/08/2026): ảnh → bi → toạ độ bàn. Pipeline: ảnh val → YOLO detect → tâm bbox → homography 4 góc → toạ độ bàn (hệ pooltool, m) → in bảng + lưu overlay (bbox, toạ độ, khung bàn) vào thư mục artifact. 4 góc bàn mỗi ảnh: dataset pix2pockets KHÔNG có nhãn góc bàn (chỉ có class Dot = nút kim cương trên thành gỗ) → dùng 4 góc ĐO TAY bằng mắt trên từng ảnh (05/08, zoom 2x + lưới toạ độ; sai số ước ±10-15 px). Quy ước thứ tự góc như `table_corners()`: 2 góc đầu là một BĂNG NGẮN, đi vòng quanh bàn — (0,0) → (W,0) → (W,L) → (0,L). Tâm bbox ≈ tâm bi chiếu xuống mặt bàn: gần đúng — bi có độ cao nên tâm ảnh lệch nhẹ so với điểm chạm nỉ, chấp nhận cho baseline (pix2pockets cũng vậy). Class Dot bị loại khỏi phép chiếu (nút nằm NGOÀI mặt bàn, trên thành gỗ); vẫn vẽ bbox mảnh trên overlay để soi detector. python scripts/cv/demo_scan.py # 3 ảnh demo mặc định python scripts/cv/demo_scan.py --images ... """ from __future__ import annotations import argparse import sys 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 TABLE_L, TABLE_W, TableHomography # noqa: E402 VAL_IMAGES = ROOT / "datasets" / "pix2pockets" / "yolo" / "valid" / "images" DEFAULT_WEIGHTS = ROOT / "runs" / "cv" / "cv_baseline_20260805" / "weights" / "best.pt" DEFAULT_OUT = ROOT.parent / "cv_baseline_20260805" / "demo" # 4 góc mặt bàn (pixel, ảnh gốc 1920x1080) đo tay 05/08/2026 — xem docstring. DEMO_CORNERS: dict[str, list[tuple[float, float]]] = { # Kozoom, nhìn xiên: trái → dưới (băng ngắn đầu bàn), phải, trên "103_png.rf.8837c4ba6e91c59a634fc8cd30cd204b.jpg": [(340, 650), (690, 785), (1550, 495), (1030, 250)], # Predator, nhìn từ sau băng ngắn: dưới-trái → dưới-phải, trên-phải, trên-trái "114_png.rf.98b2144c25b9f48816abd7edb00f365c.jpg": [(420, 790), (1555, 795), (1420, 252), (612, 255)], "130_png.rf.21f92398160a76a64b948884e4c8103c.jpg": [(335, 795), (1545, 790), (1310, 258), (645, 255)], } BALL_CLASSES = {"Black", "Cue", "Solid", "Striped"} # Dot = nút thành gỗ, bỏ chiếu def _resolve_image(token: str) -> Path: p = Path(token) if p.exists(): return p cands = sorted(VAL_IMAGES.glob(token + "*")) if len(cands) == 1: return cands[0] sys.exit(f"[ERROR] Cannot resolve image '{token}' ({len(cands)} matches in val).") def scan_one(model, img_path: Path, corners_px, out_dir: Path, conf: float) -> dict: im = cv2.imread(str(img_path)) if im is None: sys.exit(f"[ERROR] Cannot read {img_path}") th = TableHomography(np.asarray(corners_px, dtype=float)) res = model.predict(im, conf=conf, imgsz=640, verbose=False)[0] names = res.names print(f"\n=== {img_path.name} ===") print(f" {'class':>8} {'conf':>5} {'px':>14} {'table_xy_m':>16} in?") n_ball = n_in = 0 vis = im.copy() cv2.polylines(vis, [np.asarray(corners_px, int).reshape(-1, 1, 2)], True, (0, 255, 255), 2) for i, (x0, y0) in enumerate(np.asarray(corners_px, int)): cv2.circle(vis, (x0, y0), 6, (0, 255, 255), -1) cv2.putText(vis, f"C{i}", (x0 + 8, y0 - 8), 0, 0.6, (0, 255, 255), 2) for b in res.boxes: cls = names[int(b.cls)] x1, y1, x2, y2 = (float(v) for v in b.xyxy[0]) cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0 if cls not in BALL_CLASSES: cv2.rectangle(vis, (int(x1), int(y1)), (int(x2), int(y2)), (140, 140, 140), 1) continue tx, ty = th.px_to_table((cx, cy)) inside = 0.0 <= tx <= TABLE_W and 0.0 <= ty <= TABLE_L n_ball += 1 n_in += inside print(f" {cls:>8} {float(b.conf):5.2f} ({cx:6.1f},{cy:6.1f}) " f"({tx:6.3f},{ty:6.3f})m {'in' if inside else 'OUT'}") color = (0, 200, 0) if inside else (0, 0, 255) cv2.rectangle(vis, (int(x1), int(y1)), (int(x2), int(y2)), color, 2) cv2.putText(vis, f"{cls} ({tx:.2f},{ty:.2f})", (int(x1), int(y1) - 6), 0, 0.55, color, 2) out_dir.mkdir(parents=True, exist_ok=True) out = out_dir / ("scan_" + img_path.name) cv2.imwrite(str(out), vis) print(f" balls in-table: {n_in}/{n_ball} overlay: {out}") return {"image": img_path.name, "balls": n_ball, "in_table": n_in} def main() -> None: ap = argparse.ArgumentParser(description="Mini end-to-end scan demo (detect + homography)") ap.add_argument("--weights", type=Path, default=DEFAULT_WEIGHTS) ap.add_argument("--images", nargs="+", default=sorted(DEMO_CORNERS), help="image files or val-image name prefixes (need corners in DEMO_CORNERS)") ap.add_argument("--out", type=Path, default=DEFAULT_OUT) ap.add_argument("--conf", type=float, default=0.25) args = ap.parse_args() from ultralytics import YOLO model = YOLO(str(args.weights)) stats = [] for token in args.images: img = _resolve_image(token) if img.name not in DEMO_CORNERS: sys.exit(f"[ERROR] No hand-measured corners for {img.name} - add to DEMO_CORNERS.") stats.append(scan_one(model, img, DEMO_CORNERS[img.name], args.out, args.conf)) total_b = sum(s["balls"] for s in stats) total_in = sum(s["in_table"] for s in stats) print(f"\n[G4] {len(stats)} images, balls in-table {total_in}/{total_b}") if __name__ == "__main__": main()