innomium/fire-detection
0
1from __future__ import annotations2 3import math4from pathlib import Path5 6import cv27import numpy as np8import onnxruntime as ort9from numpy import ndarray10from pydantic import BaseModel11 12MODEL_DIR = Path(__file__).resolve().parent13 14class BoundingBox(BaseModel):15 x1: int16 y1: int17 x2: int18 y2: int19 cls_id: int20 conf: float21 22 23class TVFrameResult(BaseModel):24 frame_id: int25 boxes: list[BoundingBox]26 keypoints: list[tuple[int, int]]27 28 29class FireDetector:30 """ONNX Runtime miner for fire / smoke / fire_extinguisher detection.31 32 Strategy (ported from offense miner):33 - per-class confidence threshold with per-class rescue bonus34 - per-class hard NMS, then cross-class dedup35 - horizontal-flip TTA with full-set cluster score boost36 Plus fire001 specifics: class remap, sanity-box filter, TTA toggle.37 """38 39 class_names = ["fire", "smoke", "fire extinguisher"]40 # FALLBACK order the model emits classes in -- remapped to `class_names`41 # index by `self.cls_remap` (built in __init__). The authoritative order42 # is read from the ONNX `names` metadata that Ultralytics embeds at43 # export time (ships inside weights.onnx), so a retrained model with a44 # different class order is remapped correctly without code changes.45 # Used only when that metadata is missing or unparsable.46 _model_class_order = ["fire", "fire extinguisher", "smoke"]47 48 iou_thres = 0.5549 cross_iou_thresh = 0.850 max_det = 15051 52 # Per-class confidence thresholds. Higher = fewer FP for that class.53 # Indexed by class_names order: [fire, smoke, fire_extinguisher].54 _conf_thres_array = np.array(55 [0.15, 0.30, 0.23], dtype=np.float3256 )57 # Per-class rescue bonus. If a class has ZERO boxes passing the threshold58 # in a frame, its top-1 candidate is admitted when its score is at least59 # (threshold - bonus). Fire and smoke get a small bonus (variable60 # appearance); fire extinguisher does not (distinctive object, leave FP61 # control strict).62 _bonus_array = np.array(63 [0.02, 0.1, 0.1], dtype=np.float3264 )65 66 # Box sanity filter (fire001-specific FP reduction): drop tiny / degenerate67 # / image-spanning / extreme aspect ratio boxes.68 min_box_area = 14 * 1469 min_side = 870 max_aspect_ratio = 8.071 72 # Same-class merge: two boxes whose intersection covers at least this73 # fraction of the SMALLER box are treated as the same object and replaced74 # by their union. Catches nested boxes (IoU below the NMS threshold) and75 # fragmented detections. Per-class because the risk differs:76 # smoke -- diffuse plumes fragment a lot, so a moderate threshold helps.77 # fire -- separate flames must stay separate, so keep this HIGH (only a78 # tight core nested inside a looser flame box merges). Set to a79 # value > 1.0 to disable fire merging entirely.80 # Fire merge is DISABLED by default (1.01): measured on the fire-29-val102481 # val split it cost fire AP (0.751 -> 0.742, composite 0.8888 -> 0.8874)82 # because the nested core+flame boxes it collapses were scoring as separate83 # true positives. Lower it to ~0.8 to enable, and re-measure with84 # verify_filters.py / tune_miner.py after a retrain -- a model whose fire85 # boxes fragment more (or live-SAM3 GT that draws fuller flames) could flip86 # the result.87 smoke_merge_overlap = 0.888 fire_merge_overlap = 1.0189 90 # Fire containment suppression: when two FIRE boxes overlap on one object91 # (intersection >= this fraction of the SMALLER box) keep the HIGHER-conf92 # box and drop the other -- unchanged geometry, unlike the union merge93 # above. This catches the nested core+flame duplicate that per-class NMS94 # (IoU-based, iou_thres) leaves behind. Set > 1.0 to disable.95 # DISABLED by default (1.01): measured on fire-29-val1024 it cost fire AP96 # (0.751 -> 0.743, composite 0.8888 -> 0.8877). Cause: GT fire boxes almost97 # never overlap (1 pair in 416), so each nested model pair has one TP + one98 # FP, but the higher-CONF box isn't always the one matching GT at IoU 0.5 --99 # so keeping it can drop the real match, and score-ordered AP already100 # tolerates the duplicate. Lower to ~0.8 to enable; re-measure after a101 # retrain or against live-SAM3 GT, which may differ.102 fire_suppress_overlap = 0.88103 104 # ── Low-confidence color-prior FP filters ───────────────────────────────105 # Ported from the firedetect1007 miner's color checks, but applied ONLY to106 # the borderline confidence band (just above each per-class threshold) and107 # ONLY on color frames. A fire/extinguisher detection there is dropped when108 # its pixels clearly do not match the expected appearance: warm/bright for109 # fire, red for extinguisher. High-confidence detections are never touched.110 #111 # The reference miner ran these unconditionally -- a BUG on this validator,112 # which feeds some frames as grayscale (a true red extinguisher is gray113 # there, so a red test would wrongly delete it). We skip the filter when the114 # ROI is near-grayscale, so it never fires on those frames.115 #116 # Tunable: set a max-conf gate to 0.0 to disable that filter. After a model117 # retrain, re-validate these with tune_miner.py (the gates are relative to118 # the per-class thresholds, so they move when those move).119 fire_color_filter_max_conf = 0.45 # only fire boxes in (thresh, 0.45]120 fire_ext_color_filter_max_conf = 0.40 # only ext boxes in (thresh, 0.40]121 color_filter_min_saturation = 0.06 # skip filter if ROI is near-grayscale122 123 # ── Corroboration FP filters (optional; OFF by default) ─────────────────124 # Ported in spirit from firedetect1007. Both REMOVE borderline boxes that125 # lack support -- a precision play for the validator's FP pillar. OFF by126 # default because, unlike the color priors, they can also drop true127 # positives; enable + sweep with verify_filters.py and keep only the128 # settings that raise the measured composite. A max-conf gate of 0.0129 # disables the corresponding filter.130 # edge filter: drop boxes touching the frame border in a low-conf band131 # (the validator scales/crops, so border-hugging boxes are often the132 # truncated remains of an object whose body is off-frame).133 # tta view filter: drop low-conf boxes that appear in only ONE of the two134 # horizontal-flip TTA views (a real object is usually seen in both).135 use_edge_filter = False136 edge_filter_max_conf = 0.0 # drop edge-touching boxes with conf <= this137 edge_tol = 2.0 # px from the border counted as "on edge"138 use_tta_view_filter = False139 tta_view_filter_max_conf = 0.0 # drop single-view boxes with conf <= this140 tta_view_iou_thresh = 0.5 # IoU for "same object seen in both views"141 142 def __init__(self, model_dir: Path | str | None = None) -> None:143 model_dir = Path(model_dir) if model_dir is not None else MODEL_DIR144 model_path = model_dir / "weights.onnx"145 print("ORT version:", ort.__version__)146 147 try:148 ort.preload_dlls()149 print("✅ onnxruntime.preload_dlls() success")150 except Exception as e:151 print(f"⚠️ preload_dlls failed: {e}")152 153 print("ORT available providers BEFORE session:", ort.get_available_providers())154 155 sess_options = ort.SessionOptions()156 sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL157 sess_options.intra_op_num_threads = 2158 sess_options.inter_op_num_threads = 1159 sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL160 161 try:162 self.session = ort.InferenceSession(163 str(model_path),164 sess_options=sess_options,165 providers=["CPUExecutionProvider"],166 )167 except Exception as e:168 self.session = ort.InferenceSession(169 str(model_path),170 sess_options=sess_options,171 providers=["CPUExecutionProvider"],172 )173 174 print("ORT session providers:", self.session.get_providers())175 176 # Build cls_remap: for each model-emit index i,177 # cls_remap[i] = self.class_names.index(model_class_order[i])178 # i.e. converts a model-side class id into the canonical class id179 # that downstream code (BoundingBox.cls_id, validator) expects.180 # The model-side order comes from the ONNX metadata when available,181 # else falls back to the static _model_class_order.182 model_class_order = self._read_model_class_order()183 if model_class_order is None:184 model_class_order = list(self._model_class_order)185 print(f"cls order: no usable ONNX metadata, FALLBACK {model_class_order}")186 else:187 print(f"cls order: from ONNX metadata {model_class_order}")188 self.cls_remap = np.array(189 [self.class_names.index(n) for n in model_class_order],190 dtype=np.int32,191 )192 193 for inp in self.session.get_inputs():194 print("INPUT:", inp.name, inp.shape, inp.type)195 for out in self.session.get_outputs():196 print("OUTPUT:", out.name, out.shape, out.type)197 198 self.input_name = self.session.get_inputs()[0].name199 self.output_names = [output.name for output in self.session.get_outputs()]200 self.input_shape = self.session.get_inputs()[0].shape201 202 self.input_height = self._safe_dim(self.input_shape[2], default=1280)203 self.input_width = self._safe_dim(self.input_shape[3], default=1280)204 205 self.use_tta = False206 207 print(f"✅ ONNX model loaded from: {model_path}")208 print(f"✅ ONNX providers: {self.session.get_providers()}")209 print(f"✅ ONNX input: name={self.input_name}, shape={self.input_shape}")210 print("per-class conf: " + ", ".join(211 f"{n}={t:.3f}" for n, t in zip(212 self.class_names, self._conf_thres_array.tolist()213 )214 ))215 216 self._warmup()217 218 def _warmup(self, iters: int = 3) -> None:219 try:220 dummy = np.zeros((720, 1280, 3), dtype=np.uint8)221 for _ in range(max(1, iters)):222 self.predict_batch(batch_images=[dummy], offset=0, n_keypoints=0)223 print(f"✅ warmup: {iters} dummy predict_batch call(s) done")224 except Exception as e:225 print(f"⚠️ warmup skipped: {e}")226 227 def __repr__(self) -> str:228 return (229 f"ONNXRuntime(session={type(self.session).__name__}, "230 f"providers={self.session.get_providers()})"231 )232 233 @staticmethod234 def _safe_dim(value, default: int) -> int:235 return value if isinstance(value, int) and value > 0 else default236 237 def _read_model_class_order(self) -> list[str] | None:238 """Read the model's class order from Ultralytics ONNX metadata.239 240 Returns the class names ordered by model-emit index, or None when241 metadata is missing/unparsable or doesn't match `class_names` as a242 set (in which case the static _model_class_order fallback is used).243 """244 try:245 import ast246 247 meta = self.session.get_modelmeta().custom_metadata_map248 names = ast.literal_eval(meta["names"]) # e.g. {0: 'fire', ...}249 if isinstance(names, dict):250 order = [str(names[i]) for i in sorted(names)]251 else:252 order = [str(n) for n in names]253 except Exception as e:254 print(f"cls order: could not read ONNX names metadata ({e})")255 return None256 if sorted(order) != sorted(self.class_names):257 print(258 f"cls order: ONNX names {order} do not match expected classes "259 f"{self.class_names}; ignoring metadata"260 )261 return None262 return order263 264 def _letterbox(265 self,266 image: ndarray,267 new_shape: tuple[int, int],268 color=(114, 114, 114),269 ) -> tuple[ndarray, float, tuple[float, float]]:270 h, w = image.shape[:2]271 new_w, new_h = new_shape272 273 ratio = min(new_w / w, new_h / h)274 resized_w = int(round(w * ratio))275 resized_h = int(round(h * ratio))276 277 if (resized_w, resized_h) != (w, h):278 interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR279 image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)280 281 dw = (new_w - resized_w) / 2.0282 dh = (new_h - resized_h) / 2.0283 284 left = int(round(dw - 0.1))285 right = int(round(dw + 0.1))286 top = int(round(dh - 0.1))287 bottom = int(round(dh + 0.1))288 289 padded = cv2.copyMakeBorder(290 image, top, bottom, left, right,291 borderType=cv2.BORDER_CONSTANT, value=color,292 )293 return padded, ratio, (dw, dh)294 295 def _preprocess(296 self, image: ndarray297 ) -> tuple[np.ndarray, float, tuple[float, float], tuple[int, int]]:298 orig_h, orig_w = image.shape[:2]299 img, ratio, pad = self._letterbox(300 image, (self.input_width, self.input_height)301 )302 # Fused scale(1/255) + BGR->RGB swap + HWC->NCHW + contiguous float32 in303 # one optimized OpenCV call. Bit-identical (max abs diff 6e-8) to the304 # prior cvtColor + astype/255 + transpose + ascontiguousarray chain, but305 # ~half the preprocess time (preprocess is ~12% of predict_batch).306 blob = cv2.dnn.blobFromImage(img, scalefactor=1.0 / 255.0, swapRB=True)307 return blob, ratio, pad, (orig_w, orig_h)308 309 @staticmethod310 def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:311 w, h = image_size312 boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)313 boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)314 boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)315 boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)316 return boxes317 318 @staticmethod319 def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:320 out = np.empty_like(boxes)321 out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0322 out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0323 out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0324 out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0325 return out326 327 @staticmethod328 def _hard_nms(329 boxes: np.ndarray, scores: np.ndarray, iou_thresh: float330 ) -> np.ndarray:331 n = len(boxes)332 if n == 0:333 return np.array([], dtype=np.intp)334 order = np.argsort(-scores)335 keep: list[int] = []336 while len(order) > 0:337 i = int(order[0])338 keep.append(i)339 if len(order) == 1:340 break341 rest = order[1:]342 xx1 = np.maximum(boxes[i, 0], boxes[rest, 0])343 yy1 = np.maximum(boxes[i, 1], boxes[rest, 1])344 xx2 = np.minimum(boxes[i, 2], boxes[rest, 2])345 yy2 = np.minimum(boxes[i, 3], boxes[rest, 3])346 inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)347 a_i = (max(0.0, boxes[i, 2] - boxes[i, 0]) *348 max(0.0, boxes[i, 3] - boxes[i, 1]))349 a_r = (np.maximum(0.0, boxes[rest, 2] - boxes[rest, 0]) *350 np.maximum(0.0, boxes[rest, 3] - boxes[rest, 1]))351 iou = inter / (a_i + a_r - inter + 1e-7)352 order = rest[iou <= iou_thresh]353 return np.array(keep, dtype=np.intp)354 355 def _per_class_hard_nms(356 self,357 boxes: np.ndarray,358 scores: np.ndarray,359 cls_ids: np.ndarray,360 iou_thresh: float,361 ) -> np.ndarray:362 if len(boxes) == 0:363 return np.array([], dtype=np.intp)364 all_keep: list[int] = []365 for c in np.unique(cls_ids):366 mask = cls_ids == c367 indices = np.where(mask)[0]368 keep = self._hard_nms(boxes[mask], scores[mask], iou_thresh)369 all_keep.extend(indices[keep].tolist())370 all_keep.sort()371 return np.array(all_keep, dtype=np.intp)372 373 def _cross_class_dedup_op(374 self,375 boxes: np.ndarray,376 scores: np.ndarray,377 cls_ids: np.ndarray,378 iou_thresh: float,379 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:380 """Remove near-duplicate boxes across classes.381 382 Order candidates by (score - per_class_threshold) margin, then by area;383 keep the highest, suppress every other box with IoU > iou_thresh.384 This suppresses the case where the same physical object is detected385 as multiple classes (e.g. fire vs smoke on the same flames).386 """387 n = len(boxes)388 if n <= 1:389 return boxes, scores, cls_ids390 boxes = np.asarray(boxes, dtype=np.float32)391 scores = np.asarray(scores, dtype=np.float32)392 cls_ids = np.asarray(cls_ids, dtype=np.int32)393 areas = (np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) *394 np.maximum(0.0, boxes[:, 3] - boxes[:, 1]))395 margins = scores - self._conf_thres_array[cls_ids]396 order = np.lexsort((-areas, -margins))397 suppressed = np.zeros(n, dtype=bool)398 keep: list[int] = []399 for i in order:400 if suppressed[i]:401 continue402 keep.append(int(i))403 bi = boxes[i]404 xx1 = np.maximum(bi[0], boxes[:, 0])405 yy1 = np.maximum(bi[1], boxes[:, 1])406 xx2 = np.minimum(bi[2], boxes[:, 2])407 yy2 = np.minimum(bi[3], boxes[:, 3])408 inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)409 a_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))410 iou = inter / (a_i + areas - inter + 1e-7)411 dup = iou > iou_thresh412 dup[i] = False413 suppressed |= dup414 keep_idx = np.array(keep, dtype=np.intp)415 return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]416 417 def _merge_class_boxes(418 self,419 boxes: np.ndarray,420 scores: np.ndarray,421 cls_ids: np.ndarray,422 target_cls: int,423 overlap: float,424 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:425 """Merge overlapping detections of ONE class into single boxes.426 427 Two same-class boxes whose intersection covers >= `overlap` of the428 SMALLER box are treated as one object and replaced by their union with429 the max confidence of the pair. Repeats until no pair merges, so chains430 of fragments collapse. `overlap` is intersection-over-minimum-area, so431 only nested / heavily-overlapping boxes merge -- two spatially separate432 objects (low mutual overlap) are never fused. `overlap > 1.0` disables.433 """434 if overlap > 1.0:435 return boxes, scores, cls_ids436 idx = np.where(cls_ids == target_cls)[0]437 if len(idx) <= 1:438 return boxes, scores, cls_ids439 440 sb = boxes[idx].astype(np.float32).tolist()441 ss = scores[idx].astype(np.float32).tolist()442 merged_any = True443 while merged_any and len(sb) > 1:444 merged_any = False445 for i in range(len(sb)):446 for j in range(i + 1, len(sb)):447 a, b = sb[i], sb[j]448 ix1 = max(a[0], b[0])449 iy1 = max(a[1], b[1])450 ix2 = min(a[2], b[2])451 iy2 = min(a[3], b[3])452 inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)453 area_a = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1])454 area_b = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1])455 smaller = min(area_a, area_b)456 if inter / (smaller + 1e-7) >= overlap:457 sb[i] = [458 min(a[0], b[0]), min(a[1], b[1]),459 max(a[2], b[2]), max(a[3], b[3]),460 ]461 ss[i] = max(ss[i], ss[j])462 del sb[j]463 del ss[j]464 merged_any = True465 break466 if merged_any:467 break468 469 other = cls_ids != target_cls470 new_boxes = np.concatenate(471 [boxes[other].astype(np.float32),472 np.array(sb, dtype=np.float32).reshape(-1, 4)]473 )474 new_scores = np.concatenate(475 [scores[other].astype(np.float32),476 np.array(ss, dtype=np.float32)]477 )478 new_cls = np.concatenate(479 [cls_ids[other].astype(np.int32),480 np.full(len(sb), target_cls, dtype=np.int32)]481 )482 return new_boxes, new_scores, new_cls483 484 def _suppress_contained_lower_conf(485 self,486 boxes: np.ndarray,487 scores: np.ndarray,488 cls_ids: np.ndarray,489 target_cls: int,490 overlap: float,491 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:492 """For one class, when two boxes overlap (intersection >= `overlap` of493 the smaller box) keep the higher-confidence box and drop the other.494 Geometry is never changed -- only the redundant lower-conf box is495 removed. `overlap > 1.0` disables."""496 if overlap > 1.0:497 return boxes, scores, cls_ids498 idx = np.where(cls_ids == target_cls)[0]499 if len(idx) <= 1:500 return boxes, scores, cls_ids501 502 order = idx[np.argsort(-scores[idx])] # highest confidence first503 remove: set[int] = set()504 for a in range(len(order)):505 i = int(order[a])506 if i in remove:507 continue508 bi = boxes[i]509 area_i = max(1e-7, float((bi[2] - bi[0]) * (bi[3] - bi[1])))510 for b in range(a + 1, len(order)):511 j = int(order[b])512 if j in remove:513 continue514 bj = boxes[j]515 ix1 = max(bi[0], bj[0]); iy1 = max(bi[1], bj[1])516 ix2 = min(bi[2], bj[2]); iy2 = min(bi[3], bj[3])517 inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)518 if inter <= 0.0:519 continue520 area_j = max(1e-7, float((bj[2] - bj[0]) * (bj[3] - bj[1])))521 if inter / (min(area_i, area_j) + 1e-7) >= overlap:522 remove.add(j) # j is the lower-confidence box (order desc)523 if not remove:524 return boxes, scores, cls_ids525 keep = np.array(526 [k not in remove for k in range(len(boxes))], dtype=bool527 )528 return boxes[keep], scores[keep], cls_ids[keep]529 530 def _merge_same_class_boxes(531 self,532 boxes: np.ndarray,533 scores: np.ndarray,534 cls_ids: np.ndarray,535 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:536 """Resolve nested / fragmented same-object detections, per class.537 538 Smoke: diffuse plumes fragment into nested boxes NMS can't collapse, so539 they are UNION-merged (smoke_merge_overlap).540 Fire: a tight hot-core box and a looser flame box are the same flame;541 keep the HIGHER-confidence one and drop the other (fire_suppress_overlap),542 which leaves geometry intact. The union-merge variant (fire_merge_overlap)543 is also available but measured worse, so it is disabled by default.544 """545 boxes, scores, cls_ids = self._merge_class_boxes(546 boxes, scores, cls_ids,547 self.class_names.index("smoke"), self.smoke_merge_overlap,548 )549 boxes, scores, cls_ids = self._merge_class_boxes(550 boxes, scores, cls_ids,551 self.class_names.index("fire"), self.fire_merge_overlap,552 )553 boxes, scores, cls_ids = self._suppress_contained_lower_conf(554 boxes, scores, cls_ids,555 self.class_names.index("fire"), self.fire_suppress_overlap,556 )557 return boxes, scores, cls_ids558 559 # Back-compat alias (older callers / tune_miner referenced this name).560 def _merge_smoke_boxes(561 self,562 boxes: np.ndarray,563 scores: np.ndarray,564 cls_ids: np.ndarray,565 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:566 return self._merge_same_class_boxes(boxes, scores, cls_ids)567 568 @staticmethod569 def _max_score_per_cluster(570 post_boxes: np.ndarray,571 post_cls: np.ndarray,572 full_boxes: np.ndarray,573 full_scores: np.ndarray,574 full_cls: np.ndarray,575 iou_thresh: float,576 ) -> np.ndarray:577 """For each kept (post-NMS) box, return the max score over the FULL578 candidate set among same-class boxes with IoU >= iou_thresh.579 580 Used after horizontal-flip TTA: a high-confidence flipped detection581 can raise the score of the corresponding original detection.582 """583 n = len(post_boxes)584 if n == 0:585 return np.empty(0, dtype=np.float32)586 full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *587 np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))588 out = np.empty(n, dtype=np.float32)589 for i in range(n):590 bi = post_boxes[i]591 xx1 = np.maximum(bi[0], full_boxes[:, 0])592 yy1 = np.maximum(bi[1], full_boxes[:, 1])593 xx2 = np.minimum(bi[2], full_boxes[:, 2])594 yy2 = np.minimum(bi[3], full_boxes[:, 3])595 inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)596 a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))597 iou = inter / (a_i + full_areas - inter + 1e-7)598 cluster = (iou >= iou_thresh) & (full_cls == post_cls[i])599 out[i] = float(np.max(full_scores[cluster])) if np.any(cluster) else 0.0600 return out601 602 def _conf_filter_mask(603 self, scores: np.ndarray, cls_ids: np.ndarray604 ) -> np.ndarray:605 """Boolean keep-mask: score >= per-class threshold, with a per-class606 rescue -- if a class has zero boxes passing, admit its top-1 candidate607 when its score >= (per-class threshold - per-class bonus)."""608 if len(scores) == 0:609 return np.zeros(0, dtype=bool)610 thr = self._conf_thres_array[cls_ids]611 keep = scores >= thr612 for c in np.unique(cls_ids):613 b = float(self._bonus_array[c])614 if b <= 0.0:615 continue616 cm = cls_ids == c617 if keep[cm].any():618 continue619 idx = np.where(cm)[0]620 top = int(idx[int(np.argmax(scores[idx]))])621 if scores[top] >= self._conf_thres_array[c] - b:622 keep[top] = True623 return keep624 625 def _filter_sane_boxes(626 self,627 boxes: np.ndarray,628 scores: np.ndarray,629 cls_ids: np.ndarray,630 orig_size: tuple[int, int],631 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:632 """Drop tiny / degenerate / image-spanning / extreme-AR boxes (FP)."""633 if len(boxes) == 0:634 return boxes, scores, cls_ids635 orig_w, orig_h = orig_size636 image_area = float(orig_w * orig_h)637 keep = []638 for i, box in enumerate(boxes):639 x1, y1, x2, y2 = box.tolist()640 bw = x2 - x1641 bh = y2 - y1642 if bw <= 0 or bh <= 0:643 continue644 if bw < self.min_side or bh < self.min_side:645 continue646 area = bw * bh647 if area < self.min_box_area:648 continue649 if area > 0.95 * image_area:650 continue651 ar = max(bw / max(bh, 1e-6), bh / max(bw, 1e-6))652 if ar > self.max_aspect_ratio:653 continue654 keep.append(i)655 if not keep:656 return (657 np.empty((0, 4), dtype=np.float32),658 np.empty((0,), dtype=np.float32),659 np.empty((0,), dtype=np.int32),660 )661 k = np.array(keep, dtype=np.intp)662 return boxes[k], scores[k], cls_ids[k]663 664 def _per_view_pipeline(665 self,666 boxes: np.ndarray,667 scores: np.ndarray,668 cls_ids: np.ndarray,669 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:670 """Per-view post-processing pipeline: per-class NMS -> cap -> cross-class dedup -> smoke merge."""671 if len(boxes) > 1:672 keep = self._per_class_hard_nms(boxes, scores, cls_ids, self.iou_thres)673 boxes, scores, cls_ids = boxes[keep], scores[keep], cls_ids[keep]674 if len(scores) > self.max_det:675 top = np.argsort(-scores)[: self.max_det]676 boxes, scores, cls_ids = boxes[top], scores[top], cls_ids[top]677 if len(boxes) > 1:678 boxes, scores, cls_ids = self._cross_class_dedup_op(679 boxes, scores, cls_ids, self.cross_iou_thresh680 )681 if len(boxes) > 1:682 boxes, scores, cls_ids = self._merge_same_class_boxes(boxes, scores, cls_ids)683 return boxes, scores, cls_ids684 685 @staticmethod686 def _roi_for_box(image: np.ndarray, box: BoundingBox) -> np.ndarray | None:687 """Clip a BoundingBox to the image and return its BGR pixel ROI."""688 h, w = image.shape[:2]689 x1 = max(0, int(math.floor(box.x1)))690 y1 = max(0, int(math.floor(box.y1)))691 x2 = min(w, int(math.ceil(box.x2)))692 y2 = min(h, int(math.ceil(box.y2)))693 if x2 <= x1 or y2 <= y1:694 return None695 roi = image[y1:y2, x1:x2]696 return roi if roi.size else None697 698 def _roi_is_near_grayscale(self, roi: np.ndarray) -> bool:699 """True if the ROI carries almost no color (validator grayscale frame).700 On such ROIs the color priors are skipped so they can't delete valid701 red/warm objects that have been stripped of color."""702 mx = roi.max(axis=2).astype(np.float32)703 mn = roi.min(axis=2).astype(np.float32)704 sat = (mx - mn) / (mx + 1e-6)705 return float(sat.mean()) < self.color_filter_min_saturation706 707 @staticmethod708 def _passes_fire_color(roi: np.ndarray) -> bool:709 """Fire is warm and/or has a bright hotspot. ROI is BGR."""710 blue = roi[:, :, 0].astype(np.float32)711 green = roi[:, :, 1].astype(np.float32)712 red = roi[:, :, 2].astype(np.float32)713 mean_r = float(np.mean(red))714 max_rgb = float(max(np.max(red), np.max(green), np.max(blue)))715 bright_frac = float(np.mean(np.max(roi, axis=2) >= 150))716 # A bright hotspot is fire-like even with little hue (also covers the717 # near-white core of an intense flame).718 if max_rgb >= 200.0 and bright_frac >= 0.01:719 return True720 warm = (red > green + 10.0) & (red > blue + 10.0)721 warm_frac = float(np.mean(warm))722 r_minus_g = mean_r - float(np.mean(green))723 if warm_frac >= 0.05 and (724 max_rgb >= 120.0 or mean_r >= 120.0 or warm_frac >= 0.15725 ):726 return True727 if bright_frac >= 0.12 and r_minus_g >= 2.0:728 return True729 return False730 731 @staticmethod732 def _passes_fire_ext_red_color(roi: np.ndarray) -> bool:733 """Fire extinguishers are red. ROI is BGR. Lenient: only clearly734 cool/green/blue or very dark regions fail."""735 blue = roi[:, :, 0].astype(np.float32)736 green = roi[:, :, 1].astype(np.float32)737 red = roi[:, :, 2].astype(np.float32)738 red_dom = float(np.mean((red > green + 10.0) & (red > blue + 10.0)))739 if red_dom >= 0.03:740 return True741 if (float(np.mean(red)) - float(np.mean(green))) >= 0.0 and \742 float(np.mean(red)) >= 50.0:743 return True744 return False745 746 def _remove_edge_low_conf(747 self, results: list[BoundingBox], orig_size: tuple[int, int]748 ) -> list[BoundingBox]:749 """Drop border-hugging boxes in the low-confidence band."""750 if (751 not self.use_edge_filter752 or self.edge_filter_max_conf <= 0.0753 or not results754 ):755 return results756 w, h = orig_size757 tol = self.edge_tol758 out: list[BoundingBox] = []759 for b in results:760 on_edge = (761 b.x1 <= tol762 or b.y1 <= tol763 or b.x2 >= w - 1 - tol764 or b.y2 >= h - 1 - tol765 )766 if on_edge and b.conf <= self.edge_filter_max_conf:767 continue768 out.append(b)769 return out770 771 def _views_corroborated(772 self,773 post_boxes: np.ndarray,774 post_cls: np.ndarray,775 full_boxes: np.ndarray,776 full_cls: np.ndarray,777 full_views: np.ndarray,778 iou_thresh: float,779 ) -> np.ndarray:780 """For each post-NMS box, True if same-class detections from >= 2781 distinct TTA views overlap it (IoU >= iou_thresh) in the full union."""782 n = len(post_boxes)783 if n == 0:784 return np.zeros(0, dtype=bool)785 full_areas = (np.maximum(0.0, full_boxes[:, 2] - full_boxes[:, 0]) *786 np.maximum(0.0, full_boxes[:, 3] - full_boxes[:, 1]))787 out = np.zeros(n, dtype=bool)788 for i in range(n):789 bi = post_boxes[i]790 xx1 = np.maximum(bi[0], full_boxes[:, 0])791 yy1 = np.maximum(bi[1], full_boxes[:, 1])792 xx2 = np.minimum(bi[2], full_boxes[:, 2])793 yy2 = np.minimum(bi[3], full_boxes[:, 3])794 inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)795 a_i = max(0.0, float((bi[2] - bi[0]) * (bi[3] - bi[1])))796 iou = inter / (a_i + full_areas - inter + 1e-7)797 mask = (iou >= iou_thresh) & (full_cls == post_cls[i])798 if np.any(mask):799 out[i] = len(np.unique(full_views[mask])) >= 2800 return out801 802 def _filter_low_conf_by_color(803 self, image: np.ndarray, results: list[BoundingBox]804 ) -> list[BoundingBox]:805 """Drop borderline fire / extinguisher detections whose pixels clearly806 contradict the class's expected color. No-op on near-grayscale ROIs and807 on detections above the per-class color-filter conf gate."""808 if not results:809 return results810 cls_fire = self.class_names.index("fire")811 cls_ext = self.class_names.index("fire extinguisher")812 out: list[BoundingBox] = []813 for box in results:814 check_fire = (815 box.cls_id == cls_fire816 and box.conf <= self.fire_color_filter_max_conf817 )818 check_ext = (819 box.cls_id == cls_ext820 and box.conf <= self.fire_ext_color_filter_max_conf821 )822 if not check_fire and not check_ext:823 out.append(box)824 continue825 roi = self._roi_for_box(image, box)826 if roi is None or self._roi_is_near_grayscale(roi):827 out.append(box)828 continue829 if check_fire and not self._passes_fire_color(roi):830 continue831 if check_ext and not self._passes_fire_ext_red_color(roi):832 continue833 out.append(box)834 return out835 836 @staticmethod837 def _build_results(838 boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray839 ) -> list[BoundingBox]:840 results: list[BoundingBox] = []841 for box, conf, cls_id in zip(boxes, scores, cls_ids):842 x1, y1, x2, y2 = box.tolist()843 if x2 <= x1 or y2 <= y1:844 continue845 results.append(846 BoundingBox(847 x1=int(math.floor(x1)),848 y1=int(math.floor(y1)),849 x2=int(math.ceil(x2)),850 y2=int(math.ceil(y2)),851 cls_id=int(cls_id),852 conf=float(conf),853 )854 )855 return results856 857 def _decode_final_dets(858 self,859 preds: np.ndarray,860 ratio: float,861 pad: tuple[float, float],862 orig_size: tuple[int, int],863 ) -> list[BoundingBox]:864 """Final-detection output path: rows shaped [x1, y1, x2, y2, conf, cls_id]."""865 if preds.ndim == 3 and preds.shape[0] == 1:866 preds = preds[0]867 if preds.ndim != 2 or preds.shape[1] < 6:868 raise ValueError(f"Unexpected ONNX final-det output shape: {preds.shape}")869 870 boxes = preds[:, :4].astype(np.float32)871 scores = preds[:, 4].astype(np.float32)872 cls_ids = preds[:, 5].astype(np.int32)873 cls_ids = self.cls_remap[cls_ids]874 875 keep = self._conf_filter_mask(scores, cls_ids)876 boxes = boxes[keep]877 scores = scores[keep]878 cls_ids = cls_ids[keep]879 if len(boxes) == 0:880 return []881 882 pad_w, pad_h = pad883 boxes[:, [0, 2]] -= pad_w884 boxes[:, [1, 3]] -= pad_h885 boxes /= ratio886 boxes = self._clip_boxes(boxes, orig_size)887 888 boxes, scores, cls_ids = self._filter_sane_boxes(889 boxes, scores, cls_ids, orig_size890 )891 if len(boxes) == 0:892 return []893 894 boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)895 return self._build_results(boxes, scores, cls_ids)896 897 def _decode_raw_yolo(898 self,899 preds: np.ndarray,900 ratio: float,901 pad: tuple[float, float],902 orig_size: tuple[int, int],903 ) -> list[BoundingBox]:904 """Fallback raw-YOLO output path: per-anchor class logits."""905 if preds.ndim != 3 or preds.shape[0] != 1:906 raise ValueError(f"Unexpected raw ONNX output shape: {preds.shape}")907 preds = preds[0]908 if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:909 preds = preds.T910 if preds.ndim != 2 or preds.shape[1] < 5:911 raise ValueError(f"Unexpected raw output shape: {preds.shape}")912 913 boxes_xywh = preds[:, :4].astype(np.float32)914 cls_part = preds[:, 4:].astype(np.float32)915 if cls_part.shape[1] == 1:916 scores = cls_part[:, 0]917 cls_ids = np.zeros(len(scores), dtype=np.int32)918 else:919 cls_ids = np.argmax(cls_part, axis=1).astype(np.int32)920 scores = cls_part[np.arange(len(cls_part)), cls_ids]921 cls_ids = self.cls_remap[cls_ids]922 923 keep = self._conf_filter_mask(scores, cls_ids)924 boxes_xywh = boxes_xywh[keep]925 scores = scores[keep]926 cls_ids = cls_ids[keep]927 if len(boxes_xywh) == 0:928 return []929 boxes = self._xywh_to_xyxy(boxes_xywh)930 931 pad_w, pad_h = pad932 boxes[:, [0, 2]] -= pad_w933 boxes[:, [1, 3]] -= pad_h934 boxes /= ratio935 boxes = self._clip_boxes(boxes, orig_size)936 937 boxes, scores, cls_ids = self._filter_sane_boxes(938 boxes, scores, cls_ids, orig_size939 )940 if len(boxes) == 0:941 return []942 943 boxes, scores, cls_ids = self._per_view_pipeline(boxes, scores, cls_ids)944 return self._build_results(boxes, scores, cls_ids)945 946 def _postprocess(947 self,948 output: np.ndarray,949 ratio: float,950 pad: tuple[float, float],951 orig_size: tuple[int, int],952 ) -> list[BoundingBox]:953 if output.ndim == 2 and output.shape[1] >= 6:954 return self._decode_final_dets(output, ratio, pad, orig_size)955 if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:956 return self._decode_final_dets(output, ratio, pad, orig_size)957 return self._decode_raw_yolo(output, ratio, pad, orig_size)958 959 def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:960 if image is None:961 raise ValueError("Input image is None")962 if not isinstance(image, np.ndarray):963 raise TypeError(f"Input is not numpy array: {type(image)}")964 if image.ndim != 3:965 raise ValueError(f"Expected HWC image, got shape={image.shape}")966 if image.shape[0] <= 0 or image.shape[1] <= 0:967 raise ValueError(f"Invalid image shape={image.shape}")968 if image.shape[2] != 3:969 raise ValueError(f"Expected 3 channels, got shape={image.shape}")970 if image.dtype != np.uint8:971 image = image.astype(np.uint8)972 973 input_tensor, ratio, pad, orig_size = self._preprocess(image)974 expected = (1, 3, self.input_height, self.input_width)975 if input_tensor.shape != expected:976 raise ValueError(977 f"Bad input tensor shape={input_tensor.shape}, expected={expected}"978 )979 980 outputs = self.session.run(self.output_names, {self.input_name: input_tensor})981 return self._postprocess(outputs[0], ratio, pad, orig_size)982 983 def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:984 """Horizontal-flip TTA.985 986 Strategy:987 1. Predict on original and on flipped image.988 2. Map flipped boxes back to original coordinates.989 3. Per-class hard NMS on the union.990 4. For each kept box, compute the max same-class score across the991 FULL union (not just the post-NMS subset) -- this lets a high-992 confidence flipped detection raise a borderline original one.993 5. Cross-class dedup to suppress same-physical-object multi-class.994 6. Smoke merge: overlapping / nested smoke boxes collapse into995 their union (one box per smoke object).996 """997 boxes_orig = self._predict_single(image)998 flipped = cv2.flip(image, 1)999 boxes_flip = self._predict_single(flipped)1000 w = image.shape[1]1001 boxes_flip = [1002 BoundingBox(1003 x1=w - b.x2, y1=b.y1, x2=w - b.x1, y2=b.y2,1004 cls_id=b.cls_id, conf=b.conf,1005 )1006 for b in boxes_flip1007 ]1008 all_boxes = boxes_orig + boxes_flip1009 if not all_boxes:1010 return []1011 1012 coords = np.array(1013 [[b.x1, b.y1, b.x2, b.y2] for b in all_boxes], dtype=np.float321014 )1015 scores = np.array([b.conf for b in all_boxes], dtype=np.float32)1016 cls_ids = np.array([b.cls_id for b in all_boxes], dtype=np.int32)1017 # view_id 0 = original, 1 = horizontal flip (mapped back to orig coords)1018 view_ids = np.array(1019 [0] * len(boxes_orig) + [1] * len(boxes_flip), dtype=np.int321020 )1021 1022 hard_keep = self._per_class_hard_nms(coords, scores, cls_ids, self.iou_thres)1023 if len(hard_keep) == 0:1024 return []1025 if len(hard_keep) > self.max_det:1026 top = np.argsort(-scores[hard_keep])[: self.max_det]1027 hard_keep = hard_keep[top]1028 1029 boosted = self._max_score_per_cluster(1030 coords[hard_keep], cls_ids[hard_keep],1031 coords, scores, cls_ids, self.iou_thres,1032 )1033 1034 kept_coords = coords[hard_keep]1035 kept_cls = cls_ids[hard_keep]1036 1037 # Optional: drop low-conf detections seen in only one TTA view.1038 if (1039 self.use_tta_view_filter1040 and self.tta_view_filter_max_conf > 0.01041 and len(kept_coords) > 01042 ):1043 corrob = self._views_corroborated(1044 kept_coords, kept_cls, coords, cls_ids, view_ids,1045 self.tta_view_iou_thresh,1046 )1047 keep = ~((boosted <= self.tta_view_filter_max_conf) & (~corrob))1048 kept_coords = kept_coords[keep]1049 boosted = boosted[keep]1050 kept_cls = kept_cls[keep]1051 1052 if len(kept_coords) > 1:1053 kept_coords, boosted, kept_cls = self._cross_class_dedup_op(1054 kept_coords, boosted, kept_cls, self.cross_iou_thresh1055 )1056 if len(kept_coords) > 1:1057 kept_coords, boosted, kept_cls = self._merge_same_class_boxes(1058 kept_coords, boosted, kept_cls1059 )1060 1061 return [1062 BoundingBox(1063 x1=int(math.floor(kept_coords[j, 0])),1064 y1=int(math.floor(kept_coords[j, 1])),1065 x2=int(math.ceil(kept_coords[j, 2])),1066 y2=int(math.ceil(kept_coords[j, 3])),1067 cls_id=int(kept_cls[j]),1068 conf=float(boosted[j]),1069 )1070 for j in range(len(kept_coords))1071 ]1072 1073 def predict_batch(1074 self,1075 batch_images: list[ndarray],1076 offset: int,1077 n_keypoints: int,1078 ) -> list[TVFrameResult]:1079 results: list[TVFrameResult] = []1080 for frame_number_in_batch, image in enumerate(batch_images):1081 try:1082 if self.use_tta:1083 boxes = self._predict_tta(image)1084 else:1085 boxes = self._predict_single(image)1086 # Color-prior + edge FP filters on the merged result, in1087 # original-image coords. Single insertion point so they run once1088 # per frame for both the TTA and non-TTA paths.1089 if isinstance(image, np.ndarray) and image.ndim == 3:1090 boxes = self._filter_low_conf_by_color(image, boxes)1091 boxes = self._remove_edge_low_conf(1092 boxes, (image.shape[1], image.shape[0])1093 )1094 except Exception as e:1095 print(1096 f"⚠️ Inference failed for frame "1097 f"{offset + frame_number_in_batch}: {e}"1098 )1099 boxes = []1100 results.append(1101 TVFrameResult(1102 frame_id=offset + frame_number_in_batch,1103 boxes=boxes,1104 keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],1105 )1106 )1107 return results1108 1109 def predict_image(self, image: ndarray) -> list[BoundingBox]:1110 """Run detection on a single BGR image."""1111 if self.use_tta:1112 boxes = self._predict_tta(image)1113 else:1114 boxes = self._predict_single(image)1115 if isinstance(image, np.ndarray) and image.ndim == 3:1116 boxes = self._filter_low_conf_by_color(image, boxes)1117 boxes = self._remove_edge_low_conf(boxes, (image.shape[1], image.shape[0]))1118 return boxes1119 