File size: 10,289 Bytes
74e3c01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""COCO val2017 mAP eval for split-tower detection heads on frozen EUPE-ViT-B.

Default output is hard NMS (the standard reported in the FCOS / YOLO / DETR
literature). Pass `--soft-nms` to additionally report a soft-NMS comparison
pass on the same forward outputs.

Auto-detects architecture flags from the checkpoint's state_dict:
hidden width, n_std / n_dw tower depths, per_scale_bias, reg_on_raw.

Usage:
    python eval_coco_map.py --picker <ckpt.pth>
    python eval_coco_map.py --picker <ckpt.pth> --soft-nms
"""
import argparse
import os
import re
import sys
import time

os.environ.setdefault("COCO_TEXT_EMBED_PATH", "/mnt/d/detection-heads/_diag_text_embed_vitb32.pt")

import torch
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from train_split_tower_5scale import SplitTowerHead, make_locations, STRIDES

DEVICE = "cuda"
COCO_ROOT = os.environ["ARENA_COCO_ROOT"]
VAL_CACHE = os.environ["ARENA_VAL_CACHE"]
RESOLUTION = 640
H = RESOLUTION // 16
NUM_CLASSES = 80
SCORE_THRESH = 0.05
MAX_PER_IMAGE = 100


def hard_nms_per_class(boxes, scores, labels, iou_thresh=0.5):
    """Per-class hard NMS via torchvision.ops.nms. Returns indices into input."""
    from torchvision.ops import nms
    keep = []
    for c in labels.unique():
        m = labels == c
        idx = m.nonzero(as_tuple=True)[0]
        k = nms(boxes[m], scores[m], iou_thresh)
        keep.append(idx[k])
    return torch.cat(keep) if keep else torch.tensor([], dtype=torch.long, device=boxes.device)


def soft_nms_linear(boxes, scores, iou_thresh=0.5, min_score=0.001):
    """GPU-vectorized linear-decay soft NMS (Bodla et al. 2017).
    Iteratively picks the max-score box, multiplicatively decays overlapping
    scores by (1 - IoU), repeats until the max falls below min_score."""
    if boxes.numel() == 0:
        return torch.tensor([], dtype=torch.long, device=boxes.device), scores.clone()
    N = boxes.shape[0]
    scores = scores.clone()
    areas = (boxes[:, 2] - boxes[:, 0]).clamp(min=0) * (boxes[:, 3] - boxes[:, 1]).clamp(min=0)
    active = torch.ones(N, dtype=torch.bool, device=boxes.device)
    kept_idx, kept_scores = [], []
    while active.any():
        masked = torch.where(active, scores, torch.full_like(scores, -float("inf")))
        i = int(masked.argmax())
        if scores[i] < min_score:
            break
        kept_idx.append(i); kept_scores.append(scores[i].item())
        active[i] = False
        box_i = boxes[i]
        x1 = torch.maximum(box_i[0], boxes[:, 0]); y1 = torch.maximum(box_i[1], boxes[:, 1])
        x2 = torch.minimum(box_i[2], boxes[:, 2]); y2 = torch.minimum(box_i[3], boxes[:, 3])
        inter = (x2 - x1).clamp(min=0) * (y2 - y1).clamp(min=0)
        iou = inter / (areas[i] + areas - inter).clamp(min=1e-6)
        decay = torch.where(iou > iou_thresh, 1 - iou, torch.ones_like(iou))
        scores = scores * decay
    if not kept_idx:
        return (torch.tensor([], dtype=torch.long, device=boxes.device),
                torch.tensor([], device=boxes.device))
    return (torch.tensor(kept_idx, dtype=torch.long, device=boxes.device),
            torch.tensor(kept_scores, device=boxes.device))


def soft_nms_per_class(boxes, scores, labels, iou_thresh=0.5):
    keep_idx, keep_scores = [], []
    for c in labels.unique():
        m = labels == c
        idx = m.nonzero(as_tuple=True)[0]
        k, rescored = soft_nms_linear(boxes[m], scores[m], iou_thresh)
        keep_idx.append(idx[k]); keep_scores.append(rescored)
    if not keep_idx:
        return (torch.tensor([], dtype=torch.long, device=boxes.device),
                torch.tensor([], device=boxes.device))
    return torch.cat(keep_idx), torch.cat(keep_scores)


def autodetect_and_load(ckpt_path):
    ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
    sd = ckpt["head"] if isinstance(ckpt, dict) and "head" in ckpt else ckpt
    per_scale_bias = (sd["cls_bias"].shape == (5, 80))
    reg_on_raw = any("reg_stem" in k for k in sd.keys())
    hidden = sd["stem.weight"].shape[0]
    tower_idxs = sorted({int(m.group(1)) for k in sd if (m := re.match(r"cls_tower\.(\d+)\.", k))})
    n_std = sum(1 for i in tower_idxs if f"cls_tower.{i}.conv.weight" in sd)
    n_dw = len(tower_idxs) - n_std
    return sd, hidden, n_std, n_dw, per_scale_bias, reg_on_raw


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--picker", required=True, help="Path to picker .pth checkpoint")
    parser.add_argument("--soft-nms", action="store_true",
                        help="Also run a soft-NMS pass and report both. Default is hard NMS only.")
    args = parser.parse_args()

    t_start = time.time()
    print(f"[{time.time()-t_start:6.1f}s] START  pid={os.getpid()}", flush=True)
    print(f"[{time.time()-t_start:6.1f}s] picker={args.picker}  soft_nms={args.soft_nms}", flush=True)

    print(f"[{time.time()-t_start:6.1f}s] Inspecting checkpoint ...", flush=True)
    sd, hidden, n_std, n_dw, per_scale_bias, reg_on_raw = autodetect_and_load(args.picker)
    print(f"[{time.time()-t_start:6.1f}s] Arch: hidden={hidden} n_std={n_std} n_dw={n_dw} "
          f"per_scale_bias={per_scale_bias} reg_on_raw={reg_on_raw}", flush=True)

    print(f"[{time.time()-t_start:6.1f}s] Constructing head ...", flush=True)
    head = SplitTowerHead(hidden=hidden, n_std_layers=n_std, n_dw_layers=n_dw, n_scales=4,
                          reg_on_raw=reg_on_raw, per_scale_bias=per_scale_bias).to(DEVICE)
    head.load_state_dict(sd); head.eval()
    print(f"[{time.time()-t_start:6.1f}s] Head loaded: {sum(p.numel() for p in head.parameters()):,} params", flush=True)

    print(f"[{time.time()-t_start:6.1f}s] Loading val cache ...", flush=True)
    val = torch.load(VAL_CACHE, map_location="cpu", weights_only=False)
    from pycocotools.coco import COCO
    from pycocotools.cocoeval import COCOeval
    coco_gt = COCO(os.path.join(COCO_ROOT, "annotations", "instances_val2017.json"))
    cat_ids = sorted(coco_gt.getCatIds())
    idx_to_cat = {i: c for i, c in enumerate(cat_ids)}
    print(f"[{time.time()-t_start:6.1f}s] Val: {len(val)} items, {len(cat_ids)} categories", flush=True)

    feat_sizes = [(H*2, H*2), (H, H), (H//2, H//2), (H//4, H//4), (H//8, H//8)]
    all_locs = torch.cat(make_locations(feat_sizes, STRIDES, torch.device(DEVICE)))

    print(f"[{time.time()-t_start:6.1f}s] ==================== INFERENCE START ====================", flush=True)
    results_hard = []
    results_soft = [] if args.soft_nms else None
    t0 = time.time()
    PRINT_EVERY = 500

    with torch.no_grad():
        for idx, item in enumerate(val):
            spatial = item["spatial"].unsqueeze(0).float().to(DEVICE)
            img_id = int(item["img_id"]); scale = item["scale"]
            cls_l, reg_l, ctr_l = head(spatial)

            cls_s = torch.cat([c.permute(0,2,3,1).reshape(-1, NUM_CLASSES) for c in cls_l]).sigmoid()
            reg_s = torch.cat([r.permute(0,2,3,1).reshape(-1, 4) for r in reg_l])
            ctr_s = torch.cat([c.permute(0,2,3,1).reshape(-1) for c in ctr_l]).sigmoid()
            scores_all = cls_s * ctr_s.unsqueeze(1)

            mask = scores_all > SCORE_THRESH
            if not mask.any():
                if (idx+1) % PRINT_EVERY == 0:
                    elapsed = time.time()-t0; rate = (idx+1)/elapsed
                    print(f"[{time.time()-t_start:6.1f}s]   img {idx+1}/{len(val)} | "
                          f"hard={len(results_hard):,}"
                          + (f" soft={len(results_soft):,}" if args.soft_nms else "")
                          + f" | {rate:.1f} img/s | ETA {(len(val)-idx-1)/rate:.0f}s", flush=True)
                continue

            loc_idx, cls_idx = mask.nonzero(as_tuple=True)
            sc = scores_all[loc_idx, cls_idx]
            xy = all_locs[loc_idx]; rr = reg_s[loc_idx]
            x1 = (xy[:,0]-rr[:,0]).clamp(0, RESOLUTION); y1 = (xy[:,1]-rr[:,1]).clamp(0, RESOLUTION)
            x2 = (xy[:,0]+rr[:,2]).clamp(0, RESOLUTION); y2 = (xy[:,1]+rr[:,3]).clamp(0, RESOLUTION)
            boxes = torch.stack([x1, y1, x2, y2], dim=-1)

            keep_hard = hard_nms_per_class(boxes, sc, cls_idx, iou_thresh=0.5)
            _append_dets(results_hard, boxes, sc[keep_hard], keep_hard, cls_idx, scale, idx_to_cat, img_id)

            if args.soft_nms:
                keep_soft, sc_soft = soft_nms_per_class(boxes, sc, cls_idx, iou_thresh=0.5)
                _append_dets(results_soft, boxes, sc_soft, keep_soft, cls_idx, scale, idx_to_cat, img_id)

            if (idx+1) % PRINT_EVERY == 0:
                elapsed = time.time()-t0; rate = (idx+1)/elapsed
                msg = (f"[{time.time()-t_start:6.1f}s]   img {idx+1}/{len(val)} | "
                       f"hard={len(results_hard):,}")
                if args.soft_nms:
                    msg += f" soft={len(results_soft):,}"
                msg += f" | n_pos={mask.sum().item()} | {rate:.1f} img/s | ETA {(len(val)-idx-1)/rate:.0f}s"
                print(msg, flush=True)

    print(f"\n[{time.time()-t_start:6.1f}s] Inference done in {time.time()-t0:.0f}s", flush=True)

    passes = [("HARD NMS", results_hard)]
    if args.soft_nms:
        passes.append(("SOFT NMS", results_soft))
    for label, results in passes:
        print(f"\n{'='*40}\n{label}\n{'='*40}", flush=True)
        coco_dt = coco_gt.loadRes(results)
        ev = COCOeval(coco_gt, coco_dt, "bbox")
        ev.params.imgIds = sorted(coco_gt.getImgIds())[:len(val)]
        ev.evaluate(); ev.accumulate(); ev.summarize()


def _append_dets(sink, boxes, scores_kept, keep, cls_idx, scale, idx_to_cat, img_id):
    if keep.numel() == 0:
        return
    if scores_kept.numel() > MAX_PER_IMAGE:
        top = scores_kept.topk(MAX_PER_IMAGE)
        scores_kept = top.values; keep = keep[top.indices]
    bx = boxes[keep] / scale
    w = (bx[:,2]-bx[:,0]).clamp(min=0); h = (bx[:,3]-bx[:,1]).clamp(min=0)
    for i in range(keep.numel()):
        s = scores_kept[i].item()
        if s < SCORE_THRESH:
            continue
        sink.append({"image_id": img_id,
                     "category_id": idx_to_cat[cls_idx[keep[i]].item()],
                     "bbox": [bx[i,0].item(), bx[i,1].item(), w[i].item(), h[i].item()],
                     "score": s})


if __name__ == "__main__":
    main()