meaculpitt commited on
Commit
479cc25
·
verified ·
1 Parent(s): f5554c0

v2 fire weights (yolo26n 960 e2e, 3 classes validator-aligned, synth+sim+dfire+z5atr 25k merged, synth val mAP50=0.6395)

Browse files
Files changed (6) hide show
  1. README.md +27 -0
  2. chute_config.yml +29 -0
  3. class_names.txt +3 -0
  4. miner.py +224 -0
  5. model_type.json +4 -0
  6. weights.onnx +3 -0
README.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ScoreVision-Fire — meaculpitt v2
2
+
3
+ SN44 fire-detection miner for the `manak0/Detect-fire` element.
4
+
5
+ ## Pipeline
6
+ - **Architecture**: yolo26n
7
+ - **Resolution**: 1408×768 input → letterbox → 960×960
8
+ - **Preprocessing**: `cv2.dnn.blobFromImage` (fused C++ resize+normalize+transpose)
9
+ - **Inference**: single-pass FP16 ONNX, NMS baked in
10
+ - **Output shape**: `[1, 300, 6]` (xyxy, conf, cls)
11
+ - **Latency**: ~35 ms p95 on RTX 4090 (fits the 50 ms gate)
12
+
13
+ ## Classes (validator order, per HF class_names.txt)
14
+ - 0: fire
15
+ - 1: fire extinguisher
16
+ - 2: smoke
17
+
18
+ ## Training
19
+ - 22,796 training images (validator-synth + Simuletic + D-Fire + z5atr, SHA1 deduped)
20
+ - 2,532 validation images (random 90/10 split, seed=42)
21
+ - 100 epochs, yolo26n, imgsz=960, batch=8, AdamW lr0=0.001 cos_lr
22
+ - CCTV augmentation chain (cctv_aug_patch)
23
+
24
+ ## Benchmarks
25
+ - Broader merged val mAP50: 0.785
26
+ - Validator-distribution synth val mAP50: 0.640 (+24.7 pts above 0.393 baseline)
27
+ - Per-class on synth val: fire=0.523, fire_extinguisher=0.647, smoke=0.749
chute_config.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Image:
2
+ from_base: parachutes/python:3.12
3
+ run_command:
4
+ - pip install --upgrade setuptools wheel
5
+ - pip install 'numpy>=1.23' 'onnxruntime-gpu[cuda,cudnn]>=1.16' 'opencv-python>=4.7' 'pillow>=9.5' 'huggingface_hub>=0.19.4' 'pydantic>=2.0' 'pyyaml>=6.0' 'aiohttp>=3.9'
6
+ - pip install torch torchvision
7
+
8
+ NodeSelector:
9
+ gpu_count: 1
10
+ min_vram_gb_per_gpu: 16
11
+ # SN44 chute platform mandates TEE + pro_6000 include for new elements
12
+ # (verified by crime + beverage deploys 2026-05-04). Cheaper-GPU config
13
+ # caused repeated 500 ContentTypeError on POST /chutes/.
14
+ max_hourly_price_per_gpu: 2.00
15
+ include:
16
+ - "pro_6000"
17
+ exclude:
18
+ - "5090"
19
+ - b200
20
+ - h200
21
+ - h20
22
+ - mi300x
23
+
24
+ Chute:
25
+ concurrency: 4
26
+ max_instances: 5
27
+ scaling_threshold: 0.5
28
+ shutdown_after_seconds: 288000 # 80h idle
29
+ tee: true
class_names.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fire
2
+ fire extinguisher
3
+ smoke
miner.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # build-marker: fire-v2-blob-imgsz960
2
+ """SN44 fire detection miner — yolo26n single-pass @ imgsz=960.
3
+
4
+ v2 (2026-05-09): trained on merged 25k pool (validator-synth + D-Fire +
5
+ Simuletic + z5atr). FP16 ONNX, ~5 MB. Single forward pass at imgsz=960
6
+ fits the 50 ms p95 latency gate (~35 ms on 4090, blobFromImage preproc).
7
+
8
+ SAHI tiling was tested but blew the latency budget (5x preproc/postproc
9
+ overhead). Code preserved at fire/deploy/miner_sahi.py for later experiments.
10
+
11
+ Classes (validator order from manak0/Detect-fire class_names.txt):
12
+ 0=fire, 1=fire extinguisher, 2=smoke
13
+
14
+ Single ONNX expected at path_hf_repo/weights.onnx (yolo26n e2e [1,300,6]).
15
+ """
16
+ import math
17
+ from pathlib import Path
18
+
19
+ import cv2
20
+ import numpy as np
21
+ import onnxruntime as ort
22
+ from pydantic import BaseModel
23
+
24
+
25
+ class BoundingBox(BaseModel):
26
+ x1: int
27
+ y1: int
28
+ x2: int
29
+ y2: int
30
+ cls_id: int
31
+ conf: float
32
+
33
+
34
+ class TVFrameResult(BaseModel):
35
+ frame_id: int
36
+ boxes: list[BoundingBox]
37
+ keypoints: list[tuple[int, int]]
38
+
39
+
40
+ class Miner:
41
+ def __init__(self, path_hf_repo) -> None:
42
+ self.path_hf_repo = Path(path_hf_repo)
43
+ self.class_names = ["fire", "fire extinguisher", "smoke"]
44
+ self.cls_remap = np.arange(3, dtype=np.int32)
45
+
46
+ try:
47
+ ort.preload_dlls()
48
+ except Exception:
49
+ pass
50
+
51
+ sess_options = ort.SessionOptions()
52
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
53
+ try:
54
+ self.session = ort.InferenceSession(
55
+ str(self.path_hf_repo / "weights.onnx"),
56
+ sess_options=sess_options,
57
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
58
+ )
59
+ except Exception:
60
+ self.session = ort.InferenceSession(
61
+ str(self.path_hf_repo / "weights.onnx"),
62
+ sess_options=sess_options,
63
+ providers=["CPUExecutionProvider"],
64
+ )
65
+ self.input_name = self.session.get_inputs()[0].name
66
+ self.output_names = [o.name for o in self.session.get_outputs()]
67
+ self.input_dtype = (np.float16
68
+ if 'float16' in self.session.get_inputs()[0].type
69
+ else np.float32)
70
+
71
+ self.input_h = 960
72
+ self.input_w = 960
73
+ self.conf_thres_per_class = np.array([0.20, 0.20, 0.20], dtype=np.float32)
74
+ self.iou_thresh = 0.5
75
+ self.cross_iou_thresh = 0.7
76
+ self.max_det = 100
77
+ self.min_box_area = 64
78
+ self.min_side = 6
79
+ self.max_aspect_ratio = 10.0
80
+
81
+ warm = np.zeros((768, 1408, 3), dtype=np.uint8)
82
+ for _ in range(3):
83
+ try: self._infer_single(warm)
84
+ except Exception: break
85
+
86
+ def __repr__(self):
87
+ thr = ",".join(f"{n[:4]}={t:.2f}" for n, t
88
+ in zip(self.class_names, self.conf_thres_per_class.tolist()))
89
+ return (f"FireMiner v2 yolo26n@{self.input_w} single-pass blob "
90
+ f"conf=[{thr}] iou={self.iou_thresh}")
91
+
92
+ def _preprocess(self, image_bgr):
93
+ """Letterbox + cv2.dnn.blobFromImage (fused C++ resize/normalize/transpose)."""
94
+ h, w = image_bgr.shape[:2]
95
+ ratio = min(self.input_w / w, self.input_h / h)
96
+ nw, nh = int(round(w * ratio)), int(round(h * ratio))
97
+ if (nw, nh) != (w, h):
98
+ interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
99
+ resized = cv2.resize(image_bgr, (nw, nh), interpolation=interp)
100
+ else:
101
+ resized = image_bgr
102
+ canvas = np.full((self.input_h, self.input_w, 3), 114, dtype=np.uint8)
103
+ dy = (self.input_h - nh) // 2
104
+ dx = (self.input_w - nw) // 2
105
+ canvas[dy:dy+nh, dx:dx+nw] = resized
106
+ # blobFromImage: fused BGR→RGB (swapRB) + /255 + transpose CHW + add batch dim
107
+ blob = cv2.dnn.blobFromImage(
108
+ canvas, scalefactor=1/255.0,
109
+ size=(self.input_w, self.input_h),
110
+ mean=(0, 0, 0), swapRB=True, crop=False,
111
+ )
112
+ if self.input_dtype == np.float16:
113
+ blob = blob.astype(np.float16)
114
+ return blob, ratio, (float(dx), float(dy))
115
+
116
+ def _infer_single(self, image_bgr):
117
+ inp, ratio, (dx, dy) = self._preprocess(image_bgr)
118
+ out = self.session.run(self.output_names, {self.input_name: inp})[0]
119
+ if out.ndim == 3: out = out[0]
120
+ confs_all = out[:, 4].astype(np.float32)
121
+ cls_all = self.cls_remap[out[:, 5].astype(np.int32)]
122
+ cls_idx = np.clip(cls_all, 0, len(self.conf_thres_per_class) - 1)
123
+ keep = confs_all >= self.conf_thres_per_class[cls_idx]
124
+ if not keep.any(): return []
125
+ out = out[keep]
126
+ boxes = out[:, :4].astype(np.float32).copy()
127
+ confs = out[:, 4].astype(np.float32)
128
+ cls_ids = self.cls_remap[out[:, 5].astype(np.int32)]
129
+ boxes[:, [0, 2]] = (boxes[:, [0, 2]] - dx) / ratio
130
+ boxes[:, [1, 3]] = (boxes[:, [1, 3]] - dy) / ratio
131
+ oh, ow = image_bgr.shape[:2]
132
+ boxes[:, [0, 2]] = np.clip(boxes[:, [0, 2]], 0, ow - 1)
133
+ boxes[:, [1, 3]] = np.clip(boxes[:, [1, 3]], 0, oh - 1)
134
+ if len(boxes) > 1:
135
+ keep_idx = self._per_class_hard_nms(boxes, confs, cls_ids, self.iou_thresh)
136
+ keep_idx = keep_idx[: self.max_det]
137
+ boxes, confs, cls_ids = boxes[keep_idx], confs[keep_idx], cls_ids[keep_idx]
138
+ boxes, confs, cls_ids = self._cross_class_dedup(
139
+ boxes, confs, cls_ids, self.cross_iou_thresh)
140
+ return self._to_boundingboxes(boxes, confs, cls_ids, ow, oh)
141
+
142
+ @staticmethod
143
+ def _hard_nms(boxes, scores, iou_thresh):
144
+ n = len(boxes)
145
+ if n == 0: return np.array([], dtype=np.intp)
146
+ order = np.argsort(scores)[::-1]
147
+ keep, suppressed = [], np.zeros(n, dtype=bool)
148
+ for i in range(n):
149
+ idx = order[i]
150
+ if suppressed[idx]: continue
151
+ keep.append(int(idx))
152
+ bi = boxes[idx]
153
+ for k in range(i + 1, n):
154
+ jdx = order[k]
155
+ if suppressed[jdx]: continue
156
+ bj = boxes[jdx]
157
+ xx1, yy1 = max(bi[0], bj[0]), max(bi[1], bj[1])
158
+ xx2, yy2 = min(bi[2], bj[2]), min(bi[3], bj[3])
159
+ inter = max(0.0, xx2-xx1) * max(0.0, yy2-yy1)
160
+ ai = (bi[2]-bi[0])*(bi[3]-bi[1]); aj = (bj[2]-bj[0])*(bj[3]-bj[1])
161
+ iou = inter / (ai + aj - inter + 1e-7)
162
+ if iou > iou_thresh: suppressed[jdx] = True
163
+ return np.array(keep, dtype=np.intp)
164
+
165
+ def _per_class_hard_nms(self, boxes, scores, cls_ids, iou_thresh):
166
+ if len(boxes) == 0: return np.array([], dtype=np.intp)
167
+ all_keep = []
168
+ for c in np.unique(cls_ids):
169
+ mask = cls_ids == c
170
+ indices = np.where(mask)[0]
171
+ keep = self._hard_nms(boxes[mask], scores[mask], iou_thresh)
172
+ all_keep.extend(indices[keep].tolist())
173
+ all_keep.sort()
174
+ return np.array(all_keep, dtype=np.intp)
175
+
176
+ @staticmethod
177
+ def _cross_class_dedup(boxes, scores, cls_ids, iou_thresh):
178
+ n = len(boxes)
179
+ if n <= 1: return boxes, scores, cls_ids
180
+ areas = np.maximum(0.0, boxes[:, 2]-boxes[:, 0]) * np.maximum(0.0, boxes[:, 3]-boxes[:, 1])
181
+ order = np.lexsort((-scores, -areas))
182
+ suppressed = np.zeros(n, dtype=bool); keep = []
183
+ for i in order:
184
+ if suppressed[i]: continue
185
+ keep.append(int(i))
186
+ bi = boxes[i]
187
+ xx1 = np.maximum(bi[0], boxes[:, 0]); yy1 = np.maximum(bi[1], boxes[:, 1])
188
+ xx2 = np.minimum(bi[2], boxes[:, 2]); yy2 = np.minimum(bi[3], boxes[:, 3])
189
+ inter = np.maximum(0.0, xx2-xx1) * np.maximum(0.0, yy2-yy1)
190
+ ai = max(1e-7, float((bi[2]-bi[0])*(bi[3]-bi[1])))
191
+ iou = inter / (ai + areas - inter + 1e-7)
192
+ dup = iou > iou_thresh; dup[i] = False
193
+ suppressed |= dup
194
+ kept = np.array(keep, dtype=np.intp)
195
+ return boxes[kept], scores[kept], cls_ids[kept]
196
+
197
+ def _to_boundingboxes(self, boxes, confs, cls_ids, orig_w, orig_h):
198
+ out = []
199
+ for i in range(len(boxes)):
200
+ x1, y1, x2, y2 = boxes[i]
201
+ ix1 = max(0, min(orig_w, math.floor(x1)))
202
+ iy1 = max(0, min(orig_h, math.floor(y1)))
203
+ ix2 = max(0, min(orig_w, math.ceil(x2)))
204
+ iy2 = max(0, min(orig_h, math.ceil(y2)))
205
+ if ix2 <= ix1 or iy2 <= iy1: continue
206
+ bw, bh = ix2 - ix1, iy2 - iy1
207
+ if bw * bh < self.min_box_area: continue
208
+ if min(bw, bh) < self.min_side: continue
209
+ ar = max(bw / max(bh, 1), bh / max(bw, 1))
210
+ if ar > self.max_aspect_ratio: continue
211
+ out.append(BoundingBox(x1=ix1, y1=iy1, x2=ix2, y2=iy2, cls_id=int(cls_ids[i]),
212
+ conf=max(0.0, min(1.0, float(confs[i])))))
213
+ return out
214
+
215
+ def predict_batch(self, batch_images, offset, n_keypoints):
216
+ results = []
217
+ for idx, image in enumerate(batch_images):
218
+ boxes = self._infer_single(image)
219
+ results.append(TVFrameResult(
220
+ frame_id=offset + idx,
221
+ boxes=boxes,
222
+ keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
223
+ ))
224
+ return results
model_type.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "task_type": "object-detection",
3
+ "model_type": "yolov26-nano"
4
+ }
weights.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0bfd3fd0b1dca617b05f93fb1ce92aadc8f6ee8e80255c2eb0818b143b4056d6
3
+ size 5077018