ArthurSword commited on
Commit
d3f4506
Β·
verified Β·
1 Parent(s): 40046c4

scorevision: push artifact

Browse files
Files changed (1) hide show
  1. miner.py +622 -506
miner.py CHANGED
@@ -1,24 +1,6 @@
1
- """Strict config-driven consumer for Ultralytics end-to-end NMS ONNX.
2
-
3
- The model contract is a static batch-1 input and one fixed ``[1, 300, 6]``
4
- output whose rows are ``x1, y1, x2, y2, confidence, model_class_id``. NMS is
5
- inside the ONNX graph. Python only validates, applies the element confidence
6
- threshold and model-class mapping from the hardcoded element config, unletterboxes, filters
7
- geometrically impossible boxes, sorts, and caps the configured result count.
8
-
9
- This module intentionally uses no ``dataclass`` declarations. ScoreVision's
10
- current ``spec_from_file_location`` loader does not register the dynamic module
11
- in ``sys.modules`` before executing it, which makes Python dataclass decoration
12
- fail during import.
13
- """
14
-
15
- import ast
16
- import json
17
- import math
18
  import os
19
- from collections.abc import Mapping
20
  from pathlib import Path
21
- from typing import Any
22
 
23
  import cv2
24
  import numpy as np
@@ -27,24 +9,30 @@ from numpy import ndarray
27
  from pydantic import BaseModel
28
 
29
 
30
- # The deployment rail currently recognizes this strict-inference contract
31
- # version for every cycle-2 miner. The tensor layout is separately enforced by
32
- # this module's fixed [1, 300, 6] startup check.
33
- API_VERSION = "c2-strict-v1"
34
- STRICT_INFERENCE_ENV = "SV_STRICT_INFERENCE"
35
- INPUT_CHANNELS = 3
36
- OUTPUT_ROWS = 300
37
- OUTPUT_COLUMNS = 6
38
- IMGSZ_MIN, IMGSZ_MAX, IMGSZ_MULTIPLE = 320, 1280, 32
39
- MAX_DET_LIMIT = 300
40
- _ROOT_KEYS = {
41
- "element_id", "classes", "emit_order", "coco_ids", "missing_classes", "imgsz",
42
- "conf", "nms_iou", "max_det",
43
- }
44
-
45
-
46
- class InferenceInvariantError(RuntimeError):
47
- """A model tensor or decoded detection violated the serving contract."""
 
 
 
 
 
 
48
 
49
 
50
  class BoundingBox(BaseModel):
@@ -62,501 +50,629 @@ class TVFrameResult(BaseModel):
62
  keypoints: list[tuple[int, int]]
63
 
64
 
65
- def strict_inference_from_env(environ: Mapping[str, str] | None = None) -> bool:
66
- raw = (os.environ if environ is None else environ).get(STRICT_INFERENCE_ENV, "")
67
- if raw in ("", "0"):
68
- return False
69
- if raw == "1":
70
- return True
71
- raise ValueError(
72
- f"{STRICT_INFERENCE_ENV} must be '1' or unset/'0', got {raw!r}"
73
- )
74
-
75
-
76
- def _strict_int(value: Any, where: str, low: int, high: int) -> int:
77
- if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high:
78
- raise ValueError(f"element config {where} must be an integer in [{low}, {high}], got {value!r}")
79
- return value
80
-
81
-
82
- def _strict_unit(value: Any, where: str) -> float:
83
- if isinstance(value, bool) or not isinstance(value, (int, float)):
84
- raise ValueError(f"element config {where} must be a finite number in (0, 1], got {value!r}")
85
- number = float(value)
86
- if not math.isfinite(number) or not 0.0 < number <= 1.0:
87
- raise ValueError(f"element config {where} must be a finite number in (0, 1], got {value!r}")
88
- return number
89
-
90
-
91
- class ElementConfig:
92
- """Strict immutable-by-convention config without dynamic-loader dataclasses."""
93
-
94
- __slots__ = (
95
- "element_id", "classes", "emit_order", "output_cls_ids", "coco_ids", "emitted_cls_ids",
96
- "missing_classes", "imgsz", "conf", "nms_iou", "max_det",
97
- )
98
-
99
- def __init__(
100
- self,
101
- element_id: str,
102
- classes: tuple[str, ...],
103
- coco_ids: tuple[int, ...],
104
- emitted_cls_ids: tuple[int, ...],
105
- missing_classes: tuple[str, ...],
106
- imgsz: int,
107
- conf: float,
108
- nms_iou: float,
109
- max_det: int,
110
- *,
111
- emit_order: tuple[str, ...] | None = None,
112
- output_cls_ids: tuple[int, ...] = (),
113
- ) -> None:
114
- self.element_id = element_id
115
- self.classes = classes
116
- self.emit_order = emit_order
117
- self.output_cls_ids = output_cls_ids
118
- self.coco_ids = coco_ids
119
- self.emitted_cls_ids = emitted_cls_ids
120
- self.missing_classes = missing_classes
121
- self.imgsz = imgsz
122
- self.conf = conf
123
- self.nms_iou = nms_iou
124
- self.max_det = max_det
125
-
126
- @classmethod
127
- def from_mapping(cls, data: dict[str, Any]) -> "ElementConfig":
128
- required = {"element_id", "classes", "coco_ids", "imgsz", "conf", "nms_iou", "max_det"}
129
- missing = sorted(required - set(data))
130
- if missing:
131
- raise ValueError(f"element config missing required field(s): {', '.join(missing)}")
132
- unknown = sorted(set(data) - _ROOT_KEYS)
133
- if unknown:
134
- raise ValueError(f"element config root has unknown key(s): {', '.join(unknown)}")
135
- element_id = data["element_id"]
136
- classes = data["classes"]
137
- coco_ids = data["coco_ids"]
138
- missing_classes = data.get("missing_classes", [])
139
- if not isinstance(element_id, str) or not element_id:
140
- raise ValueError("element config element_id must be a non-empty string")
141
- if not isinstance(classes, list) or not classes or not all(isinstance(name, str) and name for name in classes):
142
- raise ValueError("element config classes must be a non-empty list of strings")
143
- if len(set(classes)) != len(classes):
144
- raise ValueError("element config classes must not contain duplicates")
145
- emit_order_raw = data.get("emit_order")
146
- if "emit_order" not in data:
147
- emit_order = None
148
- else:
149
- if not isinstance(emit_order_raw, list) or not emit_order_raw \
150
- or not all(isinstance(name, str) and name for name in emit_order_raw):
151
- raise ValueError("element config emit_order must be a non-empty list of class names")
152
- if len(set(emit_order_raw)) != len(emit_order_raw):
153
- raise ValueError("element config emit_order must not contain duplicates")
154
- unknown = sorted(set(emit_order_raw) - set(classes))
155
- omitted = sorted(set(classes) - set(emit_order_raw))
156
- if unknown or omitted or len(emit_order_raw) != len(classes):
157
- raise ValueError(
158
- "element config emit_order must be an exact permutation of classes; "
159
- f"unknown={unknown}, missing={omitted}"
160
- )
161
- emit_order = tuple(emit_order_raw)
162
- output_order = emit_order if emit_order is not None else tuple(classes)
163
- output_cls_ids = tuple(output_order.index(name) for name in classes)
164
- if not isinstance(missing_classes, list) or not all(isinstance(name, str) for name in missing_classes):
165
- raise ValueError("element config missing_classes must be a list of strings")
166
- if len(set(missing_classes)) != len(missing_classes) or any(name not in classes for name in missing_classes):
167
- raise ValueError("element config missing_classes must be unique members of classes")
168
- if not isinstance(coco_ids, list) or not all(
169
- isinstance(class_id, int) and not isinstance(class_id, bool) and class_id >= 0
170
- for class_id in coco_ids
171
- ):
172
- raise ValueError("element config coco_ids must be non-negative integer model class ids")
173
- if len(set(coco_ids)) != len(coco_ids):
174
- raise ValueError("element config coco_ids must not contain duplicates")
175
- emitted_cls_ids = tuple(index for index, name in enumerate(classes) if name not in missing_classes)
176
- if len(coco_ids) != len(emitted_cls_ids):
177
- raise ValueError(
178
- "element config classes/coco_ids length mismatch: "
179
- f"{len(classes)} classes minus {len(missing_classes)} missing classes requires "
180
- f"{len(emitted_cls_ids)} ids, got {len(coco_ids)}"
181
- )
182
- imgsz = _strict_int(data["imgsz"], "imgsz", IMGSZ_MIN, IMGSZ_MAX)
183
- if imgsz % IMGSZ_MULTIPLE:
184
- raise ValueError(f"element config imgsz must be a multiple of {IMGSZ_MULTIPLE}, got {imgsz}")
185
- return cls(
186
- element_id,
187
- tuple(classes),
188
- tuple(coco_ids),
189
- emitted_cls_ids,
190
- tuple(missing_classes),
191
- imgsz,
192
- _strict_unit(data["conf"], "conf"),
193
- _strict_unit(data["nms_iou"], "nms_iou"),
194
- _strict_int(data["max_det"], "max_det", 1, MAX_DET_LIMIT),
195
- emit_order=emit_order,
196
- output_cls_ids=output_cls_ids,
197
- )
198
-
199
-
200
- ELEMENT_CONFIG: dict[str, Any] = {
201
- "element_id": "person",
202
- "classes": ["person"],
203
- "coco_ids": [0],
204
- "imgsz": 640,
205
- "conf": 0.5,
206
- "nms_iou": 0.4,
207
- "max_det": 300,
208
- }
209
-
210
-
211
  class Miner:
212
- api_version = API_VERSION
213
- strict_inference = False
214
- inference_failures = 0
215
- invariant_failures = 0
216
- input_dims_source = "unbound"
217
- input_shape_declared: list[Any] = []
218
- min_side = 3.0
219
- max_aspect_ratio = 10.0
220
- crowd_count = 35
221
- crowd_conf = 0.63
222
-
223
  def __init__(self, path_hf_repo: Path) -> None:
224
- repo = Path(path_hf_repo)
225
- self.config = ElementConfig.from_mapping(ELEMENT_CONFIG)
226
- self.class_names = list(self.config.emit_order or self.config.classes)
227
- self.conf_threshold = self.config.conf
228
- self.nms_iou = self.config.nms_iou # retained for API parity; NMS is in the graph
229
- self.max_det = self.config.max_det
230
- self.strict_inference = strict_inference_from_env()
231
- self.inference_failures = 0
232
- self.invariant_failures = 0
233
-
234
- # The referee grants one physical core (two SMT threads). Let ORT own
235
- # both threads for the convolution graph, but keep OpenCV's short
236
- # resize/blob stages single-threaded: a second OpenCV worker is slower
237
- # and adds tail jitter on two sibling logical CPUs.
238
- cv2.setNumThreads(1)
239
-
240
- options = ort.SessionOptions()
241
- options.intra_op_num_threads = 2
242
- options.inter_op_num_threads = 1
243
- options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
244
- options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
245
- model_path = repo / "weights.onnx"
 
 
 
 
 
246
  try:
 
 
247
  self.session = ort.InferenceSession(
248
- str(model_path), sess_options=options,
 
249
  providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
250
  )
251
- except Exception as exc:
252
- print(f"CUDA session failed, falling back to CPU: {exc}")
 
253
  self.session = ort.InferenceSession(
254
- str(model_path), sess_options=options, providers=["CPUExecutionProvider"]
 
 
255
  )
256
- self.input_name = self.session.get_inputs()[0].name
257
- self.output_names = [output.name for output in self.session.get_outputs()]
258
- self._bind_input_dims(self.session.get_inputs()[0].shape)
259
- self._check_output_shape(self.session.get_outputs()[0].shape)
260
- self.model_names = self._read_model_names()
261
- self._bind_class_map()
262
- self._warmup(3)
263
- print(
264
- f"api_version={self.api_version} element={self.config.element_id} "
265
- f"imgsz={self.config.imgsz} conf={self.conf_threshold} "
266
- f"crowd_count={self.crowd_count} crowd_conf={self.crowd_conf} "
267
- f"providers={self.session.get_providers()} warmups=3"
268
  )
269
 
270
- @staticmethod
271
- def _static_dim(value: Any) -> bool:
272
- return isinstance(value, int) and not isinstance(value, bool) and value > 0
273
-
274
- def _bind_input_dims(self, shape: Any) -> None:
275
- if not isinstance(shape, (list, tuple)) or len(shape) != 4:
276
- raise ValueError(f"model input must be rank-4 NCHW, got {shape!r}")
277
- batch, channels, height, width = shape
278
- self.input_shape_declared = [value if self._static_dim(value) else str(value) for value in shape]
279
- if self._static_dim(batch) and batch != 1:
280
- raise ValueError(f"model input batch must be 1, got {batch}")
281
- if self._static_dim(channels) and channels != INPUT_CHANNELS:
282
- raise ValueError(f"model input channels must be 3, got {channels}")
283
- if not (self._static_dim(height) and self._static_dim(width)):
284
- raise ValueError(f"e2e shipping export must have static spatial dims, got {height}x{width}")
285
- if (height, width) != (self.config.imgsz, self.config.imgsz):
286
- raise ValueError(
287
- f"model input spatial dims {height}x{width} != config imgsz "
288
- f"{self.config.imgsz}x{self.config.imgsz}"
289
- )
290
- self.input_height = self.input_width = self.config.imgsz
291
- self.input_dims_source = "static"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
 
293
  @staticmethod
294
- def _check_output_shape(shape: Any) -> None:
295
- if list(shape) != [1, OUTPUT_ROWS, OUTPUT_COLUMNS]:
296
- raise ValueError(
297
- f"e2e model output must be exactly [1, {OUTPUT_ROWS}, {OUTPUT_COLUMNS}], got {shape!r}"
298
- )
299
 
300
- def _read_model_names(self) -> tuple[str, ...]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  try:
302
- raw = self.session.get_modelmeta().custom_metadata_map["names"]
303
- parsed = ast.literal_eval(raw)
304
- if not isinstance(parsed, dict):
305
- raise TypeError("names metadata is not a dict")
306
- keys = sorted(parsed)
307
- if keys != list(range(len(keys))):
308
- raise ValueError(f"names metadata keys are not consecutive from zero: {keys[:8]}")
309
- names = tuple(str(parsed[index]) for index in keys)
310
- except Exception as exc:
311
- raise ValueError(f"e2e ONNX requires valid consecutive names metadata: {exc}") from exc
312
- if not names:
313
- raise ValueError("e2e ONNX names metadata is empty")
314
- return names
315
-
316
- def _bind_class_map(self) -> None:
317
- self.model_to_element: dict[int, int] = {}
318
- for model_id, element_id in zip(self.config.coco_ids, self.config.emitted_cls_ids):
319
- if model_id >= len(self.model_names):
320
- raise ValueError(
321
- f"config model class id {model_id} outside ONNX names range 0..{len(self.model_names)-1}"
322
- )
323
- expected = self.config.classes[element_id]
324
- actual = self.model_names[model_id]
325
- if actual != expected:
326
- raise ValueError(
327
- f"config class mapping mismatch: model class {model_id} is {actual!r}, "
328
- f"element class {element_id} is {expected!r}"
329
- )
330
- self.model_to_element[model_id] = element_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
 
332
  @staticmethod
333
- def _letterbox(image: ndarray, new_shape: tuple[int, int], color=(114, 114, 114)):
334
- height, width = image.shape[:2]
335
- new_width, new_height = new_shape
336
- ratio = min(new_width / width, new_height / height)
337
- resized_width, resized_height = int(round(width * ratio)), int(round(height * ratio))
338
- if (resized_width, resized_height) != (width, height):
339
- image = cv2.resize(
340
- image, (resized_width, resized_height),
341
- interpolation=cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR,
342
- )
343
- dw, dh = (new_width - resized_width) / 2.0, (new_height - resized_height) / 2.0
344
- padded = cv2.copyMakeBorder(
345
- image,
346
- int(round(dh - 0.1)), int(round(dh + 0.1)),
347
- int(round(dw - 0.1)), int(round(dw + 0.1)),
348
- cv2.BORDER_CONSTANT, value=color,
349
- )
350
- return padded, ratio, (dw, dh)
351
 
352
- def _preprocess(self, image: ndarray):
353
- original_height, original_width = image.shape[:2]
354
- padded, ratio, pad = self._letterbox(image, (self.input_width, self.input_height))
355
- blob = cv2.dnn.blobFromImage(padded, scalefactor=1 / 255.0, swapRB=True)
356
- return blob, ratio, pad, (original_width, original_height)
 
 
 
357
 
358
  @staticmethod
359
- def _check_image(image: np.ndarray) -> np.ndarray:
360
- if not isinstance(image, np.ndarray) or image.ndim != 3 or image.shape[2] != 3:
361
- raise ValueError(f"expected HWC 3-channel image, got {getattr(image, 'shape', type(image))}")
362
- return image if image.dtype == np.uint8 else image.astype(np.uint8)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
 
364
  @staticmethod
365
- def _check_finite(tensor: np.ndarray, where: str) -> None:
366
- if not np.isfinite(tensor).all():
367
- raise InferenceInvariantError(
368
- f"{where} must be finite everywhere: NaN={int(np.isnan(tensor).sum())} "
369
- f"Inf={int(np.isinf(tensor).sum())}"
370
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
 
372
- def _check_outputs(self, outputs: Any) -> np.ndarray:
373
- if not isinstance(outputs, (list, tuple)) or len(outputs) != len(self.output_names):
374
- raise ValueError(f"session returned invalid output list, expected {len(self.output_names)} tensors")
375
- for name, tensor in zip(self.output_names, outputs):
376
- if not isinstance(tensor, np.ndarray):
377
- raise ValueError(f"model output {name!r} is not an ndarray")
378
- if np.issubdtype(tensor.dtype, np.floating):
379
- self._check_finite(tensor, f"model output {name!r}")
380
- output = outputs[0]
381
- if not np.issubdtype(output.dtype, np.floating) or list(output.shape) != [1, OUTPUT_ROWS, OUTPUT_COLUMNS]:
382
- raise ValueError(
383
- f"e2e output tensor must be float [1, {OUTPUT_ROWS}, {OUTPUT_COLUMNS}], "
384
- f"got {output.dtype} {list(output.shape)}"
385
- )
386
- return output
387
-
388
- def _decode_e2e(
389
- self,
390
- output: np.ndarray,
391
- ratio: float,
392
- pad: tuple[float, float],
393
- original_size: tuple[int, int],
394
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
395
- rows = output[0]
396
- boxes = rows[:, :4].astype(np.float32, copy=True)
397
- scores = rows[:, 4].astype(np.float32, copy=False)
398
- raw_classes = rows[:, 5]
399
- if (scores < 0).any() or (scores > 1).any():
400
- raise InferenceInvariantError("e2e confidence outside [0, 1]")
401
- rounded = np.rint(raw_classes)
402
- if not np.array_equal(raw_classes, rounded):
403
- raise InferenceInvariantError("e2e model class ids must be exact integers")
404
- model_classes = rounded.astype(np.int64)
405
- if (model_classes < 0).any() or (model_classes >= len(self.model_names)).any():
406
- bad = model_classes[(model_classes < 0) | (model_classes >= len(self.model_names))]
407
- raise InferenceInvariantError(f"e2e model class id outside range: {bad[:8].tolist()}")
408
-
409
- active = scores > 0.0
410
- reversed_rows = active & ((boxes[:, 2] < boxes[:, 0]) | (boxes[:, 3] < boxes[:, 1]))
411
- if reversed_rows.any():
412
- raise InferenceInvariantError(f"e2e output contains reversed boxes: {boxes[reversed_rows][:4].tolist()}")
413
- mapped = np.fromiter((int(class_id) in self.model_to_element for class_id in model_classes), bool, len(rows))
414
- keep = mapped & (scores >= np.float32(self.conf_threshold))
415
- if not keep.any():
416
- return (
417
- np.empty((0, 4), np.float32),
418
- np.empty((0,), np.float32),
419
- np.empty((0,), np.int64),
420
- )
421
- boxes = boxes[keep]
422
- scores = scores[keep]
423
- model_classes = model_classes[keep]
424
- element_classes = np.fromiter(
425
- (self.model_to_element[int(class_id)] for class_id in model_classes),
426
- dtype=np.int64,
427
- count=len(model_classes),
428
- )
429
- boxes[:, [0, 2]] = (boxes[:, [0, 2]] - pad[0]) / ratio
430
- boxes[:, [1, 3]] = (boxes[:, [1, 3]] - pad[1]) / ratio
431
- width, height = original_size
432
- boxes[:, [0, 2]] = np.clip(boxes[:, [0, 2]], 0, width - 1)
433
- boxes[:, [1, 3]] = np.clip(boxes[:, [1, 3]], 0, height - 1)
434
- self._check_decoded(boxes, scores, element_classes, original_size)
435
-
436
- box_widths = boxes[:, 2] - boxes[:, 0]
437
- box_heights = boxes[:, 3] - boxes[:, 1]
438
- areas = box_widths * box_heights
439
- image_area = float(width * height)
440
- sane = (
441
- (box_widths >= self.min_side)
442
- & (box_heights >= self.min_side)
443
- & (areas <= 0.95 * image_area)
444
- & (
445
- np.maximum(
446
- box_widths / np.maximum(box_heights, 1e-6),
447
- box_heights / np.maximum(box_widths, 1e-6),
448
- )
449
- <= self.max_aspect_ratio
450
- )
451
  )
452
- boxes, scores, element_classes = boxes[sane], scores[sane], element_classes[sane]
453
- if len(scores) > self.crowd_count:
454
- strong = scores >= np.float32(self.crowd_conf)
455
- boxes, scores, element_classes = boxes[strong], scores[strong], element_classes[strong]
456
- if len(scores):
457
- order = np.argsort(-scores, kind="stable")[: self.max_det]
458
- boxes, scores, element_classes = boxes[order], scores[order], element_classes[order]
459
- # Thresholds, model-name checks and all head semantics above stay in
460
- # ``classes`` order. Only the validator-facing ids are remapped here.
461
- if len(element_classes):
462
- element_classes = np.asarray(self.config.output_cls_ids, dtype=np.int64)[element_classes]
463
- return boxes, scores, element_classes
464
-
465
- def _check_decoded(
466
- self,
467
- boxes: np.ndarray,
468
- scores: np.ndarray,
469
- class_ids: np.ndarray,
470
- original_size: tuple[int, int],
471
- ) -> None:
472
- if not len(boxes):
473
- return
474
- width, height = original_size
475
- if not np.isfinite(boxes).all():
476
- raise InferenceInvariantError("decoded boxes contain non-finite coordinates")
477
- if (boxes[:, 2] < boxes[:, 0]).any() or (boxes[:, 3] < boxes[:, 1]).any():
478
- raise InferenceInvariantError("decoded boxes are reversed")
479
- xs, ys = boxes[:, [0, 2]], boxes[:, [1, 3]]
480
- if (xs < 0).any() or (xs > width - 1).any() or (ys < 0).any() or (ys > height - 1).any():
481
- raise InferenceInvariantError(f"decoded boxes outside {width}x{height} image")
482
- if not np.isfinite(scores).all() or (scores < 0).any() or (scores > 1).any():
483
- raise InferenceInvariantError("decoded confidence outside [0, 1]")
484
- if (class_ids < 0).any() or (class_ids >= len(self.class_names)).any():
485
- raise InferenceInvariantError("decoded cls_id outside element class range")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
 
487
  @staticmethod
488
- def _to_bounding_boxes(
489
- boxes: np.ndarray,
490
- scores: np.ndarray,
491
- class_ids: np.ndarray,
492
- ) -> list[BoundingBox]:
493
- return [
494
- BoundingBox(
495
- x1=int(math.floor(box[0])), y1=int(math.floor(box[1])),
496
- x2=int(math.ceil(box[2])), y2=int(math.ceil(box[3])),
497
- cls_id=int(class_id), conf=float(score),
498
- )
499
- for box, score, class_id in zip(boxes, scores, class_ids)
500
- if box[2] > box[0] and box[3] > box[1]
501
- ]
502
-
503
- def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
504
- image = self._check_image(image)
505
- blob, ratio, pad, original_size = self._preprocess(image)
506
- outputs = self.session.run(self.output_names, {self.input_name: blob})
507
- output = self._check_outputs(outputs)
508
- arrays = self._decode_e2e(output, ratio, pad, original_size)
509
- return self._to_bounding_boxes(*arrays)
510
-
511
- def _warmup(self, iterations: int) -> None:
512
- dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
513
- original_strict = self.strict_inference
514
- self.strict_inference = True
515
- try:
516
- for _ in range(iterations):
517
- self.predict_batch(batch_images=[dummy], offset=0, n_keypoints=0)
518
- except Exception as exc:
519
- raise RuntimeError(f"warmup failed, refusing to serve: {type(exc).__name__}: {exc}") from exc
520
- finally:
521
- self.strict_inference = original_strict
522
-
523
- def _record_failure(self, frame_id: int, exc: Exception) -> None:
524
- self.inference_failures += 1
525
- invariant = isinstance(exc, InferenceInvariantError)
526
- if invariant:
527
- self.invariant_failures += 1
528
- print(json.dumps({
529
- "event": "inference_failure",
530
- "frame_id": frame_id,
531
- "error_type": type(exc).__name__,
532
- "error": str(exc),
533
- "invariant": invariant,
534
- "inference_failures": self.inference_failures,
535
- "invariant_failures": self.invariant_failures,
536
- }, sort_keys=True))
537
-
538
- def predict_batch(
539
- self,
540
- batch_images: list[np.ndarray],
541
- offset: int,
542
- n_keypoints: int,
543
- ) -> list[TVFrameResult]:
544
- _ = n_keypoints
545
  results: list[TVFrameResult] = []
546
- for index, image in enumerate(batch_images):
547
- frame_id = offset + index
548
  try:
549
- boxes = self._predict_single(image)
550
- except Exception as exc:
551
- if self.strict_inference:
552
- raise
553
- self._record_failure(frame_id, exc)
554
  boxes = []
555
- results.append(TVFrameResult(frame_id=frame_id, boxes=boxes, keypoints=[]))
 
556
  return results
557
-
558
- def __repr__(self) -> str:
559
- return (
560
- f"E2EONNXRuntime(session={type(self.session).__name__}, "
561
- f"providers={self.session.get_providers()})"
562
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ import math
3
  from pathlib import Path
 
4
 
5
  import cv2
6
  import numpy as np
 
9
  from pydantic import BaseModel
10
 
11
 
12
+ # =============================================================================
13
+ # Person miner (manak0/Detect-Person) β€” port of the proven carwash001 miner,
14
+ # same structure as VehicleDetection/miner/my_miner.
15
+ #
16
+ # Model: yolo26n end2end ONNX @704 (person_0904_704 / powerful_selected soup),
17
+ # single element-canonical class: 0=person.
18
+ #
19
+ # Carried over (all validated on the carwash/vehicle elements):
20
+ # * robust model loading, FP16 auto-detect, metadata class order, ORT thread
21
+ # pinning, raw-YOLO decode fallback, NO CLAHE (train/test match)
22
+ # * vectorized hard NMS + cluster-max boost, per-class NMS IoU / min-area,
23
+ # pre_nms_topk, cross-class dedup (a no-op guard with one class),
24
+ # square-domain hook
25
+ # * soft-NMS with post-decay re-filter (use_soft_nms), rescue bonus β€” both
26
+ # SWEEPABLE via tune_miner.py; which setting wins is element-specific
27
+ # (carwash: soft on / cluster on / rescue off; vehicle: the opposite).
28
+ #
29
+ # The vehicle miner's dual-label bus/truck lever is intentionally absent:
30
+ # person has no sibling class to hedge against.
31
+ #
32
+ # Run tune_miner.py after ANY retrain/re-export and paste its printout below.
33
+ # A failing conformity tuple (latency/format) removes the miner from scoring
34
+ # entirely β€” bench latency with bench_latency.py (2 pinned CPUs) too.
35
+ # =============================================================================
36
 
37
 
38
  class BoundingBox(BaseModel):
 
50
  keypoints: list[tuple[int, int]]
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  class Miner:
 
 
 
 
 
 
 
 
 
 
 
54
  def __init__(self, path_hf_repo: Path) -> None:
55
+ model_path = self._resolve_model_path(path_hf_repo)
56
+ # Canonical class order every downstream consumer (validator,
57
+ # BoundingBox.cls_id) sees β€” MUST equal element.objects order:
58
+ self.class_names = ["person"]
59
+ # Fallback model-emit order (used only when ONNX metadata is missing).
60
+ self._model_class_order = ["person"]
61
+ print("ORT version:", ort.__version__)
62
+
63
+ try:
64
+ ort.preload_dlls()
65
+ print("onnxruntime.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 CPU latency gate.
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
+ # MINER_FORCE_CPU=1 skips the CUDA attempt β€” used by bench_latency.py to
79
+ # measure the conformity-gate path (this box has onnxruntime-gpu too, so
80
+ # an unforced "CPU" benchmark silently runs on the GPU).
81
+ force_cpu = os.environ.get("MINER_FORCE_CPU", "") == "1"
82
  try:
83
+ if force_cpu:
84
+ raise RuntimeError("MINER_FORCE_CPU=1")
85
  self.session = ort.InferenceSession(
86
+ str(model_path),
87
+ sess_options=sess_options,
88
  providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
89
  )
90
+ print("Created ORT session with preferred CUDA provider list")
91
+ except Exception as e:
92
+ print(f"CUDA session creation skipped/failed, using CPU: {e}")
93
  self.session = ort.InferenceSession(
94
+ str(model_path),
95
+ sess_options=sess_options,
96
+ providers=["CPUExecutionProvider"],
97
  )
98
+ print("ORT session providers:", self.session.get_providers())
99
+
100
+ # cls_remap[i] = self.class_names.index(model_class_order[i]); order comes
101
+ # from ONNX metadata when present, else the static fallback.
102
+ model_class_order = self._read_model_class_order()
103
+ if model_class_order is None:
104
+ model_class_order = list(self._model_class_order)
105
+ print(f"cls order: no usable ONNX metadata, FALLBACK {model_class_order}")
106
+ else:
107
+ print(f"cls order: from ONNX metadata {model_class_order}")
108
+ self.cls_remap = np.array(
109
+ [self.class_names.index(n) for n in model_class_order], 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 = [o.name for o in self.session.get_outputs()]
119
+ self.input_shape = self.session.get_inputs()[0].shape
120
+
121
+ # Match the ONNX input dtype (FP16 export needs float16 input).
122
+ input_type = self.session.get_inputs()[0].type
123
+ self.np_dtype = np.float16 if "float16" in input_type else np.float32
124
+ print(f"ONNX input dtype: {input_type} -> numpy {self.np_dtype}")
125
+
126
+ # The miner MUST run at the ONNX's baked input size (704). Dropping to
127
+ # 640 is a latency lever but an EXPORT decision (re-export + re-eval),
128
+ # not a miner-side change.
129
+ self.input_height = self._safe_dim(self.input_shape[2], default=704)
130
+ self.input_width = self._safe_dim(self.input_shape[3], default=704)
131
+
132
+ # ── Post-processing config (tune on person1-powerful/val) ─────────────
133
+ # Per-class NMS IoU ([person]).
134
+ self._iou_thres_array = np.array([0.45], dtype=np.float32)
135
+ self.cross_iou_thresh = 0.9 # near-duplicate guard (no-op across classes
136
+ # with one class; catches post-NMS dupes)
137
+ self.max_det = 300 # person frames can be busy
138
+ self.pre_nms_topk = 1000 # cap candidates before NMS (crowded-frame speed guard)
139
+ self.use_tta = False # 2nd flipped pass; doubles latency -> off for CPU gate
140
+
141
+ # Single-view cluster boost: raise each survivor's conf to its
142
+ # IoU-cluster max. OFF per the 2026-09-04 person sweep (carwash: on,
143
+ # vehicle: off -- element-specific, re-sweep on new weights).
144
+ self.use_cluster_boost = False
145
+
146
+ # Soft-NMS: Gaussian decay of overlapping scores instead of hard
147
+ # deletion, then RE-FILTER decayed scores against the confidence
148
+ # threshold (keeps decayed junk away from the FP pillar).
149
+ # OFF per the 2026-09-04 person sweep (off 0.5670 vs on 0.5662) --
150
+ # element- and weights-specific, re-sweep after every retrain.
151
+ self.use_soft_nms = True
152
+ self.soft_nms_sigma = 0.4
153
+
154
+ # Confidence threshold ([person]). `_conf_thres_array` is the SQUARE /
155
+ # validator-eval set (every scored frame is 1024x1024 squished, so this
156
+ # is the one that matters); `_extra` covers non-square inputs (warmup /
157
+ # any 16:9 frame). Tuned 2026-09-04 (tune_miner.py, 1853-img
158
+ # person1-powerful/val, adaptive-IoU matcher): comp 0.5385 -> 0.5670.
159
+ # NOTE: the val is dense (29 GT/frame avg) and recall is the binding
160
+ # constraint. A low-edge extension sweep found a plateau below the
161
+ # floor (0.05-0.09 -> comp 0.5713, +0.004) but on REAL challenge
162
+ # frames the 0.05-0.12 band adds ~25 boxes/frame -- too FP-risky to
163
+ # ship on dense-val evidence alone. Decide with test_miner.py + SAM3
164
+ # GT on real frames; retune after every retrain (especially once the
165
+ # sparse regenerated data lands).
166
+ self._conf_thres_array = np.array([0.35], dtype=np.float32)
167
+ self._extra_conf_thres_array = np.array([0.35], dtype=np.float32)
168
+
169
+ # Rescue bonus: if NO box passes the threshold, admit the top-1 when
170
+ # score >= (threshold - bonus). ON per the 2026-09-04 sweep.
171
+ self._bonus_array = np.full(1, 0.03, dtype=np.float32)
172
+
173
+ # Min box area (px^2), loose: distant people are tiny in 1024x1024
174
+ # frames and tiny-GT matches only need IoU 0.30 under the adaptive
175
+ # matcher β€” do not silently drop them.
176
+ self._min_box_area_array = np.array([12.0], dtype=np.float32)
177
+ self.min_side = 3
178
+ self.max_aspect_ratio = 12.0
179
+ self.max_area_frac = 0.95
180
+
181
+ # Square-domain de-squish: widen a square (validator) frame by this
182
+ # fraction before letterboxing, then drop pad-center boxes and un-pad.
183
+ # 0.0 = off β€” the model was TRAINED on squished 1024 frames, so the
184
+ # domains already match; enabling changes aspect -> validate first.
185
+ self.square_pad_frac = 0.0
186
+
187
+ self._avg_iou = float(np.mean(self._iou_thres_array))
188
+ print(f"ONNX model loaded from: {model_path}")
189
+ print(f"ONNX input: name={self.input_name}, shape={self.input_shape}")
190
+ self._warmup()
191
+
192
+ # ── Setup helpers ────────────────────────────────────────────────────────
193
+
194
+ def _warmup(self, iters: int = 3) -> None:
195
+ try:
196
+ dummy = np.zeros((1024, 1024, 3), dtype=np.uint8) # validator domain
197
+ for _ in range(max(1, iters)):
198
+ self.predict_batch(batch_images=[dummy], offset=0, n_keypoints=0)
199
+ print(f"warmup: {iters} dummy predict_batch call(s) done")
200
+ except Exception as e:
201
+ print(f"warmup skipped: {e}")
202
+
203
+ def __repr__(self) -> str:
204
+ return f"PersonMiner(classes={len(self.class_names)}, providers={self.session.get_providers()})"
205
 
206
  @staticmethod
207
+ def _safe_dim(value, default: int) -> int:
208
+ return value if isinstance(value, int) and value > 0 else default
 
 
 
209
 
210
+ @staticmethod
211
+ def _resolve_model_path(repo: Path) -> Path:
212
+ """Prefer weights.onnx, then weights_int8.onnx, then any .onnx."""
213
+ for name in ("weights.onnx", "weights_int8.onnx"):
214
+ p = repo / name
215
+ if p.exists():
216
+ if name != "weights.onnx":
217
+ print(f"model: weights.onnx not found, using {name}")
218
+ return p
219
+ candidates = sorted(repo.glob("*.onnx"))
220
+ if candidates:
221
+ print(f"model: using {candidates[0].name}")
222
+ return candidates[0]
223
+ return repo / "weights.onnx" # let session creation raise the error
224
+
225
+ def _read_model_class_order(self) -> list[str] | None:
226
+ """Read class order from Ultralytics ONNX `names` metadata, or None."""
227
  try:
228
+ import ast
229
+ meta = self.session.get_modelmeta().custom_metadata_map
230
+ names = ast.literal_eval(meta["names"])
231
+ order = ([str(names[i]) for i in sorted(names)] if isinstance(names, dict)
232
+ else [str(n) for n in names])
233
+ except Exception as e:
234
+ print(f"cls order: could not read ONNX names metadata ({e})")
235
+ return None
236
+ if sorted(order) != sorted(self.class_names):
237
+ print(f"cls order: ONNX names {order} != expected {self.class_names}; ignoring")
238
+ return None
239
+ return order
240
+
241
+ # ── Preprocessing ────────────────────────────────────────────────────────
242
+
243
+ def _letterbox(self, image: ndarray, new_shape: tuple[int, int],
244
+ color=(114, 114, 114)) -> tuple[ndarray, float, tuple[float, float]]:
245
+ h, w = image.shape[:2]
246
+ new_w, new_h = new_shape
247
+ ratio = min(new_w / w, new_h / h)
248
+ rw, rh = int(round(w * ratio)), int(round(h * ratio))
249
+ if (rw, rh) != (w, h):
250
+ interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
251
+ image = cv2.resize(image, (rw, rh), interpolation=interp)
252
+ dw = (new_w - rw) / 2.0
253
+ dh = (new_h - rh) / 2.0
254
+ left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
255
+ top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
256
+ padded = cv2.copyMakeBorder(image, top, bottom, left, right,
257
+ cv2.BORDER_CONSTANT, value=color)
258
+ return padded, ratio, (dw, dh)
259
+
260
+ def _preprocess(self, image: ndarray) -> tuple[np.ndarray, dict]:
261
+ """Letterbox to the ONNX input size; NO CLAHE/denoise/sharpen (any
262
+ enhancement the model wasn't trained on is a train/test mismatch that
263
+ HURTS accuracy and also risks the CPU latency gate).
264
+
265
+ Square-domain de-squish: when the frame is square (the validator's
266
+ 1024x1024 squished eval image) and square_pad_frac > 0, widen it with
267
+ gray bars first -- objects come out horizontally compressed in those
268
+ frames, and mild widening partly restores their aspect.
269
+ """
270
+ orig_h, orig_w = image.shape[:2]
271
+ extra_left = extra_right = 0
272
+ if self.square_pad_frac > 0.0 and orig_w == orig_h:
273
+ target_w = int(orig_w * (1.0 + self.square_pad_frac))
274
+ if target_w > orig_w:
275
+ total = target_w - orig_w
276
+ extra_left = total // 2
277
+ extra_right = total - extra_left
278
+ image = cv2.copyMakeBorder(image, 0, 0, extra_left, extra_right,
279
+ cv2.BORDER_CONSTANT, value=(114, 114, 114))
280
+
281
+ img, ratio, pad = self._letterbox(image, (self.input_width, self.input_height))
282
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
283
+ img = img.astype(np.float32) / 255.0
284
+ img = np.transpose(img, (2, 0, 1))[None, ...]
285
+ img = np.ascontiguousarray(img, dtype=self.np_dtype)
286
+ return img, {
287
+ "ratio": ratio, "pad": pad, "orig_size": (orig_w, orig_h),
288
+ "extra_left": extra_left, "extra_right": extra_right,
289
+ }
290
+
291
+ # ── Vectorized box ops ───────────────────────────────────────────────────
292
 
293
  @staticmethod
294
+ def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
295
+ w, h = image_size
296
+ boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
297
+ boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
298
+ boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
299
+ boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
300
+ return boxes
 
 
 
 
 
 
 
 
 
 
 
301
 
302
+ @staticmethod
303
+ def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
304
+ out = np.empty_like(boxes)
305
+ out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
306
+ out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
307
+ out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
308
+ out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
309
+ return out
310
 
311
  @staticmethod
312
+ def _hard_nms(boxes: np.ndarray, scores: np.ndarray, iou_thresh: float) -> np.ndarray:
313
+ """Vectorized greedy NMS: areas precomputed once, each step is a single
314
+ numpy IoU vector -- no inner Python loop."""
315
+ n = len(boxes)
316
+ if n == 0:
317
+ return np.array([], dtype=np.intp)
318
+ x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
319
+ areas = np.maximum(0.0, x2 - x1) * np.maximum(0.0, y2 - y1)
320
+ order = np.argsort(-scores)
321
+ keep = []
322
+ while order.size > 0:
323
+ i = int(order[0])
324
+ keep.append(i)
325
+ if order.size == 1:
326
+ break
327
+ rest = order[1:]
328
+ xx1 = np.maximum(x1[i], x1[rest])
329
+ yy1 = np.maximum(y1[i], y1[rest])
330
+ xx2 = np.minimum(x2[i], x2[rest])
331
+ yy2 = np.minimum(y2[i], y2[rest])
332
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
333
+ iou = inter / (areas[i] + areas[rest] - inter + 1e-7)
334
+ order = rest[iou <= iou_thresh]
335
+ return np.array(keep, dtype=np.intp)
336
 
337
  @staticmethod
338
+ def _soft_nms(boxes: np.ndarray, scores: np.ndarray, sigma: float
339
+ ) -> tuple[np.ndarray, np.ndarray]:
340
+ """Gaussian soft-NMS: decay overlapping scores instead of deleting.
341
+ Returns (original indices in decay order, decayed scores)."""
342
+ n = len(boxes)
343
+ if n == 0:
344
+ return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
345
+ b = boxes.astype(np.float32, copy=True)
346
+ sc = scores.astype(np.float32, copy=True)
347
+ order = np.arange(n)
348
+ for i in range(n):
349
+ mp = i + int(np.argmax(sc[i:]))
350
+ b[[i, mp]] = b[[mp, i]]; sc[[i, mp]] = sc[[mp, i]]
351
+ order[[i, mp]] = order[[mp, i]]
352
+ if i + 1 >= n:
353
+ break
354
+ xx1 = np.maximum(b[i, 0], b[i+1:, 0]); yy1 = np.maximum(b[i, 1], b[i+1:, 1])
355
+ xx2 = np.minimum(b[i, 2], b[i+1:, 2]); yy2 = np.minimum(b[i, 3], b[i+1:, 3])
356
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
357
+ ai = max(0.0, float((b[i, 2]-b[i, 0]) * (b[i, 3]-b[i, 1])))
358
+ aj = np.maximum(0.0, b[i+1:, 2]-b[i+1:, 0]) * np.maximum(0.0, b[i+1:, 3]-b[i+1:, 1])
359
+ iou = inter / (ai + aj - inter + 1e-7)
360
+ sc[i+1:] *= np.exp(-(iou ** 2) / sigma)
361
+ return order, sc
362
+
363
+ def _per_class_soft_nms(self, boxes: np.ndarray, scores: np.ndarray,
364
+ cls_ids: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
365
+ """Soft-NMS per class, then re-filter decayed scores against the
366
+ per-class confidence thresholds so no decayed box reaches the FP pillar
367
+ below its class bar."""
368
+ keep_idx: list[int] = []
369
+ keep_sc: list[float] = []
370
+ for c in np.unique(cls_ids):
371
+ idx = np.where(cls_ids == c)[0]
372
+ order, dec = self._soft_nms(boxes[idx], scores[idx], self.soft_nms_sigma)
373
+ thr = float(self._conf_thres_array[c])
374
+ for k, s_dec in zip(order, dec):
375
+ if s_dec >= thr:
376
+ keep_idx.append(int(idx[k])); keep_sc.append(float(s_dec))
377
+ return np.array(keep_idx, dtype=np.intp), np.array(keep_sc, dtype=np.float32)
378
+
379
+ def _per_class_hard_nms(self, boxes: np.ndarray, scores: np.ndarray,
380
+ cls_ids: np.ndarray) -> np.ndarray:
381
+ """Per-class NMS using the per-class IoU thresholds."""
382
+ if len(boxes) == 0:
383
+ return np.array([], dtype=np.intp)
384
+ all_keep: list[int] = []
385
+ for c in np.unique(cls_ids):
386
+ idx = np.where(cls_ids == c)[0]
387
+ cls_iou = float(self._iou_thres_array[c])
388
+ keep = self._hard_nms(boxes[idx], scores[idx], cls_iou)
389
+ all_keep.extend(idx[keep].tolist())
390
+ all_keep.sort()
391
+ return np.array(all_keep, dtype=np.intp)
392
 
393
+ @staticmethod
394
+ def _max_score_per_cluster(post_boxes: np.ndarray, post_cls: np.ndarray,
395
+ full_boxes: np.ndarray, full_scores: np.ndarray,
396
+ full_cls: np.ndarray, iou_thresh: float) -> np.ndarray:
397
+ """Each survivor's confidence -> max score in its SAME-CLASS IoU cluster.
398
+ Vectorized single (n_post x n_full) IoU matrix."""
399
+ n = len(post_boxes)
400
+ if n == 0:
401
+ return np.empty(0, dtype=np.float32)
402
+ m = len(full_boxes)
403
+ if m == 0:
404
+ return np.zeros(n, dtype=np.float32)
405
+ pa = (np.maximum(0.0, post_boxes[:, 2] - post_boxes[:, 0]) *
406
+ np.maximum(0.0, post_boxes[:, 3] - post_boxes[:, 1]))
407
+ fa = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
408
+ np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
409
+ xx1 = np.maximum(post_boxes[:, 0][:, None], full_boxes[:, 0][None, :])
410
+ yy1 = np.maximum(post_boxes[:, 1][:, None], full_boxes[:, 1][None, :])
411
+ xx2 = np.minimum(post_boxes[:, 2][:, None], full_boxes[:, 2][None, :])
412
+ yy2 = np.minimum(post_boxes[:, 3][:, None], full_boxes[:, 3][None, :])
413
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
414
+ iou = inter / (pa[:, None] + fa[None, :] - inter + 1e-7)
415
+ mask = (iou >= iou_thresh) & (post_cls[:, None] == full_cls[None, :])
416
+ tiled = np.where(mask, full_scores[None, :], -np.inf)
417
+ out = tiled.max(axis=1)
418
+ out[~np.isfinite(out)] = 0.0
419
+ return out.astype(np.float32)
420
+
421
+ def _conf_filter_mask(self, scores: np.ndarray, cls_ids: np.ndarray,
422
+ extra_left: int) -> np.ndarray:
423
+ """Per-class threshold (square vs non-square set) + per-class rescue."""
424
+ if len(scores) == 0:
425
+ return np.zeros(0, dtype=bool)
426
+ thr_arr = self._extra_conf_thres_array if extra_left > 0 else self._conf_thres_array
427
+ keep = scores >= thr_arr[cls_ids]
428
+ for c in np.unique(cls_ids):
429
+ b = float(self._bonus_array[c])
430
+ if b <= 0.0:
431
+ continue
432
+ cm = cls_ids == c
433
+ if keep[cm].any():
434
+ continue
435
+ idx = np.where(cm)[0]
436
+ top = int(idx[int(np.argmax(scores[idx]))])
437
+ if scores[top] >= float(self._conf_thres_array[c]) - b:
438
+ keep[top] = True
439
+ return keep
440
+
441
+ def _filter_sane_boxes(self, boxes: np.ndarray, scores: np.ndarray,
442
+ cls_ids: np.ndarray, orig_size: tuple[int, int]
443
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
444
+ """Vectorized per-class min-area / max-area / min-side / aspect filter."""
445
+ if len(boxes) == 0:
446
+ return boxes, scores, cls_ids
447
+ orig_w, orig_h = orig_size
448
+ image_area = float(orig_w * orig_h)
449
+ bw = np.maximum(0.0, boxes[:, 2] - boxes[:, 0])
450
+ bh = np.maximum(0.0, boxes[:, 3] - boxes[:, 1])
451
+ area = bw * bh
452
+ ar = np.maximum(bw / np.maximum(bh, 1e-6), bh / np.maximum(bw, 1e-6))
453
+ keep = (
454
+ (bw >= self.min_side) & (bh >= self.min_side) &
455
+ (area >= self._min_box_area_array[cls_ids]) &
456
+ (area <= self.max_area_frac * image_area) &
457
+ (ar <= self.max_aspect_ratio)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  )
459
+ return boxes[keep], scores[keep], cls_ids[keep]
460
+
461
+ def _cross_class_dedup_op(self, boxes: np.ndarray, scores: np.ndarray,
462
+ cls_ids: np.ndarray, iou_thresh: float
463
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
464
+ """Suppress near-duplicate boxes at IoU>thresh regardless of class.
465
+ With a single class this only catches rare post-NMS duplicates."""
466
+ n = len(boxes)
467
+ if n <= 1:
468
+ return boxes, scores, cls_ids
469
+ boxes = np.asarray(boxes, dtype=np.float32)
470
+ scores = np.asarray(scores, dtype=np.float32)
471
+ cls_ids = np.asarray(cls_ids, dtype=np.int32)
472
+ areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *
473
+ np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))
474
+ margins = scores - self._conf_thres_array[cls_ids]
475
+ order = np.lexsort((-areas, -margins))
476
+ suppressed = np.zeros(n, dtype=bool)
477
+ keep: list[int] = []
478
+ for i in order:
479
+ if suppressed[i]:
480
+ continue
481
+ keep.append(int(i))
482
+ bi = boxes[i]
483
+ xx1 = np.maximum(bi[0], boxes[:, 0])
484
+ yy1 = np.maximum(bi[1], boxes[:, 1])
485
+ xx2 = np.minimum(bi[2], boxes[:, 2])
486
+ yy2 = np.minimum(bi[3], boxes[:, 3])
487
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
488
+ a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
489
+ iou = inter / (a_i + areas - inter + 1e-7)
490
+ dup = iou > iou_thresh
491
+ dup[i] = False
492
+ suppressed |= dup
493
+ keep_idx = np.array(keep, dtype=np.intp)
494
+ return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]
495
+
496
+ def _per_view_pipeline(self, boxes: np.ndarray, scores: np.ndarray,
497
+ cls_ids: np.ndarray, orig_size: tuple[int, int]
498
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
499
+ """sane filter -> top-k cap -> per-class NMS -> max_det cap -> dedup."""
500
+ boxes, scores, cls_ids = self._filter_sane_boxes(boxes, scores, cls_ids, orig_size)
501
+ if len(boxes) == 0:
502
+ return boxes, scores, cls_ids
503
+ if len(scores) > self.pre_nms_topk:
504
+ top = np.argpartition(-scores, self.pre_nms_topk)[: self.pre_nms_topk]
505
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
506
+ if len(boxes) > 1:
507
+ if self.use_soft_nms:
508
+ keep, scores = self._per_class_soft_nms(boxes, scores, cls_ids)
509
+ boxes, cls_ids = boxes[keep], cls_ids[keep]
510
+ else:
511
+ keep = self._per_class_hard_nms(boxes, scores, cls_ids)
512
+ boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
513
+ if len(scores) > self.max_det:
514
+ top = np.argsort(-scores)[: self.max_det]
515
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
516
+ if len(boxes) > 1:
517
+ boxes, scores, cls_ids = self._cross_class_dedup_op(
518
+ boxes, scores, cls_ids, self.cross_iou_thresh)
519
+ return boxes, scores, cls_ids
520
+
521
+ # ── Coordinate un-mapping (shared by both decode paths) ──────────────────
522
+
523
+ def _unmap_and_finish(self, boxes: np.ndarray, scores: np.ndarray,
524
+ cls_ids: np.ndarray, meta: dict
525
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
526
+ """Undo letterbox + square-pad, drop pad-center boxes, clip, post-process."""
527
+ pad_w, pad_h = meta["pad"]
528
+ ratio = meta["ratio"]
529
+ orig_w, orig_h = meta["orig_size"]
530
+ extra_left, extra_right = meta["extra_left"], meta["extra_right"]
531
+
532
+ boxes[:, [0, 2]] -= pad_w
533
+ boxes[:, [1, 3]] -= pad_h
534
+ boxes /= ratio
535
+ if extra_left:
536
+ boxes[:, [0, 2]] -= extra_left
537
+ if extra_left or extra_right:
538
+ cx = (boxes[:, 0] + boxes[:, 2]) * 0.5
539
+ inside = (cx >= 0) & (cx <= orig_w)
540
+ boxes, scores, cls_ids = boxes[inside], scores[inside], cls_ids[inside]
541
+ if len(boxes) == 0:
542
+ return boxes, scores, cls_ids
543
+ boxes = self._clip_boxes(boxes, (orig_w, orig_h))
544
+ return self._per_view_pipeline(boxes, scores, cls_ids, (orig_w, orig_h))
545
+
546
+ # ── Decoding ─────────────────────────────────────────────────────────────
547
+
548
+ def _decode_final_dets(self, preds: np.ndarray, meta: dict
549
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
550
+ """End2end output rows [x1, y1, x2, y2, conf, cls_id] (NMS in graph)."""
551
+ empty = (np.empty((0, 4), np.float32), np.empty(0, np.float32), np.empty(0, np.int32))
552
+ if preds.ndim == 3 and preds.shape[0] == 1:
553
+ preds = preds[0]
554
+ if preds.ndim != 2 or preds.shape[1] < 6:
555
+ raise ValueError(f"Unexpected final-det output shape: {preds.shape}")
556
+ boxes = preds[:, :4].astype(np.float32)
557
+ scores = preds[:, 4].astype(np.float32)
558
+ cls_ids = self.cls_remap[preds[:, 5].astype(np.int32)]
559
+ keep = self._conf_filter_mask(scores, cls_ids, meta["extra_left"])
560
+ boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
561
+ if len(boxes) == 0:
562
+ return empty
563
+ return self._unmap_and_finish(boxes, scores, cls_ids, meta)
564
+
565
+ def _decode_raw_yolo(self, preds: np.ndarray, meta: dict
566
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
567
+ """Raw YOLO output [1,C,N] or [1,N,C]; xywh + per-class scores."""
568
+ empty = (np.empty((0, 4), np.float32), np.empty(0, np.float32), np.empty(0, np.int32))
569
+ if preds.ndim != 3 or preds.shape[0] != 1:
570
+ raise ValueError(f"Unexpected raw output shape: {preds.shape}")
571
+ preds = preds[0]
572
+ if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
573
+ preds = preds.T
574
+ if preds.ndim != 2 or preds.shape[1] < 5:
575
+ raise ValueError(f"Unexpected normalized raw shape: {preds.shape}")
576
+ boxes_xywh = preds[:, :4].astype(np.float32)
577
+ cls_part = preds[:, 4:].astype(np.float32)
578
+ if cls_part.shape[1] == 1:
579
+ scores = cls_part[:, 0]
580
+ cls_ids = np.zeros(len(scores), dtype=np.int32)
581
+ else:
582
+ cls_ids = np.argmax(cls_part, axis=1).astype(np.int32)
583
+ scores = cls_part[np.arange(len(cls_part)), cls_ids]
584
+ cls_ids = self.cls_remap[cls_ids]
585
+ keep = self._conf_filter_mask(scores, cls_ids, meta["extra_left"])
586
+ boxes_xywh, scores, cls_ids = boxes_xywh[keep], scores[keep], cls_ids[keep]
587
+ if len(boxes_xywh) == 0:
588
+ return empty
589
+ boxes = self._xywh_to_xyxy(boxes_xywh)
590
+ return self._unmap_and_finish(boxes, scores, cls_ids, meta)
591
+
592
+ def _decode(self, output: np.ndarray, meta: dict
593
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
594
+ if output.ndim == 2 and output.shape[1] >= 6:
595
+ return self._decode_final_dets(output, meta)
596
+ if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
597
+ return self._decode_final_dets(output, meta)
598
+ return self._decode_raw_yolo(output, meta)
599
+
600
+ # ── Inference ────────────────────────────────────────────────────────────
601
+
602
+ def _predict_single_arrays(self, image: np.ndarray
603
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict]:
604
+ if not isinstance(image, np.ndarray) or image.ndim != 3 or image.shape[2] != 3:
605
+ raise ValueError(f"Expected HWC BGR image, got {type(image)} {getattr(image,'shape',None)}")
606
+ if image.dtype != np.uint8:
607
+ image = image.astype(np.uint8)
608
+ inp, meta = self._preprocess(image)
609
+ outputs = self.session.run(self.output_names, {self.input_name: inp})
610
+ boxes, scores, cls_ids = self._decode(outputs[0], meta)
611
+ return boxes, scores, cls_ids, meta
612
 
613
  @staticmethod
614
+ def _to_boxes(boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray,
615
+ orig_size: tuple[int, int]) -> list[BoundingBox]:
616
+ results: list[BoundingBox] = []
617
+ orig_w, orig_h = orig_size
618
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
619
+ x1, y1, x2, y2 = box.tolist()
620
+ if x2 <= x1 or y2 <= y1:
621
+ continue
622
+ results.append(BoundingBox(
623
+ x1=max(0, min(orig_w, int(math.floor(x1)))),
624
+ y1=max(0, min(orig_h, int(math.floor(y1)))),
625
+ x2=max(0, min(orig_w, int(math.ceil(x2)))),
626
+ y2=max(0, min(orig_h, int(math.ceil(y2)))),
627
+ cls_id=int(cls_id),
628
+ conf=float(max(0.0, min(1.0, conf))),
629
+ ))
630
+ return results
631
+
632
+ def _infer_single(self, image: np.ndarray) -> list[BoundingBox]:
633
+ boxes, scores, cls_ids, meta = self._predict_single_arrays(image)
634
+ if len(boxes) == 0:
635
+ return []
636
+ if self.use_cluster_boost and len(boxes) > 1:
637
+ scores = self._max_score_per_cluster(
638
+ boxes, cls_ids, boxes, scores, cls_ids, self._avg_iou)
639
+ return self._to_boxes(boxes, scores, cls_ids, meta["orig_size"])
640
+
641
+ def _infer_tta(self, image: np.ndarray) -> list[BoundingBox]:
642
+ """Horizontal-flip TTA: union of original + flipped, per-class NMS,
643
+ same-class cluster-max boost, dedup."""
644
+ b0, s0, c0, meta = self._predict_single_arrays(image)
645
+ w = image.shape[1]
646
+ bf, sf, cf, _ = self._predict_single_arrays(cv2.flip(image, 1))
647
+ if len(bf):
648
+ bf = bf.copy()
649
+ bf[:, [0, 2]] = w - bf[:, [2, 0]] # mirror x back to original coords
650
+ coords = np.concatenate([b0, bf], axis=0) if len(bf) else b0
651
+ scores = np.concatenate([s0, sf], axis=0) if len(sf) else s0
652
+ cls_ids = np.concatenate([c0, cf], axis=0) if len(cf) else c0
653
+ if len(coords) == 0:
654
+ return []
655
+ keep = self._per_class_hard_nms(coords, scores, cls_ids)
656
+ if len(keep) == 0:
657
+ return []
658
+ if len(keep) > self.max_det:
659
+ keep = keep[np.argsort(-scores[keep])[: self.max_det]]
660
+ boosted = self._max_score_per_cluster(
661
+ coords[keep], cls_ids[keep], coords, scores, cls_ids, self._avg_iou)
662
+ kb, kc = coords[keep], cls_ids[keep]
663
+ if len(kb) > 1:
664
+ kb, boosted, kc = self._cross_class_dedup_op(kb, boosted, kc, self.cross_iou_thresh)
665
+ return self._to_boxes(kb, boosted, kc, meta["orig_size"])
666
+
667
+ def predict_batch(self, batch_images: list[ndarray], offset: int,
668
+ n_keypoints: int) -> list[TVFrameResult]:
 
 
669
  results: list[TVFrameResult] = []
670
+ for idx, image in enumerate(batch_images):
 
671
  try:
672
+ boxes = self._infer_tta(image) if self.use_tta else self._infer_single(image)
673
+ except Exception as e:
674
+ print(f"Inference failed for frame {offset + idx}: {e}")
 
 
675
  boxes = []
676
+ keypoints = [(0, 0) for _ in range(max(0, int(n_keypoints)))]
677
+ results.append(TVFrameResult(frame_id=offset + idx, boxes=boxes, keypoints=keypoints))
678
  return results