| """SN44 public-track miner entrypoint. Goes at the ROOT of your HF repo. |
| |
| Sandbox constraints (verified against |
| scorevision/validator/audit/open_source/security.py): |
| * ALL logic must live in THIS file - the chute installs an import blocker |
| that rejects modules loaded outside stdlib/site-packages. |
| * Class `Miner` with `predict_batch(batch_images, offset, n_keypoints)`; |
| parameter NAMES are checked by signature inspection. |
| * Banned imports: socket, subprocess, ctypes, multiprocessing, requests, |
| urllib, http, ftplib, telnetlib, paramiko. |
| Banned calls: eval, exec, __import__, open, os.system/popen/remove/... |
| * Model artifacts: .onnx ONLY. Repo <= 30 MB. |
| * Single-frame p95 <= 110 ms on ~4 CPU threads. |
| |
| CLASS ORDER IS THE #1 SILENT KILLER. The validator maps a prediction's |
| cls_id through the manifest `objects` list: |
| manak0/Detect-fire -> ["fire", "smoke", "fire extinguisher"] |
| Ultralytics models are commonly exported with a DIFFERENT internal order |
| (Score's own reference uses [fire, fire extinguisher, smoke]). We read the |
| `names` metadata Ultralytics embeds in the ONNX and remap onto manifest |
| order at runtime, so a retrained model with a different order still works. |
| An out-of-range or mis-mapped cls_id is dropped silently by the validator - |
| indistinguishable from a broken model. |
| |
| SCORING (measured on 157 real challenges of the incumbent): |
| raw = 0.6*map50 + 0.4*false_positive |
| false_positive = max(0, 1 - (total_FP / n_images)/10) |
| Predicting nothing already scores raw 0.40, so a loose threshold is |
| expensive. Tune with miner_dev.sweep against the real metric, not mAP. |
| """ |
|
|
| import ast |
| import json |
| from pathlib import Path |
|
|
| import numpy as np |
| import onnxruntime as ort |
| from pydantic import BaseModel |
|
|
| MANIFEST_OBJECTS = ["fire", "smoke", "fire extinguisher"] |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| class BoundingBox(BaseModel): |
| x1: int |
| y1: int |
| x2: int |
| y2: int |
| cls_id: int |
| conf: float |
|
|
|
|
| class Polygon(BaseModel): |
| cls_id: int |
| conf: float |
| points: list[tuple[int, int]] |
|
|
|
|
| class TVFrameResult(BaseModel): |
| frame_id: int |
| boxes: list[BoundingBox] | None = None |
| polygons: list[Polygon] | None = None |
| keypoints: list[tuple[int, int]] | None = None |
|
|
| MODEL_FILE = "model.onnx" |
| |
| |
| |
| INPUT_SIZE = 704 |
| NUM_THREADS = 4 |
|
|
| |
| CONF_THRES = np.array([0.2, 0.2, 0.15], dtype=np.float32) |
| |
| |
| |
| RESCUE_BONUS = np.array([0.03, 0.10, 0.05], dtype=np.float32) |
|
|
| IOU_THRES = 0.55 |
| SAME_IOU_THRES = 0.70 |
| CROSS_IOU_THRES = 0.90 |
| MAX_DET = 30 |
|
|
| |
| MIN_BOX_AREA = 14 * 14 |
| MIN_SIDE = 8 |
| MAX_ASPECT = 8.0 |
| MAX_AREA_FRAC = 0.92 |
|
|
| |
| |
| |
| MERGE_OVERLAP = np.array([1.01, 0.80, 1.01], dtype=np.float32) |
|
|
|
|
| def _letterbox(img, size): |
| import cv2 |
| h, w = img.shape[:2] |
| s = min(size / max(h, 1), size / max(w, 1)) |
| nh, nw = max(1, int(round(h * s))), max(1, int(round(w * s))) |
| canvas = np.full((size, size, 3), 114, dtype=np.uint8) |
| dy, dx = (size - nh) // 2, (size - nw) // 2 |
| canvas[dy:dy + nh, dx:dx + nw] = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR) |
| return canvas, s, dx, dy |
|
|
|
|
| def _nms(boxes, scores, thr): |
| if boxes.size == 0: |
| return [] |
| x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] |
| areas = np.maximum(0.0, x2 - x1) * np.maximum(0.0, y2 - y1) |
| order = scores.argsort()[::-1] |
| keep = [] |
| while order.size: |
| i = order[0] |
| keep.append(int(i)) |
| if order.size == 1: |
| break |
| xx1 = np.maximum(x1[i], x1[order[1:]]); yy1 = np.maximum(y1[i], y1[order[1:]]) |
| xx2 = np.minimum(x2[i], x2[order[1:]]); yy2 = np.minimum(y2[i], y2[order[1:]]) |
| inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1) |
| union = areas[i] + areas[order[1:]] - inter |
| |
| |
| iou = np.divide(inter, union, out=np.zeros_like(inter, dtype=np.float64), where=union > 0) |
| order = order[1:][iou <= thr] |
| return keep |
|
|
|
|
| def _inter_over_smaller(a, b): |
| ix1, iy1 = max(a[0], b[0]), max(a[1], b[1]) |
| ix2, iy2 = min(a[2], b[2]), min(a[3], b[3]) |
| iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1) |
| inter = iw * ih |
| if inter <= 0: |
| return 0.0 |
| sa = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1]) |
| sb = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1]) |
| m = min(sa, sb) |
| return inter / m if m > 0 else 0.0 |
|
|
|
|
| def _iou_pair(a, b): |
| ix1, iy1 = max(a[0], b[0]), max(a[1], b[1]) |
| ix2, iy2 = min(a[2], b[2]), min(a[3], b[3]) |
| iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1) |
| inter = iw * ih |
| if inter <= 0: |
| return 0.0 |
| ua = (max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1]) |
| + max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1]) - inter) |
| return inter / ua if ua > 0 else 0.0 |
|
|
|
|
| class Miner: |
| def __init__(self, path_hf_repo) -> None: |
| repo = Path(path_hf_repo) |
| model_path = repo / MODEL_FILE |
| if not model_path.is_file(): |
| raise FileNotFoundError(f"missing {MODEL_FILE} in {repo}") |
|
|
| opts = ort.SessionOptions() |
| opts.intra_op_num_threads = NUM_THREADS |
| opts.inter_op_num_threads = 1 |
| opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL |
| self.session = ort.InferenceSession(str(model_path), opts, |
| providers=["CPUExecutionProvider"]) |
| inp = self.session.get_inputs()[0] |
| self.input_name = inp.name |
| |
| |
| |
| |
| static = [d for d in inp.shape[2:] if isinstance(d, int) and d > 0] |
| self.size = int(static[0]) if len(static) == 2 else INPUT_SIZE |
| self.remap = self._build_remap() |
| self.end2end = None |
| self.last_error = None |
|
|
| def _build_remap(self): |
| """Model class index -> manifest index, by NAME, from ONNX metadata.""" |
| try: |
| meta = self.session.get_modelmeta().custom_metadata_map or {} |
| raw = meta.get("names") |
| names = ast.literal_eval(raw) if raw else None |
| if isinstance(names, dict): |
| out = {} |
| for k, v in names.items(): |
| n = str(v).strip().lower() |
| if n in MANIFEST_OBJECTS: |
| out[int(k)] = MANIFEST_OBJECTS.index(n) |
| if out: |
| return out |
| except Exception: |
| pass |
| return {i: i for i in range(len(MANIFEST_OBJECTS))} |
|
|
| def __repr__(self): |
| return (f"ONNX detector size={self.size} threads={NUM_THREADS} " |
| f"remap={self.remap} conf={CONF_THRES.tolist()}") |
|
|
| def _decode(self, raw, s, dx, dy, h, w): |
| """Return (xyxy Nx4, cls N, conf N) in ORIGINAL image coords.""" |
| arr = raw[0] if raw.ndim == 3 else raw |
| if arr.ndim == 2 and arr.shape[-1] == 6: |
| self.end2end = True |
| boxes = arr[:, :4].astype(np.float32) |
| conf = arr[:, 4].astype(np.float32) |
| cls = arr[:, 5].astype(np.int32) |
| keep = conf > 0 |
| boxes, conf, cls = boxes[keep], conf[keep], cls[keep] |
| else: |
| self.end2end = False |
| pred = arr.T if arr.shape[0] < arr.shape[1] else arr |
| if pred.shape[1] < 5: |
| return np.zeros((0, 4)), np.zeros(0, int), np.zeros(0) |
| xywh, sc = pred[:, :4], pred[:, 4:] |
| cls = sc.argmax(1).astype(np.int32) |
| conf = sc.max(1).astype(np.float32) |
| keep = conf >= float(CONF_THRES.min() - RESCUE_BONUS.max()) |
| xywh, cls, conf = xywh[keep], cls[keep], conf[keep] |
| cx, cy, bw, bh = xywh[:, 0], xywh[:, 1], xywh[:, 2], xywh[:, 3] |
| boxes = np.stack([cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2], 1) |
| sel = [] |
| for c in np.unique(cls): |
| m = np.where(cls == c)[0] |
| sel.extend(m[_nms(boxes[m], conf[m], IOU_THRES)]) |
| sel = np.array(sorted(sel), dtype=int) if sel else np.zeros(0, int) |
| boxes, cls, conf = boxes[sel], cls[sel], conf[sel] |
|
|
| if boxes.shape[0]: |
| boxes[:, [0, 2]] = (boxes[:, [0, 2]] - dx) / max(s, 1e-9) |
| boxes[:, [1, 3]] = (boxes[:, [1, 3]] - dy) / max(s, 1e-9) |
| boxes[:, [0, 2]] = boxes[:, [0, 2]].clip(0, w) |
| boxes[:, [1, 3]] = boxes[:, [1, 3]].clip(0, h) |
| return boxes, cls, conf |
|
|
| def _postprocess(self, boxes, cls, conf, h, w): |
| |
| mapped = np.array([self.remap.get(int(c), -1) for c in cls], dtype=np.int32) |
| ok = mapped >= 0 |
| boxes, conf, mapped = boxes[ok], conf[ok], mapped[ok] |
| if not boxes.shape[0]: |
| return [] |
|
|
| |
| bw = boxes[:, 2] - boxes[:, 0] |
| bh = boxes[:, 3] - boxes[:, 1] |
| area = bw * bh |
| with np.errstate(divide="ignore", invalid="ignore"): |
| ar = np.maximum(bw / np.maximum(bh, 1e-6), bh / np.maximum(bw, 1e-6)) |
| sane = ((bw >= MIN_SIDE) & (bh >= MIN_SIDE) & (area >= MIN_BOX_AREA) |
| & (ar <= MAX_ASPECT) & (area <= MAX_AREA_FRAC * h * w)) |
| boxes, conf, mapped = boxes[sane], conf[sane], mapped[sane] |
| if not boxes.shape[0]: |
| return [] |
|
|
| |
| keep_idx = [] |
| for c in range(len(MANIFEST_OBJECTS)): |
| m = np.where(mapped == c)[0] |
| if not m.size: |
| continue |
| passing = m[conf[m] >= CONF_THRES[c]] |
| if passing.size: |
| keep_idx.extend(passing.tolist()) |
| else: |
| top = m[int(np.argmax(conf[m]))] |
| if conf[top] >= CONF_THRES[c] - RESCUE_BONUS[c]: |
| keep_idx.append(int(top)) |
| if not keep_idx: |
| return [] |
| keep_idx = np.array(sorted(set(keep_idx)), dtype=int) |
| boxes, conf, mapped = boxes[keep_idx], conf[keep_idx], mapped[keep_idx] |
|
|
| |
| |
| |
| sel = [] |
| for c in range(len(MANIFEST_OBJECTS)): |
| m = np.where(mapped == c)[0] |
| if m.size: |
| sel.extend(m[_nms(boxes[m], conf[m], SAME_IOU_THRES)]) |
| if not sel: |
| return [] |
| sel = np.array(sorted(sel), dtype=int) |
| boxes, conf, mapped = boxes[sel], conf[sel], mapped[sel] |
|
|
| |
| for c in range(len(MANIFEST_OBJECTS)): |
| if MERGE_OVERLAP[c] > 1.0: |
| continue |
| changed = True |
| while changed: |
| changed = False |
| idx = np.where(mapped == c)[0] |
| for a in range(len(idx)): |
| for b in range(a + 1, len(idx)): |
| i, j = idx[a], idx[b] |
| if _inter_over_smaller(boxes[i], boxes[j]) >= MERGE_OVERLAP[c]: |
| boxes[i] = [min(boxes[i][0], boxes[j][0]), min(boxes[i][1], boxes[j][1]), |
| max(boxes[i][2], boxes[j][2]), max(boxes[i][3], boxes[j][3])] |
| conf[i] = max(conf[i], conf[j]) |
| mapped[j] = -1 |
| changed = True |
| break |
| if changed: |
| break |
| sel = mapped >= 0 |
| boxes, conf, mapped = boxes[sel], conf[sel], mapped[sel] |
|
|
| |
| |
| |
| order = conf.argsort()[::-1] |
| dead = set() |
| for a in range(len(order)): |
| i = order[a] |
| if i in dead: |
| continue |
| for b in range(a + 1, len(order)): |
| j = order[b] |
| if j in dead or mapped[i] == mapped[j]: |
| continue |
| if _iou_pair(boxes[i], boxes[j]) >= CROSS_IOU_THRES: |
| dead.add(j) |
|
|
| out = [] |
| for i in order: |
| if i in dead: |
| continue |
| x1, y1, x2, y2 = boxes[i] |
| if x2 <= x1 or y2 <= y1: |
| continue |
| out.append(BoundingBox(x1=int(x1), y1=int(y1), x2=int(x2), y2=int(y2), |
| cls_id=int(mapped[i]), conf=float(conf[i]))) |
| if len(out) >= MAX_DET: |
| break |
| return out |
|
|
| def predict_batch(self, batch_images, offset: int, n_keypoints: int) -> list: |
| """Signature is contract-checked: do not rename these parameters.""" |
| results = [] |
| for i, img in enumerate(batch_images): |
| frame_id = offset + i |
| try: |
| arr = np.asarray(img) |
| if arr.ndim == 2: |
| arr = np.stack([arr] * 3, axis=-1) |
| h, w = arr.shape[:2] |
| canvas, s, dx, dy = _letterbox(arr, self.size) |
| blob = canvas[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 |
| raw = self.session.run(None, {self.input_name: blob})[0] |
| boxes, cls, conf = self._decode(raw, s, dx, dy, h, w) |
| dets = self._postprocess(boxes, cls, conf, h, w) |
| except Exception as e: |
| |
| |
| |
| self.last_error = f"{type(e).__name__}: {e}" |
| dets = [] |
| results.append(TVFrameResult(frame_id=frame_id, boxes=dets, |
| polygons=[], keypoints=[])) |
| return results |
|
|