File size: 16,664 Bytes
69659b0
52ce84a
1c565a7
52ce84a
 
 
 
 
 
 
1c565a7
52ce84a
 
69659b0
 
 
 
 
 
 
 
 
52ce84a
69659b0
52ce84a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69659b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52ce84a
 
69659b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52ce84a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69659b0
 
 
52ce84a
69659b0
 
 
 
52ce84a
69659b0
 
52ce84a
 
 
 
 
69659b0
52ce84a
 
69659b0
52ce84a
69659b0
 
52ce84a
69659b0
 
 
 
 
 
 
52ce84a
 
 
69659b0
52ce84a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c565a7
52ce84a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69659b0
 
52ce84a
 
69659b0
 
52ce84a
 
 
 
69659b0
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
"""
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