SuperBitDev commited on
Commit
fc37e5b
·
verified ·
1 Parent(s): 2718280

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. miner.py +912 -910
  2. weights.onnx +2 -2
miner.py CHANGED
@@ -1,911 +1,913 @@
1
- from pathlib import Path
2
- import math
3
-
4
- import cv2
5
- import numpy as np
6
- import onnxruntime as ort
7
- from numpy import ndarray
8
- from pydantic import BaseModel
9
-
10
-
11
- class BoundingBox(BaseModel):
12
- x1: int
13
- y1: int
14
- x2: int
15
- y2: int
16
- cls_id: int
17
- conf: float
18
-
19
-
20
- class TVFrameResult(BaseModel):
21
- frame_id: int
22
- boxes: list[BoundingBox]
23
- keypoints: list[tuple[int, int]]
24
-
25
-
26
- class Miner:
27
- def __init__(self,
28
- path_hf_repo: Path
29
- ) -> None:
30
- model_path = self._resolve_model_path(path_hf_repo)
31
- # car-wash element classes — cls_id order MUST match element `objects`
32
- # (0=broom, 1=drainage gate, 2=nozzle, 3=track). This is the canonical
33
- # order every downstream consumer (validator, BoundingBox.cls_id) sees.
34
- self.class_names = ["broom", "drainage gate", "nozzle", "track"]
35
- # FALLBACK model-emit order: the authoritative order is read from the
36
- # ONNX `names` metadata after the session is created (embedded by
37
- # Ultralytics at export, ships inside weights.onnx), so a retrained
38
- # model with a different class order is remapped correctly without
39
- # code changes. This list is used only when metadata is missing.
40
- self._model_class_order = ["broom", "drainage gate", "nozzle", "track"]
41
- print("ORT version:", ort.__version__)
42
-
43
- try:
44
- ort.preload_dlls()
45
- print("✅ onnxruntime.preload_dlls() success")
46
- except Exception as e:
47
- print(f"⚠️ preload_dlls failed: {e}")
48
-
49
- print("ORT available providers BEFORE session:", ort.get_available_providers())
50
-
51
- sess_options = ort.SessionOptions()
52
- sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
53
- sess_options.intra_op_num_threads = 2
54
- sess_options.inter_op_num_threads = 1
55
- sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
56
-
57
- try:
58
- self.session = ort.InferenceSession(
59
- str(model_path),
60
- sess_options=sess_options,
61
- providers=["CPUExecutionProvider"],
62
- )
63
- except Exception as e:
64
- self.session = ort.InferenceSession(
65
- str(model_path),
66
- sess_options=sess_options,
67
- providers=["CPUExecutionProvider"],
68
- )
69
-
70
- print("ORT session providers:", self.session.get_providers())
71
-
72
- # Build cls_remap: for each model-emit index i,
73
- # cls_remap[i] = self.class_names.index(model_class_order[i])
74
- # The model-side order comes from the ONNX metadata when available,
75
- # else falls back to the static _model_class_order.
76
- model_class_order = self._read_model_class_order()
77
- if model_class_order is None:
78
- model_class_order = list(self._model_class_order)
79
- print(f"cls order: no usable ONNX metadata, FALLBACK {model_class_order}")
80
- else:
81
- print(f"cls order: from ONNX metadata {model_class_order}")
82
- self.cls_remap = np.array(
83
- [self.class_names.index(n) for n in model_class_order], dtype=np.int32
84
- )
85
-
86
- for inp in self.session.get_inputs():
87
- print("INPUT:", inp.name, inp.shape, inp.type)
88
-
89
- for out in self.session.get_outputs():
90
- print("OUTPUT:", out.name, out.shape, out.type)
91
-
92
- self.input_name = self.session.get_inputs()[0].name
93
- self.output_names = [output.name for output in self.session.get_outputs()]
94
- self.input_shape = self.session.get_inputs()[0].shape
95
-
96
- # Match the ONNX input dtype (this export is FP16 -> needs float16 input).
97
- input_type = self.session.get_inputs()[0].type
98
- self.np_dtype = np.float16 if "float16" in input_type else np.float32
99
- print(f"✅ ONNX input dtype: {input_type} -> numpy {self.np_dtype}")
100
-
101
- # ONNX is fixed-size 1408x1408 (v1 export); read actual shape to be safe.
102
- self.input_height = self._safe_dim(self.input_shape[2], default=1280)
103
- self.input_width = self._safe_dim(self.input_shape[3], default=1280)
104
-
105
- # Tuned for validator scoring (pillars: 0.6*map50 + 0.4*false_positive).
106
- # All values below are the measured optimum of a full TTA-off grid sweep
107
- # on the validator-style val split (tune_miner.py, car-wash-49-val1024
108
- # val, ALL 2476 crops, against the synthetic-crop model; composite
109
- # 0.8716 -> 0.8731) -- re-run the sweep after any retrain.
110
- self.iou_thres = 0.5 # Per-class NMS IoU; lower = stricter dedup
111
- self.cross_iou_thresh = 0.9 # Cross-class dedup IoU (suppress same physical object firing multiple classes)
112
- self.max_det = 200
113
- # TTA = a 2nd (flipped) forward pass. Doubles latency; off for the
114
- # CPU latency gate. Re-enable only if the latency budget allows.
115
- self.use_tta = False
116
-
117
- # conf thresholds: broom=0.38 drainage gate=0.45 nozzle=0.30 track=0.60
118
- # Per-class confidence thresholds.
119
- # Indexed by class_names order: [broom, drainage gate, nozzle, track].
120
- # Values are the 2476-crop TTA-off sweep optimum on the synthetic-crop
121
- # model: lowering drainage (0.30->0.22) and nozzle (0.45->0.38) recovers
122
- # recall (map50 0.836->0.847) while FP actually drops (0.925->0.913).
123
- self._conf_thres_array = np.array(
124
- [0.35, 0.30, 0.45, 0.30], dtype=np.float32
125
- )
126
- # Per-class rescue bonus: when a class has ZERO boxes passing the
127
- # threshold in a frame, its top-1 candidate is admitted when its score
128
- # is at least (per-class threshold - per-class bonus).
129
- # DISABLED (all zeros): the 2476-crop sweep (rescue_bonus=False won)
130
- # confirmed rescue admits more false positives than true positives
131
- # under the validator's FP pillar.
132
- self._bonus_array = np.array(
133
- [0.05, 0.05, 0.1, 0.05], dtype=np.float32
134
- )
135
-
136
- # Box sanity filter — kept loose: car-wash `nozzle` boxes are tiny
137
- # (GT median ~290 px², smallest ~32 px²). Fire's 14x14/min_side 8
138
- # would delete valid nozzles, so thresholds are dropped here.
139
- self.min_box_area = 4 * 4 # 16 px²
140
- self.min_side = 3
141
- self.max_aspect_ratio = 12.0
142
-
143
- print(f"✅ ONNX model loaded from: {model_path}")
144
- print(f"✅ ONNX providers: {self.session.get_providers()}")
145
- print(f"✅ ONNX input: name={self.input_name}, shape={self.input_shape}")
146
-
147
- self._warmup()
148
-
149
- def _warmup(self, iters: int = 3) -> None:
150
- try:
151
- dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
152
- for _ in range(max(1, iters)):
153
- self.predict_batch(batch_images=[dummy], offset=0, n_keypoints=0)
154
- print(f"✅ warmup: {iters} dummy predict_batch call(s) done")
155
- except Exception as e:
156
- print(f"⚠️ warmup skipped: {e}")
157
-
158
- def __repr__(self) -> str:
159
- return (
160
- f"ONNXRuntime(session={type(self.session).__name__}, "
161
- f"providers={self.session.get_providers()})"
162
- )
163
-
164
- @staticmethod
165
- def _safe_dim(value, default: int) -> int:
166
- return value if isinstance(value, int) and value > 0 else default
167
-
168
- @staticmethod
169
- def _resolve_model_path(repo: Path) -> Path:
170
- """Locate the ONNX model in the repo dir.
171
-
172
- Prefers weights.onnx (FP16/FP32 export), then weights_int8.onnx (the
173
- training script's INT8-quantized export -- works as-is: quantization
174
- preserves the Ultralytics metadata and QDQ models take regular fp32
175
- input), then any other .onnx file. INT8 is the fallback when the FP16
176
- export exceeds the 30 MB deployment limit (e.g. yolo26m).
177
- """
178
- for name in ("weights.onnx", "weights_int8.onnx"):
179
- p = repo / name
180
- if p.exists():
181
- if name != "weights.onnx":
182
- print(f"model: weights.onnx not found, using {name}")
183
- return p
184
- candidates = sorted(repo.glob("*.onnx"))
185
- if candidates:
186
- print(f"model: using {candidates[0].name}")
187
- return candidates[0]
188
- return repo / "weights.onnx" # let session creation raise the error
189
-
190
- def _read_model_class_order(self) -> list[str] | None:
191
- """Read the model's class order from Ultralytics ONNX metadata.
192
-
193
- Returns the class names ordered by model-emit index, or None when
194
- metadata is missing/unparsable or doesn't match `class_names` as a
195
- set (in which case the static _model_class_order fallback is used).
196
- """
197
- try:
198
- import ast
199
-
200
- meta = self.session.get_modelmeta().custom_metadata_map
201
- names = ast.literal_eval(meta["names"]) # e.g. {0: 'broom', ...}
202
- if isinstance(names, dict):
203
- order = [str(names[i]) for i in sorted(names)]
204
- else:
205
- order = [str(n) for n in names]
206
- except Exception as e:
207
- print(f"cls order: could not read ONNX names metadata ({e})")
208
- return None
209
- if sorted(order) != sorted(self.class_names):
210
- print(
211
- f"cls order: ONNX names {order} do not match expected classes "
212
- f"{self.class_names}; ignoring metadata"
213
- )
214
- return None
215
- return order
216
-
217
- def _letterbox(
218
- self,
219
- image: ndarray,
220
- new_shape: tuple[int, int],
221
- color=(114, 114, 114),
222
- ) -> tuple[ndarray, float, tuple[float, float]]:
223
- """
224
- Resize with unchanged aspect ratio and pad to target shape.
225
- Returns:
226
- padded_image,
227
- ratio,
228
- (pad_w, pad_h) # half-padding
229
- """
230
- h, w = image.shape[:2]
231
- new_w, new_h = new_shape
232
-
233
- ratio = min(new_w / w, new_h / h)
234
- resized_w = int(round(w * ratio))
235
- resized_h = int(round(h * ratio))
236
-
237
- if (resized_w, resized_h) != (w, h):
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
242
- dh = new_h - resized_h
243
- dw /= 2.0
244
- dh /= 2.0
245
-
246
- left = int(round(dw - 0.1))
247
- right = int(round(dw + 0.1))
248
- top = int(round(dh - 0.1))
249
- bottom = int(round(dh + 0.1))
250
-
251
- padded = cv2.copyMakeBorder(
252
- image,
253
- top,
254
- bottom,
255
- left,
256
- right,
257
- borderType=cv2.BORDER_CONSTANT,
258
- value=color,
259
- )
260
- return padded, ratio, (dw, dh)
261
-
262
- def _preprocess(
263
- self, image: ndarray
264
- ) -> tuple[np.ndarray, float, tuple[float, float], tuple[int, int]]:
265
- """
266
- Preprocess for fixed-size ONNX export:
267
- - enhance image quality (CLAHE, denoise, sharpen)
268
- - letterbox to model input size
269
- - BGR -> RGB
270
- - normalize to [0,1]
271
- - HWC -> NCHW float32
272
- """
273
- orig_h, orig_w = image.shape[:2]
274
-
275
- img, ratio, pad = self._letterbox(
276
- image, (self.input_width, self.input_height)
277
- )
278
- img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
279
- img = (img.astype(np.float32) / 255.0)
280
- img = np.transpose(img, (2, 0, 1))[None, ...]
281
- img = np.ascontiguousarray(img, dtype=self.np_dtype)
282
-
283
- return img, ratio, pad, (orig_w, orig_h)
284
-
285
- @staticmethod
286
- def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
287
- w, h = image_size
288
- boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
289
- boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
290
- boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
291
- boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
292
- return boxes
293
-
294
- @staticmethod
295
- def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
296
- out = np.empty_like(boxes)
297
- out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
298
- out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
299
- out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
300
- out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
301
- return out
302
-
303
- def _soft_nms(
304
- self,
305
- boxes: np.ndarray,
306
- scores: np.ndarray,
307
- sigma: float = 0.5,
308
- score_thresh: float = 0.01,
309
- ) -> tuple[np.ndarray, np.ndarray]:
310
- """
311
- Soft-NMS: Gaussian decay of overlapping scores instead of hard removal.
312
- Returns (kept_original_indices, updated_scores).
313
- """
314
- N = len(boxes)
315
- if N == 0:
316
- return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
317
-
318
- boxes = boxes.astype(np.float32, copy=True)
319
- scores = scores.astype(np.float32, copy=True)
320
- order = np.arange(N)
321
-
322
- for i in range(N):
323
- max_pos = i + int(np.argmax(scores[i:]))
324
- boxes[[i, max_pos]] = boxes[[max_pos, i]]
325
- scores[[i, max_pos]] = scores[[max_pos, i]]
326
- order[[i, max_pos]] = order[[max_pos, i]]
327
-
328
- if i + 1 >= N:
329
- break
330
-
331
- xx1 = np.maximum(boxes[i, 0], boxes[i + 1:, 0])
332
- yy1 = np.maximum(boxes[i, 1], boxes[i + 1:, 1])
333
- xx2 = np.minimum(boxes[i, 2], boxes[i + 1:, 2])
334
- yy2 = np.minimum(boxes[i, 3], boxes[i + 1:, 3])
335
- inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
336
-
337
- area_i = max(0.0, float(
338
- (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])
339
- ))
340
- areas_j = (
341
- np.maximum(0.0, boxes[i + 1:, 2] - boxes[i + 1:, 0])
342
- * np.maximum(0.0, boxes[i + 1:, 3] - boxes[i + 1:, 1])
343
- )
344
- iou = inter / (area_i + areas_j - inter + 1e-7)
345
- scores[i + 1:] *= np.exp(-(iou ** 2) / sigma)
346
-
347
- mask = scores > score_thresh
348
- return order[mask], scores[mask]
349
-
350
- @staticmethod
351
- def _hard_nms(
352
- boxes: np.ndarray,
353
- scores: np.ndarray,
354
- iou_thresh: float,
355
- ) -> np.ndarray:
356
- """
357
- Standard NMS: keep one box per overlapping cluster (the one with highest score).
358
- Returns indices of kept boxes (into the boxes/scores arrays).
359
- """
360
- N = len(boxes)
361
- if N == 0:
362
- return np.array([], dtype=np.intp)
363
- boxes = np.asarray(boxes, dtype=np.float32)
364
- scores = np.asarray(scores, dtype=np.float32)
365
- order = np.argsort(scores)[::-1]
366
- keep: list[int] = []
367
- suppressed = np.zeros(N, dtype=bool)
368
- for i in range(N):
369
- idx = order[i]
370
- if suppressed[idx]:
371
- continue
372
- keep.append(idx)
373
- bi = boxes[idx]
374
- for k in range(i + 1, N):
375
- jdx = order[k]
376
- if suppressed[jdx]:
377
- continue
378
- bj = boxes[jdx]
379
- xx1 = max(bi[0], bj[0])
380
- yy1 = max(bi[1], bj[1])
381
- xx2 = min(bi[2], bj[2])
382
- yy2 = min(bi[3], bj[3])
383
- inter = max(0.0, xx2 - xx1) * max(0.0, yy2 - yy1)
384
- area_i = (bi[2] - bi[0]) * (bi[3] - bi[1])
385
- area_j = (bj[2] - bj[0]) * (bj[3] - bj[1])
386
- iou = inter / (area_i + area_j - inter + 1e-7)
387
- if iou > iou_thresh:
388
- suppressed[jdx] = True
389
- return np.array(keep)
390
-
391
- def _per_class_hard_nms(
392
- self,
393
- boxes: np.ndarray,
394
- scores: np.ndarray,
395
- cls_ids: np.ndarray,
396
- iou_thresh: float,
397
- ) -> np.ndarray:
398
- """Hard NMS applied independently per class."""
399
- if len(boxes) == 0:
400
- return np.array([], dtype=np.intp)
401
- all_keep: list[int] = []
402
- for c in np.unique(cls_ids):
403
- mask = cls_ids == c
404
- indices = np.where(mask)[0]
405
- keep = self._hard_nms(boxes[mask], scores[mask], iou_thresh)
406
- all_keep.extend(indices[keep].tolist())
407
- all_keep.sort()
408
- return np.array(all_keep, dtype=np.intp)
409
-
410
- def _per_class_soft_nms(
411
- self,
412
- boxes: np.ndarray,
413
- scores: np.ndarray,
414
- cls_ids: np.ndarray,
415
- sigma: float = 0.5,
416
- score_thresh: float = 0.01,
417
- ) -> tuple[np.ndarray, np.ndarray]:
418
- """Soft NMS applied independently per class."""
419
- if len(boxes) == 0:
420
- return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
421
- all_keep: list[int] = []
422
- all_scores: list[float] = []
423
- for c in np.unique(cls_ids):
424
- mask = cls_ids == c
425
- indices = np.where(mask)[0]
426
- keep, updated = self._soft_nms(boxes[mask], scores[mask], sigma, score_thresh)
427
- for k, s in zip(keep, updated):
428
- all_keep.append(int(indices[k]))
429
- all_scores.append(float(s))
430
- if not all_keep:
431
- return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
432
- return np.array(all_keep, dtype=np.intp), np.array(all_scores, dtype=np.float32)
433
-
434
- def _filter_sane_boxes(
435
- self,
436
- boxes: np.ndarray,
437
- scores: np.ndarray,
438
- cls_ids: np.ndarray,
439
- orig_size: tuple[int, int],
440
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
441
- """Filter out tiny, degenerate, or implausible boxes (common FP)."""
442
- if len(boxes) == 0:
443
- return boxes, scores, cls_ids
444
- orig_w, orig_h = orig_size
445
- image_area = float(orig_w * orig_h)
446
- keep = []
447
- for i, box in enumerate(boxes):
448
- x1, y1, x2, y2 = box.tolist()
449
- bw = x2 - x1
450
- bh = y2 - y1
451
- if bw <= 0 or bh <= 0:
452
- continue
453
- if bw < self.min_side or bh < self.min_side:
454
- continue
455
- area = bw * bh
456
- if area < self.min_box_area:
457
- continue
458
- if area > 0.95 * image_area:
459
- continue
460
- ar = max(bw / max(bh, 1e-6), bh / max(bw, 1e-6))
461
- if ar > self.max_aspect_ratio:
462
- continue
463
- keep.append(i)
464
- if not keep:
465
- return (
466
- np.empty((0, 4), dtype=np.float32),
467
- np.empty((0,), dtype=np.float32),
468
- np.empty((0,), dtype=np.int32),
469
- )
470
- k = np.array(keep, dtype=np.intp)
471
- return boxes[k], scores[k], cls_ids[k]
472
-
473
- @staticmethod
474
- def _max_score_per_cluster(
475
- post_boxes: np.ndarray,
476
- post_cls: np.ndarray,
477
- full_boxes: np.ndarray,
478
- full_scores: np.ndarray,
479
- full_cls: np.ndarray,
480
- iou_thresh: float,
481
- ) -> np.ndarray:
482
- """For each kept (post-NMS) box, return the max score over the FULL
483
- candidate set among SAME-CLASS boxes with IoU >= iou_thresh.
484
-
485
- The previous version omitted the same-class constraint, which let a
486
- confident broom raise the score of a coincident nozzle (or vice
487
- versa) under TTA. That's a silent FP booster and is fixed here.
488
- """
489
- n = len(post_boxes)
490
- if n == 0:
491
- return np.empty(0, dtype=np.float32)
492
- full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
493
- np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
494
- out = np.empty(n, dtype=np.float32)
495
- for i in range(n):
496
- bi = post_boxes[i]
497
- xx1 = np.maximum(bi[0], full_boxes[:, 0])
498
- yy1 = np.maximum(bi[1], full_boxes[:, 1])
499
- xx2 = np.minimum(bi[2], full_boxes[:, 2])
500
- yy2 = np.minimum(bi[3], full_boxes[:, 3])
501
- inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
502
- a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
503
- iou = inter / (a_i + full_areas - inter + 1e-7)
504
- cluster = (iou >= iou_thresh) & (full_cls == post_cls[i])
505
- out[i] = float(np.max(full_scores[cluster])) if np.any(cluster) else 0.0
506
- return out
507
-
508
- def _conf_filter_mask(
509
- self, scores: np.ndarray, cls_ids: np.ndarray
510
- ) -> np.ndarray:
511
- """Boolean keep-mask: score >= per-class threshold, with a per-class
512
- rescue -- if a class has zero boxes passing, admit its top-1 candidate
513
- when its score >= (per-class threshold - per-class bonus).
514
- """
515
- if len(scores) == 0:
516
- return np.zeros(0, dtype=bool)
517
- thr = self._conf_thres_array[cls_ids]
518
- keep = scores >= thr
519
- for c in np.unique(cls_ids):
520
- b = float(self._bonus_array[c])
521
- if b <= 0.0:
522
- continue
523
- cm = cls_ids == c
524
- if keep[cm].any():
525
- continue
526
- idx = np.where(cm)[0]
527
- top = int(idx[int(np.argmax(scores[idx]))])
528
- if scores[top] >= self._conf_thres_array[c] - b:
529
- keep[top] = True
530
- return keep
531
-
532
- def _cross_class_dedup_op(
533
- self,
534
- boxes: np.ndarray,
535
- scores: np.ndarray,
536
- cls_ids: np.ndarray,
537
- iou_thresh: float,
538
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
539
- """Remove near-duplicate boxes across classes.
540
-
541
- Order candidates by (score - per_class_threshold) margin, then by area;
542
- keep the highest, suppress every other box with IoU > iou_thresh. For
543
- car-wash this kills the common failure where water spray makes the
544
- model fire both `nozzle` and `track` on the same patch, or where a
545
- broom handle overlaps a drainage-gate detection.
546
- """
547
- n = len(boxes)
548
- if n <= 1:
549
- return boxes, scores, cls_ids
550
- boxes = np.asarray(boxes, dtype=np.float32)
551
- scores = np.asarray(scores, dtype=np.float32)
552
- cls_ids = np.asarray(cls_ids, dtype=np.int32)
553
- areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *
554
- np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))
555
- margins = scores - self._conf_thres_array[cls_ids]
556
- order = np.lexsort((-areas, -margins))
557
- suppressed = np.zeros(n, dtype=bool)
558
- keep: list[int] = []
559
- for i in order:
560
- if suppressed[i]:
561
- continue
562
- keep.append(int(i))
563
- bi = boxes[i]
564
- xx1 = np.maximum(bi[0], boxes[:, 0])
565
- yy1 = np.maximum(bi[1], boxes[:, 1])
566
- xx2 = np.minimum(bi[2], boxes[:, 2])
567
- yy2 = np.minimum(bi[3], boxes[:, 3])
568
- inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
569
- a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
570
- iou = inter / (a_i + areas - inter + 1e-7)
571
- dup = iou > iou_thresh
572
- dup[i] = False
573
- suppressed |= dup
574
- keep_idx = np.array(keep, dtype=np.intp)
575
- return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]
576
-
577
- def _per_view_pipeline(
578
- self,
579
- boxes: np.ndarray,
580
- scores: np.ndarray,
581
- cls_ids: np.ndarray,
582
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
583
- """Per-view post-processing: per-class NMS -> cap -> cross-class dedup."""
584
- if len(boxes) > 1:
585
- keep = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
586
- boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
587
- if len(scores) > self.max_det:
588
- top = np.argsort(-scores)[: self.max_det]
589
- boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
590
- if len(boxes) > 1:
591
- boxes, scores, cls_ids = self._cross_class_dedup_op(
592
- boxes, scores, cls_ids, self.cross_iou_thresh
593
- )
594
- return boxes, scores, cls_ids
595
-
596
- def _decode_final_dets(
597
- self,
598
- preds: np.ndarray,
599
- ratio: float,
600
- pad: tuple[float, float],
601
- orig_size: tuple[int, int],
602
- apply_optional_dedup: bool = False,
603
- ) -> list[BoundingBox]:
604
- """
605
- Primary path:
606
- expected output rows like [x1, y1, x2, y2, conf, cls_id]
607
- in letterboxed input coordinates.
608
- """
609
- if preds.ndim == 3 and preds.shape[0] == 1:
610
- preds = preds[0]
611
-
612
- if preds.ndim != 2 or preds.shape[1] < 6:
613
- raise ValueError(f"Unexpected ONNX final-det output shape: {preds.shape}")
614
-
615
- boxes = preds[:, :4].astype(np.float32)
616
- scores = preds[:, 4].astype(np.float32)
617
- cls_ids = preds[:, 5].astype(np.int32)
618
- cls_ids = self.cls_remap[cls_ids]
619
-
620
- # Per-class confidence filter with rescue (replaces scalar threshold)
621
- keep = self._conf_filter_mask(scores, cls_ids)
622
- boxes = boxes[keep]
623
- scores = scores[keep]
624
- cls_ids = cls_ids[keep]
625
-
626
- if len(boxes) == 0:
627
- return []
628
-
629
- pad_w, pad_h = pad
630
- orig_w, orig_h = orig_size
631
-
632
- # reverse letterbox
633
- boxes[:, [0, 2]] -= pad_w
634
- boxes[:, [1, 3]] -= pad_h
635
- boxes /= ratio
636
- boxes = self._clip_boxes(boxes, (orig_w, orig_h))
637
-
638
- # Box sanity filter (reduces FP)
639
- boxes, scores, cls_ids = self._filter_sane_boxes(
640
- boxes, scores, cls_ids, orig_size
641
- )
642
- if len(boxes) == 0:
643
- return []
644
-
645
- if apply_optional_dedup and len(boxes) > 1:
646
- # Soft-NMS path preserved as a tunable option; default below.
647
- keep_idx, scores = self._per_class_soft_nms(boxes, scores, cls_ids)
648
- boxes = boxes[keep_idx]
649
- cls_ids = cls_ids[keep_idx]
650
- if len(scores) > self.max_det:
651
- top = np.argsort(-scores)[: self.max_det]
652
- boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
653
- if len(boxes) > 1:
654
- boxes, scores, cls_ids = self._cross_class_dedup_op(
655
- boxes, scores, cls_ids, self.cross_iou_thresh
656
- )
657
- else:
658
- # Default: per-class hard NMS -> cap -> cross-class dedup
659
- boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
660
-
661
- results: list[BoundingBox] = []
662
- for box, conf, cls_id in zip(boxes, scores, cls_ids):
663
- x1, y1, x2, y2 = box.tolist()
664
-
665
- if x2 <= x1 or y2 <= y1:
666
- continue
667
-
668
- results.append(
669
- BoundingBox(
670
- x1=int(math.floor(x1)),
671
- y1=int(math.floor(y1)),
672
- x2=int(math.ceil(x2)),
673
- y2=int(math.ceil(y2)),
674
- cls_id=int(cls_id),
675
- conf=float(conf),
676
- )
677
- )
678
-
679
- return results
680
-
681
- def _decode_raw_yolo(
682
- self,
683
- preds: np.ndarray,
684
- ratio: float,
685
- pad: tuple[float, float],
686
- orig_size: tuple[int, int],
687
- ) -> list[BoundingBox]:
688
- """
689
- Fallback path for raw YOLO predictions.
690
- Supports common layouts:
691
- - [1, C, N]
692
- - [1, N, C]
693
- """
694
- if preds.ndim != 3:
695
- raise ValueError(f"Unexpected raw ONNX output shape: {preds.shape}")
696
-
697
- if preds.shape[0] != 1:
698
- raise ValueError(f"Unexpected batch dimension in raw output: {preds.shape}")
699
-
700
- preds = preds[0]
701
-
702
- # Normalize to [N, C]
703
- if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
704
- preds = preds.T
705
-
706
- if preds.ndim != 2 or preds.shape[1] < 5:
707
- raise ValueError(f"Unexpected normalized raw output shape: {preds.shape}")
708
-
709
- boxes_xywh = preds[:, :4].astype(np.float32)
710
- cls_part = preds[:, 4:].astype(np.float32)
711
-
712
- if cls_part.shape[1] == 1:
713
- scores = cls_part[:, 0]
714
- cls_ids = np.zeros(len(scores), dtype=np.int32)
715
- else:
716
- cls_ids = np.argmax(cls_part, axis=1).astype(np.int32)
717
- scores = cls_part[np.arange(len(cls_part)), cls_ids]
718
- cls_ids = self.cls_remap[cls_ids]
719
-
720
- # Per-class confidence filter with rescue (replaces scalar threshold)
721
- keep = self._conf_filter_mask(scores, cls_ids)
722
- boxes_xywh = boxes_xywh[keep]
723
- scores = scores[keep]
724
- cls_ids = cls_ids[keep]
725
- if len(boxes_xywh) == 0:
726
- return []
727
-
728
- boxes = self._xywh_to_xyxy(boxes_xywh)
729
-
730
- # Order matches fire001 / _decode_final_dets:
731
- # unscale -> clip -> sanity filter -> per-view pipeline (NMS, cap, cross-class dedup).
732
- pad_w, pad_h = pad
733
- orig_w, orig_h = orig_size
734
- boxes[:, [0, 2]] -= pad_w
735
- boxes[:, [1, 3]] -= pad_h
736
- boxes /= ratio
737
- boxes = self._clip_boxes(boxes, (orig_w, orig_h))
738
-
739
- boxes, scores, cls_ids = self._filter_sane_boxes(
740
- boxes, scores, cls_ids, (orig_w, orig_h)
741
- )
742
- if len(boxes) == 0:
743
- return []
744
-
745
- boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
746
-
747
- results: list[BoundingBox] = []
748
- for box, conf, cls_id in zip(boxes, scores, cls_ids):
749
- x1, y1, x2, y2 = box.tolist()
750
-
751
- if x2 <= x1 or y2 <= y1:
752
- continue
753
-
754
- results.append(
755
- BoundingBox(
756
- x1=int(math.floor(x1)),
757
- y1=int(math.floor(y1)),
758
- x2=int(math.ceil(x2)),
759
- y2=int(math.ceil(y2)),
760
- cls_id=int(cls_id),
761
- conf=float(conf),
762
- )
763
- )
764
-
765
- return results
766
-
767
- def _postprocess(
768
- self,
769
- output: np.ndarray,
770
- ratio: float,
771
- pad: tuple[float, float],
772
- orig_size: tuple[int, int],
773
- ) -> list[BoundingBox]:
774
- """
775
- Prefer final detections first.
776
- Fallback to raw decode only if needed.
777
- """
778
- # final detections: [N,6]
779
- if output.ndim == 2 and output.shape[1] >= 6:
780
- return self._decode_final_dets(output, ratio, pad, orig_size)
781
-
782
- # final detections: [1,N,6]
783
- if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
784
- return self._decode_final_dets(output, ratio, pad, orig_size)
785
-
786
- # fallback raw decode
787
- return self._decode_raw_yolo(output, ratio, pad, orig_size)
788
-
789
- def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
790
- if image is None:
791
- raise ValueError("Input image is None")
792
- if not isinstance(image, np.ndarray):
793
- raise TypeError(f"Input is not numpy array: {type(image)}")
794
- if image.ndim != 3:
795
- raise ValueError(f"Expected HWC image, got shape={image.shape}")
796
- if image.shape[0] <= 0 or image.shape[1] <= 0:
797
- raise ValueError(f"Invalid image shape={image.shape}")
798
- if image.shape[2] != 3:
799
- raise ValueError(f"Expected 3 channels, got shape={image.shape}")
800
-
801
- if image.dtype != np.uint8:
802
- image = image.astype(np.uint8)
803
-
804
- input_tensor, ratio, pad, orig_size = self._preprocess(image)
805
-
806
- expected_shape = (1, 3, self.input_height, self.input_width)
807
- if input_tensor.shape != expected_shape:
808
- raise ValueError(
809
- f"Bad input tensor shape={input_tensor.shape}, expected={expected_shape}"
810
- )
811
-
812
- outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
813
- det_output = outputs[0]
814
- return self._postprocess(det_output, ratio, pad, orig_size)
815
-
816
- def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
817
- """Horizontal-flip TTA.
818
-
819
- Strategy (ported from fire001):
820
- 1. Predict on original and on flipped image.
821
- 2. Map flipped boxes back to original coordinates.
822
- 3. Per-class hard NMS on the union.
823
- 4. For each kept box, compute the max SAME-CLASS score across the
824
- FULL union -- a high-confidence flipped detection raises a
825
- borderline original one, but never one of a different class.
826
- 5. Cross-class dedup to suppress same-physical-object multi-class.
827
- """
828
- boxes_orig = self._predict_single(image)
829
-
830
- flipped = cv2.flip(image, 1)
831
- boxes_flip = self._predict_single(flipped)
832
-
833
- w = image.shape[1]
834
- boxes_flip = [
835
- BoundingBox(
836
- x1=w - b.x2, y1=b.y1, x2=w - b.x1, y2=b.y2,
837
- cls_id=b.cls_id, conf=b.conf,
838
- )
839
- for b in boxes_flip
840
- ]
841
-
842
- all_boxes = boxes_orig + boxes_flip
843
- if len(all_boxes) == 0:
844
- return []
845
-
846
- coords = np.array(
847
- [[b.x1, b.y1, b.x2, b.y2] for b in all_boxes], dtype=np.float32
848
- )
849
- scores = np.array([b.conf for b in all_boxes], dtype=np.float32)
850
- cls_ids = np.array([b.cls_id for b in all_boxes], dtype=np.int32)
851
-
852
- hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
853
- if len(hard_keep) == 0:
854
- return []
855
- if len(hard_keep) > self.max_det:
856
- top = np.argsort(-scores[hard_keep])[: self.max_det]
857
- hard_keep = hard_keep[top]
858
-
859
- # Class-aware cluster-max score boost (fixes the silent cross-class
860
- # leak in the previous _max_score_per_cluster).
861
- boosted = self._max_score_per_cluster(
862
- coords[hard_keep], cls_ids[hard_keep],
863
- coords, scores, cls_ids, self.iou_thres,
864
- )
865
-
866
- kept_coords = coords[hard_keep]
867
- kept_cls = cls_ids[hard_keep]
868
- if len(kept_coords) > 1:
869
- kept_coords, boosted, kept_cls = self._cross_class_dedup_op(
870
- kept_coords, boosted, kept_cls, self.cross_iou_thresh
871
- )
872
-
873
- return [
874
- BoundingBox(
875
- x1=int(math.floor(kept_coords[j, 0])),
876
- y1=int(math.floor(kept_coords[j, 1])),
877
- x2=int(math.ceil(kept_coords[j, 2])),
878
- y2=int(math.ceil(kept_coords[j, 3])),
879
- cls_id=int(kept_cls[j]),
880
- conf=float(boosted[j]),
881
- )
882
- for j in range(len(kept_coords))
883
- ]
884
-
885
- def predict_batch(
886
- self,
887
- batch_images: list[ndarray],
888
- offset: int,
889
- n_keypoints: int,
890
- ) -> list[TVFrameResult]:
891
- results: list[TVFrameResult] = []
892
-
893
- for frame_number_in_batch, image in enumerate(batch_images):
894
- try:
895
- if self.use_tta:
896
- boxes = self._predict_tta(image)
897
- else:
898
- boxes = self._predict_single(image)
899
- except Exception as e:
900
- print(f"⚠️ Inference failed for frame {offset + frame_number_in_batch}: {e}")
901
- boxes = []
902
-
903
- results.append(
904
- TVFrameResult(
905
- frame_id=offset + frame_number_in_batch,
906
- boxes=boxes,
907
- keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
908
- )
909
- )
910
-
 
 
911
  return results
 
1
+ from pathlib import Path
2
+ import math
3
+
4
+ import cv2
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+ from numpy import ndarray
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class BoundingBox(BaseModel):
12
+ x1: int
13
+ y1: int
14
+ x2: int
15
+ y2: int
16
+ cls_id: int
17
+ conf: float
18
+
19
+
20
+ class TVFrameResult(BaseModel):
21
+ frame_id: int
22
+ boxes: list[BoundingBox]
23
+ keypoints: list[tuple[int, int]]
24
+
25
+
26
+ class Miner:
27
+ def __init__(self,
28
+ path_hf_repo: Path
29
+ ) -> None:
30
+ model_path = self._resolve_model_path(path_hf_repo)
31
+ # car-wash element classes — cls_id order MUST match element `objects`
32
+ # (0=broom, 1=drainage gate, 2=nozzle, 3=track). This is the canonical
33
+ # order every downstream consumer (validator, BoundingBox.cls_id) sees.
34
+ self.class_names = ["broom", "drainage gate", "nozzle", "track"]
35
+ # FALLBACK model-emit order: the authoritative order is read from the
36
+ # ONNX `names` metadata after the session is created (embedded by
37
+ # Ultralytics at export, ships inside weights.onnx), so a retrained
38
+ # model with a different class order is remapped correctly without
39
+ # code changes. This list is used only when metadata is missing.
40
+ self._model_class_order = ["broom", "drainage gate", "nozzle", "track"]
41
+ print("ORT version:", ort.__version__)
42
+
43
+ try:
44
+ ort.preload_dlls()
45
+ print("✅ onnxruntime.preload_dlls() success")
46
+ except Exception as e:
47
+ print(f"⚠️ preload_dlls failed: {e}")
48
+
49
+ print("ORT available providers BEFORE session:", ort.get_available_providers())
50
+
51
+ sess_options = ort.SessionOptions()
52
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
53
+ sess_options.intra_op_num_threads = 2
54
+ sess_options.inter_op_num_threads = 1
55
+ sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
56
+
57
+ try:
58
+ self.session = ort.InferenceSession(
59
+ str(model_path),
60
+ sess_options=sess_options,
61
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
62
+ )
63
+ print("✅ Created ORT session with preferred CUDA provider list")
64
+ except Exception as e:
65
+ print(f"⚠️ CUDA session creation failed, falling back to CPU: {e}")
66
+ self.session = ort.InferenceSession(
67
+ str(model_path),
68
+ sess_options=sess_options,
69
+ providers=["CPUExecutionProvider"],
70
+ )
71
+
72
+ print("ORT session providers:", self.session.get_providers())
73
+
74
+ # Build cls_remap: for each model-emit index i,
75
+ # cls_remap[i] = self.class_names.index(model_class_order[i])
76
+ # The model-side order comes from the ONNX metadata when available,
77
+ # else falls back to the static _model_class_order.
78
+ model_class_order = self._read_model_class_order()
79
+ if model_class_order is None:
80
+ model_class_order = list(self._model_class_order)
81
+ print(f"cls order: no usable ONNX metadata, FALLBACK {model_class_order}")
82
+ else:
83
+ print(f"cls order: from ONNX metadata {model_class_order}")
84
+ self.cls_remap = np.array(
85
+ [self.class_names.index(n) for n in model_class_order], dtype=np.int32
86
+ )
87
+
88
+ for inp in self.session.get_inputs():
89
+ print("INPUT:", inp.name, inp.shape, inp.type)
90
+
91
+ for out in self.session.get_outputs():
92
+ print("OUTPUT:", out.name, out.shape, out.type)
93
+
94
+ self.input_name = self.session.get_inputs()[0].name
95
+ self.output_names = [output.name for output in self.session.get_outputs()]
96
+ self.input_shape = self.session.get_inputs()[0].shape
97
+
98
+ # Match the ONNX input dtype (this export is FP16 -> needs float16 input).
99
+ input_type = self.session.get_inputs()[0].type
100
+ self.np_dtype = np.float16 if "float16" in input_type else np.float32
101
+ print(f"✅ ONNX input dtype: {input_type} -> numpy {self.np_dtype}")
102
+
103
+ # ONNX is fixed-size 1408x1408 (v1 export); read actual shape to be safe.
104
+ self.input_height = self._safe_dim(self.input_shape[2], default=1280)
105
+ self.input_width = self._safe_dim(self.input_shape[3], default=1280)
106
+
107
+ # Tuned for validator scoring (pillars: 0.6*map50 + 0.4*false_positive).
108
+ # All values below are the measured optimum of a full TTA-off grid sweep
109
+ # on the validator-style val split (tune_miner.py, car-wash-49-val1024
110
+ # val, ALL 2476 crops, against the synthetic-crop model; composite
111
+ # 0.8716 -> 0.8731) -- re-run the sweep after any retrain.
112
+ self.iou_thres = 0.5 # Per-class NMS IoU; lower = stricter dedup
113
+ self.cross_iou_thresh = 0.9 # Cross-class dedup IoU (suppress same physical object firing multiple classes)
114
+ self.max_det = 200
115
+ # TTA = a 2nd (flipped) forward pass. Doubles latency; off for the
116
+ # CPU latency gate. Re-enable only if the latency budget allows.
117
+ self.use_tta = False
118
+
119
+ # conf thresholds: broom=0.38 drainage gate=0.45 nozzle=0.30 track=0.60
120
+ # Per-class confidence thresholds.
121
+ # Indexed by class_names order: [broom, drainage gate, nozzle, track].
122
+ # Values are the 2476-crop TTA-off sweep optimum on the synthetic-crop
123
+ # model: lowering drainage (0.30->0.22) and nozzle (0.45->0.38) recovers
124
+ # recall (map50 0.836->0.847) while FP actually drops (0.925->0.913).
125
+ self._conf_thres_array = np.array(
126
+ [0.30, 0.30, 0.40, 0.30], dtype=np.float32
127
+ )
128
+ # Per-class rescue bonus: when a class has ZERO boxes passing the
129
+ # threshold in a frame, its top-1 candidate is admitted when its score
130
+ # is at least (per-class threshold - per-class bonus).
131
+ # DISABLED (all zeros): the 2476-crop sweep (rescue_bonus=False won)
132
+ # confirmed rescue admits more false positives than true positives
133
+ # under the validator's FP pillar.
134
+ self._bonus_array = np.array(
135
+ [0.05, 0.05, 0.1, 0.05], dtype=np.float32
136
+ )
137
+
138
+ # Box sanity filter kept loose: car-wash `nozzle` boxes are tiny
139
+ # (GT median ~290 px², smallest ~32 px²). Fire's 14x14/min_side 8
140
+ # would delete valid nozzles, so thresholds are dropped here.
141
+ self.min_box_area = 4 * 4 # 16 px²
142
+ self.min_side = 3
143
+ self.max_aspect_ratio = 12.0
144
+
145
+ print(f"✅ ONNX model loaded from: {model_path}")
146
+ print(f"✅ ONNX providers: {self.session.get_providers()}")
147
+ print(f"✅ ONNX input: name={self.input_name}, shape={self.input_shape}")
148
+
149
+ self._warmup()
150
+
151
+ def _warmup(self, iters: int = 3) -> None:
152
+ try:
153
+ dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
154
+ for _ in range(max(1, iters)):
155
+ self.predict_batch(batch_images=[dummy], offset=0, n_keypoints=0)
156
+ print(f" warmup: {iters} dummy predict_batch call(s) done")
157
+ except Exception as e:
158
+ print(f"⚠️ warmup skipped: {e}")
159
+
160
+ def __repr__(self) -> str:
161
+ return (
162
+ f"ONNXRuntime(session={type(self.session).__name__}, "
163
+ f"providers={self.session.get_providers()})"
164
+ )
165
+
166
+ @staticmethod
167
+ def _safe_dim(value, default: int) -> int:
168
+ return value if isinstance(value, int) and value > 0 else default
169
+
170
+ @staticmethod
171
+ def _resolve_model_path(repo: Path) -> Path:
172
+ """Locate the ONNX model in the repo dir.
173
+
174
+ Prefers weights.onnx (FP16/FP32 export), then weights_int8.onnx (the
175
+ training script's INT8-quantized export -- works as-is: quantization
176
+ preserves the Ultralytics metadata and QDQ models take regular fp32
177
+ input), then any other .onnx file. INT8 is the fallback when the FP16
178
+ export exceeds the 30 MB deployment limit (e.g. yolo26m).
179
+ """
180
+ for name in ("weights.onnx", "weights_int8.onnx"):
181
+ p = repo / name
182
+ if p.exists():
183
+ if name != "weights.onnx":
184
+ print(f"model: weights.onnx not found, using {name}")
185
+ return p
186
+ candidates = sorted(repo.glob("*.onnx"))
187
+ if candidates:
188
+ print(f"model: using {candidates[0].name}")
189
+ return candidates[0]
190
+ return repo / "weights.onnx" # let session creation raise the error
191
+
192
+ def _read_model_class_order(self) -> list[str] | None:
193
+ """Read the model's class order from Ultralytics ONNX metadata.
194
+
195
+ Returns the class names ordered by model-emit index, or None when
196
+ metadata is missing/unparsable or doesn't match `class_names` as a
197
+ set (in which case the static _model_class_order fallback is used).
198
+ """
199
+ try:
200
+ import ast
201
+
202
+ meta = self.session.get_modelmeta().custom_metadata_map
203
+ names = ast.literal_eval(meta["names"]) # e.g. {0: 'broom', ...}
204
+ if isinstance(names, dict):
205
+ order = [str(names[i]) for i in sorted(names)]
206
+ else:
207
+ order = [str(n) for n in names]
208
+ except Exception as e:
209
+ print(f"cls order: could not read ONNX names metadata ({e})")
210
+ return None
211
+ if sorted(order) != sorted(self.class_names):
212
+ print(
213
+ f"cls order: ONNX names {order} do not match expected classes "
214
+ f"{self.class_names}; ignoring metadata"
215
+ )
216
+ return None
217
+ return order
218
+
219
+ def _letterbox(
220
+ self,
221
+ image: ndarray,
222
+ new_shape: tuple[int, int],
223
+ color=(114, 114, 114),
224
+ ) -> tuple[ndarray, float, tuple[float, float]]:
225
+ """
226
+ Resize with unchanged aspect ratio and pad to target shape.
227
+ Returns:
228
+ padded_image,
229
+ ratio,
230
+ (pad_w, pad_h) # half-padding
231
+ """
232
+ h, w = image.shape[:2]
233
+ new_w, new_h = new_shape
234
+
235
+ ratio = min(new_w / w, new_h / h)
236
+ resized_w = int(round(w * ratio))
237
+ resized_h = int(round(h * ratio))
238
+
239
+ if (resized_w, resized_h) != (w, h):
240
+ interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
241
+ image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
242
+
243
+ dw = new_w - resized_w
244
+ dh = new_h - resized_h
245
+ dw /= 2.0
246
+ dh /= 2.0
247
+
248
+ left = int(round(dw - 0.1))
249
+ right = int(round(dw + 0.1))
250
+ top = int(round(dh - 0.1))
251
+ bottom = int(round(dh + 0.1))
252
+
253
+ padded = cv2.copyMakeBorder(
254
+ image,
255
+ top,
256
+ bottom,
257
+ left,
258
+ right,
259
+ borderType=cv2.BORDER_CONSTANT,
260
+ value=color,
261
+ )
262
+ return padded, ratio, (dw, dh)
263
+
264
+ def _preprocess(
265
+ self, image: ndarray
266
+ ) -> tuple[np.ndarray, float, tuple[float, float], tuple[int, int]]:
267
+ """
268
+ Preprocess for fixed-size ONNX export:
269
+ - enhance image quality (CLAHE, denoise, sharpen)
270
+ - letterbox to model input size
271
+ - BGR -> RGB
272
+ - normalize to [0,1]
273
+ - HWC -> NCHW float32
274
+ """
275
+ orig_h, orig_w = image.shape[:2]
276
+
277
+ img, ratio, pad = self._letterbox(
278
+ image, (self.input_width, self.input_height)
279
+ )
280
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
281
+ img = (img.astype(np.float32) / 255.0)
282
+ img = np.transpose(img, (2, 0, 1))[None, ...]
283
+ img = np.ascontiguousarray(img, dtype=self.np_dtype)
284
+
285
+ return img, ratio, pad, (orig_w, orig_h)
286
+
287
+ @staticmethod
288
+ def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
289
+ w, h = image_size
290
+ boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
291
+ boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
292
+ boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
293
+ boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
294
+ return boxes
295
+
296
+ @staticmethod
297
+ def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
298
+ out = np.empty_like(boxes)
299
+ out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
300
+ out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
301
+ out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
302
+ out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
303
+ return out
304
+
305
+ def _soft_nms(
306
+ self,
307
+ boxes: np.ndarray,
308
+ scores: np.ndarray,
309
+ sigma: float = 0.5,
310
+ score_thresh: float = 0.01,
311
+ ) -> tuple[np.ndarray, np.ndarray]:
312
+ """
313
+ Soft-NMS: Gaussian decay of overlapping scores instead of hard removal.
314
+ Returns (kept_original_indices, updated_scores).
315
+ """
316
+ N = len(boxes)
317
+ if N == 0:
318
+ return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
319
+
320
+ boxes = boxes.astype(np.float32, copy=True)
321
+ scores = scores.astype(np.float32, copy=True)
322
+ order = np.arange(N)
323
+
324
+ for i in range(N):
325
+ max_pos = i + int(np.argmax(scores[i:]))
326
+ boxes[[i, max_pos]] = boxes[[max_pos, i]]
327
+ scores[[i, max_pos]] = scores[[max_pos, i]]
328
+ order[[i, max_pos]] = order[[max_pos, i]]
329
+
330
+ if i + 1 >= N:
331
+ break
332
+
333
+ xx1 = np.maximum(boxes[i, 0], boxes[i + 1:, 0])
334
+ yy1 = np.maximum(boxes[i, 1], boxes[i + 1:, 1])
335
+ xx2 = np.minimum(boxes[i, 2], boxes[i + 1:, 2])
336
+ yy2 = np.minimum(boxes[i, 3], boxes[i + 1:, 3])
337
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
338
+
339
+ area_i = max(0.0, float(
340
+ (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])
341
+ ))
342
+ areas_j = (
343
+ np.maximum(0.0, boxes[i + 1:, 2] - boxes[i + 1:, 0])
344
+ * np.maximum(0.0, boxes[i + 1:, 3] - boxes[i + 1:, 1])
345
+ )
346
+ iou = inter / (area_i + areas_j - inter + 1e-7)
347
+ scores[i + 1:] *= np.exp(-(iou ** 2) / sigma)
348
+
349
+ mask = scores > score_thresh
350
+ return order[mask], scores[mask]
351
+
352
+ @staticmethod
353
+ def _hard_nms(
354
+ boxes: np.ndarray,
355
+ scores: np.ndarray,
356
+ iou_thresh: float,
357
+ ) -> np.ndarray:
358
+ """
359
+ Standard NMS: keep one box per overlapping cluster (the one with highest score).
360
+ Returns indices of kept boxes (into the boxes/scores arrays).
361
+ """
362
+ N = len(boxes)
363
+ if N == 0:
364
+ return np.array([], dtype=np.intp)
365
+ boxes = np.asarray(boxes, dtype=np.float32)
366
+ scores = np.asarray(scores, dtype=np.float32)
367
+ order = np.argsort(scores)[::-1]
368
+ keep: list[int] = []
369
+ suppressed = np.zeros(N, dtype=bool)
370
+ for i in range(N):
371
+ idx = order[i]
372
+ if suppressed[idx]:
373
+ continue
374
+ keep.append(idx)
375
+ bi = boxes[idx]
376
+ for k in range(i + 1, N):
377
+ jdx = order[k]
378
+ if suppressed[jdx]:
379
+ continue
380
+ bj = boxes[jdx]
381
+ xx1 = max(bi[0], bj[0])
382
+ yy1 = max(bi[1], bj[1])
383
+ xx2 = min(bi[2], bj[2])
384
+ yy2 = min(bi[3], bj[3])
385
+ inter = max(0.0, xx2 - xx1) * max(0.0, yy2 - yy1)
386
+ area_i = (bi[2] - bi[0]) * (bi[3] - bi[1])
387
+ area_j = (bj[2] - bj[0]) * (bj[3] - bj[1])
388
+ iou = inter / (area_i + area_j - inter + 1e-7)
389
+ if iou > iou_thresh:
390
+ suppressed[jdx] = True
391
+ return np.array(keep)
392
+
393
+ def _per_class_hard_nms(
394
+ self,
395
+ boxes: np.ndarray,
396
+ scores: np.ndarray,
397
+ cls_ids: np.ndarray,
398
+ iou_thresh: float,
399
+ ) -> np.ndarray:
400
+ """Hard NMS applied independently per class."""
401
+ if len(boxes) == 0:
402
+ return np.array([], dtype=np.intp)
403
+ all_keep: list[int] = []
404
+ for c in np.unique(cls_ids):
405
+ mask = cls_ids == c
406
+ indices = np.where(mask)[0]
407
+ keep = self._hard_nms(boxes[mask], scores[mask], iou_thresh)
408
+ all_keep.extend(indices[keep].tolist())
409
+ all_keep.sort()
410
+ return np.array(all_keep, dtype=np.intp)
411
+
412
+ def _per_class_soft_nms(
413
+ self,
414
+ boxes: np.ndarray,
415
+ scores: np.ndarray,
416
+ cls_ids: np.ndarray,
417
+ sigma: float = 0.5,
418
+ score_thresh: float = 0.01,
419
+ ) -> tuple[np.ndarray, np.ndarray]:
420
+ """Soft NMS applied independently per class."""
421
+ if len(boxes) == 0:
422
+ return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
423
+ all_keep: list[int] = []
424
+ all_scores: list[float] = []
425
+ for c in np.unique(cls_ids):
426
+ mask = cls_ids == c
427
+ indices = np.where(mask)[0]
428
+ keep, updated = self._soft_nms(boxes[mask], scores[mask], sigma, score_thresh)
429
+ for k, s in zip(keep, updated):
430
+ all_keep.append(int(indices[k]))
431
+ all_scores.append(float(s))
432
+ if not all_keep:
433
+ return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
434
+ return np.array(all_keep, dtype=np.intp), np.array(all_scores, dtype=np.float32)
435
+
436
+ def _filter_sane_boxes(
437
+ self,
438
+ boxes: np.ndarray,
439
+ scores: np.ndarray,
440
+ cls_ids: np.ndarray,
441
+ orig_size: tuple[int, int],
442
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
443
+ """Filter out tiny, degenerate, or implausible boxes (common FP)."""
444
+ if len(boxes) == 0:
445
+ return boxes, scores, cls_ids
446
+ orig_w, orig_h = orig_size
447
+ image_area = float(orig_w * orig_h)
448
+ keep = []
449
+ for i, box in enumerate(boxes):
450
+ x1, y1, x2, y2 = box.tolist()
451
+ bw = x2 - x1
452
+ bh = y2 - y1
453
+ if bw <= 0 or bh <= 0:
454
+ continue
455
+ if bw < self.min_side or bh < self.min_side:
456
+ continue
457
+ area = bw * bh
458
+ if area < self.min_box_area:
459
+ continue
460
+ if area > 0.95 * image_area:
461
+ continue
462
+ ar = max(bw / max(bh, 1e-6), bh / max(bw, 1e-6))
463
+ if ar > self.max_aspect_ratio:
464
+ continue
465
+ keep.append(i)
466
+ if not keep:
467
+ return (
468
+ np.empty((0, 4), dtype=np.float32),
469
+ np.empty((0,), dtype=np.float32),
470
+ np.empty((0,), dtype=np.int32),
471
+ )
472
+ k = np.array(keep, dtype=np.intp)
473
+ return boxes[k], scores[k], cls_ids[k]
474
+
475
+ @staticmethod
476
+ def _max_score_per_cluster(
477
+ post_boxes: np.ndarray,
478
+ post_cls: np.ndarray,
479
+ full_boxes: np.ndarray,
480
+ full_scores: np.ndarray,
481
+ full_cls: np.ndarray,
482
+ iou_thresh: float,
483
+ ) -> np.ndarray:
484
+ """For each kept (post-NMS) box, return the max score over the FULL
485
+ candidate set among SAME-CLASS boxes with IoU >= iou_thresh.
486
+
487
+ The previous version omitted the same-class constraint, which let a
488
+ confident broom raise the score of a coincident nozzle (or vice
489
+ versa) under TTA. That's a silent FP booster and is fixed here.
490
+ """
491
+ n = len(post_boxes)
492
+ if n == 0:
493
+ return np.empty(0, dtype=np.float32)
494
+ full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
495
+ np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
496
+ out = np.empty(n, dtype=np.float32)
497
+ for i in range(n):
498
+ bi = post_boxes[i]
499
+ xx1 = np.maximum(bi[0], full_boxes[:, 0])
500
+ yy1 = np.maximum(bi[1], full_boxes[:, 1])
501
+ xx2 = np.minimum(bi[2], full_boxes[:, 2])
502
+ yy2 = np.minimum(bi[3], full_boxes[:, 3])
503
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
504
+ a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
505
+ iou = inter / (a_i + full_areas - inter + 1e-7)
506
+ cluster = (iou >= iou_thresh) & (full_cls == post_cls[i])
507
+ out[i] = float(np.max(full_scores[cluster])) if np.any(cluster) else 0.0
508
+ return out
509
+
510
+ def _conf_filter_mask(
511
+ self, scores: np.ndarray, cls_ids: np.ndarray
512
+ ) -> np.ndarray:
513
+ """Boolean keep-mask: score >= per-class threshold, with a per-class
514
+ rescue -- if a class has zero boxes passing, admit its top-1 candidate
515
+ when its score >= (per-class threshold - per-class bonus).
516
+ """
517
+ if len(scores) == 0:
518
+ return np.zeros(0, dtype=bool)
519
+ thr = self._conf_thres_array[cls_ids]
520
+ keep = scores >= thr
521
+ for c in np.unique(cls_ids):
522
+ b = float(self._bonus_array[c])
523
+ if b <= 0.0:
524
+ continue
525
+ cm = cls_ids == c
526
+ if keep[cm].any():
527
+ continue
528
+ idx = np.where(cm)[0]
529
+ top = int(idx[int(np.argmax(scores[idx]))])
530
+ if scores[top] >= self._conf_thres_array[c] - b:
531
+ keep[top] = True
532
+ return keep
533
+
534
+ def _cross_class_dedup_op(
535
+ self,
536
+ boxes: np.ndarray,
537
+ scores: np.ndarray,
538
+ cls_ids: np.ndarray,
539
+ iou_thresh: float,
540
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
541
+ """Remove near-duplicate boxes across classes.
542
+
543
+ Order candidates by (score - per_class_threshold) margin, then by area;
544
+ keep the highest, suppress every other box with IoU > iou_thresh. For
545
+ car-wash this kills the common failure where water spray makes the
546
+ model fire both `nozzle` and `track` on the same patch, or where a
547
+ broom handle overlaps a drainage-gate detection.
548
+ """
549
+ n = len(boxes)
550
+ if n <= 1:
551
+ return boxes, scores, cls_ids
552
+ boxes = np.asarray(boxes, dtype=np.float32)
553
+ scores = np.asarray(scores, dtype=np.float32)
554
+ cls_ids = np.asarray(cls_ids, dtype=np.int32)
555
+ areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *
556
+ np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))
557
+ margins = scores - self._conf_thres_array[cls_ids]
558
+ order = np.lexsort((-areas, -margins))
559
+ suppressed = np.zeros(n, dtype=bool)
560
+ keep: list[int] = []
561
+ for i in order:
562
+ if suppressed[i]:
563
+ continue
564
+ keep.append(int(i))
565
+ bi = boxes[i]
566
+ xx1 = np.maximum(bi[0], boxes[:, 0])
567
+ yy1 = np.maximum(bi[1], boxes[:, 1])
568
+ xx2 = np.minimum(bi[2], boxes[:, 2])
569
+ yy2 = np.minimum(bi[3], boxes[:, 3])
570
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
571
+ a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
572
+ iou = inter / (a_i + areas - inter + 1e-7)
573
+ dup = iou > iou_thresh
574
+ dup[i] = False
575
+ suppressed |= dup
576
+ keep_idx = np.array(keep, dtype=np.intp)
577
+ return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]
578
+
579
+ def _per_view_pipeline(
580
+ self,
581
+ boxes: np.ndarray,
582
+ scores: np.ndarray,
583
+ cls_ids: np.ndarray,
584
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
585
+ """Per-view post-processing: per-class NMS -> cap -> cross-class dedup."""
586
+ if len(boxes) > 1:
587
+ keep = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
588
+ boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
589
+ if len(scores) > self.max_det:
590
+ top = np.argsort(-scores)[: self.max_det]
591
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
592
+ if len(boxes) > 1:
593
+ boxes, scores, cls_ids = self._cross_class_dedup_op(
594
+ boxes, scores, cls_ids, self.cross_iou_thresh
595
+ )
596
+ return boxes, scores, cls_ids
597
+
598
+ def _decode_final_dets(
599
+ self,
600
+ preds: np.ndarray,
601
+ ratio: float,
602
+ pad: tuple[float, float],
603
+ orig_size: tuple[int, int],
604
+ apply_optional_dedup: bool = False,
605
+ ) -> list[BoundingBox]:
606
+ """
607
+ Primary path:
608
+ expected output rows like [x1, y1, x2, y2, conf, cls_id]
609
+ in letterboxed input coordinates.
610
+ """
611
+ if preds.ndim == 3 and preds.shape[0] == 1:
612
+ preds = preds[0]
613
+
614
+ if preds.ndim != 2 or preds.shape[1] < 6:
615
+ raise ValueError(f"Unexpected ONNX final-det output shape: {preds.shape}")
616
+
617
+ boxes = preds[:, :4].astype(np.float32)
618
+ scores = preds[:, 4].astype(np.float32)
619
+ cls_ids = preds[:, 5].astype(np.int32)
620
+ cls_ids = self.cls_remap[cls_ids]
621
+
622
+ # Per-class confidence filter with rescue (replaces scalar threshold)
623
+ keep = self._conf_filter_mask(scores, cls_ids)
624
+ boxes = boxes[keep]
625
+ scores = scores[keep]
626
+ cls_ids = cls_ids[keep]
627
+
628
+ if len(boxes) == 0:
629
+ return []
630
+
631
+ pad_w, pad_h = pad
632
+ orig_w, orig_h = orig_size
633
+
634
+ # reverse letterbox
635
+ boxes[:, [0, 2]] -= pad_w
636
+ boxes[:, [1, 3]] -= pad_h
637
+ boxes /= ratio
638
+ boxes = self._clip_boxes(boxes, (orig_w, orig_h))
639
+
640
+ # Box sanity filter (reduces FP)
641
+ boxes, scores, cls_ids = self._filter_sane_boxes(
642
+ boxes, scores, cls_ids, orig_size
643
+ )
644
+ if len(boxes) == 0:
645
+ return []
646
+
647
+ if apply_optional_dedup and len(boxes) > 1:
648
+ # Soft-NMS path preserved as a tunable option; default below.
649
+ keep_idx, scores = self._per_class_soft_nms(boxes, scores, cls_ids)
650
+ boxes = boxes[keep_idx]
651
+ cls_ids = cls_ids[keep_idx]
652
+ if len(scores) > self.max_det:
653
+ top = np.argsort(-scores)[: self.max_det]
654
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
655
+ if len(boxes) > 1:
656
+ boxes, scores, cls_ids = self._cross_class_dedup_op(
657
+ boxes, scores, cls_ids, self.cross_iou_thresh
658
+ )
659
+ else:
660
+ # Default: per-class hard NMS -> cap -> cross-class dedup
661
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
662
+
663
+ results: list[BoundingBox] = []
664
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
665
+ x1, y1, x2, y2 = box.tolist()
666
+
667
+ if x2 <= x1 or y2 <= y1:
668
+ continue
669
+
670
+ results.append(
671
+ BoundingBox(
672
+ x1=int(math.floor(x1)),
673
+ y1=int(math.floor(y1)),
674
+ x2=int(math.ceil(x2)),
675
+ y2=int(math.ceil(y2)),
676
+ cls_id=int(cls_id),
677
+ conf=float(conf),
678
+ )
679
+ )
680
+
681
+ return results
682
+
683
+ def _decode_raw_yolo(
684
+ self,
685
+ preds: np.ndarray,
686
+ ratio: float,
687
+ pad: tuple[float, float],
688
+ orig_size: tuple[int, int],
689
+ ) -> list[BoundingBox]:
690
+ """
691
+ Fallback path for raw YOLO predictions.
692
+ Supports common layouts:
693
+ - [1, C, N]
694
+ - [1, N, C]
695
+ """
696
+ if preds.ndim != 3:
697
+ raise ValueError(f"Unexpected raw ONNX output shape: {preds.shape}")
698
+
699
+ if preds.shape[0] != 1:
700
+ raise ValueError(f"Unexpected batch dimension in raw output: {preds.shape}")
701
+
702
+ preds = preds[0]
703
+
704
+ # Normalize to [N, C]
705
+ if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
706
+ preds = preds.T
707
+
708
+ if preds.ndim != 2 or preds.shape[1] < 5:
709
+ raise ValueError(f"Unexpected normalized raw output shape: {preds.shape}")
710
+
711
+ boxes_xywh = preds[:, :4].astype(np.float32)
712
+ cls_part = preds[:, 4:].astype(np.float32)
713
+
714
+ if cls_part.shape[1] == 1:
715
+ scores = cls_part[:, 0]
716
+ cls_ids = np.zeros(len(scores), dtype=np.int32)
717
+ else:
718
+ cls_ids = np.argmax(cls_part, axis=1).astype(np.int32)
719
+ scores = cls_part[np.arange(len(cls_part)), cls_ids]
720
+ cls_ids = self.cls_remap[cls_ids]
721
+
722
+ # Per-class confidence filter with rescue (replaces scalar threshold)
723
+ keep = self._conf_filter_mask(scores, cls_ids)
724
+ boxes_xywh = boxes_xywh[keep]
725
+ scores = scores[keep]
726
+ cls_ids = cls_ids[keep]
727
+ if len(boxes_xywh) == 0:
728
+ return []
729
+
730
+ boxes = self._xywh_to_xyxy(boxes_xywh)
731
+
732
+ # Order matches fire001 / _decode_final_dets:
733
+ # unscale -> clip -> sanity filter -> per-view pipeline (NMS, cap, cross-class dedup).
734
+ pad_w, pad_h = pad
735
+ orig_w, orig_h = orig_size
736
+ boxes[:, [0, 2]] -= pad_w
737
+ boxes[:, [1, 3]] -= pad_h
738
+ boxes /= ratio
739
+ boxes = self._clip_boxes(boxes, (orig_w, orig_h))
740
+
741
+ boxes, scores, cls_ids = self._filter_sane_boxes(
742
+ boxes, scores, cls_ids, (orig_w, orig_h)
743
+ )
744
+ if len(boxes) == 0:
745
+ return []
746
+
747
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
748
+
749
+ results: list[BoundingBox] = []
750
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
751
+ x1, y1, x2, y2 = box.tolist()
752
+
753
+ if x2 <= x1 or y2 <= y1:
754
+ continue
755
+
756
+ results.append(
757
+ BoundingBox(
758
+ x1=int(math.floor(x1)),
759
+ y1=int(math.floor(y1)),
760
+ x2=int(math.ceil(x2)),
761
+ y2=int(math.ceil(y2)),
762
+ cls_id=int(cls_id),
763
+ conf=float(conf),
764
+ )
765
+ )
766
+
767
+ return results
768
+
769
+ def _postprocess(
770
+ self,
771
+ output: np.ndarray,
772
+ ratio: float,
773
+ pad: tuple[float, float],
774
+ orig_size: tuple[int, int],
775
+ ) -> list[BoundingBox]:
776
+ """
777
+ Prefer final detections first.
778
+ Fallback to raw decode only if needed.
779
+ """
780
+ # final detections: [N,6]
781
+ if output.ndim == 2 and output.shape[1] >= 6:
782
+ return self._decode_final_dets(output, ratio, pad, orig_size)
783
+
784
+ # final detections: [1,N,6]
785
+ if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
786
+ return self._decode_final_dets(output, ratio, pad, orig_size)
787
+
788
+ # fallback raw decode
789
+ return self._decode_raw_yolo(output, ratio, pad, orig_size)
790
+
791
+ def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
792
+ if image is None:
793
+ raise ValueError("Input image is None")
794
+ if not isinstance(image, np.ndarray):
795
+ raise TypeError(f"Input is not numpy array: {type(image)}")
796
+ if image.ndim != 3:
797
+ raise ValueError(f"Expected HWC image, got shape={image.shape}")
798
+ if image.shape[0] <= 0 or image.shape[1] <= 0:
799
+ raise ValueError(f"Invalid image shape={image.shape}")
800
+ if image.shape[2] != 3:
801
+ raise ValueError(f"Expected 3 channels, got shape={image.shape}")
802
+
803
+ if image.dtype != np.uint8:
804
+ image = image.astype(np.uint8)
805
+
806
+ input_tensor, ratio, pad, orig_size = self._preprocess(image)
807
+
808
+ expected_shape = (1, 3, self.input_height, self.input_width)
809
+ if input_tensor.shape != expected_shape:
810
+ raise ValueError(
811
+ f"Bad input tensor shape={input_tensor.shape}, expected={expected_shape}"
812
+ )
813
+
814
+ outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
815
+ det_output = outputs[0]
816
+ return self._postprocess(det_output, ratio, pad, orig_size)
817
+
818
+ def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
819
+ """Horizontal-flip TTA.
820
+
821
+ Strategy (ported from fire001):
822
+ 1. Predict on original and on flipped image.
823
+ 2. Map flipped boxes back to original coordinates.
824
+ 3. Per-class hard NMS on the union.
825
+ 4. For each kept box, compute the max SAME-CLASS score across the
826
+ FULL union -- a high-confidence flipped detection raises a
827
+ borderline original one, but never one of a different class.
828
+ 5. Cross-class dedup to suppress same-physical-object multi-class.
829
+ """
830
+ boxes_orig = self._predict_single(image)
831
+
832
+ flipped = cv2.flip(image, 1)
833
+ boxes_flip = self._predict_single(flipped)
834
+
835
+ w = image.shape[1]
836
+ boxes_flip = [
837
+ BoundingBox(
838
+ x1=w - b.x2, y1=b.y1, x2=w - b.x1, y2=b.y2,
839
+ cls_id=b.cls_id, conf=b.conf,
840
+ )
841
+ for b in boxes_flip
842
+ ]
843
+
844
+ all_boxes = boxes_orig + boxes_flip
845
+ if len(all_boxes) == 0:
846
+ return []
847
+
848
+ coords = np.array(
849
+ [[b.x1, b.y1, b.x2, b.y2] for b in all_boxes], dtype=np.float32
850
+ )
851
+ scores = np.array([b.conf for b in all_boxes], dtype=np.float32)
852
+ cls_ids = np.array([b.cls_id for b in all_boxes], dtype=np.int32)
853
+
854
+ hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
855
+ if len(hard_keep) == 0:
856
+ return []
857
+ if len(hard_keep) > self.max_det:
858
+ top = np.argsort(-scores[hard_keep])[: self.max_det]
859
+ hard_keep = hard_keep[top]
860
+
861
+ # Class-aware cluster-max score boost (fixes the silent cross-class
862
+ # leak in the previous _max_score_per_cluster).
863
+ boosted = self._max_score_per_cluster(
864
+ coords[hard_keep], cls_ids[hard_keep],
865
+ coords, scores, cls_ids, self.iou_thres,
866
+ )
867
+
868
+ kept_coords = coords[hard_keep]
869
+ kept_cls = cls_ids[hard_keep]
870
+ if len(kept_coords) > 1:
871
+ kept_coords, boosted, kept_cls = self._cross_class_dedup_op(
872
+ kept_coords, boosted, kept_cls, self.cross_iou_thresh
873
+ )
874
+
875
+ return [
876
+ BoundingBox(
877
+ x1=int(math.floor(kept_coords[j, 0])),
878
+ y1=int(math.floor(kept_coords[j, 1])),
879
+ x2=int(math.ceil(kept_coords[j, 2])),
880
+ y2=int(math.ceil(kept_coords[j, 3])),
881
+ cls_id=int(kept_cls[j]),
882
+ conf=float(boosted[j]),
883
+ )
884
+ for j in range(len(kept_coords))
885
+ ]
886
+
887
+ def predict_batch(
888
+ self,
889
+ batch_images: list[ndarray],
890
+ offset: int,
891
+ n_keypoints: int,
892
+ ) -> list[TVFrameResult]:
893
+ results: list[TVFrameResult] = []
894
+
895
+ for frame_number_in_batch, image in enumerate(batch_images):
896
+ try:
897
+ if self.use_tta:
898
+ boxes = self._predict_tta(image)
899
+ else:
900
+ boxes = self._predict_single(image)
901
+ except Exception as e:
902
+ print(f"⚠️ Inference failed for frame {offset + frame_number_in_batch}: {e}")
903
+ boxes = []
904
+
905
+ results.append(
906
+ TVFrameResult(
907
+ frame_id=offset + frame_number_in_batch,
908
+ boxes=boxes,
909
+ keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
910
+ )
911
+ )
912
+
913
  return results
weights.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:0e6e1480df66e695ec12ca70116ff30edff54de3473fdd95c0c64ef902e6a9be
3
- size 9842746
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:756f81aa467d482bb6508d172cc20c34fbb4cb7f15eeabb5538b858a1e561c94
3
+ size 9824686