ScoreVision / miner.py
TaoMagnet's picture
deploy
52ce84a verified
Raw
History Blame Contribute Delete
16.7 kB
"""
TurboVision miner for element `manak0/Detect-car-wash` — ONNX / CPU-safe.
Why ONNX-only: the latency-loop compliance checker (branch `latency-loop`) loads THIS
miner.py in a sandbox whose image has ONLY onnxruntime + cv2 + numpy + pydantic
(NO torch, NO ultralytics), forbids `.pt/.pth/.safetensors` files, forbids importing
socket/urllib/http/subprocess and calling open()/eval/exec, blocks the network during
inference, caps memory at 8 GiB, and requires the repo to contain a `.onnx` model.
It times `predict_batch` on CPU and needs p95 <= element.latency_p95_ms (target 100 ms),
and it re-checks that these outputs match your submitted responses at IoU >= 0.85.
So: pure onnxruntime, deterministic, small input size. Classes MUST be in manifest
order (cls_id == index): 0=broom 1=drainage gate 2=nozzle 3=track.
"""
from pathlib import Path
import os
import numpy as np
import cv2
import onnxruntime as ort
from pydantic import BaseModel
CLASSES = ["broom", "drainage gate", "nozzle", "track"]
CONF = float(os.environ.get("CARWASH_CONF", "0.15")) # global floor; per-class overrides below
# Per-class confidence floors (index == cls_id). Each object sits at its own map50/FP
# sweet spot. Override via CARWASH_CONF_PER_CLASS="0.30,0.45,0.20,0.35".
_pc = os.environ.get("CARWASH_CONF_PER_CLASS", "")
# Per-class conf floors — meta-informed prior copied from the current #1 (Alexei
# a36): broom/drain/track LOW to keep recall (map50 is 0.6 wt, dominates the FP
# cost), nozzle HIGH (0.45) because its low-conf boxes are the main FP source
# (challenge 69fa60042b). Re-tuned per-model at packaging via per_class_tune.
PER_CLASS_CONF = ([float(x) for x in _pc.split(",")] if _pc else [0.32, 0.17, 0.37, 0.41])
# Per-class rescue bonus: if a class has ZERO boxes after the floor, admit its
# top-1 candidate when conf >= (floor - bonus). Kept SMALL — the #1's sweep found
# aggressive rescue "admits more false positives" than it gains (matches the FP
# issue we saw). The global fallback below is our separate, safer empty-guard.
_bn = os.environ.get("CARWASH_BONUS_PER_CLASS", "")
BONUS_PER_CLASS = ([float(x) for x in _bn.split(",")] if _bn else [0.05, 0.10, 0.10, 0.10])
IOU_NMS = float(os.environ.get("CARWASH_IOU", "0.6"))
# Per-class same-class NMS IoU: suppress a box overlapping a kept same-class box by
# more than this. Nozzles cluster tightly and can double-fire on one spray head, so
# nozzle is STRICT (0.3). Also, any box whose CENTER sits inside a kept higher-conf
# same-class box is dropped (kills "same nozzle detected twice" regardless of IoU).
_pi = os.environ.get("CARWASH_IOU_PER_CLASS", "")
IOU_PER_CLASS = ([float(x) for x in _pi.split(",")] if _pi else [0.5, 0.6, 0.30, 0.6])
# Cross-class dedup IoU: suppress a physical object firing as >1 class (e.g. water
# spray fires both `nozzle` and `track`). 0 disables. This lifts the FP pillar (0.4 wt).
CROSS_IOU = float(os.environ.get("CARWASH_CROSS_IOU", "0.9"))
# Box sanity filter — loose because nozzle boxes are tiny (GT median ~290 px²).
MIN_SIDE = float(os.environ.get("CARWASH_MIN_SIDE", "3"))
MIN_AREA = float(os.environ.get("CARWASH_MIN_AREA", "16"))
MAX_AR = float(os.environ.get("CARWASH_MAX_AR", "12"))
# Per-class MIN area as a fraction of the frame. Kills a specific FP mode: a real
# car-wash `broom` is a large floor-to-ceiling rotating brush (>=1.3% of frame);
# a tiny distant "broom" box (<=0.6%) is almost always a misfire on a dark
# structure. Only broom is gated (nozzle/track/drain legitimately vary in size).
_ma = os.environ.get("CARWASH_MIN_AREA_FRAC", "")
MIN_AREA_FRAC = ([float(x) for x in _ma.split(",")] if _ma else [0.0, 0.0, 0.0, 0.0])
MAX_DET = int(os.environ.get("CARWASH_MAX_DET", "50"))
# Global fallback: when normal post-processing yields ZERO boxes, emit the single
# highest-probability raw detection (any class, ignoring the conf floor). On
# challenges where every miner returns empty, one plausible box can catch a missed
# object and score > 0 while others score 0. Set "0" to disable.
GLOBAL_FALLBACK = os.environ.get("CARWASH_GLOBAL_FALLBACK", "1") != "0"
MODEL_FILE = os.environ.get("CARWASH_MODEL", "carwash.onnx")
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
def _letterbox(img: np.ndarray, new_shape: tuple[int, int]) -> tuple[np.ndarray, float, float, float]:
"""Resize+pad BGR image to new_shape (H,W), keep aspect. Return (img, ratio, pad_w, pad_h)."""
h, w = img.shape[:2]
nh, nw = new_shape
r = min(nh / h, nw / w)
uw, uh = int(round(w * r)), int(round(h * r))
resized = cv2.resize(img, (uw, uh), interpolation=cv2.INTER_LINEAR)
pad_w, pad_h = (nw - uw) / 2, (nh - uh) / 2
top, bottom = int(round(pad_h - 0.1)), int(round(pad_h + 0.1))
left, right = int(round(pad_w - 0.1)), int(round(pad_w + 0.1))
out = cv2.copyMakeBorder(resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114, 114, 114))
return out, r, left, top
def _nms(boxes: np.ndarray, scores: np.ndarray, iou_thr: float) -> list[int]:
if len(boxes) == 0:
return []
x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
areas = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1)
order = scores.argsort()[::-1]
keep = []
while order.size > 0:
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, xx2 - xx1) * np.maximum(0, yy2 - yy1)
iou = inter / (areas[i] + areas[order[1:]] - inter + 1e-9)
order = order[1:][iou <= iou_thr]
return keep
def _same_class_nms(boxes: np.ndarray, scores: np.ndarray, iou_thr: float) -> list[int]:
"""NMS within one class, PLUS containment suppression: drop a box whose center
lies inside a kept higher-conf box (or IoU > iou_thr). Kills duplicate detections
of the same physical object (e.g. one nozzle boxed twice)."""
n = len(boxes)
if n == 0:
return []
order = scores.argsort()[::-1]
cx = (boxes[:, 0] + boxes[:, 2]) / 2.0
cy = (boxes[:, 1] + boxes[:, 3]) / 2.0
areas = np.maximum(0, boxes[:, 2] - boxes[:, 0]) * np.maximum(0, boxes[:, 3] - boxes[:, 1])
keep = []
for i in order:
drop = False
for j in keep:
xx1 = max(boxes[i, 0], boxes[j, 0]); yy1 = max(boxes[i, 1], boxes[j, 1])
xx2 = min(boxes[i, 2], boxes[j, 2]); yy2 = min(boxes[i, 3], boxes[j, 3])
inter = max(0, xx2 - xx1) * max(0, yy2 - yy1)
iou = inter / (areas[i] + areas[j] - inter + 1e-9)
center_in = (boxes[j, 0] <= cx[i] <= boxes[j, 2]) and (boxes[j, 1] <= cy[i] <= boxes[j, 3])
if iou > iou_thr or center_in:
drop = True
break
if not drop:
keep.append(int(i))
return keep
def _sane_mask(xyxy: np.ndarray, img_area: float) -> np.ndarray:
"""Keep-mask dropping degenerate/implausible boxes (a common FP source)."""
bw = xyxy[:, 2] - xyxy[:, 0]
bh = xyxy[:, 3] - xyxy[:, 1]
area = bw * bh
ar = np.maximum(bw / np.maximum(bh, 1e-6), bh / np.maximum(bw, 1e-6))
return ((bw >= MIN_SIDE) & (bh >= MIN_SIDE) & (area >= MIN_AREA)
& (area <= 0.95 * img_area) & (ar <= MAX_AR))
def _cross_class_dedup(xyxy: np.ndarray, conf: np.ndarray, cls_id: np.ndarray,
margin: np.ndarray, iou_thr: float) -> list[int]:
"""Suppress near-duplicate boxes ACROSS classes: order by conf-margin then area,
keep the best, drop any other-index box with IoU > iou_thr. Kills same-object
multi-class fires (nozzle+track on one water patch)."""
n = len(xyxy)
if n <= 1:
return list(range(n))
areas = np.maximum(0, xyxy[:, 2] - xyxy[:, 0]) * np.maximum(0, xyxy[:, 3] - xyxy[:, 1])
order = np.lexsort((-areas, -margin))
suppressed = np.zeros(n, dtype=bool)
keep = []
for i in order:
if suppressed[i]:
continue
keep.append(int(i))
xx1 = np.maximum(xyxy[i, 0], xyxy[:, 0]); yy1 = np.maximum(xyxy[i, 1], xyxy[:, 1])
xx2 = np.minimum(xyxy[i, 2], xyxy[:, 2]); yy2 = np.minimum(xyxy[i, 3], xyxy[:, 3])
inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1)
iou = inter / (max(1e-7, areas[i]) + areas - inter + 1e-7)
dup = iou > iou_thr
dup[i] = False
suppressed |= dup
return keep
class Miner:
def __init__(self, path_hf_repo: Path) -> None:
model_path = str(Path(path_hf_repo) / MODEL_FILE)
providers = os.environ.get("CARWASH_PROVIDERS", "CPUExecutionProvider").split(",")
avail = ort.get_available_providers()
providers = [p for p in providers if p in avail] or ["CPUExecutionProvider"]
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.intra_op_num_threads = int(os.environ.get("CARWASH_THREADS", "0")) # 0 = ORT default
self.sess = ort.InferenceSession(model_path, sess_options=so, providers=providers)
self.inp = self.sess.get_inputs()[0]
# match the model's input dtype (FP16 exports need float16 input)
self.np_dtype = np.float16 if "float16" in (self.inp.type or "") else np.float32
shape = self.inp.shape # [1,3,H,W]; may contain strings if dynamic
self.H = int(shape[2]) if isinstance(shape[2], int) else 640
self.W = int(shape[3]) if isinstance(shape[3], int) else 640
self.nc = len(CLASSES)
# warmup so first real call isn't a cold-start outlier in p95
dummy = np.zeros((1, 3, self.H, self.W), dtype=self.np_dtype)
self.sess.run(None, {self.inp.name: dummy})
print(f"✅ Car-wash ONNX loaded {MODEL_FILE} input={self.H}x{self.W} providers={providers} conf={CONF}")
def __repr__(self) -> str:
return f"CarWash ONNX ({MODEL_FILE}) {self.H}x{self.W} classes={CLASSES} conf={CONF}"
def _preprocess(self, img_bgr: np.ndarray):
lb, r, pad_w, pad_h = _letterbox(img_bgr, (self.H, self.W))
rgb = lb[:, :, ::-1].astype(np.float32) / 255.0 # BGR->RGB, 0-1 (manifest norm rgb-01)
chw = np.transpose(rgb, (2, 0, 1))
return chw, r, pad_w, pad_h
def _postprocess(self, out: np.ndarray, r: float, pad_w: float, pad_h: float,
orig_w: int, orig_h: int) -> list[BoundingBox]:
# YOLOv8/11 detect ONNX head: (1, 4+nc, N) -> (N, 4+nc), xywh in input pixels
pred = out[0]
# Two supported ONNX heads (both in letterboxed input-pixel coords):
# end2end [N,6] = (x1,y1,x2,y2,conf,cls), already NMS'd (e.g. yolo11 nms=True)
# raw [4+nc,N] or [N,4+nc] = xywh + per-class scores (yolo11 nms=False)
if pred.ndim == 2 and pred.shape[1] == 6:
pred = pred[pred[:, 4] > 1e-3] # drop end2end padding rows
boxes_in_all = pred[:, :4].astype(np.float32)
conf_all = pred[:, 4].astype(np.float32)
cls_id_all = pred[:, 5].astype(np.int32)
_is_xywh = False
else:
if pred.shape[0] == (4 + self.nc):
pred = pred.transpose(1, 0)
boxes_in_all = pred[:, :4].astype(np.float32)
cls_scores = pred[:, 4:4 + self.nc]
cls_id_all = cls_scores.argmax(1).astype(np.int32)
conf_all = cls_scores.max(1).astype(np.float32)
_is_xywh = True
def _to_xyxy(b: np.ndarray) -> np.ndarray:
if _is_xywh:
cx, cy, w, h = b[:, 0], b[:, 1], b[:, 2], b[:, 3]
xy = np.stack([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], 1)
else:
xy = b.astype(np.float32, copy=True)
xy[:, [0, 2]] = (xy[:, [0, 2]] - pad_w) / r
xy[:, [1, 3]] = (xy[:, [1, 3]] - pad_h) / r
xy[:, [0, 2]] = xy[:, [0, 2]].clip(0, orig_w)
xy[:, [1, 3]] = xy[:, [1, 3]].clip(0, orig_h)
return xy
def _fallback() -> list[BoundingBox]:
# emit the single highest-prob raw detection so we're never empty-handed
if not GLOBAL_FALLBACK or len(conf_all) == 0:
return []
g = int(conf_all.argmax())
xy = _to_xyxy(boxes_in_all[g:g + 1])[0]
if xy[2] - xy[0] < 1 or xy[3] - xy[1] < 1:
return []
return [BoundingBox(x1=int(xy[0]), y1=int(xy[1]), x2=int(xy[2]), y2=int(xy[3]),
cls_id=int(cls_id_all[g]), conf=float(conf_all[g]))]
# per-class confidence floor + rescue bonus
floor = np.array(PER_CLASS_CONF, dtype=np.float32)
bonus = np.array(BONUS_PER_CLASS, dtype=np.float32)
m = conf_all >= floor[cls_id_all]
for c in range(self.nc):
if bonus[c] <= 0:
continue
cmask = cls_id_all == c
if not cmask.any() or m[cmask].any():
continue # class absent, or already kept a box
idx = np.where(cmask)[0]
top = idx[int(conf_all[idx].argmax())]
if conf_all[top] >= floor[c] - bonus[c]:
m[top] = True # rescue the top-1 candidate
if not m.any():
return _fallback()
cls_id, conf = cls_id_all[m], conf_all[m]
xyxy = _to_xyxy(boxes_in_all[m])
# box sanity filter (drops degenerate/implausible FPs)
img_area = float(orig_w * orig_h)
sm = _sane_mask(xyxy, img_area)
if not sm.any():
return _fallback()
xyxy, cls_id, conf = xyxy[sm], cls_id[sm], conf[sm]
# per-class minimum-area gate (kills tiny-broom FPs on distant structures)
mafrac = np.array(MIN_AREA_FRAC, dtype=np.float32)
if mafrac.any():
bw = xyxy[:, 2] - xyxy[:, 0]; bh = xyxy[:, 3] - xyxy[:, 1]
am = (bw * bh) >= (mafrac[cls_id] * img_area)
if not am.any():
return _fallback()
xyxy, cls_id, conf = xyxy[am], cls_id[am], conf[am]
# cap candidates before the O(n^2) dedup so pathological frames stay fast
if len(conf) > 150:
top = np.argsort(-conf)[:150]
xyxy, cls_id, conf = xyxy[top], cls_id[top], conf[top]
# per-class NMS + containment dedup (nozzle strict) -> collect survivors
keep_idx = []
for c in np.unique(cls_id):
idx = np.where(cls_id == c)[0]
iou_c = IOU_PER_CLASS[c] if 0 <= c < len(IOU_PER_CLASS) else IOU_NMS
for k in _same_class_nms(xyxy[idx], conf[idx], iou_c):
keep_idx.append(int(idx[k]))
keep_idx = np.array(keep_idx, dtype=np.intp)
xyxy, cls_id, conf = xyxy[keep_idx], cls_id[keep_idx], conf[keep_idx]
# cross-class dedup: suppress same physical object firing as multiple classes
if CROSS_IOU > 0 and len(xyxy) > 1:
margin = conf - floor[cls_id]
cd = _cross_class_dedup(xyxy, conf, cls_id, margin, CROSS_IOU)
xyxy, cls_id, conf = xyxy[cd], cls_id[cd], conf[cd]
out_boxes = [BoundingBox(
x1=int(xyxy[j, 0]), y1=int(xyxy[j, 1]), x2=int(xyxy[j, 2]), y2=int(xyxy[j, 3]),
cls_id=int(cls_id[j]), conf=float(conf[j]),
) for j in range(len(xyxy))]
if not out_boxes:
return _fallback()
out_boxes.sort(key=lambda b: b.conf, reverse=True)
return out_boxes[:MAX_DET]
def predict_batch(self, batch_images, offset: int, n_keypoints: int) -> list[TVFrameResult]:
# Run one frame at a time: the exported ONNX has a fixed batch dim of 1, and
# per-challenge latency is what the checker measures, so keep each run minimal.
results: list[TVFrameResult] = []
for i, img in enumerate(batch_images):
chw, r, pw, ph = self._preprocess(img)
inp = np.ascontiguousarray(chw[None], dtype=self.np_dtype)
out = self.sess.run(None, {self.inp.name: inp})[0] # (1, 4+nc, N)
boxes = self._postprocess(out, r, pw, ph, img.shape[1], img.shape[0])
results.append(TVFrameResult(frame_id=offset + i, boxes=boxes, polygons=[], keypoints=[]))
return results