Spaces:
Running
Running
Commit ·
9282073
0
Parent(s):
Add Innomium Ember static fire detection Space.
Browse filesBrowser ONNX demo with fire, smoke, and extinguisher detection plus Python FireDetector.
Co-authored-by: Cursor <cursoragent@cursor.com>
- .gitattributes +36 -0
- .gitignore +3 -0
- README.md +164 -0
- app.py +1118 -0
- detector.js +642 -0
- example_input.png +3 -0
- example_output.png +3 -0
- index.html +361 -0
- innomium_icon.svg +8 -0
- main.js +198 -0
- requirements.txt +4 -0
- style.css +826 -0
- 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 |
+
[](https://huggingface.co/spaces/innomium/fire-detection)
|
| 23 |
+
[](./weights.onnx)
|
| 24 |
+
[](#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
|
example_output.png
ADDED
|
Git LFS Details
|
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 & 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 & 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 & 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 & 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 & 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
|