thomaskk2024 commited on
Commit
f4264f2
·
verified ·
1 Parent(s): 8f5756d

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. chute_config.yml +20 -0
  2. miner.py +566 -0
  3. weights.onnx +3 -0
chute_config.yml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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==1.20.1' '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
+
14
+ Chute:
15
+ timeout_seconds: 900
16
+ concurrency: 4
17
+ max_instances: 5
18
+ scaling_threshold: 0.5
19
+ shutdown_after_seconds: 288000
20
+ tee: true
miner.py ADDED
@@ -0,0 +1,566 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. Hard per-class NMS + cross-class dedup + flip TTA."""
28
+
29
+ class_names = ["balaclava", "hoodie", "glove", "bat", "spray paint", "graffiti"]
30
+
31
+ # FALLBACK order the model emits classes in -- remapped to `class_names`
32
+ # index by `self.cls_remap` (built in __init__). The authoritative order is
33
+ # read from the ONNX `names` metadata that Ultralytics embeds at export time
34
+ # (ships inside weights.onnx), so a retrained model with a different class
35
+ # order is remapped correctly without code changes. This static list is used
36
+ # only when that metadata is missing or unparsable.
37
+ model_class_names = ["balaclava", "bat", "glove", "graffiti", "hoodie", "spray paint"]
38
+
39
+ input_size = 1280
40
+
41
+ # Test-time augmentation (horizontal-flip ensemble) runs a SECOND forward
42
+ # pass per frame and roughly DOUBLES latency. This 640 model is built for a
43
+ # <100 ms single-pass budget, so TTA is OFF by default. Turn it on only with
44
+ # latency headroom — and note the per-class thresholds below should be
45
+ # re-swept for whichever mode you deploy, since flipping TTA shifts scores.
46
+ use_tta = False
47
+
48
+ iou_thres = 0.3
49
+ cross_iou_thresh = 0.8
50
+ max_det = 150
51
+
52
+ _conf_thres_array = np.array(
53
+ [0.38, 0.38, 0.22, 0.22, 0.33, 0.20], dtype=np.float32,
54
+ )
55
+ _bonus_array = np.array(
56
+ [0.25, 0.25, 0.12, 0.09, 0.21, 0.06], dtype=np.float32,
57
+ )
58
+
59
+ def __init__(self, path_hf_repo: Path) -> None:
60
+ # model_path = path_hf_repo / "weights-all.onnx"
61
+ model_path = path_hf_repo / "weights.onnx"
62
+ print("ORT version:", ort.__version__)
63
+
64
+ try:
65
+ ort.preload_dlls()
66
+ print("preload_dlls success")
67
+ except Exception as e:
68
+ print(f"preload_dlls failed: {e}")
69
+
70
+ print("ORT available providers BEFORE session:", ort.get_available_providers())
71
+
72
+ sess_options = ort.SessionOptions()
73
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
74
+ # Pin threads for the 2vCPU/4GB public-track latency gate (p95 <= 100ms).
75
+ sess_options.intra_op_num_threads = 2
76
+ sess_options.inter_op_num_threads = 1
77
+ sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
78
+
79
+ try:
80
+ self.session = ort.InferenceSession(
81
+ str(model_path),
82
+ sess_options=sess_options,
83
+ providers=["CPUExecutionProvider"],
84
+ )
85
+ except Exception as e:
86
+ print(f"CUDA session creation failed, falling back to CPU: {e}")
87
+ self.session = ort.InferenceSession(
88
+ str(model_path),
89
+ sess_options=sess_options,
90
+ providers=["CPUExecutionProvider"],
91
+ )
92
+
93
+ print("ORT session providers:", self.session.get_providers())
94
+
95
+ # Build cls_remap: for each model-emit index i,
96
+ # cls_remap[i] = self.class_names.index(model_class_order[i])
97
+ # i.e. convert a model-side class id into the output class id that
98
+ # downstream code (BoundingBox.cls_id, the per-class threshold/bonus
99
+ # arrays) expects. The model-side order comes from the ONNX metadata
100
+ # when available, else falls back to the static model_class_names.
101
+ model_class_order = self._read_model_class_order()
102
+ if model_class_order is None:
103
+ model_class_order = list(self.model_class_names)
104
+ print(f"cls order: no usable ONNX metadata, FALLBACK {model_class_order}")
105
+ else:
106
+ print(f"cls order: from ONNX metadata {model_class_order}")
107
+ self.cls_remap = np.array(
108
+ [self.class_names.index(n) for n in model_class_order],
109
+ dtype=np.int32,
110
+ )
111
+
112
+ for inp in self.session.get_inputs():
113
+ print("INPUT:", inp.name, inp.shape, inp.type)
114
+ for out in self.session.get_outputs():
115
+ print("OUTPUT:", out.name, out.shape, out.type)
116
+
117
+ self.input_name = self.session.get_inputs()[0].name
118
+ self.output_names = [output.name for output in self.session.get_outputs()]
119
+ self.input_shape = self.session.get_inputs()[0].shape
120
+
121
+ self.input_height = self._safe_dim(self.input_shape[2], default=self.input_size)
122
+ self.input_width = self._safe_dim(self.input_shape[3], default=self.input_size)
123
+
124
+ print(f"ONNX model loaded from: {model_path}")
125
+ print(f"ONNX providers: {self.session.get_providers()}")
126
+ print(f"ONNX input: name={self.input_name}, shape={self.input_shape}")
127
+ print(f"ONNX input size: {self.input_width}x{self.input_height}, use_tta={self.use_tta}")
128
+ print("per-class conf: " + ", ".join(
129
+ f"{n}={t:.3f}" for n, t in zip(self.class_names,
130
+ self._conf_thres_array.tolist())))
131
+
132
+ self._warmup()
133
+
134
+ def _warmup(self, iters: int = 3) -> None:
135
+ try:
136
+ dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
137
+ for _ in range(max(1, iters)):
138
+ self.predict_batch(batch_images=[dummy], offset=0, n_keypoints=0)
139
+ print(f"warmup: {iters} dummy predict_batch call(s) done")
140
+ except Exception as e:
141
+ print(f"warmup skipped: {e}")
142
+
143
+ def _read_model_class_order(self) -> "list[str] | None":
144
+ """Read the model's class order from Ultralytics ONNX metadata.
145
+ Returns the class names ordered by model-emit index, or None when the
146
+ metadata is missing/unparsable or doesn't match `class_names` as a set
147
+ (in which case the static model_class_names fallback is used)."""
148
+ try:
149
+ import ast
150
+
151
+ meta = self.session.get_modelmeta().custom_metadata_map
152
+ names = ast.literal_eval(meta["names"]) # e.g. {0: 'balaclava', ...}
153
+ if isinstance(names, dict):
154
+ order = [str(names[i]) for i in sorted(names)]
155
+ else:
156
+ order = [str(n) for n in names]
157
+ except Exception as e:
158
+ print(f"cls order: could not read ONNX names metadata ({e})")
159
+ return None
160
+ if sorted(order) != sorted(self.class_names):
161
+ print(
162
+ f"cls order: ONNX names {order} do not match expected classes "
163
+ f"{self.class_names}; ignoring metadata"
164
+ )
165
+ return None
166
+ return order
167
+
168
+ def __repr__(self) -> str:
169
+ return (
170
+ f"ONNXRuntime(session={type(self.session).__name__}, "
171
+ f"providers={self.session.get_providers()})"
172
+ )
173
+
174
+ @staticmethod
175
+ def _safe_dim(value, default: int) -> int:
176
+ return value if isinstance(value, int) and value > 0 else default
177
+
178
+ def _letterbox(self, image: ndarray, new_shape: tuple[int, int],
179
+ color=(114, 114, 114)
180
+ ) -> tuple[ndarray, float, tuple[float, float]]:
181
+ h, w = image.shape[:2]
182
+ new_w, new_h = new_shape
183
+ ratio = min(new_w / w, new_h / h)
184
+ resized_w = int(round(w * ratio))
185
+ resized_h = int(round(h * ratio))
186
+ if (resized_w, resized_h) != (w, h):
187
+ interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
188
+ image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
189
+ dw = (new_w - resized_w) / 2.0
190
+ dh = (new_h - resized_h) / 2.0
191
+ left = int(round(dw - 0.1))
192
+ right = int(round(dw + 0.1))
193
+ top = int(round(dh - 0.1))
194
+ bottom = int(round(dh + 0.1))
195
+ padded = cv2.copyMakeBorder(image, top, bottom, left, right,
196
+ borderType=cv2.BORDER_CONSTANT, value=color)
197
+ return padded, ratio, (dw, dh)
198
+
199
+ def _preprocess(self, image: ndarray
200
+ ) -> tuple[np.ndarray, float, tuple[float, float],
201
+ tuple[int, int]]:
202
+ orig_h, orig_w = image.shape[:2]
203
+ img, ratio, pad = self._letterbox(image, (self.input_width, self.input_height))
204
+ # Fused scale(1/255) + BGR->RGB swap + HWC->NCHW + contiguous float32 in
205
+ # one optimized OpenCV call (bit-identical to the cvtColor + astype/255 +
206
+ # transpose chain, but ~half the preprocess time).
207
+ blob = cv2.dnn.blobFromImage(img, scalefactor=1.0 / 255.0, swapRB=True)
208
+ return blob, ratio, pad, (orig_w, orig_h)
209
+
210
+ @staticmethod
211
+ def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
212
+ w, h = image_size
213
+ boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
214
+ boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
215
+ boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
216
+ boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
217
+ return boxes
218
+
219
+ @staticmethod
220
+ def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
221
+ out = np.empty_like(boxes)
222
+ out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
223
+ out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
224
+ out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
225
+ out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
226
+ return out
227
+
228
+ @staticmethod
229
+ def _hard_nms(boxes: np.ndarray, scores: np.ndarray,
230
+ iou_thresh: float) -> np.ndarray:
231
+ n = len(boxes)
232
+ if n == 0:
233
+ return np.array([], dtype=np.intp)
234
+ order = np.argsort(-scores)
235
+ keep: list[int] = []
236
+ while len(order) > 0:
237
+ i = int(order[0])
238
+ keep.append(i)
239
+ if len(order) == 1:
240
+ break
241
+ rest = order[1:]
242
+ xx1 = np.maximum(boxes[i, 0], boxes[rest, 0])
243
+ yy1 = np.maximum(boxes[i, 1], boxes[rest, 1])
244
+ xx2 = np.minimum(boxes[i, 2], boxes[rest, 2])
245
+ yy2 = np.minimum(boxes[i, 3], boxes[rest, 3])
246
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
247
+ a_i = (max(0.0, boxes[i, 2] - boxes[i, 0]) *
248
+ max(0.0, boxes[i, 3] - boxes[i, 1]))
249
+ a_r = (np.maximum(0.0, boxes[rest, 2] - boxes[rest, 0]) *
250
+ np.maximum(0.0, boxes[rest, 3] - boxes[rest, 1]))
251
+ iou = inter / (a_i + a_r - inter + 1e-7)
252
+ order = rest[iou <= iou_thresh]
253
+ return np.array(keep, dtype=np.intp)
254
+
255
+ def _per_class_hard_nms(self, boxes: np.ndarray, scores: np.ndarray,
256
+ cls_ids: np.ndarray, iou_thresh: float
257
+ ) -> np.ndarray:
258
+ if len(boxes) == 0:
259
+ return np.array([], dtype=np.intp)
260
+ all_keep: list[int] = []
261
+ for c in np.unique(cls_ids):
262
+ mask = cls_ids == c
263
+ indices = np.where(mask)[0]
264
+ keep = self._hard_nms(boxes[mask], scores[mask], iou_thresh)
265
+ all_keep.extend(indices[keep].tolist())
266
+ all_keep.sort()
267
+ return np.array(all_keep, dtype=np.intp)
268
+
269
+ def _cross_class_dedup_op(self, boxes: np.ndarray, scores: np.ndarray,
270
+ cls_ids: np.ndarray, iou_thresh: float
271
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
272
+ n = len(boxes)
273
+ if n <= 1:
274
+ return boxes, scores, cls_ids
275
+ boxes = np.asarray(boxes, dtype=np.float32)
276
+ scores = np.asarray(scores, dtype=np.float32)
277
+ cls_ids = np.asarray(cls_ids, dtype=np.int32)
278
+ areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *
279
+ np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))
280
+ margins = scores - self._conf_thres_array[cls_ids]
281
+ order = np.lexsort((-areas, -margins))
282
+ suppressed = np.zeros(n, dtype=bool)
283
+ keep: list[int] = []
284
+ for i in order:
285
+ if suppressed[i]:
286
+ continue
287
+ keep.append(int(i))
288
+ bi = boxes[i]
289
+ xx1 = np.maximum(bi[0], boxes[:, 0])
290
+ yy1 = np.maximum(bi[1], boxes[:, 1])
291
+ xx2 = np.minimum(bi[2], boxes[:, 2])
292
+ yy2 = np.minimum(bi[3], boxes[:, 3])
293
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
294
+ a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
295
+ iou = inter / (a_i + areas - inter + 1e-7)
296
+ dup = iou > iou_thresh
297
+ dup[i] = False
298
+ suppressed |= dup
299
+ keep_idx = np.array(keep, dtype=np.intp)
300
+ return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]
301
+
302
+ @staticmethod
303
+ def _max_score_per_cluster(post_boxes: np.ndarray,
304
+ post_cls: np.ndarray,
305
+ full_boxes: np.ndarray,
306
+ full_scores: np.ndarray,
307
+ full_cls: np.ndarray,
308
+ iou_thresh: float) -> np.ndarray:
309
+ n = len(post_boxes)
310
+ if n == 0:
311
+ return np.empty(0, dtype=np.float32)
312
+ full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
313
+ np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
314
+ out = np.empty(n, dtype=np.float32)
315
+ for i in range(n):
316
+ bi = post_boxes[i]
317
+ xx1 = np.maximum(bi[0], full_boxes[:, 0])
318
+ yy1 = np.maximum(bi[1], full_boxes[:, 1])
319
+ xx2 = np.minimum(bi[2], full_boxes[:, 2])
320
+ yy2 = np.minimum(bi[3], full_boxes[:, 3])
321
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
322
+ a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
323
+ iou = inter / (a_i + full_areas - inter + 1e-7)
324
+ cluster = (iou >= iou_thresh) & (full_cls == post_cls[i])
325
+ out[i] = float(np.max(full_scores[cluster])) if np.any(cluster) else 0.0
326
+ return out
327
+
328
+ def _conf_filter_mask(self, scores: np.ndarray,
329
+ cls_ids: np.ndarray) -> np.ndarray:
330
+ """Boolean keep-mask: score >= per-class threshold, with a per-class
331
+ rescue — if a class has zero boxes passing, admit its top-1 candidate
332
+ when its score >= (per-class threshold - per-class bonus)."""
333
+ if len(scores) == 0:
334
+ return np.zeros(0, dtype=bool)
335
+ thr = self._conf_thres_array[cls_ids]
336
+ keep = scores >= thr
337
+ for c in np.unique(cls_ids):
338
+ b = float(self._bonus_array[c])
339
+ if b <= 0.0:
340
+ continue
341
+ cm = cls_ids == c
342
+ if keep[cm].any():
343
+ continue
344
+ idx = np.where(cm)[0]
345
+ top = int(idx[int(np.argmax(scores[idx]))])
346
+ if scores[top] >= self._conf_thres_array[c] - b:
347
+ keep[top] = True
348
+ return keep
349
+
350
+ def _per_view_pipeline(self, boxes: np.ndarray, scores: np.ndarray,
351
+ cls_ids: np.ndarray
352
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
353
+ if len(boxes) > 1:
354
+ keep = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
355
+ boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
356
+ if len(scores) > self.max_det:
357
+ top = np.argsort(-scores)[: self.max_det]
358
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
359
+ if len(boxes) > 1:
360
+ boxes, scores, cls_ids = self._cross_class_dedup_op(
361
+ boxes, scores, cls_ids, self.cross_iou_thresh
362
+ )
363
+ return boxes, scores, cls_ids
364
+
365
+ def _decode_final_dets(self, preds: np.ndarray, ratio: float,
366
+ pad: tuple[float, float],
367
+ orig_size: tuple[int, int]) -> list[BoundingBox]:
368
+ if preds.ndim == 3 and preds.shape[0] == 1:
369
+ preds = preds[0]
370
+ if preds.ndim != 2 or preds.shape[1] < 6:
371
+ raise ValueError(f"Unexpected ONNX final-det output shape: {preds.shape}")
372
+
373
+ boxes = preds[:, :4].astype(np.float32)
374
+ scores = preds[:, 4].astype(np.float32)
375
+ cls_ids = preds[:, 5].astype(np.int32)
376
+
377
+ # Remap model cls_ids -> output cls_ids BEFORE the conf filter, so the
378
+ # per-class threshold/bonus arrays (indexed in `class_names` order) are
379
+ # applied to the right class.
380
+ n_model_cls = len(self.model_class_names)
381
+ vmask = (cls_ids >= 0) & (cls_ids < n_model_cls)
382
+ boxes, scores, cls_ids = boxes[vmask], scores[vmask], cls_ids[vmask]
383
+ cls_ids = self.cls_remap[cls_ids]
384
+
385
+ keep = self._conf_filter_mask(scores, cls_ids)
386
+ boxes = boxes[keep]
387
+ scores = scores[keep]
388
+ cls_ids = cls_ids[keep]
389
+ if len(boxes) == 0:
390
+ return []
391
+
392
+ pad_w, pad_h = pad
393
+ boxes[:, [0, 2]] -= pad_w
394
+ boxes[:, [1, 3]] -= pad_h
395
+ boxes /= ratio
396
+ boxes = self._clip_boxes(boxes, orig_size)
397
+
398
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
399
+ return self._build_results(boxes, scores, cls_ids)
400
+
401
+ def _decode_raw_yolo(self, preds: np.ndarray, ratio: float,
402
+ pad: tuple[float, float],
403
+ orig_size: tuple[int, int]) -> list[BoundingBox]:
404
+ if preds.ndim != 3 or preds.shape[0] != 1:
405
+ raise ValueError(f"Unexpected raw ONNX output shape: {preds.shape}")
406
+ preds = preds[0]
407
+ if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
408
+ preds = preds.T
409
+ if preds.ndim != 2 or preds.shape[1] < 5:
410
+ raise ValueError(f"Unexpected raw output shape: {preds.shape}")
411
+
412
+ boxes_xywh = preds[:, :4].astype(np.float32)
413
+ cls_part = preds[:, 4:].astype(np.float32)
414
+ if cls_part.shape[1] == 1:
415
+ scores = cls_part[:, 0]
416
+ cls_ids = np.zeros(len(scores), dtype=np.int32)
417
+ else:
418
+ cls_ids = np.argmax(cls_part, axis=1).astype(np.int32)
419
+ scores = cls_part[np.arange(len(cls_part)), cls_ids]
420
+
421
+ # Remap model cls_ids -> output cls_ids BEFORE the conf filter, so the
422
+ # per-class threshold/bonus arrays (indexed in `class_names` order) are
423
+ # applied to the right class.
424
+ n_model_cls = len(self.model_class_names)
425
+ vmask = (cls_ids >= 0) & (cls_ids < n_model_cls)
426
+ boxes_xywh, scores, cls_ids = boxes_xywh[vmask], scores[vmask], cls_ids[vmask]
427
+ cls_ids = self.cls_remap[cls_ids]
428
+
429
+ keep = self._conf_filter_mask(scores, cls_ids)
430
+ boxes_xywh = boxes_xywh[keep]
431
+ scores = scores[keep]
432
+ cls_ids = cls_ids[keep]
433
+ if len(boxes_xywh) == 0:
434
+ return []
435
+ boxes = self._xywh_to_xyxy(boxes_xywh)
436
+
437
+ pad_w, pad_h = pad
438
+ boxes[:, [0, 2]] -= pad_w
439
+ boxes[:, [1, 3]] -= pad_h
440
+ boxes /= ratio
441
+ boxes = self._clip_boxes(boxes, orig_size)
442
+
443
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
444
+ return self._build_results(boxes, scores, cls_ids)
445
+
446
+ @staticmethod
447
+ def _build_results(boxes: np.ndarray, scores: np.ndarray,
448
+ cls_ids: np.ndarray) -> list[BoundingBox]:
449
+ results: list[BoundingBox] = []
450
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
451
+ x1, y1, x2, y2 = box.tolist()
452
+ if x2 <= x1 or y2 <= y1:
453
+ continue
454
+ results.append(
455
+ BoundingBox(
456
+ x1=int(math.floor(x1)),
457
+ y1=int(math.floor(y1)),
458
+ x2=int(math.ceil(x2)),
459
+ y2=int(math.ceil(y2)),
460
+ cls_id=int(cls_id),
461
+ conf=float(conf),
462
+ )
463
+ )
464
+ return results
465
+
466
+ def _postprocess(self, output: np.ndarray, ratio: float,
467
+ pad: tuple[float, float],
468
+ orig_size: tuple[int, int]) -> list[BoundingBox]:
469
+ if output.ndim == 2 and output.shape[1] >= 6:
470
+ return self._decode_final_dets(output, ratio, pad, orig_size)
471
+ if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
472
+ return self._decode_final_dets(output, ratio, pad, orig_size)
473
+ return self._decode_raw_yolo(output, ratio, pad, orig_size)
474
+
475
+ def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
476
+ if image is None:
477
+ raise ValueError("Input image is None")
478
+ if not isinstance(image, np.ndarray):
479
+ raise TypeError(f"Input is not numpy array: {type(image)}")
480
+ if image.ndim != 3:
481
+ raise ValueError(f"Expected HWC image, got shape={image.shape}")
482
+ if image.shape[2] != 3:
483
+ raise ValueError(f"Expected 3 channels, got shape={image.shape}")
484
+ if image.dtype != np.uint8:
485
+ image = image.astype(np.uint8)
486
+
487
+ input_tensor, ratio, pad, orig_size = self._preprocess(image)
488
+ expected = (1, 3, self.input_height, self.input_width)
489
+ if input_tensor.shape != expected:
490
+ raise ValueError(
491
+ f"Bad input tensor shape={input_tensor.shape}, expected={expected}"
492
+ )
493
+
494
+ outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
495
+ return self._postprocess(outputs[0], ratio, pad, orig_size)
496
+
497
+ def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
498
+ boxes_orig = self._predict_single(image)
499
+ flipped = cv2.flip(image, 1)
500
+ boxes_flip = self._predict_single(flipped)
501
+ w = image.shape[1]
502
+ boxes_flip = [
503
+ BoundingBox(
504
+ x1=w - b.x2, y1=b.y1, x2=w - b.x1, y2=b.y2,
505
+ cls_id=b.cls_id, conf=b.conf,
506
+ )
507
+ for b in boxes_flip
508
+ ]
509
+ all_boxes = boxes_orig + boxes_flip
510
+ if not all_boxes:
511
+ return []
512
+
513
+ coords = np.array(
514
+ [[b.x1, b.y1, b.x2, b.y2] for b in all_boxes], dtype=np.float32
515
+ )
516
+ scores = np.array([b.conf for b in all_boxes], dtype=np.float32)
517
+ cls_ids = np.array([b.cls_id for b in all_boxes], dtype=np.int32)
518
+
519
+ hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
520
+ if len(hard_keep) == 0:
521
+ return []
522
+ if len(hard_keep) > self.max_det:
523
+ top = np.argsort(-scores[hard_keep])[: self.max_det]
524
+ hard_keep = hard_keep[top]
525
+ boosted = self._max_score_per_cluster(
526
+ coords[hard_keep], cls_ids[hard_keep],
527
+ coords, scores, cls_ids, self.iou_thres,
528
+ )
529
+
530
+ kept_coords = coords[hard_keep]
531
+ kept_cls = cls_ids[hard_keep]
532
+ if len(kept_coords) > 1:
533
+ kept_coords, boosted, kept_cls = self._cross_class_dedup_op(
534
+ kept_coords, boosted, kept_cls, self.cross_iou_thresh
535
+ )
536
+
537
+ return [
538
+ BoundingBox(
539
+ x1=int(math.floor(kept_coords[j, 0])),
540
+ y1=int(math.floor(kept_coords[j, 1])),
541
+ x2=int(math.ceil(kept_coords[j, 2])),
542
+ y2=int(math.ceil(kept_coords[j, 3])),
543
+ cls_id=int(kept_cls[j]),
544
+ conf=float(boosted[j]),
545
+ )
546
+ for j in range(len(kept_coords))
547
+ ]
548
+
549
+ def predict_batch(self, batch_images: list[ndarray], offset: int,
550
+ n_keypoints: int) -> list[TVFrameResult]:
551
+ results: list[TVFrameResult] = []
552
+ predict = self._predict_tta if self.use_tta else self._predict_single
553
+ for frame_number_in_batch, image in enumerate(batch_images):
554
+ try:
555
+ boxes = predict(image)
556
+ except Exception as e:
557
+ print(f"Inference failed for frame {offset + frame_number_in_batch}: {e}")
558
+ boxes = []
559
+ results.append(
560
+ TVFrameResult(
561
+ frame_id=offset + frame_number_in_batch,
562
+ boxes=boxes,
563
+ keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
564
+ )
565
+ )
566
+ return results
weights.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:895e01c914229861d8b10e40a2d4f10fc487e6524b98cd8217a2e763085e29d7
3
+ size 9809204