MTerryJack commited on
Commit
c9f0209
·
verified ·
1 Parent(s): 2ea5e3b

subnet_bridge: copy winning miner repo into library

Browse files
Files changed (7) hide show
  1. README.md +4 -35
  2. chute_config.yml +4 -12
  3. class_names.txt +0 -3
  4. miner.py +430 -177
  5. model_type.json +0 -4
  6. readme.md +18 -0
  7. weights.onnx +2 -2
README.md CHANGED
@@ -10,40 +10,9 @@ tags:
10
  manako:
11
  source: winner_fetch
12
  manifest_element_name: manak0/Detect-fire
13
- winner_repo_id: meaculpitt/ScoreVision-Fire
14
- winner_revision: 71ae3d3e59ced8b330eea5e95710318175bb1342
15
- note: E=0.11785877 (map50=0.600000, size_mb=5.090839)
16
  ---
17
 
18
- # ScoreVision-Fire meaculpitt v2.1
19
-
20
- SN44 fire-detection miner for the `manak0/Detect-fire` element.
21
-
22
- ## Pipeline
23
- - **Architecture**: yolo26n
24
- - **Resolution**: 1408×768 input → letterbox → 960×960
25
- - **Preprocessing**: `cv2.dnn.blobFromImage` (fused C++ resize+normalize+transpose)
26
- - **Inference**: single-pass FP16 ONNX, NMS baked in
27
- - **Output shape**: `[1, 300, 6]` (xyxy, conf, cls)
28
- - **Latency**: ~35 ms p95 on RTX 4090 (fits the 50 ms gate)
29
-
30
- ## Classes (validator GT order, NOT the published class_names.txt order)
31
- - 0: fire
32
- - 1: smoke
33
- - 2: fire extinguisher
34
-
35
- Verified by audit of alfred8995/fire001 (scores 1.00) and navierstocks/fire
36
- (scores 0.96): both use [fire, smoke, fire_extinguisher] and the validator's
37
- GT order matches. Our model was trained with [fire, fire_ext, smoke]; miner.py
38
- applies cls_remap=[0,2,1] to translate model output to validator index.
39
-
40
- ## Training
41
- - 22,796 training images (validator-synth + Simuletic + D-Fire + z5atr, SHA1 deduped)
42
- - 2,532 validation images (random 90/10 split, seed=42)
43
- - 100 epochs, yolo26n, imgsz=960, batch=8, AdamW lr0=0.001 cos_lr
44
- - CCTV augmentation chain (cctv_aug_patch)
45
-
46
- ## Benchmarks
47
- - Broader merged val mAP50: 0.785
48
- - Validator-distribution synth val mAP50: 0.640 (+24.7 pts above 0.393 baseline)
49
- - Per-class on synth val: fire=0.523, fire_extinguisher=0.647, smoke=0.749
 
10
  manako:
11
  source: winner_fetch
12
  manifest_element_name: manak0/Detect-fire
13
+ winner_repo_id: navierstocks/fire-light
14
+ winner_revision: 95133792375f1fd3e5f192d0494c3b02f770cdc4
15
+ note: E=0.03088120 (map50=0.600000, size_mb=19.429295)
16
  ---
17
 
18
+ ## YOLO26 ONNX detector
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
chute_config.yml CHANGED
@@ -8,22 +8,14 @@ Image:
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
 
8
  NodeSelector:
9
  gpu_count: 1
10
  min_vram_gb_per_gpu: 16
11
+ max_hourly_price_per_gpu: 2
 
 
 
12
  include:
13
+ - pro_6000
 
 
 
 
 
 
14
 
15
  Chute:
16
+ timeout_seconds: 900
17
  concurrency: 4
18
  max_instances: 5
19
  scaling_threshold: 0.5
20
+ shutdown_after_seconds: 288000
21
  tee: true
class_names.txt DELETED
@@ -1,3 +0,0 @@
1
- fire
2
- smoke
3
- fire extinguisher
 
 
 
 
miner.py CHANGED
@@ -1,24 +1,10 @@
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
 
@@ -38,144 +24,192 @@ class TVFrameResult(BaseModel):
38
 
39
 
40
  class Miner:
41
- def __init__(self, path_hf_repo) -> None:
42
- self.path_hf_repo = Path(path_hf_repo)
43
- # Validator's actual GT class order is [fire, smoke, fire extinguisher]
44
- # verified by audit of alfred8995/fire001 (scores 1.00) and
45
- # navierstocks/fire (scores 0.96), both using this order. The published
46
- # manak0/Detect-fire class_names.txt list [fire, fire_ext, smoke] does
47
- # NOT match the actual scoring index.
48
- # Our model was trained with [fire, fire_ext, smoke] (cls=1=ext, cls=2=smoke).
49
- # cls_remap translates model output index → validator GT index.
50
- self.class_names = ["fire", "smoke", "fire extinguisher"]
51
- model_class_order = ["fire", "fire extinguisher", "smoke"]
52
- self.cls_remap = np.array(
53
- [self.class_names.index(n) for n in model_class_order],
54
- dtype=np.int32,
55
- ) # → [0, 2, 1]: model cls 0→0, 1→2, 2→1
56
 
57
  try:
58
  ort.preload_dlls()
59
- except Exception:
60
- pass
 
 
 
61
 
62
  sess_options = ort.SessionOptions()
63
  sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
 
64
  try:
65
  self.session = ort.InferenceSession(
66
- str(self.path_hf_repo / "weights.onnx"),
67
  sess_options=sess_options,
68
  providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
69
  )
70
- except Exception:
 
 
71
  self.session = ort.InferenceSession(
72
- str(self.path_hf_repo / "weights.onnx"),
73
  sess_options=sess_options,
74
  providers=["CPUExecutionProvider"],
75
  )
 
 
 
 
 
 
 
 
76
  self.input_name = self.session.get_inputs()[0].name
77
- self.output_names = [o.name for o in self.session.get_outputs()]
78
- self.input_dtype = (np.float16
79
- if 'float16' in self.session.get_inputs()[0].type
80
- else np.float32)
81
-
82
- self.input_h = 960
83
- self.input_w = 960
84
- self.conf_thres_per_class = np.array([0.20, 0.20, 0.20], dtype=np.float32)
85
- self.iou_thresh = 0.5
86
- self.cross_iou_thresh = 0.7
87
- self.max_det = 100
88
- self.min_box_area = 64
89
- self.min_side = 6
90
- self.max_aspect_ratio = 10.0
91
-
92
- warm = np.zeros((768, 1408, 3), dtype=np.uint8)
93
- for _ in range(3):
94
- try: self._infer_single(warm)
95
- except Exception: break
96
-
97
- def __repr__(self):
98
- thr = ",".join(f"{n[:4]}={t:.2f}" for n, t
99
- in zip(self.class_names, self.conf_thres_per_class.tolist()))
100
- return (f"FireMiner v2 yolo26n@{self.input_w} single-pass blob "
101
- f"conf=[{thr}] iou={self.iou_thresh}")
102
-
103
- def _preprocess(self, image_bgr):
104
- """Letterbox + cv2.dnn.blobFromImage (fused C++ resize/normalize/transpose)."""
105
- h, w = image_bgr.shape[:2]
106
- ratio = min(self.input_w / w, self.input_h / h)
107
- nw, nh = int(round(w * ratio)), int(round(h * ratio))
108
- if (nw, nh) != (w, h):
109
- interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
110
- resized = cv2.resize(image_bgr, (nw, nh), interpolation=interp)
111
- else:
112
- resized = image_bgr
113
- canvas = np.full((self.input_h, self.input_w, 3), 114, dtype=np.uint8)
114
- dy = (self.input_h - nh) // 2
115
- dx = (self.input_w - nw) // 2
116
- canvas[dy:dy+nh, dx:dx+nw] = resized
117
- # blobFromImage: fused BGR→RGB (swapRB) + /255 + transpose CHW + add batch dim
118
- blob = cv2.dnn.blobFromImage(
119
- canvas, scalefactor=1/255.0,
120
- size=(self.input_w, self.input_h),
121
- mean=(0, 0, 0), swapRB=True, crop=False,
122
  )
123
- if self.input_dtype == np.float16:
124
- blob = blob.astype(np.float16)
125
- return blob, ratio, (float(dx), float(dy))
126
-
127
- def _infer_single(self, image_bgr):
128
- inp, ratio, (dx, dy) = self._preprocess(image_bgr)
129
- out = self.session.run(self.output_names, {self.input_name: inp})[0]
130
- if out.ndim == 3: out = out[0]
131
- confs_all = out[:, 4].astype(np.float32)
132
- cls_all = self.cls_remap[out[:, 5].astype(np.int32)]
133
- cls_idx = np.clip(cls_all, 0, len(self.conf_thres_per_class) - 1)
134
- keep = confs_all >= self.conf_thres_per_class[cls_idx]
135
- if not keep.any(): return []
136
- out = out[keep]
137
- boxes = out[:, :4].astype(np.float32).copy()
138
- confs = out[:, 4].astype(np.float32)
139
- cls_ids = self.cls_remap[out[:, 5].astype(np.int32)]
140
- boxes[:, [0, 2]] = (boxes[:, [0, 2]] - dx) / ratio
141
- boxes[:, [1, 3]] = (boxes[:, [1, 3]] - dy) / ratio
142
- oh, ow = image_bgr.shape[:2]
143
- boxes[:, [0, 2]] = np.clip(boxes[:, [0, 2]], 0, ow - 1)
144
- boxes[:, [1, 3]] = np.clip(boxes[:, [1, 3]], 0, oh - 1)
145
- if len(boxes) > 1:
146
- keep_idx = self._per_class_hard_nms(boxes, confs, cls_ids, self.iou_thresh)
147
- keep_idx = keep_idx[: self.max_det]
148
- boxes, confs, cls_ids = boxes[keep_idx], confs[keep_idx], cls_ids[keep_idx]
149
- boxes, confs, cls_ids = self._cross_class_dedup(
150
- boxes, confs, cls_ids, self.cross_iou_thresh)
151
- return self._to_boundingboxes(boxes, confs, cls_ids, ow, oh)
152
 
153
  @staticmethod
154
- def _hard_nms(boxes, scores, iou_thresh):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  n = len(boxes)
156
- if n == 0: return np.array([], dtype=np.intp)
157
- order = np.argsort(scores)[::-1]
158
- keep, suppressed = [], np.zeros(n, dtype=bool)
159
- for i in range(n):
160
- idx = order[i]
161
- if suppressed[idx]: continue
162
- keep.append(int(idx))
163
- bi = boxes[idx]
164
- for k in range(i + 1, n):
165
- jdx = order[k]
166
- if suppressed[jdx]: continue
167
- bj = boxes[jdx]
168
- xx1, yy1 = max(bi[0], bj[0]), max(bi[1], bj[1])
169
- xx2, yy2 = min(bi[2], bj[2]), min(bi[3], bj[3])
170
- inter = max(0.0, xx2-xx1) * max(0.0, yy2-yy1)
171
- ai = (bi[2]-bi[0])*(bi[3]-bi[1]); aj = (bj[2]-bj[0])*(bj[3]-bj[1])
172
- iou = inter / (ai + aj - inter + 1e-7)
173
- if iou > iou_thresh: suppressed[jdx] = True
 
 
 
174
  return np.array(keep, dtype=np.intp)
175
 
176
- def _per_class_hard_nms(self, boxes, scores, cls_ids, iou_thresh):
177
- if len(boxes) == 0: return np.array([], dtype=np.intp)
178
- all_keep = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  for c in np.unique(cls_ids):
180
  mask = cls_ids == c
181
  indices = np.where(mask)[0]
@@ -184,52 +218,271 @@ class Miner:
184
  all_keep.sort()
185
  return np.array(all_keep, dtype=np.intp)
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  @staticmethod
188
- def _cross_class_dedup(boxes, scores, cls_ids, iou_thresh):
189
- n = len(boxes)
190
- if n <= 1: return boxes, scores, cls_ids
191
- areas = np.maximum(0.0, boxes[:, 2]-boxes[:, 0]) * np.maximum(0.0, boxes[:, 3]-boxes[:, 1])
192
- order = np.lexsort((-scores, -areas))
193
- suppressed = np.zeros(n, dtype=bool); keep = []
194
- for i in order:
195
- if suppressed[i]: continue
196
- keep.append(int(i))
197
- bi = boxes[i]
198
- xx1 = np.maximum(bi[0], boxes[:, 0]); yy1 = np.maximum(bi[1], boxes[:, 1])
199
- xx2 = np.minimum(bi[2], boxes[:, 2]); yy2 = np.minimum(bi[3], boxes[:, 3])
200
- inter = np.maximum(0.0, xx2-xx1) * np.maximum(0.0, yy2-yy1)
201
- ai = max(1e-7, float((bi[2]-bi[0])*(bi[3]-bi[1])))
202
- iou = inter / (ai + areas - inter + 1e-7)
203
- dup = iou > iou_thresh; dup[i] = False
204
- suppressed |= dup
205
- kept = np.array(keep, dtype=np.intp)
206
- return boxes[kept], scores[kept], cls_ids[kept]
207
-
208
- def _to_boundingboxes(self, boxes, confs, cls_ids, orig_w, orig_h):
209
- out = []
210
- for i in range(len(boxes)):
211
- x1, y1, x2, y2 = boxes[i]
212
- ix1 = max(0, min(orig_w, math.floor(x1)))
213
- iy1 = max(0, min(orig_h, math.floor(y1)))
214
- ix2 = max(0, min(orig_w, math.ceil(x2)))
215
- iy2 = max(0, min(orig_h, math.ceil(y2)))
216
- if ix2 <= ix1 or iy2 <= iy1: continue
217
- bw, bh = ix2 - ix1, iy2 - iy1
218
- if bw * bh < self.min_box_area: continue
219
- if min(bw, bh) < self.min_side: continue
220
- ar = max(bw / max(bh, 1), bh / max(bw, 1))
221
- if ar > self.max_aspect_ratio: continue
222
- out.append(BoundingBox(x1=ix1, y1=iy1, x2=ix2, y2=iy2, cls_id=int(cls_ids[i]),
223
- conf=max(0.0, min(1.0, float(confs[i])))))
224
  return out
225
 
226
- def predict_batch(self, batch_images, offset, n_keypoints):
227
- results = []
228
- for idx, image in enumerate(batch_images):
229
- boxes = self._infer_single(image)
230
- results.append(TVFrameResult(
231
- frame_id=offset + idx,
232
- boxes=boxes,
233
- keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
234
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  return results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from pathlib import Path
2
+ import math
3
 
4
  import cv2
5
  import numpy as np
6
  import onnxruntime as ort
7
+ from numpy import ndarray
8
  from pydantic import BaseModel
9
 
10
 
 
24
 
25
 
26
  class Miner:
27
+ """ONNX Runtime miner. Soft per-class NMS + sanity filter + flip TTA."""
28
+
29
+ class_names = ["fire", "smoke", "fire extinguisher"]
30
+ input_size = 1280
31
+ iou_thres = 0.3
32
+ soft_sigma = 0.5
33
+ min_side = 8.0
34
+ min_box_area = 144.0
35
+ max_aspect_ratio = 6.0
36
+ max_det = 100
37
+ _conf_thres_array = np.array([0.25, 0.15, 0.15], dtype=np.float32)
38
+
39
+ def __init__(self, path_hf_repo: Path) -> None:
40
+ model_path = path_hf_repo / "weights.onnx"
41
+ print("ORT version:", ort.__version__)
42
 
43
  try:
44
  ort.preload_dlls()
45
+ print("preload_dlls success")
46
+ except Exception as e:
47
+ print(f"preload_dlls failed: {e}")
48
+
49
+ print("ORT available providers BEFORE session:", ort.get_available_providers())
50
 
51
  sess_options = ort.SessionOptions()
52
  sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
53
+
54
  try:
55
  self.session = ort.InferenceSession(
56
+ str(model_path),
57
  sess_options=sess_options,
58
  providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
59
  )
60
+ print("Created ORT session with preferred CUDA provider list")
61
+ except Exception as e:
62
+ print(f"CUDA session creation failed, falling back to CPU: {e}")
63
  self.session = ort.InferenceSession(
64
+ str(model_path),
65
  sess_options=sess_options,
66
  providers=["CPUExecutionProvider"],
67
  )
68
+
69
+ print("ORT session providers:", self.session.get_providers())
70
+
71
+ for inp in self.session.get_inputs():
72
+ print("INPUT:", inp.name, inp.shape, inp.type)
73
+ for out in self.session.get_outputs():
74
+ print("OUTPUT:", out.name, out.shape, out.type)
75
+
76
  self.input_name = self.session.get_inputs()[0].name
77
+ self.output_names = [output.name for output in self.session.get_outputs()]
78
+ self.input_shape = self.session.get_inputs()[0].shape
79
+
80
+ self.input_height = self._safe_dim(self.input_shape[2], default=self.input_size)
81
+ self.input_width = self._safe_dim(self.input_shape[3], default=self.input_size)
82
+
83
+ print(f"ONNX model loaded from: {model_path}")
84
+ print(f"ONNX input: name={self.input_name}, shape={self.input_shape}")
85
+ print("per-class conf: " + ", ".join(
86
+ f"{n}={t:.3f}" for n, t in zip(self.class_names,
87
+ self._conf_thres_array.tolist())))
88
+
89
+ def __repr__(self) -> str:
90
+ return (
91
+ f"ONNXRuntime(session={type(self.session).__name__}, "
92
+ f"providers={self.session.get_providers()})"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  @staticmethod
96
+ def _safe_dim(value, default: int) -> int:
97
+ return value if isinstance(value, int) and value > 0 else default
98
+
99
+ def _letterbox(self, image: ndarray, new_shape: tuple[int, int],
100
+ color=(114, 114, 114)
101
+ ) -> tuple[ndarray, float, tuple[float, float]]:
102
+ h, w = image.shape[:2]
103
+ new_w, new_h = new_shape
104
+ ratio = min(new_w / w, new_h / h)
105
+ resized_w = int(round(w * ratio))
106
+ resized_h = int(round(h * ratio))
107
+ if (resized_w, resized_h) != (w, h):
108
+ interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
109
+ image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
110
+ dw = (new_w - resized_w) / 2.0
111
+ dh = (new_h - resized_h) / 2.0
112
+ left = int(round(dw - 0.1))
113
+ right = int(round(dw + 0.1))
114
+ top = int(round(dh - 0.1))
115
+ bottom = int(round(dh + 0.1))
116
+ padded = cv2.copyMakeBorder(image, top, bottom, left, right,
117
+ borderType=cv2.BORDER_CONSTANT, value=color)
118
+ return padded, ratio, (dw, dh)
119
+
120
+ def _preprocess(self, image: ndarray
121
+ ) -> tuple[np.ndarray, float, tuple[float, float],
122
+ tuple[int, int]]:
123
+ orig_h, orig_w = image.shape[:2]
124
+ img, ratio, pad = self._letterbox(image, (self.input_width, self.input_height))
125
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
126
+ img = img.astype(np.float32) / 255.0
127
+ img = np.transpose(img, (2, 0, 1))[None, ...]
128
+ img = np.ascontiguousarray(img, dtype=np.float32)
129
+ return img, ratio, pad, (orig_w, orig_h)
130
+
131
+ @staticmethod
132
+ def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
133
+ w, h = image_size
134
+ boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
135
+ boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
136
+ boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
137
+ boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
138
+ return boxes
139
+
140
+ @staticmethod
141
+ def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
142
+ out = np.empty_like(boxes)
143
+ out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
144
+ out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
145
+ out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
146
+ out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
147
+ return out
148
+
149
+ @staticmethod
150
+ def _hard_nms(boxes: np.ndarray, scores: np.ndarray,
151
+ iou_thresh: float) -> np.ndarray:
152
  n = len(boxes)
153
+ if n == 0:
154
+ return np.array([], dtype=np.intp)
155
+ order = np.argsort(-scores)
156
+ keep: list[int] = []
157
+ while len(order) > 0:
158
+ i = int(order[0])
159
+ keep.append(i)
160
+ if len(order) == 1:
161
+ break
162
+ rest = order[1:]
163
+ xx1 = np.maximum(boxes[i, 0], boxes[rest, 0])
164
+ yy1 = np.maximum(boxes[i, 1], boxes[rest, 1])
165
+ xx2 = np.minimum(boxes[i, 2], boxes[rest, 2])
166
+ yy2 = np.minimum(boxes[i, 3], boxes[rest, 3])
167
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
168
+ a_i = (max(0.0, boxes[i, 2] - boxes[i, 0]) *
169
+ max(0.0, boxes[i, 3] - boxes[i, 1]))
170
+ a_r = (np.maximum(0.0, boxes[rest, 2] - boxes[rest, 0]) *
171
+ np.maximum(0.0, boxes[rest, 3] - boxes[rest, 1]))
172
+ iou = inter / (a_i + a_r - inter + 1e-7)
173
+ order = rest[iou <= iou_thresh]
174
  return np.array(keep, dtype=np.intp)
175
 
176
+ @staticmethod
177
+ def _soft_nms(boxes: np.ndarray, scores: np.ndarray,
178
+ sigma: float, score_thresh: float = 0.001
179
+ ) -> tuple[np.ndarray, np.ndarray]:
180
+ n = len(boxes)
181
+ if n == 0:
182
+ return np.array([], dtype=np.intp), np.array([], dtype=np.float32)
183
+ boxes = boxes.astype(np.float32, copy=True)
184
+ scores = scores.astype(np.float32, copy=True)
185
+ order = np.arange(n)
186
+ for i in range(n):
187
+ max_pos = i + int(np.argmax(scores[i:]))
188
+ boxes[[i, max_pos]] = boxes[[max_pos, i]]
189
+ scores[[i, max_pos]] = scores[[max_pos, i]]
190
+ order[[i, max_pos]] = order[[max_pos, i]]
191
+ if i + 1 >= n:
192
+ break
193
+ xx1 = np.maximum(boxes[i, 0], boxes[i + 1:, 0])
194
+ yy1 = np.maximum(boxes[i, 1], boxes[i + 1:, 1])
195
+ xx2 = np.minimum(boxes[i, 2], boxes[i + 1:, 2])
196
+ yy2 = np.minimum(boxes[i, 3], boxes[i + 1:, 3])
197
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
198
+ a_i = max(0.0, float((boxes[i, 2] - boxes[i, 0]) *
199
+ (boxes[i, 3] - boxes[i, 1])))
200
+ a_j = (np.maximum(0.0, boxes[i + 1:, 2] - boxes[i + 1:, 0]) *
201
+ np.maximum(0.0, boxes[i + 1:, 3] - boxes[i + 1:, 1]))
202
+ iou = inter / (a_i + a_j - inter + 1e-7)
203
+ scores[i + 1:] *= np.exp(-(iou ** 2) / sigma)
204
+ mask = scores > score_thresh
205
+ return order[mask], scores[mask]
206
+
207
+ def _per_class_hard_nms(self, boxes: np.ndarray, scores: np.ndarray,
208
+ cls_ids: np.ndarray, iou_thresh: float
209
+ ) -> np.ndarray:
210
+ if len(boxes) == 0:
211
+ return np.array([], dtype=np.intp)
212
+ all_keep: list[int] = []
213
  for c in np.unique(cls_ids):
214
  mask = cls_ids == c
215
  indices = np.where(mask)[0]
 
218
  all_keep.sort()
219
  return np.array(all_keep, dtype=np.intp)
220
 
221
+ def _per_class_soft_nms(self, boxes: np.ndarray, scores: np.ndarray,
222
+ cls_ids: np.ndarray
223
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
224
+ if len(boxes) == 0:
225
+ return boxes, scores, cls_ids
226
+ out_b: list = []
227
+ out_s: list = []
228
+ out_c: list = []
229
+ for c in np.unique(cls_ids):
230
+ mask = cls_ids == c
231
+ sub_b = boxes[mask]
232
+ sub_s = scores[mask]
233
+ sub_c = cls_ids[mask]
234
+ idx, decayed = self._soft_nms(sub_b, sub_s, self.soft_sigma)
235
+ if len(idx) == 0:
236
+ continue
237
+ out_b.append(sub_b[idx])
238
+ out_s.append(decayed)
239
+ out_c.append(sub_c[idx])
240
+ if not out_b:
241
+ return (np.empty((0, 4), dtype=np.float32),
242
+ np.empty((0,), dtype=np.float32),
243
+ np.empty((0,), dtype=cls_ids.dtype))
244
+ return (np.concatenate(out_b, axis=0),
245
+ np.concatenate(out_s, axis=0),
246
+ np.concatenate(out_c, axis=0))
247
+
248
+ def _filter_sane_boxes(self, boxes: np.ndarray, scores: np.ndarray,
249
+ cls_ids: np.ndarray, orig_size: tuple[int, int]
250
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
251
+ if len(boxes) == 0:
252
+ return boxes, scores, cls_ids
253
+ orig_w, orig_h = orig_size
254
+ image_area = float(orig_w * orig_h)
255
+ bw = np.maximum(0.0, boxes[:, 2] - boxes[:, 0])
256
+ bh = np.maximum(0.0, boxes[:, 3] - boxes[:, 1])
257
+ area = bw * bh
258
+ ar = np.where(
259
+ (bw > 0) & (bh > 0),
260
+ np.maximum(bw / np.maximum(bh, 1e-6), bh / np.maximum(bw, 1e-6)),
261
+ np.inf,
262
+ )
263
+ keep = (
264
+ (bw >= self.min_side) & (bh >= self.min_side) &
265
+ (area >= self.min_box_area) &
266
+ (area <= 0.95 * image_area) &
267
+ (ar <= self.max_aspect_ratio)
268
+ )
269
+ return boxes[keep], scores[keep], cls_ids[keep]
270
+
271
  @staticmethod
272
+ def _max_score_per_cluster(post_boxes: np.ndarray,
273
+ full_boxes: np.ndarray,
274
+ full_scores: np.ndarray,
275
+ iou_thresh: float) -> np.ndarray:
276
+ n = len(post_boxes)
277
+ if n == 0:
278
+ return np.empty(0, dtype=np.float32)
279
+ full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *
280
+ np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))
281
+ out = np.empty(n, dtype=np.float32)
282
+ for i in range(n):
283
+ bi = post_boxes[i]
284
+ xx1 = np.maximum(bi[0], full_boxes[:, 0])
285
+ yy1 = np.maximum(bi[1], full_boxes[:, 1])
286
+ xx2 = np.minimum(bi[2], full_boxes[:, 2])
287
+ yy2 = np.minimum(bi[3], full_boxes[:, 3])
288
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
289
+ a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))
290
+ iou = inter / (a_i + full_areas - inter + 1e-7)
291
+ cluster = iou >= iou_thresh
292
+ out[i] = float(np.max(full_scores[cluster])) if np.any(cluster) else 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  return out
294
 
295
+ def _per_view_pipeline(self, boxes: np.ndarray, scores: np.ndarray,
296
+ cls_ids: np.ndarray, orig_size: tuple[int, int]
297
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
298
+ boxes, scores, cls_ids = self._filter_sane_boxes(
299
+ boxes, scores, cls_ids, orig_size
300
+ )
301
+ if len(boxes) == 0:
302
+ return boxes, scores, cls_ids
303
+ if len(boxes) > 1:
304
+ boxes, scores, cls_ids = self._per_class_soft_nms(boxes, scores, cls_ids)
305
+ if len(scores) > self.max_det:
306
+ top = np.argsort(-scores)[: self.max_det]
307
+ boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]
308
+ return boxes, scores, cls_ids
309
+
310
+ def _decode_final_dets(self, preds: np.ndarray, ratio: float,
311
+ pad: tuple[float, float],
312
+ orig_size: tuple[int, int]) -> list[BoundingBox]:
313
+ if preds.ndim == 3 and preds.shape[0] == 1:
314
+ preds = preds[0]
315
+ if preds.ndim != 2 or preds.shape[1] < 6:
316
+ raise ValueError(f"Unexpected ONNX final-det output shape: {preds.shape}")
317
+
318
+ boxes = preds[:, :4].astype(np.float32)
319
+ scores = preds[:, 4].astype(np.float32)
320
+ cls_ids = preds[:, 5].astype(np.int32)
321
+
322
+ keep = scores >= self._conf_thres_array[cls_ids]
323
+ boxes = boxes[keep]
324
+ scores = scores[keep]
325
+ cls_ids = cls_ids[keep]
326
+ if len(boxes) == 0:
327
+ return []
328
+
329
+ pad_w, pad_h = pad
330
+ boxes[:, [0, 2]] -= pad_w
331
+ boxes[:, [1, 3]] -= pad_h
332
+ boxes /= ratio
333
+ boxes = self._clip_boxes(boxes, orig_size)
334
+
335
+ boxes, scores, cls_ids = self._per_view_pipeline(
336
+ boxes, scores, cls_ids, orig_size
337
+ )
338
+ return self._build_results(boxes, scores, cls_ids)
339
+
340
+ def _decode_raw_yolo(self, preds: np.ndarray, ratio: float,
341
+ pad: tuple[float, float],
342
+ orig_size: tuple[int, int]) -> list[BoundingBox]:
343
+ if preds.ndim != 3 or preds.shape[0] != 1:
344
+ raise ValueError(f"Unexpected raw ONNX output shape: {preds.shape}")
345
+ preds = preds[0]
346
+ if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
347
+ preds = preds.T
348
+ if preds.ndim != 2 or preds.shape[1] < 5:
349
+ raise ValueError(f"Unexpected raw output shape: {preds.shape}")
350
+
351
+ boxes_xywh = preds[:, :4].astype(np.float32)
352
+ cls_part = preds[:, 4:].astype(np.float32)
353
+ if cls_part.shape[1] == 1:
354
+ scores = cls_part[:, 0]
355
+ cls_ids = np.zeros(len(scores), dtype=np.int32)
356
+ else:
357
+ cls_ids = np.argmax(cls_part, axis=1).astype(np.int32)
358
+ scores = cls_part[np.arange(len(cls_part)), cls_ids]
359
+
360
+ keep = scores >= self._conf_thres_array[cls_ids]
361
+ boxes_xywh = boxes_xywh[keep]
362
+ scores = scores[keep]
363
+ cls_ids = cls_ids[keep]
364
+ if len(boxes_xywh) == 0:
365
+ return []
366
+ boxes = self._xywh_to_xyxy(boxes_xywh)
367
+
368
+ pad_w, pad_h = pad
369
+ boxes[:, [0, 2]] -= pad_w
370
+ boxes[:, [1, 3]] -= pad_h
371
+ boxes /= ratio
372
+ boxes = self._clip_boxes(boxes, orig_size)
373
+
374
+ boxes, scores, cls_ids = self._per_view_pipeline(
375
+ boxes, scores, cls_ids, orig_size
376
+ )
377
+ return self._build_results(boxes, scores, cls_ids)
378
+
379
+ @staticmethod
380
+ def _build_results(boxes: np.ndarray, scores: np.ndarray,
381
+ cls_ids: np.ndarray) -> list[BoundingBox]:
382
+ results: list[BoundingBox] = []
383
+ for box, conf, cls_id in zip(boxes, scores, cls_ids):
384
+ x1, y1, x2, y2 = box.tolist()
385
+ if x2 <= x1 or y2 <= y1:
386
+ continue
387
+ results.append(
388
+ BoundingBox(
389
+ x1=int(math.floor(x1)),
390
+ y1=int(math.floor(y1)),
391
+ x2=int(math.ceil(x2)),
392
+ y2=int(math.ceil(y2)),
393
+ cls_id=int(cls_id),
394
+ conf=float(conf),
395
+ )
396
+ )
397
+ return results
398
+
399
+ def _postprocess(self, output: np.ndarray, ratio: float,
400
+ pad: tuple[float, float],
401
+ orig_size: tuple[int, int]) -> list[BoundingBox]:
402
+ if output.ndim == 2 and output.shape[1] >= 6:
403
+ return self._decode_final_dets(output, ratio, pad, orig_size)
404
+ if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
405
+ return self._decode_final_dets(output, ratio, pad, orig_size)
406
+ return self._decode_raw_yolo(output, ratio, pad, orig_size)
407
+
408
+ def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
409
+ if image is None:
410
+ raise ValueError("Input image is None")
411
+ if not isinstance(image, np.ndarray):
412
+ raise TypeError(f"Input is not numpy array: {type(image)}")
413
+ if image.ndim != 3:
414
+ raise ValueError(f"Expected HWC image, got shape={image.shape}")
415
+ if image.shape[2] != 3:
416
+ raise ValueError(f"Expected 3 channels, got shape={image.shape}")
417
+ if image.dtype != np.uint8:
418
+ image = image.astype(np.uint8)
419
+
420
+ input_tensor, ratio, pad, orig_size = self._preprocess(image)
421
+ expected = (1, 3, self.input_height, self.input_width)
422
+ if input_tensor.shape != expected:
423
+ raise ValueError(
424
+ f"Bad input tensor shape={input_tensor.shape}, expected={expected}"
425
+ )
426
+
427
+ outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
428
+ return self._postprocess(outputs[0], ratio, pad, orig_size)
429
+
430
+ def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
431
+ boxes_orig = self._predict_single(image)
432
+ flipped = cv2.flip(image, 1)
433
+ boxes_flip = self._predict_single(flipped)
434
+ w = image.shape[1]
435
+ boxes_flip = [
436
+ BoundingBox(
437
+ x1=w - b.x2, y1=b.y1, x2=w - b.x1, y2=b.y2,
438
+ cls_id=b.cls_id, conf=b.conf,
439
+ )
440
+ for b in boxes_flip
441
+ ]
442
+ all_boxes = boxes_orig + boxes_flip
443
+ if not all_boxes:
444
+ return []
445
+
446
+ coords = np.array(
447
+ [[b.x1, b.y1, b.x2, b.y2] for b in all_boxes], dtype=np.float32
448
+ )
449
+ scores = np.array([b.conf for b in all_boxes], dtype=np.float32)
450
+ cls_ids = np.array([b.cls_id for b in all_boxes], dtype=np.int32)
451
+
452
+ hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)
453
+ if len(hard_keep) == 0:
454
+ return []
455
+ hard_keep = hard_keep[: self.max_det]
456
+ boosted = self._max_score_per_cluster(
457
+ coords[hard_keep], coords, scores, self.iou_thres
458
+ )
459
+
460
+ return [
461
+ BoundingBox(
462
+ x1=all_boxes[i].x1,
463
+ y1=all_boxes[i].y1,
464
+ x2=all_boxes[i].x2,
465
+ y2=all_boxes[i].y2,
466
+ cls_id=all_boxes[i].cls_id,
467
+ conf=float(boosted[j]),
468
+ )
469
+ for j, i in enumerate(hard_keep)
470
+ ]
471
+
472
+ def predict_batch(self, batch_images: list[ndarray], offset: int,
473
+ n_keypoints: int) -> list[TVFrameResult]:
474
+ results: list[TVFrameResult] = []
475
+ for frame_number_in_batch, image in enumerate(batch_images):
476
+ try:
477
+ boxes = self._predict_tta(image)
478
+ except Exception as e:
479
+ print(f"Inference failed for frame {offset + frame_number_in_batch}: {e}")
480
+ boxes = []
481
+ results.append(
482
+ TVFrameResult(
483
+ frame_id=offset + frame_number_in_batch,
484
+ boxes=boxes,
485
+ keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
486
+ )
487
+ )
488
  return results
model_type.json DELETED
@@ -1,4 +0,0 @@
1
- {
2
- "task_type": "object-detection",
3
- "model_type": "yolov26-nano"
4
- }
 
 
 
 
 
readme.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ tags:
3
+ - element_type:detect
4
+ - model:onnxruntime
5
+ - subnet:winner
6
+ - object:fire
7
+ - object:smoke
8
+ - object:fire extinguisher
9
+
10
+ manako:
11
+ source: winner_fetch
12
+ manifest_element_name: manak0/Detect-fire
13
+ winner_repo_id: navierstocks/fire-light
14
+ winner_revision: 95133792375f1fd3e5f192d0494c3b02f770cdc4
15
+ note: E=0.03088120 (map50=0.600000, size_mb=19.429295)
16
+ ---
17
+
18
+ ## YOLO26 ONNX detector
weights.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:0bfd3fd0b1dca617b05f93fb1ce92aadc8f6ee8e80255c2eb0818b143b4056d6
3
- size 5077018
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:40ec65251e308d8240c59ea7704956fc44823e750067e433f287aec71e8939ac
3
+ size 19407447