File size: 11,414 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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""P/R/F1 cua PREDICT-pipeline tai conf VAN HANH β€” "so luc deploy" (ban giao 19, 05/08/2026).

Do dung nhung gi CV worker deploy (scripts/cv_worker.py) lam voi MOT anh,
tren 49 anh val pix2pockets:

  1. resize max-dim 1280 INTER_AREA (chep nguyen block cua ``scan_once`` β€”
     anh val 1920x1080 va 3360x2100 deu vuot tran nen buoc nay CO tac dung),
  2. ``model.predict(conf=<opconf.json>, imgsz=640)`` β€” NMS
     ``multi_label=False`` (chi class argmax), dung pipeline worker goi,

roi merge any-ball (Black+Cue+Solid+Striped -> "ball"; Dot LOAI khoi ca
pred lan GT β€” khong phai bi) va match GREEDY voi GT: duyet pred theo conf
GIAM DAN, moi pred lay GT chua-match co IoU cao nhat >= 0.5; pred khong
match = FP, GT thua = FN -> precision / recall / F1 toan split.

MATCHER TU VIET LA DUNG O DAY (khac ban giao 17): lan do can identity voi
val-pipeline nen matcher port bi loai; lan nay muc tieu la do THUOC DEPLOY
β€” khong co reference nao de identity, chi can khai phuong phap ro rang.

HAI CAI THUOC (bai hoc ban giao 17): so o day la P/R/F1 tai MOT diem conf
tren PREDICT-pipeline. KHONG phai AP, KHONG duoc so voi any-ball AP50
0.9383 (val-pipeline, NMS multi_label=True). Cap so hop le duy nhat:
P/R/F1@conf cua val-pipeline (opconf.json) vs P/R/F1@conf o day β€” ky vong
predict-pipeline THAP hon (multi_label=False mat cac box "duoc cuu"; Cue
tung lech -0.20 AP giua hai thuoc).

KHONG tai lap cac khau SAU predict cua worker (homography, kep mep, dedupe
tam ban, cue-dung-1): chung can corners + toa do ban, con GT o day la bbox
pixel β€” do o tang bbox, TRUOC homography.

Guard (BRIEF "Neu bi"): P hoac R < 0.5 -> nghi sai matcher/resize, DUNG
khong ghi so. Neo them: tong GT ball tu file nhan phai khop counters
``gt_kept`` cua opconf.json (563) β€” lech la parse nhan sai, DUNG.

Chay tren venv CV (can ultralytics; GPU nhu worker):

    python scripts/cv/eval_deploy_pr.py --artifact-dir "D:/Khoa luan/cv_full_20260805"

Console ASCII-only (bay cp1252). Ket qua ghi ``opconf_predict.json`` vao
artifact dir, canh ``opconf.json`` de dat hai thuoc canh nhau.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]  # poolcoach-rl/
sys.path.insert(0, str(ROOT / "src"))

DATA_YAML = ROOT / "datasets" / "pix2pockets" / "yolo" / "data.yaml"

BALL_NAMES = ("Black", "Cue", "Solid", "Striped")
EXCLUDE_NAME = "Dot"
IMGSZ = 640      # = cv_worker.IMGSZ β€” cung imgsz train/val/worker
MAX_DIM = 1280   # = cv_worker.MAX_DIM
IOU_THR = 0.5


def resize_like_worker(img, max_dim: int = MAX_DIM):
    """Chep NGUYEN block resize cua cv_worker.scan_once β€” doi o do thi doi o day."""
    import cv2

    h, w = img.shape[:2]
    scale = 1.0
    if max(h, w) > max_dim:
        scale = max_dim / max(h, w)
        img = cv2.resize(img, (round(w * scale), round(h * scale)),
                         interpolation=cv2.INTER_AREA)
    return img, scale


def load_gt_boxes(lbl_path: Path, w: int, h: int,
                  ball_ids: set[int]) -> tuple[list[list[float]], int]:
    """Nhan YOLO txt (class cx cy bw bh, normalized) -> list xyxy pixel tren
    anh DA resize (cung he voi pred). Tra (boxes_ball, n_dot_dropped)."""
    boxes: list[list[float]] = []
    n_dot = 0
    if not lbl_path.exists():
        return boxes, n_dot
    for line in lbl_path.read_text(encoding="utf-8").splitlines():
        parts = line.split()
        if not parts:
            continue
        cid = int(parts[0])
        if cid not in ball_ids:
            n_dot += 1
            continue
        cx, cy, bw, bh = (float(v) for v in parts[1:5])
        boxes.append([(cx - bw / 2) * w, (cy - bh / 2) * h,
                      (cx + bw / 2) * w, (cy + bh / 2) * h])
    return boxes, n_dot


def iou_xyxy(a: list[float], b: list[float]) -> float:
    ix1, iy1 = max(a[0], b[0]), max(a[1], b[1])
    ix2, iy2 = min(a[2], b[2]), min(a[3], b[3])
    iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
    inter = iw * ih
    if inter <= 0.0:
        return 0.0
    area_a = (a[2] - a[0]) * (a[3] - a[1])
    area_b = (b[2] - b[0]) * (b[3] - b[1])
    return inter / (area_a + area_b - inter)


def greedy_match(preds: list[tuple[float, list[float]]],
                 gts: list[list[float]],
                 thr: float = IOU_THR) -> tuple[int, int, int]:
    """Match 1 anh: pred theo conf GIAM DAN, moi pred lay GT chua-match co
    IoU cao nhat >= thr. Tra (tp, fp, fn). Mot GT match toi da MOT lan β€”
    double-detection cung mot bi thanh FP, trung thuc voi detector."""
    used = [False] * len(gts)
    tp = fp = 0
    for _conf, pb in sorted(preds, key=lambda t: -t[0]):
        best_i, best_iou = -1, thr
        for i, gb in enumerate(gts):
            if used[i]:
                continue
            v = iou_xyxy(pb, gb)
            if v >= best_iou:
                best_i, best_iou = i, v
        if best_i >= 0:
            used[best_i] = True
            tp += 1
        else:
            fp += 1
    fn = used.count(False)
    return tp, fp, fn


def main() -> None:
    ap = argparse.ArgumentParser(
        description="P/R/F1 predict-pipeline (deploy ruler) at operating conf")
    ap.add_argument("--artifact-dir", type=Path, required=True,
                    help="chua opconf.json (doc conf + weights); ghi opconf_predict.json")
    ap.add_argument("--weights", type=Path, default=None,
                    help="mac dinh: truong weights cua opconf.json")
    ap.add_argument("--conf", type=float, default=None,
                    help="mac dinh: truong conf cua opconf.json")
    ap.add_argument("--device", default=None,
                    help="mac dinh: khong truyen (model tu chon, nhu worker)")
    args = ap.parse_args()

    import torch
    import yaml
    import cv2
    from ultralytics import YOLO

    opconf = json.loads((args.artifact_dir / "opconf.json").read_text(encoding="utf-8"))
    conf = args.conf if args.conf is not None else float(opconf["conf"])
    weights = args.weights or Path(opconf["weights"])
    predict_kw = {"device": args.device} if args.device else {}
    print(f"[env] torch={torch.__version__} cuda_available={torch.cuda.is_available()}"
          f" conf={conf:g} weights={weights}")

    data = yaml.safe_load(DATA_YAML.read_text(encoding="utf-8"))
    names = {i: n for i, n in enumerate(data["names"])}
    name_to_id = {n: i for i, n in names.items()}
    missing = [n for n in (*BALL_NAMES, EXCLUDE_NAME) if n not in name_to_id]
    if missing:
        sys.exit(f"[FAIL] data.yaml thieu class {missing}")
    ball_ids = {name_to_id[n] for n in BALL_NAMES}
    img_dir = Path(data["path"]) / data["val"]
    lbl_dir = Path(str(img_dir).replace("images", "labels"))
    imgs = sorted(img_dir.glob("*.jpg"))
    if not imgs:
        sys.exit(f"[FAIL] khong thay anh val o {img_dir}")

    model = YOLO(str(weights))
    if dict(model.names) != names:
        sys.exit(f"[ERROR] names checkpoint {model.names} khac data.yaml {names}")

    tot = {"tp": 0, "fp": 0, "fn": 0, "gt_ball": 0, "gt_dot": 0,
           "pred_ball": 0, "pred_dot": 0}
    for p in imgs:
        img = cv2.imread(str(p))
        if img is None:
            sys.exit(f"[FAIL] khong doc duoc anh {p}")
        img, _scale = resize_like_worker(img)
        h, w = img.shape[:2]
        res = model.predict(img, conf=conf, imgsz=IMGSZ, verbose=False,
                            **predict_kw)[0]
        preds: list[tuple[float, list[float]]] = []
        for b in res.boxes:
            if names[int(b.cls)] not in BALL_NAMES:
                tot["pred_dot"] += 1
                continue
            preds.append((float(b.conf), [float(v) for v in b.xyxy[0]]))
        gts, n_dot = load_gt_boxes(
            lbl_dir / (p.stem + ".txt"), w, h, ball_ids)
        tp, fp, fn = greedy_match(preds, gts)
        tot["tp"] += tp
        tot["fp"] += fp
        tot["fn"] += fn
        tot["gt_ball"] += len(gts)
        tot["gt_dot"] += n_dot
        tot["pred_ball"] += len(preds)

    # --- neo: tong GT ball tu file nhan phai khop validator da nghiem thu ---
    ref_gt = opconf.get("counters", {}).get("gt_kept")
    if ref_gt is not None and tot["gt_ball"] != ref_gt:
        sys.exit(f"[FAIL] GT ball tu file nhan = {tot['gt_ball']} khac "
                 f"counters.gt_kept = {ref_gt} cua opconf.json - parse nhan "
                 f"sai, DUNG khong ghi so.")

    prec = tot["tp"] / (tot["tp"] + tot["fp"]) if tot["tp"] + tot["fp"] else 0.0
    rec = tot["tp"] / (tot["tp"] + tot["fn"]) if tot["tp"] + tot["fn"] else 0.0
    f1 = 2 * prec * rec / (prec + rec) if prec + rec else 0.0

    print(f"[deploy-pr] {len(imgs)} anh val; GT ball {tot['gt_ball']} "
          f"(Dot loai {tot['gt_dot']}); pred ball {tot['pred_ball']} "
          f"(pred Dot bo {tot['pred_dot']})")
    print(f"[deploy-pr] TP {tot['tp']}  FP {tot['fp']}  FN {tot['fn']}")
    print(f"[deploy-pr] conf {conf:g}: P {prec:.4f}  R {rec:.4f}  F1 {f1:.4f}"
          f"  (predict-pipeline)")
    print(f"[deploy-pr] val-pipeline cung conf (opconf.json): "
          f"P {opconf['precision']:.4f}  R {opconf['recall']:.4f}  "
          f"F1 {opconf['f1']:.4f}  - HAI CAI THUOC, ky vong predict thap hon")

    # BRIEF "Neu bi": so vo ly la dau hieu sai matcher/resize -> DUNG, bao lai
    if prec < 0.5 or rec < 0.5:
        sys.exit(f"[FAIL] P {prec:.4f} / R {rec:.4f} < 0.5 - nghi sai "
                 f"matcher/resize, DUNG khong ghi opconf_predict.json, "
                 f"bao lai Cowork (BRIEF).")

    out = {
        "date": "2026-08-05",
        "weights": str(weights),
        "split": "val",
        "conf": conf,
        "method": (
            "worker predict-pipeline: resize max-dim 1280 INTER_AREA (chep "
            "block scan_once cua cv_worker.py) + model.predict imgsz=640 "
            "NMS multi_label=False tai conf van hanh; any-ball merge "
            "(Black+Cue+Solid+Striped -> ball), Dot loai ca pred lan GT; "
            "greedy match theo conf giam dan, IoU >= 0.5, moi GT match "
            "toi da 1 lan. Day la P/R/F1 tai MOT diem conf - KHONG phai "
            "AP, KHONG so voi anyball AP50 val-pipeline (hai cai thuoc, "
            "ban giao 17). Khong tai lap cac khau sau predict cua worker "
            "(homography, dedupe tam ban, cue-dung-1)."),
        "predict_pipeline": {"precision": round(prec, 4),
                             "recall": round(rec, 4),
                             "f1": round(f1, 4),
                             "tp": tot["tp"], "fp": tot["fp"], "fn": tot["fn"]},
        "val_pipeline_ref": {"precision": opconf["precision"],
                             "recall": opconf["recall"],
                             "f1": opconf["f1"],
                             "source": "opconf.json (cung conf, val-pipeline)"},
        "counters": {"n_images": len(imgs),
                     "gt_ball": tot["gt_ball"],
                     "gt_dot_excluded": tot["gt_dot"],
                     "pred_ball": tot["pred_ball"],
                     "pred_dot_dropped": tot["pred_dot"]},
        "env": {"torch": torch.__version__,
                "cuda_available": torch.cuda.is_available(),
                "device_arg": args.device},
    }
    out_path = args.artifact_dir / "opconf_predict.json"
    out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
    print(f"[artifact] -> {out_path}")


if __name__ == "__main__":
    main()