shaneperry0101 commited on
Commit
7e9e6f4
·
verified ·
1 Parent(s): e6dd5ea

scorevision: push artifact

Browse files
Files changed (1) hide show
  1. miner.py +340 -0
miner.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SN44 public-track miner entrypoint. Goes at the ROOT of your HF repo.
2
+
3
+ Sandbox constraints (verified against
4
+ scorevision/validator/audit/open_source/security.py):
5
+ * ALL logic must live in THIS file - the chute installs an import blocker
6
+ that rejects modules loaded outside stdlib/site-packages.
7
+ * Class `Miner` with `predict_batch(batch_images, offset, n_keypoints)`;
8
+ parameter NAMES are checked by signature inspection.
9
+ * Banned imports: socket, subprocess, ctypes, multiprocessing, requests,
10
+ urllib, http, ftplib, telnetlib, paramiko.
11
+ Banned calls: eval, exec, __import__, open, os.system/popen/remove/...
12
+ * Model artifacts: .onnx ONLY. Repo <= 30 MB.
13
+ * Single-frame p95 <= 110 ms on ~4 CPU threads.
14
+
15
+ CLASS ORDER IS THE #1 SILENT KILLER. The validator maps a prediction's
16
+ cls_id through the manifest `objects` list:
17
+ manak0/Detect-fire -> ["fire", "smoke", "fire extinguisher"]
18
+ Ultralytics models are commonly exported with a DIFFERENT internal order
19
+ (Score's own reference uses [fire, fire extinguisher, smoke]). We read the
20
+ `names` metadata Ultralytics embeds in the ONNX and remap onto manifest
21
+ order at runtime, so a retrained model with a different order still works.
22
+ An out-of-range or mis-mapped cls_id is dropped silently by the validator -
23
+ indistinguishable from a broken model.
24
+
25
+ SCORING (measured on 157 real challenges of the incumbent):
26
+ raw = 0.6*map50 + 0.4*false_positive
27
+ false_positive = max(0, 1 - (total_FP / n_images)/10)
28
+ Predicting nothing already scores raw 0.40, so a loose threshold is
29
+ expensive. Tune with miner_dev.sweep against the real metric, not mAP.
30
+ """
31
+
32
+ import ast
33
+ import json
34
+ from pathlib import Path
35
+
36
+ import numpy as np
37
+ import onnxruntime as ort
38
+
39
+ MANIFEST_OBJECTS = ["fire", "smoke", "fire extinguisher"]
40
+
41
+ MODEL_FILE = "model.onnx"
42
+ # 640/672/768 all fit the CPU budget; the incumbent runs 672 at 43.7 ms p95
43
+ # on a 4-thread box against a 110 ms ceiling, so 768 is affordable and buys
44
+ # map50 on small/distant objects - which is where the headroom is.
45
+ INPUT_SIZE = 704
46
+ NUM_THREADS = 4
47
+
48
+ # Per-class confidence thresholds, indexed by MANIFEST order.
49
+ CONF_THRES = np.array([0.2, 0.2, 0.15], dtype=np.float32)
50
+ # If a class has ZERO boxes over threshold, admit its top-1 candidate when it
51
+ # scores at least (threshold - bonus). Recovers recall on borderline frames
52
+ # without paying the false-positive cost on frames that already have boxes.
53
+ RESCUE_BONUS = np.array([0.03, 0.10, 0.05], dtype=np.float32)
54
+
55
+ IOU_THRES = 0.55 # per-class NMS (only used for non-end2end heads)
56
+ SAME_IOU_THRES = 0.70 # same-class dedup; end2end o2o heads still emit near-duplicates
57
+ CROSS_IOU_THRES = 0.90 # cross-class duplicate suppression, by IoU (see _postprocess)
58
+ MAX_DET = 30
59
+
60
+ # Box sanity filter: drop degenerate / tiny / image-spanning detections.
61
+ MIN_BOX_AREA = 14 * 14
62
+ MIN_SIDE = 8
63
+ MAX_ASPECT = 8.0
64
+ MAX_AREA_FRAC = 0.92
65
+
66
+ # Same-class union-merge when intersection covers this fraction of the
67
+ # SMALLER box. Smoke plumes fragment, so merging helps; separate flames must
68
+ # stay separate, so fire is disabled (>1.0).
69
+ MERGE_OVERLAP = np.array([1.01, 0.80, 1.01], dtype=np.float32)
70
+
71
+
72
+ def _letterbox(img, size):
73
+ import cv2
74
+ h, w = img.shape[:2]
75
+ s = min(size / max(h, 1), size / max(w, 1))
76
+ nh, nw = max(1, int(round(h * s))), max(1, int(round(w * s)))
77
+ canvas = np.full((size, size, 3), 114, dtype=np.uint8)
78
+ dy, dx = (size - nh) // 2, (size - nw) // 2
79
+ canvas[dy:dy + nh, dx:dx + nw] = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR)
80
+ return canvas, s, dx, dy
81
+
82
+
83
+ def _nms(boxes, scores, thr):
84
+ if boxes.size == 0:
85
+ return []
86
+ x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
87
+ areas = np.maximum(0.0, x2 - x1) * np.maximum(0.0, y2 - y1)
88
+ order = scores.argsort()[::-1]
89
+ keep = []
90
+ while order.size:
91
+ i = order[0]
92
+ keep.append(int(i))
93
+ if order.size == 1:
94
+ break
95
+ xx1 = np.maximum(x1[i], x1[order[1:]]); yy1 = np.maximum(y1[i], y1[order[1:]])
96
+ xx2 = np.minimum(x2[i], x2[order[1:]]); yy2 = np.minimum(y2[i], y2[order[1:]])
97
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
98
+ union = areas[i] + areas[order[1:]] - inter
99
+ # np.where would evaluate eagerly and emit nan on union==0; nan <= thr
100
+ # is False, which would silently DROP a valid box.
101
+ iou = np.divide(inter, union, out=np.zeros_like(inter, dtype=np.float64), where=union > 0)
102
+ order = order[1:][iou <= thr]
103
+ return keep
104
+
105
+
106
+ def _inter_over_smaller(a, b):
107
+ ix1, iy1 = max(a[0], b[0]), max(a[1], b[1])
108
+ ix2, iy2 = min(a[2], b[2]), min(a[3], b[3])
109
+ iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
110
+ inter = iw * ih
111
+ if inter <= 0:
112
+ return 0.0
113
+ sa = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1])
114
+ sb = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1])
115
+ m = min(sa, sb)
116
+ return inter / m if m > 0 else 0.0
117
+
118
+
119
+ def _iou_pair(a, b):
120
+ ix1, iy1 = max(a[0], b[0]), max(a[1], b[1])
121
+ ix2, iy2 = min(a[2], b[2]), min(a[3], b[3])
122
+ iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
123
+ inter = iw * ih
124
+ if inter <= 0:
125
+ return 0.0
126
+ ua = (max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1])
127
+ + max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1]) - inter)
128
+ return inter / ua if ua > 0 else 0.0
129
+
130
+
131
+ class Miner:
132
+ def __init__(self, path_hf_repo) -> None:
133
+ repo = Path(path_hf_repo)
134
+ model_path = repo / MODEL_FILE
135
+ if not model_path.is_file():
136
+ raise FileNotFoundError(f"missing {MODEL_FILE} in {repo}")
137
+
138
+ opts = ort.SessionOptions()
139
+ opts.intra_op_num_threads = NUM_THREADS
140
+ opts.inter_op_num_threads = 1
141
+ opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
142
+ self.session = ort.InferenceSession(str(model_path), opts,
143
+ providers=["CPUExecutionProvider"])
144
+ inp = self.session.get_inputs()[0]
145
+ self.input_name = inp.name
146
+ # The exported model's own spatial size is authoritative. Forcing a
147
+ # different INPUT_SIZE against a static-shape export raises, and the
148
+ # never-raise handler in predict_batch would turn that into a silent
149
+ # zero score. Trust the graph; fall back to INPUT_SIZE only if dynamic.
150
+ static = [d for d in inp.shape[2:] if isinstance(d, int) and d > 0]
151
+ self.size = int(static[0]) if len(static) == 2 else INPUT_SIZE
152
+ self.remap = self._build_remap()
153
+ self.end2end = None # resolved on first inference from output shape
154
+ self.last_error = None # surfaced for debugging; never raised
155
+
156
+ def _build_remap(self):
157
+ """Model class index -> manifest index, by NAME, from ONNX metadata."""
158
+ try:
159
+ meta = self.session.get_modelmeta().custom_metadata_map or {}
160
+ raw = meta.get("names")
161
+ names = ast.literal_eval(raw) if raw else None
162
+ if isinstance(names, dict):
163
+ out = {}
164
+ for k, v in names.items():
165
+ n = str(v).strip().lower()
166
+ if n in MANIFEST_OBJECTS:
167
+ out[int(k)] = MANIFEST_OBJECTS.index(n)
168
+ if out:
169
+ return out
170
+ except Exception:
171
+ pass
172
+ return {i: i for i in range(len(MANIFEST_OBJECTS))}
173
+
174
+ def __repr__(self):
175
+ return (f"ONNX detector size={self.size} threads={NUM_THREADS} "
176
+ f"remap={self.remap} conf={CONF_THRES.tolist()}")
177
+
178
+ def _decode(self, raw, s, dx, dy, h, w):
179
+ """Return (xyxy Nx4, cls N, conf N) in ORIGINAL image coords."""
180
+ arr = raw[0] if raw.ndim == 3 else raw
181
+ if arr.ndim == 2 and arr.shape[-1] == 6: # end2end: already NMS'd
182
+ self.end2end = True
183
+ boxes = arr[:, :4].astype(np.float32)
184
+ conf = arr[:, 4].astype(np.float32)
185
+ cls = arr[:, 5].astype(np.int32)
186
+ keep = conf > 0
187
+ boxes, conf, cls = boxes[keep], conf[keep], cls[keep]
188
+ else: # raw head -> needs NMS
189
+ self.end2end = False
190
+ pred = arr.T if arr.shape[0] < arr.shape[1] else arr
191
+ if pred.shape[1] < 5:
192
+ return np.zeros((0, 4)), np.zeros(0, int), np.zeros(0)
193
+ xywh, sc = pred[:, :4], pred[:, 4:]
194
+ cls = sc.argmax(1).astype(np.int32)
195
+ conf = sc.max(1).astype(np.float32)
196
+ keep = conf >= float(CONF_THRES.min() - RESCUE_BONUS.max())
197
+ xywh, cls, conf = xywh[keep], cls[keep], conf[keep]
198
+ cx, cy, bw, bh = xywh[:, 0], xywh[:, 1], xywh[:, 2], xywh[:, 3]
199
+ boxes = np.stack([cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2], 1)
200
+ sel = []
201
+ for c in np.unique(cls):
202
+ m = np.where(cls == c)[0]
203
+ sel.extend(m[_nms(boxes[m], conf[m], IOU_THRES)])
204
+ sel = np.array(sorted(sel), dtype=int) if sel else np.zeros(0, int)
205
+ boxes, cls, conf = boxes[sel], cls[sel], conf[sel]
206
+
207
+ if boxes.shape[0]:
208
+ boxes[:, [0, 2]] = (boxes[:, [0, 2]] - dx) / max(s, 1e-9)
209
+ boxes[:, [1, 3]] = (boxes[:, [1, 3]] - dy) / max(s, 1e-9)
210
+ boxes[:, [0, 2]] = boxes[:, [0, 2]].clip(0, w)
211
+ boxes[:, [1, 3]] = boxes[:, [1, 3]].clip(0, h)
212
+ return boxes, cls, conf
213
+
214
+ def _postprocess(self, boxes, cls, conf, h, w):
215
+ # remap model classes onto manifest order, drop unknown classes
216
+ mapped = np.array([self.remap.get(int(c), -1) for c in cls], dtype=np.int32)
217
+ ok = mapped >= 0
218
+ boxes, conf, mapped = boxes[ok], conf[ok], mapped[ok]
219
+ if not boxes.shape[0]:
220
+ return []
221
+
222
+ # box sanity filter
223
+ bw = boxes[:, 2] - boxes[:, 0]
224
+ bh = boxes[:, 3] - boxes[:, 1]
225
+ area = bw * bh
226
+ with np.errstate(divide="ignore", invalid="ignore"):
227
+ ar = np.maximum(bw / np.maximum(bh, 1e-6), bh / np.maximum(bw, 1e-6))
228
+ sane = ((bw >= MIN_SIDE) & (bh >= MIN_SIDE) & (area >= MIN_BOX_AREA)
229
+ & (ar <= MAX_ASPECT) & (area <= MAX_AREA_FRAC * h * w))
230
+ boxes, conf, mapped = boxes[sane], conf[sane], mapped[sane]
231
+ if not boxes.shape[0]:
232
+ return []
233
+
234
+ # per-class threshold + rescue bonus
235
+ keep_idx = []
236
+ for c in range(len(MANIFEST_OBJECTS)):
237
+ m = np.where(mapped == c)[0]
238
+ if not m.size:
239
+ continue
240
+ passing = m[conf[m] >= CONF_THRES[c]]
241
+ if passing.size:
242
+ keep_idx.extend(passing.tolist())
243
+ else:
244
+ top = m[int(np.argmax(conf[m]))]
245
+ if conf[top] >= CONF_THRES[c] - RESCUE_BONUS[c]:
246
+ keep_idx.append(int(top))
247
+ if not keep_idx:
248
+ return []
249
+ keep_idx = np.array(sorted(set(keep_idx)), dtype=int)
250
+ boxes, conf, mapped = boxes[keep_idx], conf[keep_idx], mapped[keep_idx]
251
+
252
+ # same-class dedup. The end2end branch skips NMS entirely, but the o2o head
253
+ # still emits near-duplicates; each one is scored as a false positive AND
254
+ # steals no match, so it is pure loss under the adaptive-IoU rule.
255
+ sel = []
256
+ for c in range(len(MANIFEST_OBJECTS)):
257
+ m = np.where(mapped == c)[0]
258
+ if m.size:
259
+ sel.extend(m[_nms(boxes[m], conf[m], SAME_IOU_THRES)])
260
+ if not sel:
261
+ return []
262
+ sel = np.array(sorted(sel), dtype=int)
263
+ boxes, conf, mapped = boxes[sel], conf[sel], mapped[sel]
264
+
265
+ # same-class union merge (smoke fragments; fire disabled)
266
+ for c in range(len(MANIFEST_OBJECTS)):
267
+ if MERGE_OVERLAP[c] > 1.0:
268
+ continue
269
+ changed = True
270
+ while changed:
271
+ changed = False
272
+ idx = np.where(mapped == c)[0]
273
+ for a in range(len(idx)):
274
+ for b in range(a + 1, len(idx)):
275
+ i, j = idx[a], idx[b]
276
+ if _inter_over_smaller(boxes[i], boxes[j]) >= MERGE_OVERLAP[c]:
277
+ boxes[i] = [min(boxes[i][0], boxes[j][0]), min(boxes[i][1], boxes[j][1]),
278
+ max(boxes[i][2], boxes[j][2]), max(boxes[i][3], boxes[j][3])]
279
+ conf[i] = max(conf[i], conf[j])
280
+ mapped[j] = -1
281
+ changed = True
282
+ break
283
+ if changed:
284
+ break
285
+ sel = mapped >= 0
286
+ boxes, conf, mapped = boxes[sel], conf[sel], mapped[sel]
287
+
288
+ # Cross-class duplicate suppression: same object carrying two labels.
289
+ # Must be IoU, not intersection-over-smaller: fire sits *inside* smoke in
290
+ # most real frames, which drives IoS to ~1.0 and deleted the true fire box.
291
+ order = conf.argsort()[::-1]
292
+ dead = set()
293
+ for a in range(len(order)):
294
+ i = order[a]
295
+ if i in dead:
296
+ continue
297
+ for b in range(a + 1, len(order)):
298
+ j = order[b]
299
+ if j in dead or mapped[i] == mapped[j]:
300
+ continue
301
+ if _iou_pair(boxes[i], boxes[j]) >= CROSS_IOU_THRES:
302
+ dead.add(j)
303
+
304
+ out = []
305
+ for i in order:
306
+ if i in dead:
307
+ continue
308
+ x1, y1, x2, y2 = boxes[i]
309
+ if x2 <= x1 or y2 <= y1:
310
+ continue
311
+ out.append({"x1": int(x1), "y1": int(y1), "x2": int(x2), "y2": int(y2),
312
+ "cls_id": int(mapped[i]), "conf": float(conf[i])})
313
+ if len(out) >= MAX_DET:
314
+ break
315
+ return out
316
+
317
+ def predict_batch(self, batch_images, offset: int, n_keypoints: int) -> list:
318
+ """Signature is contract-checked: do not rename these parameters."""
319
+ results = []
320
+ for i, img in enumerate(batch_images):
321
+ frame_id = offset + i
322
+ try:
323
+ arr = np.asarray(img)
324
+ if arr.ndim == 2:
325
+ arr = np.stack([arr] * 3, axis=-1)
326
+ h, w = arr.shape[:2]
327
+ canvas, s, dx, dy = _letterbox(arr, self.size)
328
+ blob = canvas[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0
329
+ raw = self.session.run(None, {self.input_name: blob})[0]
330
+ boxes, cls, conf = self._decode(raw, s, dx, dy, h, w)
331
+ dets = self._postprocess(boxes, cls, conf, h, w)
332
+ except Exception as e:
333
+ # Never raise: an exception zeroes the whole challenge. Record
334
+ # it so offline harnesses can tell "no detections" apart from
335
+ # "crashed" - silent except made those indistinguishable.
336
+ self.last_error = f"{type(e).__name__}: {e}"
337
+ dets = []
338
+ results.append({"frame_id": frame_id, "boxes": dets,
339
+ "polygons": [], "keypoints": []})
340
+ return results