poolcoach / scripts /cv /demo_scan.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
5.82 kB
"""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()