stfkramer commited on
Commit
e2daff9
·
verified ·
1 Parent(s): fc06798

Update miner.py

Browse files
Files changed (1) hide show
  1. miner.py +526 -608
miner.py CHANGED
@@ -1,647 +1,565 @@
1
  from pathlib import Path
 
2
 
 
 
3
  import onnxruntime as ort
4
  from numpy import ndarray
5
  from pydantic import BaseModel
6
- import numpy as np
7
- import cv2
8
-
9
-
10
- MODEL_CLASS_NAMES = ["balaclava", "bat", "glove", "graffiti", "hoodie", "spray paint"]
11
- CLASS_CONF_LIST = [0.44, 0.25, 0.45, 0.15, 0.45, 0.45]
12
- BONUS_LIST = [0.23, 0.3, 0.45, 0.44, 0.45, 0.41]
13
-
14
- SUBNET_CLASS_NAMES = ["balaclava", "hoodie", "glove", "bat", "spray paint", "graffiti"]
15
-
16
- # =========================
17
- # ONNX YOLO MODEL
18
- # =========================
19
-
20
-
21
- class ONNXYOLO:
22
- def __init__(
23
- self,
24
- weights_path: str,
25
- imgsz: int = 640,
26
- providers: list[str] | None = None,
27
- ):
28
- self.weights_path = weights_path
29
- self.imgsz = imgsz
30
 
31
- if providers is None:
32
- providers = ["CPUExecutionProvider"]
33
 
34
- session_options = ort.SessionOptions()
35
- session_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
36
- session_options.intra_op_num_threads = 8
37
- session_options.inter_op_num_threads = 1
38
-
39
- self.session = ort.InferenceSession(
40
- weights_path,
41
- sess_options=session_options,
42
- providers=providers,
43
- )
44
 
45
- self.input_name = self.session.get_inputs()[0].name
46
- self.output_names = [output.name for output in self.session.get_outputs()]
47
 
48
- print("ONNX providers:", self.session.get_providers())
49
- print("ONNX input:", self.session.get_inputs()[0].name, self.session.get_inputs()[0].shape)
50
- print("ONNX outputs:")
51
- for output in self.session.get_outputs():
52
- print(" ", output.name, output.shape, output.type)
53
-
54
- def letterbox(
55
- self,
56
- image: np.ndarray,
57
- new_shape: int = 640,
58
- color: tuple[int, int, int] = (114, 114, 114),
59
- ):
60
- """
61
- Same idea as Ultralytics letterbox:
62
- resize image while preserving aspect ratio and pad to square.
63
- """
64
- original_h, original_w = image.shape[:2]
65
-
66
- scale = min(new_shape / original_h, new_shape / original_w)
67
-
68
- resized_w = int(round(original_w * scale))
69
- resized_h = int(round(original_h * scale))
70
-
71
- pad_w = new_shape - resized_w
72
- pad_h = new_shape - resized_h
73
-
74
- pad_left = pad_w // 2
75
- pad_right = pad_w - pad_left
76
- pad_top = pad_h // 2
77
- pad_bottom = pad_h - pad_top
78
-
79
- resized = cv2.resize(image, (resized_w, resized_h), interpolation=cv2.INTER_LINEAR)
80
-
81
- padded = cv2.copyMakeBorder(
82
- resized,
83
- pad_top,
84
- pad_bottom,
85
- pad_left,
86
- pad_right,
87
- cv2.BORDER_CONSTANT,
88
- value=color,
89
- )
90
 
91
- return padded, scale, pad_left, pad_top
92
 
93
- def preprocess(self, image: np.ndarray):
94
- """
95
- Input image is OpenCV BGR uint8.
96
- Output is float32 NCHW RGB normalized 0..1.
97
- """
98
- padded, scale, pad_left, pad_top = self.letterbox(
99
- image=image,
100
- new_shape=self.imgsz,
101
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
- rgb = cv2.cvtColor(padded, cv2.COLOR_BGR2RGB)
104
-
105
- x = rgb.astype(np.float32) / 255.0
106
- x = np.transpose(x, (2, 0, 1))
107
- x = np.expand_dims(x, axis=0)
108
- x = np.ascontiguousarray(x, dtype=np.float32)
109
-
110
- return x, scale, pad_left, pad_top
111
-
112
- def postprocess_raw_yolo(
113
- self,
114
- output: np.ndarray,
115
- original_shape: tuple[int, int],
116
- scale: float,
117
- pad_left: int,
118
- pad_top: int,
119
- conf_threshold: float,
120
- iou_threshold: float = 0.7,
121
- ) -> list[dict]:
122
- """
123
- Supports common Ultralytics YOLO ONNX raw output:
124
-
125
- Case A:
126
- [1, 4 + nc, N]
127
- example: [1, 10, 8400] for 6 classes
128
-
129
- Case B:
130
- [1, N, 4 + nc]
131
-
132
- Case C with NMS:
133
- [1, N, 6] or [N, 6]
134
- [x1, y1, x2, y2, conf, cls]
135
- """
136
- if isinstance(output, list):
137
- output = output[0]
138
-
139
- pred = np.asarray(output)
140
-
141
- # Remove batch dim.
142
- if pred.ndim == 3 and pred.shape[0] == 1:
143
- pred = pred[0]
144
-
145
- # NMS-exported output: [N, 6]
146
- if pred.ndim == 2 and pred.shape[1] == 6:
147
- return self.parse_nms_output(
148
- pred=pred,
149
- original_shape=original_shape,
150
- scale=scale,
151
- pad_left=pad_left,
152
- pad_top=pad_top,
153
- conf_threshold=conf_threshold,
154
  )
155
 
156
- # Raw output [4 + nc, N] -> [N, 4 + nc]
157
- if pred.ndim == 2 and pred.shape[0] == 4 + len(MODEL_CLASS_NAMES):
158
- pred = pred.T
159
-
160
- # Raw output [N, 4 + nc]
161
- if pred.ndim != 2:
162
- raise ValueError(f"Unsupported ONNX output shape: {pred.shape}")
163
-
164
- if pred.shape[1] == 4 + len(MODEL_CLASS_NAMES):
165
- boxes_xywh = pred[:, :4]
166
- class_scores = pred[:, 4:]
167
- elif pred.shape[1] == 5 + len(MODEL_CLASS_NAMES):
168
- # YOLOv5-style: x,y,w,h,obj,cls...
169
- boxes_xywh = pred[:, :4]
170
- objectness = pred[:, 4:5]
171
- class_scores = pred[:, 5:] * objectness
172
  else:
173
- raise ValueError(
174
- f"Unsupported prediction shape {pred.shape}. "
175
- f"Expected second dimension {4 + len(MODEL_CLASS_NAMES)} or {5 + len(MODEL_CLASS_NAMES)}."
176
- )
177
-
178
- class_ids = np.argmax(class_scores, axis=1)
179
- confidences = class_scores[np.arange(class_scores.shape[0]), class_ids]
180
-
181
- keep = confidences >= conf_threshold
182
-
183
- boxes_xywh = boxes_xywh[keep]
184
- confidences = confidences[keep]
185
- class_ids = class_ids[keep]
186
-
187
- detections = []
188
-
189
- original_h, original_w = original_shape
190
-
191
- for xywh, confidence, class_id in zip(boxes_xywh, confidences, class_ids):
192
- class_id = int(class_id)
193
- confidence = float(confidence)
194
 
195
- if class_id < 0 or class_id >= len(CLASS_CONF_LIST):
196
- continue
 
 
197
 
198
- if confidence < CLASS_CONF_LIST[class_id]:
199
- continue
200
-
201
- cx, cy, w, h = xywh
202
-
203
- x1 = cx - w / 2.0
204
- y1 = cy - h / 2.0
205
- x2 = cx + w / 2.0
206
- y2 = cy + h / 2.0
207
-
208
- # Undo letterbox padding and scale.
209
- x1 = (x1 - pad_left) / scale
210
- x2 = (x2 - pad_left) / scale
211
- y1 = (y1 - pad_top) / scale
212
- y2 = (y2 - pad_top) / scale
213
-
214
- x1 = np.clip(x1, 0, original_w - 1)
215
- x2 = np.clip(x2, 0, original_w - 1)
216
- y1 = np.clip(y1, 0, original_h - 1)
217
- y2 = np.clip(y2, 0, original_h - 1)
218
-
219
- detections.append(
220
- {
221
- "x1": float(x1),
222
- "y1": float(y1),
223
- "x2": float(x2),
224
- "y2": float(y2),
225
- "cls_id": class_id,
226
- "conf": confidence,
227
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  )
 
 
229
 
230
- detections = nms_same_class(
231
- detections=detections,
232
- iou_threshold=iou_threshold,
 
233
  )
234
 
235
- return detections
236
-
237
- def parse_nms_output(
238
- self,
239
- pred: np.ndarray,
240
- original_shape: tuple[int, int],
241
- scale: float,
242
- pad_left: int,
243
- pad_top: int,
244
- conf_threshold: float,
245
- ) -> list[dict]:
246
- detections = []
247
- original_h, original_w = original_shape
248
-
249
- for row in pred:
250
- x1, y1, x2, y2, confidence, class_id = row.tolist()
251
-
252
- class_id = int(class_id)
253
- confidence = float(confidence)
254
-
255
- if confidence < conf_threshold:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  continue
257
-
258
- if class_id < 0 or class_id >= len(CLASS_CONF_LIST):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  continue
260
-
261
- if confidence < CLASS_CONF_LIST[class_id]:
262
  continue
263
-
264
- x1 = (x1 - pad_left) / scale
265
- x2 = (x2 - pad_left) / scale
266
- y1 = (y1 - pad_top) / scale
267
- y2 = (y2 - pad_top) / scale
268
-
269
- x1 = np.clip(x1, 0, original_w - 1)
270
- x2 = np.clip(x2, 0, original_w - 1)
271
- y1 = np.clip(y1, 0, original_h - 1)
272
- y2 = np.clip(y2, 0, original_h - 1)
273
-
274
- detections.append(
275
- {
276
- "x1": float(x1),
277
- "y1": float(y1),
278
- "x2": float(x2),
279
- "y2": float(y2),
280
- "cls_id": class_id,
281
- "conf": confidence,
282
- }
283
  )
284
-
285
- return detections
286
-
287
- def predict(
288
- self,
289
- image: np.ndarray,
290
- confidence_threshold: float = 0.1,
291
- iou_threshold: float = 0.7,
292
- ) -> list[dict]:
293
- original_h, original_w = image.shape[:2]
294
-
295
- x, scale, pad_left, pad_top = self.preprocess(image)
296
-
297
- outputs = self.session.run(
298
- self.output_names,
299
- {self.input_name: x},
300
- )
301
-
302
- return self.postprocess_raw_yolo(
303
- output=outputs[0],
304
- original_shape=(original_h, original_w),
305
- scale=scale,
306
- pad_left=pad_left,
307
- pad_top=pad_top,
308
- conf_threshold=confidence_threshold,
309
- iou_threshold=iou_threshold,
310
- )
311
-
312
- # =========================
313
- # BOX UTILS
314
- # =========================
315
-
316
- def calculate_iou(box1: dict, box2: dict) -> float:
317
- x1 = max(box1["x1"], box2["x1"])
318
- y1 = max(box1["y1"], box2["y1"])
319
- x2 = min(box1["x2"], box2["x2"])
320
- y2 = min(box1["y2"], box2["y2"])
321
-
322
- iw = max(0.0, x2 - x1)
323
- ih = max(0.0, y2 - y1)
324
- inter = iw * ih
325
-
326
- a1 = max(0.0, box1["x2"] - box1["x1"]) * max(0.0, box1["y2"] - box1["y1"])
327
- a2 = max(0.0, box2["x2"] - box2["x1"]) * max(0.0, box2["y2"] - box2["y1"])
328
-
329
- union = a1 + a2 - inter
330
- if union <= 0:
331
- return 0.0
332
-
333
- return inter / union
334
-
335
-
336
- def nms_same_class(
337
- detections: list[dict],
338
- iou_threshold: float = 0.7,
339
- ) -> list[dict]:
340
- if not detections:
341
- return []
342
-
343
- final = []
344
-
345
- class_ids = sorted({box["cls_id"] for box in detections})
346
-
347
- for class_id in class_ids:
348
- boxes = [box for box in detections if box["cls_id"] == class_id]
349
- boxes.sort(key=lambda box: box["conf"], reverse=True)
350
-
351
- while boxes:
352
- best = boxes.pop(0)
353
- final.append(best)
354
-
355
- boxes = [
356
- box
357
- for box in boxes
358
- if calculate_iou(best, box) < iou_threshold
359
- ]
360
-
361
- final.sort(key=lambda box: box["conf"], reverse=True)
362
- return final
363
-
364
-
365
- def iou_xyxy_to_many(box: np.ndarray, boxes: np.ndarray) -> np.ndarray:
366
- x1 = np.maximum(box[0], boxes[:, 0])
367
- y1 = np.maximum(box[1], boxes[:, 1])
368
- x2 = np.minimum(box[2], boxes[:, 2])
369
- y2 = np.minimum(box[3], boxes[:, 3])
370
-
371
- inter_w = np.maximum(0.0, x2 - x1)
372
- inter_h = np.maximum(0.0, y2 - y1)
373
- inter_area = inter_w * inter_h
374
-
375
- box_area = max(0.0, box[2] - box[0]) * max(0.0, box[3] - box[1])
376
-
377
- boxes_area = np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) * np.maximum(
378
- 0.0,
379
- boxes[:, 3] - boxes[:, 1],
380
- )
381
-
382
- union = box_area + boxes_area - inter_area
383
- return inter_area / np.maximum(union, 1e-6)
384
-
385
-
386
- def fuse_cluster_numpy(cluster: np.ndarray) -> dict:
387
- weights = np.maximum(cluster[:, 4], 1e-6)
388
-
389
- fused_xyxy = np.average(
390
- cluster[:, :4],
391
- axis=0,
392
- weights=weights,
393
- )
394
-
395
- best_idx = int(np.argmax(cluster[:, 4]))
396
- best_box = cluster[best_idx]
397
-
398
- return {
399
- "x1": int(round(float(fused_xyxy[0]))),
400
- "y1": int(round(float(fused_xyxy[1]))),
401
- "x2": int(round(float(fused_xyxy[2]))),
402
- "y2": int(round(float(fused_xyxy[3]))),
403
- "cls_id": int(best_box[5]),
404
- "conf": float(np.max(cluster[:, 4])),
405
- "merged_count": int(len(cluster)),
406
- }
407
-
408
-
409
- def merge_same_class_boxes(
410
- detections: list[dict],
411
- iou_threshold: float = 0.45,
412
- ) -> list[dict]:
413
- if not detections:
414
- return []
415
-
416
- boxes_array = np.array(
417
- [
418
- [
419
- box["x1"],
420
- box["y1"],
421
- box["x2"],
422
- box["y2"],
423
- box["conf"],
424
- box["cls_id"],
425
- ]
426
- for box in detections
427
- ],
428
- dtype=np.float32,
429
- )
430
-
431
- merged_detections = []
432
-
433
- class_ids = np.unique(boxes_array[:, 5].astype(np.int32))
434
-
435
- for class_id in class_ids:
436
- class_boxes = boxes_array[boxes_array[:, 5] == class_id]
437
-
438
- order = np.argsort(-class_boxes[:, 4])
439
- class_boxes = class_boxes[order]
440
-
441
- alive = np.ones(len(class_boxes), dtype=bool)
442
-
443
- while np.any(alive):
444
- seed_idx = int(np.flatnonzero(alive)[0])
445
-
446
- cluster_indices = [seed_idx]
447
- alive[seed_idx] = False
448
-
449
- changed = True
450
-
451
- while changed and np.any(alive):
452
- changed = False
453
-
454
- cluster = class_boxes[cluster_indices]
455
- weights = np.maximum(cluster[:, 4], 1e-6)
456
-
457
- current_fused_xyxy = np.average(
458
- cluster[:, :4],
459
- axis=0,
460
- weights=weights,
461
- )
462
-
463
- candidate_indices = np.flatnonzero(alive)
464
- candidate_boxes = class_boxes[candidate_indices, :4]
465
-
466
- ious = iou_xyxy_to_many(
467
- current_fused_xyxy,
468
- candidate_boxes,
469
  )
 
 
470
 
471
- matched_indices = candidate_indices[ious >= iou_threshold]
472
-
473
- if len(matched_indices) > 0:
474
- cluster_indices.extend(matched_indices.tolist())
475
- alive[matched_indices] = False
476
- changed = True
477
-
478
- final_cluster = class_boxes[cluster_indices]
479
- merged_detections.append(fuse_cluster_numpy(final_cluster))
480
-
481
- merged_detections.sort(
482
- key=lambda box: box["conf"],
483
- reverse=True,
484
- )
485
-
486
- return merged_detections
487
-
488
-
489
- # =========================
490
- # INFERENCE
491
- # =========================
492
-
493
- def reference_model(
494
- image: np.ndarray,
495
- model: ONNXYOLO,
496
- confidence_threshold: float = 0.1,
497
- ) -> list[dict]:
498
- detections = model.predict(
499
- image=image,
500
- confidence_threshold=confidence_threshold,
501
- iou_threshold=0.7,
502
- )
503
-
504
- return detections
505
-
506
-
507
- class BoundingBox(BaseModel):
508
- x1: int
509
- y1: int
510
- x2: int
511
- y2: int
512
- cls_id: int
513
- conf: float
514
-
515
-
516
- class Polygon(BaseModel):
517
- cls_id: int
518
- conf: float
519
- points: list[tuple[int, int]]
520
-
521
-
522
- class TVFrameResult(BaseModel):
523
- frame_id: int
524
- boxes: list[BoundingBox] | None = None
525
- polygons: list[Polygon] | None = None
526
- keypoints: list[tuple[int, int]] | None = None
527
-
528
-
529
- class Miner:
530
- """
531
- This class is responsible for:
532
- - Loading ML models.
533
- - Running batched predictions on images.
534
- - Parsing ML model outputs into structured results (TVFrameResult).
535
-
536
- This class can be modified, but it must have the following to be compatible with the chute:
537
- - be named `Miner`
538
- - have a `predict_batch` function with the inputs and outputs specified
539
- - be stored in a file called `miner.py` which lives in the root of the HFHub repo
540
- """
541
-
542
- def __init__(self, path_hf_repo: Path) -> None:
543
- """
544
- Loads all ML models from the repository.
545
- -----(Adjust as needed)----
546
-
547
- Args:
548
- path_hf_repo (Path):
549
- Path to the downloaded HuggingFace Hub repository
550
-
551
- Returns:
552
- None
553
- """
554
- self.model = ONNXYOLO(path_hf_repo / "weights.onnx", providers=["CPUExecutionProvider"])
555
- print(f"✅ Model Loaded")
556
-
557
- self._class_id_map = np.fromiter(
558
- map(SUBNET_CLASS_NAMES.index, MODEL_CLASS_NAMES), dtype=np.int32,
559
- count=len(MODEL_CLASS_NAMES),
560
- )
561
 
562
- def __repr__(self) -> str:
563
- """
564
- Information about miner returned in the health endpoint
565
- to inspect the loaded ML models (and their types)
566
- -----(Adjust as needed)----
567
- """
568
- return f"Model: {type(self.model).__name__}"
569
-
570
- def predict_crime(self, image: np.ndarray, model: ONNXYOLO, confidence_threshold: float = 0.1) -> list[dict]:
571
- detections = reference_model(
572
- image=image,
573
- model=model,
574
- confidence_threshold=confidence_threshold,
 
 
 
 
 
 
 
 
575
  )
576
-
577
- merged_boxes = merge_same_class_boxes(
578
- detections,
579
- iou_threshold=0.65,
 
 
 
 
 
 
 
 
580
  )
581
 
582
- return merged_boxes
583
-
584
- def predict_batch(
585
- self,
586
- batch_images: list[ndarray],
587
- offset: int,
588
- n_keypoints: int,
589
- ) -> list[TVFrameResult]:
590
- """
591
- Miner prediction for a batch of images.
592
- Handles the orchestration of ML models and any preprocessing and postprocessing
593
- -----(Adjust as needed)----
594
-
595
- Args:
596
- batch_images (list[np.ndarray]):
597
- A list of images (as NumPy arrays) to process in this batch.
598
- offset (int):
599
- The frame number corresponding to the first image in the batch.
600
- Used to correctly index frames in the output results.
601
- n_keypoints (int):
602
- The number of keypoints expected for each frame in this challenge type.
603
-
604
- Returns:
605
- list[TVFrameResult]:
606
- A list of predictions for each image in the batch
607
- """
608
-
609
- bboxes: dict[int, list[BoundingBox]] = {}
610
-
611
- for frame_number_in_batch, image in enumerate(batch_images):
612
- merged_boxes = self.predict_crime(
613
- image=image,
614
- model=self.model,
615
- confidence_threshold=0.1,
616
  )
617
 
618
- boxes = []
619
- for box in merged_boxes:
620
- x1, y1, x2, y2, model_cls_id, model_conf, _ = box.values()
621
- bossted_conf = min(model_conf + BONUS_LIST[model_cls_id], 0.99)
622
-
623
- sn_cls_id = self._class_id_map[int(model_cls_id)]
624
-
625
- boxes.append(
626
- BoundingBox(
627
- x1=int(x1),
628
- y1=int(y1),
629
- x2=int(x2),
630
- y2=int(y2),
631
- cls_id=int(sn_cls_id),
632
- conf=float(bossted_conf),
633
- )
634
- )
635
- bboxes[offset + frame_number_in_batch] = boxes
636
- print("✅ BBoxes predicted")
637
 
 
 
638
  results: list[TVFrameResult] = []
639
- for frame_number in range(offset, offset + len(batch_images)):
 
 
 
 
 
 
640
  results.append(
641
  TVFrameResult(
642
- frame_id=frame_number,
643
- boxes=bboxes.get(frame_number, []),
 
644
  )
645
  )
646
- print("✅ Combined results as TVFrameResult")
647
- 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
+ """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.58, 0.22, 0.25, 0.23, 0.22], dtype=np.float32,
54
+ )
55
+ _bonus_array = np.array(
56
+ [0.18, 0.25, 0.12, 0.09, 0.07, 0.10], dtype=np.float32,
57
+ )
58
 
59
+ def __init__(self, path_hf_repo: Path) -> None:
60
+ model_path = path_hf_repo / "weights-all.onnx"
61
+ print("ORT version:", ort.__version__)
62
+
63
+ try:
64
+ ort.preload_dlls()
65
+ print("preload_dlls success")
66
+ except Exception as e:
67
+ print(f"preload_dlls failed: {e}")
68
+
69
+ print("ORT available providers BEFORE session:", ort.get_available_providers())
70
+
71
+ sess_options = ort.SessionOptions()
72
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
73
+ # Pin threads for the 2vCPU/4GB public-track latency gate (p95 <= 100ms).
74
+ sess_options.intra_op_num_threads = 2
75
+ sess_options.inter_op_num_threads = 1
76
+ sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
77
+
78
+ try:
79
+ self.session = ort.InferenceSession(
80
+ str(model_path),
81
+ sess_options=sess_options,
82
+ providers=["CPUExecutionProvider"],
83
+ )
84
+ except Exception as e:
85
+ print(f"CUDA session creation failed, falling back to CPU: {e}")
86
+ self.session = ort.InferenceSession(
87
+ str(model_path),
88
+ sess_options=sess_options,
89
+ providers=["CPUExecutionProvider"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  )
91
 
92
+ print("ORT session providers:", self.session.get_providers())
93
+
94
+ # Build cls_remap: for each model-emit index i,
95
+ # cls_remap[i] = self.class_names.index(model_class_order[i])
96
+ # i.e. convert a model-side class id into the output class id that
97
+ # downstream code (BoundingBox.cls_id, the per-class threshold/bonus
98
+ # arrays) expects. The model-side order comes from the ONNX metadata
99
+ # when available, else falls back to the static model_class_names.
100
+ model_class_order = self._read_model_class_order()
101
+ if model_class_order is None:
102
+ model_class_order = list(self.model_class_names)
103
+ print(f"cls order: no usable ONNX metadata, FALLBACK {model_class_order}")
 
 
 
 
104
  else:
105
+ print(f"cls order: from ONNX metadata {model_class_order}")
106
+ self.cls_remap = np.array(
107
+ [self.class_names.index(n) for n in model_class_order],
108
+ dtype=np.int32,
109
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
+ for inp in self.session.get_inputs():
112
+ print("INPUT:", inp.name, inp.shape, inp.type)
113
+ for out in self.session.get_outputs():
114
+ print("OUTPUT:", out.name, out.shape, out.type)
115
 
116
+ self.input_name = self.session.get_inputs()[0].name
117
+ self.output_names = [output.name for output in self.session.get_outputs()]
118
+ self.input_shape = self.session.get_inputs()[0].shape
119
+
120
+ self.input_height = self._safe_dim(self.input_shape[2], default=self.input_size)
121
+ self.input_width = self._safe_dim(self.input_shape[3], default=self.input_size)
122
+
123
+ print(f"ONNX model loaded from: {model_path}")
124
+ print(f"ONNX providers: {self.session.get_providers()}")
125
+ print(f"ONNX input: name={self.input_name}, shape={self.input_shape}")
126
+ print(f"ONNX input size: {self.input_width}x{self.input_height}, use_tta={self.use_tta}")
127
+ print("per-class conf: " + ", ".join(
128
+ f"{n}={t:.3f}" for n, t in zip(self.class_names,
129
+ self._conf_thres_array.tolist())))
130
+
131
+ self._warmup()
132
+
133
+ def _warmup(self, iters: int = 3) -> None:
134
+ try:
135
+ dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
136
+ for _ in range(max(1, iters)):
137
+ self.predict_batch(batch_images=[dummy], offset=0, n_keypoints=0)
138
+ print(f"warmup: {iters} dummy predict_batch call(s) done")
139
+ except Exception as e:
140
+ print(f"warmup skipped: {e}")
141
+
142
+ def _read_model_class_order(self) -> "list[str] | None":
143
+ """Read the model's class order from Ultralytics ONNX metadata.
144
+ Returns the class names ordered by model-emit index, or None when the
145
+ metadata is missing/unparsable or doesn't match `class_names` as a set
146
+ (in which case the static model_class_names fallback is used)."""
147
+ try:
148
+ import ast
149
+
150
+ meta = self.session.get_modelmeta().custom_metadata_map
151
+ names = ast.literal_eval(meta["names"]) # e.g. {0: 'balaclava', ...}
152
+ if isinstance(names, dict):
153
+ order = [str(names[i]) for i in sorted(names)]
154
+ else:
155
+ order = [str(n) for n in names]
156
+ except Exception as e:
157
+ print(f"cls order: could not read ONNX names metadata ({e})")
158
+ return None
159
+ if sorted(order) != sorted(self.class_names):
160
+ print(
161
+ f"cls order: ONNX names {order} do not match expected classes "
162
+ f"{self.class_names}; ignoring metadata"
163
  )
164
+ return None
165
+ return order
166
 
167
+ def __repr__(self) -> str:
168
+ return (
169
+ f"ONNXRuntime(session={type(self.session).__name__}, "
170
+ f"providers={self.session.get_providers()})"
171
  )
172
 
173
+ @staticmethod
174
+ def _safe_dim(value, default: int) -> int:
175
+ return value if isinstance(value, int) and value > 0 else default
176
+
177
+ def _letterbox(self, image: ndarray, new_shape: tuple[int, int],
178
+ color=(114, 114, 114)
179
+ ) -> tuple[ndarray, float, tuple[float, float]]:
180
+ h, w = image.shape[:2]
181
+ new_w, new_h = new_shape
182
+ ratio = min(new_w / w, new_h / h)
183
+ resized_w = int(round(w * ratio))
184
+ resized_h = int(round(h * ratio))
185
+ if (resized_w, resized_h) != (w, h):
186
+ interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
187
+ image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
188
+ dw = (new_w - resized_w) / 2.0
189
+ dh = (new_h - resized_h) / 2.0
190
+ left = int(round(dw - 0.1))
191
+ right = int(round(dw + 0.1))
192
+ top = int(round(dh - 0.1))
193
+ bottom = int(round(dh + 0.1))
194
+ padded = cv2.copyMakeBorder(image, top, bottom, left, right,
195
+ borderType=cv2.BORDER_CONSTANT, value=color)
196
+ return padded, ratio, (dw, dh)
197
+
198
+ def _preprocess(self, image: ndarray
199
+ ) -> tuple[np.ndarray, float, tuple[float, float],
200
+ tuple[int, int]]:
201
+ orig_h, orig_w = image.shape[:2]
202
+ img, ratio, pad = self._letterbox(image, (self.input_width, self.input_height))
203
+ # Fused scale(1/255) + BGR->RGB swap + HWC->NCHW + contiguous float32 in
204
+ # one optimized OpenCV call (bit-identical to the cvtColor + astype/255 +
205
+ # transpose chain, but ~half the preprocess time).
206
+ blob = cv2.dnn.blobFromImage(img, scalefactor=1.0 / 255.0, swapRB=True)
207
+ return blob, ratio, pad, (orig_w, orig_h)
208
+
209
+ @staticmethod
210
+ def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
211
+ w, h = image_size
212
+ boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
213
+ boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
214
+ boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
215
+ boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
216
+ return boxes
217
+
218
+ @staticmethod
219
+ def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
220
+ out = np.empty_like(boxes)
221
+ out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
222
+ out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
223
+ out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
224
+ out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
225
+ return out
226
+
227
+ @staticmethod
228
+ def _hard_nms(boxes: np.ndarray, scores: np.ndarray,
229
+ iou_thresh: float) -> np.ndarray:
230
+ n = len(boxes)
231
+ if n == 0:
232
+ return np.array([], dtype=np.intp)
233
+ order = np.argsort(-scores)
234
+ keep: list[int] = []
235
+ while len(order) > 0:
236
+ i = int(order[0])
237
+ keep.append(i)
238
+ if len(order) == 1:
239
+ break
240
+ rest = order[1:]
241
+ xx1 = np.maximum(boxes[i, 0], boxes[rest, 0])
242
+ yy1 = np.maximum(boxes[i, 1], boxes[rest, 1])
243
+ xx2 = np.minimum(boxes[i, 2], boxes[rest, 2])
244
+ yy2 = np.minimum(boxes[i, 3], boxes[rest, 3])
245
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
246
+ a_i = (max(0.0, boxes[i, 2] - boxes[i, 0]) *
247
+ max(0.0, boxes[i, 3] - boxes[i, 1]))
248
+ a_r = (np.maximum(0.0, boxes[rest, 2] - boxes[rest, 0]) *
249
+ np.maximum(0.0, boxes[rest, 3] - boxes[rest, 1]))
250
+ iou = inter / (a_i + a_r - inter + 1e-7)
251
+ order = rest[iou <= iou_thresh]
252
+ return np.array(keep, dtype=np.intp)
253
+
254
+ def _per_class_hard_nms(self, boxes: np.ndarray, scores: np.ndarray,
255
+ cls_ids: np.ndarray, iou_thresh: float
256
+ ) -> np.ndarray:
257
+ if len(boxes) == 0:
258
+ return np.array([], dtype=np.intp)
259
+ all_keep: list[int] = []
260
+ for c in np.unique(cls_ids):
261
+ mask = cls_ids == c
262
+ indices = np.where(mask)[0]
263
+ keep = self._hard_nms(boxes[mask], scores[mask], iou_thresh)
264
+ all_keep.extend(indices[keep].tolist())
265
+ all_keep.sort()
266
+ return np.array(all_keep, dtype=np.intp)
267
+
268
+ def _cross_class_dedup_op(self, boxes: np.ndarray, scores: np.ndarray,
269
+ cls_ids: np.ndarray, iou_thresh: float
270
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
271
+ n = len(boxes)
272
+ if n <= 1:
273
+ return boxes, scores, cls_ids
274
+ boxes = np.asarray(boxes, dtype=np.float32)
275
+ scores = np.asarray(scores, dtype=np.float32)
276
+ cls_ids = np.asarray(cls_ids, dtype=np.int32)
277
+ areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *
278
+ np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))
279
+ margins = scores - self._conf_thres_array[cls_ids]
280
+ order = np.lexsort((-areas, -margins))
281
+ suppressed = np.zeros(n, dtype=bool)
282
+ keep: list[int] = []
283
+ for i in order:
284
+ if suppressed[i]:
285
  continue
286
+ keep.append(int(i))
287
+ bi = boxes[i]
288
+ xx1 = np.maximum(bi[0], boxes[:, 0])
289
+ yy1 = np.maximum(bi[1], boxes[:, 1])
290
+ xx2 = np.minimum(bi[2], boxes[:, 2])
291
+ yy2 = np.minimum(bi[3], boxes[:, 3])
292
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
293
+ a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
294
+ iou = inter / (a_i + areas - inter + 1e-7)
295
+ dup = iou > iou_thresh
296
+ dup[i] = False
297
+ suppressed |= dup
298
+ keep_idx = np.array(keep, dtype=np.intp)
299
+ return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]
300
+
301
+ @staticmethod
302
+ def _max_score_per_cluster(post_boxes: np.ndarray,
303
+ post_cls: np.ndarray,
304
+ full_boxes: np.ndarray,
305
+ full_scores: np.ndarray,
306
+ full_cls: np.ndarray,
307
+ iou_thresh: float) -> np.ndarray:
308
+ n = len(post_boxes)
309
+ if n == 0:
310
+ return np.empty(0, dtype=np.float32)
311
+ full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
312
+ np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
313
+ out = np.empty(n, dtype=np.float32)
314
+ for i in range(n):
315
+ bi = post_boxes[i]
316
+ xx1 = np.maximum(bi[0], full_boxes[:, 0])
317
+ yy1 = np.maximum(bi[1], full_boxes[:, 1])
318
+ xx2 = np.minimum(bi[2], full_boxes[:, 2])
319
+ yy2 = np.minimum(bi[3], full_boxes[:, 3])
320
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
321
+ a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
322
+ iou = inter / (a_i + full_areas - inter + 1e-7)
323
+ cluster = (iou >= iou_thresh) & (full_cls == post_cls[i])
324
+ out[i] = float(np.max(full_scores[cluster])) if np.any(cluster) else 0.0
325
+ return out
326
+
327
+ def _conf_filter_mask(self, scores: np.ndarray,
328
+ cls_ids: np.ndarray) -> np.ndarray:
329
+ """Boolean keep-mask: score >= per-class threshold, with a per-class
330
+ rescue — if a class has zero boxes passing, admit its top-1 candidate
331
+ when its score >= (per-class threshold - per-class bonus)."""
332
+ if len(scores) == 0:
333
+ return np.zeros(0, dtype=bool)
334
+ thr = self._conf_thres_array[cls_ids]
335
+ keep = scores >= thr
336
+ for c in np.unique(cls_ids):
337
+ b = float(self._bonus_array[c])
338
+ if b <= 0.0:
339
  continue
340
+ cm = cls_ids == c
341
+ if keep[cm].any():
342
  continue
343
+ idx = np.where(cm)[0]
344
+ top = int(idx[int(np.argmax(scores[idx]))])
345
+ if scores[top] >= self._conf_thres_array[c] - b:
346
+ keep[top] = True
347
+ return keep
348
+
349
+ def _per_view_pipeline(self, boxes: np.ndarray, scores: np.ndarray,
350
+ cls_ids: np.ndarray
351
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
352
+ if len(boxes) > 1:
353
+ keep = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
354
+ boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
355
+ if len(scores) > self.max_det:
356
+ top = np.argsort(-scores)[: self.max_det]
357
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
358
+ if len(boxes) > 1:
359
+ boxes, scores, cls_ids = self._cross_class_dedup_op(
360
+ boxes, scores, cls_ids, self.cross_iou_thresh
 
 
361
  )
362
+ return boxes, scores, cls_ids
363
+
364
+ def _decode_final_dets(self, preds: np.ndarray, ratio: float,
365
+ pad: tuple[float, float],
366
+ orig_size: tuple[int, int]) -> list[BoundingBox]:
367
+ if preds.ndim == 3 and preds.shape[0] == 1:
368
+ preds = preds[0]
369
+ if preds.ndim != 2 or preds.shape[1] < 6:
370
+ raise ValueError(f"Unexpected ONNX final-det output shape: {preds.shape}")
371
+
372
+ boxes = preds[:, :4].astype(np.float32)
373
+ scores = preds[:, 4].astype(np.float32)
374
+ cls_ids = preds[:, 5].astype(np.int32)
375
+
376
+ # Remap model cls_ids -> output cls_ids BEFORE the conf filter, so the
377
+ # per-class threshold/bonus arrays (indexed in `class_names` order) are
378
+ # applied to the right class.
379
+ n_model_cls = len(self.model_class_names)
380
+ vmask = (cls_ids >= 0) & (cls_ids < n_model_cls)
381
+ boxes, scores, cls_ids = boxes[vmask], scores[vmask], cls_ids[vmask]
382
+ cls_ids = self.cls_remap[cls_ids]
383
+
384
+ keep = self._conf_filter_mask(scores, cls_ids)
385
+ boxes = boxes[keep]
386
+ scores = scores[keep]
387
+ cls_ids = cls_ids[keep]
388
+ if len(boxes) == 0:
389
+ return []
390
+
391
+ pad_w, pad_h = pad
392
+ boxes[:, [0, 2]] -= pad_w
393
+ boxes[:, [1, 3]] -= pad_h
394
+ boxes /= ratio
395
+ boxes = self._clip_boxes(boxes, orig_size)
396
+
397
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
398
+ return self._build_results(boxes, scores, cls_ids)
399
+
400
+ def _decode_raw_yolo(self, preds: np.ndarray, ratio: float,
401
+ pad: tuple[float, float],
402
+ orig_size: tuple[int, int]) -> list[BoundingBox]:
403
+ if preds.ndim != 3 or preds.shape[0] != 1:
404
+ raise ValueError(f"Unexpected raw ONNX output shape: {preds.shape}")
405
+ preds = preds[0]
406
+ if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
407
+ preds = preds.T
408
+ if preds.ndim != 2 or preds.shape[1] < 5:
409
+ raise ValueError(f"Unexpected raw output shape: {preds.shape}")
410
+
411
+ boxes_xywh = preds[:, :4].astype(np.float32)
412
+ cls_part = preds[:, 4:].astype(np.float32)
413
+ if cls_part.shape[1] == 1:
414
+ scores = cls_part[:, 0]
415
+ cls_ids = np.zeros(len(scores), dtype=np.int32)
416
+ else:
417
+ cls_ids = np.argmax(cls_part, axis=1).astype(np.int32)
418
+ scores = cls_part[np.arange(len(cls_part)), cls_ids]
419
+
420
+ # Remap model cls_ids -> output cls_ids BEFORE the conf filter, so the
421
+ # per-class threshold/bonus arrays (indexed in `class_names` order) are
422
+ # applied to the right class.
423
+ n_model_cls = len(self.model_class_names)
424
+ vmask = (cls_ids >= 0) & (cls_ids < n_model_cls)
425
+ boxes_xywh, scores, cls_ids = boxes_xywh[vmask], scores[vmask], cls_ids[vmask]
426
+ cls_ids = self.cls_remap[cls_ids]
427
+
428
+ keep = self._conf_filter_mask(scores, cls_ids)
429
+ boxes_xywh = boxes_xywh[keep]
430
+ scores = scores[keep]
431
+ cls_ids = cls_ids[keep]
432
+ if len(boxes_xywh) == 0:
433
+ return []
434
+ boxes = self._xywh_to_xyxy(boxes_xywh)
435
+
436
+ pad_w, pad_h = pad
437
+ boxes[:, [0, 2]] -= pad_w
438
+ boxes[:, [1, 3]] -= pad_h
439
+ boxes /= ratio
440
+ boxes = self._clip_boxes(boxes, orig_size)
441
+
442
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
443
+ return self._build_results(boxes, scores, cls_ids)
444
+
445
+ @staticmethod
446
+ def _build_results(boxes: np.ndarray, scores: np.ndarray,
447
+ cls_ids: np.ndarray) -> list[BoundingBox]:
448
+ results: list[BoundingBox] = []
449
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
450
+ x1, y1, x2, y2 = box.tolist()
451
+ if x2 <= x1 or y2 <= y1:
452
+ continue
453
+ results.append(
454
+ BoundingBox(
455
+ x1=int(math.floor(x1)),
456
+ y1=int(math.floor(y1)),
457
+ x2=int(math.ceil(x2)),
458
+ y2=int(math.ceil(y2)),
459
+ cls_id=int(cls_id),
460
+ conf=float(conf),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  )
462
+ )
463
+ return results
464
 
465
+ def _postprocess(self, output: np.ndarray, ratio: float,
466
+ pad: tuple[float, float],
467
+ orig_size: tuple[int, int]) -> list[BoundingBox]:
468
+ if output.ndim == 2 and output.shape[1] >= 6:
469
+ return self._decode_final_dets(output, ratio, pad, orig_size)
470
+ if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
471
+ return self._decode_final_dets(output, ratio, pad, orig_size)
472
+ return self._decode_raw_yolo(output, ratio, pad, orig_size)
473
+
474
+ def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
475
+ if image is None:
476
+ raise ValueError("Input image is None")
477
+ if not isinstance(image, np.ndarray):
478
+ raise TypeError(f"Input is not numpy array: {type(image)}")
479
+ if image.ndim != 3:
480
+ raise ValueError(f"Expected HWC image, got shape={image.shape}")
481
+ if image.shape[2] != 3:
482
+ raise ValueError(f"Expected 3 channels, got shape={image.shape}")
483
+ if image.dtype != np.uint8:
484
+ image = image.astype(np.uint8)
485
+
486
+ input_tensor, ratio, pad, orig_size = self._preprocess(image)
487
+ expected = (1, 3, self.input_height, self.input_width)
488
+ if input_tensor.shape != expected:
489
+ raise ValueError(
490
+ f"Bad input tensor shape={input_tensor.shape}, expected={expected}"
491
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
 
493
+ outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
494
+ return self._postprocess(outputs[0], ratio, pad, orig_size)
495
+
496
+ def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
497
+ boxes_orig = self._predict_single(image)
498
+ flipped = cv2.flip(image, 1)
499
+ boxes_flip = self._predict_single(flipped)
500
+ w = image.shape[1]
501
+ boxes_flip = [
502
+ BoundingBox(
503
+ x1=w - b.x2, y1=b.y1, x2=w - b.x1, y2=b.y2,
504
+ cls_id=b.cls_id, conf=b.conf,
505
+ )
506
+ for b in boxes_flip
507
+ ]
508
+ all_boxes = boxes_orig + boxes_flip
509
+ if not all_boxes:
510
+ return []
511
+
512
+ coords = np.array(
513
+ [[b.x1, b.y1, b.x2, b.y2] for b in all_boxes], dtype=np.float32
514
  )
515
+ scores = np.array([b.conf for b in all_boxes], dtype=np.float32)
516
+ cls_ids = np.array([b.cls_id for b in all_boxes], dtype=np.int32)
517
+
518
+ hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
519
+ if len(hard_keep) == 0:
520
+ return []
521
+ if len(hard_keep) > self.max_det:
522
+ top = np.argsort(-scores[hard_keep])[: self.max_det]
523
+ hard_keep = hard_keep[top]
524
+ boosted = self._max_score_per_cluster(
525
+ coords[hard_keep], cls_ids[hard_keep],
526
+ coords, scores, cls_ids, self.iou_thres,
527
  )
528
 
529
+ kept_coords = coords[hard_keep]
530
+ kept_cls = cls_ids[hard_keep]
531
+ if len(kept_coords) > 1:
532
+ kept_coords, boosted, kept_cls = self._cross_class_dedup_op(
533
+ kept_coords, boosted, kept_cls, self.cross_iou_thresh
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
  )
535
 
536
+ return [
537
+ BoundingBox(
538
+ x1=int(math.floor(kept_coords[j, 0])),
539
+ y1=int(math.floor(kept_coords[j, 1])),
540
+ x2=int(math.ceil(kept_coords[j, 2])),
541
+ y2=int(math.ceil(kept_coords[j, 3])),
542
+ cls_id=int(kept_cls[j]),
543
+ conf=float(boosted[j]),
544
+ )
545
+ for j in range(len(kept_coords))
546
+ ]
 
 
 
 
 
 
 
 
547
 
548
+ def predict_batch(self, batch_images: list[ndarray], offset: int,
549
+ n_keypoints: int) -> list[TVFrameResult]:
550
  results: list[TVFrameResult] = []
551
+ predict = self._predict_tta if self.use_tta else self._predict_single
552
+ for frame_number_in_batch, image in enumerate(batch_images):
553
+ try:
554
+ boxes = predict(image)
555
+ except Exception as e:
556
+ print(f"Inference failed for frame {offset + frame_number_in_batch}: {e}")
557
+ boxes = []
558
  results.append(
559
  TVFrameResult(
560
+ frame_id=offset + frame_number_in_batch,
561
+ boxes=boxes,
562
+ keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
563
  )
564
  )
565
+ return results