MTerryJack commited on
Commit
fe96ce1
·
verified ·
1 Parent(s): 71e2072

subnet_bridge: copy winning miner repo into library

Browse files
Files changed (8) hide show
  1. README.md +13 -4
  2. chute_config.yml +2 -3
  3. class_names.txt +0 -4
  4. miner.py +896 -977
  5. my_chute.py +0 -440
  6. pyproject.toml +4 -3
  7. readme.md +0 -18
  8. weights.onnx +2 -2
README.md CHANGED
@@ -10,9 +10,18 @@ tags:
10
  manako:
11
  source: winner_fetch
12
  manifest_element_name: manak0/Detect-fire
13
- winner_repo_id: SuperBitDev/fire4
14
- winner_revision: 0679fa90aae80dacbcaa1235755c2791da2f7cc0
15
- note: E=0.03082304 (map50=0.600000, size_mb=19.465956)
16
  ---
17
 
18
- ## YOLO26 ONNX detector
 
 
 
 
 
 
 
 
 
 
10
  manako:
11
  source: winner_fetch
12
  manifest_element_name: manak0/Detect-fire
13
+ winner_repo_id: SuperBitDev/cw1
14
+ winner_revision: 20a49423c3135551fe19c0d78df3d0a15b0b4917
15
+ note: E=0.03105072 (map50=0.600000, size_mb=19.323222)
16
  ---
17
 
18
+ # Detect-fire-winner
19
+
20
+ Published winning miner converted into a library element.
21
+
22
+ - Source winner repo: `SuperBitDev/cw1`
23
+ - Source revision: `20a49423c3135551fe19c0d78df3d0a15b0b4917`
24
+ - Manifest element: `manak0/Detect-fire`
25
+ - Element type: `Detect`
26
+ - Objects: fire, smoke, fire extinguisher
27
+ - Runtime model type: `ultralytics-yolo`
chute_config.yml CHANGED
@@ -2,13 +2,12 @@ Image:
2
  from_base: parachutes/python:3.12
3
  run_command:
4
  - pip install --upgrade setuptools wheel
5
- - pip install 'numpy>=1.23' 'onnxruntime-gpu[cuda,cudnn]>=1.16' 'opencv-python>=4.7' 'pillow>=9.5' 'huggingface_hub>=0.19.4' 'pydantic>=2.0' 'pyyaml>=6.0' 'aiohttp>=3.9'
6
- - pip install torch torchvision
7
 
8
  NodeSelector:
9
  gpu_count: 1
10
  min_vram_gb_per_gpu: 16
11
- max_hourly_price_per_gpu: 2
12
  include:
13
  - pro_6000
14
 
 
2
  from_base: parachutes/python:3.12
3
  run_command:
4
  - pip install --upgrade setuptools wheel
5
+ - pip install 'numpy>=1.23' 'onnxruntime-gpu>=1.16' 'opencv-python>=4.7' 'pillow>=9.5' 'huggingface_hub>=0.19.4' 'pydantic>=2.0' 'pyyaml>=6.0' 'aiohttp>=3.9'
6
+ - pip install torch==2.8.0 torchvision==0.23.0 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
7
 
8
  NodeSelector:
9
  gpu_count: 1
10
  min_vram_gb_per_gpu: 16
 
11
  include:
12
  - pro_6000
13
 
class_names.txt DELETED
@@ -1,4 +0,0 @@
1
- broom
2
- drainage gate
3
- nozzle
4
- track
 
 
 
 
 
miner.py CHANGED
@@ -1,977 +1,896 @@
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
- """ONNX Runtime miner for fire / smoke / fire_extinguisher detection.
28
-
29
- Strategy (ported from offense miner):
30
- - per-class confidence threshold with per-class rescue bonus
31
- - per-class hard NMS, then cross-class dedup
32
- - horizontal-flip TTA with full-set cluster score boost
33
- Plus fire001 specifics: class remap, sanity-box filter, TTA toggle.
34
- """
35
-
36
- class_names = ["fire", "smoke", "fire extinguisher"]
37
- # FALLBACK order the model emits classes in -- remapped to `class_names`
38
- # index by `self.cls_remap` (built in __init__). The authoritative order
39
- # is read from the ONNX `names` metadata that Ultralytics embeds at
40
- # export time (ships inside weights.onnx), so a retrained model with a
41
- # different class order is remapped correctly without code changes.
42
- # Used only when that metadata is missing or unparsable.
43
- _model_class_order = ["fire", "fire extinguisher", "smoke"]
44
-
45
- iou_thres = 0.45
46
- cross_iou_thresh = 0.8
47
- max_det = 150
48
-
49
- # Per-class confidence thresholds. Higher = fewer FP for that class.
50
- # Indexed by class_names order: [fire, smoke, fire_extinguisher].
51
- _conf_thres_array = np.array(
52
- [0.42, 0.2, 0.15], dtype=np.float32
53
- )
54
- # Per-class rescue bonus. If a class has ZERO boxes passing the threshold
55
- # in a frame, its top-1 candidate is admitted when its score is at least
56
- # (threshold - bonus). Fire and smoke get a small bonus (variable
57
- # appearance); fire extinguisher does not (distinctive object, leave FP
58
- # control strict).
59
- _bonus_array = np.array(
60
- [0.28, 0.05, 0.05], dtype=np.float32
61
- )
62
-
63
- # Box sanity filter (fire001-specific FP reduction): drop tiny / degenerate
64
- # / image-spanning / extreme aspect ratio boxes.
65
- min_box_area = 14 * 14
66
- min_side = 8
67
- max_aspect_ratio = 8.0
68
-
69
- # Smoke merge: two smoke boxes whose intersection covers at least this
70
- # fraction of the SMALLER box are treated as the same smoke object and
71
- # replaced by their union. Catches nested boxes (IoU below NMS threshold)
72
- # and fragmented plume detections.
73
- smoke_merge_overlap = 0.8
74
-
75
- # ── Low-confidence color-prior FP filters ───────────────────────────────
76
- # Ported from the firedetect1007 miner's color checks, but applied ONLY to
77
- # the borderline confidence band (just above each per-class threshold) and
78
- # ONLY on color frames. A fire/extinguisher detection there is dropped when
79
- # its pixels clearly do not match the expected appearance: warm/bright for
80
- # fire, red for extinguisher. High-confidence detections are never touched.
81
- #
82
- # The reference miner ran these unconditionally -- a BUG on this validator,
83
- # which feeds some frames as grayscale (a true red extinguisher is gray
84
- # there, so a red test would wrongly delete it). We skip the filter when the
85
- # ROI is near-grayscale, so it never fires on those frames.
86
- #
87
- # Tunable: set a max-conf gate to 0.0 to disable that filter. After a model
88
- # retrain, re-validate these with tune_miner.py (the gates are relative to
89
- # the per-class thresholds, so they move when those move).
90
- fire_color_filter_max_conf = 0.45 # only fire boxes in (thresh, 0.45]
91
- fire_ext_color_filter_max_conf = 0.40 # only ext boxes in (thresh, 0.40]
92
- color_filter_min_saturation = 0.06 # skip filter if ROI is near-grayscale
93
-
94
- # ── Corroboration FP filters (optional; OFF by default) ─────────────────
95
- # Ported in spirit from firedetect1007. Both REMOVE borderline boxes that
96
- # lack support -- a precision play for the validator's FP pillar. OFF by
97
- # default because, unlike the color priors, they can also drop true
98
- # positives; enable + sweep with verify_filters.py and keep only the
99
- # settings that raise the measured composite. A max-conf gate of 0.0
100
- # disables the corresponding filter.
101
- # edge filter: drop boxes touching the frame border in a low-conf band
102
- # (the validator scales/crops, so border-hugging boxes are often the
103
- # truncated remains of an object whose body is off-frame).
104
- # tta view filter: drop low-conf boxes that appear in only ONE of the two
105
- # horizontal-flip TTA views (a real object is usually seen in both).
106
- use_edge_filter = False
107
- edge_filter_max_conf = 0.0 # drop edge-touching boxes with conf <= this
108
- edge_tol = 2.0 # px from the border counted as "on edge"
109
- use_tta_view_filter = False
110
- tta_view_filter_max_conf = 0.0 # drop single-view boxes with conf <= this
111
- tta_view_iou_thresh = 0.5 # IoU for "same object seen in both views"
112
-
113
- def __init__(self, path_hf_repo: Path) -> None:
114
- model_path = path_hf_repo / "weights.onnx"
115
- print("ORT version:", ort.__version__)
116
-
117
- try:
118
- ort.preload_dlls()
119
- print("✅ onnxruntime.preload_dlls() success")
120
- except Exception as e:
121
- print(f"⚠️ preload_dlls failed: {e}")
122
-
123
- print("ORT available providers BEFORE session:", ort.get_available_providers())
124
-
125
- sess_options = ort.SessionOptions()
126
- sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
127
-
128
- try:
129
- self.session = ort.InferenceSession(
130
- str(model_path),
131
- sess_options=sess_options,
132
- providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
133
- )
134
- print("✅ Created ORT session with preferred CUDA provider list")
135
- except Exception as e:
136
- print(f"⚠️ CUDA session creation failed, falling back to CPU: {e}")
137
- self.session = ort.InferenceSession(
138
- str(model_path),
139
- sess_options=sess_options,
140
- providers=["CPUExecutionProvider"],
141
- )
142
-
143
- print("ORT session providers:", self.session.get_providers())
144
-
145
- # Build cls_remap: for each model-emit index i,
146
- # cls_remap[i] = self.class_names.index(model_class_order[i])
147
- # i.e. converts a model-side class id into the canonical class id
148
- # that downstream code (BoundingBox.cls_id, validator) expects.
149
- # The model-side order comes from the ONNX metadata when available,
150
- # else falls back to the static _model_class_order.
151
- model_class_order = self._read_model_class_order()
152
- if model_class_order is None:
153
- model_class_order = list(self._model_class_order)
154
- print(f"cls order: no usable ONNX metadata, FALLBACK {model_class_order}")
155
- else:
156
- print(f"cls order: from ONNX metadata {model_class_order}")
157
- self.cls_remap = np.array(
158
- [self.class_names.index(n) for n in model_class_order],
159
- dtype=np.int32,
160
- )
161
-
162
- for inp in self.session.get_inputs():
163
- print("INPUT:", inp.name, inp.shape, inp.type)
164
- for out in self.session.get_outputs():
165
- print("OUTPUT:", out.name, out.shape, out.type)
166
-
167
- self.input_name = self.session.get_inputs()[0].name
168
- self.output_names = [output.name for output in self.session.get_outputs()]
169
- self.input_shape = self.session.get_inputs()[0].shape
170
-
171
- self.input_height = self._safe_dim(self.input_shape[2], default=1280)
172
- self.input_width = self._safe_dim(self.input_shape[3], default=1280)
173
-
174
- self.use_tta = True
175
-
176
- print(f"✅ ONNX model loaded from: {model_path}")
177
- print(f"✅ ONNX providers: {self.session.get_providers()}")
178
- print(f"✅ ONNX input: name={self.input_name}, shape={self.input_shape}")
179
- print("per-class conf: " + ", ".join(
180
- f"{n}={t:.3f}" for n, t in zip(
181
- self.class_names, self._conf_thres_array.tolist()
182
- )
183
- ))
184
-
185
- def __repr__(self) -> str:
186
- return (
187
- f"ONNXRuntime(session={type(self.session).__name__}, "
188
- f"providers={self.session.get_providers()})"
189
- )
190
-
191
- @staticmethod
192
- def _safe_dim(value, default: int) -> int:
193
- return value if isinstance(value, int) and value > 0 else default
194
-
195
- def _read_model_class_order(self) -> list[str] | None:
196
- """Read the model's class order from Ultralytics ONNX metadata.
197
-
198
- Returns the class names ordered by model-emit index, or None when
199
- metadata is missing/unparsable or doesn't match `class_names` as a
200
- set (in which case the static _model_class_order fallback is used).
201
- """
202
- try:
203
- import ast
204
-
205
- meta = self.session.get_modelmeta().custom_metadata_map
206
- names = ast.literal_eval(meta["names"]) # e.g. {0: 'fire', ...}
207
- if isinstance(names, dict):
208
- order = [str(names[i]) for i in sorted(names)]
209
- else:
210
- order = [str(n) for n in names]
211
- except Exception as e:
212
- print(f"cls order: could not read ONNX names metadata ({e})")
213
- return None
214
- if sorted(order) != sorted(self.class_names):
215
- print(
216
- f"cls order: ONNX names {order} do not match expected classes "
217
- f"{self.class_names}; ignoring metadata"
218
- )
219
- return None
220
- return order
221
-
222
- def _letterbox(
223
- self,
224
- image: ndarray,
225
- new_shape: tuple[int, int],
226
- color=(114, 114, 114),
227
- ) -> tuple[ndarray, float, tuple[float, float]]:
228
- h, w = image.shape[:2]
229
- new_w, new_h = new_shape
230
-
231
- ratio = min(new_w / w, new_h / h)
232
- resized_w = int(round(w * ratio))
233
- resized_h = int(round(h * ratio))
234
-
235
- if (resized_w, resized_h) != (w, h):
236
- interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
237
- image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
238
-
239
- dw = (new_w - resized_w) / 2.0
240
- dh = (new_h - resized_h) / 2.0
241
-
242
- left = int(round(dw - 0.1))
243
- right = int(round(dw + 0.1))
244
- top = int(round(dh - 0.1))
245
- bottom = int(round(dh + 0.1))
246
-
247
- padded = cv2.copyMakeBorder(
248
- image, top, bottom, left, right,
249
- borderType=cv2.BORDER_CONSTANT, value=color,
250
- )
251
- return padded, ratio, (dw, dh)
252
-
253
- def _preprocess(
254
- self, image: ndarray
255
- ) -> tuple[np.ndarray, float, tuple[float, float], tuple[int, int]]:
256
- orig_h, orig_w = image.shape[:2]
257
- img, ratio, pad = self._letterbox(
258
- image, (self.input_width, self.input_height)
259
- )
260
- img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
261
- img = img.astype(np.float32) / 255.0
262
- img = np.transpose(img, (2, 0, 1))[None, ...]
263
- img = np.ascontiguousarray(img, dtype=np.float32)
264
- return img, ratio, pad, (orig_w, orig_h)
265
-
266
- @staticmethod
267
- def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
268
- w, h = image_size
269
- boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
270
- boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
271
- boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
272
- boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
273
- return boxes
274
-
275
- @staticmethod
276
- def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
277
- out = np.empty_like(boxes)
278
- out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
279
- out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
280
- out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
281
- out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
282
- return out
283
-
284
- @staticmethod
285
- def _hard_nms(
286
- boxes: np.ndarray, scores: np.ndarray, iou_thresh: float
287
- ) -> np.ndarray:
288
- n = len(boxes)
289
- if n == 0:
290
- return np.array([], dtype=np.intp)
291
- order = np.argsort(-scores)
292
- keep: list[int] = []
293
- while len(order) > 0:
294
- i = int(order[0])
295
- keep.append(i)
296
- if len(order) == 1:
297
- break
298
- rest = order[1:]
299
- xx1 = np.maximum(boxes[i, 0], boxes[rest, 0])
300
- yy1 = np.maximum(boxes[i, 1], boxes[rest, 1])
301
- xx2 = np.minimum(boxes[i, 2], boxes[rest, 2])
302
- yy2 = np.minimum(boxes[i, 3], boxes[rest, 3])
303
- inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
304
- a_i = (max(0.0, boxes[i, 2] - boxes[i, 0]) *
305
- max(0.0, boxes[i, 3] - boxes[i, 1]))
306
- a_r = (np.maximum(0.0, boxes[rest, 2] - boxes[rest, 0]) *
307
- np.maximum(0.0, boxes[rest, 3] - boxes[rest, 1]))
308
- iou = inter / (a_i + a_r - inter + 1e-7)
309
- order = rest[iou <= iou_thresh]
310
- return np.array(keep, dtype=np.intp)
311
-
312
- def _per_class_hard_nms(
313
- self,
314
- boxes: np.ndarray,
315
- scores: np.ndarray,
316
- cls_ids: np.ndarray,
317
- iou_thresh: float,
318
- ) -> np.ndarray:
319
- if len(boxes) == 0:
320
- return np.array([], dtype=np.intp)
321
- all_keep: list[int] = []
322
- for c in np.unique(cls_ids):
323
- mask = cls_ids == c
324
- indices = np.where(mask)[0]
325
- keep = self._hard_nms(boxes[mask], scores[mask], iou_thresh)
326
- all_keep.extend(indices[keep].tolist())
327
- all_keep.sort()
328
- return np.array(all_keep, dtype=np.intp)
329
-
330
- def _cross_class_dedup_op(
331
- self,
332
- boxes: np.ndarray,
333
- scores: np.ndarray,
334
- cls_ids: np.ndarray,
335
- iou_thresh: float,
336
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
337
- """Remove near-duplicate boxes across classes.
338
-
339
- Order candidates by (score - per_class_threshold) margin, then by area;
340
- keep the highest, suppress every other box with IoU > iou_thresh.
341
- This suppresses the case where the same physical object is detected
342
- as multiple classes (e.g. fire vs smoke on the same flames).
343
- """
344
- n = len(boxes)
345
- if n <= 1:
346
- return boxes, scores, cls_ids
347
- boxes = np.asarray(boxes, dtype=np.float32)
348
- scores = np.asarray(scores, dtype=np.float32)
349
- cls_ids = np.asarray(cls_ids, dtype=np.int32)
350
- areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *
351
- np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))
352
- margins = scores - self._conf_thres_array[cls_ids]
353
- order = np.lexsort((-areas, -margins))
354
- suppressed = np.zeros(n, dtype=bool)
355
- keep: list[int] = []
356
- for i in order:
357
- if suppressed[i]:
358
- continue
359
- keep.append(int(i))
360
- bi = boxes[i]
361
- xx1 = np.maximum(bi[0], boxes[:, 0])
362
- yy1 = np.maximum(bi[1], boxes[:, 1])
363
- xx2 = np.minimum(bi[2], boxes[:, 2])
364
- yy2 = np.minimum(bi[3], boxes[:, 3])
365
- inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
366
- a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
367
- iou = inter / (a_i + areas - inter + 1e-7)
368
- dup = iou > iou_thresh
369
- dup[i] = False
370
- suppressed |= dup
371
- keep_idx = np.array(keep, dtype=np.intp)
372
- return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]
373
-
374
- def _merge_smoke_boxes(
375
- self,
376
- boxes: np.ndarray,
377
- scores: np.ndarray,
378
- cls_ids: np.ndarray,
379
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
380
- """Merge multiple detections of the same smoke object into one box.
381
-
382
- Smoke has diffuse boundaries, so one plume often yields several
383
- overlapping / nested boxes that per-class NMS does not collapse
384
- (nested boxes can have IoU below `iou_thres`). Any two smoke boxes
385
- whose intersection covers >= `smoke_merge_overlap` of the smaller box
386
- are merged into their union with the max confidence of the pair.
387
- Repeats until no pair merges, so chains of fragments collapse too.
388
- """
389
- smoke_cls = self.class_names.index("smoke")
390
- smoke_idx = np.where(cls_ids == smoke_cls)[0]
391
- if len(smoke_idx) <= 1:
392
- return boxes, scores, cls_ids
393
-
394
- sb = boxes[smoke_idx].astype(np.float32).tolist()
395
- ss = scores[smoke_idx].astype(np.float32).tolist()
396
- merged_any = True
397
- while merged_any and len(sb) > 1:
398
- merged_any = False
399
- for i in range(len(sb)):
400
- for j in range(i + 1, len(sb)):
401
- a, b = sb[i], sb[j]
402
- ix1 = max(a[0], b[0])
403
- iy1 = max(a[1], b[1])
404
- ix2 = min(a[2], b[2])
405
- iy2 = min(a[3], b[3])
406
- inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
407
- area_a = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1])
408
- area_b = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1])
409
- smaller = min(area_a, area_b)
410
- if inter / (smaller + 1e-7) >= self.smoke_merge_overlap:
411
- sb[i] = [
412
- min(a[0], b[0]), min(a[1], b[1]),
413
- max(a[2], b[2]), max(a[3], b[3]),
414
- ]
415
- ss[i] = max(ss[i], ss[j])
416
- del sb[j]
417
- del ss[j]
418
- merged_any = True
419
- break
420
- if merged_any:
421
- break
422
-
423
- other = cls_ids != smoke_cls
424
- new_boxes = np.concatenate(
425
- [boxes[other].astype(np.float32),
426
- np.array(sb, dtype=np.float32).reshape(-1, 4)]
427
- )
428
- new_scores = np.concatenate(
429
- [scores[other].astype(np.float32),
430
- np.array(ss, dtype=np.float32)]
431
- )
432
- new_cls = np.concatenate(
433
- [cls_ids[other].astype(np.int32),
434
- np.full(len(sb), smoke_cls, dtype=np.int32)]
435
- )
436
- return new_boxes, new_scores, new_cls
437
-
438
- @staticmethod
439
- def _max_score_per_cluster(
440
- post_boxes: np.ndarray,
441
- post_cls: np.ndarray,
442
- full_boxes: np.ndarray,
443
- full_scores: np.ndarray,
444
- full_cls: np.ndarray,
445
- iou_thresh: float,
446
- ) -> np.ndarray:
447
- """For each kept (post-NMS) box, return the max score over the FULL
448
- candidate set among same-class boxes with IoU >= iou_thresh.
449
-
450
- Used after horizontal-flip TTA: a high-confidence flipped detection
451
- can raise the score of the corresponding original detection.
452
- """
453
- n = len(post_boxes)
454
- if n == 0:
455
- return np.empty(0, dtype=np.float32)
456
- full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
457
- np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
458
- out = np.empty(n, dtype=np.float32)
459
- for i in range(n):
460
- bi = post_boxes[i]
461
- xx1 = np.maximum(bi[0], full_boxes[:, 0])
462
- yy1 = np.maximum(bi[1], full_boxes[:, 1])
463
- xx2 = np.minimum(bi[2], full_boxes[:, 2])
464
- yy2 = np.minimum(bi[3], full_boxes[:, 3])
465
- inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
466
- a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
467
- iou = inter / (a_i + full_areas - inter + 1e-7)
468
- cluster = (iou >= iou_thresh) & (full_cls == post_cls[i])
469
- out[i] = float(np.max(full_scores[cluster])) if np.any(cluster) else 0.0
470
- return out
471
-
472
- def _conf_filter_mask(
473
- self, scores: np.ndarray, cls_ids: np.ndarray
474
- ) -> np.ndarray:
475
- """Boolean keep-mask: score >= per-class threshold, with a per-class
476
- rescue -- if a class has zero boxes passing, admit its top-1 candidate
477
- when its score >= (per-class threshold - per-class bonus)."""
478
- if len(scores) == 0:
479
- return np.zeros(0, dtype=bool)
480
- thr = self._conf_thres_array[cls_ids]
481
- keep = scores >= thr
482
- for c in np.unique(cls_ids):
483
- b = float(self._bonus_array[c])
484
- if b <= 0.0:
485
- continue
486
- cm = cls_ids == c
487
- if keep[cm].any():
488
- continue
489
- idx = np.where(cm)[0]
490
- top = int(idx[int(np.argmax(scores[idx]))])
491
- if scores[top] >= self._conf_thres_array[c] - b:
492
- keep[top] = True
493
- return keep
494
-
495
- def _filter_sane_boxes(
496
- self,
497
- boxes: np.ndarray,
498
- scores: np.ndarray,
499
- cls_ids: np.ndarray,
500
- orig_size: tuple[int, int],
501
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
502
- """Drop tiny / degenerate / image-spanning / extreme-AR boxes (FP)."""
503
- if len(boxes) == 0:
504
- return boxes, scores, cls_ids
505
- orig_w, orig_h = orig_size
506
- image_area = float(orig_w * orig_h)
507
- keep = []
508
- for i, box in enumerate(boxes):
509
- x1, y1, x2, y2 = box.tolist()
510
- bw = x2 - x1
511
- bh = y2 - y1
512
- if bw <= 0 or bh <= 0:
513
- continue
514
- if bw < self.min_side or bh < self.min_side:
515
- continue
516
- area = bw * bh
517
- if area < self.min_box_area:
518
- continue
519
- if area > 0.95 * image_area:
520
- continue
521
- ar = max(bw / max(bh, 1e-6), bh / max(bw, 1e-6))
522
- if ar > self.max_aspect_ratio:
523
- continue
524
- keep.append(i)
525
- if not keep:
526
- return (
527
- np.empty((0, 4), dtype=np.float32),
528
- np.empty((0,), dtype=np.float32),
529
- np.empty((0,), dtype=np.int32),
530
- )
531
- k = np.array(keep, dtype=np.intp)
532
- return boxes[k], scores[k], cls_ids[k]
533
-
534
- def _per_view_pipeline(
535
- self,
536
- boxes: np.ndarray,
537
- scores: np.ndarray,
538
- cls_ids: np.ndarray,
539
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
540
- """Per-view post-processing pipeline: per-class NMS -> cap -> cross-class dedup -> smoke merge."""
541
- if len(boxes) > 1:
542
- keep = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
543
- boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
544
- if len(scores) > self.max_det:
545
- top = np.argsort(-scores)[: self.max_det]
546
- boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
547
- if len(boxes) > 1:
548
- boxes, scores, cls_ids = self._cross_class_dedup_op(
549
- boxes, scores, cls_ids, self.cross_iou_thresh
550
- )
551
- if len(boxes) > 1:
552
- boxes, scores, cls_ids = self._merge_smoke_boxes(boxes, scores, cls_ids)
553
- return boxes, scores, cls_ids
554
-
555
- @staticmethod
556
- def _roi_for_box(image: np.ndarray, box: BoundingBox) -> np.ndarray | None:
557
- """Clip a BoundingBox to the image and return its BGR pixel ROI."""
558
- h, w = image.shape[:2]
559
- x1 = max(0, int(math.floor(box.x1)))
560
- y1 = max(0, int(math.floor(box.y1)))
561
- x2 = min(w, int(math.ceil(box.x2)))
562
- y2 = min(h, int(math.ceil(box.y2)))
563
- if x2 <= x1 or y2 <= y1:
564
- return None
565
- roi = image[y1:y2, x1:x2]
566
- return roi if roi.size else None
567
-
568
- def _roi_is_near_grayscale(self, roi: np.ndarray) -> bool:
569
- """True if the ROI carries almost no color (validator grayscale frame).
570
- On such ROIs the color priors are skipped so they can't delete valid
571
- red/warm objects that have been stripped of color."""
572
- mx = roi.max(axis=2).astype(np.float32)
573
- mn = roi.min(axis=2).astype(np.float32)
574
- sat = (mx - mn) / (mx + 1e-6)
575
- return float(sat.mean()) < self.color_filter_min_saturation
576
-
577
- @staticmethod
578
- def _passes_fire_color(roi: np.ndarray) -> bool:
579
- """Fire is warm and/or has a bright hotspot. ROI is BGR."""
580
- blue = roi[:, :, 0].astype(np.float32)
581
- green = roi[:, :, 1].astype(np.float32)
582
- red = roi[:, :, 2].astype(np.float32)
583
- mean_r = float(np.mean(red))
584
- max_rgb = float(max(np.max(red), np.max(green), np.max(blue)))
585
- bright_frac = float(np.mean(np.max(roi, axis=2) >= 150))
586
- # A bright hotspot is fire-like even with little hue (also covers the
587
- # near-white core of an intense flame).
588
- if max_rgb >= 200.0 and bright_frac >= 0.01:
589
- return True
590
- warm = (red > green + 10.0) & (red > blue + 10.0)
591
- warm_frac = float(np.mean(warm))
592
- r_minus_g = mean_r - float(np.mean(green))
593
- if warm_frac >= 0.05 and (
594
- max_rgb >= 120.0 or mean_r >= 120.0 or warm_frac >= 0.15
595
- ):
596
- return True
597
- if bright_frac >= 0.12 and r_minus_g >= 2.0:
598
- return True
599
- return False
600
-
601
- @staticmethod
602
- def _passes_fire_ext_red_color(roi: np.ndarray) -> bool:
603
- """Fire extinguishers are red. ROI is BGR. Lenient: only clearly
604
- cool/green/blue or very dark regions fail."""
605
- blue = roi[:, :, 0].astype(np.float32)
606
- green = roi[:, :, 1].astype(np.float32)
607
- red = roi[:, :, 2].astype(np.float32)
608
- red_dom = float(np.mean((red > green + 10.0) & (red > blue + 10.0)))
609
- if red_dom >= 0.03:
610
- return True
611
- if (float(np.mean(red)) - float(np.mean(green))) >= 0.0 and \
612
- float(np.mean(red)) >= 50.0:
613
- return True
614
- return False
615
-
616
- def _remove_edge_low_conf(
617
- self, results: list[BoundingBox], orig_size: tuple[int, int]
618
- ) -> list[BoundingBox]:
619
- """Drop border-hugging boxes in the low-confidence band."""
620
- if (
621
- not self.use_edge_filter
622
- or self.edge_filter_max_conf <= 0.0
623
- or not results
624
- ):
625
- return results
626
- w, h = orig_size
627
- tol = self.edge_tol
628
- out: list[BoundingBox] = []
629
- for b in results:
630
- on_edge = (
631
- b.x1 <= tol
632
- or b.y1 <= tol
633
- or b.x2 >= w - 1 - tol
634
- or b.y2 >= h - 1 - tol
635
- )
636
- if on_edge and b.conf <= self.edge_filter_max_conf:
637
- continue
638
- out.append(b)
639
- return out
640
-
641
- def _views_corroborated(
642
- self,
643
- post_boxes: np.ndarray,
644
- post_cls: np.ndarray,
645
- full_boxes: np.ndarray,
646
- full_cls: np.ndarray,
647
- full_views: np.ndarray,
648
- iou_thresh: float,
649
- ) -> np.ndarray:
650
- """For each post-NMS box, True if same-class detections from >= 2
651
- distinct TTA views overlap it (IoU >= iou_thresh) in the full union."""
652
- n = len(post_boxes)
653
- if n == 0:
654
- return np.zeros(0, dtype=bool)
655
- full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
656
- np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
657
- out = np.zeros(n, dtype=bool)
658
- for i in range(n):
659
- bi = post_boxes[i]
660
- xx1 = np.maximum(bi[0], full_boxes[:, 0])
661
- yy1 = np.maximum(bi[1], full_boxes[:, 1])
662
- xx2 = np.minimum(bi[2], full_boxes[:, 2])
663
- yy2 = np.minimum(bi[3], full_boxes[:, 3])
664
- inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
665
- a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
666
- iou = inter / (a_i + full_areas - inter + 1e-7)
667
- mask = (iou >= iou_thresh) & (full_cls == post_cls[i])
668
- if np.any(mask):
669
- out[i] = len(np.unique(full_views[mask])) >= 2
670
- return out
671
-
672
- def _filter_low_conf_by_color(
673
- self, image: np.ndarray, results: list[BoundingBox]
674
- ) -> list[BoundingBox]:
675
- """Drop borderline fire / extinguisher detections whose pixels clearly
676
- contradict the class's expected color. No-op on near-grayscale ROIs and
677
- on detections above the per-class color-filter conf gate."""
678
- if not results:
679
- return results
680
- cls_fire = self.class_names.index("fire")
681
- cls_ext = self.class_names.index("fire extinguisher")
682
- out: list[BoundingBox] = []
683
- for box in results:
684
- check_fire = (
685
- box.cls_id == cls_fire
686
- and box.conf <= self.fire_color_filter_max_conf
687
- )
688
- check_ext = (
689
- box.cls_id == cls_ext
690
- and box.conf <= self.fire_ext_color_filter_max_conf
691
- )
692
- if not check_fire and not check_ext:
693
- out.append(box)
694
- continue
695
- roi = self._roi_for_box(image, box)
696
- if roi is None or self._roi_is_near_grayscale(roi):
697
- out.append(box)
698
- continue
699
- if check_fire and not self._passes_fire_color(roi):
700
- continue
701
- if check_ext and not self._passes_fire_ext_red_color(roi):
702
- continue
703
- out.append(box)
704
- return out
705
-
706
- @staticmethod
707
- def _build_results(
708
- boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray
709
- ) -> list[BoundingBox]:
710
- results: list[BoundingBox] = []
711
- for box, conf, cls_id in zip(boxes, scores, cls_ids):
712
- x1, y1, x2, y2 = box.tolist()
713
- if x2 <= x1 or y2 <= y1:
714
- continue
715
- results.append(
716
- BoundingBox(
717
- x1=int(math.floor(x1)),
718
- y1=int(math.floor(y1)),
719
- x2=int(math.ceil(x2)),
720
- y2=int(math.ceil(y2)),
721
- cls_id=int(cls_id),
722
- conf=float(conf),
723
- )
724
- )
725
- return results
726
-
727
- def _decode_final_dets(
728
- self,
729
- preds: np.ndarray,
730
- ratio: float,
731
- pad: tuple[float, float],
732
- orig_size: tuple[int, int],
733
- ) -> list[BoundingBox]:
734
- """Final-detection output path: rows shaped [x1, y1, x2, y2, conf, cls_id]."""
735
- if preds.ndim == 3 and preds.shape[0] == 1:
736
- preds = preds[0]
737
- if preds.ndim != 2 or preds.shape[1] < 6:
738
- raise ValueError(f"Unexpected ONNX final-det output shape: {preds.shape}")
739
-
740
- boxes = preds[:, :4].astype(np.float32)
741
- scores = preds[:, 4].astype(np.float32)
742
- cls_ids = preds[:, 5].astype(np.int32)
743
- cls_ids = self.cls_remap[cls_ids]
744
-
745
- keep = self._conf_filter_mask(scores, cls_ids)
746
- boxes = boxes[keep]
747
- scores = scores[keep]
748
- cls_ids = cls_ids[keep]
749
- if len(boxes) == 0:
750
- return []
751
-
752
- pad_w, pad_h = pad
753
- boxes[:, [0, 2]] -= pad_w
754
- boxes[:, [1, 3]] -= pad_h
755
- boxes /= ratio
756
- boxes = self._clip_boxes(boxes, orig_size)
757
-
758
- boxes, scores, cls_ids = self._filter_sane_boxes(
759
- boxes, scores, cls_ids, orig_size
760
- )
761
- if len(boxes) == 0:
762
- return []
763
-
764
- boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
765
- return self._build_results(boxes, scores, cls_ids)
766
-
767
- def _decode_raw_yolo(
768
- self,
769
- preds: np.ndarray,
770
- ratio: float,
771
- pad: tuple[float, float],
772
- orig_size: tuple[int, int],
773
- ) -> list[BoundingBox]:
774
- """Fallback raw-YOLO output path: per-anchor class logits."""
775
- if preds.ndim != 3 or preds.shape[0] != 1:
776
- raise ValueError(f"Unexpected raw ONNX output shape: {preds.shape}")
777
- preds = preds[0]
778
- if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
779
- preds = preds.T
780
- if preds.ndim != 2 or preds.shape[1] < 5:
781
- raise ValueError(f"Unexpected raw output shape: {preds.shape}")
782
-
783
- boxes_xywh = preds[:, :4].astype(np.float32)
784
- cls_part = preds[:, 4:].astype(np.float32)
785
- if cls_part.shape[1] == 1:
786
- scores = cls_part[:, 0]
787
- cls_ids = np.zeros(len(scores), dtype=np.int32)
788
- else:
789
- cls_ids = np.argmax(cls_part, axis=1).astype(np.int32)
790
- scores = cls_part[np.arange(len(cls_part)), cls_ids]
791
- cls_ids = self.cls_remap[cls_ids]
792
-
793
- keep = self._conf_filter_mask(scores, cls_ids)
794
- boxes_xywh = boxes_xywh[keep]
795
- scores = scores[keep]
796
- cls_ids = cls_ids[keep]
797
- if len(boxes_xywh) == 0:
798
- return []
799
- boxes = self._xywh_to_xyxy(boxes_xywh)
800
-
801
- pad_w, pad_h = pad
802
- boxes[:, [0, 2]] -= pad_w
803
- boxes[:, [1, 3]] -= pad_h
804
- boxes /= ratio
805
- boxes = self._clip_boxes(boxes, orig_size)
806
-
807
- boxes, scores, cls_ids = self._filter_sane_boxes(
808
- boxes, scores, cls_ids, orig_size
809
- )
810
- if len(boxes) == 0:
811
- return []
812
-
813
- boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
814
- return self._build_results(boxes, scores, cls_ids)
815
-
816
- def _postprocess(
817
- self,
818
- output: np.ndarray,
819
- ratio: float,
820
- pad: tuple[float, float],
821
- orig_size: tuple[int, int],
822
- ) -> list[BoundingBox]:
823
- if output.ndim == 2 and output.shape[1] >= 6:
824
- return self._decode_final_dets(output, ratio, pad, orig_size)
825
- if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
826
- return self._decode_final_dets(output, ratio, pad, orig_size)
827
- return self._decode_raw_yolo(output, ratio, pad, orig_size)
828
-
829
- def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
830
- if image is None:
831
- raise ValueError("Input image is None")
832
- if not isinstance(image, np.ndarray):
833
- raise TypeError(f"Input is not numpy array: {type(image)}")
834
- if image.ndim != 3:
835
- raise ValueError(f"Expected HWC image, got shape={image.shape}")
836
- if image.shape[0] <= 0 or image.shape[1] <= 0:
837
- raise ValueError(f"Invalid image shape={image.shape}")
838
- if image.shape[2] != 3:
839
- raise ValueError(f"Expected 3 channels, got shape={image.shape}")
840
- if image.dtype != np.uint8:
841
- image = image.astype(np.uint8)
842
-
843
- input_tensor, ratio, pad, orig_size = self._preprocess(image)
844
- expected = (1, 3, self.input_height, self.input_width)
845
- if input_tensor.shape != expected:
846
- raise ValueError(
847
- f"Bad input tensor shape={input_tensor.shape}, expected={expected}"
848
- )
849
-
850
- outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
851
- return self._postprocess(outputs[0], ratio, pad, orig_size)
852
-
853
- def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
854
- """Horizontal-flip TTA.
855
-
856
- Strategy:
857
- 1. Predict on original and on flipped image.
858
- 2. Map flipped boxes back to original coordinates.
859
- 3. Per-class hard NMS on the union.
860
- 4. For each kept box, compute the max same-class score across the
861
- FULL union (not just the post-NMS subset) -- this lets a high-
862
- confidence flipped detection raise a borderline original one.
863
- 5. Cross-class dedup to suppress same-physical-object multi-class.
864
- 6. Smoke merge: overlapping / nested smoke boxes collapse into
865
- their union (one box per smoke object).
866
- """
867
- boxes_orig = self._predict_single(image)
868
- flipped = cv2.flip(image, 1)
869
- boxes_flip = self._predict_single(flipped)
870
- w = image.shape[1]
871
- boxes_flip = [
872
- BoundingBox(
873
- x1=w - b.x2, y1=b.y1, x2=w - b.x1, y2=b.y2,
874
- cls_id=b.cls_id, conf=b.conf,
875
- )
876
- for b in boxes_flip
877
- ]
878
- all_boxes = boxes_orig + boxes_flip
879
- if not all_boxes:
880
- return []
881
-
882
- coords = np.array(
883
- [[b.x1, b.y1, b.x2, b.y2] for b in all_boxes], dtype=np.float32
884
- )
885
- scores = np.array([b.conf for b in all_boxes], dtype=np.float32)
886
- cls_ids = np.array([b.cls_id for b in all_boxes], dtype=np.int32)
887
- # view_id 0 = original, 1 = horizontal flip (mapped back to orig coords)
888
- view_ids = np.array(
889
- [0] * len(boxes_orig) + [1] * len(boxes_flip), dtype=np.int32
890
- )
891
-
892
- hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
893
- if len(hard_keep) == 0:
894
- return []
895
- if len(hard_keep) > self.max_det:
896
- top = np.argsort(-scores[hard_keep])[: self.max_det]
897
- hard_keep = hard_keep[top]
898
-
899
- boosted = self._max_score_per_cluster(
900
- coords[hard_keep], cls_ids[hard_keep],
901
- coords, scores, cls_ids, self.iou_thres,
902
- )
903
-
904
- kept_coords = coords[hard_keep]
905
- kept_cls = cls_ids[hard_keep]
906
-
907
- # Optional: drop low-conf detections seen in only one TTA view.
908
- if (
909
- self.use_tta_view_filter
910
- and self.tta_view_filter_max_conf > 0.0
911
- and len(kept_coords) > 0
912
- ):
913
- corrob = self._views_corroborated(
914
- kept_coords, kept_cls, coords, cls_ids, view_ids,
915
- self.tta_view_iou_thresh,
916
- )
917
- keep = ~((boosted <= self.tta_view_filter_max_conf) & (~corrob))
918
- kept_coords = kept_coords[keep]
919
- boosted = boosted[keep]
920
- kept_cls = kept_cls[keep]
921
-
922
- if len(kept_coords) > 1:
923
- kept_coords, boosted, kept_cls = self._cross_class_dedup_op(
924
- kept_coords, boosted, kept_cls, self.cross_iou_thresh
925
- )
926
- if len(kept_coords) > 1:
927
- kept_coords, boosted, kept_cls = self._merge_smoke_boxes(
928
- kept_coords, boosted, kept_cls
929
- )
930
-
931
- return [
932
- BoundingBox(
933
- x1=int(math.floor(kept_coords[j, 0])),
934
- y1=int(math.floor(kept_coords[j, 1])),
935
- x2=int(math.ceil(kept_coords[j, 2])),
936
- y2=int(math.ceil(kept_coords[j, 3])),
937
- cls_id=int(kept_cls[j]),
938
- conf=float(boosted[j]),
939
- )
940
- for j in range(len(kept_coords))
941
- ]
942
-
943
- def predict_batch(
944
- self,
945
- batch_images: list[ndarray],
946
- offset: int,
947
- n_keypoints: int,
948
- ) -> list[TVFrameResult]:
949
- results: list[TVFrameResult] = []
950
- for frame_number_in_batch, image in enumerate(batch_images):
951
- try:
952
- if self.use_tta:
953
- boxes = self._predict_tta(image)
954
- else:
955
- boxes = self._predict_single(image)
956
- # Color-prior + edge FP filters on the merged result, in
957
- # original-image coords. Single insertion point so they run once
958
- # per frame for both the TTA and non-TTA paths.
959
- if isinstance(image, np.ndarray) and image.ndim == 3:
960
- boxes = self._filter_low_conf_by_color(image, boxes)
961
- boxes = self._remove_edge_low_conf(
962
- boxes, (image.shape[1], image.shape[0])
963
- )
964
- except Exception as e:
965
- print(
966
- f"⚠️ Inference failed for frame "
967
- f"{offset + frame_number_in_batch}: {e}"
968
- )
969
- boxes = []
970
- results.append(
971
- TVFrameResult(
972
- frame_id=offset + frame_number_in_batch,
973
- boxes=boxes,
974
- keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
975
- )
976
- )
977
- 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
+
54
+ try:
55
+ self.session = ort.InferenceSession(
56
+ str(model_path),
57
+ sess_options=sess_options,
58
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
59
+ )
60
+ print("✅ Created ORT session with preferred CUDA provider list")
61
+ except Exception as e:
62
+ print(f"⚠️ CUDA session creation failed, falling back to CPU: {e}")
63
+ self.session = ort.InferenceSession(
64
+ str(model_path),
65
+ sess_options=sess_options,
66
+ providers=["CPUExecutionProvider"],
67
+ )
68
+
69
+ print("ORT session providers:", self.session.get_providers())
70
+
71
+ # Build cls_remap: for each model-emit index i,
72
+ # cls_remap[i] = self.class_names.index(model_class_order[i])
73
+ # The model-side order comes from the ONNX metadata when available,
74
+ # else falls back to the static _model_class_order.
75
+ model_class_order = self._read_model_class_order()
76
+ if model_class_order is None:
77
+ model_class_order = list(self._model_class_order)
78
+ print(f"cls order: no usable ONNX metadata, FALLBACK {model_class_order}")
79
+ else:
80
+ print(f"cls order: from ONNX metadata {model_class_order}")
81
+ self.cls_remap = np.array(
82
+ [self.class_names.index(n) for n in model_class_order], dtype=np.int32
83
+ )
84
+
85
+ for inp in self.session.get_inputs():
86
+ print("INPUT:", inp.name, inp.shape, inp.type)
87
+
88
+ for out in self.session.get_outputs():
89
+ print("OUTPUT:", out.name, out.shape, out.type)
90
+
91
+ self.input_name = self.session.get_inputs()[0].name
92
+ self.output_names = [output.name for output in self.session.get_outputs()]
93
+ self.input_shape = self.session.get_inputs()[0].shape
94
+
95
+ # Match the ONNX input dtype (this export is FP16 -> needs float16 input).
96
+ input_type = self.session.get_inputs()[0].type
97
+ self.np_dtype = np.float16 if "float16" in input_type else np.float32
98
+ print(f"✅ ONNX input dtype: {input_type} -> numpy {self.np_dtype}")
99
+
100
+ # ONNX is fixed-size 1408x1408 (v1 export); read actual shape to be safe.
101
+ self.input_height = self._safe_dim(self.input_shape[2], default=1280)
102
+ self.input_width = self._safe_dim(self.input_shape[3], default=1280)
103
+
104
+ # Tuned for validator scoring (pillars: 0.6*map50 + 0.4*false_positive).
105
+ # All values below are the measured optimum of a full grid sweep on
106
+ # the validator-style val split (tune_miner.py, 241 1024x1024 crops,
107
+ # composite 0.8002 -> 0.8103) -- re-run the sweep after any retrain.
108
+ self.iou_thres = 0.45 # Per-class NMS IoU; lower = stricter dedup
109
+ self.cross_iou_thresh = 0.9 # Cross-class dedup IoU (suppress same physical object firing multiple classes)
110
+ self.max_det = 200
111
+ self.use_tta = True
112
+
113
+ # conf thresholds: broom=0.38 drainage gate=0.45 nozzle=0.30 track=0.60
114
+ # Per-class confidence thresholds.
115
+ # Indexed by class_names order: [broom, drainage gate, nozzle, track].
116
+ # broom/nozzle sit low: under the validator metric the mAP gained
117
+ # from the extra recall outweighs the FP-pillar cost (the previous
118
+ # 0.5/0.5 silently discarded many valid detections); track is the
119
+ # one class where false fires are common enough to need 0.38.
120
+ self._conf_thres_array = np.array(
121
+ [0.37, 0.23, 0.37, 0.45], dtype=np.float32
122
+ )
123
+ # Per-class rescue bonus: when a class has ZERO boxes passing the
124
+ # threshold in a frame, its top-1 candidate is admitted when its score
125
+ # is at least (per-class threshold - per-class bonus).
126
+ # DISABLED (all zeros): the sweep showed rescue admits more false
127
+ # positives than true positives under the validator's FP pillar.
128
+ self._bonus_array = np.array(
129
+ [0.05, 0.05, 0.0, 0.15], dtype=np.float32
130
+ )
131
+
132
+ # Box sanity filter — kept loose: car-wash `nozzle` boxes are tiny
133
+ # (GT median ~290 px², smallest ~32 px²). Fire's 14x14/min_side 8
134
+ # would delete valid nozzles, so thresholds are dropped here.
135
+ self.min_box_area = 4 * 4 # 16 px²
136
+ self.min_side = 3
137
+ self.max_aspect_ratio = 12.0
138
+
139
+ print(f"✅ ONNX model loaded from: {model_path}")
140
+ print(f"✅ ONNX providers: {self.session.get_providers()}")
141
+ print(f"✅ ONNX input: name={self.input_name}, shape={self.input_shape}")
142
+
143
+ def __repr__(self) -> str:
144
+ return (
145
+ f"ONNXRuntime(session={type(self.session).__name__}, "
146
+ f"providers={self.session.get_providers()})"
147
+ )
148
+
149
+ @staticmethod
150
+ def _safe_dim(value, default: int) -> int:
151
+ return value if isinstance(value, int) and value > 0 else default
152
+
153
+ @staticmethod
154
+ def _resolve_model_path(repo: Path) -> Path:
155
+ """Locate the ONNX model in the repo dir.
156
+
157
+ Prefers weights.onnx (FP16/FP32 export), then weights_int8.onnx (the
158
+ training script's INT8-quantized export -- works as-is: quantization
159
+ preserves the Ultralytics metadata and QDQ models take regular fp32
160
+ input), then any other .onnx file. INT8 is the fallback when the FP16
161
+ export exceeds the 30 MB deployment limit (e.g. yolo26m).
162
+ """
163
+ for name in ("weights.onnx", "weights_int8.onnx"):
164
+ p = repo / name
165
+ if p.exists():
166
+ if name != "weights.onnx":
167
+ print(f"model: weights.onnx not found, using {name}")
168
+ return p
169
+ candidates = sorted(repo.glob("*.onnx"))
170
+ if candidates:
171
+ print(f"model: using {candidates[0].name}")
172
+ return candidates[0]
173
+ return repo / "weights.onnx" # let session creation raise the error
174
+
175
+ def _read_model_class_order(self) -> list[str] | None:
176
+ """Read the model's class order from Ultralytics ONNX metadata.
177
+
178
+ Returns the class names ordered by model-emit index, or None when
179
+ metadata is missing/unparsable or doesn't match `class_names` as a
180
+ set (in which case the static _model_class_order fallback is used).
181
+ """
182
+ try:
183
+ import ast
184
+
185
+ meta = self.session.get_modelmeta().custom_metadata_map
186
+ names = ast.literal_eval(meta["names"]) # e.g. {0: 'broom', ...}
187
+ if isinstance(names, dict):
188
+ order = [str(names[i]) for i in sorted(names)]
189
+ else:
190
+ order = [str(n) for n in names]
191
+ except Exception as e:
192
+ print(f"cls order: could not read ONNX names metadata ({e})")
193
+ return None
194
+ if sorted(order) != sorted(self.class_names):
195
+ print(
196
+ f"cls order: ONNX names {order} do not match expected classes "
197
+ f"{self.class_names}; ignoring metadata"
198
+ )
199
+ return None
200
+ return order
201
+
202
+ def _letterbox(
203
+ self,
204
+ image: ndarray,
205
+ new_shape: tuple[int, int],
206
+ color=(114, 114, 114),
207
+ ) -> tuple[ndarray, float, tuple[float, float]]:
208
+ """
209
+ Resize with unchanged aspect ratio and pad to target shape.
210
+ Returns:
211
+ padded_image,
212
+ ratio,
213
+ (pad_w, pad_h) # half-padding
214
+ """
215
+ h, w = image.shape[:2]
216
+ new_w, new_h = new_shape
217
+
218
+ ratio = min(new_w / w, new_h / h)
219
+ resized_w = int(round(w * ratio))
220
+ resized_h = int(round(h * ratio))
221
+
222
+ if (resized_w, resized_h) != (w, h):
223
+ interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
224
+ image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
225
+
226
+ dw = new_w - resized_w
227
+ dh = new_h - resized_h
228
+ dw /= 2.0
229
+ dh /= 2.0
230
+
231
+ left = int(round(dw - 0.1))
232
+ right = int(round(dw + 0.1))
233
+ top = int(round(dh - 0.1))
234
+ bottom = int(round(dh + 0.1))
235
+
236
+ padded = cv2.copyMakeBorder(
237
+ image,
238
+ top,
239
+ bottom,
240
+ left,
241
+ right,
242
+ borderType=cv2.BORDER_CONSTANT,
243
+ value=color,
244
+ )
245
+ return padded, ratio, (dw, dh)
246
+
247
+ def _preprocess(
248
+ self, image: ndarray
249
+ ) -> tuple[np.ndarray, float, tuple[float, float], tuple[int, int]]:
250
+ """
251
+ Preprocess for fixed-size ONNX export:
252
+ - enhance image quality (CLAHE, denoise, sharpen)
253
+ - letterbox to model input size
254
+ - BGR -> RGB
255
+ - normalize to [0,1]
256
+ - HWC -> NCHW float32
257
+ """
258
+ orig_h, orig_w = image.shape[:2]
259
+
260
+ img, ratio, pad = self._letterbox(
261
+ image, (self.input_width, self.input_height)
262
+ )
263
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
264
+ img = (img.astype(np.float32) / 255.0)
265
+ img = np.transpose(img, (2, 0, 1))[None, ...]
266
+ img = np.ascontiguousarray(img, dtype=self.np_dtype)
267
+
268
+ return img, ratio, pad, (orig_w, orig_h)
269
+
270
+ @staticmethod
271
+ def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
272
+ w, h = image_size
273
+ boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
274
+ boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
275
+ boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
276
+ boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
277
+ return boxes
278
+
279
+ @staticmethod
280
+ def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
281
+ out = np.empty_like(boxes)
282
+ out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
283
+ out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
284
+ out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
285
+ out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
286
+ return out
287
+
288
+ def _soft_nms(
289
+ self,
290
+ boxes: np.ndarray,
291
+ scores: np.ndarray,
292
+ sigma: float = 0.5,
293
+ score_thresh: float = 0.01,
294
+ ) -> tuple[np.ndarray, np.ndarray]:
295
+ """
296
+ Soft-NMS: Gaussian decay of overlapping scores instead of hard removal.
297
+ Returns (kept_original_indices, updated_scores).
298
+ """
299
+ N = len(boxes)
300
+ if N == 0:
301
+ return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
302
+
303
+ boxes = boxes.astype(np.float32, copy=True)
304
+ scores = scores.astype(np.float32, copy=True)
305
+ order = np.arange(N)
306
+
307
+ for i in range(N):
308
+ max_pos = i + int(np.argmax(scores[i:]))
309
+ boxes[[i, max_pos]] = boxes[[max_pos, i]]
310
+ scores[[i, max_pos]] = scores[[max_pos, i]]
311
+ order[[i, max_pos]] = order[[max_pos, i]]
312
+
313
+ if i + 1 >= N:
314
+ break
315
+
316
+ xx1 = np.maximum(boxes[i, 0], boxes[i + 1:, 0])
317
+ yy1 = np.maximum(boxes[i, 1], boxes[i + 1:, 1])
318
+ xx2 = np.minimum(boxes[i, 2], boxes[i + 1:, 2])
319
+ yy2 = np.minimum(boxes[i, 3], boxes[i + 1:, 3])
320
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
321
+
322
+ area_i = max(0.0, float(
323
+ (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])
324
+ ))
325
+ areas_j = (
326
+ np.maximum(0.0, boxes[i + 1:, 2] - boxes[i + 1:, 0])
327
+ * np.maximum(0.0, boxes[i + 1:, 3] - boxes[i + 1:, 1])
328
+ )
329
+ iou = inter / (area_i + areas_j - inter + 1e-7)
330
+ scores[i + 1:] *= np.exp(-(iou ** 2) / sigma)
331
+
332
+ mask = scores > score_thresh
333
+ return order[mask], scores[mask]
334
+
335
+ @staticmethod
336
+ def _hard_nms(
337
+ boxes: np.ndarray,
338
+ scores: np.ndarray,
339
+ iou_thresh: float,
340
+ ) -> np.ndarray:
341
+ """
342
+ Standard NMS: keep one box per overlapping cluster (the one with highest score).
343
+ Returns indices of kept boxes (into the boxes/scores arrays).
344
+ """
345
+ N = len(boxes)
346
+ if N == 0:
347
+ return np.array([], dtype=np.intp)
348
+ boxes = np.asarray(boxes, dtype=np.float32)
349
+ scores = np.asarray(scores, dtype=np.float32)
350
+ order = np.argsort(scores)[::-1]
351
+ keep: list[int] = []
352
+ suppressed = np.zeros(N, dtype=bool)
353
+ for i in range(N):
354
+ idx = order[i]
355
+ if suppressed[idx]:
356
+ continue
357
+ keep.append(idx)
358
+ bi = boxes[idx]
359
+ for k in range(i + 1, N):
360
+ jdx = order[k]
361
+ if suppressed[jdx]:
362
+ continue
363
+ bj = boxes[jdx]
364
+ xx1 = max(bi[0], bj[0])
365
+ yy1 = max(bi[1], bj[1])
366
+ xx2 = min(bi[2], bj[2])
367
+ yy2 = min(bi[3], bj[3])
368
+ inter = max(0.0, xx2 - xx1) * max(0.0, yy2 - yy1)
369
+ area_i = (bi[2] - bi[0]) * (bi[3] - bi[1])
370
+ area_j = (bj[2] - bj[0]) * (bj[3] - bj[1])
371
+ iou = inter / (area_i + area_j - inter + 1e-7)
372
+ if iou > iou_thresh:
373
+ suppressed[jdx] = True
374
+ return np.array(keep)
375
+
376
+ def _per_class_hard_nms(
377
+ self,
378
+ boxes: np.ndarray,
379
+ scores: np.ndarray,
380
+ cls_ids: np.ndarray,
381
+ iou_thresh: float,
382
+ ) -> np.ndarray:
383
+ """Hard NMS applied independently per class."""
384
+ if len(boxes) == 0:
385
+ return np.array([], dtype=np.intp)
386
+ all_keep: list[int] = []
387
+ for c in np.unique(cls_ids):
388
+ mask = cls_ids == c
389
+ indices = np.where(mask)[0]
390
+ keep = self._hard_nms(boxes[mask], scores[mask], iou_thresh)
391
+ all_keep.extend(indices[keep].tolist())
392
+ all_keep.sort()
393
+ return np.array(all_keep, dtype=np.intp)
394
+
395
+ def _per_class_soft_nms(
396
+ self,
397
+ boxes: np.ndarray,
398
+ scores: np.ndarray,
399
+ cls_ids: np.ndarray,
400
+ sigma: float = 0.5,
401
+ score_thresh: float = 0.01,
402
+ ) -> tuple[np.ndarray, np.ndarray]:
403
+ """Soft NMS applied independently per class."""
404
+ if len(boxes) == 0:
405
+ return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
406
+ all_keep: list[int] = []
407
+ all_scores: list[float] = []
408
+ for c in np.unique(cls_ids):
409
+ mask = cls_ids == c
410
+ indices = np.where(mask)[0]
411
+ keep, updated = self._soft_nms(boxes[mask], scores[mask], sigma, score_thresh)
412
+ for k, s in zip(keep, updated):
413
+ all_keep.append(int(indices[k]))
414
+ all_scores.append(float(s))
415
+ if not all_keep:
416
+ return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
417
+ return np.array(all_keep, dtype=np.intp), np.array(all_scores, dtype=np.float32)
418
+
419
+ def _filter_sane_boxes(
420
+ self,
421
+ boxes: np.ndarray,
422
+ scores: np.ndarray,
423
+ cls_ids: np.ndarray,
424
+ orig_size: tuple[int, int],
425
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
426
+ """Filter out tiny, degenerate, or implausible boxes (common FP)."""
427
+ if len(boxes) == 0:
428
+ return boxes, scores, cls_ids
429
+ orig_w, orig_h = orig_size
430
+ image_area = float(orig_w * orig_h)
431
+ keep = []
432
+ for i, box in enumerate(boxes):
433
+ x1, y1, x2, y2 = box.tolist()
434
+ bw = x2 - x1
435
+ bh = y2 - y1
436
+ if bw <= 0 or bh <= 0:
437
+ continue
438
+ if bw < self.min_side or bh < self.min_side:
439
+ continue
440
+ area = bw * bh
441
+ if area < self.min_box_area:
442
+ continue
443
+ if area > 0.95 * image_area:
444
+ continue
445
+ ar = max(bw / max(bh, 1e-6), bh / max(bw, 1e-6))
446
+ if ar > self.max_aspect_ratio:
447
+ continue
448
+ keep.append(i)
449
+ if not keep:
450
+ return (
451
+ np.empty((0, 4), dtype=np.float32),
452
+ np.empty((0,), dtype=np.float32),
453
+ np.empty((0,), dtype=np.int32),
454
+ )
455
+ k = np.array(keep, dtype=np.intp)
456
+ return boxes[k], scores[k], cls_ids[k]
457
+
458
+ @staticmethod
459
+ def _max_score_per_cluster(
460
+ post_boxes: np.ndarray,
461
+ post_cls: np.ndarray,
462
+ full_boxes: np.ndarray,
463
+ full_scores: np.ndarray,
464
+ full_cls: np.ndarray,
465
+ iou_thresh: float,
466
+ ) -> np.ndarray:
467
+ """For each kept (post-NMS) box, return the max score over the FULL
468
+ candidate set among SAME-CLASS boxes with IoU >= iou_thresh.
469
+
470
+ The previous version omitted the same-class constraint, which let a
471
+ confident broom raise the score of a coincident nozzle (or vice
472
+ versa) under TTA. That's a silent FP booster and is fixed here.
473
+ """
474
+ n = len(post_boxes)
475
+ if n == 0:
476
+ return np.empty(0, dtype=np.float32)
477
+ full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
478
+ np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
479
+ out = np.empty(n, dtype=np.float32)
480
+ for i in range(n):
481
+ bi = post_boxes[i]
482
+ xx1 = np.maximum(bi[0], full_boxes[:, 0])
483
+ yy1 = np.maximum(bi[1], full_boxes[:, 1])
484
+ xx2 = np.minimum(bi[2], full_boxes[:, 2])
485
+ yy2 = np.minimum(bi[3], full_boxes[:, 3])
486
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
487
+ a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
488
+ iou = inter / (a_i + full_areas - inter + 1e-7)
489
+ cluster = (iou >= iou_thresh) & (full_cls == post_cls[i])
490
+ out[i] = float(np.max(full_scores[cluster])) if np.any(cluster) else 0.0
491
+ return out
492
+
493
+ def _conf_filter_mask(
494
+ self, scores: np.ndarray, cls_ids: np.ndarray
495
+ ) -> np.ndarray:
496
+ """Boolean keep-mask: score >= per-class threshold, with a per-class
497
+ rescue -- if a class has zero boxes passing, admit its top-1 candidate
498
+ when its score >= (per-class threshold - per-class bonus).
499
+ """
500
+ if len(scores) == 0:
501
+ return np.zeros(0, dtype=bool)
502
+ thr = self._conf_thres_array[cls_ids]
503
+ keep = scores >= thr
504
+ for c in np.unique(cls_ids):
505
+ b = float(self._bonus_array[c])
506
+ if b <= 0.0:
507
+ continue
508
+ cm = cls_ids == c
509
+ if keep[cm].any():
510
+ continue
511
+ idx = np.where(cm)[0]
512
+ top = int(idx[int(np.argmax(scores[idx]))])
513
+ if scores[top] >= self._conf_thres_array[c] - b:
514
+ keep[top] = True
515
+ return keep
516
+
517
+ def _cross_class_dedup_op(
518
+ self,
519
+ boxes: np.ndarray,
520
+ scores: np.ndarray,
521
+ cls_ids: np.ndarray,
522
+ iou_thresh: float,
523
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
524
+ """Remove near-duplicate boxes across classes.
525
+
526
+ Order candidates by (score - per_class_threshold) margin, then by area;
527
+ keep the highest, suppress every other box with IoU > iou_thresh. For
528
+ car-wash this kills the common failure where water spray makes the
529
+ model fire both `nozzle` and `track` on the same patch, or where a
530
+ broom handle overlaps a drainage-gate detection.
531
+ """
532
+ n = len(boxes)
533
+ if n <= 1:
534
+ return boxes, scores, cls_ids
535
+ boxes = np.asarray(boxes, dtype=np.float32)
536
+ scores = np.asarray(scores, dtype=np.float32)
537
+ cls_ids = np.asarray(cls_ids, dtype=np.int32)
538
+ areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *
539
+ np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))
540
+ margins = scores - self._conf_thres_array[cls_ids]
541
+ order = np.lexsort((-areas, -margins))
542
+ suppressed = np.zeros(n, dtype=bool)
543
+ keep: list[int] = []
544
+ for i in order:
545
+ if suppressed[i]:
546
+ continue
547
+ keep.append(int(i))
548
+ bi = boxes[i]
549
+ xx1 = np.maximum(bi[0], boxes[:, 0])
550
+ yy1 = np.maximum(bi[1], boxes[:, 1])
551
+ xx2 = np.minimum(bi[2], boxes[:, 2])
552
+ yy2 = np.minimum(bi[3], boxes[:, 3])
553
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
554
+ a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
555
+ iou = inter / (a_i + areas - inter + 1e-7)
556
+ dup = iou > iou_thresh
557
+ dup[i] = False
558
+ suppressed |= dup
559
+ keep_idx = np.array(keep, dtype=np.intp)
560
+ return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]
561
+
562
+ def _per_view_pipeline(
563
+ self,
564
+ boxes: np.ndarray,
565
+ scores: np.ndarray,
566
+ cls_ids: np.ndarray,
567
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
568
+ """Per-view post-processing: per-class NMS -> cap -> cross-class dedup."""
569
+ if len(boxes) > 1:
570
+ keep = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
571
+ boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
572
+ if len(scores) > self.max_det:
573
+ top = np.argsort(-scores)[: self.max_det]
574
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
575
+ if len(boxes) > 1:
576
+ boxes, scores, cls_ids = self._cross_class_dedup_op(
577
+ boxes, scores, cls_ids, self.cross_iou_thresh
578
+ )
579
+ return boxes, scores, cls_ids
580
+
581
+ def _decode_final_dets(
582
+ self,
583
+ preds: np.ndarray,
584
+ ratio: float,
585
+ pad: tuple[float, float],
586
+ orig_size: tuple[int, int],
587
+ apply_optional_dedup: bool = False,
588
+ ) -> list[BoundingBox]:
589
+ """
590
+ Primary path:
591
+ expected output rows like [x1, y1, x2, y2, conf, cls_id]
592
+ in letterboxed input coordinates.
593
+ """
594
+ if preds.ndim == 3 and preds.shape[0] == 1:
595
+ preds = preds[0]
596
+
597
+ if preds.ndim != 2 or preds.shape[1] < 6:
598
+ raise ValueError(f"Unexpected ONNX final-det output shape: {preds.shape}")
599
+
600
+ boxes = preds[:, :4].astype(np.float32)
601
+ scores = preds[:, 4].astype(np.float32)
602
+ cls_ids = preds[:, 5].astype(np.int32)
603
+ cls_ids = self.cls_remap[cls_ids]
604
+
605
+ # Per-class confidence filter with rescue (replaces scalar threshold)
606
+ keep = self._conf_filter_mask(scores, cls_ids)
607
+ boxes = boxes[keep]
608
+ scores = scores[keep]
609
+ cls_ids = cls_ids[keep]
610
+
611
+ if len(boxes) == 0:
612
+ return []
613
+
614
+ pad_w, pad_h = pad
615
+ orig_w, orig_h = orig_size
616
+
617
+ # reverse letterbox
618
+ boxes[:, [0, 2]] -= pad_w
619
+ boxes[:, [1, 3]] -= pad_h
620
+ boxes /= ratio
621
+ boxes = self._clip_boxes(boxes, (orig_w, orig_h))
622
+
623
+ # Box sanity filter (reduces FP)
624
+ boxes, scores, cls_ids = self._filter_sane_boxes(
625
+ boxes, scores, cls_ids, orig_size
626
+ )
627
+ if len(boxes) == 0:
628
+ return []
629
+
630
+ if apply_optional_dedup and len(boxes) > 1:
631
+ # Soft-NMS path preserved as a tunable option; default below.
632
+ keep_idx, scores = self._per_class_soft_nms(boxes, scores, cls_ids)
633
+ boxes = boxes[keep_idx]
634
+ cls_ids = cls_ids[keep_idx]
635
+ if len(scores) > self.max_det:
636
+ top = np.argsort(-scores)[: self.max_det]
637
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
638
+ if len(boxes) > 1:
639
+ boxes, scores, cls_ids = self._cross_class_dedup_op(
640
+ boxes, scores, cls_ids, self.cross_iou_thresh
641
+ )
642
+ else:
643
+ # Default: per-class hard NMS -> cap -> cross-class dedup
644
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
645
+
646
+ results: list[BoundingBox] = []
647
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
648
+ x1, y1, x2, y2 = box.tolist()
649
+
650
+ if x2 <= x1 or y2 <= y1:
651
+ continue
652
+
653
+ results.append(
654
+ BoundingBox(
655
+ x1=int(math.floor(x1)),
656
+ y1=int(math.floor(y1)),
657
+ x2=int(math.ceil(x2)),
658
+ y2=int(math.ceil(y2)),
659
+ cls_id=int(cls_id),
660
+ conf=float(conf),
661
+ )
662
+ )
663
+
664
+ return results
665
+
666
+ def _decode_raw_yolo(
667
+ self,
668
+ preds: np.ndarray,
669
+ ratio: float,
670
+ pad: tuple[float, float],
671
+ orig_size: tuple[int, int],
672
+ ) -> list[BoundingBox]:
673
+ """
674
+ Fallback path for raw YOLO predictions.
675
+ Supports common layouts:
676
+ - [1, C, N]
677
+ - [1, N, C]
678
+ """
679
+ if preds.ndim != 3:
680
+ raise ValueError(f"Unexpected raw ONNX output shape: {preds.shape}")
681
+
682
+ if preds.shape[0] != 1:
683
+ raise ValueError(f"Unexpected batch dimension in raw output: {preds.shape}")
684
+
685
+ preds = preds[0]
686
+
687
+ # Normalize to [N, C]
688
+ if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
689
+ preds = preds.T
690
+
691
+ if preds.ndim != 2 or preds.shape[1] < 5:
692
+ raise ValueError(f"Unexpected normalized raw output shape: {preds.shape}")
693
+
694
+ boxes_xywh = preds[:, :4].astype(np.float32)
695
+ cls_part = preds[:, 4:].astype(np.float32)
696
+
697
+ if cls_part.shape[1] == 1:
698
+ scores = cls_part[:, 0]
699
+ cls_ids = np.zeros(len(scores), dtype=np.int32)
700
+ else:
701
+ cls_ids = np.argmax(cls_part, axis=1).astype(np.int32)
702
+ scores = cls_part[np.arange(len(cls_part)), cls_ids]
703
+ cls_ids = self.cls_remap[cls_ids]
704
+
705
+ # Per-class confidence filter with rescue (replaces scalar threshold)
706
+ keep = self._conf_filter_mask(scores, cls_ids)
707
+ boxes_xywh = boxes_xywh[keep]
708
+ scores = scores[keep]
709
+ cls_ids = cls_ids[keep]
710
+ if len(boxes_xywh) == 0:
711
+ return []
712
+
713
+ boxes = self._xywh_to_xyxy(boxes_xywh)
714
+
715
+ # Order matches fire001 / _decode_final_dets:
716
+ # unscale -> clip -> sanity filter -> per-view pipeline (NMS, cap, cross-class dedup).
717
+ pad_w, pad_h = pad
718
+ orig_w, orig_h = orig_size
719
+ boxes[:, [0, 2]] -= pad_w
720
+ boxes[:, [1, 3]] -= pad_h
721
+ boxes /= ratio
722
+ boxes = self._clip_boxes(boxes, (orig_w, orig_h))
723
+
724
+ boxes, scores, cls_ids = self._filter_sane_boxes(
725
+ boxes, scores, cls_ids, (orig_w, orig_h)
726
+ )
727
+ if len(boxes) == 0:
728
+ return []
729
+
730
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
731
+
732
+ results: list[BoundingBox] = []
733
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
734
+ x1, y1, x2, y2 = box.tolist()
735
+
736
+ if x2 <= x1 or y2 <= y1:
737
+ continue
738
+
739
+ results.append(
740
+ BoundingBox(
741
+ x1=int(math.floor(x1)),
742
+ y1=int(math.floor(y1)),
743
+ x2=int(math.ceil(x2)),
744
+ y2=int(math.ceil(y2)),
745
+ cls_id=int(cls_id),
746
+ conf=float(conf),
747
+ )
748
+ )
749
+
750
+ return results
751
+
752
+ def _postprocess(
753
+ self,
754
+ output: np.ndarray,
755
+ ratio: float,
756
+ pad: tuple[float, float],
757
+ orig_size: tuple[int, int],
758
+ ) -> list[BoundingBox]:
759
+ """
760
+ Prefer final detections first.
761
+ Fallback to raw decode only if needed.
762
+ """
763
+ # final detections: [N,6]
764
+ if output.ndim == 2 and output.shape[1] >= 6:
765
+ return self._decode_final_dets(output, ratio, pad, orig_size)
766
+
767
+ # final detections: [1,N,6]
768
+ if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
769
+ return self._decode_final_dets(output, ratio, pad, orig_size)
770
+
771
+ # fallback raw decode
772
+ return self._decode_raw_yolo(output, ratio, pad, orig_size)
773
+
774
+ def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
775
+ if image is None:
776
+ raise ValueError("Input image is None")
777
+ if not isinstance(image, np.ndarray):
778
+ raise TypeError(f"Input is not numpy array: {type(image)}")
779
+ if image.ndim != 3:
780
+ raise ValueError(f"Expected HWC image, got shape={image.shape}")
781
+ if image.shape[0] <= 0 or image.shape[1] <= 0:
782
+ raise ValueError(f"Invalid image shape={image.shape}")
783
+ if image.shape[2] != 3:
784
+ raise ValueError(f"Expected 3 channels, got shape={image.shape}")
785
+
786
+ if image.dtype != np.uint8:
787
+ image = image.astype(np.uint8)
788
+
789
+ input_tensor, ratio, pad, orig_size = self._preprocess(image)
790
+
791
+ expected_shape = (1, 3, self.input_height, self.input_width)
792
+ if input_tensor.shape != expected_shape:
793
+ raise ValueError(
794
+ f"Bad input tensor shape={input_tensor.shape}, expected={expected_shape}"
795
+ )
796
+
797
+ outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
798
+ det_output = outputs[0]
799
+ return self._postprocess(det_output, ratio, pad, orig_size)
800
+
801
+ def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
802
+ """Horizontal-flip TTA.
803
+
804
+ Strategy (ported from fire001):
805
+ 1. Predict on original and on flipped image.
806
+ 2. Map flipped boxes back to original coordinates.
807
+ 3. Per-class hard NMS on the union.
808
+ 4. For each kept box, compute the max SAME-CLASS score across the
809
+ FULL union -- a high-confidence flipped detection raises a
810
+ borderline original one, but never one of a different class.
811
+ 5. Cross-class dedup to suppress same-physical-object multi-class.
812
+ """
813
+ boxes_orig = self._predict_single(image)
814
+
815
+ flipped = cv2.flip(image, 1)
816
+ boxes_flip = self._predict_single(flipped)
817
+
818
+ w = image.shape[1]
819
+ boxes_flip = [
820
+ BoundingBox(
821
+ x1=w - b.x2, y1=b.y1, x2=w - b.x1, y2=b.y2,
822
+ cls_id=b.cls_id, conf=b.conf,
823
+ )
824
+ for b in boxes_flip
825
+ ]
826
+
827
+ all_boxes = boxes_orig + boxes_flip
828
+ if len(all_boxes) == 0:
829
+ return []
830
+
831
+ coords = np.array(
832
+ [[b.x1, b.y1, b.x2, b.y2] for b in all_boxes], dtype=np.float32
833
+ )
834
+ scores = np.array([b.conf for b in all_boxes], dtype=np.float32)
835
+ cls_ids = np.array([b.cls_id for b in all_boxes], dtype=np.int32)
836
+
837
+ hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
838
+ if len(hard_keep) == 0:
839
+ return []
840
+ if len(hard_keep) > self.max_det:
841
+ top = np.argsort(-scores[hard_keep])[: self.max_det]
842
+ hard_keep = hard_keep[top]
843
+
844
+ # Class-aware cluster-max score boost (fixes the silent cross-class
845
+ # leak in the previous _max_score_per_cluster).
846
+ boosted = self._max_score_per_cluster(
847
+ coords[hard_keep], cls_ids[hard_keep],
848
+ coords, scores, cls_ids, self.iou_thres,
849
+ )
850
+
851
+ kept_coords = coords[hard_keep]
852
+ kept_cls = cls_ids[hard_keep]
853
+ if len(kept_coords) > 1:
854
+ kept_coords, boosted, kept_cls = self._cross_class_dedup_op(
855
+ kept_coords, boosted, kept_cls, self.cross_iou_thresh
856
+ )
857
+
858
+ return [
859
+ BoundingBox(
860
+ x1=int(math.floor(kept_coords[j, 0])),
861
+ y1=int(math.floor(kept_coords[j, 1])),
862
+ x2=int(math.ceil(kept_coords[j, 2])),
863
+ y2=int(math.ceil(kept_coords[j, 3])),
864
+ cls_id=int(kept_cls[j]),
865
+ conf=float(boosted[j]),
866
+ )
867
+ for j in range(len(kept_coords))
868
+ ]
869
+
870
+ def predict_batch(
871
+ self,
872
+ batch_images: list[ndarray],
873
+ offset: int,
874
+ n_keypoints: int,
875
+ ) -> list[TVFrameResult]:
876
+ results: list[TVFrameResult] = []
877
+
878
+ for frame_number_in_batch, image in enumerate(batch_images):
879
+ try:
880
+ if self.use_tta:
881
+ boxes = self._predict_tta(image)
882
+ else:
883
+ boxes = self._predict_single(image)
884
+ except Exception as e:
885
+ print(f"⚠️ Inference failed for frame {offset + frame_number_in_batch}: {e}")
886
+ boxes = []
887
+
888
+ results.append(
889
+ TVFrameResult(
890
+ frame_id=offset + frame_number_in_batch,
891
+ boxes=boxes,
892
+ keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
893
+ )
894
+ )
895
+
896
+ return results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
my_chute.py DELETED
@@ -1,440 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- import sys
4
- from importlib.machinery import PathFinder
5
- from importlib.util import module_from_spec, spec_from_file_location
6
- from inspect import signature
7
- from pathlib import Path
8
- from site import getsitepackages, getusersitepackages
9
- from sysconfig import get_paths
10
- from tempfile import NamedTemporaryFile
11
- from typing import Any, Generator
12
- from urllib.parse import quote
13
-
14
- import torch
15
- from aiohttp import ClientSession
16
- from chutes.chute import Chute, NodeSelector
17
- from chutes.image import Image
18
- from base64 import b64decode
19
- from cv2 import CAP_PROP_FRAME_COUNT, VideoCapture, imdecode, IMREAD_COLOR
20
- from huggingface_hub import snapshot_download
21
- from numpy import ndarray, frombuffer, uint8
22
- from pydantic import BaseModel
23
- from yaml import safe_load
24
-
25
- ##============VARIABLES=======================
26
- HF_REPO_NAME = "yevheniiapopova/ScoreVisionBeverage"
27
- HF_REPO_REVISION = "main"
28
- CHUTES_USERNAME = "dragonsn44miner"
29
- CHUTE_NAME = "dragonsn44miner"
30
- FILENAME = "miner.py"
31
- CLASSNAME = "Miner"
32
- CHUTE_CONFIG_FILENAME = "chute_config.yml"
33
- MAX_LOADED_MODEL_SIZE_GB = 5.0
34
-
35
-
36
- ##=========DATA CLASSES================
37
- class TVFrame(BaseModel):
38
- frame_id: int
39
- url: str | None = None
40
- data: str | None = None
41
-
42
-
43
- class TVPredictInput(BaseModel):
44
- url: str | None = None
45
- frames: list[TVFrame] | None = None
46
- meta: dict[str, Any] = {}
47
-
48
-
49
- class TVPredictOutput(BaseModel):
50
- success: bool
51
- predictions: dict[str, list[dict]] | None = None
52
- error: str | None = None
53
-
54
-
55
- ##=========UTILITY FUNCTIONS=================
56
- def get_whitelisted_paths() -> set[Path]:
57
- STDLIB_ROOT = Path(get_paths()["stdlib"]).resolve()
58
- SITE_PACKAGES_ROOTS = {Path(p).resolve() for p in getsitepackages()}
59
- SITE_PACKAGES_ROOTS.add(Path(getusersitepackages()).resolve())
60
-
61
- return {STDLIB_ROOT, *SITE_PACKAGES_ROOTS}
62
-
63
- def post_check_files_against_whitelist(*, before: set[str], miner_path: Path) -> None:
64
- miner_path = miner_path.resolve()
65
- WHITELIST = get_whitelisted_paths()
66
- after = set(sys.modules.keys()) - before
67
- offenders = []
68
- for module_loaded_name in sorted(after):
69
- module_loaded = sys.modules.get(module_loaded_name)
70
- if module_loaded is None:
71
- continue
72
- module_loaded_file = getattr(module_loaded, "__file__", None)
73
- if not module_loaded_file:
74
- continue
75
- module_loaded_filepath = Path(module_loaded_file).resolve()
76
- if module_loaded_filepath == miner_path:
77
- continue
78
- if any(module_loaded_filepath.is_relative_to(root) for root in WHITELIST):
79
- continue
80
- offenders.append(module_loaded_name)
81
-
82
- if offenders:
83
- raise ImportError(
84
- f"Miner loaded modules outside the whitelist: {', '.join(offenders)}"
85
- )
86
-
87
-
88
- def verify_cuda_version() -> str:
89
- min_version = (12, 8)
90
- if not torch.cuda.is_available():
91
- return "❌ CUDA is not available. Make sure you have a CUDA-capable GPU and correct drivers installed"
92
- device_count = torch.cuda.device_count()
93
- if device_count < 1:
94
- return "❌ No CUDA devices detected"
95
- cuda_version_str = torch.version.cuda
96
- if cuda_version_str is None:
97
- return "❌ PyTorch was not compiled with CUDA support"
98
- try:
99
- cuda_version = tuple(map(int, cuda_version_str.split(".")))
100
- except Exception as e:
101
- return f"❌ Unable to parse CUDA version string: {e}"
102
- if cuda_version < min_version:
103
- return f"❌ CUDA version {cuda_version_str} is too old. Requires >= {min_version[0]}.{min_version[1]}"
104
- return f"CUDA {cuda_version_str} detected_with {device_count} device(s)"
105
-
106
-
107
- def load_chute_config_from_hf_repo(config_path: Path) -> dict:
108
- try:
109
- if not config_path.exists():
110
- raise ValueError("No config file found in repo")
111
-
112
- with config_path.open() as configuration_file:
113
- config = safe_load(configuration_file)
114
- print("✅ Loaded Chutes Configuration")
115
- return config or {}
116
- except Exception as e:
117
- print(f"⚠️ Failed to load Chutes Configuration. Using defaults: {e}")
118
- return {}
119
-
120
-
121
- def safe_instantiate(cls, config: dict):
122
- params = {k: v for k, v in config.items() if k in signature(cls).parameters}
123
- print(
124
- f"The following parameters will be applied when instantiating {cls.__name__}: {params}"
125
- )
126
- obj = cls(**params)
127
- for function_name, value in config.items():
128
- if not hasattr(obj, function_name) or not callable(getattr(obj, function_name)):
129
- continue
130
- print(f"applying .{function_name}({value})")
131
- method = getattr(obj, function_name)
132
- if isinstance(value, list):
133
- for v in value:
134
- if isinstance(v, (tuple, list)):
135
- method(*v)
136
- else:
137
- method(v)
138
- else:
139
- method(value)
140
- return obj
141
-
142
-
143
- def load_chute(
144
- hf_repo: str,
145
- hf_revision: str,
146
- config_filename: str,
147
- chutes_username: str,
148
- chute_name: str,
149
- ) -> Chute:
150
- hf_repo_path = Path(snapshot_download(hf_repo, revision=hf_revision))
151
- print("✅ Huggingface Hub repo downloaded")
152
-
153
- config = load_chute_config_from_hf_repo(config_path=hf_repo_path / config_filename)
154
- print("✅ Config file loaded")
155
-
156
- image_config = config.get("Image", {})
157
- image_config.update(
158
- {
159
- "username": chutes_username,
160
- "name": chute_name,
161
- "tag":"latest",
162
- "readme": "README.md",
163
- }
164
- )
165
- chute_image = safe_instantiate(cls=Image, config=image_config)
166
- print("✅ Image instantiated")
167
-
168
- machine_specs = safe_instantiate(
169
- cls=NodeSelector, config=config.get("NodeSelector", {})
170
- )
171
- print("✅ NodeSelector instantiated")
172
-
173
- chute_config = config.get("Chute", {})
174
- chute_config.update(
175
- {
176
- "username": chutes_username,
177
- "name": chute_name,
178
- "image": chute_image,
179
- "node_selector": machine_specs,
180
- "allow_external_egress":False,
181
- "readme": "README.md",
182
- }
183
- )
184
- chute = safe_instantiate(cls=Chute, config=chute_config)
185
- print("✅ Chute instantiated")
186
- return chute
187
-
188
-
189
- def load_miner_from_hf_repo(path_hf_repo: Path, filename: str, classname: str):
190
- path_hf_repo = path_hf_repo.resolve()
191
- path_miner = path_hf_repo / filename
192
- if not path_miner.is_file():
193
- raise FileNotFoundError(
194
- f"❌ Required file '{filename}' not in repo {path_hf_repo}"
195
- )
196
-
197
- WHITELIST = get_whitelisted_paths()
198
-
199
- class HFRepoImportBlocker(PathFinder):
200
- @classmethod
201
- def find_spec(cls, fullname, path=None, target=None):
202
- spec = super().find_spec(fullname, path, target)
203
- if spec is None or spec.origin is None:
204
- return spec
205
- origin_path = Path(spec.origin).resolve()
206
- if any(origin_path.is_relative_to(root) for root in WHITELIST):
207
- return spec
208
- raise ImportError(
209
- f"❌ Import blocked: '{fullname}' (file: {origin_path}) is not in the whitelist. All code related to the miner MUST be inside {filename}"
210
- )
211
-
212
- try:
213
- before_exec = set(sys.modules.keys())
214
- sys.meta_path.insert(0, HFRepoImportBlocker)
215
- spec = spec_from_file_location("miner", path_miner)
216
- module = module_from_spec(spec)
217
- sys.modules["miner"] = module
218
- spec.loader.exec_module(module)
219
- miner_object = getattr(module, classname)
220
- miner = miner_object(path_hf_repo)
221
- post_check_files_against_whitelist(before=before_exec, miner_path=path_miner)
222
- except Exception as e:
223
- raise e
224
- finally:
225
- sys.meta_path.remove(HFRepoImportBlocker)
226
- return miner
227
-
228
-
229
- def object_size_in_bytes(current_obj: Any, seen_ids: set[int], limit: int) -> int:
230
- obj_id = id(current_obj)
231
- if obj_id in seen_ids or limit <= 0:
232
- return 0
233
- seen_ids.add(obj_id)
234
- total_size = 0
235
- if isinstance(current_obj, torch.Tensor):
236
- return current_obj.numel() * current_obj.element_size()
237
- if isinstance(current_obj, ndarray):
238
- return current_obj.nbytes
239
- try:
240
- total_size += sys.getsizeof(current_obj)
241
- except TypeError:
242
- pass
243
- if isinstance(current_obj, dict):
244
- for key, value in current_obj.items():
245
- total_size += object_size_in_bytes(key, seen_ids, limit - 1)
246
- total_size += object_size_in_bytes(value, seen_ids, limit - 1)
247
- elif isinstance(current_obj, (list, tuple, set)):
248
- try:
249
- for item in current_obj:
250
- total_size += object_size_in_bytes(item, seen_ids, limit - 1)
251
- except TypeError:
252
- pass
253
- elif hasattr(current_obj, "__dict__"):
254
- for value in current_obj.__dict__.values():
255
- total_size += object_size_in_bytes(value, seen_ids, limit - 1)
256
- return total_size
257
-
258
-
259
- def get_miner_size_gb(miner) -> float:
260
- total_size_bytes = object_size_in_bytes(
261
- current_obj=miner, seen_ids=set(), limit=100
262
- )
263
- return total_size_bytes / (1024**3)
264
-
265
-
266
- def get_video_frames_in_batches(
267
- video: VideoCapture, batch_size: int
268
- ) -> Generator[list[ndarray], None, None]:
269
- batch = []
270
- while True:
271
- ok, frame = video.read()
272
- if not ok:
273
- if batch:
274
- yield batch
275
- break
276
-
277
- batch.append(frame)
278
-
279
- if len(batch) >= batch_size:
280
- yield batch
281
- batch = []
282
-
283
-
284
- ##=========ENDPOINTS================
285
- chute = load_chute(
286
- hf_repo=HF_REPO_NAME,
287
- hf_revision=HF_REPO_REVISION,
288
- chutes_username=CHUTES_USERNAME,
289
- chute_name=CHUTE_NAME,
290
- config_filename=CHUTE_CONFIG_FILENAME,
291
- )
292
-
293
-
294
- @chute.on_startup()
295
- async def load_model(self) -> None:
296
- try:
297
- self.cuda_status = verify_cuda_version()
298
- except Exception as e:
299
- self.cuda_status = f"❌ Failed to check cuda status: {e}"
300
- print(self.cuda_status)
301
-
302
- self.miner_size = None
303
- try:
304
- hf_repo_path = Path(snapshot_download(HF_REPO_NAME, revision=HF_REPO_REVISION))
305
- print("✅ Huggingface Hub repo downloaded")
306
-
307
- self.miner = load_miner_from_hf_repo(
308
- path_hf_repo=hf_repo_path, filename=FILENAME, classname=CLASSNAME
309
- )
310
- print(f"✅ Miner loaded {self.miner}")
311
- self.miner_size = get_miner_size_gb(self.miner)
312
- if self.miner_size > MAX_LOADED_MODEL_SIZE_GB:
313
- raise ValueError(
314
- f"Loaded Miner memory footprint {self.miner_size:.2f} GB exceeds the allowed limit of {MAX_LOADED_MODEL_SIZE_GB:.2f} GB. "
315
- )
316
- self.status = "healthy"
317
- except Exception as e:
318
- self.status = f"❌ Failed to load miner: {e}"
319
- self.miner = None
320
- print(self.status)
321
-
322
-
323
- @chute.cord(public_api_path="/health")
324
- async def health(self, *args, **kwargs) -> dict[str, Any]:
325
- return {
326
- "status": self.status,
327
- "model_loaded": str(self.miner),
328
- "memory_gb": self.miner_size,
329
- "cuda": self.cuda_status,
330
- }
331
-
332
-
333
- @chute.cord(
334
- public_api_path="/predict",
335
- )
336
- async def predict(self, data: TVPredictInput) -> dict:
337
- try:
338
- if self.miner is None:
339
- raise ValueError("Models were not properly loaded!")
340
-
341
- metadata = data.meta
342
- batch_size = metadata.get("batch_size", 64)
343
- n_keypoints = metadata.get("n_keypoints", 32)
344
-
345
- frame_results = []
346
-
347
- if data.frames:
348
- frame_entries = [(f.frame_id, f.url, f.data) for f in data.frames]
349
- async with ClientSession() as session:
350
- loaded_frames: list[tuple[int, ndarray]] = []
351
- for frame_id, frame_url, frame_data in frame_entries:
352
- if frame_data:
353
- try:
354
- content = b64decode(frame_data)
355
- except Exception as e:
356
- raise ValueError(f"Failed to decode frame {frame_id}: {e}")
357
- elif frame_url:
358
- url = f"https://proxy.chutes.ai/misc/proxy?url={quote(frame_url, safe='')}"
359
- async with session.get(url) as response:
360
- response.raise_for_status()
361
- content = await response.read()
362
- else:
363
- raise ValueError(f"Frame {frame_id} missing url or data")
364
-
365
- arr = frombuffer(content, dtype=uint8)
366
- img = imdecode(arr, IMREAD_COLOR)
367
- if img is None:
368
- raise ValueError(f"Failed to decode frame {frame_id}")
369
- loaded_frames.append((frame_id, img))
370
-
371
- for i in range(0, len(loaded_frames), batch_size):
372
- batch = loaded_frames[i : i + batch_size]
373
- batch_images = [img for _, img in batch]
374
- batch_frame_ids = [fid for fid, _ in batch]
375
- print(f"Predicting Batch: {i // batch_size + 1}")
376
- batch_frame_results = self.miner.predict_batch(
377
- batch_images=batch_images,
378
- offset=0,
379
- n_keypoints=n_keypoints,
380
- )
381
- for frame_id, frame_result in zip(
382
- batch_frame_ids, batch_frame_results, strict=True
383
- ):
384
- fr = frame_result.model_dump()
385
- fr["frame_id"] = frame_id
386
- frame_results.append(fr)
387
- else:
388
- if not data.url:
389
- raise ValueError("Missing both video url and frames payload")
390
-
391
- url = f"https://proxy.chutes.ai/misc/proxy?url={quote(data.url, safe='')}"
392
- async with ClientSession() as session:
393
- async with session.get(url) as response:
394
- response.raise_for_status()
395
- content = await response.read()
396
- print("✅ Challenge video downloaded. Saving video to temporary file.")
397
-
398
- with NamedTemporaryFile(prefix="sv_video_", suffix=".mp4") as f:
399
- f.write(content)
400
- cap = VideoCapture(f.name)
401
- if not cap.isOpened():
402
- raise ValueError(
403
- f"Problem accessing downloaded video {f.name}"
404
- )
405
-
406
- n_frames = int(cap.get(CAP_PROP_FRAME_COUNT))
407
- print(
408
- f"Processing video with {n_frames} frames in batches of {batch_size}"
409
- )
410
-
411
- for batch_number, images in enumerate(
412
- get_video_frames_in_batches(
413
- video=cap, batch_size=batch_size
414
- )
415
- ):
416
- frame_number = batch_size * batch_number
417
- print(f"Predicting Batch: {batch_number + 1}")
418
- batch_frame_results = self.miner.predict_batch(
419
- batch_images=images,
420
- offset=frame_number,
421
- n_keypoints=n_keypoints,
422
- )
423
- frame_results.extend(
424
- [
425
- frame_result.model_dump()
426
- for frame_result in batch_frame_results
427
- ]
428
- )
429
-
430
- cap.release()
431
- print("✅ Frame Predictions Completed")
432
-
433
- result = TVPredictOutput(success=True, predictions={"frames": frame_results})
434
-
435
- except Exception as e:
436
- result = TVPredictOutput(
437
- success=False,
438
- error=f"❌ There was a problem during prediction: {e}",
439
- )
440
- return result.model_dump(mode="json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyproject.toml CHANGED
@@ -5,13 +5,14 @@ requires-python = ">=3.9"
5
 
6
  dependencies = [
7
  "numpy>=1.23",
8
- "onnxruntime[cuda,cudnn]>=1.16",
9
  "opencv-python>=4.7",
10
  "pillow>=9.5",
11
  "huggingface_hub>=0.19.4",
12
  "pydantic>=2.0",
13
  "pyyaml>=6.0",
14
  "aiohttp>=3.9",
15
- "torch",
16
- "torchvision",
 
17
  ]
 
5
 
6
  dependencies = [
7
  "numpy>=1.23",
8
+ "onnxruntime>=1.16",
9
  "opencv-python>=4.7",
10
  "pillow>=9.5",
11
  "huggingface_hub>=0.19.4",
12
  "pydantic>=2.0",
13
  "pyyaml>=6.0",
14
  "aiohttp>=3.9",
15
+ "torch==2.8.0",
16
+ "torchvision==0.23.0",
17
+ "torchaudio==2.8.0",
18
  ]
readme.md DELETED
@@ -1,18 +0,0 @@
1
- ---
2
- tags:
3
- - element_type:detect
4
- - model:ultralytics-yolo
5
- - subnet:winner
6
- - object:fire
7
- - object:smoke
8
- - object:fire extinguisher
9
-
10
- manako:
11
- source: winner_fetch
12
- manifest_element_name: manak0/Detect-fire
13
- winner_repo_id: SuperBitDev/fire4
14
- winner_revision: 0679fa90aae80dacbcaa1235755c2791da2f7cc0
15
- note: E=0.03082304 (map50=0.600000, size_mb=19.465956)
16
- ---
17
-
18
- ## YOLO26 ONNX detector
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
weights.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:f4414accb1ff7730dec4de9099e2aeab3d7301216a14515c32068b628738ae2c
3
- size 19407281
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1f9c13ee4403ddbbccd9d9707a151b1474cbb33124a2ef2965fa57d1e821ed03
3
+ size 19287011