maple-matrix commited on
Commit
32f5e20
·
verified ·
1 Parent(s): baceba4

scorevision: push artifact

Browse files
Files changed (1) hide show
  1. miner.py +220 -0
miner.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # v8: yolo11s trained on validator-aligned SAM3 labels.
2
+ # Pool val backtest: F1=0.862 vs v32 F1=0.742 (+0.125 absolute, smoke recall 89% vs 43%).
3
+ from pathlib import Path
4
+
5
+ import cv2
6
+ import numpy as np
7
+ import onnxruntime as ort
8
+ from numpy import ndarray
9
+ from pydantic import BaseModel
10
+
11
+
12
+ class BoundingBox(BaseModel):
13
+ x1: int
14
+ y1: int
15
+ x2: int
16
+ y2: int
17
+ cls_id: int
18
+ conf: float
19
+
20
+
21
+ class TVFrameResult(BaseModel):
22
+ frame_id: int
23
+ boxes: list[BoundingBox]
24
+ keypoints: list[tuple[int, int]]
25
+
26
+
27
+ class Miner:
28
+ """v8: ONNX with built-in NMS → light post-processing.
29
+
30
+ Pipeline:
31
+ 1. Letterbox to 1280x1280
32
+ 2. ONNX inference (returns [1, 300, 6] post-NMS)
33
+ 3. Conf filter
34
+ 4. Extra per-class dedup (IoU > 0.3 OR ≥80% containment) — catches nested
35
+ duplicates the model inherits from SAM3 training labels that default
36
+ NMS@0.5 doesn't suppress
37
+ 5. Top-1 fallback if everything got filtered — empty predictions are heavily
38
+ penalized; even a low-conf best guess scores better than nothing
39
+ 6. Un-letterbox coords back to original size + clip
40
+ """
41
+
42
+ # validator-visible class output order (what the runner expects in cls_id)
43
+ class_names = ["fire", "smoke", "fire extinguisher"]
44
+ # order the v8 ONNX emits classes (training CLASS_ORDER in v8_build_dataset.py)
45
+ _model_class_order = ["fire", "fire extinguisher", "smoke"]
46
+
47
+ input_size = 1280
48
+ conf_thresh = 0.25
49
+ nms_iou_thresh = 0.3
50
+ contain_thresh = 0.80
51
+ fallback_min_conf = 0.05 # top-1 fallback floors at this; never return total junk
52
+
53
+ def __init__(self, path_hf_repo: Path) -> None:
54
+ model_path = path_hf_repo / "weights.onnx"
55
+
56
+ self.cls_remap = np.array(
57
+ [self.class_names.index(n) for n in self._model_class_order],
58
+ dtype=np.int32,
59
+ )
60
+
61
+ try:
62
+ ort.preload_dlls()
63
+ except Exception as e:
64
+ print(f"preload_dlls: {e}")
65
+
66
+ sess_options = ort.SessionOptions()
67
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
68
+
69
+ try:
70
+ self.session = ort.InferenceSession(
71
+ str(model_path),
72
+ sess_options=sess_options,
73
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
74
+ )
75
+ except Exception as e:
76
+ print(f"CUDA failed, CPU: {e}")
77
+ self.session = ort.InferenceSession(
78
+ str(model_path),
79
+ sess_options=sess_options,
80
+ providers=["CPUExecutionProvider"],
81
+ )
82
+
83
+ self.input_name = self.session.get_inputs()[0].name
84
+ print(f"v8 ONNX loaded, providers={self.session.get_providers()}")
85
+
86
+ def __repr__(self) -> str:
87
+ return f"v8 Miner (providers={self.session.get_providers()})"
88
+
89
+ def _letterbox(self, image: ndarray):
90
+ h, w = image.shape[:2]
91
+ s = self.input_size / max(h, w)
92
+ nw, nh = int(round(w * s)), int(round(h * s))
93
+ if (nw, nh) != (w, h):
94
+ interp = cv2.INTER_CUBIC if s > 1.0 else cv2.INTER_LINEAR
95
+ image = cv2.resize(image, (nw, nh), interpolation=interp)
96
+ canvas = np.full((self.input_size, self.input_size, 3), 114, dtype=np.uint8)
97
+ dx = (self.input_size - nw) // 2
98
+ dy = (self.input_size - nh) // 2
99
+ canvas[dy:dy + nh, dx:dx + nw] = image
100
+ return canvas, s, (dx, dy)
101
+
102
+ def _preprocess(self, image: ndarray):
103
+ H, W = image.shape[:2]
104
+ padded, scale, (dx, dy) = self._letterbox(image)
105
+ x = padded[:, :, ::-1].astype(np.float32) / 255.0 # BGR→RGB, /255
106
+ x = np.ascontiguousarray(x.transpose(2, 0, 1)[None], dtype=np.float32)
107
+ return x, scale, (dx, dy), (W, H)
108
+
109
+ @staticmethod
110
+ def _iou(a, b):
111
+ ix1 = max(a[0], b[0]); iy1 = max(a[1], b[1])
112
+ ix2 = min(a[2], b[2]); iy2 = min(a[3], b[3])
113
+ iw = max(0.0, ix2 - ix1); ih = max(0.0, iy2 - iy1)
114
+ inter = iw * ih
115
+ ua = (a[2]-a[0])*(a[3]-a[1]) + (b[2]-b[0])*(b[3]-b[1]) - inter
116
+ return inter / ua if ua > 0 else 0.0
117
+
118
+ @staticmethod
119
+ def _containment(inner, outer):
120
+ ix1 = max(inner[0], outer[0]); iy1 = max(inner[1], outer[1])
121
+ ix2 = min(inner[2], outer[2]); iy2 = min(inner[3], outer[3])
122
+ iw = max(0.0, ix2 - ix1); ih = max(0.0, iy2 - iy1)
123
+ inter = iw * ih
124
+ a_in = (inner[2]-inner[0]) * (inner[3]-inner[1])
125
+ return inter / a_in if a_in > 0 else 0.0
126
+
127
+ def _dedup(self, boxes_xyxy, scores, cls_ids):
128
+ """Per-class dedup: drop a box if same-class IoU>nms_iou OR ≥contain_thresh
129
+ contained in a larger same-class box. Keep larger box on ties."""
130
+ n = len(boxes_xyxy)
131
+ if n <= 1:
132
+ return np.arange(n, dtype=np.intp)
133
+ # Sort by area desc (so we always test smaller against larger we already kept)
134
+ areas = (boxes_xyxy[:, 2] - boxes_xyxy[:, 0]) * (boxes_xyxy[:, 3] - boxes_xyxy[:, 1])
135
+ order = np.argsort(-areas)
136
+ keep = []
137
+ suppressed = np.zeros(n, dtype=bool)
138
+ for i in order:
139
+ if suppressed[i]: continue
140
+ keep.append(int(i))
141
+ for j in order:
142
+ if j == i or suppressed[j]: continue
143
+ if cls_ids[i] != cls_ids[j]: continue
144
+ if self._iou(boxes_xyxy[i], boxes_xyxy[j]) > self.nms_iou_thresh:
145
+ suppressed[j] = True; continue
146
+ if self._containment(boxes_xyxy[j], boxes_xyxy[i]) >= self.contain_thresh:
147
+ suppressed[j] = True
148
+ keep.sort()
149
+ return np.array(keep, dtype=np.intp)
150
+
151
+ def _predict_one(self, frame: ndarray) -> list[BoundingBox]:
152
+ x, scale, (dx, dy), (W, H) = self._preprocess(frame)
153
+ out = self.session.run(None, {self.input_name: x})[0]
154
+ # output shape: [1, 300, 6] — (x1, y1, x2, y2, conf, cls_id)
155
+ raw = out[0]
156
+ if raw.shape[0] == 0:
157
+ return []
158
+
159
+ # Apply conf filter (keep raw for fallback)
160
+ primary = raw[raw[:, 4] >= self.conf_thresh]
161
+
162
+ # Per-class dedup on the conf-filtered set
163
+ final_dets = []
164
+ if len(primary) > 0:
165
+ xyxy = primary[:, :4].astype(np.float32)
166
+ scores = primary[:, 4].astype(np.float32)
167
+ cls_ids = primary[:, 5].astype(np.int32)
168
+ keep_idx = self._dedup(xyxy, scores, cls_ids)
169
+ primary = primary[keep_idx]
170
+ for det in primary:
171
+ final_dets.append(det)
172
+
173
+ # Fallback: nothing left → return single highest-conf raw box (above floor)
174
+ if not final_dets and raw.shape[0] > 0:
175
+ top = raw[np.argmax(raw[:, 4])]
176
+ if top[4] >= self.fallback_min_conf:
177
+ final_dets.append(top)
178
+
179
+ # Build BoundingBox list with un-letterbox + cls remap
180
+ boxes_out: list[BoundingBox] = []
181
+ for det in final_dets:
182
+ x1, y1, x2, y2, conf, model_cls_id = det
183
+ x1 = (x1 - dx) / scale; x2 = (x2 - dx) / scale
184
+ y1 = (y1 - dy) / scale; y2 = (y2 - dy) / scale
185
+ x1 = max(0.0, min(W - 1.0, x1)); x2 = max(0.0, min(W - 1.0, x2))
186
+ y1 = max(0.0, min(H - 1.0, y1)); y2 = max(0.0, min(H - 1.0, y2))
187
+ if x2 <= x1 or y2 <= y1:
188
+ continue
189
+ mapped_cls = int(self.cls_remap[int(model_cls_id)])
190
+ boxes_out.append(BoundingBox(
191
+ x1=int(x1), y1=int(y1), x2=int(x2), y2=int(y2),
192
+ cls_id=mapped_cls, conf=float(conf),
193
+ ))
194
+ return boxes_out
195
+
196
+ def predict_batch(
197
+ self,
198
+ batch_images: list[ndarray],
199
+ offset: int,
200
+ n_keypoints: int,
201
+ ) -> list[TVFrameResult]:
202
+ """Required interface for chute template (sv_chutes_*.py)."""
203
+ results: list[TVFrameResult] = []
204
+ for frame_number_in_batch, image in enumerate(batch_images):
205
+ try:
206
+ boxes = self._predict_one(image)
207
+ except Exception as e:
208
+ print(f"⚠️ Inference failed for frame "
209
+ f"{offset + frame_number_in_batch}: {e}")
210
+ boxes = []
211
+ results.append(TVFrameResult(
212
+ frame_id=offset + frame_number_in_batch,
213
+ boxes=boxes,
214
+ keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
215
+ ))
216
+ return results
217
+
218
+ # Back-compat alias for local sanity testing
219
+ def run(self, frames: list[ndarray]) -> list[TVFrameResult]:
220
+ return self.predict_batch(frames, offset=0, n_keypoints=0)