Spaces:
Sleeping
Sleeping
| """Eval BallID trên ảnh thật (Bước 4 BRIEF 06/08) — báo cáo, KHÔNG gate cứng. | |
| Chạy pipeline detect + BallID thật (venv `poolcoach-cv-env`) trên các ảnh | |
| smoke bàn giao 18 (3 ảnh val có góc đo tay trong `demo_scan.DEMO_CORNERS`), | |
| xuất: | |
| 1. **Contact-sheet crop đánh INDEX** (`ballid_sheet_<ảnh>.png`) — mỗi bi một | |
| ô, chỉ ghi index, KHÔNG ghi số model đoán (Danh gán GT "mù", không bị | |
| mồi bởi prediction); | |
| 2. **GT mẫu** (`ballid_gt.json`, số để trống) — Danh điền tay số 1–9 theo | |
| contact-sheet (0 = không phải bi / không nhìn ra); file ĐÃ tồn tại thì | |
| không bao giờ ghi đè; | |
| 3. **Prediction** (`ballid_pred.json`) — số model gán + conf + commit hash | |
| lúc chạy, để chấm được cả khi checkout khác. | |
| Có GT (≥1 ô điền) → chấm luôn: accuracy theo bi / theo ảnh + confusion các | |
| cặp lẫn. Cách đọc số chốt TRƯỚC ở design §4: ≥7/9 đúng/ảnh = dùng được sau | |
| van an toàn; lẫn {4↔7↔3, 1↔9} là kỳ vọng; <5/9 = tầng màu thất bại. KHÔNG | |
| chỉnh palette/ngưỡng sau khi thấy số — đó là vòng 2, Cowork quyết. | |
| Không cần homography: BallID chỉ đọc MÀU trong crop, không cần toạ độ bàn | |
| (chiếu bàn đã đo riêng ở demo_scan bàn giao 18). Chọn bi như cv_worker: | |
| lọc class bi, dedupe tâm trùng, giữ 1 cue conf cao nhất (cue thừa hạ xuống | |
| bi thường), rồi identify_balls với WB theo cue. | |
| D:\\Khoa luan\\poolcoach-cv-env\\Scripts\\python.exe scripts\\cv\\eval_ballid.py | |
| Launcher: D:\\Khoa luan\\run_eval_ballid.bat (ngoài repo, như mọi launcher). | |
| In console ASCII thuần (bẫy cp1252 — BRIEF #4); tiếng Việt chỉ nằm trong | |
| file JSON (utf-8). | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import subprocess | |
| import sys | |
| from collections import Counter | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parents[2] # poolcoach-rl/ | |
| sys.path.insert(0, str(ROOT / "src")) | |
| sys.path.insert(0, str(Path(__file__).resolve().parent)) # demo_scan cùng chỗ | |
| import cv2 # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| from demo_scan import DEMO_CORNERS, VAL_IMAGES # noqa: E402 — ảnh smoke 18 | |
| from poolcoach_cv.ballid import identify_balls # noqa: E402 | |
| DEFAULT_WEIGHTS = Path(r"D:\Khoa luan\cv_full_20260805\best.pt") # = cv_worker | |
| OP_CONF = 0.2993 # đồng bộ cv_worker.OP_CONF (pick_conf 05/08) | |
| DEFAULT_OUT = ROOT.parent / "cv_full_20260805" / "ballid" | |
| BALL_CLASSES = {"Black", "Cue", "Solid", "Striped"} # như cv_worker, Dot loại | |
| TILE = 96 # cạnh ô contact-sheet (px) | |
| DEDUP_FRAC = 0.6 # 2 tâm gần hơn 0.6×cỡ bbox = double-detect, giữ conf cao | |
| def _git_head() -> str: | |
| try: | |
| return subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], | |
| capture_output=True, text=True, check=True).stdout.strip() | |
| except Exception: # noqa: BLE001 — thiếu git không được chặn eval | |
| return "unknown" | |
| def detect_balls(model, im: np.ndarray, conf: float) -> tuple[list[dict], dict | None]: | |
| """YOLO → (bi thường đã dedupe, bi cue) — nhại đúng nếp chọn của cv_worker: | |
| lọc class bi, dedupe tâm trùng theo conf giảm dần, cue lấy ĐÚNG 1 con | |
| conf cao nhất, cue thừa hạ xuống bi thường.""" | |
| res = model.predict(im, conf=conf, imgsz=640, verbose=False)[0] | |
| names = res.names | |
| cands = [] | |
| for b in res.boxes: | |
| cls = names[int(b.cls)] | |
| if cls not in BALL_CLASSES: | |
| continue | |
| x1, y1, x2, y2 = (float(v) for v in b.xyxy[0]) | |
| cands.append({"cls": cls, "conf": float(b.conf), "box": (x1, y1, x2, y2), | |
| "cx": (x1 + x2) / 2, "cy": (y1 + y2) / 2, | |
| "size": ((x2 - x1) + (y2 - y1)) / 2}) | |
| kept: list[dict] = [] | |
| for c in sorted(cands, key=lambda c: -c["conf"]): | |
| thr = DEDUP_FRAC * c["size"] | |
| if any(np.hypot(c["cx"] - k["cx"], c["cy"] - k["cy"]) < thr for k in kept): | |
| continue | |
| kept.append(c) | |
| cue = None | |
| others = [] | |
| for c in kept: | |
| if c["cls"] == "Cue" and cue is None: | |
| cue = c | |
| else: | |
| others.append(c) # cue thừa rơi vào đây như worker | |
| return others, cue | |
| def make_sheet(im: np.ndarray, others: list[dict], out_png: Path) -> None: | |
| """Contact-sheet 1 hàng: crop bbox NGUYÊN (không co 60% — Danh cần ngữ | |
| cảnh nhận bi), đánh index, KHÔNG in số model đoán (GT mù).""" | |
| tiles = [] | |
| for i, c in enumerate(others): | |
| x1, y1, x2, y2 = (int(round(v)) for v in c["box"]) | |
| crop = im[max(0, y1):y2, max(0, x1):x2] | |
| if crop.size == 0: | |
| crop = np.zeros((8, 8, 3), np.uint8) | |
| tile = cv2.resize(crop, (TILE, TILE), interpolation=cv2.INTER_NEAREST) | |
| canvas = np.full((TILE + 26, TILE, 3), 32, np.uint8) | |
| canvas[26:] = tile | |
| cv2.putText(canvas, f"#{i}", (6, 19), 0, 0.6, (255, 255, 255), 2) | |
| tiles.append(canvas) | |
| sheet = cv2.hconcat(tiles) if tiles else np.zeros((TILE, TILE, 3), np.uint8) | |
| out_png.parent.mkdir(parents=True, exist_ok=True) | |
| cv2.imwrite(str(out_png), sheet) | |
| def score(gt: dict, pred: dict) -> None: | |
| """Chấm khi GT có ô điền: accuracy theo bi / theo ảnh + confusion.""" | |
| per_image = {} | |
| n_ok = n_all = 0 | |
| confusion: Counter[tuple[str, str]] = Counter() | |
| for img, balls in pred["images"].items(): | |
| gt_img = gt.get(img, {}) | |
| ok = tot = 0 | |
| for idx, p in balls.items(): | |
| if idx.startswith("_"): # "_wb" — metadata, không phải bi | |
| continue | |
| g = gt_img.get(idx) | |
| if not isinstance(g, int) or not 1 <= g <= 9: | |
| continue # trống / 0 = ngoài chấm | |
| tot += 1 | |
| if p["number"] == g: | |
| ok += 1 | |
| else: | |
| confusion[(str(g), str(p["number"]))] += 1 | |
| if tot: | |
| per_image[img] = (ok, tot) | |
| n_ok += ok | |
| n_all += tot | |
| if not n_all: | |
| print("[score] GT trong — chua cham duoc (Danh dien ballid_gt.json roi chay lai).") | |
| return | |
| print(f"\n[score] accuracy theo bi: {n_ok}/{n_all} = {n_ok / n_all:.3f}") | |
| for img, (ok, tot) in per_image.items(): | |
| print(f" {img[:40]:42} {ok}/{tot}") | |
| if confusion: | |
| print("[score] confusion (GT -> pred, so lan):") | |
| for (g, p), n in confusion.most_common(): | |
| print(f" {g} -> {p}: {n}") | |
| def main() -> None: | |
| ap = argparse.ArgumentParser(description="BallID eval on handover-18 smoke images") | |
| ap.add_argument("--weights", type=Path, default=DEFAULT_WEIGHTS) | |
| ap.add_argument("--conf", type=float, default=OP_CONF) | |
| ap.add_argument("--out", type=Path, default=DEFAULT_OUT) | |
| args = ap.parse_args() | |
| from ultralytics import YOLO | |
| model = YOLO(str(args.weights)) | |
| head = _git_head() | |
| pred = {"meta": {"weights": str(args.weights), "conf": args.conf, | |
| "commit": head}, "images": {}} | |
| gt_path = args.out / "ballid_gt.json" | |
| gt_template: dict = { | |
| "_huong_dan": "Dien SO 1-9 cho tung index theo contact-sheet cung ten; " | |
| "0 = khong phai bi / khong nhin ra; de null = bo qua. " | |
| "Nguoi dien: Danh (khong phai model).", | |
| } | |
| for name in sorted(DEMO_CORNERS): | |
| im = cv2.imread(str(VAL_IMAGES / name)) | |
| if im is None: | |
| sys.exit(f"[ERROR] Cannot read smoke image {name}") | |
| others, cue = detect_balls(model, im, args.conf) | |
| assigns, wb = identify_balls(im, [c["box"] for c in others], | |
| cue["box"] if cue else None) | |
| # tên ảnh val dạng "103_png.rf.<hash>.jpg" — lấy mỗi số đầu cho gọn | |
| make_sheet(im, others, args.out / f"ballid_sheet_{name.split('_')[0]}.png") | |
| pred["images"][name] = { | |
| str(i): {"number": num, "number_conf": conf, | |
| "det_cls": c["cls"], "det_conf": round(c["conf"], 3)} | |
| for i, (c, (num, conf)) in enumerate(zip(others, assigns)) | |
| } | |
| pred["images"][name]["_wb"] = wb | |
| gt_template[name] = {str(i): None for i in range(len(others))} | |
| nums = [a[0] for a in assigns] | |
| print(f"{name[:40]:42} balls={len(others)} cue={'y' if cue else 'n'} " | |
| f"wb={'y' if wb else 'n'} numbers={nums}") | |
| args.out.mkdir(parents=True, exist_ok=True) | |
| (args.out / "ballid_pred.json").write_text( | |
| json.dumps(pred, ensure_ascii=False, indent=2), encoding="utf-8") | |
| if gt_path.exists(): | |
| print(f"[gt] {gt_path} da ton tai — KHONG ghi de (giu cong suc dien tay).") | |
| else: | |
| gt_path.write_text(json.dumps(gt_template, ensure_ascii=False, indent=2), | |
| encoding="utf-8") | |
| print(f"[gt] mau GT (so de trong) -> {gt_path}") | |
| # per-ball "number" trong pred so voi GT — chay duoc nhieu lan, cham khi co GT | |
| gt = json.loads(gt_path.read_text(encoding="utf-8")) if gt_path.exists() else {} | |
| score(gt, pred) | |
| print(f"\n[done] artifacts -> {args.out} (commit {head})") | |
| if __name__ == "__main__": | |
| main() | |