File size: 7,581 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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
"""Đ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())