SuperBitDev commited on
Commit
bb93706
·
verified ·
1 Parent(s): 2ffa8fe

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. miner.py +190 -63
  2. weights.onnx +2 -2
miner.py CHANGED
@@ -85,12 +85,31 @@ class Miner:
85
  self.input_width = self._safe_dim(self.input_shape[3], default=1280)
86
 
87
  # Tuned for validator scoring (pillars: 0.6*map50 + 0.4*false_positive).
88
- # conf 0.40 was the best element-score point in the v1 eval sweep.
89
- self.conf_thres = 0.32 # Higher = fewer FP, slightly lower recall
90
- self.iou_thres = 0.5 # Lower = suppress duplicate detections (FP)
91
- self.max_det = 200 # Cap detections per image
92
  self.use_tta = True
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  # Box sanity filter — kept loose: car-wash `nozzle` boxes are tiny
95
  # (GT median ~290 px², smallest ~32 px²). Fire's 14x14/min_side 8
96
  # would delete valid nozzles, so thresholds are dropped here.
@@ -370,35 +389,126 @@ class Miner:
370
 
371
  @staticmethod
372
  def _max_score_per_cluster(
373
- coords: np.ndarray,
374
- scores: np.ndarray,
375
- keep_indices: np.ndarray,
 
 
376
  iou_thresh: float,
377
  ) -> np.ndarray:
 
 
 
 
 
 
378
  """
379
- For each kept box, return the max original score among itself and any
380
- box that overlaps it with IOU >= iou_thresh (so TTA cluster keeps best conf).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  """
382
- n_keep = len(keep_indices)
383
- if n_keep == 0:
384
- return np.array([], dtype=np.float32)
385
- out = np.empty(n_keep, dtype=np.float32)
386
- coords = np.asarray(coords, dtype=np.float32)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  scores = np.asarray(scores, dtype=np.float32)
388
- for i in range(n_keep):
389
- idx = keep_indices[i]
390
- bi = coords[idx]
391
- xx1 = np.maximum(bi[0], coords[:, 0])
392
- yy1 = np.maximum(bi[1], coords[:, 1])
393
- xx2 = np.minimum(bi[2], coords[:, 2])
394
- yy2 = np.minimum(bi[3], coords[:, 3])
 
 
 
 
 
 
 
 
 
395
  inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
396
- area_i = (bi[2] - bi[0]) * (bi[3] - bi[1])
397
- areas_j = (coords[:, 2] - coords[:, 0]) * (coords[:, 3] - coords[:, 1])
398
- iou = inter / (area_i + areas_j - inter + 1e-7)
399
- in_cluster = iou >= iou_thresh
400
- out[i] = float(np.max(scores[in_cluster]))
401
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
403
  def _decode_final_dets(
404
  self,
@@ -424,7 +534,8 @@ class Miner:
424
  cls_ids = preds[:, 5].astype(np.int32)
425
  cls_ids = self.cls_remap[cls_ids]
426
 
427
- keep = scores >= self.conf_thres
 
428
  boxes = boxes[keep]
429
  scores = scores[keep]
430
  cls_ids = cls_ids[keep]
@@ -448,18 +559,21 @@ class Miner:
448
  if len(boxes) == 0:
449
  return []
450
 
451
- # Per-class NMS to remove duplicates without suppressing across classes
452
- if len(boxes) > 1:
453
- if apply_optional_dedup:
454
- keep_idx, scores = self._per_class_soft_nms(boxes, scores, cls_ids)
455
- boxes = boxes[keep_idx]
456
- cls_ids = cls_ids[keep_idx]
457
- else:
458
- keep_idx = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
459
- keep_idx = keep_idx[: self.max_det]
460
- boxes = boxes[keep_idx]
461
- scores = scores[keep_idx]
462
- cls_ids = cls_ids[keep_idx]
 
 
 
463
 
464
  results: list[BoundingBox] = []
465
  for box, conf, cls_id in zip(boxes, scores, cls_ids):
@@ -520,25 +634,20 @@ class Miner:
520
  scores = cls_part[np.arange(len(cls_part)), cls_ids]
521
  cls_ids = self.cls_remap[cls_ids]
522
 
523
- keep = scores >= self.conf_thres
 
524
  boxes_xywh = boxes_xywh[keep]
525
  scores = scores[keep]
526
  cls_ids = cls_ids[keep]
527
-
528
  if len(boxes_xywh) == 0:
529
  return []
530
 
531
  boxes = self._xywh_to_xyxy(boxes_xywh)
532
 
533
- keep_idx = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
534
- keep_idx = keep_idx[: self.max_det]
535
- boxes = boxes[keep_idx]
536
- scores = scores[keep_idx]
537
- cls_ids = cls_ids[keep_idx]
538
-
539
  pad_w, pad_h = pad
540
  orig_w, orig_h = orig_size
541
-
542
  boxes[:, [0, 2]] -= pad_w
543
  boxes[:, [1, 3]] -= pad_h
544
  boxes /= ratio
@@ -550,6 +659,8 @@ class Miner:
550
  if len(boxes) == 0:
551
  return []
552
 
 
 
553
  results: list[BoundingBox] = []
554
  for box, conf, cls_id in zip(boxes, scores, cls_ids):
555
  x1, y1, x2, y2 = box.tolist()
@@ -620,10 +731,16 @@ class Miner:
620
  return self._postprocess(det_output, ratio, pad, orig_size)
621
 
622
  def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
623
- """
624
- Horizontal-flip TTA: merge original + flipped via hard NMS.
625
- Boost confidence for consensus detections (both views agree) to improve
626
- mAP: validator sorts by confidence, so higher conf for TP helps PR curve.
 
 
 
 
 
 
627
  """
628
  boxes_orig = self._predict_single(image)
629
 
@@ -652,24 +769,34 @@ class Miner:
652
  hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
653
  if len(hard_keep) == 0:
654
  return []
 
 
 
655
 
656
- hard_keep = hard_keep[: self.max_det]
657
-
658
- # Boost confidence when both views agree (overlapping detections)
659
  boosted = self._max_score_per_cluster(
660
- coords, scores, hard_keep, self.iou_thres
 
661
  )
662
 
 
 
 
 
 
 
 
663
  return [
664
  BoundingBox(
665
- x1=all_boxes[i].x1,
666
- y1=all_boxes[i].y1,
667
- x2=all_boxes[i].x2,
668
- y2=all_boxes[i].y2,
669
- cls_id=all_boxes[i].cls_id,
670
  conf=float(boosted[j]),
671
  )
672
- for j, i in enumerate(hard_keep)
673
  ]
674
 
675
  def predict_batch(
 
85
  self.input_width = self._safe_dim(self.input_shape[3], default=1280)
86
 
87
  # Tuned for validator scoring (pillars: 0.6*map50 + 0.4*false_positive).
88
+ self.iou_thres = 0.5 # Per-class NMS IoU; lower = stricter dedup
89
+ self.cross_iou_thresh = 0.8 # Cross-class dedup IoU (suppress same physical object firing multiple classes)
90
+ self.max_det = 200
 
91
  self.use_tta = True
92
 
93
+ # Per-class confidence thresholds (ported pattern from fire001 miner).
94
+ # A single global conf cannot serve both tiny nozzles and large tracks.
95
+ # Indexed by class_names order: [broom, drainage gate, nozzle, track].
96
+ # broom (0.35) -- distinctive long handle, moderate
97
+ # drainage gate (0.30) -- floor element often water-obscured
98
+ # nozzle (0.25) -- TINY GT objects (median ~290 px²), permissive
99
+ # track (0.35) -- large clear object when present, moderate
100
+ self._conf_thres_array = np.array(
101
+ [0.35, 0.015, 0.4, 0.3], dtype=np.float32
102
+ )
103
+ # Per-class rescue bonus: when a class has ZERO boxes passing the
104
+ # threshold in a frame, its top-1 candidate is admitted when its score
105
+ # is at least (per-class threshold - per-class bonus). Nozzles get the
106
+ # biggest rescue because spray + motion blur often shaves a few points
107
+ # off otherwise valid detections; track gets the smallest because it's
108
+ # rarely borderline.
109
+ self._bonus_array = np.array(
110
+ [0.10, 0.10, 0.05, 0.05], dtype=np.float32
111
+ )
112
+
113
  # Box sanity filter — kept loose: car-wash `nozzle` boxes are tiny
114
  # (GT median ~290 px², smallest ~32 px²). Fire's 14x14/min_side 8
115
  # would delete valid nozzles, so thresholds are dropped here.
 
389
 
390
  @staticmethod
391
  def _max_score_per_cluster(
392
+ post_boxes: np.ndarray,
393
+ post_cls: np.ndarray,
394
+ full_boxes: np.ndarray,
395
+ full_scores: np.ndarray,
396
+ full_cls: np.ndarray,
397
  iou_thresh: float,
398
  ) -> np.ndarray:
399
+ """For each kept (post-NMS) box, return the max score over the FULL
400
+ candidate set among SAME-CLASS boxes with IoU >= iou_thresh.
401
+
402
+ The previous version omitted the same-class constraint, which let a
403
+ confident broom raise the score of a coincident nozzle (or vice
404
+ versa) under TTA. That's a silent FP booster and is fixed here.
405
  """
406
+ n = len(post_boxes)
407
+ if n == 0:
408
+ return np.empty(0, dtype=np.float32)
409
+ full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
410
+ np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
411
+ out = np.empty(n, dtype=np.float32)
412
+ for i in range(n):
413
+ bi = post_boxes[i]
414
+ xx1 = np.maximum(bi[0], full_boxes[:, 0])
415
+ yy1 = np.maximum(bi[1], full_boxes[:, 1])
416
+ xx2 = np.minimum(bi[2], full_boxes[:, 2])
417
+ yy2 = np.minimum(bi[3], full_boxes[:, 3])
418
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
419
+ a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
420
+ iou = inter / (a_i + full_areas - inter + 1e-7)
421
+ cluster = (iou >= iou_thresh) & (full_cls == post_cls[i])
422
+ out[i] = float(np.max(full_scores[cluster])) if np.any(cluster) else 0.0
423
+ return out
424
+
425
+ def _conf_filter_mask(
426
+ self, scores: np.ndarray, cls_ids: np.ndarray
427
+ ) -> np.ndarray:
428
+ """Boolean keep-mask: score >= per-class threshold, with a per-class
429
+ rescue -- if a class has zero boxes passing, admit its top-1 candidate
430
+ when its score >= (per-class threshold - per-class bonus).
431
  """
432
+ if len(scores) == 0:
433
+ return np.zeros(0, dtype=bool)
434
+ thr = self._conf_thres_array[cls_ids]
435
+ keep = scores >= thr
436
+ for c in np.unique(cls_ids):
437
+ b = float(self._bonus_array[c])
438
+ if b <= 0.0:
439
+ continue
440
+ cm = cls_ids == c
441
+ if keep[cm].any():
442
+ continue
443
+ idx = np.where(cm)[0]
444
+ top = int(idx[int(np.argmax(scores[idx]))])
445
+ if scores[top] >= self._conf_thres_array[c] - b:
446
+ keep[top] = True
447
+ return keep
448
+
449
+ def _cross_class_dedup_op(
450
+ self,
451
+ boxes: np.ndarray,
452
+ scores: np.ndarray,
453
+ cls_ids: np.ndarray,
454
+ iou_thresh: float,
455
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
456
+ """Remove near-duplicate boxes across classes.
457
+
458
+ Order candidates by (score - per_class_threshold) margin, then by area;
459
+ keep the highest, suppress every other box with IoU > iou_thresh. For
460
+ car-wash this kills the common failure where water spray makes the
461
+ model fire both `nozzle` and `track` on the same patch, or where a
462
+ broom handle overlaps a drainage-gate detection.
463
+ """
464
+ n = len(boxes)
465
+ if n <= 1:
466
+ return boxes, scores, cls_ids
467
+ boxes = np.asarray(boxes, dtype=np.float32)
468
  scores = np.asarray(scores, dtype=np.float32)
469
+ cls_ids = np.asarray(cls_ids, dtype=np.int32)
470
+ areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *
471
+ np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))
472
+ margins = scores - self._conf_thres_array[cls_ids]
473
+ order = np.lexsort((-areas, -margins))
474
+ suppressed = np.zeros(n, dtype=bool)
475
+ keep: list[int] = []
476
+ for i in order:
477
+ if suppressed[i]:
478
+ continue
479
+ keep.append(int(i))
480
+ bi = boxes[i]
481
+ xx1 = np.maximum(bi[0], boxes[:, 0])
482
+ yy1 = np.maximum(bi[1], boxes[:, 1])
483
+ xx2 = np.minimum(bi[2], boxes[:, 2])
484
+ yy2 = np.minimum(bi[3], boxes[:, 3])
485
  inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
486
+ a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
487
+ iou = inter / (a_i + areas - inter + 1e-7)
488
+ dup = iou > iou_thresh
489
+ dup[i] = False
490
+ suppressed |= dup
491
+ keep_idx = np.array(keep, dtype=np.intp)
492
+ return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]
493
+
494
+ def _per_view_pipeline(
495
+ self,
496
+ boxes: np.ndarray,
497
+ scores: np.ndarray,
498
+ cls_ids: np.ndarray,
499
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
500
+ """Per-view post-processing: per-class NMS -> cap -> cross-class dedup."""
501
+ if len(boxes) > 1:
502
+ keep = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)
503
+ boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
504
+ if len(scores) > self.max_det:
505
+ top = np.argsort(-scores)[: self.max_det]
506
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
507
+ if len(boxes) > 1:
508
+ boxes, scores, cls_ids = self._cross_class_dedup_op(
509
+ boxes, scores, cls_ids, self.cross_iou_thresh
510
+ )
511
+ return boxes, scores, cls_ids
512
 
513
  def _decode_final_dets(
514
  self,
 
534
  cls_ids = preds[:, 5].astype(np.int32)
535
  cls_ids = self.cls_remap[cls_ids]
536
 
537
+ # Per-class confidence filter with rescue (replaces scalar threshold)
538
+ keep = self._conf_filter_mask(scores, cls_ids)
539
  boxes = boxes[keep]
540
  scores = scores[keep]
541
  cls_ids = cls_ids[keep]
 
559
  if len(boxes) == 0:
560
  return []
561
 
562
+ if apply_optional_dedup and len(boxes) > 1:
563
+ # Soft-NMS path preserved as a tunable option; default below.
564
+ keep_idx, scores = self._per_class_soft_nms(boxes, scores, cls_ids)
565
+ boxes = boxes[keep_idx]
566
+ cls_ids = cls_ids[keep_idx]
567
+ if len(scores) > self.max_det:
568
+ top = np.argsort(-scores)[: self.max_det]
569
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
570
+ if len(boxes) > 1:
571
+ boxes, scores, cls_ids = self._cross_class_dedup_op(
572
+ boxes, scores, cls_ids, self.cross_iou_thresh
573
+ )
574
+ else:
575
+ # Default: per-class hard NMS -> cap -> cross-class dedup
576
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
577
 
578
  results: list[BoundingBox] = []
579
  for box, conf, cls_id in zip(boxes, scores, cls_ids):
 
634
  scores = cls_part[np.arange(len(cls_part)), cls_ids]
635
  cls_ids = self.cls_remap[cls_ids]
636
 
637
+ # Per-class confidence filter with rescue (replaces scalar threshold)
638
+ keep = self._conf_filter_mask(scores, cls_ids)
639
  boxes_xywh = boxes_xywh[keep]
640
  scores = scores[keep]
641
  cls_ids = cls_ids[keep]
 
642
  if len(boxes_xywh) == 0:
643
  return []
644
 
645
  boxes = self._xywh_to_xyxy(boxes_xywh)
646
 
647
+ # Order matches fire001 / _decode_final_dets:
648
+ # unscale -> clip -> sanity filter -> per-view pipeline (NMS, cap, cross-class dedup).
 
 
 
 
649
  pad_w, pad_h = pad
650
  orig_w, orig_h = orig_size
 
651
  boxes[:, [0, 2]] -= pad_w
652
  boxes[:, [1, 3]] -= pad_h
653
  boxes /= ratio
 
659
  if len(boxes) == 0:
660
  return []
661
 
662
+ boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)
663
+
664
  results: list[BoundingBox] = []
665
  for box, conf, cls_id in zip(boxes, scores, cls_ids):
666
  x1, y1, x2, y2 = box.tolist()
 
731
  return self._postprocess(det_output, ratio, pad, orig_size)
732
 
733
  def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
734
+ """Horizontal-flip TTA.
735
+
736
+ Strategy (ported from fire001):
737
+ 1. Predict on original and on flipped image.
738
+ 2. Map flipped boxes back to original coordinates.
739
+ 3. Per-class hard NMS on the union.
740
+ 4. For each kept box, compute the max SAME-CLASS score across the
741
+ FULL union -- a high-confidence flipped detection raises a
742
+ borderline original one, but never one of a different class.
743
+ 5. Cross-class dedup to suppress same-physical-object multi-class.
744
  """
745
  boxes_orig = self._predict_single(image)
746
 
 
769
  hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
770
  if len(hard_keep) == 0:
771
  return []
772
+ if len(hard_keep) > self.max_det:
773
+ top = np.argsort(-scores[hard_keep])[: self.max_det]
774
+ hard_keep = hard_keep[top]
775
 
776
+ # Class-aware cluster-max score boost (fixes the silent cross-class
777
+ # leak in the previous _max_score_per_cluster).
 
778
  boosted = self._max_score_per_cluster(
779
+ coords[hard_keep], cls_ids[hard_keep],
780
+ coords, scores, cls_ids, self.iou_thres,
781
  )
782
 
783
+ kept_coords = coords[hard_keep]
784
+ kept_cls = cls_ids[hard_keep]
785
+ if len(kept_coords) > 1:
786
+ kept_coords, boosted, kept_cls = self._cross_class_dedup_op(
787
+ kept_coords, boosted, kept_cls, self.cross_iou_thresh
788
+ )
789
+
790
  return [
791
  BoundingBox(
792
+ x1=int(math.floor(kept_coords[j, 0])),
793
+ y1=int(math.floor(kept_coords[j, 1])),
794
+ x2=int(math.ceil(kept_coords[j, 2])),
795
+ y2=int(math.ceil(kept_coords[j, 3])),
796
+ cls_id=int(kept_cls[j]),
797
  conf=float(boosted[j]),
798
  )
799
+ for j in range(len(kept_coords))
800
  ]
801
 
802
  def predict_batch(
weights.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:c41ef35650af81eab3d4d57b8605dd93544df185af1171db61b6e091c5820e3b
3
- size 19408005
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fb83bf2b7e7948721246a6137da1e64389c9198d72400b62dc18a4c91552776b
3
+ size 19408006