Phoenix-95107 commited on
Commit
c0e1aa5
·
verified ·
1 Parent(s): 211c296

Add miner.py

Browse files
Files changed (1) hide show
  1. miner.py +303 -0
miner.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ast
2
+ import math
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import cv2
7
+ import numpy as np
8
+ import onnxruntime as ort
9
+ from numpy import ndarray
10
+ from pydantic import BaseModel
11
+
12
+ # manak0/Detect-Vehicle manifest order
13
+ SUBNET_LABELS: tuple[str, ...] = ("car", "bus", "truck", "motorcycle", "van")
14
+ SUBNET_INDEX: dict[str, int] = {name: i for i, name in enumerate(SUBNET_LABELS)}
15
+
16
+ # Chosen against this checkpoint on 1024 challenge frames, not copied from another miner.
17
+ # Truck is the only class with a real false-positive tail. Motorcycle precision is
18
+ # already saturated at 0.25, so raising it throws away recall.
19
+ CONF_THRES = np.array([0.25, 0.25, 0.35, 0.25, 0.25], dtype=np.float32)
20
+ NMS_IOU = 0.50
21
+ # Same physical vehicle emitted as two classes. Measured overlaps were ~0.98.
22
+ CROSS_CLASS_IOU = 0.70
23
+
24
+
25
+ def _normalize_label(name: str) -> str:
26
+ return "".join(ch for ch in name.lower() if ch.isalnum())
27
+
28
+
29
+ class BoundingBox(BaseModel):
30
+ x1: int
31
+ y1: int
32
+ x2: int
33
+ y2: int
34
+ cls_id: int
35
+ conf: float
36
+
37
+
38
+ class TVFrameResult(BaseModel):
39
+ frame_id: int
40
+ boxes: list[BoundingBox] | None = None
41
+ polygons: list[list] | None = None
42
+ keypoints: list[tuple[int, int]] | None = None
43
+
44
+
45
+ class Miner:
46
+ """Detect-Vehicle miner. cls_id: 0=car, 1=bus, 2=truck, 3=motorcycle, 4=van.
47
+
48
+ Challenge frames are 1024x1024. The ONNX file is exported smaller so the
49
+ CPU compliance rerun stays under 100 ms; square frames are resized
50
+ uniformly and boxes are scaled back. The published weights have no van class.
51
+ """
52
+
53
+ def __init__(self, path_hf_repo: Path) -> None:
54
+ model_path = self._resolve_model_path(Path(path_hf_repo))
55
+ sess_options = ort.SessionOptions()
56
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
57
+ sess_options.intra_op_num_threads = 2
58
+ sess_options.inter_op_num_threads = 1
59
+ sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
60
+
61
+ force_cpu = os.environ.get("MINER_FORCE_CPU", "") == "1"
62
+ use_cuda = (not force_cpu) and ("CUDAExecutionProvider" in ort.get_available_providers())
63
+ if use_cuda:
64
+ try:
65
+ ort.preload_dlls()
66
+ except Exception as exc:
67
+ print(f"preload_dlls skipped: {exc}")
68
+ providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
69
+ else:
70
+ providers = ["CPUExecutionProvider"]
71
+ self.session = ort.InferenceSession(str(model_path), sess_options=sess_options, providers=providers)
72
+
73
+ order = self._read_model_class_order() or ["car", "bus", "truck", "motorcycle"]
74
+ remap = [SUBNET_INDEX.get(_normalize_label(name), -1) for name in order]
75
+ if all(idx < 0 for idx in remap):
76
+ raise ValueError(f"No vehicle classes in ONNX names: {order}")
77
+ self.cls_remap = np.asarray(remap, dtype=np.int32)
78
+
79
+ self.input_name = self.session.get_inputs()[0].name
80
+ shape = self.session.get_inputs()[0].shape
81
+ self.input_height = shape[2] if isinstance(shape[2], int) and shape[2] > 0 else 640
82
+ self.input_width = shape[3] if isinstance(shape[3], int) and shape[3] > 0 else 640
83
+ print(
84
+ f"ONNX {model_path.name} input={self.input_width}x{self.input_height} "
85
+ f"classes={order} providers={self.session.get_providers()}"
86
+ )
87
+ self._warmup()
88
+
89
+ def __repr__(self) -> str:
90
+ return (
91
+ f"VehicleONNX({self.input_width}x{self.input_height}, "
92
+ f"classes={self.cls_remap.tolist()}, providers={self.session.get_providers()})"
93
+ )
94
+
95
+ def _warmup(self) -> None:
96
+ dummy = np.zeros((self.input_height, self.input_width, 3), dtype=np.uint8)
97
+ self.predict_batch([dummy], offset=0, n_keypoints=0)
98
+
99
+ @staticmethod
100
+ def _resolve_model_path(repo: Path) -> Path:
101
+ for name in ("weights.onnx", "vehicle.onnx"):
102
+ path = repo / name
103
+ if path.is_file():
104
+ return path
105
+ found = sorted(repo.glob("*.onnx"))
106
+ if found:
107
+ return found[0]
108
+ raise FileNotFoundError(f"No ONNX weights in {repo}")
109
+
110
+ def _read_model_class_order(self) -> list[str] | None:
111
+ try:
112
+ raw = self.session.get_modelmeta().custom_metadata_map["names"]
113
+ names = ast.literal_eval(raw)
114
+ except Exception as exc:
115
+ print(f"cls order: metadata unreadable ({exc})")
116
+ return None
117
+ if isinstance(names, dict):
118
+ return [str(names[key]) for key in sorted(names, key=lambda item: int(item))]
119
+ if isinstance(names, (list, tuple)):
120
+ return [str(name) for name in names]
121
+ return None
122
+
123
+ def _preprocess(self, image: ndarray) -> tuple[np.ndarray, float, float, float]:
124
+ """Letterbox into the ONNX input. Square 1024 frames have no side padding."""
125
+ height, width = image.shape[:2]
126
+ if image.dtype != np.uint8:
127
+ image = image.astype(np.uint8)
128
+ new_w, new_h = self.input_width, self.input_height
129
+ ratio = min(new_w / width, new_h / height)
130
+ resized_w, resized_h = int(round(width * ratio)), int(round(height * ratio))
131
+ if (resized_w, resized_h) != (width, height):
132
+ image = cv2.resize(image, (resized_w, resized_h), interpolation=cv2.INTER_LINEAR)
133
+ pad_w = (new_w - resized_w) / 2.0
134
+ pad_h = (new_h - resized_h) / 2.0
135
+ left, right = int(round(pad_w - 0.1)), int(round(pad_w + 0.1))
136
+ top, bottom = int(round(pad_h - 0.1)), int(round(pad_h + 0.1))
137
+ padded = cv2.copyMakeBorder(image, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114, 114, 114))
138
+ pad_h_px, pad_w_px = padded.shape[:2]
139
+ if pad_w_px != new_w or pad_h_px != new_h:
140
+ padded = cv2.copyMakeBorder(
141
+ padded,
142
+ 0,
143
+ max(0, new_h - pad_h_px),
144
+ 0,
145
+ max(0, new_w - pad_w_px),
146
+ cv2.BORDER_CONSTANT,
147
+ value=(114, 114, 114),
148
+ )
149
+ padded = padded[:new_h, :new_w]
150
+ blob = cv2.dnn.blobFromImage(padded, scalefactor=1.0 / 255.0, swapRB=True, crop=False)
151
+ return blob, ratio, pad_w, pad_h
152
+
153
+ @staticmethod
154
+ def _nms(boxes: np.ndarray, scores: np.ndarray, iou_thresh: float) -> np.ndarray:
155
+ if len(boxes) == 0:
156
+ return np.empty(0, dtype=np.intp)
157
+ x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
158
+ areas = np.maximum(0.0, x2 - x1) * np.maximum(0.0, y2 - y1)
159
+ order = np.argsort(-scores)
160
+ keep: list[int] = []
161
+ while order.size:
162
+ current = int(order[0])
163
+ keep.append(current)
164
+ if order.size == 1:
165
+ break
166
+ rest = order[1:]
167
+ xx1 = np.maximum(x1[current], x1[rest])
168
+ yy1 = np.maximum(y1[current], y1[rest])
169
+ xx2 = np.minimum(x2[current], x2[rest])
170
+ yy2 = np.minimum(y2[current], y2[rest])
171
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
172
+ iou = inter / (areas[current] + areas[rest] - inter + 1e-7)
173
+ order = rest[iou <= iou_thresh]
174
+ return np.asarray(keep, dtype=np.intp)
175
+
176
+ def _decode(
177
+ self,
178
+ output: np.ndarray,
179
+ ratio: float,
180
+ pad_w: float,
181
+ pad_h: float,
182
+ width: int,
183
+ height: int,
184
+ ) -> list[BoundingBox]:
185
+ preds = output[0] if output.ndim == 3 else output
186
+ if preds.shape[0] < preds.shape[1]:
187
+ preds = preds.T
188
+ class_scores = preds[:, 4:].astype(np.float32)
189
+ raw_cls = np.argmax(class_scores, axis=1).astype(np.int32)
190
+ scores = class_scores[np.arange(len(class_scores)), raw_cls]
191
+ known = (raw_cls >= 0) & (raw_cls < len(self.cls_remap))
192
+ mapped = np.full(len(raw_cls), -1, dtype=np.int32)
193
+ mapped[known] = self.cls_remap[raw_cls[known]]
194
+ keep = (mapped >= 0) & (scores >= CONF_THRES[np.clip(mapped, 0, len(CONF_THRES) - 1)])
195
+ keep &= mapped >= 0
196
+ if not np.any(keep):
197
+ return []
198
+
199
+ xywh = preds[keep, :4].astype(np.float32)
200
+ scores = scores[keep]
201
+ cls_ids = mapped[keep]
202
+ boxes = np.empty_like(xywh)
203
+ boxes[:, 0] = xywh[:, 0] - xywh[:, 2] / 2.0
204
+ boxes[:, 1] = xywh[:, 1] - xywh[:, 3] / 2.0
205
+ boxes[:, 2] = xywh[:, 0] + xywh[:, 2] / 2.0
206
+ boxes[:, 3] = xywh[:, 1] + xywh[:, 3] / 2.0
207
+ boxes[:, [0, 2]] = (boxes[:, [0, 2]] - pad_w) / ratio
208
+ boxes[:, [1, 3]] = (boxes[:, [1, 3]] - pad_h) / ratio
209
+ np.clip(boxes[:, 0], 0, width - 1, out=boxes[:, 0])
210
+ np.clip(boxes[:, 1], 0, height - 1, out=boxes[:, 1])
211
+ np.clip(boxes[:, 2], 0, width - 1, out=boxes[:, 2])
212
+ np.clip(boxes[:, 3], 0, height - 1, out=boxes[:, 3])
213
+
214
+ kept: list[int] = []
215
+ for cls_id in np.unique(cls_ids):
216
+ idx = np.where(cls_ids == cls_id)[0]
217
+ chosen = self._nms(boxes[idx], scores[idx], NMS_IOU)
218
+ kept.extend(idx[chosen].tolist())
219
+ if not kept:
220
+ return []
221
+ order = np.asarray(kept, dtype=np.intp)
222
+ order = order[np.argsort(-scores[order])]
223
+ boxes, scores, cls_ids = boxes[order], scores[order], cls_ids[order]
224
+ boxes, scores, cls_ids = self._drop_cross_class_duplicates(boxes, scores, cls_ids)
225
+
226
+ results: list[BoundingBox] = []
227
+ for box, score, cls_id in zip(boxes, scores, cls_ids):
228
+ x1, y1, x2, y2 = box.tolist()
229
+ x1_i = max(0, min(width, int(math.floor(x1))))
230
+ y1_i = max(0, min(height, int(math.floor(y1))))
231
+ x2_i = max(0, min(width, int(math.ceil(x2))))
232
+ y2_i = max(0, min(height, int(math.ceil(y2))))
233
+ if x2_i <= x1_i or y2_i <= y1_i:
234
+ continue
235
+ results.append(
236
+ BoundingBox(
237
+ x1=x1_i,
238
+ y1=y1_i,
239
+ x2=x2_i,
240
+ y2=y2_i,
241
+ cls_id=int(cls_id),
242
+ conf=float(max(0.0, min(1.0, score))),
243
+ )
244
+ )
245
+ return results
246
+
247
+ @staticmethod
248
+ def _drop_cross_class_duplicates(
249
+ boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray
250
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
251
+ n = len(boxes)
252
+ if n < 2:
253
+ return boxes, scores, cls_ids
254
+ drop = np.zeros(n, dtype=bool)
255
+ for i in range(n):
256
+ if drop[i]:
257
+ continue
258
+ for j in range(i + 1, n):
259
+ if drop[j] or cls_ids[i] == cls_ids[j]:
260
+ continue
261
+ xx1 = max(float(boxes[i, 0]), float(boxes[j, 0]))
262
+ yy1 = max(float(boxes[i, 1]), float(boxes[j, 1]))
263
+ xx2 = min(float(boxes[i, 2]), float(boxes[j, 2]))
264
+ yy2 = min(float(boxes[i, 3]), float(boxes[j, 3]))
265
+ inter = max(0.0, xx2 - xx1) * max(0.0, yy2 - yy1)
266
+ if inter <= 0.0:
267
+ continue
268
+ area_i = max(0.0, float(boxes[i, 2] - boxes[i, 0])) * max(0.0, float(boxes[i, 3] - boxes[i, 1]))
269
+ area_j = max(0.0, float(boxes[j, 2] - boxes[j, 0])) * max(0.0, float(boxes[j, 3] - boxes[j, 1]))
270
+ iou = inter / (area_i + area_j - inter + 1e-7)
271
+ if iou >= CROSS_CLASS_IOU:
272
+ drop[j if scores[j] < scores[i] else i] = True
273
+ keep = ~drop
274
+ return boxes[keep], scores[keep], cls_ids[keep]
275
+
276
+ def predict_batch(
277
+ self,
278
+ batch_images: list[ndarray],
279
+ offset: int,
280
+ n_keypoints: int,
281
+ ) -> list[TVFrameResult]:
282
+ n_keypoints = max(0, int(n_keypoints))
283
+ results: list[TVFrameResult] = []
284
+ for index, image in enumerate(batch_images):
285
+ try:
286
+ if not isinstance(image, np.ndarray) or image.ndim != 3 or image.shape[2] != 3:
287
+ raise ValueError(f"Expected HWC BGR image, got {getattr(image, 'shape', None)}")
288
+ blob, ratio, pad_w, pad_h = self._preprocess(image)
289
+ height, width = image.shape[:2]
290
+ output = self.session.run(None, {self.input_name: blob})[0]
291
+ boxes = self._decode(output, ratio, pad_w, pad_h, width, height)
292
+ except Exception as exc:
293
+ print(f"Inference failed for frame {offset + index}: {exc}")
294
+ boxes = []
295
+ results.append(
296
+ TVFrameResult(
297
+ frame_id=offset + index,
298
+ boxes=boxes,
299
+ polygons=[],
300
+ keypoints=[(0, 0) for _ in range(n_keypoints)],
301
+ )
302
+ )
303
+ return results