meaculpitt commited on
Commit
b071ae7
·
verified ·
1 Parent(s): fb35ef6

v4 weights (yolo26n e2e, 38k merged beverage dataset, val mAP50=0.796, latency ~7ms/frame)

Browse files
Files changed (3) hide show
  1. miner.py +70 -389
  2. model_type.json +1 -1
  3. weights.onnx +2 -2
miner.py CHANGED
@@ -1,41 +1,16 @@
1
- # build-marker: v2-2026-05-01 (regenerate nickname after stuck server-side name reservation)
2
  """SN44 beverage detection miner — single-element chute for
3
  manak0/Detect-beverage-detect.
4
 
5
- Adapted from the auto-generated Manako baseline with three substantive
6
- changes ported from the production numberplate miner:
7
-
8
- 1. CUDA library preload at import time so onnxruntime-gpu finds
9
- libcudnn / libcublas from the nvidia-* pip wheels even when
10
- LD_LIBRARY_PATH is not set.
11
- 2. Letterbox preprocessing (aspect-preserving with grey 114 padding)
12
- instead of anisotropic cv2.resize. Beverage geometry (cylindrical
13
- bottles/cans/cups) is sensitive to AR distortion.
14
- 3. Standard NMS replaced with per-class Gaussian Soft-NMS (sigma=0.5).
15
- Soft-NMS decays scores of overlapping boxes instead of suppressing
16
- them outright. Per-class so that an overlapping bottle and cup don't
17
- suppress each other (beverage scenes routinely have mixed objects in
18
- frame). We use a gentler sigma than the numberplate miner's 0.3
19
- because beverage scenes typically have fewer near-duplicate
20
- detections than plate scenes.
21
-
22
- Plus a GPU warmup pass in __init__ (10 calls on a synthetic frame) to
23
- force ORT/CUDA/cuDNN kernel compilation before the first real
24
- validator frame.
25
-
26
- Soft-NMS is inlined here rather than imported because the chute
27
- platform sandbox restricts non-stdlib imports beyond the deps declared
28
- in chute_config.yml.
29
-
30
- NOT ported from numberplate (intentional):
31
- - SAHI quad-4 tiling: beverage objects are 50–500 px on validator
32
- frames, not 5–30 px like plates — tiling is overkill.
33
- - Horizontal-flip TTA: doubles latency for marginal gain.
34
- - End2end [1,N,6] shape support: our ONNX export uses raw
35
- [1, C, anchors] format with NMS done here.
36
- - Aspect-ratio / max-side output filters: plate-specific (plates
37
- are wide-flat); beverage geometry is the opposite.
38
- - Empty-submission guard: plate-specific failure mode.
39
  """
40
  import ctypes
41
  import glob as _glob
@@ -50,24 +25,14 @@ def _preload_cuda_libs() -> None:
50
  """Pre-load CUDA + cuDNN + cuBLAS shared libs from nvidia-* pip wheels.
51
 
52
  Without this, onnxruntime-gpu's CUDAExecutionProvider silently falls
53
- back to CPU because it can't dlopen libcudnn.so.9 — the nvidia
54
- wheels ship the library inside `nvidia/cudnn/lib/` but do NOT add
55
- that directory to the loader path. We import the wheel modules to
56
- locate their lib dirs, prepend them to LD_LIBRARY_PATH for any
57
- child processes, and ctypes.CDLL the .so files with RTLD_GLOBAL so
58
- onnxruntime's dlopen sees them.
59
  """
60
  try:
61
  lib_dirs: list[str] = []
62
  for mod_name in (
63
- "nvidia.cudnn",
64
- "nvidia.cublas",
65
- "nvidia.cuda_runtime",
66
- "nvidia.cufft",
67
- "nvidia.curand",
68
- "nvidia.cusolver",
69
- "nvidia.cusparse",
70
- "nvidia.nvjitlink",
71
  ):
72
  try:
73
  mod = __import__(mod_name, fromlist=["__file__"])
@@ -85,14 +50,13 @@ def _preload_cuda_libs() -> None:
85
  os.environ["LD_LIBRARY_PATH"] = ":".join(
86
  lib_dirs + ([existing] if existing else [])
87
  )
88
-
89
  for lib_dir in lib_dirs:
90
  for so in sorted(_glob.glob(os.path.join(lib_dir, "lib*.so*"))):
91
  try:
92
  ctypes.CDLL(so, mode=ctypes.RTLD_GLOBAL)
93
  except OSError:
94
  pass
95
- except Exception as e: # pragma: no cover - best effort
96
  _cuda_log.warning("CUDA preload failed: %s", e)
97
 
98
 
@@ -124,27 +88,16 @@ class TVFrameResult(BaseModel):
124
 
125
 
126
  class Miner:
127
- """Single-element ONNX miner for the manak0/Detect-beverage-detect
128
- element. Auto-loaded by the chute platform; the platform passes the
129
- snapshot path of the HF repo containing weights.onnx as
130
- ``path_hf_repo`` and calls ``predict_batch(batch_images, offset,
131
- n_keypoints)`` for each request.
132
  """
133
 
134
  def __init__(self, path_hf_repo) -> None:
135
  self.path_hf_repo = Path(path_hf_repo)
136
- # Class order MUST match the SN44 element manifest's `objects` field:
137
- # https://console.scorevision.io/api/v2/elements/manak0%2FDetect-beverage-detect
138
- # → ["cup","bottle","can"]. Validators score by positional cls_id, so a
139
- # mismatched order silently zeros all per-class scores even with a
140
- # perfect detector.
141
  self.class_names = ["cup", "bottle", "can"]
142
- # Our model was TRAINED with class order [bottle, can, cup] (data.yaml
143
- # at training time). Remap model_cls_id → validator_cls_id so outputs
144
- # land in the slots the validator expects:
145
- # model 0 (bottle) → validator 1 (bottle)
146
- # model 1 (can) → validator 2 (can)
147
- # model 2 (cup) → validator 0 (cup)
148
  self.cls_remap = np.array([1, 2, 0], dtype=np.int32)
149
 
150
  self.session = ort.InferenceSession(
@@ -153,69 +106,42 @@ class Miner:
153
  )
154
  self.input_name = self.session.get_inputs()[0].name
155
 
156
- # Hard-pin to 960x960 — this is the resolution we trained at and
157
- # exported the ONNX with. Single-resolution preprocessing keeps the
158
- # pipeline simple and matches what we've validated. The ONNX itself
159
- # was exported with dynamic axes so it accepts other shapes too,
160
- # but there's no reason to deviate from training resolution.
161
- self.input_h = 960
162
- self.input_w = 960
163
-
164
- # Pre-NMS confidence threshold. v3 sets 0.55 to match the only
165
- # working SN44-beverage miner (alfred8995). Their empirical tuning
166
- # heavily prioritises the false_positive pillar (40% of composite);
167
- # validator-side data shows their 0.55 threshold consistently
168
- # delivers nonzero composites while looser thresholds (incl. our
169
- # earlier 0.4 attempt) score 0.
170
  self.conf_threshold = 0.55
171
- # Gaussian Soft-NMS sigma. 0.5 is the textbook default — gentler
172
- # than numberplate's 0.3 because beverage scenes are less crowded.
173
- self.soft_nms_sigma = 0.5
174
- # Final score floor after Soft-NMS decay.
175
- self.score_threshold = 0.01
176
-
177
- # Sane-box geometry filters. v3 keeps the alfred-aligned values
178
- # (100 area / 8 side / 8 AR). Loosening them was a hypothesis
179
- # that contradicted alfred's working empirical tuning.
180
  self.min_box_area = 100 # 10x10 px²
181
  self.min_side = 8 # min(w, h) in pixels
182
  self.max_aspect_ratio = 8.0 # max(w/h, h/w)
183
- # Per-image detection cap. Mirrors alfred's max_det=150 — caps
184
- # over-prediction in dense or noisy scenes that would otherwise
185
- # tank the false_positive pillar.
186
- self.max_det = 150
187
 
188
- # Horizontal-flip TTA. DISABLED in v3 because the actual latency cap
189
- # is RTF≤1.0 with service_rate_fps=1, which means p95 ≤ 5000 ms per
190
- # /predict call. Empirical batch tests showed our chute fails the
191
- # gate at 20+ frames per call. Halving inference cost (TTA off) gives
192
- # ~2× headroom. Scoring impact: small recall loss (TTA usually adds
193
- # +0.5–2% mAP), worth it to clear the gate.
194
- self.use_tta = False
195
-
196
- # GPU warmup — force ORT/CUDA/cuDNN kernel compilation before the
197
- # first real validator frame. Mirrors the numberplate miner pattern.
198
- # Warmup uses the single-view path (no TTA) — cheaper and sufficient
199
- # to compile all the kernels TTA also exercises.
200
  _warmup_frame = np.zeros((self.input_h, self.input_w, 3), dtype=np.uint8)
201
  for _ in range(10):
202
  try:
203
  self._infer_single(_warmup_frame)
204
- except Exception: # pragma: no cover - best effort
205
  break
206
 
207
  def __repr__(self) -> str:
208
  return (
209
  f"BeverageMiner session={type(self.session).__name__} "
210
- f"input={self.input_h}x{self.input_w} classes={len(self.class_names)}"
211
  )
212
 
213
  # ---------------------------------------------------------------- preproc
214
  def _preprocess(self, image_bgr: ndarray):
215
  """Letterbox the BGR image to (input_h, input_w), preserving aspect.
216
-
217
- Returns the float32 NCHW tensor plus the metadata needed to undo
218
- the letterbox during decode: (orig_h, orig_w, scale, dx, dy).
219
  """
220
  h, w = image_bgr.shape[:2]
221
  scale = min(self.input_h / h, self.input_w / w)
@@ -230,223 +156,44 @@ class Miner:
230
  x = np.transpose(x, (2, 0, 1))[None, ...]
231
  return x, (h, w, scale, dx, dy)
232
 
233
- # ---------------------------------------------------------------- decode
234
- def _normalize_predictions(self, raw: np.ndarray) -> np.ndarray:
235
- """Handle both common ultralytics export shapes ([1,C,N] and [1,N,C])."""
236
- pred = raw[0]
237
- if pred.ndim != 2:
238
- raise ValueError(f"Unexpected prediction shape: {raw.shape}")
239
- if pred.shape[0] < pred.shape[1]:
240
- pred = pred.transpose(1, 0)
241
- return pred
242
-
243
- # ---------------------------------------------------------------- cluster dedup
244
- def _cluster_dedup(
245
- self,
246
- dets: list[tuple[float, float, float, float, float, int]],
247
- iou_thresh: float = 0.5,
248
- ) -> list[tuple[float, float, float, float, float, int]]:
249
- """Per-class greedy near-duplicate suppression.
250
-
251
- For any pair of same-class detections with IoU >= ``iou_thresh``,
252
- keep only the higher-confidence one. Runs BEFORE Soft-NMS to kill
253
- nearly-identical raw detections that Soft-NMS's gentle decay
254
- leaves above ``score_threshold`` (verified failure mode in v1
255
- smoke test: at sigma=0.5 and IoU≈1.0, a 0.94 detection decays to
256
- only 0.13 — still above the 0.01 floor).
257
-
258
- Per-class (not class-agnostic) so an overlapping bottle/cup pair
259
- survives intact, consistent with the per-class Soft-NMS choice.
260
- Cross-class confusion at IoU>=0.5 is rare with our trained model.
261
-
262
- Mirrors the cluster-dedup step in the production numberplate
263
- miner; threshold raised to 0.5 (vs 0.3 there) because we have no
264
- TTA-induced near-duplicates to merge.
265
- """
266
- if not dets:
267
- return []
268
- srt = sorted(dets, key=lambda d: -d[4])
269
- kept: list[tuple[float, float, float, float, float, int]] = []
270
- suppressed = [False] * len(srt)
271
- for i in range(len(srt)):
272
- if suppressed[i]:
273
- continue
274
- x1i, y1i, x2i, y2i = srt[i][0], srt[i][1], srt[i][2], srt[i][3]
275
- cls_i = srt[i][5]
276
- area_i = max(0.0, x2i - x1i) * max(0.0, y2i - y1i)
277
- kept.append(srt[i])
278
- for j in range(i + 1, len(srt)):
279
- if suppressed[j]:
280
- continue
281
- if srt[j][5] != cls_i: # per-class only
282
- continue
283
- x1j, y1j, x2j, y2j = srt[j][0], srt[j][1], srt[j][2], srt[j][3]
284
- ix1 = max(x1i, x1j); iy1 = max(y1i, y1j)
285
- ix2 = min(x2i, x2j); iy2 = min(y2i, y2j)
286
- iw = max(0.0, ix2 - ix1); ih = max(0.0, iy2 - iy1)
287
- inter = iw * ih
288
- area_j = max(0.0, x2j - x1j) * max(0.0, y2j - y1j)
289
- union = area_i + area_j - inter
290
- if union > 0 and inter / union >= iou_thresh:
291
- suppressed[j] = True
292
- return kept
293
-
294
- # ---------------------------------------------------------------- soft NMS
295
- def _soft_nms(
296
- self,
297
- dets: list[tuple[float, float, float, float, float, int]],
298
- ) -> list[tuple[float, float, float, float, float, int]]:
299
- """Per-class Gaussian Soft-NMS.
300
-
301
- Partitions detections by class id, runs the Gaussian decay
302
- independently within each class, then merges and sorts by score
303
- descending. A high-confidence can detection therefore won't
304
- suppress an overlapping bottle detection — beverage scenes
305
- routinely contain mixed objects in close spatial proximity.
306
- """
307
- if not dets:
308
- return []
309
- by_class: dict[int, list[tuple[float, float, float, float, float, int]]] = {}
310
- for d in dets:
311
- by_class.setdefault(int(d[5]), []).append(d)
312
- combined: list[tuple[float, float, float, float, float, int]] = []
313
- for class_dets in by_class.values():
314
- combined.extend(self._soft_nms_per_class_pool(class_dets))
315
- combined.sort(key=lambda d: -d[4])
316
- return combined
317
-
318
- def _soft_nms_per_class_pool(
319
- self,
320
- dets: list[tuple[float, float, float, float, float, int]],
321
- ) -> list[tuple[float, float, float, float, float, int]]:
322
- """Gaussian Soft-NMS over a pool of same-class detections.
323
-
324
- Decays each remaining box's score by ``exp(-iou^2 / sigma)`` against
325
- the highest-scoring picked box, then drops anything below
326
- ``self.score_threshold``. Returns kept detections in descending
327
- decayed-score order.
328
- """
329
- if not dets:
330
- return []
331
-
332
- boxes = np.asarray([[d[0], d[1], d[2], d[3]] for d in dets], dtype=np.float32)
333
- scores = np.asarray([d[4] for d in dets], dtype=np.float32)
334
- cls_ids = [int(d[5]) for d in dets]
335
- n = len(dets)
336
-
337
- keep_idx: list[int] = []
338
- keep_scores: list[float] = []
339
- active = np.ones(n, dtype=bool)
340
-
341
- while True:
342
- valid_mask = active & (scores >= self.score_threshold)
343
- if not valid_mask.any():
344
- break
345
- valid_idx = np.where(valid_mask)[0]
346
- m_local = valid_idx[int(np.argmax(scores[valid_idx]))]
347
-
348
- keep_idx.append(int(m_local))
349
- keep_scores.append(float(scores[m_local]))
350
- active[m_local] = False
351
-
352
- others = np.where(active)[0]
353
- if others.size == 0:
354
- break
355
- ax1 = np.maximum(boxes[m_local, 0], boxes[others, 0])
356
- ay1 = np.maximum(boxes[m_local, 1], boxes[others, 1])
357
- ax2 = np.minimum(boxes[m_local, 2], boxes[others, 2])
358
- ay2 = np.minimum(boxes[m_local, 3], boxes[others, 3])
359
- inter_w = np.clip(ax2 - ax1, a_min=0.0, a_max=None)
360
- inter_h = np.clip(ay2 - ay1, a_min=0.0, a_max=None)
361
- inter = inter_w * inter_h
362
- area_m = max(0.0, (boxes[m_local, 2] - boxes[m_local, 0])) * \
363
- max(0.0, (boxes[m_local, 3] - boxes[m_local, 1]))
364
- area_o = (
365
- np.clip(boxes[others, 2] - boxes[others, 0], a_min=0.0, a_max=None) *
366
- np.clip(boxes[others, 3] - boxes[others, 1], a_min=0.0, a_max=None)
367
- )
368
- union = area_m + area_o - inter
369
- iou = np.where(union > 0.0, inter / union, 0.0)
370
-
371
- decay = np.exp(-(iou * iou) / self.soft_nms_sigma)
372
- scores[others] = scores[others] * decay
373
-
374
- return [
375
- (
376
- float(boxes[i, 0]),
377
- float(boxes[i, 1]),
378
- float(boxes[i, 2]),
379
- float(boxes[i, 3]),
380
- float(s),
381
- cls_ids[i],
382
- )
383
- for i, s in zip(keep_idx, keep_scores)
384
- ]
385
-
386
  # ---------------------------------------------------------------- inference
387
  def _infer_single(self, image_bgr: ndarray) -> list[BoundingBox]:
388
- """Letterbox preprocess -> ONNX -> unletterbox -> per-class Soft-NMS -> BoundingBox list."""
389
  inp, (orig_h, orig_w, scale, dx, dy) = self._preprocess(image_bgr)
390
- out = self.session.run(None, {self.input_name: inp})[0]
391
- pred = self._normalize_predictions(out)
392
-
393
- if pred.shape[1] < 5:
394
  return []
395
 
396
- boxes_m = pred[:, :4]
397
- cls_scores = pred[:, 4:]
398
- if cls_scores.shape[1] == 0:
399
- return []
400
-
401
- cls_ids = np.argmax(cls_scores, axis=1)
402
- # Remap model-trained class order [bottle,can,cup] → validator order
403
- # [cup,bottle,can]. See cls_remap definition in __init__.
404
- cls_ids = self.cls_remap[cls_ids]
405
- confs = np.max(cls_scores, axis=1)
406
  keep = confs >= self.conf_threshold
407
- boxes_m = boxes_m[keep]
408
- confs = confs[keep]
409
- cls_ids = cls_ids[keep]
410
- if boxes_m.shape[0] == 0:
411
  return []
 
 
 
 
 
 
 
 
412
 
413
- # Decode model-space cx,cy,w,h -> letterbox-space xyxy -> original xyxy
414
- # via inverse letterbox: (model - pad) / scale.
415
- dets: list[tuple[float, float, float, float, float, int]] = []
416
- for i in range(boxes_m.shape[0]):
417
- cx, cy, bw, bh = boxes_m[i].tolist()
418
- x1m = cx - bw / 2.0
419
- y1m = cy - bh / 2.0
420
- x2m = cx + bw / 2.0
421
- y2m = cy + bh / 2.0
422
- x1 = (x1m - dx) / scale
423
- y1 = (y1m - dy) / scale
424
- x2 = (x2m - dx) / scale
425
- y2 = (y2m - dy) / scale
426
- dets.append((x1, y1, x2, y2, float(confs[i]), int(cls_ids[i])))
427
-
428
- # Pre-NMS dedup: kill same-class near-duplicates (IoU >= 0.5) that
429
- # would otherwise survive Soft-NMS's gentle decay above the score floor.
430
- dets = self._cluster_dedup(dets, iou_thresh=0.5)
431
- dets = self._soft_nms(dets)
432
- # Cap per-image detection count (mirrors alfred). Soft-NMS already
433
- # returns dets sorted by descending decayed score, so [:max_det]
434
- # keeps the top-confidence ones.
435
- if len(dets) > self.max_det:
436
- dets = dets[: self.max_det]
437
 
438
  out_boxes: list[BoundingBox] = []
439
- for x1, y1, x2, y2, conf, cls_id in dets:
 
440
  ix1 = max(0, min(orig_w, math.floor(x1)))
441
  iy1 = max(0, min(orig_h, math.floor(y1)))
442
  ix2 = max(0, min(orig_w, math.ceil(x2)))
443
  iy2 = max(0, min(orig_h, math.ceil(y2)))
444
  if ix2 <= ix1 or iy2 <= iy1:
445
  continue
446
- # Sane-box geometry filters (drop spurious detections that hurt
447
- # the false_positive pillar). See __init__ for thresholds.
448
- bw = ix2 - ix1
449
- bh = iy2 - iy1
450
  if bw * bh < self.min_box_area:
451
  continue
452
  if min(bw, bh) < self.min_side:
@@ -454,64 +201,12 @@ class Miner:
454
  ar = max(bw / max(bh, 1), bh / max(bw, 1))
455
  if ar > self.max_aspect_ratio:
456
  continue
457
- out_boxes.append(
458
- BoundingBox(
459
- x1=ix1,
460
- y1=iy1,
461
- x2=ix2,
462
- y2=iy2,
463
- cls_id=cls_id,
464
- conf=max(0.0, min(1.0, conf)),
465
- )
466
- )
467
- return out_boxes
468
-
469
- # ---------------------------------------------------------------- TTA
470
- def _infer_with_tta(self, image_bgr: ndarray) -> list[BoundingBox]:
471
- """Horizontal-flip TTA wrapper.
472
-
473
- Runs _infer_single on the original AND the h-flipped image, un-flips
474
- the x-coordinates of the flipped-view detections, then merges via
475
- cluster_dedup at IoU >= 0.5 to suppress duplicates between the two
476
- views. Mirrors the TTA pattern used by the current top miner
477
- (alfred8995) and our prior numberplate deployment.
478
- """
479
- boxes_orig = self._infer_single(image_bgr)
480
- flipped = cv2.flip(image_bgr, 1)
481
- h, w = image_bgr.shape[:2]
482
- boxes_flip_raw = self._infer_single(flipped)
483
- # Un-flip x coordinates (validator expects original-image space)
484
- boxes_flip: list[BoundingBox] = []
485
- for b in boxes_flip_raw:
486
- boxes_flip.append(BoundingBox(
487
- x1=w - b.x2,
488
- y1=b.y1,
489
- x2=w - b.x1,
490
- y2=b.y2,
491
- cls_id=b.cls_id,
492
- conf=b.conf,
493
  ))
494
- if not boxes_orig and not boxes_flip:
495
- return []
496
- if not boxes_flip:
497
- return boxes_orig
498
- if not boxes_orig:
499
- return boxes_flip
500
- # Merge via cluster_dedup. Convert BoundingBox → det tuple → dedup →
501
- # back to BoundingBox.
502
- dets = [
503
- (float(b.x1), float(b.y1), float(b.x2), float(b.y2), float(b.conf), int(b.cls_id))
504
- for b in (boxes_orig + boxes_flip)
505
- ]
506
- dets = self._cluster_dedup(dets, iou_thresh=0.5)
507
- return [
508
- BoundingBox(
509
- x1=int(d[0]), y1=int(d[1]), x2=int(d[2]), y2=int(d[3]),
510
- cls_id=int(d[5]),
511
- conf=max(0.0, min(1.0, float(d[4]))),
512
- )
513
- for d in dets
514
- ]
515
 
516
  # ---------------------------------------------------------------- entry
517
  def predict_batch(
@@ -520,27 +215,13 @@ class Miner:
520
  offset: int,
521
  n_keypoints: int,
522
  ) -> list[TVFrameResult]:
523
- # v3 diagnostic: log batch_size to stderr so chute logs reveal what
524
- # the validator actually sends per /predict call. Used to confirm
525
- # whether the latency gate is the failure mode (large batches ⇒
526
- # high p95). Cheap; one print per batch.
527
- import sys as _sys
528
- _sys.stderr.write(
529
- f"[trace] predict_batch n={len(batch_images)} offset={offset} "
530
- f"n_kp={n_keypoints} use_tta={self.use_tta}\n"
531
- )
532
- _sys.stderr.flush()
533
-
534
  results: list[TVFrameResult] = []
535
- infer = self._infer_with_tta if self.use_tta else self._infer_single
536
  for idx, image in enumerate(batch_images):
537
- boxes = infer(image)
538
  keypoints = [(0, 0) for _ in range(max(0, int(n_keypoints)))]
539
- results.append(
540
- TVFrameResult(
541
- frame_id=offset + idx,
542
- boxes=boxes,
543
- keypoints=keypoints,
544
- )
545
- )
546
  return results
 
1
+ # build-marker: v4-yolo26n
2
  """SN44 beverage detection miner — single-element chute for
3
  manak0/Detect-beverage-detect.
4
 
5
+ yolo26n with end-to-end ONNX export. The [1, 300, 6] output bakes the
6
+ TopK NMS-equivalent into the graph, so this miner does NO post-NMS:
7
+
8
+ preprocess (letterbox 640) -> ONNX -> conf filter -> cls_remap ->
9
+ unletterbox -> geometry filters -> max_det cap
10
+
11
+ Compared to the previous yolo11m miner, dropped: raw [1,C,N] decode,
12
+ per-class Soft-NMS, cluster dedup, TTA, score-decay floor. The model
13
+ itself produces deduplicated boxes with conf and cls already chosen.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  """
15
  import ctypes
16
  import glob as _glob
 
25
  """Pre-load CUDA + cuDNN + cuBLAS shared libs from nvidia-* pip wheels.
26
 
27
  Without this, onnxruntime-gpu's CUDAExecutionProvider silently falls
28
+ back to CPU because it can't dlopen libcudnn.so.9.
 
 
 
 
 
29
  """
30
  try:
31
  lib_dirs: list[str] = []
32
  for mod_name in (
33
+ "nvidia.cudnn", "nvidia.cublas", "nvidia.cuda_runtime",
34
+ "nvidia.cufft", "nvidia.curand", "nvidia.cusolver",
35
+ "nvidia.cusparse", "nvidia.nvjitlink",
 
 
 
 
 
36
  ):
37
  try:
38
  mod = __import__(mod_name, fromlist=["__file__"])
 
50
  os.environ["LD_LIBRARY_PATH"] = ":".join(
51
  lib_dirs + ([existing] if existing else [])
52
  )
 
53
  for lib_dir in lib_dirs:
54
  for so in sorted(_glob.glob(os.path.join(lib_dir, "lib*.so*"))):
55
  try:
56
  ctypes.CDLL(so, mode=ctypes.RTLD_GLOBAL)
57
  except OSError:
58
  pass
59
+ except Exception as e:
60
  _cuda_log.warning("CUDA preload failed: %s", e)
61
 
62
 
 
88
 
89
 
90
  class Miner:
91
+ """yolo26n end-to-end ONNX miner for manak0/Detect-beverage-detect.
92
+ Chute platform calls predict_batch(batch_images, offset, n_keypoints).
 
 
 
93
  """
94
 
95
  def __init__(self, path_hf_repo) -> None:
96
  self.path_hf_repo = Path(path_hf_repo)
97
+ # Validator's positional class order from the SN44 element manifest.
 
 
 
 
98
  self.class_names = ["cup", "bottle", "can"]
99
+ # Model trained with data.yaml class order [bottle, can, cup].
100
+ # Remap: model 0 (bottle) -> 1, model 1 (can) -> 2, model 2 (cup) -> 0.
 
 
 
 
101
  self.cls_remap = np.array([1, 2, 0], dtype=np.int32)
102
 
103
  self.session = ort.InferenceSession(
 
106
  )
107
  self.input_name = self.session.get_inputs()[0].name
108
 
109
+ # Match training and ONNX export resolution.
110
+ self.input_h = 640
111
+ self.input_w = 640
112
+
113
+ # Confidence floor (alfred-aligned). The e2e graph emits up to 300
114
+ # detections padded with low-conf entries; this drops them and
115
+ # tightens the false_positive pillar.
 
 
 
 
 
 
 
116
  self.conf_threshold = 0.55
117
+
118
+ # Per-image detection cap. Graph already TopKs at 300; we cap
119
+ # tighter to mirror alfred's max_det=150.
120
+ self.max_det = 150
121
+
122
+ # Geometry filters (kept from v3).
 
 
 
123
  self.min_box_area = 100 # 10x10 px²
124
  self.min_side = 8 # min(w, h) in pixels
125
  self.max_aspect_ratio = 8.0 # max(w/h, h/w)
 
 
 
 
126
 
127
+ # GPU warmup — force ORT/CUDA/cuDNN kernel compilation.
 
 
 
 
 
 
 
 
 
 
 
128
  _warmup_frame = np.zeros((self.input_h, self.input_w, 3), dtype=np.uint8)
129
  for _ in range(10):
130
  try:
131
  self._infer_single(_warmup_frame)
132
+ except Exception:
133
  break
134
 
135
  def __repr__(self) -> str:
136
  return (
137
  f"BeverageMiner session={type(self.session).__name__} "
138
+ f"input={self.input_h}x{self.input_w} classes={len(self.class_names)} e2e=True"
139
  )
140
 
141
  # ---------------------------------------------------------------- preproc
142
  def _preprocess(self, image_bgr: ndarray):
143
  """Letterbox the BGR image to (input_h, input_w), preserving aspect.
144
+ Returns (NCHW float32 tensor, (orig_h, orig_w, scale, dx, dy)).
 
 
145
  """
146
  h, w = image_bgr.shape[:2]
147
  scale = min(self.input_h / h, self.input_w / w)
 
156
  x = np.transpose(x, (2, 0, 1))[None, ...]
157
  return x, (h, w, scale, dx, dy)
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  # ---------------------------------------------------------------- inference
160
  def _infer_single(self, image_bgr: ndarray) -> list[BoundingBox]:
161
+ """Letterbox -> e2e ONNX -> conf filter -> cls_remap -> unletterbox -> geometry filter."""
162
  inp, (orig_h, orig_w, scale, dx, dy) = self._preprocess(image_bgr)
163
+ # e2e output: shape [1, 300, 6] = [batch, max_dets, (x1,y1,x2,y2,conf,cls)]
164
+ out = self.session.run(None, {self.input_name: inp})[0][0]
165
+ if out.size == 0:
 
166
  return []
167
 
168
+ confs = out[:, 4]
 
 
 
 
 
 
 
 
 
169
  keep = confs >= self.conf_threshold
170
+ if not keep.any():
 
 
 
171
  return []
172
+ out = out[keep]
173
+
174
+ # Vectorized unletterbox: model-space xyxy -> original-image xyxy
175
+ boxes = out[:, :4].copy()
176
+ boxes[:, [0, 2]] = (boxes[:, [0, 2]] - dx) / scale
177
+ boxes[:, [1, 3]] = (boxes[:, [1, 3]] - dy) / scale
178
+ confs = out[:, 4]
179
+ cls_ids = self.cls_remap[out[:, 5].astype(np.int32)]
180
 
181
+ # Sort by descending conf, cap at max_det.
182
+ order = np.argsort(-confs)[: self.max_det]
183
+ boxes = boxes[order]
184
+ confs = confs[order]
185
+ cls_ids = cls_ids[order]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
  out_boxes: list[BoundingBox] = []
188
+ for i in range(boxes.shape[0]):
189
+ x1, y1, x2, y2 = boxes[i]
190
  ix1 = max(0, min(orig_w, math.floor(x1)))
191
  iy1 = max(0, min(orig_h, math.floor(y1)))
192
  ix2 = max(0, min(orig_w, math.ceil(x2)))
193
  iy2 = max(0, min(orig_h, math.ceil(y2)))
194
  if ix2 <= ix1 or iy2 <= iy1:
195
  continue
196
+ bw, bh = ix2 - ix1, iy2 - iy1
 
 
 
197
  if bw * bh < self.min_box_area:
198
  continue
199
  if min(bw, bh) < self.min_side:
 
201
  ar = max(bw / max(bh, 1), bh / max(bw, 1))
202
  if ar > self.max_aspect_ratio:
203
  continue
204
+ out_boxes.append(BoundingBox(
205
+ x1=ix1, y1=iy1, x2=ix2, y2=iy2,
206
+ cls_id=int(cls_ids[i]),
207
+ conf=max(0.0, min(1.0, float(confs[i]))),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  ))
209
+ return out_boxes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
  # ---------------------------------------------------------------- entry
212
  def predict_batch(
 
215
  offset: int,
216
  n_keypoints: int,
217
  ) -> list[TVFrameResult]:
 
 
 
 
 
 
 
 
 
 
 
218
  results: list[TVFrameResult] = []
 
219
  for idx, image in enumerate(batch_images):
220
+ boxes = self._infer_single(image)
221
  keypoints = [(0, 0) for _ in range(max(0, int(n_keypoints)))]
222
+ results.append(TVFrameResult(
223
+ frame_id=offset + idx,
224
+ boxes=boxes,
225
+ keypoints=keypoints,
226
+ ))
 
 
227
  return results
model_type.json CHANGED
@@ -1,4 +1,4 @@
1
  {
2
  "task_type": "object-detection",
3
- "model_type": "yolov11-medium"
4
  }
 
1
  {
2
  "task_type": "object-detection",
3
+ "model_type": "yolov26-nano"
4
  }
weights.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:53894da05455855eccf32cc93b02621535ba6e53577e7a4fc6dad576a77b0377
3
- size 81084027
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1f4ba482d27432962803cf41741c0277abcd5cabda3b4aab6a27d4b3daad9422
3
+ size 9806734