cforge42 Cursor commited on
Commit
9282073
·
0 Parent(s):

Add Innomium Ember static fire detection Space.

Browse files

Browser ONNX demo with fire, smoke, and extinguisher detection plus Python FireDetector.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (13) hide show
  1. .gitattributes +36 -0
  2. .gitignore +3 -0
  3. README.md +164 -0
  4. app.py +1118 -0
  5. detector.js +642 -0
  6. example_input.png +3 -0
  7. example_output.png +3 -0
  8. index.html +361 -0
  9. innomium_icon.svg +8 -0
  10. main.js +198 -0
  11. requirements.txt +4 -0
  12. style.css +826 -0
  13. weights.onnx +3 -0
.gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
README.md ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Innomium Ember
3
+ emoji: 🔥
4
+ colorFrom: red
5
+ colorTo: orange
6
+ sdk: static
7
+ app_file: index.html
8
+ pinned: false
9
+ license: apache-2.0
10
+ ---
11
+
12
+ <div align="center">
13
+
14
+ <img src="innomium_icon.svg" width="88" alt="Innomium logo" />
15
+
16
+ # Innomium Ember
17
+
18
+ ### Ultra-light YOLO fire, smoke, and extinguisher detection for safety monitoring and edge vision.
19
+
20
+ **3 classes · 9.8 MB ONNX · Runs in browser & on edge**
21
+
22
+ [![Live Demo](https://img.shields.io/badge/Live_Demo-Open_Space-1565FF?style=for-the-badge)](https://huggingface.co/spaces/innomium/fire-detection)
23
+ [![Model Size](https://img.shields.io/badge/Model-9.8_MB-001530?style=for-the-badge)](./weights.onnx)
24
+ [![Classes](https://img.shields.io/badge/Classes-3-1565FF?style=for-the-badge)](#overview)
25
+
26
+ </div>
27
+
28
+ ---
29
+
30
+ ## Overview
31
+
32
+ **Innomium Ember** is a cutting-edge YOLO-based fire hazard detector — very light, very strong, and built for real-world safety scenes.
33
+
34
+ Detect **fire**, **smoke**, and **fire extinguisher** with a compact **~9.8 MB ONNX** model that runs on CPU, edge hardware, or directly in the browser.
35
+
36
+ | | |
37
+ |---|---|
38
+ | **Classes** | fire · smoke · fire extinguisher |
39
+ | **Model format** | ONNX |
40
+ | **Model size** | ~9.8 MB |
41
+ | **Input size** | 640 × 640 |
42
+ | **Inference** | Browser (WASM) · Python · Edge CPU/GPU |
43
+ | **Post-processing** | Per-class NMS · Smoke merge · Color-prior filters |
44
+
45
+ ---
46
+
47
+ ## Performance
48
+
49
+ Ember classifies fire hazards in outdoor and industrial scenes with per-class bounding boxes and confidence scores.
50
+
51
+ <div align="center">
52
+
53
+ | Input | Ember Output |
54
+ |:---:|:---:|
55
+ | <img src="example_input.png" width="420" alt="Bonfire scene — input frame" /> | <img src="example_output.png" width="420" alt="Scene with fire and smoke detections" /> |
56
+ | Raw camera frame | Class labels + confidence scores |
57
+
58
+ </div>
59
+
60
+ **What this demonstrates:**
61
+ - Multi-class detection of fire and smoke in a bonfire scene
62
+ - Per-class NMS without cross-class suppression
63
+ - Smoke plume detection alongside flame localization
64
+ - Suitable for warehouses, factories, and perimeter monitoring
65
+
66
+ ---
67
+
68
+ ## Key Features
69
+
70
+ - **Multi-class YOLO** — Fire, smoke, and fire extinguisher with class ID remapping from the ONNX head.
71
+ - **Per-class NMS** — Hard NMS applied independently per class to preserve overlapping hazard types.
72
+ - **Smoke merge** — Fragmented smoke boxes merge into coherent plumes for cleaner alerts.
73
+ - **Color-prior filters** — Borderline fire and extinguisher detections are validated against expected pixel appearance.
74
+ - **Privacy-first demo** — The live Space runs inference entirely in your browser. No frames are uploaded.
75
+
76
+ ---
77
+
78
+ ## Use Cases
79
+
80
+ | Sector | Application |
81
+ |--------|-------------|
82
+ | **Industrial** | Warehouse and factory fire/smoke monitoring |
83
+ | **Commercial** | Kitchen, server room, and office hazard detection |
84
+ | **Outdoor** | Wildfire perimeter and campsite flame detection |
85
+ | **Safety** | Fire extinguisher location verification in camera views |
86
+
87
+ ---
88
+
89
+ ## Live Demo
90
+
91
+ Try Ember directly in your browser:
92
+
93
+ **[https://huggingface.co/spaces/innomium/fire-detection](https://huggingface.co/spaces/innomium/fire-detection)**
94
+
95
+ 1. Open the Space
96
+ 2. Upload an image or click **Load Example**
97
+ 3. View detections with class labels and confidence scores
98
+
99
+ ---
100
+
101
+ ## Repository Structure
102
+
103
+ ```
104
+ ├── index.html # Marketing site + interactive demo
105
+ ├── main.js / detector.js # Browser inference (ONNX Runtime Web)
106
+ ├── weights.onnx # ONNX model weights (Git LFS)
107
+ ├── app.py # Python FireDetector for batch / server use
108
+ ├── example_input.png # Sample input frame
109
+ ├── example_output.png # Sample detection output
110
+ └── innomium_icon.svg # Innomium logo
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Local Python Usage
116
+
117
+ ```bash
118
+ pip install -r requirements.txt
119
+ ```
120
+
121
+ ```python
122
+ from pathlib import Path
123
+ import cv2
124
+ from app import FireDetector
125
+
126
+ detector = FireDetector(Path("."))
127
+ image = cv2.imread("example_input.png")
128
+ boxes = detector.predict_image(image)
129
+ for box in boxes:
130
+ print(detector.class_names[box.cls_id], box.conf)
131
+ ```
132
+
133
+ ### Batch inference
134
+
135
+ ```python
136
+ results = detector.predict_batch([image], offset=0, n_keypoints=0)
137
+ for frame in results:
138
+ print(frame.frame_id, len(frame.boxes))
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Model Pipeline
144
+
145
+ 1. **Letterbox preprocess** — Resize and pad to model input (640×640)
146
+ 2. **ONNX inference** — YOLO multi-class fire hazard detection
147
+ 3. **Class remap** — Map model head order to `[fire, smoke, fire extinguisher]`
148
+ 4. **Per-class NMS** — Independent hard NMS per hazard class
149
+ 5. **Smoke merge & fire suppress** — Merge fragmented smoke; suppress nested fire duplicates
150
+ 6. **Color filters** — Validate borderline fire/extinguisher boxes against pixel appearance
151
+
152
+ ---
153
+
154
+ ## About Innomium
155
+
156
+ Innomium builds cutting-edge computer vision models for mission-critical environments — where accuracy, latency, and deployability all matter.
157
+
158
+ **Very light. Very strong. Built for safety.**
159
+
160
+ ---
161
+
162
+ ## License
163
+
164
+ Apache 2.0
app.py ADDED
@@ -0,0 +1,1118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from pathlib import Path
5
+
6
+ import cv2
7
+ import numpy as np
8
+ import onnxruntime as ort
9
+ from numpy import ndarray
10
+ from pydantic import BaseModel
11
+
12
+ MODEL_DIR = Path(__file__).resolve().parent
13
+
14
+ class BoundingBox(BaseModel):
15
+ x1: int
16
+ y1: int
17
+ x2: int
18
+ y2: int
19
+ cls_id: int
20
+ conf: float
21
+
22
+
23
+ class TVFrameResult(BaseModel):
24
+ frame_id: int
25
+ boxes: list[BoundingBox]
26
+ keypoints: list[tuple[int, int]]
27
+
28
+
29
+ class 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 bonus
34
+ - per-class hard NMS, then cross-class dedup
35
+ - horizontal-flip TTA with full-set cluster score boost
36
+ 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 order
42
+ # is read from the ONNX `names` metadata that Ultralytics embeds at
43
+ # export time (ships inside weights.onnx), so a retrained model with a
44
+ # 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.55
49
+ cross_iou_thresh = 0.8
50
+ max_det = 150
51
+
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.float32
56
+ )
57
+ # Per-class rescue bonus. If a class has ZERO boxes passing the threshold
58
+ # in a frame, its top-1 candidate is admitted when its score is at least
59
+ # (threshold - bonus). Fire and smoke get a small bonus (variable
60
+ # appearance); fire extinguisher does not (distinctive object, leave FP
61
+ # control strict).
62
+ _bonus_array = np.array(
63
+ [0.02, 0.1, 0.1], dtype=np.float32
64
+ )
65
+
66
+ # Box sanity filter (fire001-specific FP reduction): drop tiny / degenerate
67
+ # / image-spanning / extreme aspect ratio boxes.
68
+ min_box_area = 14 * 14
69
+ min_side = 8
70
+ max_aspect_ratio = 8.0
71
+
72
+ # Same-class merge: two boxes whose intersection covers at least this
73
+ # fraction of the SMALLER box are treated as the same object and replaced
74
+ # by their union. Catches nested boxes (IoU below the NMS threshold) and
75
+ # 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 a
78
+ # tight core nested inside a looser flame box merges). Set to a
79
+ # value > 1.0 to disable fire merging entirely.
80
+ # Fire merge is DISABLED by default (1.01): measured on the fire-29-val1024
81
+ # 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 separate
83
+ # true positives. Lower it to ~0.8 to enable, and re-measure with
84
+ # verify_filters.py / tune_miner.py after a retrain -- a model whose fire
85
+ # boxes fragment more (or live-SAM3 GT that draws fuller flames) could flip
86
+ # the result.
87
+ smoke_merge_overlap = 0.8
88
+ fire_merge_overlap = 1.01
89
+
90
+ # Fire containment suppression: when two FIRE boxes overlap on one object
91
+ # (intersection >= this fraction of the SMALLER box) keep the HIGHER-conf
92
+ # box and drop the other -- unchanged geometry, unlike the union merge
93
+ # above. This catches the nested core+flame duplicate that per-class NMS
94
+ # (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 AP
96
+ # (0.751 -> 0.743, composite 0.8888 -> 0.8877). Cause: GT fire boxes almost
97
+ # never overlap (1 pair in 416), so each nested model pair has one TP + one
98
+ # 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 already
100
+ # tolerates the duplicate. Lower to ~0.8 to enable; re-measure after a
101
+ # retrain or against live-SAM3 GT, which may differ.
102
+ fire_suppress_overlap = 0.88
103
+
104
+ # ── Low-confidence color-prior FP filters ───────────────────────────────
105
+ # Ported from the firedetect1007 miner's color checks, but applied ONLY to
106
+ # the borderline confidence band (just above each per-class threshold) and
107
+ # ONLY on color frames. A fire/extinguisher detection there is dropped when
108
+ # its pixels clearly do not match the expected appearance: warm/bright for
109
+ # 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 gray
113
+ # there, so a red test would wrongly delete it). We skip the filter when the
114
+ # 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 model
117
+ # retrain, re-validate these with tune_miner.py (the gates are relative to
118
+ # 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-grayscale
122
+
123
+ # ── Corroboration FP filters (optional; OFF by default) ─────────────────
124
+ # Ported in spirit from firedetect1007. Both REMOVE borderline boxes that
125
+ # lack support -- a precision play for the validator's FP pillar. OFF by
126
+ # default because, unlike the color priors, they can also drop true
127
+ # positives; enable + sweep with verify_filters.py and keep only the
128
+ # settings that raise the measured composite. A max-conf gate of 0.0
129
+ # disables the corresponding filter.
130
+ # edge filter: drop boxes touching the frame border in a low-conf band
131
+ # (the validator scales/crops, so border-hugging boxes are often the
132
+ # 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 two
134
+ # horizontal-flip TTA views (a real object is usually seen in both).
135
+ use_edge_filter = False
136
+ edge_filter_max_conf = 0.0 # drop edge-touching boxes with conf <= this
137
+ edge_tol = 2.0 # px from the border counted as "on edge"
138
+ use_tta_view_filter = False
139
+ tta_view_filter_max_conf = 0.0 # drop single-view boxes with conf <= this
140
+ 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_DIR
144
+ 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_ALL
157
+ sess_options.intra_op_num_threads = 2
158
+ sess_options.inter_op_num_threads = 1
159
+ sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
160
+
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 id
179
+ # 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].name
199
+ self.output_names = [output.name for output in self.session.get_outputs()]
200
+ self.input_shape = self.session.get_inputs()[0].shape
201
+
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 = False
206
+
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
+ @staticmethod
234
+ def _safe_dim(value, default: int) -> int:
235
+ return value if isinstance(value, int) and value > 0 else default
236
+
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 when
241
+ metadata is missing/unparsable or doesn't match `class_names` as a
242
+ set (in which case the static _model_class_order fallback is used).
243
+ """
244
+ try:
245
+ import ast
246
+
247
+ meta = self.session.get_modelmeta().custom_metadata_map
248
+ 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 None
256
+ 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 None
262
+ return order
263
+
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_shape
272
+
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_LINEAR
279
+ image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
280
+
281
+ dw = (new_w - resized_w) / 2.0
282
+ dh = (new_h - resized_h) / 2.0
283
+
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: ndarray
297
+ ) -> 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 in
303
+ # one optimized OpenCV call. Bit-identical (max abs diff 6e-8) to the
304
+ # prior cvtColor + astype/255 + transpose + ascontiguousarray chain, but
305
+ # ~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
+ @staticmethod
310
+ def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
311
+ w, h = image_size
312
+ 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 boxes
317
+
318
+ @staticmethod
319
+ def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
320
+ out = np.empty_like(boxes)
321
+ out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
322
+ out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
323
+ out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
324
+ out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
325
+ return out
326
+
327
+ @staticmethod
328
+ def _hard_nms(
329
+ boxes: np.ndarray, scores: np.ndarray, iou_thresh: float
330
+ ) -> 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
+ break
341
+ 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 == c
367
+ 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 detected
385
+ 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_ids
390
+ 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
+ continue
402
+ 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_thresh
412
+ dup[i] = False
413
+ suppressed |= dup
414
+ 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 the
428
+ SMALLER box are treated as one object and replaced by their union with
429
+ the max confidence of the pair. Repeats until no pair merges, so chains
430
+ of fragments collapse. `overlap` is intersection-over-minimum-area, so
431
+ only nested / heavily-overlapping boxes merge -- two spatially separate
432
+ objects (low mutual overlap) are never fused. `overlap > 1.0` disables.
433
+ """
434
+ if overlap > 1.0:
435
+ return boxes, scores, cls_ids
436
+ idx = np.where(cls_ids == target_cls)[0]
437
+ if len(idx) <= 1:
438
+ return boxes, scores, cls_ids
439
+
440
+ sb = boxes[idx].astype(np.float32).tolist()
441
+ ss = scores[idx].astype(np.float32).tolist()
442
+ merged_any = True
443
+ while merged_any and len(sb) > 1:
444
+ merged_any = False
445
+ 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 = True
465
+ break
466
+ if merged_any:
467
+ break
468
+
469
+ other = cls_ids != target_cls
470
+ 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_cls
483
+
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` of
493
+ the smaller box) keep the higher-confidence box and drop the other.
494
+ Geometry is never changed -- only the redundant lower-conf box is
495
+ removed. `overlap > 1.0` disables."""
496
+ if overlap > 1.0:
497
+ return boxes, scores, cls_ids
498
+ idx = np.where(cls_ids == target_cls)[0]
499
+ if len(idx) <= 1:
500
+ return boxes, scores, cls_ids
501
+
502
+ order = idx[np.argsort(-scores[idx])] # highest confidence first
503
+ remove: set[int] = set()
504
+ for a in range(len(order)):
505
+ i = int(order[a])
506
+ if i in remove:
507
+ continue
508
+ 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
+ continue
514
+ 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
+ continue
520
+ 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_ids
525
+ keep = np.array(
526
+ [k not in remove for k in range(len(boxes))], dtype=bool
527
+ )
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, so
539
+ 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_ids
558
+
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
+ @staticmethod
569
+ 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 FULL
578
+ candidate set among same-class boxes with IoU >= iou_thresh.
579
+
580
+ Used after horizontal-flip TTA: a high-confidence flipped detection
581
+ 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.0
600
+ return out
601
+
602
+ def _conf_filter_mask(
603
+ self, scores: np.ndarray, cls_ids: np.ndarray
604
+ ) -> np.ndarray:
605
+ """Boolean keep-mask: score >= per-class threshold, with a per-class
606
+ rescue -- if a class has zero boxes passing, admit its top-1 candidate
607
+ 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 >= thr
612
+ for c in np.unique(cls_ids):
613
+ b = float(self._bonus_array[c])
614
+ if b <= 0.0:
615
+ continue
616
+ cm = cls_ids == c
617
+ if keep[cm].any():
618
+ continue
619
+ 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] = True
623
+ return keep
624
+
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_ids
635
+ orig_w, orig_h = orig_size
636
+ 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 - x1
641
+ bh = y2 - y1
642
+ if bw <= 0 or bh <= 0:
643
+ continue
644
+ if bw < self.min_side or bh < self.min_side:
645
+ continue
646
+ area = bw * bh
647
+ if area < self.min_box_area:
648
+ continue
649
+ if area > 0.95 * image_area:
650
+ continue
651
+ ar = max(bw / max(bh, 1e-6), bh / max(bw, 1e-6))
652
+ if ar > self.max_aspect_ratio:
653
+ continue
654
+ 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_thresh
680
+ )
681
+ if len(boxes) > 1:
682
+ boxes, scores, cls_ids = self._merge_same_class_boxes(boxes, scores, cls_ids)
683
+ return boxes, scores, cls_ids
684
+
685
+ @staticmethod
686
+ 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 None
695
+ roi = image[y1:y2, x1:x2]
696
+ return roi if roi.size else None
697
+
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 valid
701
+ 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_saturation
706
+
707
+ @staticmethod
708
+ 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 the
717
+ # near-white core of an intense flame).
718
+ if max_rgb >= 200.0 and bright_frac >= 0.01:
719
+ return True
720
+ 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.15
725
+ ):
726
+ return True
727
+ if bright_frac >= 0.12 and r_minus_g >= 2.0:
728
+ return True
729
+ return False
730
+
731
+ @staticmethod
732
+ def _passes_fire_ext_red_color(roi: np.ndarray) -> bool:
733
+ """Fire extinguishers are red. ROI is BGR. Lenient: only clearly
734
+ 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 True
741
+ if (float(np.mean(red)) - float(np.mean(green))) >= 0.0 and \
742
+ float(np.mean(red)) >= 50.0:
743
+ return True
744
+ return False
745
+
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_filter
752
+ or self.edge_filter_max_conf <= 0.0
753
+ or not results
754
+ ):
755
+ return results
756
+ w, h = orig_size
757
+ tol = self.edge_tol
758
+ out: list[BoundingBox] = []
759
+ for b in results:
760
+ on_edge = (
761
+ b.x1 <= tol
762
+ or b.y1 <= tol
763
+ or b.x2 >= w - 1 - tol
764
+ or b.y2 >= h - 1 - tol
765
+ )
766
+ if on_edge and b.conf <= self.edge_filter_max_conf:
767
+ continue
768
+ out.append(b)
769
+ return out
770
+
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 >= 2
781
+ 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])) >= 2
800
+ return out
801
+
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 clearly
806
+ contradict the class's expected color. No-op on near-grayscale ROIs and
807
+ on detections above the per-class color-filter conf gate."""
808
+ if not results:
809
+ return results
810
+ 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_fire
816
+ and box.conf <= self.fire_color_filter_max_conf
817
+ )
818
+ check_ext = (
819
+ box.cls_id == cls_ext
820
+ and box.conf <= self.fire_ext_color_filter_max_conf
821
+ )
822
+ if not check_fire and not check_ext:
823
+ out.append(box)
824
+ continue
825
+ roi = self._roi_for_box(image, box)
826
+ if roi is None or self._roi_is_near_grayscale(roi):
827
+ out.append(box)
828
+ continue
829
+ if check_fire and not self._passes_fire_color(roi):
830
+ continue
831
+ if check_ext and not self._passes_fire_ext_red_color(roi):
832
+ continue
833
+ out.append(box)
834
+ return out
835
+
836
+ @staticmethod
837
+ def _build_results(
838
+ boxes: np.ndarray, scores: np.ndarray, cls_ids: np.ndarray
839
+ ) -> 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
+ continue
845
+ 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 results
856
+
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 = pad
883
+ boxes[:, [0, 2]] -= pad_w
884
+ boxes[:, [1, 3]] -= pad_h
885
+ boxes /= ratio
886
+ boxes = self._clip_boxes(boxes, orig_size)
887
+
888
+ boxes, scores, cls_ids = self._filter_sane_boxes(
889
+ boxes, scores, cls_ids, orig_size
890
+ )
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.T
910
+ 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 = pad
932
+ boxes[:, [0, 2]] -= pad_w
933
+ boxes[:, [1, 3]] -= pad_h
934
+ boxes /= ratio
935
+ boxes = self._clip_boxes(boxes, orig_size)
936
+
937
+ boxes, scores, cls_ids = self._filter_sane_boxes(
938
+ boxes, scores, cls_ids, orig_size
939
+ )
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 the
991
+ 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 into
995
+ 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_flip
1007
+ ]
1008
+ all_boxes = boxes_orig + boxes_flip
1009
+ 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.float32
1014
+ )
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.int32
1020
+ )
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_filter
1040
+ and self.tta_view_filter_max_conf > 0.0
1041
+ and len(kept_coords) > 0
1042
+ ):
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_thresh
1055
+ )
1056
+ if len(kept_coords) > 1:
1057
+ kept_coords, boosted, kept_cls = self._merge_same_class_boxes(
1058
+ kept_coords, boosted, kept_cls
1059
+ )
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, in
1087
+ # original-image coords. Single insertion point so they run once
1088
+ # 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 results
1108
+
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 boxes
detector.js ADDED
@@ -0,0 +1,642 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ort from "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.min.mjs";
2
+
3
+ const CLASS_NAMES = ["fire", "smoke", "fire extinguisher"];
4
+ const CLS_REMAP = [0, 2, 1];
5
+
6
+ const CLASS_COLORS = {
7
+ fire: "#EF4444",
8
+ smoke: "#64748B",
9
+ "fire extinguisher": "#DC2626",
10
+ };
11
+
12
+ const CONFIG = {
13
+ confThres: [0.15, 0.3, 0.23],
14
+ bonus: [0.02, 0.1, 0.1],
15
+ iouThres: 0.55,
16
+ crossIouThresh: 0.8,
17
+ maxDet: 150,
18
+ useTta: false,
19
+ minBoxArea: 14 * 14,
20
+ minSide: 8,
21
+ maxAspectRatio: 8.0,
22
+ maxBoxAreaRatio: 0.95,
23
+ smokeMergeOverlap: 0.8,
24
+ fireMergeOverlap: 1.01,
25
+ fireSuppressOverlap: 0.88,
26
+ fireColorFilterMaxConf: 0.45,
27
+ fireExtColorFilterMaxConf: 0.4,
28
+ colorFilterMinSaturation: 0.06,
29
+ };
30
+
31
+ function safeDim(value, fallback) {
32
+ return Number.isInteger(value) && value > 0 ? value : fallback;
33
+ }
34
+
35
+ function remapClassId(clsId) {
36
+ return CLS_REMAP[clsId] ?? clsId;
37
+ }
38
+
39
+ function clipBoxes(boxes, imageW, imageH) {
40
+ for (const box of boxes) {
41
+ box[0] = Math.min(Math.max(box[0], 0), imageW - 1);
42
+ box[1] = Math.min(Math.max(box[1], 0), imageH - 1);
43
+ box[2] = Math.min(Math.max(box[2], 0), imageW - 1);
44
+ box[3] = Math.min(Math.max(box[3], 0), imageH - 1);
45
+ }
46
+ return boxes;
47
+ }
48
+
49
+ function hardNms(boxes, scores, iouThresh) {
50
+ const n = boxes.length;
51
+ if (n === 0) return [];
52
+ const order = scores
53
+ .map((score, index) => [score, index])
54
+ .sort((a, b) => b[0] - a[0])
55
+ .map((pair) => pair[1]);
56
+ const keep = [];
57
+ while (order.length > 0) {
58
+ const i = order.shift();
59
+ keep.push(i);
60
+ if (order.length === 0) break;
61
+ const rest = [];
62
+ const boxI = boxes[i];
63
+ const areaI = Math.max(0, boxI[2] - boxI[0]) * Math.max(0, boxI[3] - boxI[1]);
64
+ for (const j of order) {
65
+ const boxJ = boxes[j];
66
+ const xx1 = Math.max(boxI[0], boxJ[0]);
67
+ const yy1 = Math.max(boxI[1], boxJ[1]);
68
+ const xx2 = Math.min(boxI[2], boxJ[2]);
69
+ const yy2 = Math.min(boxI[3], boxJ[3]);
70
+ const inter = Math.max(0, xx2 - xx1) * Math.max(0, yy2 - yy1);
71
+ const areaJ = Math.max(0, boxJ[2] - boxJ[0]) * Math.max(0, boxJ[3] - boxJ[1]);
72
+ const iou = inter / (areaI + areaJ - inter + 1e-7);
73
+ if (iou <= iouThresh) rest.push(j);
74
+ }
75
+ order.length = 0;
76
+ order.push(...rest);
77
+ }
78
+ return keep;
79
+ }
80
+
81
+ function perClassHardNms(boxes, scores, clsIds, iouThresh) {
82
+ if (boxes.length === 0) return [];
83
+ const classes = [...new Set(clsIds)];
84
+ const allKeep = [];
85
+ for (const cls of classes) {
86
+ const indices = clsIds.map((id, i) => (id === cls ? i : -1)).filter((i) => i >= 0);
87
+ const classBoxes = indices.map((i) => boxes[i]);
88
+ const classScores = indices.map((i) => scores[i]);
89
+ const keep = hardNms(classBoxes, classScores, iouThresh);
90
+ allKeep.push(...keep.map((k) => indices[k]));
91
+ }
92
+ allKeep.sort((a, b) => a - b);
93
+ return allKeep;
94
+ }
95
+
96
+ function confFilterMask(scores, clsIds) {
97
+ const keep = scores.map((score, i) => score >= CONFIG.confThres[clsIds[i]]);
98
+ const classes = [...new Set(clsIds)];
99
+ for (const c of classes) {
100
+ if (CONFIG.bonus[c] <= 0) continue;
101
+ const indices = clsIds.map((id, i) => (id === c ? i : -1)).filter((i) => i >= 0);
102
+ if (indices.some((i) => keep[i])) continue;
103
+ let top = indices[0];
104
+ for (const i of indices) {
105
+ if (scores[i] > scores[top]) top = i;
106
+ }
107
+ if (scores[top] >= CONFIG.confThres[c] - CONFIG.bonus[c]) keep[top] = true;
108
+ }
109
+ return keep;
110
+ }
111
+
112
+ function filterSaneBoxes(boxes, scores, clsIds, origW, origH) {
113
+ const imageArea = origW * origH;
114
+ const keptBoxes = [];
115
+ const keptScores = [];
116
+ const keptCls = [];
117
+ for (let i = 0; i < boxes.length; i += 1) {
118
+ const [x1, y1, x2, y2] = boxes[i];
119
+ const bw = x2 - x1;
120
+ const bh = y2 - y1;
121
+ if (bw <= 0 || bh <= 0) continue;
122
+ if (bw < CONFIG.minSide || bh < CONFIG.minSide) continue;
123
+ const area = bw * bh;
124
+ if (area < CONFIG.minBoxArea) continue;
125
+ if (area > CONFIG.maxBoxAreaRatio * imageArea) continue;
126
+ const ar = Math.max(bw / Math.max(bh, 1e-6), bh / Math.max(bw, 1e-6));
127
+ if (ar > CONFIG.maxAspectRatio) continue;
128
+ keptBoxes.push(boxes[i]);
129
+ keptScores.push(scores[i]);
130
+ keptCls.push(clsIds[i]);
131
+ }
132
+ return { boxes: keptBoxes, scores: keptScores, clsIds: keptCls };
133
+ }
134
+
135
+ function crossClassDedup(boxes, scores, clsIds, iouThresh) {
136
+ const n = boxes.length;
137
+ if (n <= 1) return { boxes, scores, clsIds };
138
+ const areas = boxes.map(([x1, y1, x2, y2]) => Math.max(0, x2 - x1) * Math.max(0, y2 - y1));
139
+ const margins = scores.map((s, i) => s - CONFIG.confThres[clsIds[i]]);
140
+ const order = [...Array(n).keys()].sort((a, b) => {
141
+ if (margins[b] !== margins[a]) return margins[b] - margins[a];
142
+ return areas[b] - areas[a];
143
+ });
144
+ const suppressed = new Array(n).fill(false);
145
+ const keep = [];
146
+ for (const i of order) {
147
+ if (suppressed[i]) continue;
148
+ keep.push(i);
149
+ const bi = boxes[i];
150
+ const areaI = Math.max(1e-7, (bi[2] - bi[0]) * (bi[3] - bi[1]));
151
+ for (let j = 0; j < n; j += 1) {
152
+ if (j === i || suppressed[j]) continue;
153
+ const bj = boxes[j];
154
+ const xx1 = Math.max(bi[0], bj[0]);
155
+ const yy1 = Math.max(bi[1], bj[1]);
156
+ const xx2 = Math.min(bi[2], bj[2]);
157
+ const yy2 = Math.min(bi[3], bj[3]);
158
+ const inter = Math.max(0, xx2 - xx1) * Math.max(0, yy2 - yy1);
159
+ const iou = inter / (areaI + areas[j] - inter + 1e-7);
160
+ if (iou > iouThresh) suppressed[j] = true;
161
+ }
162
+ }
163
+ return {
164
+ boxes: keep.map((i) => boxes[i]),
165
+ scores: keep.map((i) => scores[i]),
166
+ clsIds: keep.map((i) => clsIds[i]),
167
+ };
168
+ }
169
+
170
+ function mergeClassBoxes(boxes, scores, clsIds, targetCls, overlap) {
171
+ if (overlap > 1.0) return { boxes, scores, clsIds };
172
+ const idx = clsIds.map((id, i) => (id === targetCls ? i : -1)).filter((i) => i >= 0);
173
+ if (idx.length <= 1) return { boxes, scores, clsIds };
174
+
175
+ let sb = idx.map((i) => [...boxes[i]]);
176
+ let ss = idx.map((i) => scores[i]);
177
+ let mergedAny = true;
178
+ while (mergedAny && sb.length > 1) {
179
+ mergedAny = false;
180
+ for (let i = 0; i < sb.length; i += 1) {
181
+ for (let j = i + 1; j < sb.length; j += 1) {
182
+ const a = sb[i];
183
+ const b = sb[j];
184
+ const ix1 = Math.max(a[0], b[0]);
185
+ const iy1 = Math.max(a[1], b[1]);
186
+ const ix2 = Math.min(a[2], b[2]);
187
+ const iy2 = Math.min(a[3], b[3]);
188
+ const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
189
+ const areaA = Math.max(0, a[2] - a[0]) * Math.max(0, a[3] - a[1]);
190
+ const areaB = Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]);
191
+ if (inter / (Math.min(areaA, areaB) + 1e-7) >= overlap) {
192
+ sb[i] = [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
193
+ ss[i] = Math.max(ss[i], ss[j]);
194
+ sb.splice(j, 1);
195
+ ss.splice(j, 1);
196
+ mergedAny = true;
197
+ break;
198
+ }
199
+ }
200
+ if (mergedAny) break;
201
+ }
202
+ }
203
+
204
+ const other = [];
205
+ const otherScores = [];
206
+ const otherCls = [];
207
+ for (let i = 0; i < boxes.length; i += 1) {
208
+ if (clsIds[i] !== targetCls) {
209
+ other.push(boxes[i]);
210
+ otherScores.push(scores[i]);
211
+ otherCls.push(clsIds[i]);
212
+ }
213
+ }
214
+ return {
215
+ boxes: [...other, ...sb],
216
+ scores: [...otherScores, ...ss],
217
+ clsIds: [...otherCls, ...new Array(sb.length).fill(targetCls)],
218
+ };
219
+ }
220
+
221
+ function suppressContainedLowerConf(boxes, scores, clsIds, targetCls, overlap) {
222
+ if (overlap > 1.0) return { boxes, scores, clsIds };
223
+ const idx = clsIds.map((id, i) => (id === targetCls ? i : -1)).filter((i) => i >= 0);
224
+ if (idx.length <= 1) return { boxes, scores, clsIds };
225
+ const order = [...idx].sort((a, b) => scores[b] - scores[a]);
226
+ const remove = new Set();
227
+ for (let a = 0; a < order.length; a += 1) {
228
+ const i = order[a];
229
+ if (remove.has(i)) continue;
230
+ const bi = boxes[i];
231
+ const areaI = Math.max(1e-7, (bi[2] - bi[0]) * (bi[3] - bi[1]));
232
+ for (let b = a + 1; b < order.length; b += 1) {
233
+ const j = order[b];
234
+ if (remove.has(j)) continue;
235
+ const bj = boxes[j];
236
+ const ix1 = Math.max(bi[0], bj[0]);
237
+ const iy1 = Math.max(bi[1], bj[1]);
238
+ const ix2 = Math.min(bi[2], bj[2]);
239
+ const iy2 = Math.min(bi[3], bj[3]);
240
+ const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
241
+ if (inter <= 0) continue;
242
+ const areaJ = Math.max(1e-7, (bj[2] - bj[0]) * (bj[3] - bj[1]));
243
+ if (inter / (Math.min(areaI, areaJ) + 1e-7) >= overlap) remove.add(j);
244
+ }
245
+ }
246
+ if (remove.size === 0) return { boxes, scores, clsIds };
247
+ const keep = boxes.map((_, i) => !remove.has(i));
248
+ return {
249
+ boxes: boxes.filter((_, i) => keep[i]),
250
+ scores: scores.filter((_, i) => keep[i]),
251
+ clsIds: clsIds.filter((_, i) => keep[i]),
252
+ };
253
+ }
254
+
255
+ function mergeSameClassBoxes(boxes, scores, clsIds) {
256
+ let state = mergeClassBoxes(boxes, scores, clsIds, CLASS_NAMES.indexOf("smoke"), CONFIG.smokeMergeOverlap);
257
+ state = mergeClassBoxes(state.boxes, state.scores, state.clsIds, CLASS_NAMES.indexOf("fire"), CONFIG.fireMergeOverlap);
258
+ state = suppressContainedLowerConf(state.boxes, state.scores, state.clsIds, CLASS_NAMES.indexOf("fire"), CONFIG.fireSuppressOverlap);
259
+ return state;
260
+ }
261
+
262
+ function perViewPipeline(boxes, scores, clsIds) {
263
+ if (boxes.length > 1) {
264
+ const keep = perClassHardNms(boxes, scores, clsIds, CONFIG.iouThres);
265
+ boxes = keep.map((i) => boxes[i]);
266
+ scores = keep.map((i) => scores[i]);
267
+ clsIds = keep.map((i) => clsIds[i]);
268
+ }
269
+ if (scores.length > CONFIG.maxDet) {
270
+ const order = scores.map((s, i) => [s, i]).sort((a, b) => b[0] - a[0]).slice(0, CONFIG.maxDet).map((p) => p[1]);
271
+ boxes = order.map((i) => boxes[i]);
272
+ scores = order.map((i) => scores[i]);
273
+ clsIds = order.map((i) => clsIds[i]);
274
+ }
275
+ if (boxes.length > 1) {
276
+ const deduped = crossClassDedup(boxes, scores, clsIds, CONFIG.crossIouThresh);
277
+ boxes = deduped.boxes;
278
+ scores = deduped.scores;
279
+ clsIds = deduped.clsIds;
280
+ }
281
+ if (boxes.length > 1) {
282
+ const merged = mergeSameClassBoxes(boxes, scores, clsIds);
283
+ boxes = merged.boxes;
284
+ scores = merged.scores;
285
+ clsIds = merged.clsIds;
286
+ }
287
+ return { boxes, scores, clsIds };
288
+ }
289
+
290
+ function toBoundingBoxes(boxes, scores, clsIds) {
291
+ const out = [];
292
+ for (let i = 0; i < boxes.length; i += 1) {
293
+ const [x1, y1, x2, y2] = boxes[i];
294
+ if (x2 <= x1 || y2 <= y1) continue;
295
+ out.push({
296
+ x1: Math.floor(x1),
297
+ y1: Math.floor(y1),
298
+ x2: Math.ceil(x2),
299
+ y2: Math.ceil(y2),
300
+ cls_id: clsIds[i],
301
+ conf: scores[i],
302
+ });
303
+ }
304
+ return out;
305
+ }
306
+
307
+ function letterboxCanvas(image, newW, newH) {
308
+ const srcW = image.width;
309
+ const srcH = image.height;
310
+ const ratio = Math.min(newW / srcW, newH / srcH);
311
+ const resizedW = Math.round(srcW * ratio);
312
+ const resizedH = Math.round(srcH * ratio);
313
+ const resized = document.createElement("canvas");
314
+ resized.width = resizedW;
315
+ resized.height = resizedH;
316
+ resized.getContext("2d").drawImage(image, 0, 0, resizedW, resizedH);
317
+ const padW = (newW - resizedW) / 2;
318
+ const padH = (newH - resizedH) / 2;
319
+ const canvas = document.createElement("canvas");
320
+ canvas.width = newW;
321
+ canvas.height = newH;
322
+ const ctx = canvas.getContext("2d");
323
+ ctx.fillStyle = "rgb(114,114,114)";
324
+ ctx.fillRect(0, 0, newW, newH);
325
+ ctx.drawImage(resized, padW, padH);
326
+ return { canvas, ratio, pad: [padW, padH] };
327
+ }
328
+
329
+ function imageToTensor(canvas, inputW, inputH) {
330
+ const { data } = canvas.getContext("2d").getImageData(0, 0, inputW, inputH);
331
+ const tensor = new Float32Array(1 * 3 * inputH * inputW);
332
+ const plane = inputH * inputW;
333
+ for (let y = 0; y < inputH; y += 1) {
334
+ for (let x = 0; x < inputW; x += 1) {
335
+ const i = (y * inputW + x) * 4;
336
+ const offset = y * inputW + x;
337
+ tensor[offset] = data[i] / 255;
338
+ tensor[plane + offset] = data[i + 1] / 255;
339
+ tensor[2 * plane + offset] = data[i + 2] / 255;
340
+ }
341
+ }
342
+ return tensor;
343
+ }
344
+
345
+ function transpose(matrix) {
346
+ const rows = matrix.length;
347
+ const cols = matrix[0].length;
348
+ const out = Array.from({ length: cols }, () => new Array(rows));
349
+ for (let r = 0; r < rows; r += 1) {
350
+ for (let c = 0; c < cols; c += 1) {
351
+ out[c][r] = matrix[r][c];
352
+ }
353
+ }
354
+ return out;
355
+ }
356
+
357
+ function tensorToNestedArray(tensor) {
358
+ const data = Array.from(tensor.data);
359
+ const dims = tensor.dims;
360
+ if (dims.length === 2) {
361
+ const [rows, cols] = dims;
362
+ const out = [];
363
+ for (let r = 0; r < rows; r += 1) out.push(data.slice(r * cols, (r + 1) * cols));
364
+ return out;
365
+ }
366
+ if (dims.length === 3) {
367
+ const [batch, dim1, dim2] = dims;
368
+ const out = [];
369
+ const stride = dim1 * dim2;
370
+ for (let b = 0; b < batch; b += 1) {
371
+ const batchData = data.slice(b * stride, (b + 1) * stride);
372
+ const rows = [];
373
+ for (let r = 0; r < dim1; r += 1) rows.push(batchData.slice(r * dim2, (r + 1) * dim2));
374
+ out.push(rows);
375
+ }
376
+ return out;
377
+ }
378
+ throw new Error(`Unsupported output tensor shape: ${dims.join("x")}`);
379
+ }
380
+
381
+ function decodeFinalDets(preds, ratio, pad, origW, origH) {
382
+ let rows = preds;
383
+ if (preds.length === 1 && Array.isArray(preds[0]) && Array.isArray(preds[0][0])) {
384
+ rows = preds[0];
385
+ }
386
+
387
+ const rawScores = [];
388
+ const rawCls = [];
389
+ for (const row of rows) {
390
+ if (row.length < 6) continue;
391
+ rawScores.push(row[4]);
392
+ rawCls.push(remapClassId(Math.round(row[5])));
393
+ }
394
+ const mask = confFilterMask(rawScores, rawCls);
395
+
396
+ let boxes = [];
397
+ let scores = [];
398
+ let clsIds = [];
399
+ for (let i = 0; i < rows.length; i += 1) {
400
+ if (!mask[i] || rows[i].length < 6) continue;
401
+ boxes.push(rows[i].slice(0, 4));
402
+ scores.push(rows[i][4]);
403
+ clsIds.push(rawCls[i]);
404
+ }
405
+
406
+ if (boxes.length === 0) return [];
407
+ const [padW, padH] = pad;
408
+ boxes = boxes.map(([x1, y1, x2, y2]) => [
409
+ (x1 - padW) / ratio,
410
+ (y1 - padH) / ratio,
411
+ (x2 - padW) / ratio,
412
+ (y2 - padH) / ratio,
413
+ ]);
414
+ clipBoxes(boxes, origW, origH);
415
+ let filtered = filterSaneBoxes(boxes, scores, clsIds, origW, origH);
416
+ if (filtered.boxes.length === 0) return [];
417
+ const piped = perViewPipeline(filtered.boxes, filtered.scores, filtered.clsIds);
418
+ return toBoundingBoxes(piped.boxes, piped.scores, piped.clsIds);
419
+ }
420
+
421
+ function decodeRawYolo(preds, ratio, pad, origW, origH) {
422
+ let rows = preds[0];
423
+ if (rows.length <= 16 && rows[0].length > rows.length) rows = transpose(rows);
424
+
425
+ const rawScores = [];
426
+ const rawCls = [];
427
+ const boxesXywh = [];
428
+ for (const row of rows) {
429
+ if (row.length < 5) continue;
430
+ const tail = row.slice(4);
431
+ let score;
432
+ let clsId;
433
+ if (tail.length === 1) {
434
+ score = tail[0];
435
+ clsId = 0;
436
+ } else {
437
+ clsId = tail.indexOf(Math.max(...tail));
438
+ score = tail[clsId];
439
+ }
440
+ clsId = remapClassId(clsId);
441
+ rawScores.push(score);
442
+ rawCls.push(clsId);
443
+ boxesXywh.push(row.slice(0, 4));
444
+ }
445
+
446
+ const mask = confFilterMask(rawScores, rawCls);
447
+ let boxes = [];
448
+ let scores = [];
449
+ let clsIds = [];
450
+ for (let i = 0; i < boxesXywh.length; i += 1) {
451
+ if (!mask[i]) continue;
452
+ const [x, y, w, h] = boxesXywh[i];
453
+ boxes.push([x - w / 2, y - h / 2, x + w / 2, y + h / 2]);
454
+ scores.push(rawScores[i]);
455
+ clsIds.push(rawCls[i]);
456
+ }
457
+
458
+ if (boxes.length === 0) return [];
459
+ const [padW, padH] = pad;
460
+ boxes = boxes.map(([x1, y1, x2, y2]) => [
461
+ (x1 - padW) / ratio,
462
+ (y1 - padH) / ratio,
463
+ (x2 - padW) / ratio,
464
+ (y2 - padH) / ratio,
465
+ ]);
466
+ clipBoxes(boxes, origW, origH);
467
+ let filtered = filterSaneBoxes(boxes, scores, clsIds, origW, origH);
468
+ if (filtered.boxes.length === 0) return [];
469
+ const piped = perViewPipeline(filtered.boxes, filtered.scores, filtered.clsIds);
470
+ return toBoundingBoxes(piped.boxes, piped.scores, piped.clsIds);
471
+ }
472
+
473
+ function postprocess(output, ratio, pad, origW, origH) {
474
+ const preds = tensorToNestedArray(output);
475
+ if (output.dims.length === 2 && output.dims[1] >= 6) {
476
+ return decodeFinalDets(preds, ratio, pad, origW, origH);
477
+ }
478
+ if (output.dims.length === 3 && output.dims[0] === 1 && output.dims[2] >= 6) {
479
+ return decodeFinalDets(preds, ratio, pad, origW, origH);
480
+ }
481
+ return decodeRawYolo(preds, ratio, pad, origW, origH);
482
+ }
483
+
484
+ function getRoiData(image, box) {
485
+ const canvas = document.createElement("canvas");
486
+ const w = image.width;
487
+ const h = image.height;
488
+ const x1 = Math.max(0, Math.floor(box.x1));
489
+ const y1 = Math.max(0, Math.floor(box.y1));
490
+ const x2 = Math.min(w, Math.ceil(box.x2));
491
+ const y2 = Math.min(h, Math.ceil(box.y2));
492
+ if (x2 <= x1 || y2 <= y1) return null;
493
+ canvas.width = x2 - x1;
494
+ canvas.height = y2 - y1;
495
+ const ctx = canvas.getContext("2d");
496
+ ctx.drawImage(image, x1, y1, x2 - x1, y2 - y1, 0, 0, x2 - x1, y2 - y1);
497
+ return ctx.getImageData(0, 0, x2 - x1, y2 - y1).data;
498
+ }
499
+
500
+ function roiIsNearGrayscale(data) {
501
+ let satSum = 0;
502
+ const pixels = data.length / 4;
503
+ for (let i = 0; i < data.length; i += 4) {
504
+ const r = data[i];
505
+ const g = data[i + 1];
506
+ const b = data[i + 2];
507
+ const mx = Math.max(r, g, b);
508
+ const mn = Math.min(r, g, b);
509
+ satSum += (mx - mn) / (mx + 1e-6);
510
+ }
511
+ return satSum / pixels < CONFIG.colorFilterMinSaturation;
512
+ }
513
+
514
+ function passesFireColor(data) {
515
+ let meanR = 0;
516
+ let meanG = 0;
517
+ let maxRgb = 0;
518
+ let brightCount = 0;
519
+ let warmCount = 0;
520
+ const pixels = data.length / 4;
521
+ for (let i = 0; i < data.length; i += 4) {
522
+ const r = data[i];
523
+ const g = data[i + 1];
524
+ const b = data[i + 2];
525
+ meanR += r;
526
+ meanG += g;
527
+ maxRgb = Math.max(maxRgb, r, g, b);
528
+ if (Math.max(r, g, b) >= 150) brightCount += 1;
529
+ if (r > g + 10 && r > b + 10) warmCount += 1;
530
+ }
531
+ meanR /= pixels;
532
+ meanG /= pixels;
533
+ const brightFrac = brightCount / pixels;
534
+ const warmFrac = warmCount / pixels;
535
+ if (maxRgb >= 200 && brightFrac >= 0.01) return true;
536
+ if (warmFrac >= 0.05 && (maxRgb >= 120 || meanR >= 120 || warmFrac >= 0.15)) return true;
537
+ if (brightFrac >= 0.12 && meanR - meanG >= 2) return true;
538
+ return false;
539
+ }
540
+
541
+ function passesFireExtColor(data) {
542
+ let redDom = 0;
543
+ let meanR = 0;
544
+ let meanG = 0;
545
+ const pixels = data.length / 4;
546
+ for (let i = 0; i < data.length; i += 4) {
547
+ const r = data[i];
548
+ const g = data[i + 1];
549
+ const b = data[i + 2];
550
+ meanR += r;
551
+ meanG += g;
552
+ if (r > g + 10 && r > b + 10) redDom += 1;
553
+ }
554
+ meanR /= pixels;
555
+ meanG /= pixels;
556
+ if (redDom / pixels >= 0.03) return true;
557
+ return meanR - meanG >= 0 && meanR >= 50;
558
+ }
559
+
560
+ function filterLowConfByColor(image, boxes) {
561
+ const clsFire = CLASS_NAMES.indexOf("fire");
562
+ const clsExt = CLASS_NAMES.indexOf("fire extinguisher");
563
+ const out = [];
564
+ for (const box of boxes) {
565
+ const checkFire = box.cls_id === clsFire && box.conf <= CONFIG.fireColorFilterMaxConf;
566
+ const checkExt = box.cls_id === clsExt && box.conf <= CONFIG.fireExtColorFilterMaxConf;
567
+ if (!checkFire && !checkExt) {
568
+ out.push(box);
569
+ continue;
570
+ }
571
+ const data = getRoiData(image, box);
572
+ if (!data || roiIsNearGrayscale(data)) {
573
+ out.push(box);
574
+ continue;
575
+ }
576
+ if (checkFire && !passesFireColor(data)) continue;
577
+ if (checkExt && !passesFireExtColor(data)) continue;
578
+ out.push(box);
579
+ }
580
+ return out;
581
+ }
582
+
583
+ export class FireDetector {
584
+ constructor() {
585
+ this.session = null;
586
+ this.inputName = null;
587
+ this.outputNames = null;
588
+ this.inputHeight = 1280;
589
+ this.inputWidth = 1280;
590
+ }
591
+
592
+ async load(modelUrl) {
593
+ ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/";
594
+ this.session = await ort.InferenceSession.create(modelUrl, {
595
+ executionProviders: ["wasm"],
596
+ });
597
+ this.inputName = this.session.inputNames[0];
598
+ this.outputNames = this.session.outputNames;
599
+ const shape = this.session.inputs.get(this.inputName).dims;
600
+ this.inputHeight = safeDim(shape[2], 1280);
601
+ this.inputWidth = safeDim(shape[3], 1280);
602
+ }
603
+
604
+ async predictSingle(image) {
605
+ const origW = image.width;
606
+ const origH = image.height;
607
+ const { canvas, ratio, pad } = letterboxCanvas(image, this.inputWidth, this.inputHeight);
608
+ const tensorData = imageToTensor(canvas, this.inputWidth, this.inputHeight);
609
+ const inputTensor = new ort.Tensor("float32", tensorData, [1, 3, this.inputHeight, this.inputWidth]);
610
+ const outputs = await this.session.run({ [this.inputName]: inputTensor });
611
+ return postprocess(outputs[this.outputNames[0]], ratio, pad, origW, origH);
612
+ }
613
+
614
+ async predictImage(image) {
615
+ const boxes = await this.predictSingle(image);
616
+ return filterLowConfByColor(image, boxes);
617
+ }
618
+ }
619
+
620
+ export function className(clsId) {
621
+ return CLASS_NAMES[clsId] ?? "unknown";
622
+ }
623
+
624
+ export function drawBoxes(ctx, boxes) {
625
+ ctx.lineWidth = 2;
626
+ ctx.font = "600 12px Inter, system-ui, sans-serif";
627
+ for (const box of boxes) {
628
+ const label = CLASS_NAMES[box.cls_id] ?? "unknown";
629
+ const color = CLASS_COLORS[label] ?? "#EF4444";
630
+ const w = box.x2 - box.x1;
631
+ const h = box.y2 - box.y1;
632
+ ctx.strokeStyle = color;
633
+ ctx.strokeRect(box.x1, box.y1, w, h);
634
+ const text = `${label.toUpperCase()} ${(box.conf * 100).toFixed(1)}%`;
635
+ const textWidth = ctx.measureText(text).width;
636
+ const y = Math.max(box.y1, 18);
637
+ ctx.fillStyle = color;
638
+ ctx.fillRect(box.x1, y - 18, textWidth + 10, 18);
639
+ ctx.fillStyle = "#ffffff";
640
+ ctx.fillText(text, box.x1 + 5, y - 4);
641
+ }
642
+ }
example_input.png ADDED

Git LFS Details

  • SHA256: c05f9db0fcf412d343611e082d572058fd0858b425e743918996da620a1030e2
  • Pointer size: 131 Bytes
  • Size of remote file: 132 kB
example_output.png ADDED

Git LFS Details

  • SHA256: 228938e0361d7737da9a017939a011cdfc843d3c793f91382c6304b666da9b9b
  • Pointer size: 131 Bytes
  • Size of remote file: 717 kB
index.html ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta
7
+ name="description"
8
+ content="Innomium Ember — ultra-light YOLO fire, smoke, and extinguisher detection for safety monitoring and edge vision."
9
+ />
10
+ <title>Innomium Ember — Fire Detection</title>
11
+ <link rel="icon" href="innomium_icon.svg" type="image/svg+xml" />
12
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
13
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
14
+ <link
15
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
16
+ rel="stylesheet"
17
+ />
18
+ <link rel="stylesheet" href="style.css" />
19
+ </head>
20
+ <body>
21
+ <header class="site-header">
22
+ <div class="container header-inner">
23
+ <a class="brand" href="#top">
24
+ <img src="innomium_icon.svg" alt="Innomium" class="brand-logo" width="40" height="40" />
25
+ <span class="brand-text">
26
+ <strong>Innomium</strong>
27
+ <small>Ember</small>
28
+ </span>
29
+ </a>
30
+ <nav class="nav">
31
+ <a href="#performance">Performance</a>
32
+ <a href="#capabilities">Capabilities</a>
33
+ <a href="#use-cases">Use Cases</a>
34
+ <a href="#demo" class="nav-cta">Live Demo</a>
35
+ </nav>
36
+ </div>
37
+ </header>
38
+
39
+ <main id="top">
40
+ <section class="hero">
41
+ <div class="container hero-grid">
42
+ <div class="hero-copy">
43
+ <p class="eyebrow">Innomium Vision · Edge YOLO</p>
44
+ <h1>
45
+ Fire &amp; smoke detection<br />
46
+ <span class="accent-text">built for safety.</span>
47
+ </h1>
48
+ <p class="hero-lead">
49
+ Ember is Innomium's cutting-edge YOLO model for fire, smoke, and fire
50
+ extinguisher detection — very light, very strong, and tuned for warehouses,
51
+ industrial sites, and mission-critical safety monitoring.
52
+ </p>
53
+ <div class="hero-actions">
54
+ <a href="#demo" class="btn btn-primary">Try Live Demo</a>
55
+ <a href="#performance" class="btn btn-secondary">See Results</a>
56
+ </div>
57
+ <dl class="hero-stats">
58
+ <div>
59
+ <dt>Classes</dt>
60
+ <dd>3 hazards</dd>
61
+ </div>
62
+ <div>
63
+ <dt>Model size</dt>
64
+ <dd>9.8 MB</dd>
65
+ </div>
66
+ <div>
67
+ <dt>Runtime</dt>
68
+ <dd>Edge / Browser</dd>
69
+ </div>
70
+ </dl>
71
+ </div>
72
+
73
+ <div class="hero-visual">
74
+ <div class="hero-visual-card">
75
+ <img
76
+ src="example_output.png"
77
+ alt="Ember detecting fire and smoke in an outdoor bonfire scene"
78
+ class="hero-image"
79
+ />
80
+ <div class="hero-visual-caption">
81
+ <span class="pill pill--live">Live detection</span>
82
+ <span>Fire + smoke · Multi-class hazards</span>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </section>
88
+
89
+ <section id="performance" class="section">
90
+ <div class="container">
91
+ <div class="section-head section-head--center">
92
+ <p class="eyebrow">Proven performance</p>
93
+ <h2>From raw scene to classified hazards.</h2>
94
+ <p class="section-lead">
95
+ Ember detects fire, smoke, and fire extinguishers with per-class NMS,
96
+ smoke merging, and color-prior filters — keeping alerts accurate even in
97
+ complex industrial scenes.
98
+ </p>
99
+ </div>
100
+
101
+ <div class="compare-grid">
102
+ <figure class="compare-card">
103
+ <figcaption>
104
+ <span class="compare-label">Input</span>
105
+ <span class="compare-meta">Raw camera frame</span>
106
+ </figcaption>
107
+ <div class="compare-frame">
108
+ <img
109
+ src="example_input.png"
110
+ alt="Outdoor bonfire — input frame without detections"
111
+ loading="lazy"
112
+ />
113
+ </div>
114
+ </figure>
115
+
116
+ <div class="compare-arrow" aria-hidden="true">
117
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
118
+ <path
119
+ d="M5 12H19M19 12L13 6M19 12L13 18"
120
+ stroke="currentColor"
121
+ stroke-width="1.75"
122
+ stroke-linecap="round"
123
+ stroke-linejoin="round"
124
+ />
125
+ </svg>
126
+ </div>
127
+
128
+ <figure class="compare-card compare-card--highlight">
129
+ <figcaption>
130
+ <span class="compare-label">Ember output</span>
131
+ <span class="compare-meta">Class labels + confidence</span>
132
+ </figcaption>
133
+ <div class="compare-frame">
134
+ <img
135
+ src="example_output.png"
136
+ alt="Same scene with Ember fire and smoke detections"
137
+ loading="lazy"
138
+ />
139
+ </div>
140
+ </figure>
141
+ </div>
142
+
143
+ <div class="performance-notes">
144
+ <article class="note-card">
145
+ <h3>Per-class precision</h3>
146
+ <p>
147
+ Separate NMS per class prevents smoke plumes from suppressing fire
148
+ flames or extinguisher detections in overlapping regions.
149
+ </p>
150
+ </article>
151
+ <article class="note-card">
152
+ <h3>Smoke merge &amp; fire suppress</h3>
153
+ <p>
154
+ Fragmented smoke boxes merge into coherent plumes, while nested fire
155
+ duplicates are suppressed to reduce false alerts.
156
+ </p>
157
+ </article>
158
+ <article class="note-card">
159
+ <h3>Edge-ready ONNX</h3>
160
+ <p>
161
+ A compact ~9.8 MB model deploys on safety cameras, drones, and
162
+ in-browser demos without a GPU farm.
163
+ </p>
164
+ </article>
165
+ </div>
166
+ </div>
167
+ </section>
168
+
169
+ <section id="capabilities" class="section section--muted">
170
+ <div class="container">
171
+ <div class="section-head">
172
+ <p class="eyebrow">Capabilities</p>
173
+ <h2>Safety intelligence, without the overhead.</h2>
174
+ </div>
175
+
176
+ <div class="feature-grid">
177
+ <article class="feature-card">
178
+ <div class="feature-icon" aria-hidden="true">
179
+ <svg viewBox="0 0 24 24" fill="none"><path d="M12 22C16.97 22 21 17.97 21 13C21 8.03 12 2 12 2C12 2 3 8.03 3 13C3 17.97 7.03 22 12 22Z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"/><path d="M12 18C14.21 18 16 16.21 16 14C16 11.5 12 8 12 8C12 8 8 11.5 8 14C8 16.21 9.79 18 12 18Z" stroke="currentColor" stroke-width="1.75"/></svg>
180
+ </div>
181
+ <h3>3 hazard classes</h3>
182
+ <p>
183
+ Detects fire, smoke, and fire extinguisher with class-aware remapping and
184
+ per-class post-processing tuned for validator scoring.
185
+ </p>
186
+ </article>
187
+ <article class="feature-card">
188
+ <div class="feature-icon" aria-hidden="true">
189
+ <svg viewBox="0 0 24 24" fill="none"><path d="M13 2L4 14H11L10 22L20 10H13L13 2Z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"/></svg>
190
+ </div>
191
+ <h3>Ultra-light ONNX</h3>
192
+ <p>
193
+ ~9.8 MB footprint runs on CPU via ONNX Runtime Web in the browser or on
194
+ edge hardware at the camera.
195
+ </p>
196
+ </article>
197
+ <article class="feature-card">
198
+ <div class="feature-icon" aria-hidden="true">
199
+ <svg viewBox="0 0 24 24" fill="none"><path d="M4 12H20M4 12L8 8M4 12L8 16" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/><circle cx="16" cy="12" r="3" stroke="currentColor" stroke-width="1.75"/></svg>
200
+ </div>
201
+ <h3>False-positive control</h3>
202
+ <p>
203
+ Color-prior filters, per-class hard NMS, and confidence thresholds reduce
204
+ spurious detections in complex scenes.
205
+ </p>
206
+ </article>
207
+ <article class="feature-card">
208
+ <div class="feature-icon" aria-hidden="true">
209
+ <svg viewBox="0 0 24 24" fill="none"><rect x="5" y="10" width="14" height="10" rx="2" stroke="currentColor" stroke-width="1.75"/><path d="M8 10V8C8 5.8 9.8 4 12 4C14.2 4 16 5.8 16 8V10" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/></svg>
210
+ </div>
211
+ <h3>Privacy-first demo</h3>
212
+ <p>
213
+ Inference runs entirely in your browser. Frames never leave your device —
214
+ ideal for regulated safety and surveillance pilots.
215
+ </p>
216
+ </article>
217
+ </div>
218
+ </div>
219
+ </section>
220
+
221
+ <section id="use-cases" class="section">
222
+ <div class="container">
223
+ <div class="section-head">
224
+ <p class="eyebrow">Use cases</p>
225
+ <h2>Where Ember delivers.</h2>
226
+ </div>
227
+
228
+ <div class="usecase-grid">
229
+ <article class="usecase-card">
230
+ <span class="usecase-tag">Industrial</span>
231
+ <h3>Warehouses &amp; factories</h3>
232
+ <p>Early fire and smoke detection in storage facilities, production floors, and loading bays.</p>
233
+ </article>
234
+ <article class="usecase-card">
235
+ <span class="usecase-tag">Commercial</span>
236
+ <h3>Retail &amp; offices</h3>
237
+ <p>Monitor kitchens, server rooms, and common areas for fire hazards and smoke buildup.</p>
238
+ </article>
239
+ <article class="usecase-card">
240
+ <span class="usecase-tag">Outdoor</span>
241
+ <h3>Wildfire &amp; perimeter</h3>
242
+ <p>Detect flames and smoke plumes along forest edges, campsites, and remote perimeters.</p>
243
+ </article>
244
+ <article class="usecase-card">
245
+ <span class="usecase-tag">Safety</span>
246
+ <h3>Extinguisher compliance</h3>
247
+ <p>Locate fire extinguishers in camera views for safety audits and equipment verification.</p>
248
+ </article>
249
+ </div>
250
+ </div>
251
+ </section>
252
+
253
+ <section id="demo" class="section section--muted">
254
+ <div class="container">
255
+ <div class="section-head section-head--center">
256
+ <p class="eyebrow">Interactive demo</p>
257
+ <h2>Upload your own scene.</h2>
258
+ <p class="section-lead">
259
+ Run the production ONNX model in your browser — per-class NMS and color filters included.
260
+ </p>
261
+ </div>
262
+
263
+ <div class="demo-shell">
264
+ <div class="demo-toolbar">
265
+ <div class="demo-status">
266
+ <span id="status-dot" class="status-dot status-dot--loading"></span>
267
+ <span id="status">Initializing model...</span>
268
+ </div>
269
+ <div class="demo-metrics">
270
+ <div class="metric-pill">
271
+ <span class="metric-pill-label">Detected</span>
272
+ <span id="count-value" class="metric-pill-value">—</span>
273
+ </div>
274
+ <div class="metric-pill">
275
+ <span class="metric-pill-label">Latency</span>
276
+ <span id="latency-value" class="metric-pill-value">—</span>
277
+ </div>
278
+ </div>
279
+ </div>
280
+
281
+ <label id="dropzone" class="dropzone" for="file-input">
282
+ <input id="file-input" type="file" accept="image/*" hidden />
283
+ <div class="dropzone-inner">
284
+ <div class="dropzone-icon" aria-hidden="true">
285
+ <svg viewBox="0 0 24 24" fill="none"><path d="M12 16V4M12 4L8 8M12 4L16 8M4 17V18C4 19.1 4.9 20 6 20H18C19.1 20 20 19.1 20 18V17" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
286
+ </div>
287
+ <p><strong>Drop an image here</strong> or click to browse</p>
288
+ <p class="dropzone-hint">PNG, JPG, WEBP — industrial, outdoor, and indoor safety scenes</p>
289
+ </div>
290
+ </label>
291
+
292
+ <div class="demo-actions">
293
+ <button id="detect-btn" type="button" class="btn btn-primary" disabled>Run Detection</button>
294
+ <button id="clear-btn" type="button" class="btn btn-secondary" disabled>Clear</button>
295
+ <button id="sample-btn" type="button" class="btn btn-secondary">Load Example</button>
296
+ </div>
297
+
298
+ <div class="canvas-grid">
299
+ <figure class="canvas-card">
300
+ <figcaption>Input</figcaption>
301
+ <div class="canvas-frame">
302
+ <canvas id="input-canvas"></canvas>
303
+ <div class="canvas-placeholder">No image loaded</div>
304
+ </div>
305
+ </figure>
306
+ <figure class="canvas-card canvas-card--highlight">
307
+ <figcaption>
308
+ Output
309
+ <span id="output-badge" class="output-badge hidden">0 hazards</span>
310
+ </figcaption>
311
+ <div class="canvas-frame">
312
+ <canvas id="output-canvas"></canvas>
313
+ <div class="canvas-placeholder">Awaiting detection</div>
314
+ </div>
315
+ </figure>
316
+ </div>
317
+
318
+ <details class="json-panel">
319
+ <summary>Detection JSON</summary>
320
+ <pre id="json-output">[]</pre>
321
+ </details>
322
+ </div>
323
+ </div>
324
+ </section>
325
+
326
+ <section class="cta-section">
327
+ <div class="container cta-card">
328
+ <img src="innomium_icon.svg" alt="" class="cta-logo" width="56" height="56" aria-hidden="true" />
329
+ <div class="cta-copy">
330
+ <h2>Deploy Ember in your safety stack.</h2>
331
+ <p>
332
+ Very light. Very strong. Multi-class fire and smoke detection for warehouses,
333
+ industrial sites, and edge deployments that need reliable hazard alerts.
334
+ </p>
335
+ </div>
336
+ <div class="cta-actions">
337
+ <a href="#demo" class="btn btn-primary">Try the Demo</a>
338
+ <a
339
+ href="https://huggingface.co/innomium"
340
+ class="btn btn-secondary"
341
+ target="_blank"
342
+ rel="noopener noreferrer"
343
+ >Hugging Face</a>
344
+ </div>
345
+ </div>
346
+ </section>
347
+ </main>
348
+
349
+ <footer class="site-footer">
350
+ <div class="container footer-inner">
351
+ <div class="footer-brand">
352
+ <img src="innomium_icon.svg" alt="Innomium" width="28" height="28" />
353
+ <span>© 2026 Innomium</span>
354
+ </div>
355
+ <p class="footer-note">Ember runs locally in your browser · No data uploaded</p>
356
+ </div>
357
+ </footer>
358
+
359
+ <script type="module" src="main.js"></script>
360
+ </body>
361
+ </html>
innomium_icon.svg ADDED
main.js ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FireDetector, drawBoxes, className } from "./detector.js";
2
+
3
+ const fileInput = document.getElementById("file-input");
4
+ const detectBtn = document.getElementById("detect-btn");
5
+ const clearBtn = document.getElementById("clear-btn");
6
+ const sampleBtn = document.getElementById("sample-btn");
7
+ const statusEl = document.getElementById("status");
8
+ const statusDot = document.getElementById("status-dot");
9
+ const inputCanvas = document.getElementById("input-canvas");
10
+ const outputCanvas = document.getElementById("output-canvas");
11
+ const inputFrame = inputCanvas.closest(".canvas-frame");
12
+ const outputFrame = outputCanvas.closest(".canvas-frame");
13
+ const jsonOutput = document.getElementById("json-output");
14
+ const dropzone = document.getElementById("dropzone");
15
+ const countValue = document.getElementById("count-value");
16
+ const latencyValue = document.getElementById("latency-value");
17
+ const outputBadge = document.getElementById("output-badge");
18
+
19
+ const detector = new FireDetector();
20
+ let currentImage = null;
21
+
22
+ function setStatus(message, state = "ready") {
23
+ statusEl.textContent = message;
24
+ statusDot.className = "status-dot";
25
+ if (state === "loading") statusDot.classList.add("status-dot--loading");
26
+ else if (state === "error") statusDot.classList.add("status-dot--error");
27
+ else statusDot.classList.add("status-dot--ready");
28
+ }
29
+
30
+ function setCanvasState(frame, hasImage) {
31
+ frame.classList.toggle("has-image", hasImage);
32
+ }
33
+
34
+ function drawImageToCanvas(canvas, image) {
35
+ canvas.width = image.width;
36
+ canvas.height = image.height;
37
+ const ctx = canvas.getContext("2d");
38
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
39
+ ctx.drawImage(image, 0, 0);
40
+ }
41
+
42
+ async function loadImageFromUrl(url) {
43
+ const response = await fetch(url);
44
+ if (!response.ok) throw new Error("Failed to load image");
45
+ const blob = await response.blob();
46
+ return loadImageFromFile(blob);
47
+ }
48
+
49
+ function loadImageFromFile(file) {
50
+ return new Promise((resolve, reject) => {
51
+ const url = URL.createObjectURL(file);
52
+ const image = new Image();
53
+ image.onload = () => {
54
+ URL.revokeObjectURL(url);
55
+ resolve(image);
56
+ };
57
+ image.onerror = () => {
58
+ URL.revokeObjectURL(url);
59
+ reject(new Error("Failed to load image"));
60
+ };
61
+ image.src = url;
62
+ });
63
+ }
64
+
65
+ function summarizeDetections(boxes) {
66
+ const counts = {};
67
+ for (const box of boxes) {
68
+ const name = className(box.cls_id);
69
+ counts[name] = (counts[name] ?? 0) + 1;
70
+ }
71
+ return Object.entries(counts)
72
+ .map(([name, count]) => `${count} ${name}${count === 1 ? "" : "s"}`)
73
+ .join(", ");
74
+ }
75
+
76
+ function updateMetrics(count, latencyMs, boxes = null) {
77
+ countValue.textContent = count ?? "—";
78
+ latencyValue.textContent = latencyMs != null ? `${latencyMs} ms` : "—";
79
+ if (count == null) {
80
+ outputBadge.classList.add("hidden");
81
+ return;
82
+ }
83
+ const summary = boxes?.length ? summarizeDetections(boxes) : `${count} detections`;
84
+ outputBadge.textContent = summary;
85
+ outputBadge.classList.remove("hidden");
86
+ }
87
+
88
+ function clearDemo() {
89
+ currentImage = null;
90
+ fileInput.value = "";
91
+ jsonOutput.textContent = "[]";
92
+ setCanvasState(inputFrame, false);
93
+ setCanvasState(outputFrame, false);
94
+ detectBtn.disabled = true;
95
+ clearBtn.disabled = true;
96
+ updateMetrics(null, null);
97
+ setStatus("Model ready. Upload a scene or load the example.", "ready");
98
+ }
99
+
100
+ async function setCurrentImage(image) {
101
+ currentImage = image;
102
+ drawImageToCanvas(inputCanvas, currentImage);
103
+ drawImageToCanvas(outputCanvas, currentImage);
104
+ setCanvasState(inputFrame, true);
105
+ setCanvasState(outputFrame, true);
106
+ jsonOutput.textContent = "[]";
107
+ detectBtn.disabled = false;
108
+ clearBtn.disabled = false;
109
+ updateMetrics(null, null);
110
+ }
111
+
112
+ async function loadImage(file) {
113
+ setStatus("Loading image...", "loading");
114
+ const image = await loadImageFromFile(file);
115
+ await setCurrentImage(image);
116
+ setStatus("Image loaded. Running Ember...", "loading");
117
+ await runDetection();
118
+ }
119
+
120
+ async function loadExample() {
121
+ try {
122
+ setStatus("Loading example scene...", "loading");
123
+ const image = await loadImageFromUrl("./example_input.png");
124
+ await setCurrentImage(image);
125
+ setStatus("Example loaded. Running Ember...", "loading");
126
+ await runDetection();
127
+ } catch (error) {
128
+ setStatus(error.message, "error");
129
+ }
130
+ }
131
+
132
+ async function runDetection() {
133
+ if (!currentImage) return;
134
+ detectBtn.disabled = true;
135
+ setStatus("Analyzing scene...", "loading");
136
+ const started = performance.now();
137
+ try {
138
+ const boxes = await detector.predictImage(currentImage);
139
+ const latencyMs = Math.round(performance.now() - started);
140
+ drawImageToCanvas(outputCanvas, currentImage);
141
+ drawBoxes(outputCanvas.getContext("2d"), boxes);
142
+ jsonOutput.textContent = JSON.stringify(boxes, null, 2);
143
+ updateMetrics(boxes.length, latencyMs, boxes);
144
+ const headline =
145
+ boxes.length === 0
146
+ ? "No fire hazards detected in this frame."
147
+ : `Ember found ${boxes.length} detection${boxes.length === 1 ? "" : "s"} in ${latencyMs} ms.`;
148
+ setStatus(headline, "ready");
149
+ } catch (error) {
150
+ console.error(error);
151
+ setStatus(`Detection failed: ${error.message}`, "error");
152
+ } finally {
153
+ detectBtn.disabled = false;
154
+ }
155
+ }
156
+
157
+ fileInput.addEventListener("change", async (event) => {
158
+ const file = event.target.files?.[0];
159
+ if (!file) return;
160
+ try {
161
+ await loadImage(file);
162
+ } catch (error) {
163
+ setStatus(error.message, "error");
164
+ }
165
+ });
166
+
167
+ detectBtn.addEventListener("click", runDetection);
168
+ clearBtn.addEventListener("click", clearDemo);
169
+ sampleBtn.addEventListener("click", loadExample);
170
+
171
+ dropzone.addEventListener("dragover", (event) => {
172
+ event.preventDefault();
173
+ dropzone.classList.add("is-dragover");
174
+ });
175
+ dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragover"));
176
+ dropzone.addEventListener("drop", async (event) => {
177
+ event.preventDefault();
178
+ dropzone.classList.remove("is-dragover");
179
+ const file = event.dataTransfer?.files?.[0];
180
+ if (!file || !file.type.startsWith("image/")) {
181
+ setStatus("Please drop an image file.", "error");
182
+ return;
183
+ }
184
+ try {
185
+ await loadImage(file);
186
+ } catch (error) {
187
+ setStatus(error.message, "error");
188
+ }
189
+ });
190
+
191
+ try {
192
+ setStatus("Loading Ember model...", "loading");
193
+ await detector.load("./weights.onnx");
194
+ setStatus("Ember ready. Upload a scene or load the example.", "ready");
195
+ } catch (error) {
196
+ console.error(error);
197
+ setStatus("Failed to load model. Check that weights.onnx is available.", "error");
198
+ }
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ onnxruntime
2
+ opencv-python-headless
3
+ numpy
4
+ pydantic
style.css ADDED
@@ -0,0 +1,826 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --brand: #1565ff;
3
+ --brand-dark: #001530;
4
+ --brand-soft: #e8f0ff;
5
+ --bg: #ffffff;
6
+ --bg-muted: #f6f8fc;
7
+ --bg-dark: #001530;
8
+ --surface: #ffffff;
9
+ --border: #e2e8f0;
10
+ --border-strong: #c7d7f5;
11
+ --text: #0f172a;
12
+ --text-secondary: #475569;
13
+ --text-muted: #64748b;
14
+ --success: #059669;
15
+ --warning: #d97706;
16
+ --danger: #dc2626;
17
+ --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
18
+ --shadow: 0 12px 40px rgba(15, 23, 42, 0.08);
19
+ --shadow-lg: 0 24px 60px rgba(21, 101, 255, 0.12);
20
+ --radius: 16px;
21
+ --radius-sm: 10px;
22
+ --container: 1120px;
23
+ }
24
+
25
+ *,
26
+ *::before,
27
+ *::after {
28
+ box-sizing: border-box;
29
+ }
30
+
31
+ html {
32
+ scroll-behavior: smooth;
33
+ }
34
+
35
+ body {
36
+ margin: 0;
37
+ font-family: Inter, system-ui, sans-serif;
38
+ background: var(--bg);
39
+ color: var(--text);
40
+ line-height: 1.6;
41
+ -webkit-font-smoothing: antialiased;
42
+ }
43
+
44
+ img {
45
+ display: block;
46
+ max-width: 100%;
47
+ }
48
+
49
+ a {
50
+ color: inherit;
51
+ text-decoration: none;
52
+ }
53
+
54
+ .container {
55
+ width: min(var(--container), calc(100% - 2rem));
56
+ margin: 0 auto;
57
+ }
58
+
59
+ .site-header {
60
+ position: sticky;
61
+ top: 0;
62
+ z-index: 20;
63
+ background: rgba(255, 255, 255, 0.9);
64
+ backdrop-filter: blur(12px);
65
+ border-bottom: 1px solid var(--border);
66
+ }
67
+
68
+ .header-inner {
69
+ display: flex;
70
+ align-items: center;
71
+ justify-content: space-between;
72
+ gap: 1rem;
73
+ min-height: 72px;
74
+ }
75
+
76
+ .brand {
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 0.75rem;
80
+ }
81
+
82
+ .brand-logo {
83
+ border-radius: 10px;
84
+ box-shadow: var(--shadow-sm);
85
+ }
86
+
87
+ .brand-text {
88
+ display: flex;
89
+ flex-direction: column;
90
+ line-height: 1.15;
91
+ }
92
+
93
+ .brand-text strong {
94
+ font-size: 0.98rem;
95
+ font-weight: 700;
96
+ color: var(--brand-dark);
97
+ }
98
+
99
+ .brand-text small {
100
+ font-size: 0.78rem;
101
+ color: var(--text-muted);
102
+ font-weight: 500;
103
+ }
104
+
105
+ .nav {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 1.5rem;
109
+ font-size: 0.92rem;
110
+ font-weight: 500;
111
+ }
112
+
113
+ .nav a {
114
+ color: var(--text-secondary);
115
+ transition: color 0.15s ease;
116
+ }
117
+
118
+ .nav a:hover {
119
+ color: var(--brand);
120
+ }
121
+
122
+ .nav-cta {
123
+ padding: 0.55rem 1rem;
124
+ border-radius: 999px;
125
+ background: var(--brand);
126
+ color: #fff !important;
127
+ }
128
+
129
+ .nav-cta:hover {
130
+ background: #0d52d9;
131
+ color: #fff !important;
132
+ }
133
+
134
+ .eyebrow {
135
+ margin: 0 0 0.65rem;
136
+ color: var(--brand);
137
+ font-size: 0.78rem;
138
+ font-weight: 600;
139
+ letter-spacing: 0.1em;
140
+ text-transform: uppercase;
141
+ }
142
+
143
+ .hero {
144
+ padding: 4.5rem 0 3.5rem;
145
+ background:
146
+ radial-gradient(circle at top right, rgba(21, 101, 255, 0.08), transparent 34%),
147
+ linear-gradient(180deg, #fff, #f8fbff);
148
+ }
149
+
150
+ .hero-grid {
151
+ display: grid;
152
+ grid-template-columns: 1fr 1.05fr;
153
+ gap: 3rem;
154
+ align-items: center;
155
+ }
156
+
157
+ .hero-copy h1 {
158
+ margin: 0 0 1rem;
159
+ font-size: clamp(2.3rem, 4.8vw, 3.6rem);
160
+ line-height: 1.08;
161
+ letter-spacing: -0.03em;
162
+ color: var(--brand-dark);
163
+ }
164
+
165
+ .accent-text {
166
+ color: var(--brand);
167
+ }
168
+
169
+ .hero-lead {
170
+ margin: 0 0 1.5rem;
171
+ max-width: 34rem;
172
+ color: var(--text-secondary);
173
+ font-size: 1.05rem;
174
+ }
175
+
176
+ .hero-actions,
177
+ .demo-actions,
178
+ .cta-actions {
179
+ display: flex;
180
+ flex-wrap: wrap;
181
+ gap: 0.75rem;
182
+ }
183
+
184
+ .btn {
185
+ display: inline-flex;
186
+ align-items: center;
187
+ justify-content: center;
188
+ padding: 0.78rem 1.15rem;
189
+ border-radius: 10px;
190
+ border: 1px solid transparent;
191
+ font: inherit;
192
+ font-size: 0.92rem;
193
+ font-weight: 600;
194
+ cursor: pointer;
195
+ transition:
196
+ background 0.15s ease,
197
+ border-color 0.15s ease,
198
+ transform 0.15s ease;
199
+ }
200
+
201
+ .btn:hover {
202
+ transform: translateY(-1px);
203
+ }
204
+
205
+ .btn:disabled {
206
+ opacity: 0.45;
207
+ cursor: not-allowed;
208
+ transform: none;
209
+ }
210
+
211
+ .btn-primary {
212
+ background: var(--brand);
213
+ color: #fff;
214
+ box-shadow: var(--shadow-lg);
215
+ }
216
+
217
+ .btn-primary:hover {
218
+ background: #0d52d9;
219
+ }
220
+
221
+ .btn-secondary {
222
+ background: #fff;
223
+ color: var(--brand-dark);
224
+ border-color: var(--border);
225
+ }
226
+
227
+ .btn-secondary:hover {
228
+ border-color: var(--border-strong);
229
+ background: var(--bg-muted);
230
+ }
231
+
232
+ .hero-stats {
233
+ display: grid;
234
+ grid-template-columns: repeat(3, minmax(0, 1fr));
235
+ gap: 1rem;
236
+ margin: 2rem 0 0;
237
+ padding: 0;
238
+ }
239
+
240
+ .hero-stats div {
241
+ padding: 1rem;
242
+ border: 1px solid var(--border);
243
+ border-radius: var(--radius-sm);
244
+ background: #fff;
245
+ }
246
+
247
+ .hero-stats dt {
248
+ margin: 0;
249
+ color: var(--text-muted);
250
+ font-size: 0.78rem;
251
+ font-weight: 500;
252
+ text-transform: uppercase;
253
+ letter-spacing: 0.06em;
254
+ }
255
+
256
+ .hero-stats dd {
257
+ margin: 0.2rem 0 0;
258
+ font-size: 1.35rem;
259
+ font-weight: 700;
260
+ color: var(--brand-dark);
261
+ }
262
+
263
+ .hero-visual-card {
264
+ border: 1px solid var(--border);
265
+ border-radius: calc(var(--radius) + 4px);
266
+ overflow: hidden;
267
+ background: #fff;
268
+ box-shadow: var(--shadow);
269
+ }
270
+
271
+ .hero-image {
272
+ width: 100%;
273
+ aspect-ratio: 16 / 10;
274
+ object-fit: cover;
275
+ }
276
+
277
+ .hero-visual-caption {
278
+ display: flex;
279
+ align-items: center;
280
+ justify-content: space-between;
281
+ gap: 0.75rem;
282
+ padding: 0.85rem 1rem;
283
+ border-top: 1px solid var(--border);
284
+ font-size: 0.86rem;
285
+ color: var(--text-secondary);
286
+ }
287
+
288
+ .pill {
289
+ display: inline-flex;
290
+ align-items: center;
291
+ padding: 0.25rem 0.55rem;
292
+ border-radius: 999px;
293
+ font-size: 0.72rem;
294
+ font-weight: 600;
295
+ text-transform: uppercase;
296
+ letter-spacing: 0.05em;
297
+ }
298
+
299
+ .pill--live {
300
+ background: #dcfce7;
301
+ color: #166534;
302
+ }
303
+
304
+ .section {
305
+ padding: 4.5rem 0;
306
+ }
307
+
308
+ .section--muted {
309
+ background: var(--bg-muted);
310
+ }
311
+
312
+ .section-head {
313
+ max-width: 680px;
314
+ margin-bottom: 2.25rem;
315
+ }
316
+
317
+ .section-head--center {
318
+ margin-inline: auto;
319
+ text-align: center;
320
+ }
321
+
322
+ .section-head h2 {
323
+ margin: 0 0 0.75rem;
324
+ font-size: clamp(1.7rem, 3vw, 2.35rem);
325
+ letter-spacing: -0.03em;
326
+ color: var(--brand-dark);
327
+ }
328
+
329
+ .section-lead {
330
+ margin: 0;
331
+ color: var(--text-secondary);
332
+ }
333
+
334
+ .compare-grid {
335
+ display: grid;
336
+ grid-template-columns: 1fr auto 1fr;
337
+ gap: 1rem;
338
+ align-items: center;
339
+ }
340
+
341
+ .compare-card {
342
+ margin: 0;
343
+ }
344
+
345
+ .compare-card figcaption {
346
+ display: flex;
347
+ align-items: baseline;
348
+ justify-content: space-between;
349
+ gap: 0.5rem;
350
+ margin-bottom: 0.65rem;
351
+ }
352
+
353
+ .compare-label {
354
+ font-size: 0.92rem;
355
+ font-weight: 600;
356
+ color: var(--brand-dark);
357
+ }
358
+
359
+ .compare-meta {
360
+ font-size: 0.8rem;
361
+ color: var(--text-muted);
362
+ }
363
+
364
+ .compare-frame {
365
+ border: 1px solid var(--border);
366
+ border-radius: var(--radius);
367
+ overflow: hidden;
368
+ background: #fff;
369
+ box-shadow: var(--shadow-sm);
370
+ }
371
+
372
+ .compare-frame img {
373
+ width: 100%;
374
+ aspect-ratio: 16 / 10;
375
+ object-fit: cover;
376
+ }
377
+
378
+ .compare-card--highlight .compare-frame {
379
+ border-color: var(--border-strong);
380
+ box-shadow: var(--shadow);
381
+ }
382
+
383
+ .compare-arrow {
384
+ display: grid;
385
+ place-items: center;
386
+ width: 40px;
387
+ height: 40px;
388
+ border-radius: 50%;
389
+ background: var(--brand-soft);
390
+ color: var(--brand);
391
+ }
392
+
393
+ .performance-notes {
394
+ display: grid;
395
+ grid-template-columns: repeat(3, minmax(0, 1fr));
396
+ gap: 1rem;
397
+ margin-top: 2rem;
398
+ }
399
+
400
+ .note-card {
401
+ padding: 1.2rem;
402
+ border: 1px solid var(--border);
403
+ border-radius: var(--radius-sm);
404
+ background: #fff;
405
+ }
406
+
407
+ .note-card h3 {
408
+ margin: 0 0 0.45rem;
409
+ font-size: 1rem;
410
+ color: var(--brand-dark);
411
+ }
412
+
413
+ .note-card p {
414
+ margin: 0;
415
+ color: var(--text-secondary);
416
+ font-size: 0.92rem;
417
+ }
418
+
419
+ .feature-grid,
420
+ .usecase-grid {
421
+ display: grid;
422
+ grid-template-columns: repeat(2, minmax(0, 1fr));
423
+ gap: 1rem;
424
+ }
425
+
426
+ .feature-card,
427
+ .usecase-card {
428
+ padding: 1.35rem;
429
+ border: 1px solid var(--border);
430
+ border-radius: var(--radius);
431
+ background: #fff;
432
+ }
433
+
434
+ .feature-icon {
435
+ width: 40px;
436
+ height: 40px;
437
+ display: grid;
438
+ place-items: center;
439
+ margin-bottom: 0.85rem;
440
+ border-radius: 10px;
441
+ background: var(--brand-soft);
442
+ color: var(--brand);
443
+ }
444
+
445
+ .feature-icon svg {
446
+ width: 20px;
447
+ height: 20px;
448
+ }
449
+
450
+ .feature-card h3,
451
+ .usecase-card h3 {
452
+ margin: 0 0 0.45rem;
453
+ font-size: 1.05rem;
454
+ color: var(--brand-dark);
455
+ }
456
+
457
+ .feature-card p,
458
+ .usecase-card p {
459
+ margin: 0;
460
+ color: var(--text-secondary);
461
+ font-size: 0.94rem;
462
+ }
463
+
464
+ .usecase-tag {
465
+ display: inline-block;
466
+ margin-bottom: 0.65rem;
467
+ padding: 0.22rem 0.55rem;
468
+ border-radius: 999px;
469
+ background: var(--brand-soft);
470
+ color: var(--brand);
471
+ font-size: 0.72rem;
472
+ font-weight: 600;
473
+ letter-spacing: 0.05em;
474
+ text-transform: uppercase;
475
+ }
476
+
477
+ .demo-shell {
478
+ padding: 1.25rem;
479
+ border: 1px solid var(--border);
480
+ border-radius: calc(var(--radius) + 2px);
481
+ background: #fff;
482
+ box-shadow: var(--shadow);
483
+ }
484
+
485
+ .demo-toolbar {
486
+ display: flex;
487
+ flex-wrap: wrap;
488
+ align-items: center;
489
+ justify-content: space-between;
490
+ gap: 1rem;
491
+ margin-bottom: 1rem;
492
+ }
493
+
494
+ .demo-status {
495
+ display: flex;
496
+ align-items: center;
497
+ gap: 0.6rem;
498
+ color: var(--text-secondary);
499
+ font-size: 0.92rem;
500
+ }
501
+
502
+ .status-dot {
503
+ width: 8px;
504
+ height: 8px;
505
+ border-radius: 50%;
506
+ background: var(--text-muted);
507
+ }
508
+
509
+ .status-dot--loading {
510
+ background: var(--warning);
511
+ animation: pulse 1.4s ease infinite;
512
+ }
513
+
514
+ .status-dot--ready {
515
+ background: var(--success);
516
+ }
517
+
518
+ .status-dot--error {
519
+ background: var(--danger);
520
+ }
521
+
522
+ @keyframes pulse {
523
+ 50% {
524
+ opacity: 0.4;
525
+ }
526
+ }
527
+
528
+ .demo-metrics {
529
+ display: flex;
530
+ gap: 0.65rem;
531
+ }
532
+
533
+ .metric-pill {
534
+ min-width: 88px;
535
+ padding: 0.5rem 0.75rem;
536
+ border: 1px solid var(--border);
537
+ border-radius: var(--radius-sm);
538
+ background: var(--bg-muted);
539
+ }
540
+
541
+ .metric-pill-label {
542
+ display: block;
543
+ font-size: 0.68rem;
544
+ font-weight: 600;
545
+ text-transform: uppercase;
546
+ letter-spacing: 0.06em;
547
+ color: var(--text-muted);
548
+ }
549
+
550
+ .metric-pill-value {
551
+ display: block;
552
+ font-size: 1.05rem;
553
+ font-weight: 700;
554
+ color: var(--brand-dark);
555
+ }
556
+
557
+ .dropzone {
558
+ display: block;
559
+ margin-bottom: 1rem;
560
+ border: 1.5px dashed var(--border-strong);
561
+ border-radius: var(--radius);
562
+ background: var(--bg-muted);
563
+ cursor: pointer;
564
+ transition:
565
+ border-color 0.15s ease,
566
+ background 0.15s ease;
567
+ }
568
+
569
+ .dropzone:hover,
570
+ .dropzone.is-dragover {
571
+ border-color: var(--brand);
572
+ background: var(--brand-soft);
573
+ }
574
+
575
+ .dropzone-inner {
576
+ padding: 1.6rem 1rem;
577
+ text-align: center;
578
+ }
579
+
580
+ .dropzone-icon {
581
+ width: 44px;
582
+ height: 44px;
583
+ margin: 0 auto 0.75rem;
584
+ display: grid;
585
+ place-items: center;
586
+ border-radius: 12px;
587
+ background: #fff;
588
+ color: var(--brand);
589
+ border: 1px solid var(--border);
590
+ }
591
+
592
+ .dropzone-icon svg {
593
+ width: 22px;
594
+ height: 22px;
595
+ }
596
+
597
+ .dropzone p {
598
+ margin: 0;
599
+ }
600
+
601
+ .dropzone-hint {
602
+ margin-top: 0.35rem !important;
603
+ color: var(--text-muted);
604
+ font-size: 0.86rem;
605
+ }
606
+
607
+ .canvas-grid {
608
+ display: grid;
609
+ grid-template-columns: repeat(2, minmax(0, 1fr));
610
+ gap: 1rem;
611
+ }
612
+
613
+ .canvas-card {
614
+ margin: 0;
615
+ }
616
+
617
+ .canvas-card figcaption {
618
+ display: flex;
619
+ align-items: center;
620
+ justify-content: space-between;
621
+ gap: 0.5rem;
622
+ margin-bottom: 0.5rem;
623
+ font-size: 0.88rem;
624
+ font-weight: 600;
625
+ color: var(--brand-dark);
626
+ }
627
+
628
+ .canvas-card--highlight figcaption {
629
+ color: var(--brand);
630
+ }
631
+
632
+ .canvas-frame {
633
+ position: relative;
634
+ min-height: 220px;
635
+ border: 1px solid var(--border);
636
+ border-radius: var(--radius-sm);
637
+ overflow: hidden;
638
+ background: #f1f5f9;
639
+ }
640
+
641
+ .canvas-frame canvas {
642
+ display: none;
643
+ width: 100%;
644
+ height: auto;
645
+ }
646
+
647
+ .canvas-frame.has-image canvas {
648
+ display: block;
649
+ }
650
+
651
+ .canvas-frame.has-image .canvas-placeholder {
652
+ display: none;
653
+ }
654
+
655
+ .canvas-placeholder {
656
+ position: absolute;
657
+ inset: 0;
658
+ display: grid;
659
+ place-items: center;
660
+ color: var(--text-muted);
661
+ font-size: 0.9rem;
662
+ }
663
+
664
+ .output-badge {
665
+ padding: 0.18rem 0.5rem;
666
+ border-radius: 999px;
667
+ background: var(--brand-soft);
668
+ color: var(--brand);
669
+ font-size: 0.72rem;
670
+ }
671
+
672
+ .output-badge.hidden {
673
+ display: none;
674
+ }
675
+
676
+ .json-panel {
677
+ margin-top: 1rem;
678
+ border: 1px solid var(--border);
679
+ border-radius: var(--radius-sm);
680
+ background: var(--bg-muted);
681
+ }
682
+
683
+ .json-panel summary {
684
+ padding: 0.8rem 1rem;
685
+ cursor: pointer;
686
+ color: var(--text-secondary);
687
+ font-size: 0.88rem;
688
+ font-weight: 500;
689
+ }
690
+
691
+ .json-panel pre {
692
+ margin: 0;
693
+ padding: 0 1rem 1rem;
694
+ max-height: 260px;
695
+ overflow: auto;
696
+ font-family: "JetBrains Mono", monospace;
697
+ font-size: 0.78rem;
698
+ color: var(--text-secondary);
699
+ }
700
+
701
+ .cta-section {
702
+ padding: 1rem 0 4rem;
703
+ }
704
+
705
+ .cta-card {
706
+ display: grid;
707
+ grid-template-columns: auto 1fr auto;
708
+ gap: 1.25rem;
709
+ align-items: center;
710
+ padding: 1.75rem;
711
+ border-radius: calc(var(--radius) + 4px);
712
+ background: var(--brand-dark);
713
+ color: #fff;
714
+ }
715
+
716
+ .cta-logo {
717
+ border-radius: 12px;
718
+ }
719
+
720
+ .cta-copy h2 {
721
+ margin: 0 0 0.35rem;
722
+ font-size: clamp(1.35rem, 2.5vw, 1.8rem);
723
+ }
724
+
725
+ .cta-copy p {
726
+ margin: 0;
727
+ color: #cbd5e1;
728
+ max-width: 38rem;
729
+ font-size: 0.95rem;
730
+ }
731
+
732
+ .cta-card .btn-primary {
733
+ background: #fff;
734
+ color: var(--brand-dark);
735
+ box-shadow: none;
736
+ }
737
+
738
+ .cta-card .btn-primary:hover {
739
+ background: var(--brand-soft);
740
+ }
741
+
742
+ .cta-card .btn-secondary {
743
+ background: transparent;
744
+ color: #fff;
745
+ border-color: rgba(255, 255, 255, 0.25);
746
+ }
747
+
748
+ .cta-card .btn-secondary:hover {
749
+ background: rgba(255, 255, 255, 0.08);
750
+ }
751
+
752
+ .site-footer {
753
+ border-top: 1px solid var(--border);
754
+ padding: 1.25rem 0 2rem;
755
+ background: #fff;
756
+ }
757
+
758
+ .footer-inner {
759
+ display: flex;
760
+ flex-wrap: wrap;
761
+ align-items: center;
762
+ justify-content: space-between;
763
+ gap: 0.75rem;
764
+ }
765
+
766
+ .footer-brand {
767
+ display: flex;
768
+ align-items: center;
769
+ gap: 0.55rem;
770
+ color: var(--text-secondary);
771
+ font-size: 0.88rem;
772
+ font-weight: 500;
773
+ }
774
+
775
+ .footer-note {
776
+ margin: 0;
777
+ color: var(--text-muted);
778
+ font-size: 0.84rem;
779
+ }
780
+
781
+ @media (max-width: 960px) {
782
+ .hero-grid,
783
+ .feature-grid,
784
+ .usecase-grid,
785
+ .canvas-grid,
786
+ .performance-notes {
787
+ grid-template-columns: 1fr;
788
+ }
789
+
790
+ .compare-grid {
791
+ grid-template-columns: 1fr;
792
+ }
793
+
794
+ .compare-arrow {
795
+ margin: 0 auto;
796
+ transform: rotate(90deg);
797
+ }
798
+
799
+ .cta-card {
800
+ grid-template-columns: 1fr;
801
+ text-align: center;
802
+ }
803
+
804
+ .cta-actions {
805
+ justify-content: center;
806
+ }
807
+
808
+ .nav a:not(.nav-cta) {
809
+ display: none;
810
+ }
811
+ }
812
+
813
+ @media (max-width: 560px) {
814
+ .hero {
815
+ padding-top: 3rem;
816
+ }
817
+
818
+ .hero-stats {
819
+ grid-template-columns: 1fr;
820
+ }
821
+
822
+ .demo-toolbar {
823
+ flex-direction: column;
824
+ align-items: flex-start;
825
+ }
826
+ }
weights.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:695ac2b54dc6bbc9c2440c8e802befba096b54497e29017e69b9a0de4dc634b5
3
+ size 9806770