meaculpitt commited on
Commit
40dd494
·
verified ·
1 Parent(s): 05d138f

scorevision: push artifact

Browse files
Files changed (1) hide show
  1. miner.py.pre_tb2_20260411T091548Z +352 -0
miner.py.pre_tb2_20260411T091548Z ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SN44 number plate detection miner — single-element chute for
3
+ manak0/Detect-number-plates-1-0.
4
+
5
+ Adapted from the auto-generated detect-person-reference miner with four
6
+ substantive changes:
7
+
8
+ 1. Class set is the single class ``numberplate`` (the validator's exact
9
+ label string).
10
+ 2. Lower confidence threshold (0.15 vs 0.25) because the validator's
11
+ plates are tiny — 5–92 px wide on a 1408 px frame, median ~30 px.
12
+ At standard 0.25 most true positives get filtered before NMS.
13
+ 3. Standard NMS replaced with Gaussian Soft-NMS (sigma=0.5). Soft-NMS
14
+ decays scores of overlapping boxes instead of suppressing them
15
+ outright, which helps on plate-dense frames (parking lot, car
16
+ carrier, gas station forecourt) where standard NMS over-suppresses
17
+ adjacent plates.
18
+ 4. CUDA library preload at import time so onnxruntime-gpu finds
19
+ libcudnn / libcublas from the nvidia-* pip wheels even when
20
+ LD_LIBRARY_PATH is not set (the chute container ships these wheels
21
+ but does not export them).
22
+
23
+ Soft-NMS is inlined here rather than imported from /home/miner/utils
24
+ because the chute platform sandbox restricts non-stdlib imports beyond
25
+ the deps declared in chute_config.yml. The implementation is a
26
+ specialised single-class version of soft_nms_yolo from
27
+ /home/miner/utils/soft_nms.py — see that file for the full
28
+ multi-class / multi-backend version.
29
+ """
30
+ import ctypes
31
+ import glob as _glob
32
+ import logging as _logging
33
+ import os
34
+
35
+ _cuda_log = _logging.getLogger(__name__)
36
+
37
+
38
+ def _preload_cuda_libs() -> None:
39
+ """Pre-load CUDA + cuDNN + cuBLAS shared libs from nvidia-* pip wheels.
40
+
41
+ Without this, onnxruntime-gpu's CUDAExecutionProvider silently falls
42
+ back to CPU because it can't dlopen libcudnn.so.9 — the nvidia
43
+ wheels ship the library inside `nvidia/cudnn/lib/` but do NOT add
44
+ that directory to the loader path. We import the wheel modules to
45
+ locate their lib dirs, prepend them to LD_LIBRARY_PATH for any
46
+ child processes, and ctypes.CDLL the .so files with RTLD_GLOBAL so
47
+ onnxruntime's dlopen sees them.
48
+ """
49
+ try:
50
+ lib_dirs: list[str] = []
51
+ for mod_name in (
52
+ "nvidia.cudnn",
53
+ "nvidia.cublas",
54
+ "nvidia.cuda_runtime",
55
+ "nvidia.cufft",
56
+ "nvidia.curand",
57
+ "nvidia.cusolver",
58
+ "nvidia.cusparse",
59
+ "nvidia.nvjitlink",
60
+ ):
61
+ try:
62
+ mod = __import__(mod_name, fromlist=["__file__"])
63
+ lib_dir = os.path.join(os.path.dirname(mod.__file__), "lib")
64
+ if os.path.isdir(lib_dir) and lib_dir not in lib_dirs:
65
+ lib_dirs.append(lib_dir)
66
+ except ImportError:
67
+ pass
68
+
69
+ if not lib_dirs:
70
+ _cuda_log.warning("no nvidia-* lib dirs found; ORT GPU may fall back to CPU")
71
+ return
72
+
73
+ # Update LD_LIBRARY_PATH for any child processes / dlopen fallbacks
74
+ existing = os.environ.get("LD_LIBRARY_PATH", "")
75
+ os.environ["LD_LIBRARY_PATH"] = ":".join(
76
+ lib_dirs + ([existing] if existing else [])
77
+ )
78
+
79
+ # ctypes.CDLL each .so so the symbols are globally visible to ORT
80
+ for lib_dir in lib_dirs:
81
+ for so in sorted(_glob.glob(os.path.join(lib_dir, "lib*.so*"))):
82
+ try:
83
+ ctypes.CDLL(so, mode=ctypes.RTLD_GLOBAL)
84
+ except OSError:
85
+ pass
86
+ except Exception as e: # pragma: no cover - best effort
87
+ _cuda_log.warning("CUDA preload failed: %s", e)
88
+
89
+
90
+ _preload_cuda_libs()
91
+
92
+
93
+ from pathlib import Path
94
+ import math
95
+
96
+ import cv2
97
+ import numpy as np
98
+ import onnxruntime as ort
99
+ from numpy import ndarray
100
+ from pydantic import BaseModel
101
+
102
+
103
+ class BoundingBox(BaseModel):
104
+ x1: int
105
+ y1: int
106
+ x2: int
107
+ y2: int
108
+ cls_id: int
109
+ conf: float
110
+
111
+
112
+ class TVFrameResult(BaseModel):
113
+ frame_id: int
114
+ boxes: list[BoundingBox]
115
+ keypoints: list[tuple[int, int]]
116
+
117
+
118
+ class Miner:
119
+ """
120
+ Single-element ONNX miner for the manak0/Detect-number-plates-1-0
121
+ element. Auto-loaded by the chute platform; the platform passes the
122
+ snapshot path of the HF repo containing weights.onnx as
123
+ ``path_hf_repo`` and calls ``predict_batch(batch_images, offset,
124
+ n_keypoints)`` for each request.
125
+ """
126
+
127
+ def __init__(self, path_hf_repo) -> None:
128
+ self.path_hf_repo = Path(path_hf_repo)
129
+ self.class_names = ['numberplate']
130
+ self.session = ort.InferenceSession(
131
+ str(self.path_hf_repo / "numberplate_weights.onnx"),
132
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
133
+ )
134
+ self.input_name = self.session.get_inputs()[0].name
135
+ input_shape = self.session.get_inputs()[0].shape
136
+ # expected [N, C, H, W]; dynamic-export ONNX has string placeholders
137
+ # for spatial dims. We always run inference at 1408 (the validator's
138
+ # native frame width); the ONNX accepts variable shapes via dynamic
139
+ # axes, and inference at 1408 gives substantially better small-plate
140
+ # recall than the model's training resolution (verified on the 7
141
+ # starter assets: 43% recall at 960 vs 60% at 1408).
142
+ def _maybe_int(d, default):
143
+ try:
144
+ return int(d)
145
+ except (TypeError, ValueError):
146
+ return default
147
+ # Hard-pin to the validator's native 1408x768 (rectangular). This
148
+ # is half the pixel count of a 1408x1408 square pad and matches
149
+ # the validator's exact frame shape, eliminating wasted padding
150
+ # rows. yolo11s strides are 32, both 1408 (44*32) and 768 (24*32)
151
+ # are valid.
152
+ self.input_h = 768
153
+ self.input_w = 1408
154
+ # Record what the ONNX *declared*, for diagnostic logging only
155
+ self._onnx_declared_h = _maybe_int(input_shape[2], None)
156
+ self._onnx_declared_w = _maybe_int(input_shape[3], None)
157
+
158
+ # Pre-NMS confidence threshold. The reference uses 0.25; we lower
159
+ # slightly because validator plates are tiny but not as far as 0.15
160
+ # which produces too many decayed-score ghost detections at 1408
161
+ # input resolution (verified on starter assets: F1 dropped from
162
+ # 0.625 to 0.462 at conf=0.15).
163
+ self.conf_threshold = 0.25
164
+ # Soft-NMS hyperparameters (Gaussian variant).
165
+ self.soft_nms_sigma = 0.5
166
+ # Final score floor after Soft-NMS decay. At higher input resolution
167
+ # the model produces more medium-confidence detections that survive
168
+ # decay; we keep this stricter so they don't pollute the output.
169
+ self.score_threshold = 0.20
170
+
171
+ def __repr__(self) -> str:
172
+ return (
173
+ f"NumberplateMiner session={type(self.session).__name__} "
174
+ f"input={self.input_h}x{self.input_w} classes={len(self.class_names)}"
175
+ )
176
+
177
+ # ---------------------------------------------------------------- preproc
178
+ def _preprocess(self, image_bgr: ndarray):
179
+ """Letterbox the BGR image to (input_h, input_w), preserving aspect.
180
+
181
+ Returns the float32 NCHW tensor plus the metadata needed to undo
182
+ the letterbox during decode: (orig_h, orig_w, scale, dx, dy).
183
+ """
184
+ h, w = image_bgr.shape[:2]
185
+ scale = min(self.input_h / h, self.input_w / w)
186
+ nh, nw = int(round(h * scale)), int(round(w * scale))
187
+ resized = cv2.resize(image_bgr, (nw, nh))
188
+ # Pad to (input_h, input_w) with grey (114) - ultralytics default
189
+ canvas = np.full((self.input_h, self.input_w, 3), 114, dtype=np.uint8)
190
+ dy = (self.input_h - nh) // 2
191
+ dx = (self.input_w - nw) // 2
192
+ canvas[dy:dy + nh, dx:dx + nw] = resized
193
+ rgb = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB)
194
+ x = rgb.astype(np.float32) / 255.0
195
+ x = np.transpose(x, (2, 0, 1))[None, ...]
196
+ return x, (h, w, scale, dx, dy)
197
+
198
+ # ---------------------------------------------------------------- decode
199
+ def _normalize_predictions(self, raw: np.ndarray) -> np.ndarray:
200
+ """Handle both common ultralytics export shapes ([1,C,N] and [1,N,C])."""
201
+ pred = raw[0]
202
+ if pred.ndim != 2:
203
+ raise ValueError(f"Unexpected prediction shape: {raw.shape}")
204
+ if pred.shape[0] < pred.shape[1]:
205
+ pred = pred.transpose(1, 0)
206
+ return pred
207
+
208
+ # ---------------------------------------------------------------- soft NMS
209
+ def _soft_nms(
210
+ self,
211
+ dets: list[tuple[float, float, float, float, float, int]],
212
+ ) -> list[tuple[float, float, float, float, float, int]]:
213
+ """Gaussian Soft-NMS for a single class.
214
+
215
+ Decays each remaining box's score by ``exp(-iou^2 / sigma)`` against
216
+ the highest-scoring picked box, then drops anything below
217
+ ``self.score_threshold``. Returns detections in descending decayed
218
+ score order.
219
+ """
220
+ if not dets:
221
+ return []
222
+
223
+ boxes = np.asarray([[d[0], d[1], d[2], d[3]] for d in dets], dtype=np.float32)
224
+ scores = np.asarray([d[4] for d in dets], dtype=np.float32)
225
+ cls_ids = [int(d[5]) for d in dets]
226
+ n = len(dets)
227
+
228
+ keep_idx: list[int] = []
229
+ keep_scores: list[float] = []
230
+ active = np.ones(n, dtype=bool)
231
+
232
+ while True:
233
+ valid_mask = active & (scores >= self.score_threshold)
234
+ if not valid_mask.any():
235
+ break
236
+ valid_idx = np.where(valid_mask)[0]
237
+ m_local = valid_idx[int(np.argmax(scores[valid_idx]))]
238
+
239
+ keep_idx.append(int(m_local))
240
+ keep_scores.append(float(scores[m_local]))
241
+ active[m_local] = False
242
+
243
+ # IoU of m_local against all still-active boxes
244
+ others = np.where(active)[0]
245
+ if others.size == 0:
246
+ break
247
+ ax1 = np.maximum(boxes[m_local, 0], boxes[others, 0])
248
+ ay1 = np.maximum(boxes[m_local, 1], boxes[others, 1])
249
+ ax2 = np.minimum(boxes[m_local, 2], boxes[others, 2])
250
+ ay2 = np.minimum(boxes[m_local, 3], boxes[others, 3])
251
+ inter_w = np.clip(ax2 - ax1, a_min=0.0, a_max=None)
252
+ inter_h = np.clip(ay2 - ay1, a_min=0.0, a_max=None)
253
+ inter = inter_w * inter_h
254
+ area_m = max(0.0, (boxes[m_local, 2] - boxes[m_local, 0])) * \
255
+ max(0.0, (boxes[m_local, 3] - boxes[m_local, 1]))
256
+ area_o = (
257
+ np.clip(boxes[others, 2] - boxes[others, 0], a_min=0.0, a_max=None) *
258
+ np.clip(boxes[others, 3] - boxes[others, 1], a_min=0.0, a_max=None)
259
+ )
260
+ union = area_m + area_o - inter
261
+ iou = np.where(union > 0.0, inter / union, 0.0)
262
+
263
+ decay = np.exp(-(iou * iou) / self.soft_nms_sigma)
264
+ scores[others] = scores[others] * decay
265
+
266
+ return [
267
+ (
268
+ float(boxes[i, 0]),
269
+ float(boxes[i, 1]),
270
+ float(boxes[i, 2]),
271
+ float(boxes[i, 3]),
272
+ float(s),
273
+ cls_ids[i],
274
+ )
275
+ for i, s in zip(keep_idx, keep_scores)
276
+ ]
277
+
278
+ # ---------------------------------------------------------------- inference
279
+ def _infer_single(self, image_bgr: ndarray) -> list[BoundingBox]:
280
+ inp, (orig_h, orig_w, scale, dx, dy) = self._preprocess(image_bgr)
281
+ out = self.session.run(None, {self.input_name: inp})[0]
282
+ pred = self._normalize_predictions(out)
283
+
284
+ if pred.shape[1] < 5:
285
+ return []
286
+
287
+ boxes = pred[:, :4]
288
+ cls_scores = pred[:, 4:]
289
+ if cls_scores.shape[1] == 0:
290
+ return []
291
+
292
+ cls_ids = np.argmax(cls_scores, axis=1)
293
+ confs = np.max(cls_scores, axis=1)
294
+ keep = confs >= self.conf_threshold
295
+
296
+ boxes = boxes[keep]
297
+ confs = confs[keep]
298
+ cls_ids = cls_ids[keep]
299
+
300
+ if boxes.shape[0] == 0:
301
+ return []
302
+
303
+ # Undo letterbox: model coords -> remove pad -> divide by scale ->
304
+ # original image coords
305
+ dets: list[tuple[float, float, float, float, float, int]] = []
306
+ for i in range(boxes.shape[0]):
307
+ cx, cy, bw, bh = boxes[i].tolist()
308
+ x1 = (cx - bw / 2.0 - dx) / scale
309
+ y1 = (cy - bh / 2.0 - dy) / scale
310
+ x2 = (cx + bw / 2.0 - dx) / scale
311
+ y2 = (cy + bh / 2.0 - dy) / scale
312
+ dets.append((x1, y1, x2, y2, float(confs[i]), int(cls_ids[i])))
313
+
314
+ dets = self._soft_nms(dets)
315
+
316
+ out_boxes: list[BoundingBox] = []
317
+ for x1, y1, x2, y2, conf, cls_id in dets:
318
+ ix1 = max(0, min(orig_w, math.floor(x1)))
319
+ iy1 = max(0, min(orig_h, math.floor(y1)))
320
+ ix2 = max(0, min(orig_w, math.ceil(x2)))
321
+ iy2 = max(0, min(orig_h, math.ceil(y2)))
322
+ out_boxes.append(
323
+ BoundingBox(
324
+ x1=ix1,
325
+ y1=iy1,
326
+ x2=ix2,
327
+ y2=iy2,
328
+ cls_id=cls_id,
329
+ conf=max(0.0, min(1.0, conf)),
330
+ )
331
+ )
332
+ return out_boxes
333
+
334
+ # ---------------------------------------------------------------- entry
335
+ def predict_batch(
336
+ self,
337
+ batch_images: list[ndarray],
338
+ offset: int,
339
+ n_keypoints: int,
340
+ ) -> list[TVFrameResult]:
341
+ results: list[TVFrameResult] = []
342
+ for idx, image in enumerate(batch_images):
343
+ boxes = self._infer_single(image)
344
+ keypoints = [(0, 0) for _ in range(max(0, int(n_keypoints)))]
345
+ results.append(
346
+ TVFrameResult(
347
+ frame_id=offset + idx,
348
+ boxes=boxes,
349
+ keypoints=keypoints,
350
+ )
351
+ )
352
+ return results