thomaskk2024 commited on
Commit
42b9ad4
·
verified ·
1 Parent(s): d37dcb1

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. miner.py +391 -339
  2. weights.onnx +2 -2
miner.py CHANGED
@@ -1,13 +1,11 @@
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
@@ -16,151 +14,127 @@ class BoundingBox(BaseModel):
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
- """
28
- YOLO (NMS-free) ONNX miner for road-sign detection. One forward pass per
29
- frame no TTA.
30
-
31
- Class: single 'road sign'.
32
-
33
- Pipeline per frame: preprocess -> ONNX -> decode -> conf threshold (+ rescue
34
- bonus) -> un-letterbox -> sanity filter -> per-class NMS -> cross-class dedup
35
- -> same-class cluster score boost -> results.
36
-
37
- Speed: the detection pipeline runs exactly ONCE per frame (the old redundant
38
- second NMS/dedup pass is gone), and the cluster boost is a single vectorized
39
- IoU matrix, so cost stays flat as the number of detected objects grows.
40
- """
41
-
42
- class_names = ['road sign']
43
- input_size = 1536
44
- cross_iou_thresh = 0.8
45
- max_det = 300
46
- # NMS is O(n^2); if a frame yields a huge candidate list, keep the top-K by
47
- # score before NMS. Set high enough never to touch real detections.
48
- pre_nms_topk = 1000
49
- #overlap_suppress_threshold = 0.85
50
-
51
- # Per-class confidence threshold
52
- _conf_thres_array = np.array([0.32], dtype=np.float32)
53
-
54
- # Per-class IoU threshold for same-class NMS
55
- _iou_thres_array = np.array([0.8], dtype=np.float32)
56
-
57
- # Per-class rescue bonus
58
- _bonus_array = np.array([0.2], dtype=np.float32)
59
-
60
- # Per-class minimum box area (index 0 = road sign)
61
- _min_box_area_array = np.array([9.0], dtype=np.float32)
62
 
63
  def __init__(self, path_hf_repo: Path) -> None:
64
- self.path_hf_repo = path_hf_repo
65
-
66
- print("ORT version:", ort.__version__)
67
-
68
  try:
69
  ort.preload_dlls()
70
- print("preload_dlls success")
71
  except Exception as e:
72
- print(f"preload_dlls failed: {e}")
73
-
74
- print("ORT available providers BEFORE session:", ort.get_available_providers())
75
-
76
  sess_options = ort.SessionOptions()
77
  sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
78
-
79
- self.session = ort.InferenceSession(
80
- str(path_hf_repo / "weights.onnx"),
81
- sess_options=sess_options,
82
- providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
83
- )
84
- print("Created ORT session with preferred CUDA provider list")
85
- print("ORT session providers:", self.session.get_providers())
86
-
 
 
 
 
 
 
 
 
 
 
 
 
87
  self.input_name = self.session.get_inputs()[0].name
88
- input_shape = self.session.get_inputs()[0].shape
89
-
90
- self.input_h = self._safe_dim(input_shape[2], default=self.input_size)
91
- self.input_w = self._safe_dim(input_shape[3], default=self.input_size)
92
-
93
- # Same-class cluster score boost (raises overlapping same-class survivors
94
- # to their cluster max). Part of the current tuned behaviour; for a single
95
- # class it is nearly a no-op. Set False to disable.
96
- self.use_cluster_boost = True
97
- self._avg_iou = float(np.mean(self._iou_thres_array))
98
-
99
  self._warmup()
100
 
101
- def _warmup(self, iters: int = 3) -> None:
102
  try:
103
  dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
104
  for _ in range(max(1, iters)):
105
  self.predict_batch(batch_images=[dummy], offset=0, n_keypoints=0)
106
- print(f"warmup: {iters} dummy predict_batch call(s) done")
107
  except Exception as e:
108
- print(f"warmup skipped: {e}")
109
 
110
  def __repr__(self) -> str:
111
- return f"road sign Miner classes={len(self.class_names)}"
112
 
113
  @staticmethod
114
  def _safe_dim(value, default: int) -> int:
115
  return value if isinstance(value, int) and value > 0 else default
116
 
117
- # ─── Preprocessing ────────────────────────────────────────────
118
-
119
- def _letterbox(
120
- self, image: ndarray, new_shape: tuple[int, int],
121
- color: tuple[int, int, int] = (114, 114, 114),
122
- ) -> tuple[ndarray, float, float, float]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  orig_h, orig_w = image.shape[:2]
124
- target_w, target_h = new_shape
125
-
126
- r = min(target_w / orig_w, target_h / orig_h)
127
- new_unpad_w = int(round(orig_w * r))
128
- new_unpad_h = int(round(orig_h * r))
129
-
130
- resized = cv2.resize(image, (new_unpad_w, new_unpad_h), interpolation=cv2.INTER_LINEAR)
131
-
132
- dw = target_w - new_unpad_w
133
- dh = target_h - new_unpad_h
134
- pad_w = dw / 2.0
135
- pad_h = dh / 2.0
136
-
137
- left = int(round(pad_w - 0.1))
138
- right = int(round(pad_w + 0.1))
139
- top = int(round(pad_h - 0.1))
140
- bottom = int(round(pad_h + 0.1))
141
-
142
- out = cv2.copyMakeBorder(
143
- resized, top, bottom, left, right,
144
- cv2.BORDER_CONSTANT, value=color,
145
- )
146
- return out, r, pad_w, pad_h
147
-
148
- def _preprocess(self, image_bgr: ndarray) -> tuple[np.ndarray, dict]:
149
- orig_h, orig_w = image_bgr.shape[:2]
150
- rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
151
- img, ratio, pad_w, pad_h = self._letterbox(rgb, (self.input_w, self.input_h))
152
- x = img.astype(np.float32) / 255.0
153
- x = np.transpose(x, (2, 0, 1))[None, ...]
154
- x = np.ascontiguousarray(x)
155
- return x, {
156
- "orig_h": orig_h,
157
- "orig_w": orig_w,
158
- "ratio": ratio,
159
- "pad_w": pad_w,
160
- "pad_h": pad_h,
161
- }
162
-
163
- # ─── Vectorized box operations ───────────────────────────────
164
 
165
  @staticmethod
166
  def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
@@ -172,134 +146,126 @@ class Miner:
172
  return boxes
173
 
174
  @staticmethod
175
- def _hard_nms(boxes: np.ndarray, scores: np.ndarray,
176
- iou_thresh: float) -> np.ndarray:
177
- """Vectorized greedy NMS. Areas precomputed once. Returns indices to keep."""
178
  n = len(boxes)
179
  if n == 0:
180
  return np.array([], dtype=np.intp)
181
- x1, y1 = boxes[:, 0], boxes[:, 1]
182
- x2, y2 = boxes[:, 2], boxes[:, 3]
183
- areas = np.maximum(0.0, x2 - x1) * np.maximum(0.0, y2 - y1)
184
  order = np.argsort(-scores)
185
- keep = []
186
- while order.size > 0:
187
  i = int(order[0])
188
  keep.append(i)
189
- if order.size == 1:
190
  break
191
  rest = order[1:]
192
- xx1 = np.maximum(x1[i], x1[rest])
193
- yy1 = np.maximum(y1[i], y1[rest])
194
- xx2 = np.minimum(x2[i], x2[rest])
195
- yy2 = np.minimum(y2[i], y2[rest])
196
  inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
197
- iou = inter / (areas[i] + areas[rest] - inter + 1e-7)
 
 
198
  order = rest[iou <= iou_thresh]
199
  return np.array(keep, dtype=np.intp)
200
 
201
- def _per_class_hard_nms(self, boxes: np.ndarray, scores: np.ndarray,
202
- cls_ids: np.ndarray) -> np.ndarray:
203
- """Per-class NMS using per-class IoU thresholds."""
204
  if len(boxes) == 0:
205
  return np.array([], dtype=np.intp)
206
- all_keep = []
207
  for c in np.unique(cls_ids):
208
  mask = cls_ids == c
209
  indices = np.where(mask)[0]
210
- cls_iou = float(self._iou_thres_array[c]) # per-class IoU threshold
211
- keep = self._hard_nms(boxes[mask], scores[mask], cls_iou)
212
  all_keep.extend(indices[keep].tolist())
213
  all_keep.sort()
214
  return np.array(all_keep, dtype=np.intp)
215
 
216
- def _cross_class_dedup_op(self, boxes: np.ndarray, scores: np.ndarray,
217
- cls_ids: np.ndarray, iou_thresh: float
218
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
219
- n = len(boxes)
220
- if n <= 1:
221
- return boxes, scores, cls_ids
222
  boxes = np.asarray(boxes, dtype=np.float32)
223
  scores = np.asarray(scores, dtype=np.float32)
224
  cls_ids = np.asarray(cls_ids, dtype=np.int32)
225
- areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *
226
- np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))
227
  margins = scores - self._conf_thres_array[cls_ids]
228
  order = np.lexsort((-areas, -margins))
229
- suppressed = np.zeros(n, dtype=bool)
230
- keep = []
231
- for i in order:
232
- if suppressed[i]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  continue
234
- keep.append(int(i))
235
  bi = boxes[i]
236
- xx1 = np.maximum(bi[0], boxes[:, 0])
237
- yy1 = np.maximum(bi[1], boxes[:, 1])
238
- xx2 = np.minimum(bi[2], boxes[:, 2])
239
- yy2 = np.minimum(bi[3], boxes[:, 3])
240
- inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
241
- a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
242
- iou = inter / (a_i + areas - inter + 1e-7)
243
- dup = iou > iou_thresh
244
- dup[i] = False
245
- suppressed |= dup
246
- keep_idx = np.array(keep, dtype=np.intp)
247
- return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]
248
-
249
- def _filter_sane_boxes(self, boxes: np.ndarray, scores: np.ndarray,
250
- cls_ids: np.ndarray, orig_size: tuple[int, int]
251
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
252
- """Filter by per-class min area and max area ratio."""
253
- if len(boxes) == 0:
254
- return boxes, scores, cls_ids
255
-
256
- orig_w, orig_h = orig_size
257
- image_area = float(orig_w * orig_h)
258
- bw = np.maximum(0.0, boxes[:, 2] - boxes[:, 0])
259
- bh = np.maximum(0.0, boxes[:, 3] - boxes[:, 1])
260
- area = bw * bh
261
-
262
- class_min_area = self._min_box_area_array[cls_ids]
263
-
264
- keep = (
265
- (area >= class_min_area) &
266
- (area <= 0.95 * image_area)
267
- )
268
- return boxes[keep], scores[keep], cls_ids[keep]
269
-
270
- def _max_score_per_cluster(self, post_boxes: np.ndarray,
271
- post_cls: np.ndarray,
272
- full_boxes: np.ndarray,
273
- full_scores: np.ndarray,
274
- full_cls: np.ndarray,
275
- iou_thresh: float) -> np.ndarray:
276
- """For each kept box, confidence = max score in its SAME-CLASS IoU cluster.
277
- Vectorized: single (n_post x n_full) IoU matrix, no per-box Python loop."""
278
- n = len(post_boxes)
279
- if n == 0:
280
- return np.empty(0, dtype=np.float32)
281
- m = len(full_boxes)
282
- if m == 0:
283
- return np.zeros(n, dtype=np.float32)
284
- pa = (np.maximum(0.0, post_boxes[:, 2] - post_boxes[:, 0]) *
285
- np.maximum(0.0, post_boxes[:, 3] - post_boxes[:, 1]))
286
- fa = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
287
- np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
288
- xx1 = np.maximum(post_boxes[:, 0][:, None], full_boxes[:, 0][None, :])
289
- yy1 = np.maximum(post_boxes[:, 1][:, None], full_boxes[:, 1][None, :])
290
- xx2 = np.minimum(post_boxes[:, 2][:, None], full_boxes[:, 2][None, :])
291
- yy2 = np.minimum(post_boxes[:, 3][:, None], full_boxes[:, 3][None, :])
292
- inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
293
- iou = inter / (pa[:, None] + fa[None, :] - inter + 1e-7)
294
- mask = (iou >= iou_thresh) & (post_cls[:, None] == full_cls[None, :])
295
- tiled = np.where(mask, full_scores[None, :], -np.inf)
296
- out = tiled.max(axis=1)
297
- out[~np.isfinite(out)] = 0.0
298
- return out.astype(np.float32)
299
-
300
- def _conf_filter_mask(self, scores: np.ndarray,
301
- cls_ids: np.ndarray) -> np.ndarray:
302
- """Per-class threshold with rescue bonus for missed classes."""
303
  if len(scores) == 0:
304
  return np.zeros(0, dtype=bool)
305
  thr = self._conf_thres_array[cls_ids]
@@ -317,145 +283,231 @@ class Miner:
317
  keep[top] = True
318
  return keep
319
 
320
- def _per_view_pipeline(self, boxes: np.ndarray, scores: np.ndarray,
321
- cls_ids: np.ndarray, orig_size: tuple[int, int]
322
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
323
- """Sanity filter -> (top-k cap) -> per-class NMS -> cap -> cross-class dedup."""
324
- boxes, scores, cls_ids = self._filter_sane_boxes(
325
- boxes, scores, cls_ids, orig_size
326
- )
327
  if len(boxes) == 0:
328
- return boxes, scores, cls_ids
329
- if len(scores) > self.pre_nms_topk:
330
- top = np.argpartition(-scores, self.pre_nms_topk)[: self.pre_nms_topk]
331
- boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  if len(boxes) > 1:
333
- keep = self._per_class_hard_nms(boxes, scores, cls_ids)
334
- boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
335
  if len(scores) > self.max_det:
336
- top = np.argsort(-scores)[: self.max_det]
337
- boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
 
 
338
  if len(boxes) > 1:
339
- boxes, scores, cls_ids = self._cross_class_dedup_op(
340
- boxes, scores, cls_ids, self.cross_iou_thresh
341
- )
342
- return boxes, scores, cls_ids
343
-
344
- # ─── Decoding ─────────────────────────────────────────────────
345
-
346
- def _decode_yolo_output(self, preds: np.ndarray, ratio: float,
347
- pad: tuple[float, float],
348
- orig_size: tuple[int, int]
349
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
350
- """Decode NMS-free output [1,N,6]=(x1,y1,x2,y2,conf,cls) -> conf filter ->
351
- un-letterbox -> sanity+NMS+dedup. Returns arrays in ORIGINAL coords."""
352
- empty = (np.empty((0, 4), np.float32), np.empty(0, np.float32),
353
- np.empty(0, np.int32))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  if preds.ndim == 3 and preds.shape[0] == 1:
355
  preds = preds[0]
356
  if preds.ndim != 2 or preds.shape[1] < 6:
357
- print(f"Warning: Unexpected output shape: {preds.shape}")
358
- return empty
359
-
360
  boxes = preds[:, :4].astype(np.float32)
361
  scores = preds[:, 4].astype(np.float32)
362
- cls_ids = preds[:, 5].astype(np.int32)
363
-
364
- n_cls = len(self.class_names)
365
- valid = (cls_ids >= 0) & (cls_ids < n_cls)
366
- boxes, scores, cls_ids = boxes[valid], scores[valid], cls_ids[valid]
367
- if len(boxes) == 0:
368
- return empty
369
-
370
  keep = self._conf_filter_mask(scores, cls_ids)
371
- boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
 
 
372
  if len(boxes) == 0:
373
- return empty
374
-
375
  pad_w, pad_h = pad
376
  boxes[:, [0, 2]] -= pad_w
377
  boxes[:, [1, 3]] -= pad_h
378
  boxes /= ratio
379
  boxes = self._clip_boxes(boxes, orig_size)
380
-
381
- return self._per_view_pipeline(boxes, scores, cls_ids, orig_size)
382
-
383
- @staticmethod
384
- def _build_results(boxes: np.ndarray, scores: np.ndarray,
385
- cls_ids: np.ndarray,
386
- orig_size: tuple[int, int]) -> list[BoundingBox]:
387
- results = []
388
- orig_w, orig_h = orig_size
389
- for box, conf, cls_id in zip(boxes, scores, cls_ids):
390
- x1, y1, x2, y2 = box.tolist() if hasattr(box, "tolist") else box
391
- if x2 <= x1 or y2 <= y1:
392
- continue
393
- results.append(
394
- BoundingBox(
395
- x1=max(0, min(orig_w, int(math.floor(x1)))),
396
- y1=max(0, min(orig_h, int(math.floor(y1)))),
397
- x2=max(0, min(orig_w, int(math.ceil(x2)))),
398
- y2=max(0, min(orig_h, int(math.ceil(y2)))),
399
- cls_id=int(cls_id),
400
- conf=float(max(0.0, min(1.0, conf))),
401
- )
402
- )
403
- return results
404
-
405
- # ─── Inference (single view, no TTA) ──────────────────────────
406
-
407
- def _predict_single(self, image_bgr: np.ndarray
408
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
409
- """One forward pass -> decoded (boxes, scores, cls_ids) in original coords."""
410
- if image_bgr is None or not isinstance(image_bgr, np.ndarray):
411
- raise ValueError("Invalid image input")
412
- if image_bgr.dtype != np.uint8:
413
- image_bgr = image_bgr.astype(np.uint8)
414
-
415
- inp, meta = self._preprocess(image_bgr)
416
- outputs = self.session.run(None, {self.input_name: inp})
417
-
418
- ratio = float(meta["ratio"])
419
- pad = (float(meta["pad_w"]), float(meta["pad_h"]))
420
- orig_size = (int(meta["orig_w"]), int(meta["orig_h"]))
421
-
422
- return self._decode_yolo_output(outputs[0], ratio, pad, orig_size)
423
-
424
- def _infer_single(self, image_bgr: ndarray) -> list[BoundingBox]:
425
- orig_h, orig_w = image_bgr.shape[:2]
426
- orig_size = (orig_w, orig_h)
427
-
428
- boxes, scores, cls_ids = self._predict_single(image_bgr)
429
  if len(boxes) == 0:
430
- return []
431
-
432
- if self.use_cluster_boost and len(boxes) > 1:
433
- scores = self._max_score_per_cluster(
434
- boxes, cls_ids, boxes, scores, cls_ids, self._avg_iou)
435
-
436
- return self._build_results(boxes, scores, cls_ids, orig_size)
437
-
438
- # ─── Public API ───────────────────────────────────────────────
439
-
440
- def predict_batch(
441
- self,
442
- batch_images: list[ndarray],
443
- offset: int,
444
- n_keypoints: int,
445
- ) -> list[TVFrameResult]:
 
 
 
 
 
 
 
 
 
446
  results: list[TVFrameResult] = []
447
- for idx, image in enumerate(batch_images):
448
  try:
449
- boxes = self._infer_single(image)
 
 
 
 
 
450
  except Exception as e:
451
- print(f"Inference failed for frame {offset + idx}: {e}")
452
  boxes = []
453
- keypoints = [(0, 0) for _ in range(max(0, int(n_keypoints)))]
454
- results.append(
455
- TVFrameResult(
456
- frame_id=offset + idx,
457
- boxes=boxes,
458
- keypoints=keypoints,
459
- )
460
- )
461
- return results
 
1
  from pathlib import Path
2
  import math
 
3
  import cv2
4
  import numpy as np
5
  import onnxruntime as ort
6
  from numpy import ndarray
7
  from pydantic import BaseModel
8
 
 
9
  class BoundingBox(BaseModel):
10
  x1: int
11
  y1: int
 
14
  cls_id: int
15
  conf: float
16
 
 
17
  class TVFrameResult(BaseModel):
18
  frame_id: int
19
  boxes: list[BoundingBox]
20
  keypoints: list[tuple[int, int]]
21
 
 
22
  class Miner:
23
+ class_names = ['fire', 'smoke', 'fire extinguisher']
24
+ _model_class_order = ['fire', 'smoke', 'fire extinguisher']
25
+ iou_thres = 0.5
26
+ max_det = 30
27
+ _conf_thres_array = np.array([0.10, 0.10, 0.20], dtype=np.float32)
28
+ _bonus_array = np.array([0.05, 0.05, 0.05], dtype=np.float32)
29
+ min_box_area = 100
30
+ min_side = 8
31
+ max_aspect_ratio = 8.0
32
+ smoke_merge_overlap = 0.35
33
+ fire_suppress_overlap = 0.9
34
+ ext_scale = 0.95
35
+ fire_expand = 1.01
36
+ fire_color_filter_max_conf = 0.45
37
+ color_filter_min_saturation = 0.06
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  def __init__(self, path_hf_repo: Path) -> None:
40
+ model_path = path_hf_repo / 'weights.onnx'
41
+ print('ORT version:', ort.__version__)
 
 
42
  try:
43
  ort.preload_dlls()
44
+ print('✅ onnxruntime.preload_dlls() success')
45
  except Exception as e:
46
+ print(f'⚠️ preload_dlls failed: {e}')
47
+ print('ORT available providers BEFORE session:', ort.get_available_providers())
 
 
48
  sess_options = ort.SessionOptions()
49
  sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
50
+ sess_options.intra_op_num_threads = 2
51
+ sess_options.inter_op_num_threads = 1
52
+ sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
53
+ try:
54
+ self.session = ort.InferenceSession(str(model_path), sess_options=sess_options, providers=['CUDAExecutionProvider', 'CPUExecutionProvider'])
55
+ print('✅ Created ORT session with preferred CUDA provider list')
56
+ except Exception as e:
57
+ print(f'⚠️ CUDA session creation failed, falling back to CPU: {e}')
58
+ self.session = ort.InferenceSession(str(model_path), sess_options=sess_options, providers=['CPUExecutionProvider'])
59
+ print('ORT session providers:', self.session.get_providers())
60
+ model_class_order = self._read_model_class_order()
61
+ if model_class_order is None:
62
+ model_class_order = list(self._model_class_order)
63
+ print(f'cls order: no usable ONNX metadata, FALLBACK {model_class_order}')
64
+ else:
65
+ print(f'cls order: from ONNX metadata {model_class_order}')
66
+ self.cls_remap = np.array([self.class_names.index(n) for n in model_class_order], dtype=np.int32)
67
+ for inp in self.session.get_inputs():
68
+ print('INPUT:', inp.name, inp.shape, inp.type)
69
+ for out in self.session.get_outputs():
70
+ print('OUTPUT:', out.name, out.shape, out.type)
71
  self.input_name = self.session.get_inputs()[0].name
72
+ self.output_names = [output.name for output in self.session.get_outputs()]
73
+ self.input_shape = self.session.get_inputs()[0].shape
74
+ self.input_height = self._safe_dim(self.input_shape[2], default=1280)
75
+ self.input_width = self._safe_dim(self.input_shape[3], default=1280)
76
+ print(f'✅ ONNX model loaded from: {model_path}')
77
+ print(f'✅ ONNX providers: {self.session.get_providers()}')
78
+ print(f'✅ ONNX input: name={self.input_name}, shape={self.input_shape}')
79
+ print('per-class conf: ' + ', '.join((f'{n}={t:.3f}' for n, t in zip(self.class_names, self._conf_thres_array.tolist()))))
 
 
 
80
  self._warmup()
81
 
82
+ def _warmup(self, iters: int=3) -> None:
83
  try:
84
  dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
85
  for _ in range(max(1, iters)):
86
  self.predict_batch(batch_images=[dummy], offset=0, n_keypoints=0)
87
+ print(f'✅ warmup: {iters} dummy predict_batch call(s) done')
88
  except Exception as e:
89
+ print(f'⚠️ warmup skipped: {e}')
90
 
91
  def __repr__(self) -> str:
92
+ return f'ONNXRuntime(session={type(self.session).__name__}, providers={self.session.get_providers()})'
93
 
94
  @staticmethod
95
  def _safe_dim(value, default: int) -> int:
96
  return value if isinstance(value, int) and value > 0 else default
97
 
98
+ def _read_model_class_order(self) -> list[str] | None:
99
+ try:
100
+ import ast
101
+ meta = self.session.get_modelmeta().custom_metadata_map
102
+ names = ast.literal_eval(meta['names'])
103
+ if isinstance(names, dict):
104
+ order = [str(names[i]) for i in sorted(names)]
105
+ else:
106
+ order = [str(n) for n in names]
107
+ except Exception as e:
108
+ print(f'cls order: could not read ONNX names metadata ({e})')
109
+ return None
110
+ if sorted(order) != sorted(self.class_names):
111
+ print(f'cls order: ONNX names {order} do not match expected classes {self.class_names}; ignoring metadata')
112
+ return None
113
+ return order
114
+
115
+ def _letterbox(self, image: ndarray, new_shape: tuple[int, int], color=(114, 114, 114)) -> tuple[ndarray, float, tuple[float, float]]:
116
+ h, w = image.shape[:2]
117
+ new_w, new_h = new_shape
118
+ ratio = min(new_w / w, new_h / h)
119
+ resized_w = int(round(w * ratio))
120
+ resized_h = int(round(h * ratio))
121
+ if (resized_w, resized_h) != (w, h):
122
+ interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
123
+ image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
124
+ dw = (new_w - resized_w) / 2.0
125
+ dh = (new_h - resized_h) / 2.0
126
+ left = int(round(dw - 0.1))
127
+ right = int(round(dw + 0.1))
128
+ top = int(round(dh - 0.1))
129
+ bottom = int(round(dh + 0.1))
130
+ padded = cv2.copyMakeBorder(image, top, bottom, left, right, borderType=cv2.BORDER_CONSTANT, value=color)
131
+ return (padded, ratio, (dw, dh))
132
+
133
+ def _preprocess(self, image: ndarray) -> tuple[np.ndarray, float, tuple[float, float], tuple[int, int]]:
134
  orig_h, orig_w = image.shape[:2]
135
+ img, ratio, pad = self._letterbox(image, (self.input_width, self.input_height))
136
+ blob = cv2.dnn.blobFromImage(img, scalefactor=1.0 / 255.0, swapRB=True)
137
+ return (blob, ratio, pad, (orig_w, orig_h))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  @staticmethod
140
  def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
 
146
  return boxes
147
 
148
  @staticmethod
149
+ def _hard_nms(boxes: np.ndarray, scores: np.ndarray, iou_thresh: float) -> np.ndarray:
 
 
150
  n = len(boxes)
151
  if n == 0:
152
  return np.array([], dtype=np.intp)
 
 
 
153
  order = np.argsort(-scores)
154
+ keep: list[int] = []
155
+ while len(order) > 0:
156
  i = int(order[0])
157
  keep.append(i)
158
+ if len(order) == 1:
159
  break
160
  rest = order[1:]
161
+ xx1 = np.maximum(boxes[i, 0], boxes[rest, 0])
162
+ yy1 = np.maximum(boxes[i, 1], boxes[rest, 1])
163
+ xx2 = np.minimum(boxes[i, 2], boxes[rest, 2])
164
+ yy2 = np.minimum(boxes[i, 3], boxes[rest, 3])
165
  inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
166
+ a_i = max(0.0, boxes[i, 2] - boxes[i, 0]) * max(0.0, boxes[i, 3] - boxes[i, 1])
167
+ a_r = np.maximum(0.0, boxes[rest, 2] - boxes[rest, 0]) * np.maximum(0.0, boxes[rest, 3] - boxes[rest, 1])
168
+ iou = inter / (a_i + a_r - inter + 1e-07)
169
  order = rest[iou <= iou_thresh]
170
  return np.array(keep, dtype=np.intp)
171
 
172
+ def _per_class_hard_nms(self, boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray, iou_thresh: float) -> np.ndarray:
 
 
173
  if len(boxes) == 0:
174
  return np.array([], dtype=np.intp)
175
+ all_keep: list[int] = []
176
  for c in np.unique(cls_ids):
177
  mask = cls_ids == c
178
  indices = np.where(mask)[0]
179
+ keep = self._hard_nms(boxes[mask], scores[mask], iou_thresh)
 
180
  all_keep.extend(indices[keep].tolist())
181
  all_keep.sort()
182
  return np.array(all_keep, dtype=np.intp)
183
 
184
+ def _order_by_margin(self, boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
 
 
 
 
 
185
  boxes = np.asarray(boxes, dtype=np.float32)
186
  scores = np.asarray(scores, dtype=np.float32)
187
  cls_ids = np.asarray(cls_ids, dtype=np.int32)
188
+ areas = np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) * np.maximum(0.0, boxes[:, 3] - boxes[:, 1])
 
189
  margins = scores - self._conf_thres_array[cls_ids]
190
  order = np.lexsort((-areas, -margins))
191
+ return (boxes[order], scores[order], cls_ids[order])
192
+
193
+ def _merge_smoke_boxes(self, boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
194
+ target_cls = self.class_names.index('smoke')
195
+ overlap = self.smoke_merge_overlap
196
+ idx = np.where(cls_ids == target_cls)[0]
197
+ if len(idx) <= 1:
198
+ return (boxes, scores, cls_ids)
199
+ sb = boxes[idx].astype(np.float32).tolist()
200
+ ss = scores[idx].astype(np.float32).tolist()
201
+ merged_any = True
202
+ while merged_any and len(sb) > 1:
203
+ merged_any = False
204
+ for i in range(len(sb)):
205
+ for j in range(i + 1, len(sb)):
206
+ a, b = (sb[i], sb[j])
207
+ ix1 = max(a[0], b[0])
208
+ iy1 = max(a[1], b[1])
209
+ ix2 = min(a[2], b[2])
210
+ iy2 = min(a[3], b[3])
211
+ inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
212
+ area_a = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1])
213
+ area_b = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1])
214
+ smaller = min(area_a, area_b)
215
+ if inter / (smaller + 1e-07) >= overlap:
216
+ sb[i] = [min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])]
217
+ ss[i] = max(ss[i], ss[j])
218
+ del sb[j]
219
+ del ss[j]
220
+ merged_any = True
221
+ break
222
+ if merged_any:
223
+ break
224
+ other = cls_ids != target_cls
225
+ new_boxes = np.concatenate([boxes[other].astype(np.float32), np.array(sb, dtype=np.float32).reshape(-1, 4)])
226
+ new_scores = np.concatenate([scores[other].astype(np.float32), np.array(ss, dtype=np.float32)])
227
+ new_cls = np.concatenate([cls_ids[other].astype(np.int32), np.full(len(sb), target_cls, dtype=np.int32)])
228
+ return (new_boxes, new_scores, new_cls)
229
+
230
+ def _suppress_contained_fire(self, boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
231
+ target_cls = self.class_names.index('fire')
232
+ overlap = self.fire_suppress_overlap
233
+ idx = np.where(cls_ids == target_cls)[0]
234
+ if len(idx) <= 1:
235
+ return (boxes, scores, cls_ids)
236
+ order = idx[np.argsort(-scores[idx])]
237
+ remove: set[int] = set()
238
+ for a in range(len(order)):
239
+ i = int(order[a])
240
+ if i in remove:
241
  continue
 
242
  bi = boxes[i]
243
+ area_i = max(1e-07, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
244
+ for b in range(a + 1, len(order)):
245
+ j = int(order[b])
246
+ if j in remove:
247
+ continue
248
+ bj = boxes[j]
249
+ ix1 = max(bi[0], bj[0])
250
+ iy1 = max(bi[1], bj[1])
251
+ ix2 = min(bi[2], bj[2])
252
+ iy2 = min(bi[3], bj[3])
253
+ inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
254
+ if inter <= 0.0:
255
+ continue
256
+ area_j = max(1e-07, float((bj[2] - bj[0]) * (bj[3] - bj[1])))
257
+ if inter / (min(area_i, area_j) + 1e-07) >= overlap:
258
+ remove.add(j)
259
+ if not remove:
260
+ return (boxes, scores, cls_ids)
261
+ keep = np.array([k not in remove for k in range(len(boxes))], dtype=bool)
262
+ return (boxes[keep], scores[keep], cls_ids[keep])
263
+
264
+ def _merge_same_class_boxes(self, boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
265
+ boxes, scores, cls_ids = self._merge_smoke_boxes(boxes, scores, cls_ids)
266
+ return self._suppress_contained_fire(boxes, scores, cls_ids)
267
+
268
+ def _conf_filter_mask(self, scores: np.ndarray, cls_ids: np.ndarray) -> np.ndarray:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  if len(scores) == 0:
270
  return np.zeros(0, dtype=bool)
271
  thr = self._conf_thres_array[cls_ids]
 
283
  keep[top] = True
284
  return keep
285
 
286
+ def _filter_sane_boxes(self, boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray, orig_size: tuple[int, int]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
 
 
 
 
 
 
287
  if len(boxes) == 0:
288
+ return (boxes, scores, cls_ids)
289
+ orig_w, orig_h = orig_size
290
+ image_area = float(orig_w * orig_h)
291
+ keep = []
292
+ for i, box in enumerate(boxes):
293
+ x1, y1, x2, y2 = box.tolist()
294
+ bw = x2 - x1
295
+ bh = y2 - y1
296
+ if bw <= 0 or bh <= 0:
297
+ continue
298
+ if bw < self.min_side or bh < self.min_side:
299
+ continue
300
+ area = bw * bh
301
+ if area < self.min_box_area:
302
+ continue
303
+ if area > 0.95 * image_area:
304
+ continue
305
+ ar = max(bw / max(bh, 1e-06), bh / max(bw, 1e-06))
306
+ if ar > self.max_aspect_ratio:
307
+ continue
308
+ keep.append(i)
309
+ if not keep:
310
+ return (np.empty((0, 4), dtype=np.float32), np.empty((0,), dtype=np.float32), np.empty((0,), dtype=np.int32))
311
+ k = np.array(keep, dtype=np.intp)
312
+ return (boxes[k], scores[k], cls_ids[k])
313
+
314
+ def _per_view_pipeline(self, boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
315
  if len(boxes) > 1:
316
+ keep = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
317
+ boxes, scores, cls_ids = (boxes[keep], scores[keep], cls_ids[keep])
318
  if len(scores) > self.max_det:
319
+ top = np.argsort(-scores)[:self.max_det]
320
+ boxes, scores, cls_ids = (boxes[top], scores[top], cls_ids[top])
321
+ if len(boxes) > 1:
322
+ boxes, scores, cls_ids = self._order_by_margin(boxes, scores, cls_ids)
323
  if len(boxes) > 1:
324
+ boxes, scores, cls_ids = self._merge_same_class_boxes(boxes, scores, cls_ids)
325
+ return (boxes, scores, cls_ids)
326
+
327
+ @staticmethod
328
+ def _roi_for_box(image: np.ndarray, box: BoundingBox) -> np.ndarray | None:
329
+ h, w = image.shape[:2]
330
+ x1 = max(0, int(math.floor(box.x1)))
331
+ y1 = max(0, int(math.floor(box.y1)))
332
+ x2 = min(w, int(math.ceil(box.x2)))
333
+ y2 = min(h, int(math.ceil(box.y2)))
334
+ if x2 <= x1 or y2 <= y1:
335
+ return None
336
+ roi = image[y1:y2, x1:x2]
337
+ return roi if roi.size else None
338
+
339
+ def _roi_is_near_grayscale(self, roi: np.ndarray) -> bool:
340
+ mx = roi.max(axis=2).astype(np.float32)
341
+ mn = roi.min(axis=2).astype(np.float32)
342
+ sat = (mx - mn) / (mx + 1e-06)
343
+ return float(sat.mean()) < self.color_filter_min_saturation
344
+
345
+ @staticmethod
346
+ def _passes_fire_color(roi: np.ndarray) -> bool:
347
+ blue = roi[:, :, 0].astype(np.float32)
348
+ green = roi[:, :, 1].astype(np.float32)
349
+ red = roi[:, :, 2].astype(np.float32)
350
+ mean_r = float(np.mean(red))
351
+ max_rgb = float(max(np.max(red), np.max(green), np.max(blue)))
352
+ bright_frac = float(np.mean(np.max(roi, axis=2) >= 150))
353
+ if max_rgb >= 200.0 and bright_frac >= 0.01:
354
+ return True
355
+ warm = (red > green + 10.0) & (red > blue + 10.0)
356
+ warm_frac = float(np.mean(warm))
357
+ r_minus_g = mean_r - float(np.mean(green))
358
+ if warm_frac >= 0.05 and (max_rgb >= 120.0 or mean_r >= 120.0 or warm_frac >= 0.15):
359
+ return True
360
+ if bright_frac >= 0.12 and r_minus_g >= 2.0:
361
+ return True
362
+ return False
363
+
364
+ def _filter_low_conf_by_color(self, image: np.ndarray, results: list[BoundingBox]) -> list[BoundingBox]:
365
+ if not results:
366
+ return results
367
+ cls_fire = self.class_names.index('fire')
368
+ out: list[BoundingBox] = []
369
+ for box in results:
370
+ if box.cls_id != cls_fire or box.conf > self.fire_color_filter_max_conf:
371
+ out.append(box)
372
+ continue
373
+ roi = self._roi_for_box(image, box)
374
+ if roi is None or self._roi_is_near_grayscale(roi):
375
+ out.append(box)
376
+ continue
377
+ if not self._passes_fire_color(roi):
378
+ continue
379
+ out.append(box)
380
+ return out
381
+
382
+ @staticmethod
383
+ def _build_results(boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray) -> list[BoundingBox]:
384
+ results: list[BoundingBox] = []
385
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
386
+ x1, y1, x2, y2 = box.tolist()
387
+ if x2 <= x1 or y2 <= y1:
388
+ continue
389
+ results.append(BoundingBox(x1=int(math.floor(x1)), y1=int(math.floor(y1)), x2=int(math.ceil(x2)), y2=int(math.ceil(y2)), cls_id=int(cls_id), conf=float(conf)))
390
+ return results
391
+
392
+ @staticmethod
393
+ def _empty_raw() -> tuple[np.ndarray, np.ndarray, np.ndarray]:
394
+ return (np.empty((0, 4), dtype=np.float32), np.empty((0,), dtype=np.float32), np.empty((0,), dtype=np.int32))
395
+
396
+ def _renms_when_raw_smoke(self, finals: list[BoundingBox], raw_boxes: np.ndarray, raw_cls: np.ndarray) -> list[BoundingBox]:
397
+ if not finals or len(raw_boxes) == 0 or len(finals) <= 1:
398
+ return finals
399
+ if not np.any(raw_cls == self.class_names.index('smoke')):
400
+ return finals
401
+ boxes = np.array([[b.x1, b.y1, b.x2, b.y2] for b in finals], dtype=np.float32)
402
+ scores = np.array([b.conf for b in finals], dtype=np.float32)
403
+ cls_ids = np.array([b.cls_id for b in finals], dtype=np.int32)
404
+ keep = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
405
+ return [finals[int(i)] for i in keep]
406
+
407
+ def _rescale_class_boxes(self, finals: list[BoundingBox], orig_size: tuple[int, int]) -> list[BoundingBox]:
408
+ if not finals:
409
+ return finals
410
+ img_w, img_h = orig_size
411
+ fire_id = self.class_names.index('fire')
412
+ ext_id = self.class_names.index('fire extinguisher')
413
+ out: list[BoundingBox] = []
414
+ for b in finals:
415
+ x1, y1, x2, y2 = (float(b.x1), float(b.y1), float(b.x2), float(b.y2))
416
+ w = max(0.0, x2 - x1)
417
+ h = max(0.0, y2 - y1)
418
+ if w <= 0.0 or h <= 0.0:
419
+ continue
420
+ if b.cls_id == ext_id:
421
+ scale = float(self.ext_scale)
422
+ nw, nh = (w * scale, h * scale)
423
+ cx = 0.5 * (x1 + x2)
424
+ nx1 = cx - 0.5 * nw
425
+ nx2 = cx + 0.5 * nw
426
+ ny2 = y2
427
+ ny1 = ny2 - nh
428
+ elif b.cls_id == fire_id:
429
+ scale = float(self.fire_expand)
430
+ nw, nh = (w * scale, h * scale)
431
+ cx = 0.5 * (x1 + x2)
432
+ cy = 0.5 * (y1 + y2)
433
+ nx1 = cx - 0.5 * nw
434
+ nx2 = cx + 0.5 * nw
435
+ ny1 = cy - 0.5 * nh
436
+ ny2 = cy + 0.5 * nh
437
+ else:
438
+ out.append(b)
439
+ continue
440
+ nx1 = max(0.0, min(float(img_w), nx1))
441
+ nx2 = max(0.0, min(float(img_w), nx2))
442
+ ny1 = max(0.0, min(float(img_h), ny1))
443
+ ny2 = max(0.0, min(float(img_h), ny2))
444
+ if nx2 <= nx1 or ny2 <= ny1:
445
+ continue
446
+ out.append(BoundingBox(x1=int(math.floor(nx1)), y1=int(math.floor(ny1)), x2=int(math.ceil(nx2)), y2=int(math.ceil(ny2)), cls_id=b.cls_id, conf=b.conf))
447
+ return out
448
+
449
+ def _apply_extra_post(self, finals: list[BoundingBox], raw_boxes: np.ndarray, raw_cls: np.ndarray, orig_size: tuple[int, int]) -> list[BoundingBox]:
450
+ finals = self._renms_when_raw_smoke(finals, raw_boxes, raw_cls)
451
+ return self._rescale_class_boxes(finals, orig_size)
452
+
453
+ def _postprocess(self, preds: np.ndarray, ratio: float, pad: tuple[float, float], orig_size: tuple[int, int]) -> tuple[list[BoundingBox], tuple[np.ndarray, np.ndarray, np.ndarray]]:
454
  if preds.ndim == 3 and preds.shape[0] == 1:
455
  preds = preds[0]
456
  if preds.ndim != 2 or preds.shape[1] < 6:
457
+ raise ValueError(f'Unexpected ONNX final-det output shape: {preds.shape}')
 
 
458
  boxes = preds[:, :4].astype(np.float32)
459
  scores = preds[:, 4].astype(np.float32)
460
+ cls_ids = self.cls_remap[preds[:, 5].astype(np.int32)]
 
 
 
 
 
 
 
461
  keep = self._conf_filter_mask(scores, cls_ids)
462
+ boxes = boxes[keep]
463
+ scores = scores[keep]
464
+ cls_ids = cls_ids[keep]
465
  if len(boxes) == 0:
466
+ return ([], self._empty_raw())
 
467
  pad_w, pad_h = pad
468
  boxes[:, [0, 2]] -= pad_w
469
  boxes[:, [1, 3]] -= pad_h
470
  boxes /= ratio
471
  boxes = self._clip_boxes(boxes, orig_size)
472
+ raw = (boxes, scores, cls_ids)
473
+ boxes, scores, cls_ids = self._filter_sane_boxes(boxes, scores, cls_ids, orig_size)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  if len(boxes) == 0:
475
+ return ([], raw)
476
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
477
+ return (self._build_results(boxes, scores, cls_ids), raw)
478
+
479
+ def _predict_single(self, image: np.ndarray) -> tuple[list[BoundingBox], tuple[np.ndarray, np.ndarray, np.ndarray]]:
480
+ if image is None:
481
+ raise ValueError('Input image is None')
482
+ if not isinstance(image, np.ndarray):
483
+ raise TypeError(f'Input is not numpy array: {type(image)}')
484
+ if image.ndim != 3:
485
+ raise ValueError(f'Expected HWC image, got shape={image.shape}')
486
+ if image.shape[0] <= 0 or image.shape[1] <= 0:
487
+ raise ValueError(f'Invalid image shape={image.shape}')
488
+ if image.shape[2] != 3:
489
+ raise ValueError(f'Expected 3 channels, got shape={image.shape}')
490
+ if image.dtype != np.uint8:
491
+ image = image.astype(np.uint8)
492
+ input_tensor, ratio, pad, orig_size = self._preprocess(image)
493
+ expected = (1, 3, self.input_height, self.input_width)
494
+ if input_tensor.shape != expected:
495
+ raise ValueError(f'Bad input tensor shape={input_tensor.shape}, expected={expected}')
496
+ outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
497
+ return self._postprocess(outputs[0], ratio, pad, orig_size)
498
+
499
+ def predict_batch(self, batch_images: list[ndarray], offset: int, n_keypoints: int) -> list[TVFrameResult]:
500
  results: list[TVFrameResult] = []
501
+ for frame_number_in_batch, image in enumerate(batch_images):
502
  try:
503
+ boxes, raw = self._predict_single(image)
504
+ if isinstance(image, np.ndarray) and image.ndim == 3:
505
+ boxes = self._filter_low_conf_by_color(image, boxes)
506
+ boxes = self._apply_extra_post(boxes, raw[0], raw[2], (image.shape[1], image.shape[0]))
507
+ else:
508
+ boxes = self._apply_extra_post(boxes, raw[0], raw[2], (0, 0))
509
  except Exception as e:
510
+ print(f'⚠️ Inference failed for frame {offset + frame_number_in_batch}: {e}')
511
  boxes = []
512
+ results.append(TVFrameResult(frame_id=offset + frame_number_in_batch, boxes=boxes, keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))]))
513
+ return results
 
 
 
 
 
 
 
weights.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:9ad0e8fc7e38bd4c5f5c8f4cfece75dcc2fa6a41c2cf71e4cba9672c87b98687
3
- size 9757809
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f6a645e1257d008bd69e5589a8bad49155040175af30a4bdc03e97cd1b19fa8d
3
+ size 9842101