"""Đo gợi ý 4 góc bàn tự động vs corner CHẤM TAY đã lưu (gate A.1, 14/08/2026). Phép đo đầu tiên của ``poolcoach_cv.autocorner`` — BRIEF 14/08 không đặt ngưỡng phải "đạt", chỉ yêu cầu số thật: **median + max lệch px per-corner** và **tỉ lệ ca qua van sanity**. Mốc tự đặt để đọc kết quả: median > ~15px thì kết luận "gợi ý chưa đáng tin" thay vì ship im lặng. Nguồn chuẩn (corner tay CÓ THẬT trên đĩa, không dựng lại): 1. ``datasets/bb9_pilot/shot_XX/`` — mỗi cú một ``homography.json`` (``oriented_px``, Danh chấm 11/08 bằng ``click_homography.py``) kèm đúng frame đã chấm ``frames/00000.jpg``. 10 cú. 2. VOD ``datasets/bb9_pilot/rack_a1/rack_5245_5805.mp4`` — corner tay là bộ đã dùng THẬT cho run A2b ``c9fda71c`` (chép từ ``scripts/experiments/run_analyzer_a2b_vod.bat``, làm tròn 0.1px của ``shot_01``). Đây là rack KHÁC shot_01 nên bộ này là corner "dùng lại" chứ không phải chấm riêng — ghi rõ để đừng đọc nhầm nó là chuẩn vàng. Clip KHÔNG có corner tay (``--clips``) chỉ đóng góp **tỉ lệ qua van**, không vào thống kê lệch — không có chuẩn thì không có lệch để đo. Chạy (venv nào cũng được, chỉ cần cv2 + numpy): python scripts/broadcast/eval_autocorner.py python scripts/broadcast/eval_autocorner.py --clips "D:/Khoa luan/cu*.mp4" Ghi CSV ra workspace (nếp bb9_*): ``autocorner_20260814.csv``. """ from __future__ import annotations import argparse import csv import glob import json import statistics import sys from pathlib import Path import numpy as np ROOT = Path(__file__).resolve().parents[2] if str(ROOT / "src") not in sys.path: sys.path.insert(0, str(ROOT / "src")) from poolcoach_cv.autocorner import (corner_errors_px, # noqa: E402 suggest_corners, suggest_from_video) from poolcoach_cv.homography import orient_corners # noqa: E402 WORKSPACE = ROOT.parent PILOT = ROOT / "datasets" / "bb9_pilot" VOD = PILOT / "rack_a1" / "rack_5245_5805.mp4" # Chép nguyên từ run_analyzer_a2b_vod.bat (run c9fda71c) — xem docstring. VOD_CORNERS = [[527.5, 869.7], [1380.1, 864.0], [1232.3, 278.5], [673.0, 272.8]] def _rows_pilot() -> list[dict]: out = [] for d in sorted(PILOT.glob("shot_*")): hj, frame = d / "homography.json", d / "frames" / "00000.jpg" if not (hj.exists() and frame.exists()): continue truth = json.loads(hj.read_text(encoding="utf-8"))["oriented_px"] out.append({"case": d.name, "kind": "pilot", "frame": frame, "truth": truth}) return out def _read_image(path: Path): import cv2 img = cv2.imread(str(path), cv2.IMREAD_COLOR) if img is None: raise SystemExit(f"không đọc được ảnh {path}") return img def _eval_case(row: dict) -> dict: if row["kind"] == "video": res = suggest_from_video(row["frame"]) else: res = suggest_corners(_read_image(row["frame"])) rec = {"case": row["case"], "kind": row["kind"], "ok": res["ok"], "reason": res["reason"] or "", "hue": (res["cloth"] or {}).get("hue"), "area_frac": (res["cloth"] or {}).get("area_frac"), "quad_fill": (res["cloth"] or {}).get("quad_fill"), "touches_border": (res["cloth"] or {}).get("touches_border"), "cam_h_m": (res["camera"] or {}).get("h_m"), "cam_f_px": (res["camera"] or {}).get("f_px"), "asym_pair0": (res["rails"] or {}).get("asym_pair0"), "asym_pair1": (res["rails"] or {}).get("asym_pair1"), "swapped": (res["rails"] or {}).get("swapped"), "corners": json.dumps(res["corners"])} truth = row.get("truth") if res["ok"] and truth is not None: t = orient_corners(np.asarray(truth, dtype=np.float64)) errs, rot = corner_errors_px(res["corners"], t) # xoay 1 nhịp khớp hơn HẲN ⇒ máy gán nhầm cặp băng ngắn (lỗi nặng # nhất của tầng này) — bắt riêng, không nuốt vào phép khớp alt = min( float(np.linalg.norm(np.roll(np.asarray(res["corners"]), -r, axis=0) - t, axis=1).mean()) for r in (1, 3)) rec.update(err_px=[round(e, 1) for e in errs], err_max=round(max(errs), 1), err_mean=round(sum(errs) / 4, 1), rot=rot, pairing_suspect=bool(alt < sum(errs) / 4)) return rec def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--clips", default="", help="glob video KHÔNG có corner tay — chỉ tính tỉ lệ van") ap.add_argument("--out", default=str(WORKSPACE / "autocorner_20260814.csv")) a = ap.parse_args() rows = _rows_pilot() if VOD.exists(): rows.append({"case": "rack_a1(VOD)", "kind": "video", "frame": VOD, "truth": VOD_CORNERS}) for p in sorted(glob.glob(a.clips)) if a.clips else []: rows.append({"case": Path(p).stem, "kind": "video", "frame": Path(p)}) if not rows: raise SystemExit("không có ca nào để đo — kiểm datasets/bb9_pilot") recs = [_eval_case(r) for r in rows] fields = ["case", "kind", "ok", "reason", "err_px", "err_mean", "err_max", "rot", "pairing_suspect", "cam_h_m", "cam_f_px", "hue", "area_frac", "quad_fill", "touches_border", "asym_pair0", "asym_pair1", "swapped", "corners"] with open(a.out, "w", encoding="utf-8-sig", newline="") as f: w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore") w.writeheader() for r in recs: w.writerow(r) print(f"{'ca':<16}{'van':<5}{'lech px per-corner':<30}" f"{'max':>7}{'h_m':>8}{'f_px':>8} ghi chu") print("-" * 92) for r in recs: ep = ("" if "err_px" not in r else " ".join(f"{v:.0f}" for v in r["err_px"])) note = r["reason"] if not r["ok"] else ( "GAN NHAM CAP BANG NGAN?" if r.get("pairing_suspect") else ("xoay 180 do" if r.get("rot") else "")) cell = lambda k: ("" if r.get(k) is None else str(r[k])) # noqa: E731 print(f"{r['case']:<16}{'OK' if r['ok'] else 'CHAN':<5}{ep:<30}" f"{cell('err_max'):>7}{cell('cam_h_m'):>8}" f"{cell('cam_f_px'):>8} {note}") n_ok = sum(1 for r in recs if r["ok"]) per_corner = [e for r in recs for e in r.get("err_px", [])] print("-" * 92) print(f"qua van sanity: {n_ok}/{len(recs)} ca " f"({n_ok / len(recs):.0%})") if per_corner: n_gt = sum(1 for r in recs if "err_px" in r) print(f"lech px PER-CORNER tren {n_gt} ca co corner tay " f"({len(per_corner)} goc): median " f"{statistics.median(per_corner):.1f}px · trung binh " f"{statistics.fmean(per_corner):.1f}px · max " f"{max(per_corner):.1f}px") worst = max((r for r in recs if "err_max" in r), key=lambda r: r["err_max"]) print(f"ca lech nhat: {worst['case']} (max {worst['err_max']}px)") n_pair = sum(1 for r in recs if r.get("pairing_suspect")) print(f"nghi gan nham cap bang ngan: {n_pair} ca") print(f"CSV: {a.out}") return 0 if __name__ == "__main__": sys.exit(main())