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

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. chute_config.yml +3 -3
  2. miner.py +472 -168
  3. weights.onnx +2 -2
chute_config.yml CHANGED
@@ -2,8 +2,8 @@ Image:
2
  from_base: parachutes/python:3.12
3
  run_command:
4
  - pip install --upgrade setuptools wheel
5
- - pip install 'numpy>=1.23' 'onnxruntime-gpu[cuda,cudnn]>=1.16' 'opencv-python>=4.7' 'pillow>=9.5' 'huggingface_hub>=0.19.4' 'pydantic>=2.0' 'pyyaml>=6.0' 'aiohttp>=3.9'
6
- - pip install torch torchvision
7
 
8
  NodeSelector:
9
  gpu_count: 1
@@ -18,4 +18,4 @@ Chute:
18
  max_instances: 5
19
  scaling_threshold: 0.5
20
  shutdown_after_seconds: 288000
21
- tee: true
 
2
  from_base: parachutes/python:3.12
3
  run_command:
4
  - pip install --upgrade setuptools wheel
5
+ - pip install 'numpy>=1.23' 'onnxruntime-gpu>=1.16' 'opencv-python>=4.7' 'pillow>=9.5' 'huggingface_hub>=0.19.4' 'pydantic>=2.0' 'pyyaml>=6.0' 'aiohttp>=3.9'
6
+ - pip install torch==2.8.0 torchvision==0.23.0 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
7
 
8
  NodeSelector:
9
  gpu_count: 1
 
18
  max_instances: 5
19
  scaling_threshold: 0.5
20
  shutdown_after_seconds: 288000
21
+ tee: true
miner.py CHANGED
@@ -1,36 +1,12 @@
1
- """
2
- TurboVision miner for element `manak0/Detect-road-signs` β€” ONNX / CPU-safe.
3
-
4
- Single class: cls_id 0 == "road sign". Element scoring:
5
- composite = max(0.6*map50 + 0.4*false_positive - 0.37, 0.01), per-challenge ceiling 0.63.
6
-
7
- Pure onnxruntime, deterministic, sandbox-safe (only cv2/numpy/onnxruntime/os imports;
8
- no network or dynamic-exec calls), requires a `.onnx` in the repo. Runs on a GPU chute,
9
- but ALSO passes the 2-vCPU CPU compliance loop (100ms gate): imgsz 512, no TTA -> ~58ms.
10
- """
11
  from pathlib import Path
12
- import os
13
 
14
- import numpy as np
15
  import cv2
 
16
  import onnxruntime as ort
 
17
  from pydantic import BaseModel
18
 
19
- CLASSES = ["road sign"]
20
-
21
- # RECALL-FIRST config (v2r @576). Live challenges have small/distant signs; the fp
22
- # pillar is forgiving (Γ·10), so low conf + NO min-size filters maximizes map50.
23
- # Sanity min_side/min_area OFF (they dropped small live signs); keep a loose aspect cap.
24
- CONF = float(os.environ.get("RS_CONF", "0.13")) # single-class conf floor (recall)
25
- IOU_NMS = float(os.environ.get("RS_IOU", "0.50")) # hard NMS IoU
26
- MAX_DET = int(os.environ.get("RS_MAX_DET", "300"))
27
- MAX_ASPECT = float(os.environ.get("RS_MAX_ASPECT", "8.0")) # drop only extreme slivers (never real signs)
28
- MIN_SIDE = float(os.environ.get("RS_MIN_SIDE", "0")) # OFF β€” keep small/distant signs
29
- MIN_AREA = float(os.environ.get("RS_MIN_AREA", "0")) # OFF β€” keep small/distant signs
30
- USE_TTA = os.environ.get("RS_TTA", "0") not in ("0", "", "false") # OFF for 100ms compliance
31
- FALLBACK = os.environ.get("RS_FALLBACK", "1") not in ("0", "", "false") # emit top candidate if frame empty
32
- MODEL_FILE = os.environ.get("RS_MODEL", "weights.onnx")
33
-
34
 
35
  class BoundingBox(BaseModel):
36
  x1: int
@@ -41,158 +17,486 @@ class BoundingBox(BaseModel):
41
  conf: float
42
 
43
 
44
- class Polygon(BaseModel):
45
- cls_id: int
46
- conf: float
47
- points: list[tuple[int, int]]
48
-
49
-
50
  class TVFrameResult(BaseModel):
51
  frame_id: int
52
- boxes: list[BoundingBox] | None = None
53
- polygons: list[Polygon] | None = None
54
- keypoints: list[tuple[int, int]] | None = None
55
-
56
-
57
- def _letterbox(img: np.ndarray, new_shape: tuple[int, int]):
58
- h, w = img.shape[:2]
59
- nh, nw = new_shape
60
- r = min(nh / h, nw / w)
61
- uw, uh = int(round(w * r)), int(round(h * r))
62
- resized = cv2.resize(img, (uw, uh), interpolation=cv2.INTER_LINEAR)
63
- pad_w, pad_h = (nw - uw) / 2, (nh - uh) / 2
64
- top, bottom = int(round(pad_h - 0.1)), int(round(pad_h + 0.1))
65
- left, right = int(round(pad_w - 0.1)), int(round(pad_w + 0.1))
66
- out = cv2.copyMakeBorder(resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114, 114, 114))
67
- return out, r, left, top
68
-
69
-
70
- def _nms(boxes: np.ndarray, scores: np.ndarray, iou_thr: float) -> list[int]:
71
- if len(boxes) == 0:
72
- return []
73
- x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
74
- areas = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1)
75
- order = scores.argsort()[::-1]
76
- keep = []
77
- while order.size > 0:
78
- i = order[0]
79
- keep.append(int(i))
80
- if order.size == 1:
81
- break
82
- xx1 = np.maximum(x1[i], x1[order[1:]])
83
- yy1 = np.maximum(y1[i], y1[order[1:]])
84
- xx2 = np.minimum(x2[i], x2[order[1:]])
85
- yy2 = np.minimum(y2[i], y2[order[1:]])
86
- inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1)
87
- iou = inter / (areas[i] + areas[order[1:]] - inter + 1e-9)
88
- order = order[1:][iou <= iou_thr]
89
- return keep
90
 
91
 
92
  class Miner:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  def __init__(self, path_hf_repo: Path) -> None:
94
- model_path = str(Path(path_hf_repo) / MODEL_FILE)
95
- providers = os.environ.get("RS_PROVIDERS", "CPUExecutionProvider").split(",")
96
- avail = ort.get_available_providers()
97
- providers = [p for p in providers if p in avail] or ["CPUExecutionProvider"]
98
- so = ort.SessionOptions()
99
- so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
100
- so.intra_op_num_threads = int(os.environ.get("RS_THREADS", "0"))
101
- self.sess = ort.InferenceSession(model_path, sess_options=so, providers=providers)
102
- self.inp = self.sess.get_inputs()[0]
103
- shape = self.inp.shape # [1,3,H,W]
104
- self.H = int(shape[2]) if isinstance(shape[2], int) else 1024
105
- self.W = int(shape[3]) if isinstance(shape[3], int) else 1024
106
- self.nc = len(CLASSES)
107
- dummy = np.zeros((1, 3, self.H, self.W), dtype=np.float32)
108
- self.sess.run(None, {self.inp.name: dummy})
109
- print(f"RoadSign ONNX loaded {MODEL_FILE} input={self.H}x{self.W} providers={providers} conf={CONF}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
  def __repr__(self) -> str:
112
- return f"RoadSign ONNX ({MODEL_FILE}) {self.H}x{self.W} conf={CONF} tta={USE_TTA}"
113
-
114
- def _preprocess(self, img_bgr: np.ndarray):
115
- lb, r, pad_w, pad_h = _letterbox(img_bgr, (self.H, self.W))
116
- rgb = lb[:, :, ::-1].astype(np.float32) / 255.0 # BGR->RGB, 0-1 (manifest norm rgb-01)
117
- chw = np.transpose(rgb, (2, 0, 1))
118
- return chw, r, pad_w, pad_h
119
-
120
- def _decode(self, out: np.ndarray, r: float, pad_w: float, pad_h: float,
121
- orig_w: int, orig_h: int, flipped: bool, thresh: float = None):
122
- if thresh is None:
123
- thresh = CONF
124
- pred = out[0]
125
- if pred.shape[0] == (4 + self.nc):
126
- pred = pred.transpose(1, 0)
127
- boxes_xywh = pred[:, :4]
128
- cls_scores = pred[:, 4:4 + self.nc]
129
- conf = cls_scores.max(1)
130
- m = conf >= thresh
131
- if not m.any():
132
- return np.zeros((0, 4), np.float32), np.zeros((0,), np.float32)
133
- boxes_xywh, conf = boxes_xywh[m], conf[m]
134
- cx, cy, w, h = boxes_xywh[:, 0], boxes_xywh[:, 1], boxes_xywh[:, 2], boxes_xywh[:, 3]
135
- x1 = (cx - w / 2 - pad_w) / r
136
- y1 = (cy - h / 2 - pad_h) / r
137
- x2 = (cx + w / 2 - pad_w) / r
138
- y2 = (cy + h / 2 - pad_h) / r
139
- if flipped:
140
- nx1 = orig_w - x2
141
- nx2 = orig_w - x1
142
- x1, x2 = nx1, nx2
143
- xyxy = np.stack([x1, y1, x2, y2], 1)
144
- xyxy[:, [0, 2]] = xyxy[:, [0, 2]].clip(0, orig_w)
145
- xyxy[:, [1, 3]] = xyxy[:, [1, 3]].clip(0, orig_h)
146
- return xyxy, conf
147
-
148
- def _finalize(self, xyxy, conf) -> list[BoundingBox]:
149
- if len(xyxy) == 0:
150
- return []
151
- keep = _nms(xyxy, conf, IOU_NMS)[:MAX_DET]
152
- out = []
153
- for j in keep:
154
- w = max(1e-6, xyxy[j, 2] - xyxy[j, 0])
155
- h = max(1e-6, xyxy[j, 3] - xyxy[j, 1])
156
- if MIN_SIDE > 0 and min(w, h) < MIN_SIDE: # sanity: tiny side
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  continue
158
- if MIN_AREA > 0 and (w * h) < MIN_AREA: # sanity: tiny area
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  continue
160
- if MAX_ASPECT > 0 and max(w / h, h / w) > MAX_ASPECT: # sanity: extreme aspect
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  continue
162
- out.append(BoundingBox(x1=int(xyxy[j, 0]), y1=int(xyxy[j, 1]),
163
- x2=int(xyxy[j, 2]), y2=int(xyxy[j, 3]),
164
- cls_id=0, conf=float(conf[j])))
165
- return out
166
-
167
- def _infer(self, img, flipped: bool, thresh: float = None):
168
- src = img[:, ::-1, :] if flipped else img
169
- chw, r, pw, ph = self._preprocess(src)
170
- inp = np.ascontiguousarray(chw[None], dtype=np.float32)
171
- out = self.sess.run(None, {self.inp.name: inp})[0]
172
- return self._decode(out, r, pw, ph, img.shape[1], img.shape[0], flipped, thresh)
173
-
174
- def _fallback_box(self, img) -> list[BoundingBox]:
175
- """Road-sign challenges always contain >=1 sign, so an empty frame is a
176
- guaranteed 0. Emit the single highest-confidence raw candidate (below the
177
- conf floor, sanity filters bypassed) so the frame is never empty."""
178
- xyxy, conf = self._infer(img, flipped=False, thresh=0.0)
179
- if len(conf) == 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  return []
181
- j = int(conf.argmax())
182
- return [BoundingBox(x1=int(xyxy[j, 0]), y1=int(xyxy[j, 1]),
183
- x2=int(xyxy[j, 2]), y2=int(xyxy[j, 3]),
184
- cls_id=0, conf=float(conf[j]))]
185
 
186
- def predict_batch(self, batch_images, offset: int, n_keypoints: int) -> list[TVFrameResult]:
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  results: list[TVFrameResult] = []
188
- for i, img in enumerate(batch_images):
189
- xyxy, conf = self._infer(img, flipped=False)
190
- if USE_TTA:
191
- fx, fs = self._infer(img, flipped=True)
192
- xyxy = np.concatenate([xyxy, fx], 0)
193
- conf = np.concatenate([conf, fs], 0)
194
- boxes = self._finalize(xyxy, conf)
195
- if not boxes and FALLBACK: # never return an empty frame -> avoid a guaranteed 0
196
- boxes = self._fallback_box(img)
197
- results.append(TVFrameResult(frame_id=offset + i, boxes=boxes, polygons=[], keypoints=[]))
198
- return results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from pathlib import Path
2
+ import math
3
 
 
4
  import cv2
5
+ import numpy as np
6
  import onnxruntime as ort
7
+ from numpy import ndarray
8
  from pydantic import BaseModel
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  class BoundingBox(BaseModel):
12
  x1: int
 
17
  conf: float
18
 
19
 
 
 
 
 
 
 
20
  class TVFrameResult(BaseModel):
21
  frame_id: int
22
+ boxes: list[BoundingBox]
23
+ keypoints: list[tuple[int, int]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
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
69
+
70
+ print("ORT version:", ort.__version__)
71
+
72
+ try:
73
+ ort.preload_dlls()
74
+ print("preload_dlls success")
75
+ except Exception as e:
76
+ print(f"preload_dlls failed: {e}")
77
+
78
+ print("ORT available providers BEFORE session:", ort.get_available_providers())
79
+
80
+ sess_options = ort.SessionOptions()
81
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
82
+
83
+ self.session = ort.InferenceSession(
84
+ str(path_hf_repo / "weights.onnx"),
85
+ sess_options=sess_options,
86
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
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
94
+
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
+
104
+ self._warmup()
105
+
106
+ def _warmup(self, iters: int = 3) -> None:
107
+ try:
108
+ dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
109
+ for _ in range(max(1, iters)):
110
+ self.predict_batch(batch_images=[dummy], offset=0, n_keypoints=0)
111
+ print(f"warmup: {iters} dummy predict_batch call(s) done")
112
+ except Exception as e:
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:
120
+ return value if isinstance(value, int) and value > 0 else default
121
+
122
+ # ─── Preprocessing ────────────────────────────────────────────
123
+
124
+ def _letterbox(
125
+ self, image: ndarray, new_shape: tuple[int, int],
126
+ color: tuple[int, int, int] = (114, 114, 114),
127
+ ) -> tuple[ndarray, float, float, float]:
128
+ orig_h, orig_w = image.shape[:2]
129
+ target_w, target_h = new_shape
130
+
131
+ r = min(target_w / orig_w, target_h / orig_h)
132
+ new_unpad_w = int(round(orig_w * r))
133
+ new_unpad_h = int(round(orig_h * r))
134
+
135
+ resized = cv2.resize(image, (new_unpad_w, new_unpad_h), interpolation=cv2.INTER_LINEAR)
136
+
137
+ dw = target_w - new_unpad_w
138
+ dh = target_h - new_unpad_h
139
+ pad_w = dw / 2.0
140
+ pad_h = dh / 2.0
141
+
142
+ left = int(round(pad_w - 0.1))
143
+ right = int(round(pad_w + 0.1))
144
+ top = int(round(pad_h - 0.1))
145
+ bottom = int(round(pad_h + 0.1))
146
+
147
+ out = cv2.copyMakeBorder(
148
+ resized, top, bottom, left, right,
149
+ cv2.BORDER_CONSTANT, value=color,
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 ───────────────────────────────
180
+
181
+ @staticmethod
182
+ def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
183
+ w, h = image_size
184
+ boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
185
+ boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
186
+ boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
187
+ boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
188
+ return boxes
189
+
190
+ @staticmethod
191
+ def _hard_nms(boxes: np.ndarray, scores: np.ndarray,
192
+ iou_thresh: float) -> np.ndarray:
193
+ """Vectorized greedy NMS. Areas precomputed once. Returns indices to keep."""
194
+ n = len(boxes)
195
+ if n == 0:
196
+ return np.array([], dtype=np.intp)
197
+ x1, y1 = boxes[:, 0], boxes[:, 1]
198
+ x2, y2 = boxes[:, 2], boxes[:, 3]
199
+ areas = np.maximum(0.0, x2 - x1) * np.maximum(0.0, y2 - y1)
200
+ order = np.argsort(-scores)
201
+ keep = []
202
+ while order.size > 0:
203
+ i = int(order[0])
204
+ keep.append(i)
205
+ if order.size == 1:
206
+ break
207
+ rest = order[1:]
208
+ xx1 = np.maximum(x1[i], x1[rest])
209
+ yy1 = np.maximum(y1[i], y1[rest])
210
+ xx2 = np.minimum(x2[i], x2[rest])
211
+ yy2 = np.minimum(y2[i], y2[rest])
212
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
213
+ iou = inter / (areas[i] + areas[rest] - inter + 1e-7)
214
+ order = rest[iou <= iou_thresh]
215
+ return np.array(keep, dtype=np.intp)
216
+
217
+ def _per_class_hard_nms(self, boxes: np.ndarray, scores: np.ndarray,
218
+ cls_ids: np.ndarray) -> np.ndarray:
219
+ """Per-class NMS using per-class IoU thresholds."""
220
+ if len(boxes) == 0:
221
+ return np.array([], dtype=np.intp)
222
+ all_keep = []
223
+ for c in np.unique(cls_ids):
224
+ mask = cls_ids == c
225
+ indices = np.where(mask)[0]
226
+ cls_iou = float(self._iou_thres_array[c]) # per-class IoU threshold
227
+ keep = self._hard_nms(boxes[mask], scores[mask], cls_iou)
228
+ all_keep.extend(indices[keep].tolist())
229
+ all_keep.sort()
230
+ return np.array(all_keep, dtype=np.intp)
231
+
232
+ def _cross_class_dedup_op(self, boxes: np.ndarray, scores: np.ndarray,
233
+ cls_ids: np.ndarray, iou_thresh: float
234
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
235
+ n = len(boxes)
236
+ if n <= 1:
237
+ return boxes, scores, cls_ids
238
+ boxes = np.asarray(boxes, dtype=np.float32)
239
+ scores = np.asarray(scores, dtype=np.float32)
240
+ cls_ids = np.asarray(cls_ids, dtype=np.int32)
241
+ areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *
242
+ np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))
243
+ margins = scores - self._conf_thres_array[cls_ids]
244
+ order = np.lexsort((-areas, -margins))
245
+ suppressed = np.zeros(n, dtype=bool)
246
+ keep = []
247
+ for i in order:
248
+ if suppressed[i]:
249
  continue
250
+ keep.append(int(i))
251
+ bi = boxes[i]
252
+ xx1 = np.maximum(bi[0], boxes[:, 0])
253
+ yy1 = np.maximum(bi[1], boxes[:, 1])
254
+ xx2 = np.minimum(bi[2], boxes[:, 2])
255
+ yy2 = np.minimum(bi[3], boxes[:, 3])
256
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
257
+ a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
258
+ iou = inter / (a_i + areas - inter + 1e-7)
259
+ dup = iou > iou_thresh
260
+ dup[i] = False
261
+ suppressed |= dup
262
+ keep_idx = np.array(keep, dtype=np.intp)
263
+ return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]
264
+
265
+ def _filter_sane_boxes(self, boxes: np.ndarray, scores: np.ndarray,
266
+ cls_ids: np.ndarray, orig_size: tuple[int, int]
267
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
268
+ """Filter by per-class min area and max area ratio."""
269
+ if len(boxes) == 0:
270
+ return boxes, scores, cls_ids
271
+
272
+ orig_w, orig_h = orig_size
273
+ image_area = float(orig_w * orig_h)
274
+ bw = np.maximum(0.0, boxes[:, 2] - boxes[:, 0])
275
+ bh = np.maximum(0.0, boxes[:, 3] - boxes[:, 1])
276
+ area = bw * bh
277
+
278
+ class_min_area = self._min_box_area_array[cls_ids]
279
+
280
+ keep = (
281
+ (area >= class_min_area) &
282
+ (area <= 0.95 * image_area)
283
+ )
284
+ return boxes[keep], scores[keep], cls_ids[keep]
285
+
286
+ def _max_score_per_cluster(self, post_boxes: np.ndarray,
287
+ post_cls: np.ndarray,
288
+ full_boxes: np.ndarray,
289
+ full_scores: np.ndarray,
290
+ full_cls: np.ndarray,
291
+ iou_thresh: float) -> np.ndarray:
292
+ """For each kept box, confidence = max score in its SAME-CLASS IoU cluster.
293
+ Vectorized: single (n_post x n_full) IoU matrix, no per-box Python loop."""
294
+ n = len(post_boxes)
295
+ if n == 0:
296
+ return np.empty(0, dtype=np.float32)
297
+ m = len(full_boxes)
298
+ if m == 0:
299
+ return np.zeros(n, dtype=np.float32)
300
+ pa = (np.maximum(0.0, post_boxes[:, 2] - post_boxes[:, 0]) *
301
+ np.maximum(0.0, post_boxes[:, 3] - post_boxes[:, 1]))
302
+ fa = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
303
+ np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
304
+ xx1 = np.maximum(post_boxes[:, 0][:, None], full_boxes[:, 0][None, :])
305
+ yy1 = np.maximum(post_boxes[:, 1][:, None], full_boxes[:, 1][None, :])
306
+ xx2 = np.minimum(post_boxes[:, 2][:, None], full_boxes[:, 2][None, :])
307
+ yy2 = np.minimum(post_boxes[:, 3][:, None], full_boxes[:, 3][None, :])
308
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
309
+ iou = inter / (pa[:, None] + fa[None, :] - inter + 1e-7)
310
+ mask = (iou >= iou_thresh) & (post_cls[:, None] == full_cls[None, :])
311
+ tiled = np.where(mask, full_scores[None, :], -np.inf)
312
+ out = tiled.max(axis=1)
313
+ out[~np.isfinite(out)] = 0.0
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])
329
+ if b <= 0.0:
330
+ continue
331
+ cm = cls_ids == c
332
+ if keep[cm].any():
333
  continue
334
+ idx = np.where(cm)[0]
335
+ top = int(idx[int(np.argmax(scores[idx]))])
336
+ if scores[top] >= self._conf_thres_array[c] - b:
337
+ keep[top] = True
338
+ return keep
339
+
340
+ def _per_view_pipeline(self, boxes: np.ndarray, scores: np.ndarray,
341
+ cls_ids: np.ndarray, orig_size: tuple[int, int]
342
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
343
+ """Sanity filter -> (top-k cap) -> per-class NMS -> cap -> cross-class dedup."""
344
+ boxes, scores, cls_ids = self._filter_sane_boxes(
345
+ boxes, scores, cls_ids, orig_size
346
+ )
347
+ if len(boxes) == 0:
348
+ return boxes, scores, cls_ids
349
+ if len(scores) > self.pre_nms_topk:
350
+ top = np.argpartition(-scores, self.pre_nms_topk)[: self.pre_nms_topk]
351
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
352
+ if len(boxes) > 1:
353
+ keep = self._per_class_hard_nms(boxes, scores, cls_ids)
354
+ boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]
355
+ if len(scores) > self.max_det:
356
+ top = np.argsort(-scores)[: self.max_det]
357
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
358
+ if len(boxes) > 1:
359
+ boxes, scores, cls_ids = self._cross_class_dedup_op(
360
+ boxes, scores, cls_ids, self.cross_iou_thresh
361
+ )
362
+ return boxes, scores, cls_ids
363
+
364
+ # ─── Decoding ─────────────────────────────────────────────────
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:
376
+ preds = preds[0]
377
+ if preds.ndim != 2 or preds.shape[1] < 6:
378
+ print(f"Warning: Unexpected output shape: {preds.shape}")
379
+ return empty
380
+
381
+ boxes = preds[:, :4].astype(np.float32)
382
+ scores = preds[:, 4].astype(np.float32)
383
+ cls_ids = preds[:, 5].astype(np.int32)
384
+
385
+ n_cls = len(self.class_names)
386
+ valid = (cls_ids >= 0) & (cls_ids < n_cls)
387
+ boxes, scores, cls_ids = boxes[valid], scores[valid], cls_ids[valid]
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)
421
+
422
+ @staticmethod
423
+ def _build_results(boxes: np.ndarray, scores: np.ndarray,
424
+ cls_ids: np.ndarray,
425
+ orig_size: tuple[int, int]) -> list[BoundingBox]:
426
+ results = []
427
+ orig_w, orig_h = orig_size
428
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
429
+ x1, y1, x2, y2 = box.tolist() if hasattr(box, "tolist") else box
430
+ if x2 <= x1 or y2 <= y1:
431
  continue
432
+ results.append(
433
+ BoundingBox(
434
+ x1=max(0, min(orig_w, int(math.floor(x1)))),
435
+ y1=max(0, min(orig_h, int(math.floor(y1)))),
436
+ x2=max(0, min(orig_w, int(math.ceil(x2)))),
437
+ y2=max(0, min(orig_h, int(math.ceil(y2)))),
438
+ cls_id=int(cls_id),
439
+ conf=float(max(0.0, min(1.0, conf))),
440
+ )
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):
450
+ raise ValueError("Invalid image input")
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
 
473
+ if self.use_cluster_boost and len(boxes) > 1:
474
+ scores = self._max_score_per_cluster(
475
+ boxes, cls_ids, boxes, scores, cls_ids, self._avg_iou)
476
+
477
+ return self._build_results(boxes, scores, cls_ids, orig_size)
478
+
479
+ # ─── Public API ───────────────────────────────────────────────
480
+
481
+ def predict_batch(
482
+ self,
483
+ batch_images: list[ndarray],
484
+ offset: int,
485
+ n_keypoints: int,
486
+ ) -> list[TVFrameResult]:
487
  results: list[TVFrameResult] = []
488
+ for idx, image in enumerate(batch_images):
489
+ try:
490
+ boxes = self._infer_single(image)
491
+ except Exception as e:
492
+ print(f"Inference failed for frame {offset + idx}: {e}")
493
+ boxes = []
494
+ keypoints = [(0, 0) for _ in range(max(0, int(n_keypoints)))]
495
+ results.append(
496
+ TVFrameResult(
497
+ frame_id=offset + idx,
498
+ boxes=boxes,
499
+ keypoints=keypoints,
500
+ )
501
+ )
502
+ return results
weights.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:320f568d4ba8079dba59acd32bf697ce1567b6b740f7e3972d5ee00b8a67c27e
3
- size 10572128
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:17f8cd5cf9253b555a05bd6ac874bcbe2a892298f008d2f01a0fd4e4c6c0a867
3
+ size 9760190