File size: 5,821 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
"""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 <stem-hoặc-tên-file> ...
"""

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()