thomaskk2024 commited on
Commit
d37dcb1
Β·
verified Β·
1 Parent(s): f061f3e

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. miner.py +42 -83
  2. weights.onnx +2 -2
miner.py CHANGED
@@ -25,44 +25,40 @@ class TVFrameResult(BaseModel):
25
 
26
  class Miner:
27
  """
28
- YOLO ONNX miner for car wash detection. Single forward pass per frame (no TTA).
 
29
 
30
- Classes: broom, drainage gate, nozzle, track
31
 
32
- Pipeline per frame: preprocess -> ONNX -> decode -> per-class conf threshold
33
- (+ rescue bonus) -> un-letterbox -> sanity filter -> per-class NMS ->
34
- cross-class dedup -> same-class cluster score boost -> results.
35
 
36
- Speed characteristics:
37
- - The detection pipeline runs exactly ONCE per frame.
38
- - `_max_score_per_cluster` (the cluster boost) is a single vectorized IoU
39
- matrix, so cost stays flat as the number of detected objects grows
40
- instead of scaling like a Python loop.
41
- - `_hard_nms` precomputes box areas once; `pre_nms_topk` bounds NMS cost
42
- on pathologically crowded frames.
43
  """
44
 
45
- class_names = ['broom', 'drainage gate', 'nozzle', 'track']
46
- input_size = 640
47
- cross_iou_thresh = 0.9
48
  max_det = 300
49
- # NMS is O(n^2). If a frame yields a huge candidate list, keep only the
50
- # top-K by score before NMS. Set high enough to never touch real detections.
51
  pre_nms_topk = 1000
52
  #overlap_suppress_threshold = 0.85
53
 
54
- # Per-class confidence thresholds
55
- _conf_thres_array = np.array([0.35, 0.7, 0.4, 0.7], dtype=np.float32)
56
- _extra_conf_thres_array = np.array([0.32, 0.3, 0.36, 0.3], dtype=np.float32)
57
 
58
- # Per-class IoU thresholds for same-class NMS
59
- _iou_thres_array = np.array([0.6, 0.7, 0.5, 0.7], dtype=np.float32)
60
 
61
  # Per-class rescue bonus
62
- _bonus_array = np.array([0.2, 0.2, 0.0, 0.2], dtype=np.float32)
63
 
64
- # Per-class minimum box area (0=broom, 1=drainage gate, 2=nozzle, 3=track)
65
- _min_box_area_array = np.array([144.0, 144.0, 4.0, 64.0], dtype=np.float32)
66
 
67
  def __init__(self, path_hf_repo: Path) -> None:
68
  self.path_hf_repo = path_hf_repo
@@ -87,7 +83,6 @@ class Miner:
87
  )
88
  print("Created ORT session with preferred CUDA provider list")
89
  print("ORT session providers:", self.session.get_providers())
90
- # If CUDAExecutionProvider is NOT listed above, you are running on CPU.
91
 
92
  self.input_name = self.session.get_inputs()[0].name
93
  input_shape = self.session.get_inputs()[0].shape
@@ -95,9 +90,9 @@ class Miner:
95
  self.input_h = self._safe_dim(input_shape[2], default=self.input_size)
96
  self.input_w = self._safe_dim(input_shape[3], default=self.input_size)
97
 
98
- # Same-class cluster score boost. Raises the confidence of overlapping
99
- # same-class survivors to their cluster max. Part of the current tuned
100
- # behaviour; set False to disable (slightly faster, changes confidences).
101
  self.use_cluster_boost = True
102
  self._avg_iou = float(np.mean(self._iou_thres_array))
103
 
@@ -113,7 +108,7 @@ class Miner:
113
  print(f"warmup skipped: {e}")
114
 
115
  def __repr__(self) -> str:
116
- return f"Car Wash Miner classes={len(self.class_names)}"
117
 
118
  @staticmethod
119
  def _safe_dim(value, default: int) -> int:
@@ -150,30 +145,19 @@ class Miner:
150
  )
151
  return out, r, pad_w, pad_h
152
 
153
- def _preprocess(self, image_bgr: np.ndarray,
154
- allow_pad: bool = True) -> tuple[np.ndarray, dict]:
155
  orig_h, orig_w = image_bgr.shape[:2]
156
- extra_left = 0
157
- extra_right = 0
158
- if allow_pad and orig_w == orig_h: # only pad when allowed
159
- target_w = int(orig_w * 1.05)
160
- if target_w > orig_w:
161
- total_extra = target_w - orig_w
162
- extra_left = total_extra // 2
163
- extra_right = total_extra - extra_left
164
- image_bgr = cv2.copyMakeBorder(
165
- image_bgr, 0, 0, extra_left, extra_right,
166
- cv2.BORDER_CONSTANT, value=(114, 114, 114),
167
- )
168
  rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
169
  img, ratio, pad_w, pad_h = self._letterbox(rgb, (self.input_w, self.input_h))
170
  x = img.astype(np.float32) / 255.0
171
  x = np.transpose(x, (2, 0, 1))[None, ...]
172
  x = np.ascontiguousarray(x)
173
  return x, {
174
- "orig_h": orig_h, "orig_w": orig_w,
175
- "ratio": ratio, "pad_w": pad_w, "pad_h": pad_h,
176
- "extra_left": extra_left, "extra_right": extra_right,
 
 
177
  }
178
 
179
  # ─── Vectorized box operations ───────────────────────────────
@@ -314,15 +298,11 @@ class Miner:
314
  return out.astype(np.float32)
315
 
316
  def _conf_filter_mask(self, scores: np.ndarray,
317
- cls_ids: np.ndarray, extra_left: int) -> np.ndarray:
318
  """Per-class threshold with rescue bonus for missed classes."""
319
  if len(scores) == 0:
320
  return np.zeros(0, dtype=bool)
321
- thr = 0
322
- if extra_left > 0:
323
- thr = self._extra_conf_thres_array[cls_ids]
324
- else:
325
- thr = self._conf_thres_array[cls_ids]
326
  keep = scores >= thr
327
  for c in np.unique(cls_ids):
328
  b = float(self._bonus_array[c])
@@ -365,11 +345,10 @@ class Miner:
365
 
366
  def _decode_yolo_output(self, preds: np.ndarray, ratio: float,
367
  pad: tuple[float, float],
368
- orig_size: tuple[int, int],
369
- extra: tuple[int, int] = (0, 0)
370
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
371
- """Decode -> conf filter -> un-letterbox -> sanity+NMS+dedup.
372
- Returns (boxes, scores, cls_ids) in ORIGINAL image coords."""
373
  empty = (np.empty((0, 4), np.float32), np.empty(0, np.float32),
374
  np.empty(0, np.int32))
375
  if preds.ndim == 3 and preds.shape[0] == 1:
@@ -388,33 +367,15 @@ class Miner:
388
  if len(boxes) == 0:
389
  return empty
390
 
391
- extra_left, _extra_right = extra
392
-
393
- keep = self._conf_filter_mask(scores, cls_ids, extra_left)
394
  boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
395
  if len(boxes) == 0:
396
  return empty
397
 
398
- # 1) undo letterbox -> coords in the PADDED (widened) image
399
  pad_w, pad_h = pad
400
  boxes[:, [0, 2]] -= pad_w
401
  boxes[:, [1, 3]] -= pad_h
402
  boxes /= ratio
403
-
404
- # 2) undo left/right pre-padding -> original-image coords
405
- if extra_left:
406
- boxes[:, [0, 2]] -= extra_left
407
-
408
- # 2b) drop boxes whose CENTER falls in the black padding bars
409
- if extra_left or _extra_right:
410
- orig_w, orig_h = orig_size
411
- cx = (boxes[:, 0] + boxes[:, 2]) * 0.5
412
- inside = (cx >= 0) & (cx <= orig_w)
413
- boxes, scores, cls_ids = boxes[inside], scores[inside], cls_ids[inside]
414
- if len(boxes) == 0:
415
- return empty
416
-
417
- # 3) clip to ORIGINAL image bounds
418
  boxes = self._clip_boxes(boxes, orig_size)
419
 
420
  return self._per_view_pipeline(boxes, scores, cls_ids, orig_size)
@@ -441,9 +402,9 @@ class Miner:
441
  )
442
  return results
443
 
444
- # ─── Inference ────────────────────────────────────────────────
445
 
446
- def _predict_single(self, image_bgr: np.ndarray, allow_pad: bool = True
447
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
448
  """One forward pass -> decoded (boxes, scores, cls_ids) in original coords."""
449
  if image_bgr is None or not isinstance(image_bgr, np.ndarray):
@@ -451,22 +412,20 @@ class Miner:
451
  if image_bgr.dtype != np.uint8:
452
  image_bgr = image_bgr.astype(np.uint8)
453
 
454
- inp, meta = self._preprocess(image_bgr, allow_pad=allow_pad)
455
  outputs = self.session.run(None, {self.input_name: inp})
456
 
457
  ratio = float(meta["ratio"])
458
  pad = (float(meta["pad_w"]), float(meta["pad_h"]))
459
  orig_size = (int(meta["orig_w"]), int(meta["orig_h"]))
460
- extra = (int(meta["extra_left"]), int(meta["extra_right"]))
461
 
462
- return self._decode_yolo_output(outputs[0], ratio, pad, orig_size, extra)
463
 
464
  def _infer_single(self, image_bgr: ndarray) -> list[BoundingBox]:
465
- """Single-view inference (no TTA)."""
466
  orig_h, orig_w = image_bgr.shape[:2]
467
  orig_size = (orig_w, orig_h)
468
 
469
- boxes, scores, cls_ids = self._predict_single(image_bgr, allow_pad=True)
470
  if len(boxes) == 0:
471
  return []
472
 
 
25
 
26
  class Miner:
27
  """
28
+ YOLO (NMS-free) ONNX miner for road-sign detection. One forward pass per
29
+ frame β€” no TTA.
30
 
31
+ Class: single 'road sign'.
32
 
33
+ Pipeline per frame: preprocess -> ONNX -> decode -> conf threshold (+ rescue
34
+ bonus) -> un-letterbox -> sanity filter -> per-class NMS -> cross-class dedup
35
+ -> same-class cluster score boost -> results.
36
 
37
+ Speed: the detection pipeline runs exactly ONCE per frame (the old redundant
38
+ second NMS/dedup pass is gone), and the cluster boost is a single vectorized
39
+ IoU matrix, so cost stays flat as the number of detected objects grows.
 
 
 
 
40
  """
41
 
42
+ class_names = ['road sign']
43
+ input_size = 1536
44
+ cross_iou_thresh = 0.8
45
  max_det = 300
46
+ # NMS is O(n^2); if a frame yields a huge candidate list, keep the top-K by
47
+ # score before NMS. Set high enough never to touch real detections.
48
  pre_nms_topk = 1000
49
  #overlap_suppress_threshold = 0.85
50
 
51
+ # Per-class confidence threshold
52
+ _conf_thres_array = np.array([0.32], dtype=np.float32)
 
53
 
54
+ # Per-class IoU threshold for same-class NMS
55
+ _iou_thres_array = np.array([0.8], dtype=np.float32)
56
 
57
  # Per-class rescue bonus
58
+ _bonus_array = np.array([0.2], dtype=np.float32)
59
 
60
+ # Per-class minimum box area (index 0 = road sign)
61
+ _min_box_area_array = np.array([9.0], dtype=np.float32)
62
 
63
  def __init__(self, path_hf_repo: Path) -> None:
64
  self.path_hf_repo = path_hf_repo
 
83
  )
84
  print("Created ORT session with preferred CUDA provider list")
85
  print("ORT session providers:", self.session.get_providers())
 
86
 
87
  self.input_name = self.session.get_inputs()[0].name
88
  input_shape = self.session.get_inputs()[0].shape
 
90
  self.input_h = self._safe_dim(input_shape[2], default=self.input_size)
91
  self.input_w = self._safe_dim(input_shape[3], default=self.input_size)
92
 
93
+ # Same-class cluster score boost (raises overlapping same-class survivors
94
+ # to their cluster max). Part of the current tuned behaviour; for a single
95
+ # class it is nearly a no-op. Set False to disable.
96
  self.use_cluster_boost = True
97
  self._avg_iou = float(np.mean(self._iou_thres_array))
98
 
 
108
  print(f"warmup skipped: {e}")
109
 
110
  def __repr__(self) -> str:
111
+ return f"road sign Miner classes={len(self.class_names)}"
112
 
113
  @staticmethod
114
  def _safe_dim(value, default: int) -> int:
 
145
  )
146
  return out, r, pad_w, pad_h
147
 
148
+ def _preprocess(self, image_bgr: ndarray) -> tuple[np.ndarray, dict]:
 
149
  orig_h, orig_w = image_bgr.shape[:2]
 
 
 
 
 
 
 
 
 
 
 
 
150
  rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
151
  img, ratio, pad_w, pad_h = self._letterbox(rgb, (self.input_w, self.input_h))
152
  x = img.astype(np.float32) / 255.0
153
  x = np.transpose(x, (2, 0, 1))[None, ...]
154
  x = np.ascontiguousarray(x)
155
  return x, {
156
+ "orig_h": orig_h,
157
+ "orig_w": orig_w,
158
+ "ratio": ratio,
159
+ "pad_w": pad_w,
160
+ "pad_h": pad_h,
161
  }
162
 
163
  # ─── Vectorized box operations ───────────────────────────────
 
298
  return out.astype(np.float32)
299
 
300
  def _conf_filter_mask(self, scores: np.ndarray,
301
+ cls_ids: np.ndarray) -> np.ndarray:
302
  """Per-class threshold with rescue bonus for missed classes."""
303
  if len(scores) == 0:
304
  return np.zeros(0, dtype=bool)
305
+ thr = self._conf_thres_array[cls_ids]
 
 
 
 
306
  keep = scores >= thr
307
  for c in np.unique(cls_ids):
308
  b = float(self._bonus_array[c])
 
345
 
346
  def _decode_yolo_output(self, preds: np.ndarray, ratio: float,
347
  pad: tuple[float, float],
348
+ orig_size: tuple[int, int]
 
349
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
350
+ """Decode NMS-free output [1,N,6]=(x1,y1,x2,y2,conf,cls) -> conf filter ->
351
+ un-letterbox -> sanity+NMS+dedup. Returns arrays in ORIGINAL coords."""
352
  empty = (np.empty((0, 4), np.float32), np.empty(0, np.float32),
353
  np.empty(0, np.int32))
354
  if preds.ndim == 3 and preds.shape[0] == 1:
 
367
  if len(boxes) == 0:
368
  return empty
369
 
370
+ keep = self._conf_filter_mask(scores, cls_ids)
 
 
371
  boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
372
  if len(boxes) == 0:
373
  return empty
374
 
 
375
  pad_w, pad_h = pad
376
  boxes[:, [0, 2]] -= pad_w
377
  boxes[:, [1, 3]] -= pad_h
378
  boxes /= ratio
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  boxes = self._clip_boxes(boxes, orig_size)
380
 
381
  return self._per_view_pipeline(boxes, scores, cls_ids, orig_size)
 
402
  )
403
  return results
404
 
405
+ # ─── Inference (single view, no TTA) ──────────────────────────
406
 
407
+ def _predict_single(self, image_bgr: np.ndarray
408
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
409
  """One forward pass -> decoded (boxes, scores, cls_ids) in original coords."""
410
  if image_bgr is None or not isinstance(image_bgr, np.ndarray):
 
412
  if image_bgr.dtype != np.uint8:
413
  image_bgr = image_bgr.astype(np.uint8)
414
 
415
+ inp, meta = self._preprocess(image_bgr)
416
  outputs = self.session.run(None, {self.input_name: inp})
417
 
418
  ratio = float(meta["ratio"])
419
  pad = (float(meta["pad_w"]), float(meta["pad_h"]))
420
  orig_size = (int(meta["orig_w"]), int(meta["orig_h"]))
 
421
 
422
+ return self._decode_yolo_output(outputs[0], ratio, pad, orig_size)
423
 
424
  def _infer_single(self, image_bgr: ndarray) -> list[BoundingBox]:
 
425
  orig_h, orig_w = image_bgr.shape[:2]
426
  orig_size = (orig_w, orig_h)
427
 
428
+ boxes, scores, cls_ids = self._predict_single(image_bgr)
429
  if len(boxes) == 0:
430
  return []
431
 
weights.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:17f8cd5cf9253b555a05bd6ac874bcbe2a892298f008d2f01a0fd4e4c6c0a867
3
- size 9760190
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9ad0e8fc7e38bd4c5f5c8f4cfece75dcc2fa6a41c2cf71e4cba9672c87b98687
3
+ size 9757809