Martin99113 commited on
Commit
bc90bce
·
verified ·
1 Parent(s): f06069b

scorevision: push artifact

Browse files
Files changed (1) hide show
  1. miner.py +69 -220
miner.py CHANGED
@@ -7,6 +7,7 @@ import onnxruntime as ort
7
  from numpy import ndarray
8
  from pydantic import BaseModel
9
 
 
10
  class BoundingBox(BaseModel):
11
  x1: int
12
  y1: int
@@ -24,59 +25,62 @@ class TVFrameResult(BaseModel):
24
 
25
  class Miner:
26
  """ONNX Runtime miner for road-sign detection (single class).
27
-
28
- Post-processing: per-class confidence + rescue bonus, hard NMS,
29
- cross-class dedup, sanity-box filter, optional TTA / tile merge.
 
 
 
30
  """
31
 
32
  class_names = ["road_sign"]
 
33
  _model_class_order = ["road_sign"]
34
 
35
  iou_thres = 0.5
36
  cross_iou_thresh = 0.8
37
  max_det = 150
38
 
39
- use_secondary_merge = True
40
- secondary_conf = 0.70
41
- merge_iou = 0.2
42
- dual_head_dedup_iou = 0.35
43
- remove_contained_boxes = True
44
  _conf_thres_array = np.array(
45
- [0.28], dtype=np.float32
46
  )
 
 
 
 
 
47
  _bonus_array = np.array(
48
- [0.18], dtype=np.float32
49
  )
50
 
 
 
 
 
 
 
 
 
 
 
 
51
  min_box_area = 8 * 8
52
  min_side = 3
53
  max_aspect_ratio = 12.0
54
 
 
 
 
 
 
 
55
  tile_trigger_ratio = 1.4
56
  tile_overlap_ratio = 0.20
57
- # use_fast_postprocess = True
58
-
59
- @staticmethod
60
- def _ort_provider_chain() -> list[str]:
61
- """Prefer GPU/DML providers when installed; always end with CPU fallback."""
62
- available = set(ort.get_available_providers())
63
- preferred = (
64
- "CPUExecutionProvider",
65
- "CUDAExecutionProvider",
66
- "DmlExecutionProvider",
67
- )
68
- return [p for p in preferred if p in available] or ["CPUExecutionProvider"]
69
-
70
- @staticmethod
71
- def _ort_session_options() -> ort.SessionOptions:
72
- sess_options = ort.SessionOptions()
73
- sess_options.enable_cpu_mem_arena = True
74
- sess_options.enable_mem_pattern = True
75
- sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
76
- sess_options.intra_op_num_threads = 2
77
- sess_options.inter_op_num_threads = 1
78
- sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
79
- return sess_options
80
 
81
  def __init__(self, path_hf_repo: Path) -> None:
82
  model_path = path_hf_repo / "weights.onnx"
@@ -90,17 +94,24 @@ class Miner:
90
 
91
  print("ORT available providers BEFORE session:", ort.get_available_providers())
92
 
93
- sess_options = self._ort_session_options()
94
- providers = self._ort_provider_chain()
95
- print(
96
- f"ORT session config: providers={providers} "
97
- f"intra_op_threads={sess_options.intra_op_num_threads}"
98
- )
99
- self.session = ort.InferenceSession(
100
- str(model_path),
101
- sess_options=sess_options,
102
- providers=providers,
103
- )
 
 
 
 
 
 
 
104
 
105
  print("ORT session providers:", self.session.get_providers())
106
 
@@ -121,10 +132,10 @@ class Miner:
121
  dtype=np.int32,
122
  )
123
 
124
- for i, inp in enumerate(self.session.get_inputs()):
125
- print(f"INPUT[{i}]: shape={inp.shape} type={inp.type}")
126
- for i, out in enumerate(self.session.get_outputs()):
127
- print(f"OUTPUT[{i}]: shape={out.shape} type={out.type}")
128
 
129
  self.input_name = self.session.get_inputs()[0].name
130
  self.output_names = [output.name for output in self.session.get_outputs()]
@@ -148,7 +159,7 @@ class Miner:
148
 
149
  print(f"✅ ONNX model loaded from: {model_path}")
150
  print(f"✅ ONNX providers: {self.session.get_providers()}")
151
- print(f"✅ ONNX input shape={self.input_shape}")
152
  print(f"✅ ONNX input size: {self.input_width}x{self.input_height}, "
153
  f"use_tta={self.use_tta}, use_tile_tta={self.use_tile_tta}")
154
  print("per-class conf: " + ", ".join(
@@ -170,7 +181,6 @@ class Miner:
170
 
171
  def _read_model_class_order(self) -> "list[str] | None":
172
  """Read the model's class order from Ultralytics ONNX metadata.
173
-
174
  Returns the class names ordered by model-emit index, or None when the
175
  metadata is missing/unparsable or doesn't match `class_names` as a set
176
  (in which case the static _model_class_order fallback is used)."""
@@ -257,42 +267,6 @@ class Miner:
257
  boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
258
  return boxes
259
 
260
- @staticmethod
261
- def _box_area(b: BoundingBox) -> int:
262
- return max(0, b.x2 - b.x1) * max(0, b.y2 - b.y1)
263
-
264
- @staticmethod
265
- def _box_fully_contains(outer: BoundingBox, inner: BoundingBox) -> bool:
266
- """True when outer strictly contains inner (outer area must be larger)."""
267
- if (outer.x1 > inner.x1 or outer.y1 > inner.y1
268
- or outer.x2 < inner.x2 or outer.y2 < inner.y2):
269
- return False
270
- outer_area = max(0, outer.x2 - outer.x1) * max(0, outer.y2 - outer.y1)
271
- inner_area = max(0, inner.x2 - inner.x1) * max(0, inner.y2 - inner.y1)
272
- return outer_area > inner_area
273
-
274
- def _remove_contained_boxes(self, boxes: list[BoundingBox]) -> list[BoundingBox]:
275
- """Drop smaller boxes fully contained inside a larger kept box."""
276
- if not boxes or not self.remove_contained_boxes:
277
- return boxes
278
-
279
- sorted_boxes = sorted(boxes, key=self._box_area, reverse=True)
280
- kept: list[BoundingBox] = []
281
- for b in sorted_boxes:
282
- if any(self._box_fully_contains(k, b) for k in kept):
283
- continue
284
- kept.append(b)
285
- return kept
286
-
287
- def _finalize_boxes(
288
- self,
289
- boxes: list[BoundingBox],
290
- image_size: tuple[int, int],
291
- ) -> list[BoundingBox]:
292
- """Expand boxes, then remove smaller boxes contained in larger ones."""
293
-
294
- return self._remove_contained_boxes(boxes)
295
-
296
  @staticmethod
297
  def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
298
  out = np.empty_like(boxes)
@@ -415,7 +389,6 @@ class Miner:
415
  iou_thresh: float,
416
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
417
  """Remove near-duplicate boxes across classes.
418
-
419
  Order candidates by (score - per_class_threshold) margin, then by area;
420
  keep the highest, suppress every other box with IoU > iou_thresh.
421
  With a single road_sign class this is effectively a no-op, but the
@@ -463,7 +436,6 @@ class Miner:
463
  ) -> np.ndarray:
464
  """For each kept (post-NMS) box, return the max score over the FULL
465
  candidate set among same-class boxes with IoU >= iou_thresh.
466
-
467
  Used after horizontal-flip TTA: a high-confidence flipped detection
468
  can raise the score of the corresponding original detection.
469
  """
@@ -601,10 +573,6 @@ class Miner:
601
  pad: tuple[float, float],
602
  orig_size: tuple[int, int],
603
  ) -> list[BoundingBox]:
604
-
605
- # if self.use_fast_postprocess:
606
- # return self._decode_final_dets_fast(preds, ratio, pad, orig_size)
607
-
608
  """Final-detection output path: rows shaped [x1, y1, x2, y2, conf, cls_id]."""
609
  if preds.ndim == 3 and preds.shape[0] == 1:
610
  preds = preds[0]
@@ -615,7 +583,7 @@ class Miner:
615
  scores = preds[:, 4].astype(np.float32)
616
  cls_ids = preds[:, 5].astype(np.int32)
617
  cls_ids = self.cls_remap[cls_ids]
618
-
619
  keep = self._conf_filter_mask(scores, cls_ids)
620
  boxes = boxes[keep]
621
  scores = scores[keep]
@@ -634,6 +602,7 @@ class Miner:
634
  )
635
  if len(boxes) == 0:
636
  return []
 
637
  boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
638
  return self._build_results(boxes, scores, cls_ids)
639
 
@@ -699,109 +668,6 @@ class Miner:
699
  return self._decode_final_dets(output, ratio, pad, orig_size)
700
  return self._decode_raw_yolo(output, ratio, pad, orig_size)
701
 
702
- @staticmethod
703
- def _iou_box(a: BoundingBox, b: BoundingBox) -> float:
704
- x1 = max(a.x1, b.x1)
705
- y1 = max(a.y1, b.y1)
706
- x2 = min(a.x2, b.x2)
707
- y2 = min(a.y2, b.y2)
708
- inter = max(0, x2 - x1) * max(0, y2 - y1)
709
- if inter <= 0:
710
- return 0.0
711
- area_a = max(0, a.x2 - a.x1) * max(0, a.y2 - a.y1)
712
- area_b = max(0, b.x2 - b.x1) * max(0, b.y2 - b.y1)
713
- union = area_a + area_b - inter
714
- return float(inter / union) if union > 0 else 0.0
715
-
716
- def _selective_merge(
717
- self,
718
- primary: list[BoundingBox],
719
- secondary_boxes: list[BoundingBox],
720
- ) -> list[BoundingBox]:
721
- """Keep high-conf secondary boxes that do not overlap primary."""
722
- if not secondary_boxes:
723
- return []
724
- merged: list[BoundingBox] = []
725
- for a in secondary_boxes:
726
- if a.conf < self.secondary_conf:
727
- continue
728
- if primary:
729
- max_iou = max(self._iou_box(a, p) for p in primary)
730
- if max_iou >= self.merge_iou:
731
- continue
732
- merged.append(a)
733
- return merged
734
-
735
- def _harmonize_overlapping_heads(
736
- self,
737
- primary: list[BoundingBox],
738
- secondary: list[BoundingBox],
739
- ) -> list[BoundingBox]:
740
- """Where primary and secondary overlap, keep primary geometry and partner score."""
741
- if not primary or not secondary:
742
- return primary
743
-
744
- out: list[BoundingBox] = []
745
- for p in primary:
746
- partner_conf = p.conf
747
- best_iou = 0.0
748
- for s in secondary:
749
- if s.cls_id != p.cls_id:
750
- continue
751
- iou = self._iou_box(p, s)
752
- if iou >= self.merge_iou and iou > best_iou:
753
- best_iou = iou
754
- partner_conf = s.conf if s.conf >= 0.90 else p.conf
755
- out.append(
756
- BoundingBox(
757
- x1=p.x1,
758
- y1=p.y1,
759
- x2=p.x2,
760
- y2=p.y2,
761
- cls_id=p.cls_id,
762
- conf=float(partner_conf),
763
- )
764
- )
765
- return out
766
-
767
- def _merge_dual_head_boxes(
768
- self,
769
- primary_boxes: list[BoundingBox],
770
- secondary_boxes: list[BoundingBox],
771
- orig_size: tuple[int, int],
772
- ) -> list[BoundingBox]:
773
- """Preserve primary boxes; append alex fill after extra-only dedupe.
774
-
775
- `merge_iou` only gates cross-head exclusion in `_selective_merge`.
776
- `dual_head_dedup_iou` only dedupes alex extras (never re-NMS primary).
777
- Primary geometry/conf are left unchanged (no harmonize).
778
- """
779
- extra = self._selective_merge(primary_boxes, secondary_boxes)
780
- if not extra:
781
- return primary_boxes
782
-
783
- deduped_extra = self._merge_views(
784
- [extra], orig_size, iou_thresh=self.dual_head_dedup_iou
785
- )
786
- return primary_boxes + deduped_extra
787
-
788
- def _merge_model_outputs(
789
- self,
790
- primary_out: np.ndarray,
791
- secondary_out: np.ndarray,
792
- ratio: float,
793
- pad: tuple[float, float],
794
- orig_size: tuple[int, int],
795
- ) -> list[BoundingBox]:
796
- """Primary detections plus optional non-overlapping secondary boxes."""
797
- primary_boxes = self._postprocess(primary_out, ratio, pad, orig_size)
798
- if not self.use_secondary_merge:
799
- return primary_boxes
800
- secondary_boxes = self._postprocess(secondary_out, ratio, pad, orig_size)
801
- return self._merge_dual_head_boxes(
802
- primary_boxes, secondary_boxes, orig_size
803
- )
804
-
805
  def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
806
  if image is None:
807
  raise ValueError("Input image is None")
@@ -824,20 +690,10 @@ class Miner:
824
  )
825
 
826
  outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
827
- if len(outputs) < 2:
828
- return self._postprocess(outputs[0], ratio, pad, orig_size)
829
-
830
- out0, out1 = outputs[0], outputs[1]
831
- # Drop batch dim when present ([1, N, 6] -> [N, 6]).
832
- if isinstance(out0, np.ndarray) and out0.ndim == 3 and out0.shape[0] == 1:
833
- out0 = out0[0]
834
- if isinstance(out1, np.ndarray) and out1.ndim == 3 and out1.shape[0] == 1:
835
- out1 = out1[0]
836
- return self._merge_model_outputs(out0, out1, ratio, pad, orig_size)
837
 
838
  def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
839
  """Horizontal-flip TTA.
840
-
841
  Strategy:
842
  1. Predict on original and on flipped image.
843
  2. Map flipped boxes back to original coordinates.
@@ -901,13 +757,11 @@ class Miner:
901
 
902
  def _predict_tiles(self, image: np.ndarray) -> list[BoundingBox]:
903
  """Tile-based TTA for high-resolution images.
904
-
905
  Splits the source image into two overlapping horizontal tiles, runs
906
  single-pass inference on each at native scale, and translates boxes
907
  back to the global frame. Useful when source width >> model input
908
  width because letterboxing otherwise discards effective resolution
909
  that small / distant signs depend on.
910
-
911
  Returns an empty list if the image isn't wide enough to benefit; the
912
  caller falls back to the regular pipeline in that case.
913
  """
@@ -943,16 +797,13 @@ class Miner:
943
  self,
944
  view_boxes: list[list[BoundingBox]],
945
  image_size: tuple[int, int],
946
- iou_thresh: float | None = None,
947
  ) -> list[BoundingBox]:
948
  """Merge boxes from multiple views (single / hflip / tiles).
949
-
950
  Same logic as `_predict_tta`'s tail: per-class hard NMS to dedupe,
951
  then for each kept box take the max same-class score across the full
952
  candidate union — a high-confidence detection in any view boosts
953
  borderline matches in others.
954
  """
955
- nms_iou = self.iou_thres if iou_thresh is None else float(iou_thresh)
956
  all_boxes: list[BoundingBox] = []
957
  for vb in view_boxes:
958
  all_boxes.extend(vb)
@@ -967,7 +818,7 @@ class Miner:
967
 
968
  coords = self._clip_boxes(coords, image_size)
969
 
970
- hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, nms_iou)
971
  if len(hard_keep) == 0:
972
  return []
973
  if len(hard_keep) > self.max_det:
@@ -976,7 +827,7 @@ class Miner:
976
 
977
  boosted = self._max_score_per_cluster(
978
  coords[hard_keep], cls_ids[hard_keep],
979
- coords, scores, cls_ids, nms_iou,
980
  )
981
 
982
  kept_coords = coords[hard_keep]
@@ -1000,21 +851,18 @@ class Miner:
1000
 
1001
  def _predict_full(self, image: np.ndarray) -> list[BoundingBox]:
1002
  """Top-level per-frame prediction with all enabled augmentations.
1003
-
1004
  - `use_tta=True`: original + horizontal flip
1005
  - `use_tile_tta=True` AND image wide enough: two overlapping tiles
1006
  All views are merged via per-class NMS + cluster-max score boost.
1007
  """
1008
- h, w = image.shape[:2]
1009
- image_size = (w, h)
1010
-
1011
  if not self.use_tta and not self.use_tile_tta:
1012
- return self._finalize_boxes(self._predict_single(image), image_size)
1013
 
1014
  views: list[list[BoundingBox]] = []
1015
  if self.use_tta:
1016
  views.append(self._predict_single(image))
1017
  flipped = cv2.flip(image, 1)
 
1018
  flipped_dets = self._predict_single(flipped)
1019
  views.append([
1020
  BoundingBox(
@@ -1031,7 +879,8 @@ class Miner:
1031
  if tile_boxes:
1032
  views.append(tile_boxes)
1033
 
1034
- return self._finalize_boxes(self._merge_views(views, image_size), image_size)
 
1035
 
1036
  def predict_batch(
1037
  self,
 
7
  from numpy import ndarray
8
  from pydantic import BaseModel
9
 
10
+
11
  class BoundingBox(BaseModel):
12
  x1: int
13
  y1: int
 
25
 
26
  class Miner:
27
  """ONNX Runtime miner for road-sign detection (single class).
28
+ Strategy (ported from offense / fire001 miner):
29
+ - per-class confidence threshold with per-class rescue bonus
30
+ - per-class hard NMS, then cross-class dedup (no-op for single class)
31
+ - horizontal-flip TTA with full-set cluster score boost
32
+ Plus: class remap, sanity-box filter tuned for small distant signs,
33
+ TTA toggle.
34
  """
35
 
36
  class_names = ["road_sign"]
37
+ # Order the model emits classes in -- remapped to `class_names` index.
38
  _model_class_order = ["road_sign"]
39
 
40
  iou_thres = 0.5
41
  cross_iou_thresh = 0.8
42
  max_det = 150
43
 
44
+ # Per-class confidence threshold. Road signs in this dataset are
45
+ # frequently degraded / rear-facing / partly-obscured / distant, so we
46
+ # run noticeably below the fire/smoke baseline. The validator's
47
+ # false_positive pillar = max(0, 1 - ffpi/10): we can tolerate ~2 FP per
48
+ # image and still keep that pillar above 0.8.
49
  _conf_thres_array = np.array(
50
+ [0.28], dtype=np.float32
51
  )
52
+ # Per-class rescue bonus. If a class has ZERO boxes passing the threshold
53
+ # in a frame, its top-1 candidate is admitted when its score is at least
54
+ # (threshold - bonus). Bumped from 0.05 -> 0.10 so a single faint sign in
55
+ # an otherwise empty frame still produces a detection (map50 recall win,
56
+ # at most one extra FP per such frame).
57
  _bonus_array = np.array(
58
+ [0.18], dtype=np.float32
59
  )
60
 
61
+ # Box sanity filter: drop tiny / degenerate / image-spanning / extreme
62
+ # aspect ratio boxes.
63
+ # min_box_area = 14x14 -> 14x14 is the smallest credible sign. The old
64
+ # value of 64 (8x8) silently discarded narrow
65
+ # distant signs like a 10x6 px overhead chevron.
66
+ # min_side = 3 -> matches min_box_area; anything thinner is
67
+ # almost certainly a pole or shadow false alarm.
68
+ # max_aspect_ratio = 12.0
69
+ # -> overhead destination panels and lane-assignment
70
+ # signs are very wide (long, thin rectangles);
71
+ # 8.0 was clipping legitimate detections.
72
  min_box_area = 8 * 8
73
  min_side = 3
74
  max_aspect_ratio = 12.0
75
 
76
+ # Tile-based TTA: when the source image is significantly larger than the
77
+ # model input, letterboxing throws away ~1.5x of effective resolution,
78
+ # which kills small-sign recall. Splitting into overlapping horizontal
79
+ # tiles preserves native resolution on each half. Triggered only when
80
+ # source width >= tile_trigger_ratio * model_input_width to avoid wasted
81
+ # compute on already-small images.
82
  tile_trigger_ratio = 1.4
83
  tile_overlap_ratio = 0.20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
  def __init__(self, path_hf_repo: Path) -> None:
86
  model_path = path_hf_repo / "weights.onnx"
 
94
 
95
  print("ORT available providers BEFORE session:", ort.get_available_providers())
96
 
97
+ sess_options = ort.SessionOptions()
98
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
99
+ sess_options.intra_op_num_threads = 2
100
+ sess_options.inter_op_num_threads = 1
101
+ sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
102
+
103
+ try:
104
+ self.session = ort.InferenceSession(
105
+ str(model_path),
106
+ sess_options=sess_options,
107
+ providers=["CPUExecutionProvider"],
108
+ )
109
+ except Exception as e:
110
+ self.session = ort.InferenceSession(
111
+ str(model_path),
112
+ sess_options=sess_options,
113
+ providers=["CPUExecutionProvider"],
114
+ )
115
 
116
  print("ORT session providers:", self.session.get_providers())
117
 
 
132
  dtype=np.int32,
133
  )
134
 
135
+ for inp in self.session.get_inputs():
136
+ print("INPUT:", inp.name, inp.shape, inp.type)
137
+ for out in self.session.get_outputs():
138
+ print("OUTPUT:", out.name, out.shape, out.type)
139
 
140
  self.input_name = self.session.get_inputs()[0].name
141
  self.output_names = [output.name for output in self.session.get_outputs()]
 
159
 
160
  print(f"✅ ONNX model loaded from: {model_path}")
161
  print(f"✅ ONNX providers: {self.session.get_providers()}")
162
+ print(f"✅ ONNX input: name={self.input_name}, shape={self.input_shape}")
163
  print(f"✅ ONNX input size: {self.input_width}x{self.input_height}, "
164
  f"use_tta={self.use_tta}, use_tile_tta={self.use_tile_tta}")
165
  print("per-class conf: " + ", ".join(
 
181
 
182
  def _read_model_class_order(self) -> "list[str] | None":
183
  """Read the model's class order from Ultralytics ONNX metadata.
 
184
  Returns the class names ordered by model-emit index, or None when the
185
  metadata is missing/unparsable or doesn't match `class_names` as a set
186
  (in which case the static _model_class_order fallback is used)."""
 
267
  boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
268
  return boxes
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  @staticmethod
271
  def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
272
  out = np.empty_like(boxes)
 
389
  iou_thresh: float,
390
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
391
  """Remove near-duplicate boxes across classes.
 
392
  Order candidates by (score - per_class_threshold) margin, then by area;
393
  keep the highest, suppress every other box with IoU > iou_thresh.
394
  With a single road_sign class this is effectively a no-op, but the
 
436
  ) -> np.ndarray:
437
  """For each kept (post-NMS) box, return the max score over the FULL
438
  candidate set among same-class boxes with IoU >= iou_thresh.
 
439
  Used after horizontal-flip TTA: a high-confidence flipped detection
440
  can raise the score of the corresponding original detection.
441
  """
 
573
  pad: tuple[float, float],
574
  orig_size: tuple[int, int],
575
  ) -> list[BoundingBox]:
 
 
 
 
576
  """Final-detection output path: rows shaped [x1, y1, x2, y2, conf, cls_id]."""
577
  if preds.ndim == 3 and preds.shape[0] == 1:
578
  preds = preds[0]
 
583
  scores = preds[:, 4].astype(np.float32)
584
  cls_ids = preds[:, 5].astype(np.int32)
585
  cls_ids = self.cls_remap[cls_ids]
586
+
587
  keep = self._conf_filter_mask(scores, cls_ids)
588
  boxes = boxes[keep]
589
  scores = scores[keep]
 
602
  )
603
  if len(boxes) == 0:
604
  return []
605
+
606
  boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
607
  return self._build_results(boxes, scores, cls_ids)
608
 
 
668
  return self._decode_final_dets(output, ratio, pad, orig_size)
669
  return self._decode_raw_yolo(output, ratio, pad, orig_size)
670
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
672
  if image is None:
673
  raise ValueError("Input image is None")
 
690
  )
691
 
692
  outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
693
+ return self._postprocess(outputs[0], ratio, pad, orig_size)
 
 
 
 
 
 
 
 
 
694
 
695
  def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
696
  """Horizontal-flip TTA.
 
697
  Strategy:
698
  1. Predict on original and on flipped image.
699
  2. Map flipped boxes back to original coordinates.
 
757
 
758
  def _predict_tiles(self, image: np.ndarray) -> list[BoundingBox]:
759
  """Tile-based TTA for high-resolution images.
 
760
  Splits the source image into two overlapping horizontal tiles, runs
761
  single-pass inference on each at native scale, and translates boxes
762
  back to the global frame. Useful when source width >> model input
763
  width because letterboxing otherwise discards effective resolution
764
  that small / distant signs depend on.
 
765
  Returns an empty list if the image isn't wide enough to benefit; the
766
  caller falls back to the regular pipeline in that case.
767
  """
 
797
  self,
798
  view_boxes: list[list[BoundingBox]],
799
  image_size: tuple[int, int],
 
800
  ) -> list[BoundingBox]:
801
  """Merge boxes from multiple views (single / hflip / tiles).
 
802
  Same logic as `_predict_tta`'s tail: per-class hard NMS to dedupe,
803
  then for each kept box take the max same-class score across the full
804
  candidate union — a high-confidence detection in any view boosts
805
  borderline matches in others.
806
  """
 
807
  all_boxes: list[BoundingBox] = []
808
  for vb in view_boxes:
809
  all_boxes.extend(vb)
 
818
 
819
  coords = self._clip_boxes(coords, image_size)
820
 
821
+ hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
822
  if len(hard_keep) == 0:
823
  return []
824
  if len(hard_keep) > self.max_det:
 
827
 
828
  boosted = self._max_score_per_cluster(
829
  coords[hard_keep], cls_ids[hard_keep],
830
+ coords, scores, cls_ids, self.iou_thres,
831
  )
832
 
833
  kept_coords = coords[hard_keep]
 
851
 
852
  def _predict_full(self, image: np.ndarray) -> list[BoundingBox]:
853
  """Top-level per-frame prediction with all enabled augmentations.
 
854
  - `use_tta=True`: original + horizontal flip
855
  - `use_tile_tta=True` AND image wide enough: two overlapping tiles
856
  All views are merged via per-class NMS + cluster-max score boost.
857
  """
 
 
 
858
  if not self.use_tta and not self.use_tile_tta:
859
+ return self._predict_single(image)
860
 
861
  views: list[list[BoundingBox]] = []
862
  if self.use_tta:
863
  views.append(self._predict_single(image))
864
  flipped = cv2.flip(image, 1)
865
+ w = image.shape[1]
866
  flipped_dets = self._predict_single(flipped)
867
  views.append([
868
  BoundingBox(
 
879
  if tile_boxes:
880
  views.append(tile_boxes)
881
 
882
+ h, w = image.shape[:2]
883
+ return self._merge_views(views, (w, h))
884
 
885
  def predict_batch(
886
  self,