scorevision: push artifact
Browse files
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 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
_conf_thres_array = np.array(
|
| 45 |
-
|
| 46 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
_bonus_array = np.array(
|
| 48 |
-
|
| 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 =
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 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
|
| 125 |
-
print(
|
| 126 |
-
for
|
| 127 |
-
print(
|
| 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 |
-
|
| 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,
|
| 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,
|
| 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.
|
| 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 |
-
|
|
|
|
| 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,
|