SuperBitDev commited on
Commit
5dcb3e7
·
verified ·
1 Parent(s): 1ceb5a8

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. miner.py +554 -380
  2. weights.onnx +2 -2
miner.py CHANGED
@@ -1,5 +1,4 @@
1
  from pathlib import Path
2
- import math
3
 
4
  import cv2
5
  import numpy as np
@@ -24,17 +23,74 @@ class TVFrameResult(BaseModel):
24
 
25
 
26
  class Miner:
27
- def __init__(self,
28
- path_hf_repo: Path
29
- ) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  model_path = path_hf_repo / "weights.onnx"
31
- # road-signs element — single class. cls_id 0 = "road sign" (matches
32
- # element `objects` and the YOLO training order in yolo_full/data.yaml).
33
- self.class_names = ["road sign"]
34
- model_class_order = ["road sign"]
35
- self.cls_remap = np.array(
36
- [self.class_names.index(n) for n in model_class_order], dtype=np.int32
37
- )
38
  print("ORT version:", ort.__version__)
39
 
40
  try:
@@ -47,16 +103,17 @@ class Miner:
47
 
48
  sess_options = ort.SessionOptions()
49
  sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
 
 
 
50
 
51
  try:
52
  self.session = ort.InferenceSession(
53
  str(model_path),
54
  sess_options=sess_options,
55
- providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
56
  )
57
- print("✅ Created ORT session with preferred CUDA provider list")
58
  except Exception as e:
59
- print(f"⚠️ CUDA session creation failed, falling back to CPU: {e}")
60
  self.session = ort.InferenceSession(
61
  str(model_path),
62
  sess_options=sess_options,
@@ -65,9 +122,25 @@ class Miner:
65
 
66
  print("ORT session providers:", self.session.get_providers())
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  for inp in self.session.get_inputs():
69
  print("INPUT:", inp.name, inp.shape, inp.type)
70
-
71
  for out in self.session.get_outputs():
72
  print("OUTPUT:", out.name, out.shape, out.type)
73
 
@@ -75,32 +148,68 @@ class Miner:
75
  self.output_names = [output.name for output in self.session.get_outputs()]
76
  self.input_shape = self.session.get_inputs()[0].shape
77
 
78
- # Match the ONNX input dtype (this export is FP16 -> needs float16 input).
79
- input_type = self.session.get_inputs()[0].type
80
- self.np_dtype = np.float16 if "float16" in input_type else np.float32
81
- print(f"✅ ONNX input dtype: {input_type} -> numpy {self.np_dtype}")
82
-
83
- # ONNX is fixed-size 1408x1408 (v1 export); read actual shape to be safe.
84
- self.input_height = self._safe_dim(self.input_shape[2], default=1408)
85
- self.input_width = self._safe_dim(self.input_shape[3], default=1408)
86
-
87
- # Tuned for validator scoring (pillars: 0.6*map50 + 0.4*false_positive).
88
- # conf 0.40 was the best element-score point in the v1 eval sweep.
89
- self.conf_thres = 0.40 # Higher = fewer FP, slightly lower recall
90
- self.iou_thres = 0.43 # Lower = suppress duplicate detections (FP)
91
- self.max_det = 200 # Cap detections per image
92
- self.use_tta = True
93
-
94
- # Box sanity filter — kept loose: car-wash `nozzle` boxes are tiny
95
- # (GT median ~290 px², smallest ~32 px²). Fire's 14x14/min_side 8
96
- # would delete valid nozzles, so thresholds are dropped here.
97
- self.min_box_area = 4 * 4 # 16 px²
98
- self.min_side = 3
99
- self.max_aspect_ratio = 12.0
100
 
101
  print(f"✅ ONNX model loaded from: {model_path}")
102
  print(f"✅ ONNX providers: {self.session.get_providers()}")
103
  print(f"✅ ONNX input: name={self.input_name}, shape={self.input_shape}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  def __repr__(self) -> str:
106
  return (
@@ -118,13 +227,6 @@ class Miner:
118
  new_shape: tuple[int, int],
119
  color=(114, 114, 114),
120
  ) -> tuple[ndarray, float, tuple[float, float]]:
121
- """
122
- Resize with unchanged aspect ratio and pad to target shape.
123
- Returns:
124
- padded_image,
125
- ratio,
126
- (pad_w, pad_h) # half-padding
127
- """
128
  h, w = image.shape[:2]
129
  new_w, new_h = new_shape
130
 
@@ -136,10 +238,8 @@ class Miner:
136
  interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
137
  image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
138
 
139
- dw = new_w - resized_w
140
- dh = new_h - resized_h
141
- dw /= 2.0
142
- dh /= 2.0
143
 
144
  left = int(round(dw - 0.1))
145
  right = int(round(dw + 0.1))
@@ -147,38 +247,23 @@ class Miner:
147
  bottom = int(round(dh + 0.1))
148
 
149
  padded = cv2.copyMakeBorder(
150
- image,
151
- top,
152
- bottom,
153
- left,
154
- right,
155
- borderType=cv2.BORDER_CONSTANT,
156
- value=color,
157
  )
158
  return padded, ratio, (dw, dh)
159
 
160
  def _preprocess(
161
  self, image: ndarray
162
  ) -> tuple[np.ndarray, float, tuple[float, float], tuple[int, int]]:
163
- """
164
- Preprocess for fixed-size ONNX export:
165
- - enhance image quality (CLAHE, denoise, sharpen)
166
- - letterbox to model input size
167
- - BGR -> RGB
168
- - normalize to [0,1]
169
- - HWC -> NCHW float32
170
- """
171
  orig_h, orig_w = image.shape[:2]
172
-
173
  img, ratio, pad = self._letterbox(
174
  image, (self.input_width, self.input_height)
175
  )
176
- img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
177
- img = (img.astype(np.float32) / 255.0)
178
- img = np.transpose(img, (2, 0, 1))[None, ...]
179
- img = np.ascontiguousarray(img, dtype=self.np_dtype)
180
-
181
- return img, ratio, pad, (orig_w, orig_h)
182
 
183
  @staticmethod
184
  def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
@@ -198,6 +283,52 @@ class Miner:
198
  out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
199
  return out
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  def _soft_nms(
202
  self,
203
  boxes: np.ndarray,
@@ -205,106 +336,35 @@ class Miner:
205
  sigma: float = 0.5,
206
  score_thresh: float = 0.01,
207
  ) -> tuple[np.ndarray, np.ndarray]:
208
- """
209
- Soft-NMS: Gaussian decay of overlapping scores instead of hard removal.
210
- Returns (kept_original_indices, updated_scores).
211
- """
212
  N = len(boxes)
213
  if N == 0:
214
  return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
215
-
216
  boxes = boxes.astype(np.float32, copy=True)
217
  scores = scores.astype(np.float32, copy=True)
218
  order = np.arange(N)
219
-
220
  for i in range(N):
221
  max_pos = i + int(np.argmax(scores[i:]))
222
  boxes[[i, max_pos]] = boxes[[max_pos, i]]
223
  scores[[i, max_pos]] = scores[[max_pos, i]]
224
  order[[i, max_pos]] = order[[max_pos, i]]
225
-
226
  if i + 1 >= N:
227
  break
228
-
229
  xx1 = np.maximum(boxes[i, 0], boxes[i + 1:, 0])
230
  yy1 = np.maximum(boxes[i, 1], boxes[i + 1:, 1])
231
  xx2 = np.minimum(boxes[i, 2], boxes[i + 1:, 2])
232
  yy2 = np.minimum(boxes[i, 3], boxes[i + 1:, 3])
233
  inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
234
-
235
  area_i = max(0.0, float(
236
- (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])
237
- ))
238
- areas_j = (
239
- np.maximum(0.0, boxes[i + 1:, 2] - boxes[i + 1:, 0])
240
- * np.maximum(0.0, boxes[i + 1:, 3] - boxes[i + 1:, 1])
241
- )
242
  iou = inter / (area_i + areas_j - inter + 1e-7)
243
  scores[i + 1:] *= np.exp(-(iou ** 2) / sigma)
244
-
245
  mask = scores > score_thresh
246
  return order[mask], scores[mask]
247
 
248
- @staticmethod
249
- def _hard_nms(
250
- boxes: np.ndarray,
251
- scores: np.ndarray,
252
- iou_thresh: float,
253
- ) -> np.ndarray:
254
- """
255
- Standard NMS: keep one box per overlapping cluster (the one with highest score).
256
- Returns indices of kept boxes (into the boxes/scores arrays).
257
- """
258
- N = len(boxes)
259
- if N == 0:
260
- return np.array([], dtype=np.intp)
261
- boxes = np.asarray(boxes, dtype=np.float32)
262
- scores = np.asarray(scores, dtype=np.float32)
263
- order = np.argsort(scores)[::-1]
264
- keep: list[int] = []
265
- suppressed = np.zeros(N, dtype=bool)
266
- for i in range(N):
267
- idx = order[i]
268
- if suppressed[idx]:
269
- continue
270
- keep.append(idx)
271
- bi = boxes[idx]
272
- for k in range(i + 1, N):
273
- jdx = order[k]
274
- if suppressed[jdx]:
275
- continue
276
- bj = boxes[jdx]
277
- xx1 = max(bi[0], bj[0])
278
- yy1 = max(bi[1], bj[1])
279
- xx2 = min(bi[2], bj[2])
280
- yy2 = min(bi[3], bj[3])
281
- inter = max(0.0, xx2 - xx1) * max(0.0, yy2 - yy1)
282
- area_i = (bi[2] - bi[0]) * (bi[3] - bi[1])
283
- area_j = (bj[2] - bj[0]) * (bj[3] - bj[1])
284
- iou = inter / (area_i + area_j - inter + 1e-7)
285
- if iou > iou_thresh:
286
- suppressed[jdx] = True
287
- return np.array(keep)
288
-
289
- def _per_class_hard_nms(
290
- self,
291
- boxes: np.ndarray,
292
- scores: np.ndarray,
293
- cls_ids: np.ndarray,
294
- iou_thresh: float,
295
- ) -> np.ndarray:
296
- """Hard NMS applied independently per class."""
297
- if len(boxes) == 0:
298
- return np.array([], dtype=np.intp)
299
- all_keep: list[int] = []
300
- for c in np.unique(cls_ids):
301
- mask = cls_ids == c
302
- indices = np.where(mask)[0]
303
- keep = self._hard_nms(boxes[mask], scores[mask], iou_thresh)
304
- all_keep.extend(indices[keep].tolist())
305
- all_keep.sort()
306
- return np.array(all_keep, dtype=np.intp)
307
-
308
  def _per_class_soft_nms(
309
  self,
310
  boxes: np.ndarray,
@@ -313,22 +373,121 @@ class Miner:
313
  sigma: float = 0.5,
314
  score_thresh: float = 0.01,
315
  ) -> tuple[np.ndarray, np.ndarray]:
316
- """Soft NMS applied independently per class."""
317
  if len(boxes) == 0:
318
  return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
319
  all_keep: list[int] = []
320
  all_scores: list[float] = []
321
  for c in np.unique(cls_ids):
322
- mask = cls_ids == c
323
- indices = np.where(mask)[0]
324
- keep, updated = self._soft_nms(boxes[mask], scores[mask], sigma, score_thresh)
325
  for k, s in zip(keep, updated):
326
- all_keep.append(int(indices[k]))
327
- all_scores.append(float(s))
328
  if not all_keep:
329
  return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
330
  return np.array(all_keep, dtype=np.intp), np.array(all_scores, dtype=np.float32)
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  def _filter_sane_boxes(
333
  self,
334
  boxes: np.ndarray,
@@ -336,7 +495,7 @@ class Miner:
336
  cls_ids: np.ndarray,
337
  orig_size: tuple[int, int],
338
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
339
- """Filter out tiny, degenerate, or implausible boxes (common FP)."""
340
  if len(boxes) == 0:
341
  return boxes, scores, cls_ids
342
  orig_w, orig_h = orig_size
@@ -368,37 +527,67 @@ class Miner:
368
  k = np.array(keep, dtype=np.intp)
369
  return boxes[k], scores[k], cls_ids[k]
370
 
371
- @staticmethod
372
- def _max_score_per_cluster(
373
- coords: np.ndarray,
374
  scores: np.ndarray,
375
- keep_indices: np.ndarray,
376
- iou_thresh: float,
377
- ) -> np.ndarray:
378
- """
379
- For each kept box, return the max original score among itself and any
380
- box that overlaps it with IOU >= iou_thresh (so TTA cluster keeps best conf).
381
- """
382
- n_keep = len(keep_indices)
383
- if n_keep == 0:
384
- return np.array([], dtype=np.float32)
385
- out = np.empty(n_keep, dtype=np.float32)
386
- coords = np.asarray(coords, dtype=np.float32)
387
- scores = np.asarray(scores, dtype=np.float32)
388
- for i in range(n_keep):
389
- idx = keep_indices[i]
390
- bi = coords[idx]
391
- xx1 = np.maximum(bi[0], coords[:, 0])
392
- yy1 = np.maximum(bi[1], coords[:, 1])
393
- xx2 = np.minimum(bi[2], coords[:, 2])
394
- yy2 = np.minimum(bi[3], coords[:, 3])
395
- inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
396
- area_i = (bi[2] - bi[0]) * (bi[3] - bi[1])
397
- areas_j = (coords[:, 2] - coords[:, 0]) * (coords[:, 3] - coords[:, 1])
398
- iou = inter / (area_i + areas_j - inter + 1e-7)
399
- in_cluster = iou >= iou_thresh
400
- out[i] = float(np.max(scores[in_cluster]))
401
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
403
  def _decode_final_dets(
404
  self,
@@ -406,16 +595,10 @@ class Miner:
406
  ratio: float,
407
  pad: tuple[float, float],
408
  orig_size: tuple[int, int],
409
- apply_optional_dedup: bool = False,
410
  ) -> list[BoundingBox]:
411
- """
412
- Primary path:
413
- expected output rows like [x1, y1, x2, y2, conf, cls_id]
414
- in letterboxed input coordinates.
415
- """
416
  if preds.ndim == 3 and preds.shape[0] == 1:
417
  preds = preds[0]
418
-
419
  if preds.ndim != 2 or preds.shape[1] < 6:
420
  raise ValueError(f"Unexpected ONNX final-det output shape: {preds.shape}")
421
 
@@ -424,88 +607,27 @@ class Miner:
424
  cls_ids = preds[:, 5].astype(np.int32)
425
  cls_ids = self.cls_remap[cls_ids]
426
 
427
- # Save raw before primary conf filter for rescue path
428
- raw_boxes = boxes.copy()
429
- raw_scores = scores.copy()
430
- raw_cls_ids = cls_ids.copy()
431
-
432
- keep = scores >= self.conf_thres
433
  boxes = boxes[keep]
434
  scores = scores[keep]
435
  cls_ids = cls_ids[keep]
436
-
437
- # Rescue: for each class, if 0 boxes passed primary threshold,
438
- # take the top-1 raw candidate if its score >= rescue_thres.
439
- # Avoids zero-prediction frames where validator scores us composite ~0.05.
440
- rescue_margin = 0.10
441
- rescue_thres = max(0.0, self.conf_thres - rescue_margin)
442
- present_cls = set(cls_ids.tolist()) if len(cls_ids) > 0 else set()
443
- for tgt_cid in range(len(self.class_names)):
444
- if tgt_cid in present_cls:
445
- continue
446
- cls_mask = raw_cls_ids == tgt_cid
447
- if not cls_mask.any():
448
- continue
449
- cls_scores = raw_scores[cls_mask]
450
- top_pos = int(np.argmax(cls_scores))
451
- if float(cls_scores[top_pos]) >= rescue_thres:
452
- cls_indices = np.where(cls_mask)[0]
453
- chosen = cls_indices[top_pos]
454
- boxes = np.vstack([boxes, raw_boxes[chosen:chosen + 1]]) if len(boxes) > 0 else raw_boxes[chosen:chosen + 1]
455
- scores = np.append(scores, raw_scores[chosen])
456
- cls_ids = np.append(cls_ids, tgt_cid)
457
-
458
  if len(boxes) == 0:
459
  return []
460
 
461
  pad_w, pad_h = pad
462
- orig_w, orig_h = orig_size
463
-
464
- # reverse letterbox
465
  boxes[:, [0, 2]] -= pad_w
466
  boxes[:, [1, 3]] -= pad_h
467
  boxes /= ratio
468
- boxes = self._clip_boxes(boxes, (orig_w, orig_h))
469
 
470
- # Box sanity filter (reduces FP)
471
  boxes, scores, cls_ids = self._filter_sane_boxes(
472
  boxes, scores, cls_ids, orig_size
473
  )
474
  if len(boxes) == 0:
475
  return []
476
 
477
- # Per-class NMS to remove duplicates without suppressing across classes
478
- if len(boxes) > 1:
479
- if apply_optional_dedup:
480
- keep_idx, scores = self._per_class_soft_nms(boxes, scores, cls_ids)
481
- boxes = boxes[keep_idx]
482
- cls_ids = cls_ids[keep_idx]
483
- else:
484
- keep_idx = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
485
- keep_idx = keep_idx[: self.max_det]
486
- boxes = boxes[keep_idx]
487
- scores = scores[keep_idx]
488
- cls_ids = cls_ids[keep_idx]
489
-
490
- results: list[BoundingBox] = []
491
- for box, conf, cls_id in zip(boxes, scores, cls_ids):
492
- x1, y1, x2, y2 = box.tolist()
493
-
494
- if x2 <= x1 or y2 <= y1:
495
- continue
496
-
497
- results.append(
498
- BoundingBox(
499
- x1=int(math.floor(x1)),
500
- y1=int(math.floor(y1)),
501
- x2=int(math.ceil(x2)),
502
- y2=int(math.ceil(y2)),
503
- cls_id=int(cls_id),
504
- conf=float(conf),
505
- )
506
- )
507
-
508
- return results
509
 
510
  def _decode_raw_yolo(
511
  self,
@@ -514,30 +636,17 @@ class Miner:
514
  pad: tuple[float, float],
515
  orig_size: tuple[int, int],
516
  ) -> list[BoundingBox]:
517
- """
518
- Fallback path for raw YOLO predictions.
519
- Supports common layouts:
520
- - [1, C, N]
521
- - [1, N, C]
522
- """
523
- if preds.ndim != 3:
524
  raise ValueError(f"Unexpected raw ONNX output shape: {preds.shape}")
525
-
526
- if preds.shape[0] != 1:
527
- raise ValueError(f"Unexpected batch dimension in raw output: {preds.shape}")
528
-
529
  preds = preds[0]
530
-
531
- # Normalize to [N, C]
532
  if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
533
  preds = preds.T
534
-
535
  if preds.ndim != 2 or preds.shape[1] < 5:
536
- raise ValueError(f"Unexpected normalized raw output shape: {preds.shape}")
537
 
538
  boxes_xywh = preds[:, :4].astype(np.float32)
539
  cls_part = preds[:, 4:].astype(np.float32)
540
-
541
  if cls_part.shape[1] == 1:
542
  scores = cls_part[:, 0]
543
  cls_ids = np.zeros(len(scores), dtype=np.int32)
@@ -546,55 +655,28 @@ class Miner:
546
  scores = cls_part[np.arange(len(cls_part)), cls_ids]
547
  cls_ids = self.cls_remap[cls_ids]
548
 
549
- keep = scores >= self.conf_thres
550
  boxes_xywh = boxes_xywh[keep]
551
  scores = scores[keep]
552
  cls_ids = cls_ids[keep]
553
-
554
  if len(boxes_xywh) == 0:
555
  return []
556
-
557
  boxes = self._xywh_to_xyxy(boxes_xywh)
558
 
559
- keep_idx = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
560
- keep_idx = keep_idx[: self.max_det]
561
- boxes = boxes[keep_idx]
562
- scores = scores[keep_idx]
563
- cls_ids = cls_ids[keep_idx]
564
-
565
  pad_w, pad_h = pad
566
- orig_w, orig_h = orig_size
567
-
568
  boxes[:, [0, 2]] -= pad_w
569
  boxes[:, [1, 3]] -= pad_h
570
  boxes /= ratio
571
- boxes = self._clip_boxes(boxes, (orig_w, orig_h))
572
 
573
  boxes, scores, cls_ids = self._filter_sane_boxes(
574
- boxes, scores, cls_ids, (orig_w, orig_h)
575
  )
576
  if len(boxes) == 0:
577
  return []
578
 
579
- results: list[BoundingBox] = []
580
- for box, conf, cls_id in zip(boxes, scores, cls_ids):
581
- x1, y1, x2, y2 = box.tolist()
582
-
583
- if x2 <= x1 or y2 <= y1:
584
- continue
585
-
586
- results.append(
587
- BoundingBox(
588
- x1=int(math.floor(x1)),
589
- y1=int(math.floor(y1)),
590
- x2=int(math.ceil(x2)),
591
- y2=int(math.ceil(y2)),
592
- cls_id=int(cls_id),
593
- conf=float(conf),
594
- )
595
- )
596
-
597
- return results
598
 
599
  def _postprocess(
600
  self,
@@ -603,19 +685,10 @@ class Miner:
603
  pad: tuple[float, float],
604
  orig_size: tuple[int, int],
605
  ) -> list[BoundingBox]:
606
- """
607
- Prefer final detections first.
608
- Fallback to raw decode only if needed.
609
- """
610
- # final detections: [N,6]
611
  if output.ndim == 2 and output.shape[1] >= 6:
612
  return self._decode_final_dets(output, ratio, pad, orig_size)
613
-
614
- # final detections: [1,N,6]
615
  if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
616
  return self._decode_final_dets(output, ratio, pad, orig_size)
617
-
618
- # fallback raw decode
619
  return self._decode_raw_yolo(output, ratio, pad, orig_size)
620
 
621
  def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
@@ -629,33 +702,33 @@ class Miner:
629
  raise ValueError(f"Invalid image shape={image.shape}")
630
  if image.shape[2] != 3:
631
  raise ValueError(f"Expected 3 channels, got shape={image.shape}")
632
-
633
  if image.dtype != np.uint8:
634
  image = image.astype(np.uint8)
635
 
636
  input_tensor, ratio, pad, orig_size = self._preprocess(image)
637
-
638
- expected_shape = (1, 3, self.input_height, self.input_width)
639
- if input_tensor.shape != expected_shape:
640
  raise ValueError(
641
- f"Bad input tensor shape={input_tensor.shape}, expected={expected_shape}"
642
  )
643
 
644
  outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
645
- det_output = outputs[0]
646
- return self._postprocess(det_output, ratio, pad, orig_size)
647
 
648
  def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
649
- """
650
- Horizontal-flip TTA: merge original + flipped via hard NMS.
651
- Boost confidence for consensus detections (both views agree) to improve
652
- mAP: validator sorts by confidence, so higher conf for TP helps PR curve.
 
 
 
 
 
653
  """
654
  boxes_orig = self._predict_single(image)
655
-
656
  flipped = cv2.flip(image, 1)
657
  boxes_flip = self._predict_single(flipped)
658
-
659
  w = image.shape[1]
660
  boxes_flip = [
661
  BoundingBox(
@@ -664,9 +737,8 @@ class Miner:
664
  )
665
  for b in boxes_flip
666
  ]
667
-
668
  all_boxes = boxes_orig + boxes_flip
669
- if len(all_boxes) == 0:
670
  return []
671
 
672
  coords = np.array(
@@ -678,57 +750,166 @@ class Miner:
678
  hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
679
  if len(hard_keep) == 0:
680
  return []
 
 
 
681
 
682
- hard_keep = hard_keep[: self.max_det]
683
-
684
- # Boost confidence when both views agree (overlapping detections)
685
  boosted = self._max_score_per_cluster(
686
- coords, scores, hard_keep, self.iou_thres
 
687
  )
688
 
 
 
 
 
 
 
 
 
 
 
689
  return [
690
  BoundingBox(
691
- x1=all_boxes[i].x1,
692
- y1=all_boxes[i].y1,
693
- x2=all_boxes[i].x2,
694
- y2=all_boxes[i].y2,
695
- cls_id=all_boxes[i].cls_id,
696
  conf=float(boosted[j]),
697
  )
698
- for j, i in enumerate(hard_keep)
699
  ]
700
 
701
- def _guaranteed_top1(self, image: np.ndarray) -> list[BoundingBox]:
702
- """Last-resort: NEVER emit zero boxes. Returning nothing scores 0
703
- (the validator gives a zero-prediction frame composite ~0). When every
704
- detection is below threshold, return the single highest-confidence raw
705
- detection unconditionally (no conf/sanity/NMS filtering)."""
706
- try:
707
- input_tensor, ratio, pad, orig_size = self._preprocess(image)
708
- out = self.session.run(self.output_names, {self.input_name: input_tensor})[0]
709
- if out.ndim == 3 and out.shape[0] == 1:
710
- out = out[0]
711
- if out.ndim != 2 or out.shape[1] < 6 or out.shape[0] == 0:
712
- return []
713
- scores = out[:, 4].astype(np.float32)
714
- top = int(np.argmax(scores))
715
- box = out[top, :4].astype(np.float32).copy()
716
- cls_id = int(self.cls_remap[int(out[top, 5])])
717
- conf = float(scores[top])
718
- # reverse letterbox -> original image coords
719
- pad_w, pad_h = pad
720
- box[[0, 2]] -= pad_w
721
- box[[1, 3]] -= pad_h
722
- box /= ratio
723
- box = self._clip_boxes(box.reshape(1, 4), orig_size)[0]
724
- x1, y1, x2, y2 = box.tolist()
725
- if x2 <= x1 or y2 <= y1:
726
- return []
727
- return [BoundingBox(x1=int(x1), y1=int(y1), x2=int(x2), y2=int(y2),
728
- cls_id=cls_id, conf=conf)]
729
- except Exception as e:
730
- print(f"⚠️ guaranteed_top1 failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732
 
733
  def predict_batch(
734
  self,
@@ -737,21 +918,15 @@ class Miner:
737
  n_keypoints: int,
738
  ) -> list[TVFrameResult]:
739
  results: list[TVFrameResult] = []
740
-
741
  for frame_number_in_batch, image in enumerate(batch_images):
742
  try:
743
- if self.use_tta:
744
- boxes = self._predict_tta(image)
745
- else:
746
- boxes = self._predict_single(image)
747
  except Exception as e:
748
- print(f"⚠️ Inference failed for frame {offset + frame_number_in_batch}: {e}")
 
 
 
749
  boxes = []
750
-
751
- # Never return an empty frame: fall back to the single highest-prob box.
752
- if not boxes:
753
- boxes = self._guaranteed_top1(image)
754
-
755
  results.append(
756
  TVFrameResult(
757
  frame_id=offset + frame_number_in_batch,
@@ -759,5 +934,4 @@ class Miner:
759
  keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
760
  )
761
  )
762
-
763
- return results
 
1
  from pathlib import Path
 
2
 
3
  import cv2
4
  import numpy as np
 
23
 
24
 
25
  class Miner:
26
+ """ONNX Runtime miner for road-sign detection (single class).
27
+ Strategy (ported from offense / fire001 miner):
28
+ - per-class confidence threshold with per-class rescue bonus
29
+ - per-class hard NMS, then cross-class dedup (no-op for single class)
30
+ - horizontal-flip TTA with full-set cluster score boost
31
+ Plus: class remap, sanity-box filter tuned for small distant signs,
32
+ TTA toggle.
33
+ """
34
+
35
+ class_names = ["road_sign"]
36
+ # Order the model emits classes in -- remapped to `class_names` index.
37
+ _model_class_order = ["road_sign"]
38
+
39
+ iou_thres = 0.5
40
+ cross_iou_thresh = 0.8
41
+ max_det = 150
42
+
43
+ # Per-class confidence threshold. Road signs in this dataset are
44
+ # frequently degraded / rear-facing / partly-obscured / distant, so we
45
+ # run noticeably below the fire/smoke baseline. The validator's
46
+ # false_positive pillar = max(0, 1 - ffpi/10): we can tolerate ~2 FP per
47
+ # image and still keep that pillar above 0.8.
48
+ _conf_thres_array = np.array(
49
+ [0.33], dtype=np.float32
50
+ )
51
+ # Per-class rescue bonus. If a class has ZERO boxes passing the threshold
52
+ # in a frame, its top-1 candidate is admitted when its score is at least
53
+ # (threshold - bonus). Bumped from 0.05 -> 0.10 so a single faint sign in
54
+ # an otherwise empty frame still produces a detection (map50 recall win,
55
+ # at most one extra FP per such frame).
56
+ _bonus_array = np.array(
57
+ [0.12], dtype=np.float32
58
+ )
59
+
60
+ # Box sanity filter: drop tiny / degenerate / image-spanning / extreme
61
+ # aspect ratio boxes.
62
+ # min_box_area = 14x14 -> 14x14 is the smallest credible sign. The old
63
+ # value of 64 (8x8) silently discarded narrow
64
+ # distant signs like a 10x6 px overhead chevron.
65
+ # min_side = 3 -> matches min_box_area; anything thinner is
66
+ # almost certainly a pole or shadow false alarm.
67
+ # max_aspect_ratio = 12.0
68
+ # -> overhead destination panels and lane-assignment
69
+ # signs are very wide (long, thin rectangles);
70
+ # 8.0 was clipping legitimate detections.
71
+ min_box_area = 8 * 8
72
+ min_side = 3
73
+ max_aspect_ratio = 12.0
74
+
75
+ # Final box-size calibration. The detector + de-letterbox + integer-rounding
76
+ # pipeline emits boxes slightly larger than the object, so shrink every
77
+ # emitted box about its center by a fixed per-axis factor before output:
78
+ # new_w = w / box_shrink_w, new_h = h / box_shrink_h.
79
+ box_shrink_w = 1.027
80
+ box_shrink_h = 1.014
81
+
82
+ # Tile-based TTA: when the source image is significantly larger than the
83
+ # model input, letterboxing throws away ~1.5x of effective resolution,
84
+ # which kills small-sign recall. Splitting into overlapping horizontal
85
+ # tiles preserves native resolution on each half. Triggered only when
86
+ # source width >= tile_trigger_ratio * model_input_width to avoid wasted
87
+ # compute on already-small images.
88
+ tile_trigger_ratio = 1.4
89
+ tile_overlap_ratio = 0.20
90
+
91
+ def __init__(self, path_hf_repo: Path) -> None:
92
  model_path = path_hf_repo / "weights.onnx"
93
+
 
 
 
 
 
 
94
  print("ORT version:", ort.__version__)
95
 
96
  try:
 
103
 
104
  sess_options = ort.SessionOptions()
105
  sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
106
+ sess_options.intra_op_num_threads = 2
107
+ sess_options.inter_op_num_threads = 1
108
+ sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
109
 
110
  try:
111
  self.session = ort.InferenceSession(
112
  str(model_path),
113
  sess_options=sess_options,
114
+ providers=["CPUExecutionProvider"],
115
  )
 
116
  except Exception as e:
 
117
  self.session = ort.InferenceSession(
118
  str(model_path),
119
  sess_options=sess_options,
 
122
 
123
  print("ORT session providers:", self.session.get_providers())
124
 
125
+ # Build cls_remap: for each model-emit index i,
126
+ # cls_remap[i] = self.class_names.index(model_class_order[i])
127
+ # i.e. convert a model-side class id into the output class id that
128
+ # downstream code (BoundingBox.cls_id, the per-class threshold/bonus
129
+ # arrays) expects. The model-side order comes from the ONNX metadata
130
+ # when available, else falls back to the static _model_class_order.
131
+ model_class_order = self._read_model_class_order()
132
+ if model_class_order is None:
133
+ model_class_order = list(self._model_class_order)
134
+ print(f"cls order: no usable ONNX metadata, FALLBACK {model_class_order}")
135
+ else:
136
+ print(f"cls order: from ONNX metadata {model_class_order}")
137
+ self.cls_remap = np.array(
138
+ [self.class_names.index(n) for n in model_class_order],
139
+ dtype=np.int32,
140
+ )
141
+
142
  for inp in self.session.get_inputs():
143
  print("INPUT:", inp.name, inp.shape, inp.type)
 
144
  for out in self.session.get_outputs():
145
  print("OUTPUT:", out.name, out.shape, out.type)
146
 
 
148
  self.output_names = [output.name for output in self.session.get_outputs()]
149
  self.input_shape = self.session.get_inputs()[0].shape
150
 
151
+ # weights.onnx is exported at 1280x1280 (Ultralytics imgsz metadata),
152
+ # static (dynamic=False). The default is only the fallback for when the
153
+ # ONNX input dims aren't fixed; the real value is read from the session.
154
+ self.input_height = self._safe_dim(self.input_shape[2], default=1280)
155
+ self.input_width = self._safe_dim(self.input_shape[3], default=1280)
156
+
157
+ self.use_tta = False
158
+ self.use_tile_tta = False
159
+ # Soft-NMS (ported from carwash001): Gaussian score decay of overlapping
160
+ # boxes instead of hard removal. OFF by default to preserve the current
161
+ # deployed behaviour; flip on (and tune sigma) via tune_miner.py to see if
162
+ # it scores better useful where signs cluster (gantries, sign assemblies).
163
+ self.use_soft_nms = False
164
+ self.soft_nms_sigma = 0.5
165
+ self.soft_nms_score_thresh = 0.01
 
 
 
 
 
 
 
166
 
167
  print(f"✅ ONNX model loaded from: {model_path}")
168
  print(f"✅ ONNX providers: {self.session.get_providers()}")
169
  print(f"✅ ONNX input: name={self.input_name}, shape={self.input_shape}")
170
+ print(f"✅ ONNX input size: {self.input_width}x{self.input_height}, "
171
+ f"use_tta={self.use_tta}, use_tile_tta={self.use_tile_tta}")
172
+ print("per-class conf: " + ", ".join(
173
+ f"{n}={t:.3f}" for n, t in zip(
174
+ self.class_names, self._conf_thres_array.tolist()
175
+ )
176
+ ))
177
+
178
+ self._warmup()
179
+
180
+ def _warmup(self, iters: int = 3) -> None:
181
+ try:
182
+ dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
183
+ for _ in range(max(1, iters)):
184
+ self.predict_batch(batch_images=[dummy], offset=0, n_keypoints=0)
185
+ print(f"✅ warmup: {iters} dummy predict_batch call(s) done")
186
+ except Exception as e:
187
+ print(f"⚠️ warmup skipped: {e}")
188
+
189
+ def _read_model_class_order(self) -> "list[str] | None":
190
+ """Read the model's class order from Ultralytics ONNX metadata.
191
+ Returns the class names ordered by model-emit index, or None when the
192
+ metadata is missing/unparsable or doesn't match `class_names` as a set
193
+ (in which case the static _model_class_order fallback is used)."""
194
+ try:
195
+ import ast
196
+
197
+ meta = self.session.get_modelmeta().custom_metadata_map
198
+ names = ast.literal_eval(meta["names"]) # e.g. {0: 'road_sign'}
199
+ if isinstance(names, dict):
200
+ order = [str(names[i]) for i in sorted(names)]
201
+ else:
202
+ order = [str(n) for n in names]
203
+ except Exception as e:
204
+ print(f"cls order: could not read ONNX names metadata ({e})")
205
+ return None
206
+ if sorted(order) != sorted(self.class_names):
207
+ print(
208
+ f"cls order: ONNX names {order} do not match expected classes "
209
+ f"{self.class_names}; ignoring metadata"
210
+ )
211
+ return None
212
+ return order
213
 
214
  def __repr__(self) -> str:
215
  return (
 
227
  new_shape: tuple[int, int],
228
  color=(114, 114, 114),
229
  ) -> tuple[ndarray, float, tuple[float, float]]:
 
 
 
 
 
 
 
230
  h, w = image.shape[:2]
231
  new_w, new_h = new_shape
232
 
 
238
  interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
239
  image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
240
 
241
+ dw = (new_w - resized_w) / 2.0
242
+ dh = (new_h - resized_h) / 2.0
 
 
243
 
244
  left = int(round(dw - 0.1))
245
  right = int(round(dw + 0.1))
 
247
  bottom = int(round(dh + 0.1))
248
 
249
  padded = cv2.copyMakeBorder(
250
+ image, top, bottom, left, right,
251
+ borderType=cv2.BORDER_CONSTANT, value=color,
 
 
 
 
 
252
  )
253
  return padded, ratio, (dw, dh)
254
 
255
  def _preprocess(
256
  self, image: ndarray
257
  ) -> tuple[np.ndarray, float, tuple[float, float], tuple[int, int]]:
 
 
 
 
 
 
 
 
258
  orig_h, orig_w = image.shape[:2]
 
259
  img, ratio, pad = self._letterbox(
260
  image, (self.input_width, self.input_height)
261
  )
262
+ # Fused scale(1/255) + BGR->RGB swap + HWC->NCHW + contiguous float32 in
263
+ # one optimized OpenCV call (bit-identical to the cvtColor + astype/255 +
264
+ # transpose chain, but ~half the preprocess time).
265
+ blob = cv2.dnn.blobFromImage(img, scalefactor=1.0 / 255.0, swapRB=True)
266
+ return blob, ratio, pad, (orig_w, orig_h)
 
267
 
268
  @staticmethod
269
  def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
 
283
  out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
284
  return out
285
 
286
+ @staticmethod
287
+ def _hard_nms(
288
+ boxes: np.ndarray, scores: np.ndarray, iou_thresh: float
289
+ ) -> np.ndarray:
290
+ n = len(boxes)
291
+ if n == 0:
292
+ return np.array([], dtype=np.intp)
293
+ order = np.argsort(-scores)
294
+ keep: list[int] = []
295
+ while len(order) > 0:
296
+ i = int(order[0])
297
+ keep.append(i)
298
+ if len(order) == 1:
299
+ break
300
+ rest = order[1:]
301
+ xx1 = np.maximum(boxes[i, 0], boxes[rest, 0])
302
+ yy1 = np.maximum(boxes[i, 1], boxes[rest, 1])
303
+ xx2 = np.minimum(boxes[i, 2], boxes[rest, 2])
304
+ yy2 = np.minimum(boxes[i, 3], boxes[rest, 3])
305
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
306
+ a_i = (max(0.0, boxes[i, 2] - boxes[i, 0]) *
307
+ max(0.0, boxes[i, 3] - boxes[i, 1]))
308
+ a_r = (np.maximum(0.0, boxes[rest, 2] - boxes[rest, 0]) *
309
+ np.maximum(0.0, boxes[rest, 3] - boxes[rest, 1]))
310
+ iou = inter / (a_i + a_r - inter + 1e-7)
311
+ order = rest[iou <= iou_thresh]
312
+ return np.array(keep, dtype=np.intp)
313
+
314
+ def _per_class_hard_nms(
315
+ self,
316
+ boxes: np.ndarray,
317
+ scores: np.ndarray,
318
+ cls_ids: np.ndarray,
319
+ iou_thresh: float,
320
+ ) -> np.ndarray:
321
+ if len(boxes) == 0:
322
+ return np.array([], dtype=np.intp)
323
+ all_keep: list[int] = []
324
+ for c in np.unique(cls_ids):
325
+ mask = cls_ids == c
326
+ indices = np.where(mask)[0]
327
+ keep = self._hard_nms(boxes[mask], scores[mask], iou_thresh)
328
+ all_keep.extend(indices[keep].tolist())
329
+ all_keep.sort()
330
+ return np.array(all_keep, dtype=np.intp)
331
+
332
  def _soft_nms(
333
  self,
334
  boxes: np.ndarray,
 
336
  sigma: float = 0.5,
337
  score_thresh: float = 0.01,
338
  ) -> tuple[np.ndarray, np.ndarray]:
339
+ """Soft-NMS: Gaussian decay of overlapping scores instead of hard removal.
340
+ Returns (kept_original_indices, updated_scores). (Ported from carwash001.)"""
 
 
341
  N = len(boxes)
342
  if N == 0:
343
  return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
 
344
  boxes = boxes.astype(np.float32, copy=True)
345
  scores = scores.astype(np.float32, copy=True)
346
  order = np.arange(N)
 
347
  for i in range(N):
348
  max_pos = i + int(np.argmax(scores[i:]))
349
  boxes[[i, max_pos]] = boxes[[max_pos, i]]
350
  scores[[i, max_pos]] = scores[[max_pos, i]]
351
  order[[i, max_pos]] = order[[max_pos, i]]
 
352
  if i + 1 >= N:
353
  break
 
354
  xx1 = np.maximum(boxes[i, 0], boxes[i + 1:, 0])
355
  yy1 = np.maximum(boxes[i, 1], boxes[i + 1:, 1])
356
  xx2 = np.minimum(boxes[i, 2], boxes[i + 1:, 2])
357
  yy2 = np.minimum(boxes[i, 3], boxes[i + 1:, 3])
358
  inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
 
359
  area_i = max(0.0, float(
360
+ (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])))
361
+ areas_j = (np.maximum(0.0, boxes[i + 1:, 2] - boxes[i + 1:, 0])
362
+ * np.maximum(0.0, boxes[i + 1:, 3] - boxes[i + 1:, 1]))
 
 
 
363
  iou = inter / (area_i + areas_j - inter + 1e-7)
364
  scores[i + 1:] *= np.exp(-(iou ** 2) / sigma)
 
365
  mask = scores > score_thresh
366
  return order[mask], scores[mask]
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  def _per_class_soft_nms(
369
  self,
370
  boxes: np.ndarray,
 
373
  sigma: float = 0.5,
374
  score_thresh: float = 0.01,
375
  ) -> tuple[np.ndarray, np.ndarray]:
376
+ """Soft-NMS applied independently per class. Returns (kept_idx, updated_scores)."""
377
  if len(boxes) == 0:
378
  return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
379
  all_keep: list[int] = []
380
  all_scores: list[float] = []
381
  for c in np.unique(cls_ids):
382
+ indices = np.where(cls_ids == c)[0]
383
+ keep, updated = self._soft_nms(boxes[indices], scores[indices],
384
+ sigma, score_thresh)
385
  for k, s in zip(keep, updated):
386
+ all_keep.append(int(indices[k])); all_scores.append(float(s))
 
387
  if not all_keep:
388
  return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
389
  return np.array(all_keep, dtype=np.intp), np.array(all_scores, dtype=np.float32)
390
 
391
+ def _cross_class_dedup_op(
392
+ self,
393
+ boxes: np.ndarray,
394
+ scores: np.ndarray,
395
+ cls_ids: np.ndarray,
396
+ iou_thresh: float,
397
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
398
+ """Remove near-duplicate boxes across classes.
399
+ Order candidates by (score - per_class_threshold) margin, then by area;
400
+ keep the highest, suppress every other box with IoU > iou_thresh.
401
+ With a single road_sign class this is effectively a no-op, but the
402
+ method is kept so the pipeline stays compatible with the multi-class
403
+ miner template.
404
+ """
405
+ n = len(boxes)
406
+ if n <= 1:
407
+ return boxes, scores, cls_ids
408
+ boxes = np.asarray(boxes, dtype=np.float32)
409
+ scores = np.asarray(scores, dtype=np.float32)
410
+ cls_ids = np.asarray(cls_ids, dtype=np.int32)
411
+ areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *
412
+ np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))
413
+ margins = scores - self._conf_thres_array[cls_ids]
414
+ order = np.lexsort((-areas, -margins))
415
+ suppressed = np.zeros(n, dtype=bool)
416
+ keep: list[int] = []
417
+ for i in order:
418
+ if suppressed[i]:
419
+ continue
420
+ keep.append(int(i))
421
+ bi = boxes[i]
422
+ xx1 = np.maximum(bi[0], boxes[:, 0])
423
+ yy1 = np.maximum(bi[1], boxes[:, 1])
424
+ xx2 = np.minimum(bi[2], boxes[:, 2])
425
+ yy2 = np.minimum(bi[3], boxes[:, 3])
426
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
427
+ a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
428
+ iou = inter / (a_i + areas - inter + 1e-7)
429
+ dup = iou > iou_thresh
430
+ dup[i] = False
431
+ suppressed |= dup
432
+ keep_idx = np.array(keep, dtype=np.intp)
433
+ return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]
434
+
435
+ @staticmethod
436
+ def _max_score_per_cluster(
437
+ post_boxes: np.ndarray,
438
+ post_cls: np.ndarray,
439
+ full_boxes: np.ndarray,
440
+ full_scores: np.ndarray,
441
+ full_cls: np.ndarray,
442
+ iou_thresh: float,
443
+ ) -> np.ndarray:
444
+ """For each kept (post-NMS) box, return the max score over the FULL
445
+ candidate set among same-class boxes with IoU >= iou_thresh.
446
+ Used after horizontal-flip TTA: a high-confidence flipped detection
447
+ can raise the score of the corresponding original detection.
448
+ """
449
+ n = len(post_boxes)
450
+ if n == 0:
451
+ return np.empty(0, dtype=np.float32)
452
+ full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
453
+ np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
454
+ out = np.empty(n, dtype=np.float32)
455
+ for i in range(n):
456
+ bi = post_boxes[i]
457
+ xx1 = np.maximum(bi[0], full_boxes[:, 0])
458
+ yy1 = np.maximum(bi[1], full_boxes[:, 1])
459
+ xx2 = np.minimum(bi[2], full_boxes[:, 2])
460
+ yy2 = np.minimum(bi[3], full_boxes[:, 3])
461
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
462
+ a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
463
+ iou = inter / (a_i + full_areas - inter + 1e-7)
464
+ cluster = (iou >= iou_thresh) & (full_cls == post_cls[i])
465
+ out[i] = float(np.max(full_scores[cluster])) if np.any(cluster) else 0.0
466
+ return out
467
+
468
+ def _conf_filter_mask(
469
+ self, scores: np.ndarray, cls_ids: np.ndarray
470
+ ) -> np.ndarray:
471
+ """Boolean keep-mask: score >= per-class threshold, with a per-class
472
+ rescue -- if a class has zero boxes passing, admit its top-1 candidate
473
+ when its score >= (per-class threshold - per-class bonus)."""
474
+ if len(scores) == 0:
475
+ return np.zeros(0, dtype=bool)
476
+ thr = self._conf_thres_array[cls_ids]
477
+ keep = scores >= thr
478
+ for c in np.unique(cls_ids):
479
+ b = float(self._bonus_array[c])
480
+ if b <= 0.0:
481
+ continue
482
+ cm = cls_ids == c
483
+ if keep[cm].any():
484
+ continue
485
+ idx = np.where(cm)[0]
486
+ top = int(idx[int(np.argmax(scores[idx]))])
487
+ if scores[top] >= self._conf_thres_array[c] - b:
488
+ keep[top] = True
489
+ return keep
490
+
491
  def _filter_sane_boxes(
492
  self,
493
  boxes: np.ndarray,
 
495
  cls_ids: np.ndarray,
496
  orig_size: tuple[int, int],
497
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
498
+ """Drop tiny / degenerate / image-spanning / extreme-AR boxes (FP)."""
499
  if len(boxes) == 0:
500
  return boxes, scores, cls_ids
501
  orig_w, orig_h = orig_size
 
527
  k = np.array(keep, dtype=np.intp)
528
  return boxes[k], scores[k], cls_ids[k]
529
 
530
+ def _per_view_pipeline(
531
+ self,
532
+ boxes: np.ndarray,
533
  scores: np.ndarray,
534
+ cls_ids: np.ndarray,
535
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
536
+ """Per-view post-processing pipeline: per-class NMS -> cap -> cross-class dedup."""
537
+ if len(boxes) > 1:
538
+ if self.use_soft_nms:
539
+ keep, new_scores = self._per_class_soft_nms(
540
+ boxes, scores, cls_ids,
541
+ self.soft_nms_sigma, self.soft_nms_score_thresh)
542
+ boxes, scores, cls_ids = boxes[keep], new_scores, cls_ids[keep]
543
+ else:
544
+ keep = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
545
+ boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
546
+ if len(scores) > self.max_det:
547
+ top = np.argsort(-scores)[: self.max_det]
548
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
549
+ if len(boxes) > 1:
550
+ boxes, scores, cls_ids = self._cross_class_dedup_op(
551
+ boxes, scores, cls_ids, self.cross_iou_thresh
552
+ )
553
+ return boxes, scores, cls_ids
554
+
555
+ @staticmethod
556
+ def _shrink_wh(coords: np.ndarray, sw: float, sh: float) -> np.ndarray:
557
+ """Shrink each xyxy box about its center: new_w = w/sw, new_h = h/sh."""
558
+ if len(coords) == 0:
559
+ return coords
560
+ coords = np.asarray(coords, dtype=np.float32).copy()
561
+ cx = (coords[:, 0] + coords[:, 2]) * 0.5
562
+ cy = (coords[:, 1] + coords[:, 3]) * 0.5
563
+ hw = (coords[:, 2] - coords[:, 0]) * (0.5 / sw)
564
+ hh = (coords[:, 3] - coords[:, 1]) * (0.5 / sh)
565
+ coords[:, 0] = cx - hw
566
+ coords[:, 1] = cy - hh
567
+ coords[:, 2] = cx + hw
568
+ coords[:, 3] = cy + hh
569
+ return coords
570
+
571
+ def _build_results(
572
+ self, boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray
573
+ ) -> list[BoundingBox]:
574
+ boxes = self._shrink_wh(boxes, self.box_shrink_w, self.box_shrink_h)
575
+ results: list[BoundingBox] = []
576
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
577
+ x1, y1, x2, y2 = box.tolist()
578
+ if x2 <= x1 or y2 <= y1:
579
+ continue
580
+ results.append(
581
+ BoundingBox(
582
+ x1=int(round(x1)),
583
+ y1=int(round(y1)),
584
+ x2=int(round(x2)),
585
+ y2=int(round(y2)),
586
+ cls_id=int(cls_id),
587
+ conf=float(conf),
588
+ )
589
+ )
590
+ return results
591
 
592
  def _decode_final_dets(
593
  self,
 
595
  ratio: float,
596
  pad: tuple[float, float],
597
  orig_size: tuple[int, int],
 
598
  ) -> list[BoundingBox]:
599
+ """Final-detection output path: rows shaped [x1, y1, x2, y2, conf, cls_id]."""
 
 
 
 
600
  if preds.ndim == 3 and preds.shape[0] == 1:
601
  preds = preds[0]
 
602
  if preds.ndim != 2 or preds.shape[1] < 6:
603
  raise ValueError(f"Unexpected ONNX final-det output shape: {preds.shape}")
604
 
 
607
  cls_ids = preds[:, 5].astype(np.int32)
608
  cls_ids = self.cls_remap[cls_ids]
609
 
610
+ keep = self._conf_filter_mask(scores, cls_ids)
 
 
 
 
 
611
  boxes = boxes[keep]
612
  scores = scores[keep]
613
  cls_ids = cls_ids[keep]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  if len(boxes) == 0:
615
  return []
616
 
617
  pad_w, pad_h = pad
 
 
 
618
  boxes[:, [0, 2]] -= pad_w
619
  boxes[:, [1, 3]] -= pad_h
620
  boxes /= ratio
621
+ boxes = self._clip_boxes(boxes, orig_size)
622
 
 
623
  boxes, scores, cls_ids = self._filter_sane_boxes(
624
  boxes, scores, cls_ids, orig_size
625
  )
626
  if len(boxes) == 0:
627
  return []
628
 
629
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
630
+ return self._build_results(boxes, scores, cls_ids)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
 
632
  def _decode_raw_yolo(
633
  self,
 
636
  pad: tuple[float, float],
637
  orig_size: tuple[int, int],
638
  ) -> list[BoundingBox]:
639
+ """Fallback raw-YOLO output path: per-anchor class logits."""
640
+ if preds.ndim != 3 or preds.shape[0] != 1:
 
 
 
 
 
641
  raise ValueError(f"Unexpected raw ONNX output shape: {preds.shape}")
 
 
 
 
642
  preds = preds[0]
 
 
643
  if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
644
  preds = preds.T
 
645
  if preds.ndim != 2 or preds.shape[1] < 5:
646
+ raise ValueError(f"Unexpected raw output shape: {preds.shape}")
647
 
648
  boxes_xywh = preds[:, :4].astype(np.float32)
649
  cls_part = preds[:, 4:].astype(np.float32)
 
650
  if cls_part.shape[1] == 1:
651
  scores = cls_part[:, 0]
652
  cls_ids = np.zeros(len(scores), dtype=np.int32)
 
655
  scores = cls_part[np.arange(len(cls_part)), cls_ids]
656
  cls_ids = self.cls_remap[cls_ids]
657
 
658
+ keep = self._conf_filter_mask(scores, cls_ids)
659
  boxes_xywh = boxes_xywh[keep]
660
  scores = scores[keep]
661
  cls_ids = cls_ids[keep]
 
662
  if len(boxes_xywh) == 0:
663
  return []
 
664
  boxes = self._xywh_to_xyxy(boxes_xywh)
665
 
 
 
 
 
 
 
666
  pad_w, pad_h = pad
 
 
667
  boxes[:, [0, 2]] -= pad_w
668
  boxes[:, [1, 3]] -= pad_h
669
  boxes /= ratio
670
+ boxes = self._clip_boxes(boxes, orig_size)
671
 
672
  boxes, scores, cls_ids = self._filter_sane_boxes(
673
+ boxes, scores, cls_ids, orig_size
674
  )
675
  if len(boxes) == 0:
676
  return []
677
 
678
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
679
+ return self._build_results(boxes, scores, cls_ids)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
 
681
  def _postprocess(
682
  self,
 
685
  pad: tuple[float, float],
686
  orig_size: tuple[int, int],
687
  ) -> list[BoundingBox]:
 
 
 
 
 
688
  if output.ndim == 2 and output.shape[1] >= 6:
689
  return self._decode_final_dets(output, ratio, pad, orig_size)
 
 
690
  if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
691
  return self._decode_final_dets(output, ratio, pad, orig_size)
 
 
692
  return self._decode_raw_yolo(output, ratio, pad, orig_size)
693
 
694
  def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
 
702
  raise ValueError(f"Invalid image shape={image.shape}")
703
  if image.shape[2] != 3:
704
  raise ValueError(f"Expected 3 channels, got shape={image.shape}")
 
705
  if image.dtype != np.uint8:
706
  image = image.astype(np.uint8)
707
 
708
  input_tensor, ratio, pad, orig_size = self._preprocess(image)
709
+ expected = (1, 3, self.input_height, self.input_width)
710
+ if input_tensor.shape != expected:
 
711
  raise ValueError(
712
+ f"Bad input tensor shape={input_tensor.shape}, expected={expected}"
713
  )
714
 
715
  outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
716
+ return self._postprocess(outputs[0], ratio, pad, orig_size)
 
717
 
718
  def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
719
+ """Horizontal-flip TTA.
720
+ Strategy:
721
+ 1. Predict on original and on flipped image.
722
+ 2. Map flipped boxes back to original coordinates.
723
+ 3. Per-class hard NMS on the union.
724
+ 4. For each kept box, compute the max same-class score across the
725
+ FULL union (not just the post-NMS subset) -- this lets a high-
726
+ confidence flipped detection raise a borderline original one.
727
+ 5. Cross-class dedup to suppress same-physical-object multi-class.
728
  """
729
  boxes_orig = self._predict_single(image)
 
730
  flipped = cv2.flip(image, 1)
731
  boxes_flip = self._predict_single(flipped)
 
732
  w = image.shape[1]
733
  boxes_flip = [
734
  BoundingBox(
 
737
  )
738
  for b in boxes_flip
739
  ]
 
740
  all_boxes = boxes_orig + boxes_flip
741
+ if not all_boxes:
742
  return []
743
 
744
  coords = np.array(
 
750
  hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
751
  if len(hard_keep) == 0:
752
  return []
753
+ if len(hard_keep) > self.max_det:
754
+ top = np.argsort(-scores[hard_keep])[: self.max_det]
755
+ hard_keep = hard_keep[top]
756
 
 
 
 
757
  boosted = self._max_score_per_cluster(
758
+ coords[hard_keep], cls_ids[hard_keep],
759
+ coords, scores, cls_ids, self.iou_thres,
760
  )
761
 
762
+ kept_coords = coords[hard_keep]
763
+ kept_cls = cls_ids[hard_keep]
764
+ if len(kept_coords) > 1:
765
+ kept_coords, boosted, kept_cls = self._cross_class_dedup_op(
766
+ kept_coords, boosted, kept_cls, self.cross_iou_thresh
767
+ )
768
+
769
+ kept_coords = self._shrink_wh(
770
+ kept_coords, self.box_shrink_w, self.box_shrink_h
771
+ )
772
  return [
773
  BoundingBox(
774
+ x1=int(round(float(kept_coords[j, 0]))),
775
+ y1=int(round(float(kept_coords[j, 1]))),
776
+ x2=int(round(float(kept_coords[j, 2]))),
777
+ y2=int(round(float(kept_coords[j, 3]))),
778
+ cls_id=int(kept_cls[j]),
779
  conf=float(boosted[j]),
780
  )
781
+ for j in range(len(kept_coords))
782
  ]
783
 
784
+ def _predict_tiles(self, image: np.ndarray) -> list[BoundingBox]:
785
+ """Tile-based TTA for high-resolution images.
786
+ Splits the source image into two overlapping horizontal tiles, runs
787
+ single-pass inference on each at native scale, and translates boxes
788
+ back to the global frame. Useful when source width >> model input
789
+ width because letterboxing otherwise discards effective resolution
790
+ that small / distant signs depend on.
791
+ Returns an empty list if the image isn't wide enough to benefit; the
792
+ caller falls back to the regular pipeline in that case.
793
+ """
794
+ h, w = image.shape[:2]
795
+ if w < int(self.input_width * self.tile_trigger_ratio):
796
+ return []
797
+
798
+ overlap = int(w * self.tile_overlap_ratio)
799
+ mid = w // 2
800
+ x_left_end = min(w, mid + overlap // 2)
801
+ x_right_start = max(0, mid - overlap // 2)
802
+
803
+ left = image[:, :x_left_end]
804
+ right = image[:, x_right_start:]
805
+
806
+ boxes_left = self._predict_single(left)
807
+ boxes_right = self._predict_single(right)
808
+
809
+ shifted_right = [
810
+ BoundingBox(
811
+ x1=b.x1 + x_right_start,
812
+ y1=b.y1,
813
+ x2=b.x2 + x_right_start,
814
+ y2=b.y2,
815
+ cls_id=b.cls_id,
816
+ conf=b.conf,
817
+ )
818
+ for b in boxes_right
819
+ ]
820
+ return boxes_left + shifted_right
821
+
822
+ def _merge_views(
823
+ self,
824
+ view_boxes: list[list[BoundingBox]],
825
+ image_size: tuple[int, int],
826
+ ) -> list[BoundingBox]:
827
+ """Merge boxes from multiple views (single / hflip / tiles).
828
+ Same logic as `_predict_tta`'s tail: per-class hard NMS to dedupe,
829
+ then for each kept box take the max same-class score across the full
830
+ candidate union — a high-confidence detection in any view boosts
831
+ borderline matches in others.
832
+ """
833
+ all_boxes: list[BoundingBox] = []
834
+ for vb in view_boxes:
835
+ all_boxes.extend(vb)
836
+ if not all_boxes:
837
+ return []
838
+
839
+ coords = np.array(
840
+ [[b.x1, b.y1, b.x2, b.y2] for b in all_boxes], dtype=np.float32
841
+ )
842
+ scores = np.array([b.conf for b in all_boxes], dtype=np.float32)
843
+ cls_ids = np.array([b.cls_id for b in all_boxes], dtype=np.int32)
844
+
845
+ coords = self._clip_boxes(coords, image_size)
846
+
847
+ hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
848
+ if len(hard_keep) == 0:
849
  return []
850
+ if len(hard_keep) > self.max_det:
851
+ top = np.argsort(-scores[hard_keep])[: self.max_det]
852
+ hard_keep = hard_keep[top]
853
+
854
+ boosted = self._max_score_per_cluster(
855
+ coords[hard_keep], cls_ids[hard_keep],
856
+ coords, scores, cls_ids, self.iou_thres,
857
+ )
858
+
859
+ kept_coords = coords[hard_keep]
860
+ kept_cls = cls_ids[hard_keep]
861
+ if len(kept_coords) > 1:
862
+ kept_coords, boosted, kept_cls = self._cross_class_dedup_op(
863
+ kept_coords, boosted, kept_cls, self.cross_iou_thresh
864
+ )
865
+
866
+ kept_coords = self._shrink_wh(
867
+ kept_coords, self.box_shrink_w, self.box_shrink_h
868
+ )
869
+ return [
870
+ BoundingBox(
871
+ x1=int(round(float(kept_coords[j, 0]))),
872
+ y1=int(round(float(kept_coords[j, 1]))),
873
+ x2=int(round(float(kept_coords[j, 2]))),
874
+ y2=int(round(float(kept_coords[j, 3]))),
875
+ cls_id=int(kept_cls[j]),
876
+ conf=float(boosted[j]),
877
+ )
878
+ for j in range(len(kept_coords))
879
+ ]
880
+
881
+ def _predict_full(self, image: np.ndarray) -> list[BoundingBox]:
882
+ """Top-level per-frame prediction with all enabled augmentations.
883
+ - `use_tta=True`: original + horizontal flip
884
+ - `use_tile_tta=True` AND image wide enough: two overlapping tiles
885
+ All views are merged via per-class NMS + cluster-max score boost.
886
+ """
887
+ if not self.use_tta and not self.use_tile_tta:
888
+ return self._predict_single(image)
889
+
890
+ views: list[list[BoundingBox]] = []
891
+ if self.use_tta:
892
+ views.append(self._predict_single(image))
893
+ flipped = cv2.flip(image, 1)
894
+ w = image.shape[1]
895
+ flipped_dets = self._predict_single(flipped)
896
+ views.append([
897
+ BoundingBox(
898
+ x1=w - b.x2, y1=b.y1, x2=w - b.x1, y2=b.y2,
899
+ cls_id=b.cls_id, conf=b.conf,
900
+ )
901
+ for b in flipped_dets
902
+ ])
903
+ else:
904
+ views.append(self._predict_single(image))
905
+
906
+ if self.use_tile_tta:
907
+ tile_boxes = self._predict_tiles(image)
908
+ if tile_boxes:
909
+ views.append(tile_boxes)
910
+
911
+ h, w = image.shape[:2]
912
+ return self._merge_views(views, (w, h))
913
 
914
  def predict_batch(
915
  self,
 
918
  n_keypoints: int,
919
  ) -> list[TVFrameResult]:
920
  results: list[TVFrameResult] = []
 
921
  for frame_number_in_batch, image in enumerate(batch_images):
922
  try:
923
+ boxes = self._predict_full(image)
 
 
 
924
  except Exception as e:
925
+ print(
926
+ f"⚠️ Inference failed for frame "
927
+ f"{offset + frame_number_in_batch}: {e}"
928
+ )
929
  boxes = []
 
 
 
 
 
930
  results.append(
931
  TVFrameResult(
932
  frame_id=offset + frame_number_in_batch,
 
934
  keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
935
  )
936
  )
937
+ return results
 
weights.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:80f299677b5fa464bd0d4f5635b17ea0d7c01c03567e0f0681a4a77bcd579f06
3
- size 19475561
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:afe2b5700f8f5e764449706f8587c0a3862631f5cda3c9098c05d3227e0374e8
3
+ size 9840334