Chris Leo commited on
Commit
eee67f0
·
verified ·
1 Parent(s): cf3e9bc

scorevision: push artifact

Browse files
Files changed (1) hide show
  1. miner.py +431 -418
miner.py CHANGED
@@ -1,19 +1,5 @@
1
- """manak0/Detect-crime miner — 3-view TTA + Weighted Box Fusion.
2
-
3
- Same interface as king's miner.py (Miner.predict_batch returns list[TVFrameResult]).
4
- Differences vs king:
5
- • 3 forward views: orig 1280, hflip 1280, zoomed 1408 (re-letterboxed)
6
- • WBF fusion across views (better than score-tier consensus for recall)
7
- • Per-class confidence thresholds (calibrated on SAM3-distilled val)
8
- • Same per-class NMS at IoU=0.35 + same sane-box filters as king
9
-
10
- ONNX expected: input `images [1,3,H,W]`, output `[1, max_det, 6]` = (x1,y1,x2,y2,score,cls).
11
- """
12
- from __future__ import annotations
13
-
14
- import math
15
  from pathlib import Path
16
- from typing import Any
17
 
18
  import cv2
19
  import numpy as np
@@ -22,34 +8,6 @@ from numpy import ndarray
22
  from pydantic import BaseModel
23
 
24
 
25
- CRIME_CLASSES = ["balaclava", "hoodie", "glove", "bat", "spray paint", "graffiti"]
26
-
27
- # Per-class confidence thresholds. Calibrated against the REAL validator
28
- # challenge images (133 images pulled from public R2 response shards,
29
- # scripts/05_calibrate_thresholds.py --val-dir dataset/real_chal/val).
30
- # v2 fine-tuned on web+real_chal mix; this calibration on real_chal val:
31
- # composite=0.571, map50=0.758, FP/img=1.09, fp_pillar=0.891.
32
- # Compare: king on same val=0.233, coolroman=0.271.
33
- PER_CLASS_CONF = np.array([
34
- 0.400, # balaclava
35
- 0.500, # hoodie
36
- 0.450, # glove
37
- 0.325, # bat
38
- 0.325, # spray paint
39
- 0.500, # graffiti
40
- ], dtype=np.float32)
41
-
42
- # Per-class aspect-ratio constraint: keep box only if height/width >= ratio.
43
- PER_CLASS_MIN_H_OVER_W: dict[int, float] = {}
44
-
45
- # WBF parameters
46
- WBF_IOU_THR = 0.55
47
- WBF_SKIP_BOX_THR = 0.05 # below this, treat as no signal even pre-fusion
48
-
49
- # View weights (orig, hflip, zoomed-1408)
50
- VIEW_WEIGHTS = np.array([1.0, 1.0, 0.85], dtype=np.float32)
51
-
52
-
53
  class BoundingBox(BaseModel):
54
  x1: int
55
  y1: int
@@ -65,437 +23,492 @@ class TVFrameResult(BaseModel):
65
  keypoints: list[tuple[int, int]]
66
 
67
 
68
- # ===================== Weighted Box Fusion =====================
69
- def _wbf_one_class(
70
- boxes: np.ndarray, # [N,4] xyxy in image pixels
71
- scores: np.ndarray, # [N]
72
- weights: np.ndarray, # [N] view-weights per box
73
- iou_thr: float,
74
- ) -> tuple[np.ndarray, np.ndarray]:
75
- """Single-class Weighted Box Fusion. Returns fused (boxes, scores)."""
76
- if len(boxes) == 0:
77
- return np.empty((0, 4), dtype=np.float32), np.empty((0,), dtype=np.float32)
78
-
79
- order = np.argsort(scores)[::-1]
80
- boxes = boxes[order]
81
- scores = scores[order]
82
- weights = weights[order]
83
-
84
- clusters: list[list[int]] = []
85
- fused_boxes: list[np.ndarray] = []
86
-
87
- for i in range(len(boxes)):
88
- bi = boxes[i]
89
- best_j = -1
90
- best_iou = 0.0
91
- for j, fb in enumerate(fused_boxes):
92
- xx1 = max(bi[0], fb[0]); yy1 = max(bi[1], fb[1])
93
- xx2 = min(bi[2], fb[2]); yy2 = min(bi[3], fb[3])
94
- iw = max(0.0, xx2 - xx1); ih = max(0.0, yy2 - yy1)
95
- inter = iw * ih
96
- area_i = max(0.0, bi[2] - bi[0]) * max(0.0, bi[3] - bi[1])
97
- area_f = max(0.0, fb[2] - fb[0]) * max(0.0, fb[3] - fb[1])
98
- union = area_i + area_f - inter
99
- iou = inter / union if union > 0 else 0.0
100
- if iou > best_iou:
101
- best_iou = iou; best_j = j
102
- if best_j >= 0 and best_iou >= iou_thr:
103
- clusters[best_j].append(i)
104
- members = clusters[best_j]
105
- ws = weights[members] * scores[members]
106
- wsum = ws.sum() + 1e-9
107
- fused = (boxes[members] * ws[:, None]).sum(axis=0) / wsum
108
- fused_boxes[best_j] = fused
109
- else:
110
- clusters.append([i])
111
- fused_boxes.append(bi.copy())
112
-
113
- out_boxes = np.stack(fused_boxes, axis=0).astype(np.float32)
114
- out_scores = np.zeros(len(clusters), dtype=np.float32)
115
- n_views = max(1.0, float(weights.max()) if len(weights) else 1.0)
116
- for k, members in enumerate(clusters):
117
- # WBF score: avg score weighted by view-weight, scaled by cluster coverage
118
- ws = weights[members]
119
- s = scores[members]
120
- avg = float((s * ws).sum() / (ws.sum() + 1e-9))
121
- coverage = min(1.0, len(members) / n_views) # 1.0 if all views agreed
122
- out_scores[k] = avg * (0.5 + 0.5 * coverage)
123
- return out_boxes, out_scores
124
-
125
-
126
- def weighted_box_fusion(
127
- boxes_per_view: list[np.ndarray], # each [Ni,4]
128
- scores_per_view: list[np.ndarray], # each [Ni]
129
- cls_per_view: list[np.ndarray], # each [Ni]
130
- view_weights: np.ndarray,
131
- iou_thr: float = WBF_IOU_THR,
132
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
133
- all_b: list[np.ndarray] = []
134
- all_s: list[np.ndarray] = []
135
- all_w: list[np.ndarray] = []
136
- all_c: list[np.ndarray] = []
137
- for b, s, c, w in zip(boxes_per_view, scores_per_view, cls_per_view, view_weights):
138
- if len(b) == 0:
139
- continue
140
- all_b.append(b); all_s.append(s); all_c.append(c)
141
- all_w.append(np.full(len(b), float(w), dtype=np.float32))
142
- if not all_b:
143
- return (np.empty((0, 4), dtype=np.float32),
144
- np.empty((0,), dtype=np.float32),
145
- np.empty((0,), dtype=np.int32))
146
- B = np.concatenate(all_b, axis=0)
147
- S = np.concatenate(all_s, axis=0)
148
- C = np.concatenate(all_c, axis=0)
149
- W = np.concatenate(all_w, axis=0)
150
-
151
- out_b: list[np.ndarray] = []
152
- out_s: list[np.ndarray] = []
153
- out_c: list[np.ndarray] = []
154
- for c in np.unique(C):
155
- mask = C == c
156
- fb, fs = _wbf_one_class(B[mask], S[mask], W[mask], iou_thr)
157
- if len(fb) == 0:
158
- continue
159
- out_b.append(fb)
160
- out_s.append(fs)
161
- out_c.append(np.full(len(fb), int(c), dtype=np.int32))
162
- if not out_b:
163
- return (np.empty((0, 4), dtype=np.float32),
164
- np.empty((0,), dtype=np.float32),
165
- np.empty((0,), dtype=np.int32))
166
- return np.concatenate(out_b, axis=0), np.concatenate(out_s, axis=0), np.concatenate(out_c, axis=0)
167
-
168
-
169
- # ===================== Miner =====================
170
  class Miner:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def __init__(self, path_hf_repo: Path) -> None:
172
  model_path = path_hf_repo / "weights.onnx"
173
- self.class_names = CRIME_CLASSES
174
  print("ORT version:", ort.__version__)
 
175
  try:
176
  ort.preload_dlls()
177
- except Exception:
178
- pass
 
 
 
 
 
 
179
 
180
- sess_opt = ort.SessionOptions()
181
- sess_opt.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
182
  try:
183
  self.session = ort.InferenceSession(
184
- str(model_path), sess_options=sess_opt,
 
185
  providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
186
  )
 
187
  except Exception as e:
188
- print(f"⚠️ CUDA session failed, CPU fallback: {e}")
189
  self.session = ort.InferenceSession(
190
- str(model_path), sess_options=sess_opt, providers=["CPUExecutionProvider"]
 
 
191
  )
 
192
  print("ORT session providers:", self.session.get_providers())
193
 
 
 
 
 
 
194
  self.input_name = self.session.get_inputs()[0].name
195
  self.output_names = [o.name for o in self.session.get_outputs()]
196
- in_meta = self.session.get_inputs()[0]
197
- ishape = in_meta.shape
198
- self.input_h = self._safe_dim(ishape[2], 1280)
199
- self.input_w = self._safe_dim(ishape[3], 1280)
200
- self.input_np_dtype = np.float16 if "float16" in (in_meta.type or "") else np.float32
201
-
202
- # Inference recipe
203
- self.use_tta = True
204
- self.zoom_long_side = 1408 # second view at higher res, then letterbox to model input
205
- self.iou_thres = 0.35
206
- self.max_det = 200
207
- self.per_class_conf = PER_CLASS_CONF.copy()
208
-
209
- # box sanity filters (match king — they're well-tuned for crime)
210
- self.min_box_area = 10 * 10
211
- self.min_w = 6
212
- self.min_h = 6
213
- self.max_aspect_ratio = 8.0
214
- self.max_box_area_ratio = 0.95
215
-
216
- print(f"✅ ONNX loaded {model_path} input={self.input_w}x{self.input_h}")
217
 
218
  @staticmethod
219
- def _safe_dim(v, default):
220
- return v if isinstance(v, int) and v > 0 else default
221
 
222
- # ---- preprocessing ----
223
- def _letterbox(self, image: ndarray, new_shape: tuple[int, int]):
 
 
 
 
 
224
  h, w = image.shape[:2]
225
- nw, nh = new_shape
226
- ratio = min(nw / w, nh / h)
227
- rw, rh = int(round(w * ratio)), int(round(h * ratio))
228
- if (rw, rh) != (w, h):
 
229
  interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
230
- image = cv2.resize(image, (rw, rh), interpolation=interp)
231
- dw, dh = (nw - rw) / 2.0, (nh - rh) / 2.0
232
- l = int(round(dw - 0.1)); r = int(round(dw + 0.1))
233
- t = int(round(dh - 0.1)); b = int(round(dh + 0.1))
234
- padded = cv2.copyMakeBorder(image, t, b, l, r, borderType=cv2.BORDER_CONSTANT, value=(114, 114, 114))
 
 
 
 
 
 
235
  return padded, ratio, (dw, dh)
236
 
237
- def _preproc(self, image: ndarray, target_long: int | None = None):
238
- if target_long is not None:
239
- h, w = image.shape[:2]
240
- if max(h, w) != target_long:
241
- s = target_long / max(h, w)
242
- image = cv2.resize(image, (int(round(w * s)), int(round(h * s))), interpolation=cv2.INTER_AREA)
243
  orig_h, orig_w = image.shape[:2]
244
- img, ratio, pad = self._letterbox(image, (self.input_w, self.input_h))
245
  img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
246
  img = img.astype(np.float32) / 255.0
247
  img = np.transpose(img, (2, 0, 1))[None, ...]
248
- img = np.ascontiguousarray(img, dtype=self.input_np_dtype)
249
  return img, ratio, pad, (orig_w, orig_h)
250
 
251
- # ---- forward + decode (no per-class conf, no NMS — both happen in WBF/postproc) ----
252
- def _forward_decode(self, image: ndarray, target_long: int | None) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
253
- if image.dtype != np.uint8:
254
- image = image.astype(np.uint8)
255
- tensor, ratio, pad, (ow, oh) = self._preproc(image, target_long)
256
- outs = self.session.run(self.output_names, {self.input_name: tensor})
257
- preds = outs[0]
258
- if preds.ndim == 3 and preds.shape[0] == 1:
259
- preds = preds[0]
260
- if preds.ndim != 2 or preds.shape[1] < 6:
261
- return (np.empty((0, 4), np.float32), np.empty((0,), np.float32), np.empty((0,), np.int32))
262
-
263
- boxes = preds[:, :4].astype(np.float32)
264
- scores = preds[:, 4].astype(np.float32)
265
- cls_ids = preds[:, 5].astype(np.int32)
266
-
267
- # de-letterbox back to ORIGINAL image coords (the un-resized one passed in to predict)
268
- # NB: target_long step was a downsample of the input image; we de-letterbox to that
269
- # downsampled size, then upscale to the true original.
270
- keep = scores >= WBF_SKIP_BOX_THR
271
- boxes = boxes[keep]; scores = scores[keep]; cls_ids = cls_ids[keep]
272
- if len(boxes) == 0:
273
- return boxes, scores, cls_ids
274
-
275
- boxes[:, [0, 2]] -= pad[0]
276
- boxes[:, [1, 3]] -= pad[1]
277
- boxes /= ratio # now in coords of the (possibly downsampled) image fed to letterbox
278
-
279
- # If we had pre-resized for zoom view, scale boxes back to original image coords
280
- true_h, true_w = image.shape[:2]
281
- if target_long is not None and max(true_h, true_w) != target_long:
282
- s = max(true_h, true_w) / target_long
283
- boxes *= s
284
-
285
- boxes[:, [0, 2]] = np.clip(boxes[:, [0, 2]], 0, true_w - 1)
286
- boxes[:, [1, 3]] = np.clip(boxes[:, [1, 3]], 0, true_h - 1)
287
- return boxes.astype(np.float32), scores, cls_ids
288
 
289
  @staticmethod
290
- def _flip_boxes_h(boxes: np.ndarray, w: int) -> np.ndarray:
291
- if len(boxes) == 0:
292
- return boxes
293
- out = boxes.copy()
294
- x1 = out[:, 0].copy(); x2 = out[:, 2].copy()
295
- out[:, 0] = w - x2
296
- out[:, 2] = w - x1
297
  return out
298
 
299
- # ---- box sanity ----
300
- def _filter_sane(self, boxes, scores, cls_ids, true_size):
301
- if len(boxes) == 0:
302
- return boxes, scores, cls_ids
303
- tw, th = true_size
304
- area_img = float(tw * th)
305
- keep = []
306
- for i, (x1, y1, x2, y2) in enumerate(boxes):
307
- bw = x2 - x1; bh = y2 - y1
308
- if bw < self.min_w or bh < self.min_h:
309
- continue
310
- area = bw * bh
311
- if area < self.min_box_area or area > self.max_box_area_ratio * area_img:
312
- continue
313
- ar = max(bw / max(bh, 1e-6), bh / max(bw, 1e-6))
314
- if ar > self.max_aspect_ratio:
315
- continue
316
- keep.append(i)
317
- if not keep:
318
- return (np.empty((0, 4), np.float32), np.empty((0,), np.float32), np.empty((0,), np.int32))
319
- keep = np.asarray(keep, dtype=np.intp)
320
- return boxes[keep], scores[keep], cls_ids[keep]
321
-
322
- # ---- per-class NMS ----
323
  @staticmethod
324
- def _hard_nms(boxes, scores, iou_thr):
325
- if len(boxes) == 0:
326
- return np.array([], dtype=np.intp)
327
- order = np.argsort(scores)[::-1]
328
- keep = []
329
- while len(order) > 0:
330
- i = order[0]; keep.append(i)
331
- if len(order) == 1:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  break
333
- rest = order[1:]
334
- xx1 = np.maximum(boxes[i, 0], boxes[rest, 0]); yy1 = np.maximum(boxes[i, 1], boxes[rest, 1])
335
- xx2 = np.minimum(boxes[i, 2], boxes[rest, 2]); yy2 = np.minimum(boxes[i, 3], boxes[rest, 3])
336
- inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1)
337
- ai = max(0, boxes[i, 2] - boxes[i, 0]) * max(0, boxes[i, 3] - boxes[i, 1])
338
- ar = np.maximum(0, boxes[rest, 2] - boxes[rest, 0]) * np.maximum(0, boxes[rest, 3] - boxes[rest, 1])
339
- iou = inter / (ai + ar - inter + 1e-7)
340
- order = rest[iou <= iou_thr]
341
- return np.array(keep, dtype=np.intp)
342
-
343
- @staticmethod
344
- def _merge_close_graffiti(boxes, scores, cls_ids, gap_frac=0.25):
345
- """Agglomerative proximity-merge for graffiti only.
346
-
347
- Graffiti tags on the same wall often appear as multiple disjoint boxes with
348
- near-zero pairwise IoU, so standard per-class NMS doesn't suppress them. King's
349
- model produces one large box per wall panel; ours produces many small ones per
350
- tag. We iteratively merge pairs of graffiti boxes whose nearest-edge gap is
351
- smaller than `gap_frac` × the smaller box's max-side (already-overlapping
352
- pairs trivially satisfy this, so they merge too).
353
- """
354
  if len(boxes) == 0:
355
  return boxes, scores, cls_ids
356
- mask = cls_ids == 5
357
- if mask.sum() < 2:
358
- return boxes, scores, cls_ids
359
- g_boxes = boxes[mask].tolist()
360
- g_scores = scores[mask].tolist()
361
- # leave non-graffiti alone
362
- other_b = boxes[~mask]; other_s = scores[~mask]; other_c = cls_ids[~mask]
363
-
364
- changed = True
365
- while changed and len(g_boxes) > 1:
366
- changed = False
367
- for i in range(len(g_boxes)):
368
- bi = g_boxes[i]
369
- for j in range(i + 1, len(g_boxes)):
370
- bj = g_boxes[j]
371
- gap_x = max(bj[0] - bi[2], bi[0] - bj[2], 0.0)
372
- gap_y = max(bj[1] - bi[3], bi[1] - bj[3], 0.0)
373
- gap = max(gap_x, gap_y)
374
- ref = min(
375
- max(bi[2] - bi[0], bi[3] - bi[1]),
376
- max(bj[2] - bj[0], bj[3] - bj[1]),
377
- )
378
- if gap < gap_frac * ref:
379
- union = [
380
- min(bi[0], bj[0]), min(bi[1], bj[1]),
381
- max(bi[2], bj[2]), max(bi[3], bj[3]),
382
- ]
383
- new_score = max(g_scores[i], g_scores[j])
384
- g_boxes[j] = union
385
- g_scores[j] = new_score
386
- g_boxes.pop(i); g_scores.pop(i)
387
- changed = True
388
- break
389
- if changed:
390
- break
391
- if not g_boxes:
392
- return other_b, other_s, other_c
393
- merged_g = np.array(g_boxes, dtype=np.float32)
394
- merged_s = np.array(g_scores, dtype=np.float32)
395
- merged_c = np.full(len(g_boxes), 5, dtype=np.int32)
396
- if len(other_b) == 0:
397
- return merged_g, merged_s, merged_c
398
- return (np.concatenate([other_b, merged_g], axis=0),
399
- np.concatenate([other_s, merged_s], axis=0),
400
- np.concatenate([other_c, merged_c], axis=0))
401
-
402
- def _nms_per_class(self, boxes, scores, cls_ids):
403
  if len(boxes) == 0:
404
  return boxes, scores, cls_ids
405
- keep_all = []
 
 
406
  for c in np.unique(cls_ids):
407
- idxs = np.nonzero(cls_ids == c)[0]
408
- local = self._hard_nms(boxes[idxs], scores[idxs], self.iou_thres)
409
- keep_all.extend(idxs[local].tolist())
410
- keep_all = np.asarray(keep_all, dtype=np.intp)
411
- order = np.argsort(scores[keep_all])[::-1][: self.max_det]
412
- keep_all = keep_all[order]
413
- return boxes[keep_all], scores[keep_all], cls_ids[keep_all]
414
-
415
- # ---- main predict ----
416
- def _predict_one(self, image: ndarray) -> list[BoundingBox]:
417
- H, W = image.shape[:2]
418
-
419
- # View 1: original at native resolution
420
- b1, s1, c1 = self._forward_decode(image, target_long=None)
421
-
422
- if not self.use_tta:
423
- views_b, views_s, views_c = [b1], [s1], [c1]
424
- view_weights = VIEW_WEIGHTS[:1]
425
- else:
426
- # View 2: hflip
427
- flipped = cv2.flip(image, 1)
428
- b2, s2, c2 = self._forward_decode(flipped, target_long=None)
429
- b2 = self._flip_boxes_h(b2, W)
430
-
431
- # View 3: zoomed (longer side resized to self.zoom_long_side, then letterbox to model input)
432
- b3, s3, c3 = self._forward_decode(image, target_long=self.zoom_long_side)
433
-
434
- views_b = [b1, b2, b3]
435
- views_s = [s1, s2, s3]
436
- views_c = [c1, c2, c3]
437
- view_weights = VIEW_WEIGHTS
438
-
439
- boxes, scores, cls_ids = weighted_box_fusion(
440
- views_b, views_s, views_c, view_weights, iou_thr=WBF_IOU_THR
 
 
 
 
 
 
 
 
 
 
 
 
 
441
  )
442
- if len(boxes) == 0:
443
- return []
444
 
445
- # Per-class confidence threshold
446
- keep = scores >= self.per_class_conf[cls_ids]
447
- boxes = boxes[keep]; scores = scores[keep]; cls_ids = cls_ids[keep]
 
 
448
  if len(boxes) == 0:
449
- return []
 
 
 
 
 
 
 
 
 
 
 
 
450
 
451
- # Sanity + NMS
452
- boxes, scores, cls_ids = self._filter_sane(boxes, scores, cls_ids, (W, H))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  if len(boxes) == 0:
454
  return []
455
- boxes, scores, cls_ids = self._nms_per_class(boxes, scores, cls_ids)
456
- # Graffiti-only proximity merge: collapse tiny adjacent tags into wall-panel boxes,
457
- # which matches the validator's preferred granularity (one box per panel).
458
- boxes, scores, cls_ids = self._merge_close_graffiti(boxes, scores, cls_ids, gap_frac=0.25)
459
-
460
- # Per-class aspect-ratio filter (currently: hoodie must be tall ≥1.2× wide,
461
- # so we keep only detections that include the hooded-head region).
462
- if len(boxes) > 0 and PER_CLASS_MIN_H_OVER_W:
463
- keep = np.ones(len(boxes), dtype=bool)
464
- for i in range(len(boxes)):
465
- c = int(cls_ids[i])
466
- if c in PER_CLASS_MIN_H_OVER_W:
467
- bw = float(boxes[i, 2] - boxes[i, 0])
468
- bh = float(boxes[i, 3] - boxes[i, 1])
469
- if bw <= 0 or (bh / bw) < PER_CLASS_MIN_H_OVER_W[c]:
470
- keep[i] = False
471
- boxes = boxes[keep]; scores = scores[keep]; cls_ids = cls_ids[keep]
472
-
473
- return [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  BoundingBox(
475
- x1=int(math.floor(b[0])), y1=int(math.floor(b[1])),
476
- x2=int(math.ceil(b[2])), y2=int(math.ceil(b[3])),
477
- cls_id=int(c), conf=float(s),
478
  )
479
- for b, s, c in zip(boxes, scores, cls_ids)
480
- if b[2] > b[0] and b[3] > b[1]
481
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
 
483
  def predict_batch(
484
- self,
485
- batch_images: list[ndarray],
486
- offset: int,
487
- n_keypoints: int,
488
  ) -> list[TVFrameResult]:
489
  results: list[TVFrameResult] = []
490
- for i, image in enumerate(batch_images):
491
  try:
492
- boxes = self._predict_one(image)
493
  except Exception as e:
494
- print(f"⚠️ inference failed frame {offset + i}: {e}")
 
 
 
495
  boxes = []
496
- results.append(TVFrameResult(
497
- frame_id=offset + i,
498
- boxes=boxes,
499
- keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
500
- ))
 
 
501
  return results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from pathlib import Path
2
+ import math
3
 
4
  import cv2
5
  import numpy as np
 
8
  from pydantic import BaseModel
9
 
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  class BoundingBox(BaseModel):
12
  x1: int
13
  y1: int
 
23
  keypoints: list[tuple[int, int]]
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  class Miner:
27
+ """
28
+ ONNX Runtime miner for Detect-crime.
29
+
30
+ Recipe (see recipe.md for derivation):
31
+ * per-class confidence thresholds (king's lever)
32
+ * per-class IoU thresholds (closer pairs allowed for graffiti / spray paint)
33
+ * per-class soft-NMS with sigma=0.5
34
+ * horizontal-flip TTA merged with **weighted box fusion** (coord-averaged)
35
+ * per-class cluster confidence boost (fixes king's cross-class leak)
36
+ * no min-side / min-area filter (small TPs survive)
37
+ * no h/w aspect gate beyond a sane outlier cap (8x)
38
+ """
39
+
40
+ class_names = ["balaclava", "hoodie", "glove", "bat", "spray paint", "graffiti"]
41
+ input_size = 1280
42
+ max_det = 300
43
+ max_aspect_ratio = 8.0
44
+ soft_sigma = 0.5
45
+
46
+ _conf_thres_array = np.array(
47
+ # balaclava, hoodie, glove, bat, spray paint, graffiti
48
+ [0.50, 0.60, 0.30, 0.20, 0.45, 0.30],
49
+ dtype=np.float32,
50
+ )
51
+
52
+ _iou_thres_array = np.array(
53
+ # hoodie/balaclava: clean people, tight NMS
54
+ # glove/bat: small rare objects, mid
55
+ # spray/graffiti: legitimately overlapping marks, loose
56
+ [0.60, 0.60, 0.55, 0.55, 0.45, 0.45],
57
+ dtype=np.float32,
58
+ )
59
+
60
  def __init__(self, path_hf_repo: Path) -> None:
61
  model_path = path_hf_repo / "weights.onnx"
 
62
  print("ORT version:", ort.__version__)
63
+
64
  try:
65
  ort.preload_dlls()
66
+ print("preload_dlls success")
67
+ except Exception as e:
68
+ print(f"preload_dlls failed: {e}")
69
+
70
+ print("ORT providers available:", ort.get_available_providers())
71
+
72
+ sess_options = ort.SessionOptions()
73
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
74
 
 
 
75
  try:
76
  self.session = ort.InferenceSession(
77
+ str(model_path),
78
+ sess_options=sess_options,
79
  providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
80
  )
81
+ print("ORT session: CUDA provider")
82
  except Exception as e:
83
+ print(f"CUDA session creation failed, falling back to CPU: {e}")
84
  self.session = ort.InferenceSession(
85
+ str(model_path),
86
+ sess_options=sess_options,
87
+ providers=["CPUExecutionProvider"],
88
  )
89
+
90
  print("ORT session providers:", self.session.get_providers())
91
 
92
+ for inp in self.session.get_inputs():
93
+ print("INPUT:", inp.name, inp.shape, inp.type)
94
+ for out in self.session.get_outputs():
95
+ print("OUTPUT:", out.name, out.shape, out.type)
96
+
97
  self.input_name = self.session.get_inputs()[0].name
98
  self.output_names = [o.name for o in self.session.get_outputs()]
99
+ self.input_shape = self.session.get_inputs()[0].shape
100
+ # Detect FP16 vs FP32 input from the session metadata.
101
+ input_type = self.session.get_inputs()[0].type
102
+ self.input_dtype = np.float16 if "float16" in input_type else np.float32
103
+ self.input_height = self._safe_dim(self.input_shape[2], default=self.input_size)
104
+ self.input_width = self._safe_dim(self.input_shape[3], default=self.input_size)
105
+
106
+ print(f"ONNX model loaded: {model_path}")
107
+ print("per-class conf: " + ", ".join(
108
+ f"{n}={t:.2f}" for n, t in zip(self.class_names,
109
+ self._conf_thres_array.tolist())))
110
+ print("per-class iou : " + ", ".join(
111
+ f"{n}={t:.2f}" for n, t in zip(self.class_names,
112
+ self._iou_thres_array.tolist())))
113
+
114
+ def __repr__(self) -> str:
115
+ return (
116
+ f"ONNXRuntime(session={type(self.session).__name__}, "
117
+ f"providers={self.session.get_providers()})"
118
+ )
 
119
 
120
  @staticmethod
121
+ def _safe_dim(value, default: int) -> int:
122
+ return value if isinstance(value, int) and value > 0 else default
123
 
124
+ # ---------------------------------------------------------------- preproc
125
+ def _letterbox(
126
+ self,
127
+ image: ndarray,
128
+ new_shape: tuple[int, int],
129
+ color=(114, 114, 114),
130
+ ) -> tuple[ndarray, float, tuple[float, float]]:
131
  h, w = image.shape[:2]
132
+ new_w, new_h = new_shape
133
+ ratio = min(new_w / w, new_h / h)
134
+ resized_w = int(round(w * ratio))
135
+ resized_h = int(round(h * ratio))
136
+ if (resized_w, resized_h) != (w, h):
137
  interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
138
+ image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
139
+ dw = (new_w - resized_w) / 2.0
140
+ dh = (new_h - resized_h) / 2.0
141
+ left = int(round(dw - 0.1))
142
+ right = int(round(dw + 0.1))
143
+ top = int(round(dh - 0.1))
144
+ bottom = int(round(dh + 0.1))
145
+ padded = cv2.copyMakeBorder(
146
+ image, top, bottom, left, right,
147
+ borderType=cv2.BORDER_CONSTANT, value=color,
148
+ )
149
  return padded, ratio, (dw, dh)
150
 
151
+ def _preprocess(
152
+ self, image: ndarray
153
+ ) -> tuple[np.ndarray, float, tuple[float, float], tuple[int, int]]:
 
 
 
154
  orig_h, orig_w = image.shape[:2]
155
+ img, ratio, pad = self._letterbox(image, (self.input_width, self.input_height))
156
  img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
157
  img = img.astype(np.float32) / 255.0
158
  img = np.transpose(img, (2, 0, 1))[None, ...]
159
+ img = np.ascontiguousarray(img, dtype=self.input_dtype)
160
  return img, ratio, pad, (orig_w, orig_h)
161
 
162
+ # ---------------------------------------------------------------- helpers
163
+ @staticmethod
164
+ def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
165
+ w, h = image_size
166
+ boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
167
+ boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
168
+ boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
169
+ boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
170
+ return boxes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
  @staticmethod
173
+ def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
174
+ out = np.empty_like(boxes)
175
+ out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
176
+ out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
177
+ out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
178
+ out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
 
179
  return out
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  @staticmethod
182
+ def _iou_matrix(a: np.ndarray, b: np.ndarray) -> np.ndarray:
183
+ if len(a) == 0 or len(b) == 0:
184
+ return np.zeros((len(a), len(b)), dtype=np.float32)
185
+ ax1, ay1, ax2, ay2 = a[:, 0:1], a[:, 1:2], a[:, 2:3], a[:, 3:4]
186
+ bx1, by1, bx2, by2 = b[:, 0], b[:, 1], b[:, 2], b[:, 3]
187
+ ix1 = np.maximum(ax1, bx1)
188
+ iy1 = np.maximum(ay1, by1)
189
+ ix2 = np.minimum(ax2, bx2)
190
+ iy2 = np.minimum(ay2, by2)
191
+ inter = np.maximum(0.0, ix2 - ix1) * np.maximum(0.0, iy2 - iy1)
192
+ area_a = np.maximum(0.0, ax2 - ax1) * np.maximum(0.0, ay2 - ay1)
193
+ area_b = np.maximum(0.0, bx2 - bx1) * np.maximum(0.0, by2 - by1)
194
+ union = area_a + area_b - inter + 1e-7
195
+ return (inter / union).astype(np.float32)
196
+
197
+ # ---------------------------------------------------------------- NMS
198
+ def _soft_nms(
199
+ self, boxes: np.ndarray, scores: np.ndarray, sigma: float,
200
+ score_thresh: float = 0.001,
201
+ ) -> tuple[np.ndarray, np.ndarray]:
202
+ n = len(boxes)
203
+ if n == 0:
204
+ return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
205
+ boxes = boxes.astype(np.float32, copy=True)
206
+ scores = scores.astype(np.float32, copy=True)
207
+ order = np.arange(n)
208
+ for i in range(n):
209
+ max_pos = i + int(np.argmax(scores[i:]))
210
+ boxes[[i, max_pos]] = boxes[[max_pos, i]]
211
+ scores[[i, max_pos]] = scores[[max_pos, i]]
212
+ order[[i, max_pos]] = order[[max_pos, i]]
213
+ if i + 1 >= n:
214
  break
215
+ xx1 = np.maximum(boxes[i, 0], boxes[i + 1:, 0])
216
+ yy1 = np.maximum(boxes[i, 1], boxes[i + 1:, 1])
217
+ xx2 = np.minimum(boxes[i, 2], boxes[i + 1:, 2])
218
+ yy2 = np.minimum(boxes[i, 3], boxes[i + 1:, 3])
219
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
220
+ a_i = max(0.0, float(
221
+ (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])
222
+ ))
223
+ a_j = (
224
+ np.maximum(0.0, boxes[i + 1:, 2] - boxes[i + 1:, 0]) *
225
+ np.maximum(0.0, boxes[i + 1:, 3] - boxes[i + 1:, 1])
226
+ )
227
+ iou = inter / (a_i + a_j - inter + 1e-7)
228
+ scores[i + 1:] *= np.exp(-(iou ** 2) / sigma)
229
+ mask = scores > score_thresh
230
+ return order[mask], scores[mask]
231
+
232
+ def _per_class_soft_nms(
233
+ self, boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray,
234
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
 
235
  if len(boxes) == 0:
236
  return boxes, scores, cls_ids
237
+ out_b: list = []
238
+ out_s: list = []
239
+ out_c: list = []
240
+ for c in np.unique(cls_ids):
241
+ mask = cls_ids == c
242
+ sub_b = boxes[mask]
243
+ sub_s = scores[mask]
244
+ idx, decayed = self._soft_nms(sub_b, sub_s, self.soft_sigma)
245
+ if len(idx) == 0:
246
+ continue
247
+ out_b.append(sub_b[idx])
248
+ out_s.append(decayed)
249
+ out_c.append(np.full(len(idx), c, dtype=cls_ids.dtype))
250
+ if not out_b:
251
+ return (np.empty((0, 4), dtype=np.float32),
252
+ np.empty((0,), dtype=np.float32),
253
+ np.empty((0,), dtype=cls_ids.dtype))
254
+ return (np.concatenate(out_b, axis=0),
255
+ np.concatenate(out_s, axis=0),
256
+ np.concatenate(out_c, axis=0))
257
+
258
+ # ---------------------------------------------------------------- WBF
259
+ def _weighted_box_fusion(
260
+ self,
261
+ boxes: np.ndarray,
262
+ scores: np.ndarray,
263
+ cls_ids: np.ndarray,
264
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
265
+ """Per-class confidence-weighted box fusion across orig+flip detections."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  if len(boxes) == 0:
267
  return boxes, scores, cls_ids
268
+ fused_b: list = []
269
+ fused_s: list = []
270
+ fused_c: list = []
271
  for c in np.unique(cls_ids):
272
+ mask = cls_ids == c
273
+ sub_b = boxes[mask].astype(np.float32)
274
+ sub_s = scores[mask].astype(np.float32)
275
+ iou_thr = float(self._iou_thres_array[int(c)])
276
+ order = np.argsort(-sub_s)
277
+ sub_b = sub_b[order]
278
+ sub_s = sub_s[order]
279
+ used = np.zeros(len(sub_b), dtype=bool)
280
+ for i in range(len(sub_b)):
281
+ if used[i]:
282
+ continue
283
+ used[i] = True
284
+ cluster_b = [sub_b[i]]
285
+ cluster_s = [sub_s[i]]
286
+ if i + 1 < len(sub_b):
287
+ rest = sub_b[i + 1:]
288
+ ious = self._iou_matrix(sub_b[i:i + 1], rest)[0]
289
+ for j_offset, iou in enumerate(ious):
290
+ j = i + 1 + j_offset
291
+ if used[j]:
292
+ continue
293
+ if iou >= iou_thr:
294
+ used[j] = True
295
+ cluster_b.append(sub_b[j])
296
+ cluster_s.append(sub_s[j])
297
+ ws = np.asarray(cluster_s, dtype=np.float32)
298
+ bs = np.asarray(cluster_b, dtype=np.float32)
299
+ w_sum = float(ws.sum())
300
+ if w_sum <= 0:
301
+ continue
302
+ fused_box = (bs * ws[:, None]).sum(axis=0) / w_sum
303
+ # cluster confidence: max member, slight boost when >1 supporter
304
+ support = len(cluster_s)
305
+ fused_conf = float(ws.max())
306
+ if support > 1:
307
+ fused_conf = min(1.0, fused_conf * (1.0 + 0.10 * (support - 1)))
308
+ fused_b.append(fused_box)
309
+ fused_s.append(fused_conf)
310
+ fused_c.append(int(c))
311
+ if not fused_b:
312
+ return (np.empty((0, 4), dtype=np.float32),
313
+ np.empty((0,), dtype=np.float32),
314
+ np.empty((0,), dtype=cls_ids.dtype))
315
+ return (
316
+ np.asarray(fused_b, dtype=np.float32),
317
+ np.asarray(fused_s, dtype=np.float32),
318
+ np.asarray(fused_c, dtype=cls_ids.dtype),
319
  )
 
 
320
 
321
+ # ---------------------------------------------------------------- sanity
322
+ def _filter_sane_boxes(
323
+ self, boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray,
324
+ orig_size: tuple[int, int],
325
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
326
  if len(boxes) == 0:
327
+ return boxes, scores, cls_ids
328
+ orig_w, orig_h = orig_size
329
+ image_area = float(orig_w * orig_h)
330
+ bw = np.maximum(0.0, boxes[:, 2] - boxes[:, 0])
331
+ bh = np.maximum(0.0, boxes[:, 3] - boxes[:, 1])
332
+ area = bw * bh
333
+ ar = np.where(
334
+ (bw > 0) & (bh > 0),
335
+ np.maximum(bw / np.maximum(bh, 1e-6), bh / np.maximum(bw, 1e-6)),
336
+ np.inf,
337
+ )
338
+ keep = (area > 0) & (area <= 0.95 * image_area) & (ar <= self.max_aspect_ratio)
339
+ return boxes[keep], scores[keep], cls_ids[keep]
340
 
341
+ # ---------------------------------------------------------------- decode
342
+ def _per_view_pipeline(
343
+ self, boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray,
344
+ orig_size: tuple[int, int],
345
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
346
+ boxes, scores, cls_ids = self._filter_sane_boxes(boxes, scores, cls_ids, orig_size)
347
+ if len(boxes) == 0:
348
+ return boxes, scores, cls_ids
349
+ if len(boxes) > 1:
350
+ boxes, scores, cls_ids = self._per_class_soft_nms(boxes, scores, cls_ids)
351
+ if len(scores) > self.max_det:
352
+ top = np.argsort(-scores)[: self.max_det]
353
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
354
+ return boxes, scores, cls_ids
355
+
356
+ def _decode_final_dets(
357
+ self, preds: np.ndarray, ratio: float, pad: tuple[float, float],
358
+ orig_size: tuple[int, int],
359
+ ) -> list[BoundingBox]:
360
+ if preds.ndim == 3 and preds.shape[0] == 1:
361
+ preds = preds[0]
362
+ if preds.ndim != 2 or preds.shape[1] < 6:
363
+ raise ValueError(f"Unexpected final-det output shape: {preds.shape}")
364
+ boxes = preds[:, :4].astype(np.float32)
365
+ scores = preds[:, 4].astype(np.float32)
366
+ cls_ids = preds[:, 5].astype(np.int32)
367
+ keep = scores >= self._conf_thres_array[cls_ids]
368
+ boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
369
  if len(boxes) == 0:
370
  return []
371
+ pad_w, pad_h = pad
372
+ boxes[:, [0, 2]] -= pad_w
373
+ boxes[:, [1, 3]] -= pad_h
374
+ boxes /= ratio
375
+ boxes = self._clip_boxes(boxes, orig_size)
376
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids, orig_size)
377
+ return self._build_results(boxes, scores, cls_ids)
378
+
379
+ def _decode_raw_yolo(
380
+ self, preds: np.ndarray, ratio: float, pad: tuple[float, float],
381
+ orig_size: tuple[int, int],
382
+ ) -> list[BoundingBox]:
383
+ if preds.ndim != 3 or preds.shape[0] != 1:
384
+ raise ValueError(f"Unexpected raw output shape: {preds.shape}")
385
+ preds = preds[0]
386
+ if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
387
+ preds = preds.T
388
+ if preds.ndim != 2 or preds.shape[1] < 5:
389
+ raise ValueError(f"Unexpected raw output shape: {preds.shape}")
390
+ boxes_xywh = preds[:, :4].astype(np.float32)
391
+ cls_part = preds[:, 4:].astype(np.float32)
392
+ if cls_part.shape[1] == 1:
393
+ scores = cls_part[:, 0]
394
+ cls_ids = np.zeros(len(scores), dtype=np.int32)
395
+ else:
396
+ cls_ids = np.argmax(cls_part, axis=1).astype(np.int32)
397
+ scores = cls_part[np.arange(len(cls_part)), cls_ids]
398
+ keep = scores >= self._conf_thres_array[cls_ids]
399
+ boxes_xywh, scores, cls_ids = boxes_xywh[keep], scores[keep], cls_ids[keep]
400
+ if len(boxes_xywh) == 0:
401
+ return []
402
+ boxes = self._xywh_to_xyxy(boxes_xywh)
403
+ pad_w, pad_h = pad
404
+ boxes[:, [0, 2]] -= pad_w
405
+ boxes[:, [1, 3]] -= pad_h
406
+ boxes /= ratio
407
+ boxes = self._clip_boxes(boxes, orig_size)
408
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids, orig_size)
409
+ return self._build_results(boxes, scores, cls_ids)
410
+
411
+ @staticmethod
412
+ def _build_results(
413
+ boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray,
414
+ ) -> list[BoundingBox]:
415
+ results: list[BoundingBox] = []
416
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
417
+ x1, y1, x2, y2 = box.tolist()
418
+ if x2 <= x1 or y2 <= y1:
419
+ continue
420
+ results.append(
421
+ BoundingBox(
422
+ x1=int(math.floor(x1)),
423
+ y1=int(math.floor(y1)),
424
+ x2=int(math.ceil(x2)),
425
+ y2=int(math.ceil(y2)),
426
+ cls_id=int(cls_id),
427
+ conf=float(conf),
428
+ )
429
+ )
430
+ return results
431
+
432
+ def _postprocess(
433
+ self, output: np.ndarray, ratio: float, pad: tuple[float, float],
434
+ orig_size: tuple[int, int],
435
+ ) -> list[BoundingBox]:
436
+ if output.ndim == 2 and output.shape[1] >= 6:
437
+ return self._decode_final_dets(output, ratio, pad, orig_size)
438
+ if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
439
+ return self._decode_final_dets(output, ratio, pad, orig_size)
440
+ return self._decode_raw_yolo(output, ratio, pad, orig_size)
441
+
442
+ # ---------------------------------------------------------------- inference
443
+ def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
444
+ if image is None:
445
+ raise ValueError("Input image is None")
446
+ if not isinstance(image, np.ndarray):
447
+ raise TypeError(f"Input is not numpy array: {type(image)}")
448
+ if image.ndim != 3:
449
+ raise ValueError(f"Expected HWC image, got shape={image.shape}")
450
+ if image.shape[2] != 3:
451
+ raise ValueError(f"Expected 3 channels, got shape={image.shape}")
452
+ if image.dtype != np.uint8:
453
+ image = image.astype(np.uint8)
454
+
455
+ input_tensor, ratio, pad, orig_size = self._preprocess(image)
456
+ expected = (1, 3, self.input_height, self.input_width)
457
+ if input_tensor.shape != expected:
458
+ raise ValueError(
459
+ f"Bad input tensor shape={input_tensor.shape}, expected={expected}"
460
+ )
461
+ outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
462
+ return self._postprocess(outputs[0], ratio, pad, orig_size)
463
+
464
+ def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
465
+ boxes_orig = self._predict_single(image)
466
+ flipped = cv2.flip(image, 1)
467
+ boxes_flip = self._predict_single(flipped)
468
+ w = image.shape[1]
469
+ boxes_flip = [
470
  BoundingBox(
471
+ x1=w - b.x2, y1=b.y1, x2=w - b.x1, y2=b.y2,
472
+ cls_id=b.cls_id, conf=b.conf,
 
473
  )
474
+ for b in boxes_flip
 
475
  ]
476
+ all_boxes = boxes_orig + boxes_flip
477
+ if not all_boxes:
478
+ return []
479
+ coords = np.array(
480
+ [[b.x1, b.y1, b.x2, b.y2] for b in all_boxes], dtype=np.float32
481
+ )
482
+ scores = np.array([b.conf for b in all_boxes], dtype=np.float32)
483
+ cls_ids = np.array([b.cls_id for b in all_boxes], dtype=np.int32)
484
+
485
+ # Per-class weighted box fusion (replaces hard-NMS + cluster-boost)
486
+ boxes_f, scores_f, cls_f = self._weighted_box_fusion(coords, scores, cls_ids)
487
+ if len(boxes_f) == 0:
488
+ return []
489
+ if len(scores_f) > self.max_det:
490
+ top = np.argsort(-scores_f)[: self.max_det]
491
+ boxes_f, scores_f, cls_f = boxes_f[top], scores_f[top], cls_f[top]
492
+ return self._build_results(boxes_f, scores_f, cls_f)
493
 
494
  def predict_batch(
495
+ self, batch_images: list[ndarray], offset: int, n_keypoints: int,
 
 
 
496
  ) -> list[TVFrameResult]:
497
  results: list[TVFrameResult] = []
498
+ for frame_number_in_batch, image in enumerate(batch_images):
499
  try:
500
+ boxes = self._predict_tta(image)
501
  except Exception as e:
502
+ print(
503
+ f"Inference failed for frame "
504
+ f"{offset + frame_number_in_batch}: {e}"
505
+ )
506
  boxes = []
507
+ results.append(
508
+ TVFrameResult(
509
+ frame_id=offset + frame_number_in_batch,
510
+ boxes=boxes,
511
+ keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
512
+ )
513
+ )
514
  return results