Mateo commited on
Commit
acc1fa9
·
verified ·
1 Parent(s): 5bd4325

Sync from temporal-model/demo

Browse files
Files changed (13) hide show
  1. .dockerignore +0 -12
  2. .gitignore +1 -4
  3. Dockerfile +0 -24
  4. Makefile +17 -15
  5. README.md +45 -34
  6. app.py +272 -1309
  7. docker-compose.yml +0 -60
  8. images_to_video.py +0 -100
  9. packages.txt +2 -1
  10. requirements.txt +2 -6
  11. test_app.py +66 -0
  12. utils.py +0 -116
  13. vision.py +0 -306
.dockerignore DELETED
@@ -1,12 +0,0 @@
1
- .git
2
- .gitignore
3
- .venv
4
- __pycache__/
5
- .ipynb_checkpoints/
6
- *.py[cod]
7
- *.ipynb
8
- .DS_Store
9
- debug_frames/
10
- force_06/
11
- out*.mp4
12
- output.png
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -1,5 +1,2 @@
1
- data/
2
- *.mp4
3
  __pycache__/
4
- *.ipynb
5
- acme.json
 
1
+ .venv/
 
2
  __pycache__/
 
 
Dockerfile DELETED
@@ -1,24 +0,0 @@
1
- FROM python:3.11-slim
2
-
3
- ENV PYTHONDONTWRITEBYTECODE=1 \
4
- PYTHONUNBUFFERED=1 \
5
- PIP_NO_CACHE_DIR=1 \
6
- STREAMLIT_SERVER_ADDRESS=0.0.0.0 \
7
- STREAMLIT_SERVER_PORT=7860 \
8
- STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
9
-
10
- WORKDIR /app
11
-
12
- RUN apt-get update && apt-get install -y --no-install-recommends \
13
- ffmpeg \
14
- libgomp1 \
15
- && rm -rf /var/lib/apt/lists/*
16
-
17
- COPY requirements.txt .
18
- RUN pip install --upgrade pip && pip install -r requirements.txt
19
-
20
- COPY . .
21
-
22
- EXPOSE 7860
23
-
24
- CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=7860"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Makefile CHANGED
@@ -1,21 +1,23 @@
1
- COMPOSE := docker compose
 
 
2
 
3
- .PHONY: run run-bg stop down logs build ps
4
 
5
- run:
6
- $(COMPOSE) up --build -d
 
7
 
8
- stop:
9
- $(COMPOSE) stop
10
 
11
- down:
12
- $(COMPOSE) down
13
 
14
- logs:
15
- $(COMPOSE) logs -f app
16
 
17
- build:
18
- $(COMPOSE) build
19
-
20
- ps:
21
- $(COMPOSE) ps
 
1
+ # This folder is a mirror of the HuggingFace Space, so it is shaped like one
2
+ # (app.py + requirements.txt + README.md), not like the repo's uv packages.
3
+ SPACE ?= pyronear/Pyronear-Wildfire-Detection
4
 
5
+ .PHONY: install run lint format push
6
 
7
+ install: ## build .venv from requirements.txt (pulls torch — large)
8
+ uv venv --python 3.12
9
+ uv pip install --python .venv -r requirements.txt
10
 
11
+ run: ## serve the demo at http://localhost:7860
12
+ .venv/bin/python app.py
13
 
14
+ lint: ## ruff check
15
+ uv run --project ../core ruff check .
16
 
17
+ format: ## ruff format
18
+ uv run --project ../core ruff format .
19
 
20
+ push: ## mirror this folder to the Space (needs `hf auth login` or HF_TOKEN)
21
+ uvx --from huggingface_hub hf upload $(SPACE) . . \
22
+ --repo-type space --exclude ".venv/*" --delete "*" \
23
+ --commit-message "Sync from temporal-model/demo"
 
README.md CHANGED
@@ -1,55 +1,66 @@
1
  ---
2
  title: Pyronear Wildfire Detection
3
- emoji: 🏆
4
  colorFrom: blue
5
  colorTo: pink
6
- sdk: streamlit
7
- python_version: 3.11
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
 
 
 
11
  ---
12
 
13
- # MP4 to 8 Frames + Wildfire Detection for Pyronear
14
 
15
- Upload an MP4, extract evenly spaced frames, run wildfire detection on each,
16
- and display the main detections (one image per main detection).
 
17
 
18
- ## Requirements
19
- - Python 3.9+
20
- - Packages listed in `requirements.txt`
 
21
 
22
- ## Install
23
- ```bash
24
- python -m venv .venv
25
- source .venv/bin/activate
26
- pip install -r requirements.txt
27
- ```
28
 
29
- ## Run
30
- ```bash
31
- streamlit run app.py
32
- ```
 
 
33
 
34
- Streamlit will print a local URL (for example, `http://localhost:8501`). Open it
35
- in your browser and upload an MP4. Detection starts automatically after upload.
36
 
37
- ## Docker Compose + Make
38
- Run with:
39
  ```bash
40
- make run
 
41
  ```
42
 
43
- Other commands:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  ```bash
45
- make logs
46
- make stop
47
- make down
48
  ```
49
 
50
- Then open `http://127.0.0.1:7860` in your browser.
51
-
52
- ## Notes
53
- - The first run downloads the wildfire detection model from Hugging Face.
54
- - `ffmpeg`/`ffprobe` are required for frame extraction.
55
- - OpenCV is used for motion features and image processing.
 
1
  ---
2
  title: Pyronear Wildfire Detection
3
+ emoji: 🔥
4
  colorFrom: blue
5
  colorTo: pink
6
+ sdk: gradio
7
+ python_version: "3.12"
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
+ models:
12
+ - pyronear/yolov11s
13
+ - pyronear/temporal-model
14
  ---
15
 
16
+ # Pyronear — smoke detection demo
17
 
18
+ Gradio Space to try the two Pyronear wildfire-smoke models side by side. Source
19
+ lives in [`pyronear/temporal-model`](https://github.com/pyronear/temporal-model)
20
+ under `demo/`; the Space is a mirror of that folder.
21
 
22
+ | Tab | Model | Input | Output |
23
+ |---|---|---|---|
24
+ | Single frame — detection | [`pyronear/yolov11s`](https://huggingface.co/pyronear/yolov11s) `v8.2.0` | one image | boxes + confidences |
25
+ | Sequence — temporal model | [`pyronear/temporal-model`](https://huggingface.co/pyronear/temporal-model) `v0.4.0` | ordered frames, or a video | smoke / no-smoke, per-tube probabilities, trigger frame |
26
 
27
+ Both tabs run the detector at the pipeline's production settings
28
+ (`conf=0.1`, `iou=0.2`, `imgsz=1024`). The temporal `model.zip` bundles its own
29
+ pinned YOLO, so tab 2's boxes come from that one — they can differ from tab 1's.
 
 
 
30
 
31
+ Frames of a sequence are ordered **by filename**, the production convention
32
+ (`<prefix>_<YYYY-MM-DDTHH-MM-SS>.jpg`). A video is sampled into evenly spaced
33
+ frames instead.
34
+
35
+ The trigger frame (earliest frame the model would have fired on) is off by
36
+ default: it re-scores growing prefixes of each tube, which is slow on CPU.
37
 
38
+ ## Run locally
 
39
 
 
 
40
  ```bash
41
+ make install
42
+ make run # http://localhost:7860
43
  ```
44
 
45
+ `make install` builds `.venv` from `requirements.txt`, which pulls
46
+ `temporal-model-core[torch]` (torch, timm, ultralytics) — the first run is a
47
+ large download. Model weights are fetched from the Hub on first use and cached.
48
+
49
+ Override the pinned models with `DETECTOR_REPO` / `DETECTOR_REVISION` /
50
+ `TEMPORAL_REPO` / `TEMPORAL_REVISION`.
51
+
52
+ ## Examples
53
+
54
+ Drop a folder of frames in `demo/examples/<name>/` and it shows up as a
55
+ one-click example in the sequence tab. Committed examples are mirrored to the
56
+ Space; none are bundled today.
57
+
58
+ ## Deploy
59
+
60
  ```bash
61
+ hf auth login # once, or set HF_TOKEN
62
+ make push
 
63
  ```
64
 
65
+ Mirrors this folder to the Space, deleting anything there that is no longer
66
+ here.
 
 
 
 
app.py CHANGED
@@ -1,1359 +1,322 @@
1
- import logging
2
- import os
3
- import shutil
4
- import subprocess
5
- import tempfile
6
- import time
7
- from hashlib import sha1
8
- from collections import deque
9
- from contextlib import contextmanager
10
-
11
- import cv2
12
- import numpy as np
13
- import streamlit as st
14
- from PIL import Image, ImageDraw
15
-
16
- from vision import Classifier
17
- from utils import box_iou, nms
18
-
19
-
20
- LOGGER = logging.getLogger(__name__)
21
-
22
-
23
- PYRONEAR_LOGO_URL = (
24
- "https://raw.githubusercontent.com/pyronear/pyro-engine/develop/docs/source/_static/img/pyronear-logo-dark.png"
25
- )
26
-
27
-
28
- DEFAULT_SPLIT_CFG = {
29
- "n_samples": 16,
30
- "max_w": 400,
31
- "crop_y": (0.25, 0.90),
32
- "dx_threshold_px": 1.5,
33
- "min_inlier_ratio": 0.20,
34
- "min_stable_frames": 2,
35
- "smooth_window": 2,
36
- "orb_nfeatures": 800,
37
- "orb_fast_threshold": 12,
38
- "min_matches": 25,
39
- "keep_ratio": 0.4,
40
- "jump_meanabs_threshold": 18.0,
41
- "progress_every": 0,
42
- }
43
- ENABLE_MOTION_SEGMENTATION = os.getenv("ENABLE_MOTION_SEGMENTATION", "0").strip().lower() in {
44
- "1",
45
- "true",
46
- "yes",
47
- "on",
48
- }
49
- FAST_N_SAMPLES = max(1, int(os.getenv("FAST_N_SAMPLES", "12")))
50
- INFER_BATCH_SIZE = max(1, int(os.getenv("INFER_BATCH_SIZE", "16")))
51
- MODEL_IMGSZ = max(320, int(os.getenv("MODEL_IMGSZ", "1024")))
52
- MAX_INFER_FRAMES_PER_SPLIT = max(0, int(os.getenv("MAX_INFER_FRAMES_PER_SPLIT", "12")))
53
- MIN_MAIN_MATCH_ABS = max(1, int(os.getenv("MIN_MAIN_MATCH_ABS", "3")))
54
- MIN_MAIN_MATCH_RATIO = float(os.getenv("MIN_MAIN_MATCH_RATIO", "0.20"))
55
- MAIN_DET_MATCH_IOU_THRESHOLD = float(os.getenv("MAIN_DET_MATCH_IOU_THRESHOLD", "0.12"))
56
- MIN_COMBINED_MEDIAN_CONF = float(os.getenv("MIN_COMBINED_MEDIAN_CONF", "0.12"))
57
- DISPLAY_DET_MATCH_IOU_THRESHOLD = float(os.getenv("DISPLAY_DET_MATCH_IOU_THRESHOLD", "0.0"))
58
-
59
-
60
- def _log_timing_summary(label, stats, wall_time=None, max_items=12):
61
- if not stats:
62
- LOGGER.info("%s timing | no data", label)
63
- return
64
-
65
- entries = sorted(
66
- ((name, float(value)) for name, value in stats.items() if value is not None),
67
- key=lambda item: item[1],
68
- reverse=True,
69
- )
70
- if wall_time is None:
71
- wall_time = stats.get("wall")
72
-
73
- step_entries = [(name, sec) for name, sec in entries if name != "wall"]
74
- parts = []
75
- if wall_time is not None:
76
- parts.append(f"wall={float(wall_time):.3f}s")
77
- for name, sec in step_entries[:max_items]:
78
- if wall_time and wall_time > 0:
79
- parts.append(f"{name}={sec:.3f}s ({(100.0 * sec / float(wall_time)):.1f}%)")
80
- else:
81
- parts.append(f"{name}={sec:.3f}s")
82
- remaining = max(0, len(step_entries) - max_items)
83
- if remaining:
84
- parts.append(f"+{remaining} more")
85
-
86
- LOGGER.info("%s timing | %s", label, " | ".join(parts))
87
-
88
-
89
- def _sample_indices(total, n):
90
- if total <= 0:
91
- return []
92
- if total <= n:
93
- return list(range(total))
94
- return np.linspace(0, total - 1, n).astype(int).tolist()
95
-
96
-
97
- def _format_idx_list(indices, max_items=40):
98
- if not indices:
99
- return "[]"
100
- values = [int(i) for i in indices]
101
- if len(values) <= max_items:
102
- return str(values)
103
- head = values[: max_items // 2]
104
- tail = values[-(max_items // 2) :]
105
- return f"{head} ... {tail} (len={len(values)})"
106
-
107
-
108
- def _sample_uniform_items(items, n):
109
- n = max(1, int(n))
110
- if len(items) <= n:
111
- return items
112
- indices = np.linspace(0, len(items) - 1, n).astype(int).tolist()
113
- return [items[i] for i in indices]
114
-
115
-
116
- def _parse_fraction(value):
117
- if not value:
118
- return None
119
- txt = str(value).strip()
120
- if not txt or txt == "0/0":
121
- return None
122
- if "/" in txt:
123
- num, den = txt.split("/", 1)
124
- try:
125
- den_f = float(den)
126
- if den_f == 0:
127
- return None
128
- return float(num) / den_f
129
- except Exception:
130
- return None
131
- try:
132
- return float(txt)
133
- except Exception:
134
- return None
135
-
136
-
137
- def _probe_total_frames_ffprobe(video_path):
138
- ffprobe = shutil.which("ffprobe")
139
- if ffprobe is None:
140
- return None
141
-
142
- timing = {}
143
- wall_t0 = time.perf_counter()
144
- video_name = os.path.basename(video_path)
145
-
146
- # Try direct frame count first.
147
- cmd = [
148
- ffprobe,
149
- "-v",
150
- "error",
151
- "-select_streams",
152
- "v:0",
153
- "-show_entries",
154
- "stream=nb_frames",
155
- "-of",
156
- "default=noprint_wrappers=1:nokey=1",
157
- video_path,
158
- ]
159
- with timer("ffprobe_nb_frames", timing):
160
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
161
- if proc.returncode == 0:
162
- raw = proc.stdout.strip()
163
- if raw.isdigit():
164
- val = int(raw)
165
- if val > 0:
166
- timing["wall"] = time.perf_counter() - wall_t0
167
- _log_timing_summary(f"ffprobe ({video_name})", timing, wall_time=timing["wall"])
168
- return val
169
-
170
- # Fallback: estimate from duration * avg frame rate.
171
- cmd = [
172
- ffprobe,
173
- "-v",
174
- "error",
175
- "-select_streams",
176
- "v:0",
177
- "-show_entries",
178
- "stream=avg_frame_rate,duration",
179
- "-of",
180
- "default=noprint_wrappers=1:nokey=1",
181
- video_path,
182
- ]
183
- with timer("ffprobe_fps_duration", timing):
184
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
185
- if proc.returncode != 0:
186
- timing["wall"] = time.perf_counter() - wall_t0
187
- _log_timing_summary(f"ffprobe ({video_name})", timing, wall_time=timing["wall"])
188
- return None
189
-
190
- lines = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
191
- if len(lines) < 2:
192
- timing["wall"] = time.perf_counter() - wall_t0
193
- _log_timing_summary(f"ffprobe ({video_name})", timing, wall_time=timing["wall"])
194
- return None
195
 
196
- fps = _parse_fraction(lines[0])
197
- duration = _parse_fraction(lines[1])
198
- if fps is None or duration is None:
199
- timing["wall"] = time.perf_counter() - wall_t0
200
- _log_timing_summary(f"ffprobe ({video_name})", timing, wall_time=timing["wall"])
201
- return None
202
-
203
- estimate = int(round(fps * duration))
204
- timing["wall"] = time.perf_counter() - wall_t0
205
- _log_timing_summary(f"ffprobe ({video_name})", timing, wall_time=timing["wall"])
206
- return estimate if estimate > 0 else None
207
 
 
 
 
 
 
 
208
 
209
- def _probe_duration_ffprobe(video_path):
210
- ffprobe = shutil.which("ffprobe")
211
- if ffprobe is None:
212
- return None
213
-
214
- cmd = [
215
- ffprobe,
216
- "-v",
217
- "error",
218
- "-show_entries",
219
- "format=duration",
220
- "-of",
221
- "default=noprint_wrappers=1:nokey=1",
222
- video_path,
223
- ]
224
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
225
- if proc.returncode != 0:
226
- return None
227
-
228
- lines = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
229
- if not lines:
230
- return None
231
- duration = _parse_fraction(lines[0])
232
- if duration is None or duration <= 0:
233
- return None
234
- return float(duration)
235
-
236
-
237
- def _probe_video_size_ffprobe(video_path):
238
- ffprobe = shutil.which("ffprobe")
239
- if ffprobe is None:
240
- return None
241
-
242
- cmd = [
243
- ffprobe,
244
- "-v",
245
- "error",
246
- "-select_streams",
247
- "v:0",
248
- "-show_entries",
249
- "stream=width,height",
250
- "-of",
251
- "csv=p=0:s=x",
252
- video_path,
253
- ]
254
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
255
- if proc.returncode != 0:
256
- return None
257
-
258
- line = next((txt.strip() for txt in proc.stdout.splitlines() if txt.strip()), "")
259
- if "x" not in line:
260
- return None
261
- left, right = line.split("x", 1)
262
- if not left.isdigit() or not right.isdigit():
263
- return None
264
-
265
- width, height = int(left), int(right)
266
- if width <= 0 or height <= 0:
267
- return None
268
- return width, height
269
-
270
-
271
- def _extract_bgr_with_ffmpeg_disk(video_path, n):
272
- ffmpeg = shutil.which("ffmpeg")
273
- if ffmpeg is None:
274
- raise RuntimeError("ffmpeg is not available")
275
-
276
- timing = {}
277
- wall_t0 = time.perf_counter()
278
- video_name = os.path.basename(video_path)
279
-
280
- with timer("probe_total_frames", timing):
281
- total = _probe_total_frames_ffprobe(video_path)
282
- if total is None or total <= 0:
283
- timing["wall"] = time.perf_counter() - wall_t0
284
- _log_timing_summary(f"Frame extraction ({video_name})", timing, wall_time=timing["wall"])
285
- raise RuntimeError("ffprobe could not determine total frame count")
286
-
287
- with timer("sample_indices", timing):
288
- indices = _sample_indices(total, int(n))
289
- if not indices:
290
- timing["wall"] = time.perf_counter() - wall_t0
291
- _log_timing_summary(f"Frame extraction ({video_name})", timing, wall_time=timing["wall"])
292
- return []
293
-
294
- LOGGER.info(
295
- "Frame extraction | video=%s total_frames=%d n_samples=%d sampled_indices=%s",
296
- os.path.basename(video_path),
297
- total,
298
- len(indices),
299
- _format_idx_list(indices),
300
- )
301
-
302
- select_expr = "+".join(f"eq(n\\,{int(i)})" for i in indices)
303
- vf = f"select={select_expr}"
304
-
305
- with tempfile.TemporaryDirectory(prefix="ffmpeg_frames_") as tmpdir:
306
- pattern = os.path.join(tmpdir, "frame_%06d.jpg")
307
- cmd = [
308
- ffmpeg,
309
- "-hide_banner",
310
- "-loglevel",
311
- "error",
312
- "-i",
313
- video_path,
314
- "-vf",
315
- vf,
316
- "-vsync",
317
- "vfr",
318
- "-q:v",
319
- "2",
320
- pattern,
321
- ]
322
- with timer("ffmpeg_extract", timing):
323
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
324
- if proc.returncode != 0:
325
- timing["wall"] = time.perf_counter() - wall_t0
326
- _log_timing_summary(f"Frame extraction ({video_name})", timing, wall_time=timing["wall"])
327
- raise RuntimeError(proc.stderr.strip() or "ffmpeg extraction failed")
328
-
329
- frames = []
330
- with timer("read_extracted_images", timing):
331
- for name in sorted(os.listdir(tmpdir)):
332
- if not name.lower().endswith(".jpg"):
333
- continue
334
- frame = cv2.imread(os.path.join(tmpdir, name), cv2.IMREAD_COLOR)
335
- if frame is not None:
336
- frames.append(frame)
337
- LOGGER.info(
338
- "Frame extraction done | video=%s extracted=%d requested=%d",
339
- os.path.basename(video_path),
340
- len(frames),
341
- len(indices),
342
- )
343
- timing["wall"] = time.perf_counter() - wall_t0
344
- _log_timing_summary(f"Frame extraction ({video_name})", timing, wall_time=timing["wall"])
345
- return frames
346
-
347
-
348
- def _extract_bgr_with_ffmpeg(video_path, n):
349
- ffmpeg = shutil.which("ffmpeg")
350
- if ffmpeg is None:
351
- raise RuntimeError("ffmpeg is not available")
352
-
353
- n = max(1, int(n))
354
- timing = {}
355
- wall_t0 = time.perf_counter()
356
- video_name = os.path.basename(video_path)
357
-
358
- with timer("probe_duration", timing):
359
- duration = _probe_duration_ffprobe(video_path)
360
- if duration is None or duration <= 0:
361
- LOGGER.warning("Frame extraction | ffprobe duration unavailable, fallback to disk extraction")
362
- with timer("fallback_disk_extract", timing):
363
- frames = _extract_bgr_with_ffmpeg_disk(video_path, n)
364
- timing["wall"] = time.perf_counter() - wall_t0
365
- _log_timing_summary(f"Frame extraction ({video_name})", timing, wall_time=timing["wall"])
366
- return frames
367
-
368
- with timer("probe_video_size", timing):
369
- video_size = _probe_video_size_ffprobe(video_path)
370
- if video_size is None:
371
- LOGGER.warning("Frame extraction | ffprobe size unavailable, fallback to disk extraction")
372
- with timer("fallback_disk_extract", timing):
373
- frames = _extract_bgr_with_ffmpeg_disk(video_path, n)
374
- timing["wall"] = time.perf_counter() - wall_t0
375
- _log_timing_summary(f"Frame extraction ({video_name})", timing, wall_time=timing["wall"])
376
- return frames
377
-
378
- width, height = video_size
379
- frame_size = int(width) * int(height) * 3
380
- if frame_size <= 0:
381
- LOGGER.warning("Frame extraction | invalid frame size, fallback to disk extraction")
382
- with timer("fallback_disk_extract", timing):
383
- frames = _extract_bgr_with_ffmpeg_disk(video_path, n)
384
- timing["wall"] = time.perf_counter() - wall_t0
385
- _log_timing_summary(f"Frame extraction ({video_name})", timing, wall_time=timing["wall"])
386
- return frames
387
 
388
- sample_fps = max(1e-6, float(n) / float(duration))
 
 
389
 
390
- LOGGER.info(
391
- "Frame extraction (single ffmpeg/rawvideo) | video=%s duration=%.3fs n_samples=%d fps=%.6f size=%dx%d",
392
- video_name,
393
- duration,
394
- n,
395
- sample_fps,
396
- width,
397
- height,
398
- )
399
 
400
- cmd = [
401
- ffmpeg,
402
- "-hide_banner",
403
- "-loglevel",
404
- "error",
405
- "-i",
406
- video_path,
407
- "-vf",
408
- f"fps={sample_fps:.8f}",
409
- "-frames:v",
410
- str(n),
411
- "-f",
412
- "rawvideo",
413
- "-pix_fmt",
414
- "bgr24",
415
- "-",
416
- ]
417
- with timer("ffmpeg_extract_rawvideo", timing):
418
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
419
- if proc.returncode != 0 or not proc.stdout:
420
- LOGGER.warning(
421
- "Frame extraction rawvideo failed | video=%s err=%s",
422
- video_name,
423
- (proc.stderr.decode("utf-8", errors="ignore").strip() if proc.stderr else "no stderr"),
424
- )
425
- with timer("fallback_disk_extract", timing):
426
- frames = _extract_bgr_with_ffmpeg_disk(video_path, n)
427
- timing["wall"] = time.perf_counter() - wall_t0
428
- _log_timing_summary(f"Frame extraction ({video_name})", timing, wall_time=timing["wall"])
429
- return frames
430
 
431
- with timer("decode_rawvideo", timing):
432
- raw = proc.stdout
433
- frame_count = len(raw) // frame_size
434
- usable_bytes = frame_count * frame_size
435
- if frame_count > 0 and usable_bytes:
436
- arr = np.frombuffer(raw[:usable_bytes], dtype=np.uint8).reshape(frame_count, height, width, 3)
437
- frames = [arr[idx].copy() for idx in range(frame_count)]
438
- else:
439
- frames = []
440
- if len(frames) > n:
441
- frames = _sample_uniform_items(frames, n)
442
 
443
- if not frames:
444
- LOGGER.warning("Frame extraction | rawvideo mode returned 0 frame, fallback to disk extraction")
445
- with timer("fallback_disk_extract", timing):
446
- frames = _extract_bgr_with_ffmpeg_disk(video_path, n)
 
 
 
 
 
 
 
 
 
447
 
448
- LOGGER.info(
449
- "Frame extraction done | video=%s extracted=%d requested=%d",
450
- video_name,
451
- len(frames),
452
- n,
453
- )
454
- timing["wall"] = time.perf_counter() - wall_t0
455
- _log_timing_summary(f"Frame extraction ({video_name})", timing, wall_time=timing["wall"])
456
- return frames
457
 
458
 
459
- def _extract_with_ffmpeg(video_path, n):
460
- timing = {}
461
- wall_t0 = time.perf_counter()
462
- with timer("extract_bgr", timing):
463
- frames = _extract_bgr_with_ffmpeg(video_path, n)
464
- with timer("bgr_to_pil", timing):
465
- pil_frames = [Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) for frame in frames]
466
- timing["wall"] = time.perf_counter() - wall_t0
467
- _log_timing_summary("Extract with ffmpeg", timing, wall_time=timing["wall"])
468
- return pil_frames
469
 
470
-
471
- def split_video(video_path, n=8):
472
- if not video_path or not os.path.exists(video_path):
473
- return []
474
- timing = {}
475
- wall_t0 = time.perf_counter()
476
- with timer("extract_with_ffmpeg", timing):
477
- frames = _extract_with_ffmpeg(video_path, n)
478
- timing["wall"] = time.perf_counter() - wall_t0
479
- _log_timing_summary("split_video", timing, wall_time=timing["wall"])
480
- return frames
481
 
482
 
483
- @contextmanager
484
- def timer(name, stats):
485
- t0 = time.perf_counter()
486
- yield
487
- stats[name] = stats.get(name, 0.0) + (time.perf_counter() - t0)
488
 
 
 
489
 
490
- def _iter_sampled_frames(video_path, n_samples, sampled_frames=None):
491
- timing = {}
492
- wall_t0 = time.perf_counter()
493
- if sampled_frames is None:
494
- with timer("extract_bgr_with_ffmpeg", timing):
495
- frames = _extract_bgr_with_ffmpeg(video_path, int(n_samples))
496
- else:
497
- with timer("reuse_sampled_frames", timing):
498
- frames = sampled_frames
499
- timing["wall"] = time.perf_counter() - wall_t0
500
- _log_timing_summary("Iter sampled frames", timing, wall_time=timing["wall"])
501
- for out_idx, frame in enumerate(frames):
502
- yield out_idx, frame
503
 
 
 
 
504
 
505
- def iter_frames(video_path, n_samples, max_w, crop_y, sampled_frames=None):
506
- timing = {"resize": 0.0, "crop": 0.0}
507
- wall_t0 = time.perf_counter()
508
- frame_count = 0
509
  try:
510
- for out_idx, frame in _iter_sampled_frames(video_path, n_samples, sampled_frames=sampled_frames):
511
- frame_count += 1
512
- proc = frame
513
- if max_w > 0 and proc.shape[1] != max_w:
514
- t_resize = time.perf_counter()
515
- scale = max_w / float(proc.shape[1])
516
- proc = cv2.resize(
517
- proc,
518
- (max_w, int(proc.shape[0] * scale)),
519
- interpolation=cv2.INTER_AREA,
520
- )
521
- timing["resize"] += time.perf_counter() - t_resize
522
-
523
- if crop_y is not None:
524
- t_crop = time.perf_counter()
525
- h = proc.shape[0]
526
- y0 = int(max(0.0, min(1.0, float(crop_y[0]))) * h)
527
- y1 = int(max(0.0, min(1.0, float(crop_y[1]))) * h)
528
- if y1 > y0:
529
- proc = proc[y0:y1, :]
530
- timing["crop"] += time.perf_counter() - t_crop
531
-
532
- yield out_idx, proc
533
  finally:
534
- timing["wall"] = time.perf_counter() - wall_t0
535
- LOGGER.info(
536
- "iter_frames summary | n_samples=%d yielded=%d max_w=%d crop_y=%s",
537
- int(n_samples),
538
- frame_count,
539
- int(max_w),
540
- crop_y,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  )
542
- _log_timing_summary("iter_frames", timing, wall_time=timing["wall"])
543
-
544
-
545
- def quick_jump_score(prev_gray, gray, small_w=160):
546
- h, w = prev_gray.shape[:2]
547
- if w > small_w:
548
- scale = small_w / float(w)
549
- prev_s = cv2.resize(prev_gray, (small_w, int(h * scale)), interpolation=cv2.INTER_AREA)
550
- gray_s = cv2.resize(gray, (small_w, int(h * scale)), interpolation=cv2.INTER_AREA)
551
- else:
552
- prev_s = prev_gray
553
- gray_s = gray
554
-
555
- diff = cv2.absdiff(prev_s, gray_s)
556
- return float(np.mean(diff))
557
 
558
 
559
- def estimate_dx_orb_affine(prev_gray, gray, orb, bf, min_matches, keep_ratio, timing_pair):
560
- with timer("orb_detect_compute", timing_pair):
561
- kp1, des1 = orb.detectAndCompute(prev_gray, None)
562
- kp2, des2 = orb.detectAndCompute(gray, None)
563
 
564
- if des1 is None or des2 is None or len(kp1) < 8 or len(kp2) < 8:
 
 
 
565
  return None
566
-
567
- with timer("bf_match", timing_pair):
568
- matches = bf.match(des1, des2)
569
-
570
- if len(matches) < min_matches:
571
- return None
572
-
573
- with timer("match_sort_filter", timing_pair):
574
- matches = sorted(matches, key=lambda m: m.distance)
575
- keep_n = max(8, int(len(matches) * keep_ratio))
576
- matches = matches[:keep_n]
577
-
578
- pts1 = np.float32([kp1[m.queryIdx].pt for m in matches])
579
- pts2 = np.float32([kp2[m.trainIdx].pt for m in matches])
580
-
581
- with timer("ransac_affine", timing_pair):
582
- M, inliers = cv2.estimateAffinePartial2D(
583
- pts1,
584
- pts2,
585
- method=cv2.RANSAC,
586
- ransacReprojThreshold=3.0,
587
- maxIters=1500,
588
- confidence=0.99,
589
  )
590
-
591
- if M is None:
592
- return None
593
-
594
- dx = float(M[0, 2])
595
- dy = float(M[1, 2])
596
- inlier_ratio = float(np.mean(inliers)) if inliers is not None else 0.0
597
-
598
- return {
599
- "dx": dx,
600
- "dy": dy,
601
- "score_dx": float(abs(dx)),
602
- "score_px": float(np.hypot(dx, dy)),
603
- "inlier_ratio": inlier_ratio,
604
- "matches": len(matches),
605
- "M": M,
606
- }
607
 
608
 
609
- def split_video_into_stable_segments_fast(
610
- video_path,
611
- n_samples=16,
612
- max_w=400,
613
- crop_y=(0.25, 0.90),
614
- dx_threshold_px=1.5,
615
- min_inlier_ratio=0.20,
616
- min_stable_frames=2,
617
- smooth_window=2,
618
- orb_nfeatures=800,
619
- orb_fast_threshold=12,
620
- min_matches=25,
621
- keep_ratio=0.4,
622
- jump_meanabs_threshold=18.0,
623
- progress_every=200,
624
- sampled_frames=None,
625
  ):
626
- wall_t0 = time.perf_counter()
627
- timing_total = {}
628
- timing_pair = {}
629
-
630
- with timer("setup", timing_total):
631
- orb = cv2.ORB_create(nfeatures=orb_nfeatures, fastThreshold=orb_fast_threshold)
632
- bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
633
-
634
- metrics = []
635
- prev_gray = None
636
- frame_count = 0
637
-
638
- with timer("loop_total", timing_total):
639
- for _, frame in iter_frames(
640
- video_path,
641
- n_samples=n_samples,
642
- max_w=max_w,
643
- crop_y=crop_y,
644
- sampled_frames=sampled_frames,
645
- ):
646
- frame_count += 1
647
-
648
- with timer("to_gray", timing_total):
649
- gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
650
-
651
- if prev_gray is not None:
652
- with timer("quick_jump", timing_total):
653
- q = quick_jump_score(prev_gray, gray)
654
-
655
- if q >= jump_meanabs_threshold:
656
- metrics.append(
657
- {
658
- "dx": np.nan,
659
- "dy": np.nan,
660
- "score_dx": 1e9,
661
- "score_px": 1e9,
662
- "inlier_ratio": 0.0,
663
- "matches": 0,
664
- "M": None,
665
- "quick_jump": q,
666
- }
667
- )
668
- else:
669
- m = estimate_dx_orb_affine(
670
- prev_gray,
671
- gray,
672
- orb=orb,
673
- bf=bf,
674
- min_matches=min_matches,
675
- keep_ratio=keep_ratio,
676
- timing_pair=timing_pair,
677
- )
678
- if m is None:
679
- metrics.append(
680
- {
681
- "dx": np.nan,
682
- "dy": np.nan,
683
- "score_dx": 1e9,
684
- "score_px": 1e9,
685
- "inlier_ratio": 0.0,
686
- "matches": 0,
687
- "M": None,
688
- "quick_jump": q,
689
- }
690
- )
691
- else:
692
- m["quick_jump"] = q
693
- metrics.append(m)
694
-
695
- if progress_every and (len(metrics) % progress_every == 0):
696
- print(f"processed pairs: {len(metrics)}")
697
-
698
- prev_gray = gray
699
-
700
- if frame_count < 2:
701
- timing_total["wall"] = time.perf_counter() - wall_t0
702
- _log_timing_summary("Segmentation total", timing_total, wall_time=timing_total["wall"])
703
- if timing_pair:
704
- _log_timing_summary(
705
- "Segmentation pair internals",
706
- timing_pair,
707
- wall_time=max(timing_total.get("loop_total", 0.0), 1e-9),
708
- )
709
- return [], metrics, [], {"total": timing_total, "per_pair": timing_pair}
710
-
711
- with timer("post_smooth", timing_total):
712
- raw_dx = [m["score_dx"] for m in metrics]
713
- raw_inlier = [m["inlier_ratio"] for m in metrics]
714
-
715
- smoothed_dx = []
716
- q = deque(maxlen=max(1, int(smooth_window)))
717
- for v in raw_dx:
718
- if not np.isfinite(v):
719
- q.clear()
720
- smoothed_dx.append(np.nan)
721
- else:
722
- q.append(v)
723
- smoothed_dx.append(float(np.mean(q)))
724
-
725
- with timer("post_segments", timing_total):
726
- min_len = max(1, int(min_stable_frames))
727
-
728
- stable_flags = []
729
- for dx_s, r in zip(smoothed_dx, raw_inlier):
730
- if not np.isfinite(dx_s):
731
- stable_flags.append(False)
732
- else:
733
- stable_flags.append((dx_s < dx_threshold_px) and (r >= min_inlier_ratio))
734
-
735
- segments = []
736
- start = None
737
- for i, is_stable in enumerate(stable_flags):
738
- if is_stable and start is None:
739
- start = i
740
- if (not is_stable) and start is not None:
741
- end = i
742
- if (end - start) >= min_len:
743
- segments.append((start, end))
744
- start = None
745
-
746
- if start is not None:
747
- end = len(stable_flags)
748
- if (end - start) >= min_len:
749
- segments.append((start, end))
750
-
751
- LOGGER.info(
752
- "Segmentation summary | sampled_frames=%d pair_metrics=%d stable_segments=%d",
753
- frame_count,
754
- len(metrics),
755
- len(segments),
756
- )
757
- if segments:
758
- LOGGER.info("Segment ranges (sample indices) | %s", segments)
759
- timing_total["wall"] = time.perf_counter() - wall_t0
760
- _log_timing_summary("Segmentation total", timing_total, wall_time=timing_total["wall"])
761
- if timing_pair:
762
- _log_timing_summary(
763
- "Segmentation pair internals",
764
- timing_pair,
765
- wall_time=max(timing_total.get("loop_total", 0.0), 1e-9),
766
- )
767
-
768
- return segments, metrics, smoothed_dx, {"total": timing_total, "per_pair": timing_pair}
769
-
770
-
771
- def _bgr_to_pil(frame):
772
- return Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
773
-
774
-
775
- def extract_segment_frames(video_path, segments, n_samples, sampled_frames=None):
776
- timing = {}
777
- wall_t0 = time.perf_counter()
778
-
779
- if not segments:
780
- LOGGER.info("Segment frame extraction | no segments found")
781
- timing["wall"] = time.perf_counter() - wall_t0
782
- _log_timing_summary("Segment frame extraction", timing, wall_time=timing["wall"])
783
- return []
784
-
785
- with timer("normalize_segments", timing):
786
- normalized_segments = []
787
- for start, end in segments:
788
- s = max(0, int(start))
789
- e = max(s, int(end))
790
- normalized_segments.append((s, e))
791
-
792
- with timer("prepare_groups", timing):
793
- normalized_segments.sort(key=lambda x: x[0])
794
- grouped_frames = [[] for _ in normalized_segments]
795
- grouped_indices = [[] for _ in normalized_segments]
796
- segment_idx = 0
797
-
798
- # Detection runs on original sampled frames (no resize / no crop).
799
- to_pil_time = 0.0
800
- with timer("assign_frames_to_segments", timing):
801
- for frame_idx, frame in _iter_sampled_frames(video_path, n_samples=n_samples, sampled_frames=sampled_frames):
802
- while segment_idx < len(normalized_segments) and frame_idx > normalized_segments[segment_idx][1]:
803
- segment_idx += 1
804
-
805
- if segment_idx >= len(normalized_segments):
806
- break
807
-
808
- seg_start, seg_end = normalized_segments[segment_idx]
809
- if seg_start <= frame_idx <= seg_end:
810
- t_pil = time.perf_counter()
811
- grouped_frames[segment_idx].append(_bgr_to_pil(frame))
812
- to_pil_time += time.perf_counter() - t_pil
813
- grouped_indices[segment_idx].append(frame_idx)
814
- timing["to_pil"] = to_pil_time
815
-
816
- LOGGER.info(
817
- "Segment frame extraction summary | segments=%d n_samples=%d",
818
- len(normalized_segments),
819
- n_samples,
820
- )
821
- for seg_i, ((seg_start, seg_end), idx_list, frames) in enumerate(
822
- zip(normalized_segments, grouped_indices, grouped_frames),
823
- start=1,
824
- ):
825
- LOGGER.info(
826
- "Segment %d | requested_range=[%d,%d] matched_frames=%d matched_indices=%s",
827
- seg_i,
828
- seg_start,
829
- seg_end,
830
- len(frames),
831
- _format_idx_list(idx_list),
832
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833
 
834
- timing["wall"] = time.perf_counter() - wall_t0
835
- _log_timing_summary("Segment frame extraction", timing, wall_time=timing["wall"])
836
- return [frames for frames in grouped_frames if frames]
837
-
838
-
839
- def split_video_stable(video_path, split_cfg=None, fallback_n=16):
840
- if not video_path or not os.path.exists(video_path):
841
- return []
842
-
843
- timing = {}
844
- wall_t0 = time.perf_counter()
845
- cfg = DEFAULT_SPLIT_CFG.copy()
846
- if split_cfg:
847
- cfg.update(split_cfg)
848
-
849
- LOGGER.info("Split config | %s", cfg)
850
-
851
- with timer("extract_sampled_frames", timing):
852
- sampled_frames = _extract_bgr_with_ffmpeg(video_path, int(cfg["n_samples"]))
853
-
854
- with timer("split_video_into_stable_segments_fast", timing):
855
- segments, _, _, _ = split_video_into_stable_segments_fast(video_path, sampled_frames=sampled_frames, **cfg)
856
- with timer("extract_segment_frames", timing):
857
- frame_groups = extract_segment_frames(
858
- video_path,
859
- segments,
860
- n_samples=cfg["n_samples"],
861
- sampled_frames=sampled_frames,
862
  )
863
-
864
- if frame_groups:
865
- LOGGER.info(
866
- "Split result | stable_splits=%d split_frame_counts=%s",
867
- len(frame_groups),
868
- [len(group) for group in frame_groups],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
869
  )
870
- timing["wall"] = time.perf_counter() - wall_t0
871
- _log_timing_summary("split_video_stable", timing, wall_time=timing["wall"])
872
- return frame_groups
873
-
874
- LOGGER.info("Split result | no stable segment, using fallback sampling n=%d", fallback_n)
875
- if int(fallback_n) == int(cfg["n_samples"]):
876
- with timer("fallback_reuse_sampled_frames", timing):
877
- fallback_frames = [_bgr_to_pil(frame) for frame in sampled_frames]
878
- else:
879
- with timer("fallback_split_video", timing):
880
- fallback_frames = split_video(video_path, n=fallback_n)
881
- LOGGER.info("Fallback frame count | %d", len(fallback_frames))
882
- timing["wall"] = time.perf_counter() - wall_t0
883
- _log_timing_summary("split_video_stable", timing, wall_time=timing["wall"])
884
- return [fallback_frames] if fallback_frames else []
885
-
886
-
887
- @st.cache_resource(show_spinner=False)
888
- def _load_model():
889
- model_t0 = time.perf_counter()
890
- clf = Classifier(format="onnx", conf=0.05, imgsz=MODEL_IMGSZ)
891
- LOGGER.info("Model init timing | wall=%.3fs", time.perf_counter() - model_t0)
892
- LOGGER.info("Model config | imgsz=%d", MODEL_IMGSZ)
893
- return clf
894
-
895
-
896
- model = _load_model()
897
-
898
-
899
- def _resolve_video_path(video_input):
900
- if not video_input:
901
- return None
902
- if isinstance(video_input, str):
903
- return video_input
904
- if isinstance(video_input, dict):
905
- for key in ("name", "path", "data", "video"):
906
- value = video_input.get(key)
907
- if isinstance(value, str) and os.path.exists(value):
908
- return value
909
- if isinstance(video_input, (list, tuple)):
910
- for value in video_input:
911
- if isinstance(value, str) and os.path.exists(value):
912
- return value
913
- return None
914
-
915
-
916
- def _draw_detections(pil_img, preds, subtitle=None):
917
- img = pil_img.copy()
918
- draw = ImageDraw.Draw(img)
919
- width, height = img.size
920
- color = (255, 80, 0)
921
- preds = np.asarray(preds)
922
-
923
- for x1, y1, x2, y2, conf in preds:
924
- x1 = int(max(0.0, min(1.0, float(x1))) * width)
925
- y1 = int(max(0.0, min(1.0, float(y1))) * height)
926
- x2 = int(max(0.0, min(1.0, float(x2))) * width)
927
- y2 = int(max(0.0, min(1.0, float(y2))) * height)
928
- draw.rectangle([x1, y1, x2, y2], outline=color, width=3)
929
- draw.text((x1 + 4, y1 + 4), f"{conf:.2f}", fill=color)
930
-
931
- draw.text((6, 6), f"detections : {len(preds)}", fill=color)
932
- if subtitle:
933
- draw.text((6, 26), subtitle, fill=color)
934
- return img
935
-
936
-
937
- def _combine_predictions_per_split(frame_preds):
938
- n_frames = len(frame_preds)
939
- if n_frames == 0:
940
- return []
941
-
942
- boxes = np.zeros((0, 5), dtype=np.float64)
943
- for bbox in frame_preds:
944
- if bbox.size > 0:
945
- boxes = np.vstack([boxes, bbox])
946
 
947
- if boxes.size == 0:
948
- return []
949
 
950
- main_bboxes = np.asarray(nms(boxes), dtype=np.float64)
951
- if main_bboxes.size == 0:
 
952
  return []
 
 
 
 
 
953
 
954
- n_main = len(main_bboxes)
955
- matches_per_main = np.zeros(n_main, dtype=int)
956
- conf_max_per_main = np.zeros(n_main, dtype=np.float64)
957
- matched_conf_values_per_main = [[] for _ in range(n_main)]
958
- matched_frame_indices_per_main = [[] for _ in range(n_main)]
959
- first_match_frame_idx_per_main = [None for _ in range(n_main)]
960
- first_match_bbox_per_main = [None for _ in range(n_main)]
961
- for frame_idx, bbox in enumerate(frame_preds):
962
- if bbox.size == 0:
963
- continue
964
- ious = box_iou(bbox[:, :4], main_bboxes[:, :4])
965
- match_mask = ious >= MAIN_DET_MATCH_IOU_THRESHOLD
966
- has_match = match_mask.any(axis=1)
967
- matches_per_main += has_match.astype(int)
968
- if np.any(has_match):
969
- # Keep only one bbox per frame for each main bbox (best IoU among matches).
970
- masked_ious = np.where(match_mask, ious, -1.0)
971
- best_idx_per_main = np.argmax(masked_ious, axis=1)
972
- best_conf_per_main = bbox[best_idx_per_main, 4].astype(np.float64)
973
- matched_conf = np.where(has_match, best_conf_per_main, 0.0)
974
- conf_max_per_main = np.maximum(conf_max_per_main, matched_conf)
975
- for main_idx in np.flatnonzero(has_match):
976
- matched_conf_values_per_main[main_idx].append(float(best_conf_per_main[main_idx]))
977
- matched_frame_indices_per_main[main_idx].append(int(frame_idx))
978
- if first_match_frame_idx_per_main[main_idx] is None:
979
- first_match_frame_idx_per_main[main_idx] = int(frame_idx)
980
- first_match_bbox_per_main[main_idx] = np.asarray(
981
- bbox[int(best_idx_per_main[main_idx])], dtype=np.float64
982
- ).copy()
983
-
984
- required_matches = max(MIN_MAIN_MATCH_ABS, int(np.ceil(float(MIN_MAIN_MATCH_RATIO) * n_frames)))
985
- keep_main = matches_per_main >= required_matches
986
- if not np.any(keep_main):
987
- return []
988
 
989
- kept = []
990
- for idx in np.flatnonzero(keep_main):
991
- match_count = int(matches_per_main[idx])
992
- matched_conf_values = matched_conf_values_per_main[idx]
993
- median_conf = (
994
- float(np.median(np.asarray(matched_conf_values, dtype=np.float64))) if matched_conf_values else 0.0
995
- )
996
- if median_conf < MIN_COMBINED_MEDIAN_CONF:
997
- LOGGER.info(
998
- (
999
- "Combine drop candidate | matches=%d/%d (required=%d) | "
1000
- "median_conf=%.2f < min_combined_median_conf=%.2f"
1001
- ),
1002
- match_count,
1003
- n_frames,
1004
- required_matches,
1005
- median_conf,
1006
- MIN_COMBINED_MEDIAN_CONF,
1007
- )
1008
- continue
1009
- kept.append(
1010
- {
1011
- "box": main_bboxes[idx],
1012
- "match_count": match_count,
1013
- "n_frames": int(n_frames),
1014
- "required_matches": int(required_matches),
1015
- "match_ratio": float(match_count / max(n_frames, 1)),
1016
- "median_conf": median_conf,
1017
- "max_conf": float(conf_max_per_main[idx]),
1018
- "matched_conf_values": matched_conf_values,
1019
- "matched_frame_indices": matched_frame_indices_per_main[idx],
1020
- "first_match_frame_idx": first_match_frame_idx_per_main[idx],
1021
- "first_match_bbox": first_match_bbox_per_main[idx],
1022
- }
1023
  )
1024
- return kept
1025
-
1026
 
1027
- def infer(video_file):
1028
- timing = {}
1029
- wall_t0 = time.perf_counter()
1030
-
1031
- with timer("resolve_video_path", timing):
1032
- video_path = _resolve_video_path(video_file)
1033
- LOGGER.info("Inference start | video=%s", video_path)
1034
- LOGGER.info(
1035
- (
1036
- "Inference config | batch_size=%d motion_segmentation=%s fast_n_samples=%d "
1037
- "max_infer_frames_per_split=%d min_main_match_abs=%d min_main_match_ratio=%.2f "
1038
- "main_det_match_iou_threshold=%.2f min_combined_median_conf=%.2f "
1039
- "display_det_match_iou_threshold=%.2f"
1040
- ),
1041
- INFER_BATCH_SIZE,
1042
- ENABLE_MOTION_SEGMENTATION,
1043
- FAST_N_SAMPLES,
1044
- MAX_INFER_FRAMES_PER_SPLIT,
1045
- MIN_MAIN_MATCH_ABS,
1046
- MIN_MAIN_MATCH_RATIO,
1047
- MAIN_DET_MATCH_IOU_THRESHOLD,
1048
- MIN_COMBINED_MEDIAN_CONF,
1049
- DISPLAY_DET_MATCH_IOU_THRESHOLD,
1050
- )
1051
- with timer("prepare_splits", timing):
1052
- if ENABLE_MOTION_SEGMENTATION:
1053
- split_frames = split_video_stable(video_path)
1054
- else:
1055
- fast_frames = split_video(video_path, n=FAST_N_SAMPLES)
1056
- split_frames = [fast_frames] if fast_frames else []
1057
- total_frames = sum(len(frames) for frames in split_frames)
1058
- LOGGER.info("Inference workload | splits=%d total_frames=%d", len(split_frames), total_frames)
1059
- if not split_frames:
1060
- LOGGER.info("Inference stop | no frames available")
1061
- timing["wall"] = time.perf_counter() - wall_t0
1062
- _log_timing_summary("Inference", timing, wall_time=timing["wall"])
1063
- return {"detections": [], "all_frame_predictions": []}
1064
-
1065
- outputs = []
1066
- all_frame_predictions = []
1067
- infer_model = 0.0
1068
- combine_time = 0.0
1069
- iou_time = 0.0
1070
- draw_time = 0.0
1071
- draw_all_frames_time = 0.0
1072
- split_loop_time = 0.0
1073
- for split_idx, frames in enumerate(split_frames):
1074
- split_t0 = time.perf_counter()
1075
- original_len = len(frames)
1076
- if MAX_INFER_FRAMES_PER_SPLIT > 0 and original_len > MAX_INFER_FRAMES_PER_SPLIT:
1077
- frames_for_infer = _sample_uniform_items(frames, MAX_INFER_FRAMES_PER_SPLIT)
1078
- else:
1079
- frames_for_infer = frames
1080
- LOGGER.info(
1081
- "Inference split %d | frames=%d used_for_infer=%d",
1082
- split_idx + 1,
1083
- original_len,
1084
- len(frames_for_infer),
1085
- )
1086
- t_model = time.perf_counter()
1087
- if hasattr(model, "infer_batch"):
1088
- frame_preds = model.infer_batch(frames_for_infer, batch_size=INFER_BATCH_SIZE)
1089
- else:
1090
- frame_preds = [model(frame) for frame in frames_for_infer]
1091
- frame_preds = [np.asarray(bbox, dtype=np.float64).reshape(-1, 5) for bbox in frame_preds]
1092
- for frame_idx, bbox in enumerate(frame_preds):
1093
- if bbox.size == 0:
1094
- LOGGER.info(
1095
- "Inference split %d frame %d | detections=0",
1096
- split_idx + 1,
1097
- frame_idx + 1,
1098
- )
1099
- continue
1100
- confs = bbox[:, 4].astype(np.float64)
1101
- conf_list_txt = ", ".join(f"{float(c):.2f}" for c in confs.tolist())
1102
- LOGGER.info(
1103
- (
1104
- "Inference split %d frame %d | detections=%d | confs=[%s] | "
1105
- "frame_max_conf=%.2f | frame_mean_conf_all_bboxes=%.2f"
1106
- ),
1107
- split_idx + 1,
1108
- frame_idx + 1,
1109
- len(bbox),
1110
- conf_list_txt,
1111
- float(np.max(confs)),
1112
- float(np.mean(confs)),
1113
  )
1114
- for frame_idx, (frame, bbox) in enumerate(zip(frames_for_infer, frame_preds)):
1115
- subtitle = f"segment {split_idx + 1} / frame {frame_idx + 1}"
1116
- t_draw_all = time.perf_counter()
1117
- all_frame_predictions.append(
1118
- {
1119
- "image": _draw_detections(frame, bbox, subtitle=subtitle),
1120
- "caption": f"Segment {split_idx + 1} - Frame {frame_idx + 1}",
1121
- }
1122
  )
1123
- draw_all_frames_time += time.perf_counter() - t_draw_all
1124
-
1125
- split_model = time.perf_counter() - t_model
1126
- infer_model += split_model
1127
- split_iou = 0.0
1128
- split_draw = 0.0
1129
 
1130
- t_combine = time.perf_counter()
1131
- kept_main = _combine_predictions_per_split(frame_preds)
1132
- dt_combine = time.perf_counter() - t_combine
1133
- combine_time += dt_combine
1134
- LOGGER.info(
1135
- "Inference split %d | combined_detections=%d",
1136
- split_idx + 1,
1137
- len(kept_main),
1138
- )
1139
- for det_idx, det_info in enumerate(kept_main):
1140
- conf_values_txt = ", ".join(f"{float(c):.2f}" for c in det_info["matched_conf_values"])
1141
- frame_indices_txt = ", ".join(str(int(i) + 1) for i in det_info["matched_frame_indices"])
1142
- LOGGER.info(
1143
- (
1144
- "Inference split %d combined detection %d | matches=%d/%d "
1145
- "(required=%d, ratio=%.2f) | combine_median_conf=%.2f | combine_max_conf=%.2f | "
1146
- "matched_frames=[%s] | matched_confs=[%s]"
1147
- ),
1148
- split_idx + 1,
1149
- det_idx + 1,
1150
- det_info["match_count"],
1151
- det_info["n_frames"],
1152
- det_info["required_matches"],
1153
- det_info["match_ratio"],
1154
- det_info["median_conf"],
1155
- det_info["max_conf"],
1156
- frame_indices_txt,
1157
- conf_values_txt,
1158
- )
1159
- if not kept_main:
1160
- split_elapsed = time.perf_counter() - split_t0
1161
- split_loop_time += split_elapsed
1162
- LOGGER.info(
1163
- (
1164
- "Inference split %d timing | total=%.3fs | model=%.3fs | combine=%.3fs | "
1165
- "iou=%.3fs | draw=%.3fs | avg_model_ms=%.1f"
1166
- ),
1167
- split_idx + 1,
1168
- split_elapsed,
1169
- split_model,
1170
- dt_combine,
1171
- split_iou,
1172
- split_draw,
1173
- (1000.0 * split_model / max(len(frames_for_infer), 1)),
1174
  )
1175
- continue
1176
-
1177
- for det_idx, det_info in enumerate(kept_main):
1178
- main_box = det_info["box"]
1179
- selected_frame_idx = None
1180
- selected_bbox = None
1181
- selection_source = None
1182
-
1183
- # Prefer the earliest frame that overlaps the combined detection, using a relaxed
1184
- # threshold for display (so we show the first visible appearance of the event).
1185
- for frame_idx, bbox in enumerate(frame_preds):
1186
- if bbox.size == 0:
1187
- continue
1188
- t_iou = time.perf_counter()
1189
- ious = box_iou(bbox[:, :4], main_box[:4].reshape(1, 4))
1190
- dt_iou = time.perf_counter() - t_iou
1191
- split_iou += dt_iou
1192
- iou_time += dt_iou
1193
- if (ious > DISPLAY_DET_MATCH_IOU_THRESHOLD).any():
1194
- match_idx = int(np.argmax(ious[0]))
1195
- selected_frame_idx = int(frame_idx)
1196
- selected_bbox = np.asarray(bbox[match_idx], dtype=np.float64).reshape(1, 5)
1197
- selection_source = "display_first_overlap"
1198
- break
1199
-
1200
- first_match_frame_idx = det_info.get("first_match_frame_idx")
1201
- first_match_bbox = det_info.get("first_match_bbox")
1202
- if selected_frame_idx is None or selected_bbox is None:
1203
- if (
1204
- first_match_frame_idx is None
1205
- or first_match_bbox is None
1206
- or int(first_match_frame_idx) < 0
1207
- or int(first_match_frame_idx) >= len(frames_for_infer)
1208
- ):
1209
- LOGGER.warning(
1210
- "Inference split %d detection %d | missing display frame and first matched frame/bbox",
1211
- split_idx + 1,
1212
- det_idx + 1,
1213
  )
1214
- continue
1215
- selected_frame_idx = int(first_match_frame_idx)
1216
- selected_bbox = np.asarray(first_match_bbox, dtype=np.float64).reshape(1, 5)
1217
- selection_source = "combine_first_match_fallback"
1218
-
1219
- frame = frames_for_infer[selected_frame_idx]
1220
- LOGGER.info(
1221
- (
1222
- "Inference split %d detection %d | selected_frame=%d | source=%s | "
1223
- "selected frame_conf=%.2f | combine_median_conf=%.2f | combine_max_conf=%.2f"
1224
- ),
1225
- split_idx + 1,
1226
- det_idx + 1,
1227
- selected_frame_idx + 1,
1228
- selection_source,
1229
- float(selected_bbox[0, 4]),
1230
- det_info["median_conf"],
1231
- det_info["max_conf"],
1232
- )
1233
- subtitle = (
1234
- f"segment {split_idx + 1} / detection {det_idx + 1} | "
1235
- f"frame {selected_frame_idx + 1} | "
1236
- f"matchs {det_info['match_count']}/{det_info['n_frames']} | "
1237
- f"conf_med {det_info['median_conf']:.2f}"
1238
  )
1239
- t_draw = time.perf_counter()
1240
- outputs.append(_draw_detections(frame, selected_bbox, subtitle=subtitle))
1241
- dt_draw = time.perf_counter() - t_draw
1242
- split_draw += dt_draw
1243
- draw_time += dt_draw
1244
-
1245
- split_elapsed = time.perf_counter() - split_t0
1246
- split_loop_time += split_elapsed
1247
- LOGGER.info(
1248
- (
1249
- "Inference split %d timing | total=%.3fs | model=%.3fs | combine=%.3fs | "
1250
- "iou=%.3fs | draw=%.3fs | avg_model_ms=%.1f"
1251
- ),
1252
- split_idx + 1,
1253
- split_elapsed,
1254
- split_model,
1255
- dt_combine,
1256
- split_iou,
1257
- split_draw,
1258
- (1000.0 * split_model / max(len(frames_for_infer), 1)),
1259
- )
1260
-
1261
- timing["split_loop"] = split_loop_time
1262
- timing["model_infer"] = infer_model
1263
- timing["combine_predictions"] = combine_time
1264
- timing["iou_matching"] = iou_time
1265
- timing["draw_detections"] = draw_time
1266
- timing["draw_all_frame_predictions"] = draw_all_frames_time
1267
- timing["wall"] = time.perf_counter() - wall_t0
1268
- _log_timing_summary("Inference", timing, wall_time=timing["wall"])
1269
- LOGGER.info(
1270
- "Inference done | output_images=%d all_frame_prediction_images=%d",
1271
- len(outputs),
1272
- len(all_frame_predictions),
1273
- )
1274
- return {"detections": outputs, "all_frame_predictions": all_frame_predictions}
1275
-
1276
-
1277
- def _upload_signature(uploaded_file):
1278
- buffer = uploaded_file.getbuffer()
1279
- size = uploaded_file.size if uploaded_file.size is not None else len(buffer)
1280
- digest = sha1(buffer).hexdigest()
1281
- return (uploaded_file.name or "uploaded.mp4", int(size), digest)
1282
-
1283
-
1284
- def _write_uploaded_video(uploaded_file):
1285
- ext = os.path.splitext(uploaded_file.name or "")[1] or ".mp4"
1286
- with tempfile.NamedTemporaryFile(prefix="upload_", suffix=ext, delete=False) as tmp:
1287
- tmp.write(uploaded_file.getbuffer())
1288
- return tmp.name
1289
-
1290
-
1291
- def _render_outputs(outputs):
1292
- detections = outputs
1293
- all_frame_predictions = []
1294
- if isinstance(outputs, dict):
1295
- detections = outputs.get("detections", [])
1296
- all_frame_predictions = outputs.get("all_frame_predictions", [])
1297
-
1298
- if not detections:
1299
- st.warning("Aucune detection d'incendie trouvee dans cette video.")
1300
- else:
1301
- st.subheader("Incendies detectes")
1302
- columns = st.columns(2)
1303
- for idx, image in enumerate(detections):
1304
- columns[idx % 2].image(image, caption=f"Detection {idx + 1}", use_container_width=True)
1305
-
1306
- # if all_frame_predictions:
1307
- # with st.expander(
1308
- # f"Predictions sur toutes les frames echantillonnees ({len(all_frame_predictions)})",
1309
- # expanded=False,
1310
- # ):
1311
- # columns = st.columns(2)
1312
- # for idx, item in enumerate(all_frame_predictions):
1313
- # image = item["image"] if isinstance(item, dict) else item
1314
- # caption = (
1315
- # item.get("caption", f"Frame {idx + 1}")
1316
- # if isinstance(item, dict)
1317
- # else f"Frame {idx + 1}"
1318
- # )
1319
- # columns[idx % 2].image(image, caption=caption, use_container_width=True)
1320
-
1321
-
1322
- def main():
1323
- st.set_page_config(page_title="Detection d'incendies Pyronear", layout="wide")
1324
- st.image(PYRONEAR_LOGO_URL, width=220)
1325
- st.title("Detection d'incendies Pyronear")
1326
- st.write("Televersez un MP4 pour lancer la detection automatiquement.")
1327
-
1328
- uploaded = st.file_uploader("Televerser un MP4", type=["mp4"])
1329
- if uploaded is None:
1330
- st.info("En attente du televersement d'une video.")
1331
- return
1332
-
1333
- signature = _upload_signature(uploaded)
1334
- previous_signature = st.session_state.get("upload_signature")
1335
- if signature != previous_signature:
1336
- temp_path = None
1337
- st.session_state["upload_signature"] = signature
1338
- with st.spinner("Detection d'incendies en cours..."):
1339
- try:
1340
- temp_path = _write_uploaded_video(uploaded)
1341
- st.session_state["output_images"] = infer(temp_path)
1342
- st.session_state["inference_error"] = None
1343
- except Exception as exc:
1344
- LOGGER.exception("Inference failed")
1345
- st.session_state["output_images"] = []
1346
- st.session_state["inference_error"] = str(exc)
1347
- finally:
1348
- if temp_path and os.path.exists(temp_path):
1349
- os.remove(temp_path)
1350
-
1351
- if st.session_state.get("inference_error"):
1352
- st.error(f"Echec de la detection : {st.session_state['inference_error']}")
1353
- return
1354
 
1355
- _render_outputs(st.session_state.get("output_images", []))
1356
 
1357
 
1358
  if __name__ == "__main__":
1359
- main()
 
1
+ """Gradio demo for the two Pyronear smoke models.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ Tab 1 — the single-frame detector published at ``pyronear/yolov11s``: one image
4
+ in, boxes out.
 
 
 
 
 
 
 
 
 
5
 
6
+ Tab 2 — the temporal classifier published at ``pyronear/temporal-model``: an
7
+ ordered sequence in (frames or a video), one smoke/no-smoke decision out. It
8
+ links its detector's boxes into tubes across frames and scores each tube with a
9
+ ViT. Its ``model.zip`` bundles its own YOLO, so tab 2's boxes come from that
10
+ pinned detector, not from tab 1's.
11
+ """
12
 
13
+ import os
14
+ import tempfile
15
+ from functools import lru_cache
16
+ from pathlib import Path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
+ import gradio as gr
19
+ from huggingface_hub import hf_hub_download
20
+ from PIL import Image, ImageDraw, ImageFont
21
 
22
+ DETECTOR_REPO = os.getenv("DETECTOR_REPO", "pyronear/yolov11s")
23
+ DETECTOR_REVISION = os.getenv("DETECTOR_REVISION", "v8.2.0")
24
+ TEMPORAL_REPO = os.getenv("TEMPORAL_REPO", "pyronear/temporal-model")
25
+ TEMPORAL_REVISION = os.getenv("TEMPORAL_REVISION", "v0.4.0")
 
 
 
 
 
26
 
27
+ # The detector settings the temporal pipeline runs with (train/params.yaml).
28
+ CONF_THRESHOLD = 0.1
29
+ IOU_NMS = 0.2
30
+ IMAGE_SIZE = 1024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
+ EXAMPLES_DIR = Path(__file__).parent / "examples"
33
+ IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
 
 
 
 
 
 
 
 
 
34
 
35
+ # Same palette as the eval viewer, so tube colours read the same across tools.
36
+ TUBE_PALETTE = [
37
+ "#1f77b4",
38
+ "#ff7f0e",
39
+ "#2ca02c",
40
+ "#d62728",
41
+ "#9467bd",
42
+ "#8c564b",
43
+ "#e377c2",
44
+ "#7f7f7f",
45
+ "#bcbd22",
46
+ "#17becf",
47
+ ]
48
 
49
+ try:
50
+ FONT = ImageFont.load_default(size=18)
51
+ except TypeError: # older Pillow without the size kwarg
52
+ FONT = ImageFont.load_default()
 
 
 
 
 
53
 
54
 
55
+ @lru_cache(maxsize=1)
56
+ def detector():
57
+ """The single-frame YOLO detector (downloaded once, then cached)."""
58
+ from ultralytics import YOLO # noqa: PLC0415 # keep app startup fast
 
 
 
 
 
 
59
 
60
+ return YOLO(hf_hub_download(DETECTOR_REPO, "best.pt", revision=DETECTOR_REVISION))
 
 
 
 
 
 
 
 
 
 
61
 
62
 
63
+ @lru_cache(maxsize=1)
64
+ def temporal_model():
65
+ """The packaged temporal classifier (downloaded once, then cached)."""
66
+ from temporal_model.core.model import BboxTubeTemporalModel # noqa: PLC0415
 
67
 
68
+ package = hf_hub_download(TEMPORAL_REPO, "model.zip", revision=TEMPORAL_REVISION)
69
+ return BboxTubeTemporalModel.from_package(Path(package))
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
+ def video_to_frames(video_path: str, n_frames: int, out_dir: Path) -> list[Path]:
73
+ """Extract ``n_frames`` evenly spaced frames from a video, in time order."""
74
+ import cv2 # noqa: PLC0415 # only the video path needs OpenCV
75
 
76
+ capture = cv2.VideoCapture(video_path)
 
 
 
77
  try:
78
+ total = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
79
+ if total <= 0:
80
+ raise gr.Error(f"Could not read any frame from {Path(video_path).name}")
81
+ step = max(1, total // n_frames)
82
+ indices = list(range(0, total, step))[:n_frames]
83
+ paths = []
84
+ for i, index in enumerate(indices):
85
+ capture.set(cv2.CAP_PROP_POS_FRAMES, index)
86
+ ok, frame = capture.read()
87
+ if not ok:
88
+ continue
89
+ path = out_dir / f"frame_{i:03d}.jpg"
90
+ cv2.imwrite(str(path), frame)
91
+ paths.append(path)
 
 
 
 
 
 
 
 
 
92
  finally:
93
+ capture.release()
94
+ if not paths:
95
+ raise gr.Error(f"Could not decode {Path(video_path).name}")
96
+ return paths
97
+
98
+
99
+ def collect_frames(
100
+ images: list[str] | None, video: str | None, n_frames: int, work_dir: Path
101
+ ) -> list[Path]:
102
+ """Resolve the sequence input into ordered frame paths.
103
+
104
+ Uploaded images are ordered by filename — the Pyronear convention is that
105
+ filename order is time order. A video is sampled into ``n_frames``.
106
+ """
107
+ if video:
108
+ return video_to_frames(video, n_frames, work_dir)
109
+ if not images:
110
+ raise gr.Error("Upload a sequence of frames, or a video.")
111
+ paths = sorted((Path(p) for p in images), key=lambda p: p.name)
112
+ bad = [p.name for p in paths if p.suffix.lower() not in IMAGE_SUFFIXES]
113
+ if bad:
114
+ raise gr.Error(f"Not image files: {', '.join(bad)}")
115
+ return paths
116
+
117
+
118
+ def tube_color(tube_id: int) -> str:
119
+ return TUBE_PALETTE[tube_id % len(TUBE_PALETTE)]
120
+
121
+
122
+ def draw_boxes(image_path: Path, boxes: list[tuple]) -> Image.Image:
123
+ """Draw ``[(bbox_cxcywh_normalized, confidence, colour, label)]`` on a frame."""
124
+ image = Image.open(image_path).convert("RGB")
125
+ width, height = image.size
126
+ draw = ImageDraw.Draw(image)
127
+ for (cx, cy, w, h), confidence, colour, label in boxes:
128
+ x0, y0 = (cx - w / 2) * width, (cy - h / 2) * height
129
+ x1, y1 = (cx + w / 2) * width, (cy + h / 2) * height
130
+ draw.rectangle([x0, y0, x1, y1], outline=colour, width=4)
131
+ caption = " ".join(
132
+ part
133
+ for part in (label, None if confidence is None else f"{confidence:.2f}")
134
+ if part
135
  )
136
+ if caption:
137
+ draw.text((x0, max(0, y0 - 20)), caption, fill=colour, font=FONT)
138
+ return image
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
 
141
+ def input_index(frame_idx: int, padded_frame_indices: list[int]) -> int | None:
142
+ """Map a model-processed frame index back to the uploaded-frame index.
 
 
143
 
144
+ The model pads short sequences with duplicate frames; ``None`` marks such a
145
+ synthetic slot, which has no uploaded frame to draw on.
146
+ """
147
+ if frame_idx in padded_frame_indices:
148
  return None
149
+ return frame_idx - sum(1 for p in padded_frame_indices if p < frame_idx)
150
+
151
+
152
+ def detect_one_frame(image_path: str | None):
153
+ """Tab 1: run the single-frame detector and return the annotated image."""
154
+ if not image_path:
155
+ raise gr.Error("Upload an image.")
156
+ results = detector().predict(
157
+ image_path, imgsz=IMAGE_SIZE, conf=CONF_THRESHOLD, iou=IOU_NMS, verbose=False
158
+ )[0]
159
+ rows = [
160
+ [round(c, 3), *(round(v, 1) for v in xyxy)]
161
+ for c, xyxy in zip(
162
+ results.boxes.conf.tolist(), results.boxes.xyxy.tolist(), strict=True
 
 
 
 
 
 
 
 
 
163
  )
164
+ ]
165
+ annotated = Image.fromarray(results.plot()[:, :, ::-1])
166
+ summary = f"**{len(rows)} detection(s)**" if rows else "**No detection**"
167
+ return annotated, summary, rows
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
 
170
+ def classify_sequence(
171
+ images: list[str] | None, video: str | None, n_frames: int, compute_trigger: bool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  ):
173
+ """Tab 2: run the temporal model and return the decision, frames and tubes."""
174
+ with tempfile.TemporaryDirectory() as tmp:
175
+ paths = collect_frames(images, video, int(n_frames), Path(tmp))
176
+ model = temporal_model()
177
+ output = model.predict(
178
+ model.load_sequence(paths), compute_trigger=compute_trigger
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  )
180
+ details = output.details
181
+ kept = details["tubes"]["kept"]
182
+ padded = details["preprocessing"]["padded_frame_indices"]
183
+
184
+ boxes_per_frame: dict[int, list[tuple]] = {}
185
+ for tube in kept:
186
+ colour = tube_color(tube["tube_id"])
187
+ for entry in tube["entries"]:
188
+ index = (
189
+ None
190
+ if entry["bbox"] is None
191
+ else input_index(entry["frame_idx"], padded)
192
+ )
193
+ if index is None:
194
+ continue
195
+ label = f"#{tube['tube_id']}"
196
+ boxes_per_frame.setdefault(index, []).append(
197
+ (entry["bbox"], entry["confidence"], colour, label)
198
+ )
199
 
200
+ trigger = (
201
+ None
202
+ if output.trigger_frame_index is None
203
+ else input_index(output.trigger_frame_index, padded)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  )
205
+ gallery = []
206
+ for i, path in enumerate(paths):
207
+ caption = f"{i}: {path.name}"
208
+ if trigger == i:
209
+ caption += " ⚡ trigger"
210
+ gallery.append((draw_boxes(path, boxes_per_frame.get(i, [])), caption))
211
+
212
+ probabilities = [t["probability"] for t in kept if t["probability"] is not None]
213
+ verdict = "🔥 **Smoke**" if output.is_positive else "✅ **No smoke**"
214
+ lines = [
215
+ verdict,
216
+ f"- probability: **{max(probabilities):.3f}**"
217
+ if probabilities
218
+ else "- probability: n/a (uncalibrated)",
219
+ f"- tubes: {len(kept)} kept / {details['tubes']['num_candidates']} candidates",
220
+ f"- frames: {len(paths)} uploaded, {len(padded)} padded",
221
+ ]
222
+ if compute_trigger:
223
+ lines.append(
224
+ f"- trigger frame: **{trigger}**"
225
+ if trigger is not None
226
+ else "- trigger frame: none"
227
  )
228
+ rows = [
229
+ [
230
+ t["tube_id"],
231
+ f"{t['start_frame']}–{t['end_frame']}",
232
+ round(t["logit"], 3),
233
+ None if t["probability"] is None else round(t["probability"], 3),
234
+ t["first_crossing_frame"],
235
+ ]
236
+ for t in kept
237
+ ]
238
+ return "\n".join(lines), gallery, rows, details
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
 
 
240
 
241
+ def example_sequences() -> list[list]:
242
+ """Any ``examples/<name>/*.jpg`` folder becomes a one-click example."""
243
+ if not EXAMPLES_DIR.is_dir():
244
  return []
245
+ return [
246
+ [sorted(str(p) for p in d.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)]
247
+ for d in sorted(EXAMPLES_DIR.iterdir())
248
+ if d.is_dir()
249
+ ]
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
+ def build_demo() -> gr.Blocks:
253
+ with gr.Blocks(title="Pyronear — smoke detection") as demo:
254
+ gr.Markdown(
255
+ "# 🔥 Pyronear — smoke detection\n"
256
+ "Two models, two tabs: the **single-frame detector** "
257
+ f"([{DETECTOR_REPO}](https://huggingface.co/{DETECTOR_REPO}) "
258
+ f"`{DETECTOR_REVISION}`) and the **temporal classifier** "
259
+ f"([{TEMPORAL_REPO}](https://huggingface.co/{TEMPORAL_REPO}) "
260
+ f"`{TEMPORAL_REVISION}`), which decides on a whole sequence.\n\n"
261
+ "Running on free CPU — a sequence takes a minute or so."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  )
 
 
263
 
264
+ with gr.Tab("Single frame — detection"):
265
+ with gr.Row():
266
+ with gr.Column():
267
+ image_in = gr.Image(type="filepath", label="Frame")
268
+ detect_button = gr.Button("Detect", variant="primary")
269
+ with gr.Column():
270
+ detect_summary = gr.Markdown()
271
+ image_out = gr.Image(label="Detections")
272
+ detect_table = gr.Dataframe(
273
+ headers=["confidence", "x0", "y0", "x1", "y1"], label="Boxes (pixels)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  )
275
+ detect_button.click(
276
+ detect_one_frame,
277
+ inputs=image_in,
278
+ outputs=[image_out, detect_summary, detect_table],
 
 
 
 
279
  )
 
 
 
 
 
 
280
 
281
+ with gr.Tab("Sequence — temporal model"):
282
+ gr.Markdown(
283
+ "Upload the frames of one sequence (ordered by filename, as in "
284
+ "production) **or** a video, which is sampled into frames. "
285
+ "Boxes are coloured per tube."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  )
287
+ with gr.Row():
288
+ with gr.Column():
289
+ frames_in = gr.File(
290
+ file_count="multiple", file_types=["image"], label="Frames"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  )
292
+ video_in = gr.Video(label="…or a video")
293
+ n_frames_in = gr.Slider(
294
+ 3, 30, value=12, step=1, label="Frames sampled from the video"
295
+ )
296
+ trigger_in = gr.Checkbox(
297
+ label="Compute the trigger frame (slower)", value=False
298
+ )
299
+ classify_button = gr.Button("Classify sequence", variant="primary")
300
+ with gr.Column():
301
+ verdict_out = gr.Markdown()
302
+ tubes_out = gr.Dataframe(
303
+ headers=["tube", "frames", "logit", "probability", "crossing"],
304
+ label="Tubes",
305
+ )
306
+ gallery_out = gr.Gallery(label="Frames", columns=4, height=420)
307
+ with gr.Accordion("Raw details", open=False):
308
+ details_out = gr.JSON()
309
+ examples = example_sequences()
310
+ if examples:
311
+ gr.Examples(examples=examples, inputs=frames_in)
312
+ classify_button.click(
313
+ classify_sequence,
314
+ inputs=[frames_in, video_in, n_frames_in, trigger_in],
315
+ outputs=[verdict_out, gallery_out, tubes_out, details_out],
316
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
 
318
+ return demo
319
 
320
 
321
  if __name__ == "__main__":
322
+ build_demo().launch()
docker-compose.yml DELETED
@@ -1,60 +0,0 @@
1
- services:
2
- traefik:
3
- image: traefik:v3.6.8
4
- container_name: traefik
5
- environment:
6
- DOCKER_API_VERSION: "${TRAEFIK_DOCKER_API_VERSION:-1.44}"
7
- command:
8
- - "--log.level=INFO"
9
- - "--providers.docker=true"
10
- - "--providers.docker.exposedbydefault=false"
11
- - "--entrypoints.web.address=:80"
12
- - "--entrypoints.websecure.address=:443"
13
- - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
14
- - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
15
- - "--certificatesresolvers.pyroresolver.acme.tlschallenge=true"
16
- - "--certificatesresolvers.pyroresolver.acme.email=${TRAEFIK_ACME_EMAIL:-contact@pyronear.org}"
17
- - "--certificatesresolvers.pyroresolver.acme.storage=/acme.json"
18
- ports:
19
- - "80:80"
20
- - "443:443"
21
- volumes:
22
- - "/var/run/docker.sock:/var/run/docker.sock:ro"
23
- - "./acme.json:/acme.json"
24
- restart: unless-stopped
25
- depends_on:
26
- - app
27
-
28
- app:
29
- build:
30
- context: .
31
- dockerfile: Dockerfile
32
- container_name: pyronear-wildfire-detection
33
- expose:
34
- - "7860"
35
- environment:
36
- STREAMLIT_SERVER_ADDRESS: "0.0.0.0"
37
- STREAMLIT_SERVER_PORT: "7860"
38
- STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false"
39
- ENABLE_MOTION_SEGMENTATION: "0"
40
- FAST_N_SAMPLES: "12"
41
- INFER_BATCH_SIZE: "16"
42
- MODEL_IMGSZ: "1024"
43
- MAX_INFER_FRAMES_PER_SPLIT: "12"
44
- MIN_MAIN_MATCH_ABS: "3"
45
- MIN_MAIN_MATCH_RATIO: "0.20"
46
- ORT_PROVIDERS: "CPUExecutionProvider"
47
- ORT_INTRA_OP_NUM_THREADS: "8"
48
- ORT_INTER_OP_NUM_THREADS: "1"
49
- labels:
50
- - "traefik.enable=true"
51
- - "traefik.http.routers.pyronear-http.rule=Host(`demo-pyronear-egm.pyronear.org`)"
52
- - "traefik.http.routers.pyronear-http.entrypoints=web"
53
- - "traefik.http.routers.pyronear-http.middlewares=redirect-to-https"
54
- - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
55
- - "traefik.http.routers.pyronear.rule=Host(`demo-pyronear-egm.pyronear.org`)"
56
- - "traefik.http.routers.pyronear.entrypoints=websecure"
57
- - "traefik.http.routers.pyronear.tls=true"
58
- - "traefik.http.routers.pyronear.tls.certresolver=pyroresolver"
59
- - "traefik.http.services.pyronear.loadbalancer.server.port=7860"
60
- restart: unless-stopped
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
images_to_video.py DELETED
@@ -1,100 +0,0 @@
1
- import argparse
2
- import re
3
- from pathlib import Path
4
-
5
- import imageio.v2 as imageio
6
- import numpy as np
7
- from PIL import Image
8
-
9
-
10
- def _natural_key(text):
11
- parts = re.split(r"(\d+)", text)
12
- return [int(p) if p.isdigit() else p.lower() for p in parts]
13
-
14
-
15
- def _iter_images(folder, extensions, natural_sort):
16
- paths = [
17
- p
18
- for p in Path(folder).iterdir()
19
- if p.is_file() and p.suffix.lower() in extensions
20
- ]
21
- if natural_sort:
22
- return sorted(paths, key=lambda p: _natural_key(p.name))
23
- return sorted(paths, key=lambda p: p.name.lower())
24
-
25
-
26
- def images_to_mp4(
27
- input_dir,
28
- output_path,
29
- fps,
30
- extensions,
31
- natural_sort,
32
- resize_to_first,
33
- codec,
34
- ):
35
- images = _iter_images(input_dir, extensions, natural_sort)
36
- if not images:
37
- raise ValueError(f"No images found in {input_dir}")
38
-
39
- first = Image.open(images[0]).convert("RGB")
40
- target_size = first.size
41
-
42
- with imageio.get_writer(output_path, fps=fps, codec=codec) as writer:
43
- writer.append_data(np.array(first))
44
- for path in images[1:]:
45
- img = Image.open(path).convert("RGB")
46
- if img.size != target_size:
47
- if resize_to_first:
48
- img = img.resize(target_size, Image.Resampling.LANCZOS)
49
- else:
50
- raise ValueError(
51
- f"Size mismatch: {path.name} is {img.size}, expected {target_size}"
52
- )
53
- writer.append_data(np.array(img))
54
-
55
-
56
- def main():
57
- parser = argparse.ArgumentParser(
58
- description="Create an MP4 video from images in a folder."
59
- )
60
- parser.add_argument("input_dir", help="Folder containing images.")
61
- parser.add_argument("output", help="Output MP4 path, e.g. out.mp4.")
62
- parser.add_argument(
63
- "--fps", type=float, default=24, help="Frames per second (default: 24)."
64
- )
65
- parser.add_argument(
66
- "--ext",
67
- action="append",
68
- default=[".jpg", ".jpeg", ".png"],
69
- help="Allowed extensions (repeatable). Default: .jpg .jpeg .png",
70
- )
71
- parser.add_argument(
72
- "--natural",
73
- action="store_true",
74
- help="Use natural sort for filenames (e.g. img2 before img10).",
75
- )
76
- parser.add_argument(
77
- "--no-resize",
78
- action="store_true",
79
- help="Fail if image sizes differ instead of resizing to the first image.",
80
- )
81
- parser.add_argument(
82
- "--codec",
83
- default="libx264",
84
- help="Video codec for MP4 (default: libx264).",
85
- )
86
- args = parser.parse_args()
87
-
88
- images_to_mp4(
89
- args.input_dir,
90
- args.output,
91
- fps=args.fps,
92
- extensions={e.lower() for e in args.ext},
93
- natural_sort=args.natural,
94
- resize_to_first=not args.no_resize,
95
- codec=args.codec,
96
- )
97
-
98
-
99
- if __name__ == "__main__":
100
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
packages.txt CHANGED
@@ -1 +1,2 @@
1
- ffmpeg
 
 
1
+ libgl1
2
+ libglib2.0-0
requirements.txt CHANGED
@@ -1,6 +1,2 @@
1
- streamlit==1.43.2
2
- numpy>=1.26,<3
3
- Pillow>=10.0.0
4
- opencv-python-headless>=4.10.0.84
5
- onnxruntime>=1.20.0
6
- tqdm>=4.66.0
 
1
+ gradio>=5.0
2
+ temporal-model-core[torch] @ git+https://github.com/pyronear/temporal-model.git@main#subdirectory=core
 
 
 
 
test_app.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-check for the demo's pure helpers: `python test_app.py`.
2
+
3
+ The model-running paths need torch and the Hub; these are the bits that can be
4
+ silently wrong (frame ordering, padded-index mapping, box geometry).
5
+ """
6
+
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from app import collect_frames, draw_boxes, input_index, tube_color
11
+ from PIL import Image
12
+
13
+
14
+ def test_input_index():
15
+ # No padding: identity.
16
+ assert [input_index(i, []) for i in range(3)] == [0, 1, 2]
17
+ # Frames 0 and 3 are synthetic duplicates; the rest shift down past them.
18
+ assert input_index(0, [0, 3]) is None
19
+ assert input_index(1, [0, 3]) == 0
20
+ assert input_index(2, [0, 3]) == 1
21
+ assert input_index(3, [0, 3]) is None
22
+ assert input_index(4, [0, 3]) == 2
23
+
24
+
25
+ def test_collect_frames_orders_by_filename():
26
+ with tempfile.TemporaryDirectory() as tmp:
27
+ work = Path(tmp)
28
+ unordered = [str(work / "b_02.jpg"), str(work / "a_01.jpg")]
29
+ assert [p.name for p in collect_frames(unordered, None, 8, work)] == [
30
+ "a_01.jpg",
31
+ "b_02.jpg",
32
+ ]
33
+
34
+
35
+ def test_collect_frames_rejects_non_images():
36
+ with tempfile.TemporaryDirectory() as tmp:
37
+ work = Path(tmp)
38
+ try:
39
+ collect_frames([str(work / "notes.txt")], None, 8, work)
40
+ except Exception as e:
41
+ assert "notes.txt" in str(e)
42
+ else:
43
+ raise AssertionError("expected a rejection")
44
+
45
+
46
+ def test_draw_boxes_marks_the_box_pixels():
47
+ with tempfile.TemporaryDirectory() as tmp:
48
+ path = Path(tmp) / "frame.jpg"
49
+ Image.new("RGB", (200, 100), "black").save(path)
50
+ # Centred box, half the frame: outline lands on the mid-height edges.
51
+ out = draw_boxes(path, [((0.5, 0.5, 0.5, 0.5), 0.42, "#ff0000", "#0")])
52
+ assert out.size == (200, 100)
53
+ assert out.getpixel((50, 50))[0] > 100, "left edge should be drawn"
54
+ assert out.getpixel((150, 50))[0] > 100, "right edge should be drawn"
55
+ assert out.getpixel((100, 50)) == (0, 0, 0), "box interior stays untouched"
56
+
57
+
58
+ def test_tube_color_cycles():
59
+ assert tube_color(0) == tube_color(10) != tube_color(1)
60
+
61
+
62
+ if __name__ == "__main__":
63
+ for name, fn in sorted(globals().items()):
64
+ if name.startswith("test_"):
65
+ fn()
66
+ print(f"ok {name}")
utils.py DELETED
@@ -1,116 +0,0 @@
1
- # Copyright (C) 2022-2025, Pyronear.
2
-
3
- # This program is licensed under the Apache License 2.0.
4
- # See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.
5
-
6
-
7
- import cv2
8
- import numpy as np
9
- from tqdm import tqdm
10
-
11
- __all__ = ["DownloadProgressBar", "letterbox", "nms", "xywh2xyxy"]
12
-
13
-
14
- def xywh2xyxy(x: np.ndarray):
15
- y = np.copy(x)
16
- y[..., 0] = x[..., 0] - x[..., 2] / 2 # top left x
17
- y[..., 1] = x[..., 1] - x[..., 3] / 2 # top left y
18
- y[..., 2] = x[..., 0] + x[..., 2] / 2 # bottom right x
19
- y[..., 3] = x[..., 1] + x[..., 3] / 2 # bottom right y
20
- return y
21
-
22
-
23
- def letterbox(
24
- im: np.ndarray,
25
- new_shape: tuple = (1024, 1024),
26
- color: tuple = (114, 114, 114),
27
- auto: bool = False,
28
- stride: int = 32,
29
- ):
30
- """Letterbox image transform for yolo models
31
- Args:
32
- im (np.ndarray): Input image
33
- new_shape (tuple, optional): Image size. Defaults to (1024, 1024).
34
- color (tuple, optional): Pixel fill value for the area outside the transformed image.
35
- Defaults to (114, 114, 114).
36
- auto (bool, optional): auto padding. Defaults to False.
37
- stride (int, optional): padding stride. Defaults to 32.
38
- Returns:
39
- np.ndarray: Output image
40
- """
41
- # Resize and pad image while meeting stride-multiple constraints
42
- im = np.array(im)
43
- shape = im.shape[:2] # current shape [height, width]
44
- if isinstance(new_shape, int):
45
- new_shape = (new_shape, new_shape)
46
- # Scale ratio (new / old)
47
- r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
48
- # Compute padding
49
- new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r))
50
- dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1] # wh padding
51
- if auto: # minimum rectangle
52
- dw, dh = np.mod(dw, stride), np.mod(dh, stride) # wh padding
53
- dw /= 2 # divide padding into 2 sides
54
- dh /= 2
55
- if shape[::-1] != new_unpad: # resize
56
- im = cv2.resize(im, new_unpad, interpolation=cv2.INTER_LINEAR)
57
- top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
58
- left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
59
- # add border
60
- h, w = im.shape[:2]
61
- im_b = np.zeros((h + top + bottom, w + left + right, 3)) + color
62
- im_b[top : top + h, left : left + w, :] = im
63
- return im_b.astype("uint8"), (left, top)
64
-
65
-
66
- def box_iou(box1: np.ndarray, box2: np.ndarray, eps: float = 1e-7):
67
- """
68
- Calculate intersection-over-union (IoU) of boxes.
69
- Both sets of boxes are expected to be in (x1, y1, x2, y2) format.
70
- Based on https://github.com/pytorch/vision/blob/master/torchvision/ops/boxes.py
71
-
72
- Args:
73
- box1 (np.ndarray): A numpy array of shape (N, 4) representing N bounding boxes.
74
- box2 (np.ndarray): A numpy array of shape (M, 4) representing M bounding boxes.
75
- eps (float, optional): A small value to avoid division by zero. Defaults to 1e-7.
76
-
77
- Returns:
78
- (np.ndarray): An NxM numpy array containing the pairwise IoU values for every element in box1 and box2.
79
- """
80
- (a1, a2), (b1, b2) = np.split(box1, 2, 1), np.split(box2, 2, 1)
81
- inter = (np.minimum(a2, b2[:, None, :]) - np.maximum(a1, b1[:, None, :])).clip(0).prod(2)
82
-
83
- # IoU = inter / (area1 + area2 - inter)
84
- return inter / ((a2 - a1).prod(1) + (b2 - b1).prod(1)[:, None] - inter + eps)
85
-
86
-
87
- def nms(boxes: np.ndarray, overlapThresh: int = 0):
88
- """Non maximum suppression
89
-
90
- Args:
91
- boxes (np.ndarray): A numpy array of shape (N, 4) representing N bounding boxes in (x1, y1, x2, y2, conf) format
92
- overlapThresh (int, optional): iou threshold. Defaults to 0.
93
-
94
- Returns:
95
- boxes: Boxes after NMS
96
- """
97
- # Return an empty list, if no boxes given
98
- boxes = boxes[boxes[:, -1].argsort()]
99
- if len(boxes) == 0:
100
- return []
101
-
102
- indices = np.arange(len(boxes))
103
- rr = box_iou(boxes[:, :4], boxes[:, :4])
104
- for i, box in enumerate(boxes):
105
- temp_indices = indices[indices != i]
106
- if np.any(rr[i, temp_indices] > overlapThresh):
107
- indices = indices[indices != i]
108
-
109
- return boxes[indices]
110
-
111
-
112
- class DownloadProgressBar(tqdm):
113
- def update_to(self, b=1, bsize=1, tsize=None):
114
- if tsize is not None:
115
- self.total = tsize
116
- self.update(b * bsize - self.n)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vision.py DELETED
@@ -1,306 +0,0 @@
1
- # Copyright (C) 2022-2025, Pyronear.
2
-
3
- # This program is licensed under the Apache License 2.0.
4
- # See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.
5
-
6
- import logging
7
- import os
8
- import platform
9
- import tarfile
10
- from typing import Sequence, Tuple
11
- from urllib.request import urlretrieve
12
-
13
- import numpy as np
14
- from PIL import Image
15
-
16
- try:
17
- import ncnn
18
- except ImportError:
19
- ncnn = None
20
-
21
- try:
22
- import onnxruntime
23
- except ImportError:
24
- onnxruntime = None
25
-
26
- try:
27
- from .utils import DownloadProgressBar, box_iou, letterbox, nms, xywh2xyxy
28
- except ImportError:
29
- from utils import DownloadProgressBar, box_iou, letterbox, nms, xywh2xyxy
30
-
31
- __all__ = ["Classifier"]
32
-
33
- MODEL_URL_FOLDER = "https://huggingface.co/pyronear/yolo11s_mighty-mongoose_v5.1.0/resolve/main/"
34
- MODEL_NAME = "ncnn_cpu_yolo11s_mighty-mongoose_v5.1.0.tar.gz"
35
-
36
- logging.basicConfig(format="%(asctime)s | %(levelname)s: %(message)s", level=logging.INFO, force=True)
37
-
38
-
39
- def _env_int(name: str, default: int) -> int:
40
- try:
41
- return int(os.getenv(name, str(default)))
42
- except Exception:
43
- return int(default)
44
-
45
-
46
- class Classifier:
47
- """Implements an image classification model using YOLO backend.
48
-
49
- Examples:
50
- >>> from pyroengine.vision import Classifier
51
- >>> model = Classifier()
52
-
53
- Args:
54
- model_path: model path
55
- """
56
-
57
- def __init__(
58
- self,
59
- model_folder="data",
60
- imgsz=1024,
61
- conf=0.15,
62
- iou=0,
63
- format="ncnn",
64
- model_path=None,
65
- max_bbox_size=0.4,
66
- ) -> None:
67
- if model_path:
68
- if not os.path.isfile(model_path):
69
- raise ValueError(f"Model file not found: {model_path}")
70
- if os.path.splitext(model_path)[-1].lower() != ".onnx":
71
- raise ValueError(f"Input model_path should point to an ONNX export but currently is {model_path}")
72
- self.format = "onnx"
73
- else:
74
- if format == "ncnn":
75
- if ncnn is None:
76
- raise ImportError("ncnn is required for format='ncnn'. Install ncnn or use format='onnx'.")
77
- if not self.is_arm_architecture():
78
- logging.info("NCNN format is optimized for arm architecture only, switching to onnx is recommended")
79
- model = MODEL_NAME
80
- self.format = "ncnn"
81
- elif format == "onnx":
82
- if onnxruntime is None:
83
- raise ImportError("onnxruntime is required for format='onnx'. Install onnxruntime.")
84
- model = MODEL_NAME.replace("ncnn", "onnx")
85
- self.format = "onnx"
86
- else:
87
- raise ValueError("Unsupported format: should be 'ncnn' or 'onnx'")
88
-
89
- model_path = os.path.join(model_folder, model)
90
- model_url = MODEL_URL_FOLDER + model
91
-
92
- if not os.path.isfile(model_path):
93
- logging.info(f"Downloading model from {model_url} ...")
94
- os.makedirs(model_folder, exist_ok=True)
95
- with DownloadProgressBar(unit="B", unit_scale=True, miniters=1, desc=model_path) as t:
96
- urlretrieve(model_url, model_path, reporthook=t.update_to)
97
- logging.info("Model downloaded!")
98
-
99
- # Extract .tar.gz archive
100
- if model_path.endswith(".tar.gz"):
101
- base_name = os.path.basename(model_path).replace(".tar.gz", "")
102
- extract_path = os.path.join(model_folder, base_name)
103
- if not os.path.isdir(extract_path):
104
- with tarfile.open(model_path, "r:gz") as tar:
105
- tar.extractall(model_folder)
106
- logging.info(f"Extracted model to: {extract_path}")
107
- model_path = extract_path
108
-
109
- if self.format == "ncnn":
110
- if ncnn is None:
111
- raise RuntimeError("ncnn is not available; cannot load NCNN model.")
112
- self.model = ncnn.Net()
113
- self.model.load_param(os.path.join(model_path, "best_ncnn_model", "model.ncnn.param"))
114
- self.model.load_model(os.path.join(model_path, "best_ncnn_model", "model.ncnn.bin"))
115
-
116
- else:
117
- if onnxruntime is None:
118
- raise RuntimeError("onnxruntime is not available; cannot load ONNX model.")
119
- try:
120
- onnx_file = model_path if model_path.endswith(".onnx") else os.path.join(model_path, "best.onnx")
121
- sess_options = onnxruntime.SessionOptions()
122
- sess_options.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
123
- sess_options.execution_mode = onnxruntime.ExecutionMode.ORT_SEQUENTIAL
124
-
125
- default_intra_threads = max(1, int(os.cpu_count() or 1))
126
- intra_threads = max(1, _env_int("ORT_INTRA_OP_NUM_THREADS", default_intra_threads))
127
- inter_threads = max(1, _env_int("ORT_INTER_OP_NUM_THREADS", 1))
128
- sess_options.intra_op_num_threads = intra_threads
129
- sess_options.inter_op_num_threads = inter_threads
130
-
131
- providers_env = os.getenv("ORT_PROVIDERS", "CPUExecutionProvider")
132
- requested_providers = [p.strip() for p in providers_env.split(",") if p.strip()]
133
- available_providers = set(onnxruntime.get_available_providers())
134
- providers = [p for p in requested_providers if p in available_providers]
135
- if not providers:
136
- providers = ["CPUExecutionProvider"]
137
-
138
- self.ort_session = onnxruntime.InferenceSession(
139
- onnx_file,
140
- sess_options=sess_options,
141
- providers=providers,
142
- )
143
- logging.info(
144
- "ONNX Runtime config | providers=%s intra_op_threads=%d inter_op_threads=%d",
145
- providers,
146
- intra_threads,
147
- inter_threads,
148
- )
149
-
150
- except Exception as e:
151
- raise RuntimeError(f"Failed to load the ONNX model from {model_path}: {e!s}") from e
152
-
153
- logging.info(f"ONNX model loaded successfully from {model_path}")
154
-
155
- self.imgsz = imgsz
156
- self.conf = conf
157
- self.iou = iou
158
- self.max_bbox_size = max_bbox_size
159
-
160
- def is_arm_architecture(self):
161
- # Check for ARM architecture
162
- return platform.machine().startswith("arm") or platform.machine().startswith("aarch")
163
-
164
- def prep_process(self, pil_img: Image.Image) -> Tuple[np.ndarray, Tuple[int, int]]:
165
- """Preprocess an image for inference
166
-
167
- Args:
168
- pil_img: A valid PIL image.
169
-
170
- Returns:
171
- A tuple containing:
172
- - The resized and normalized image of shape (1, C, H, W).
173
- - Padding information as a tuple of integers (pad_height, pad_width).
174
- """
175
- np_img, pad = letterbox(np.array(pil_img), self.imgsz) # Applies letterbox resize with padding
176
-
177
- if self.format == "ncnn":
178
- np_img = ncnn.Mat.from_pixels(np_img, ncnn.Mat.PixelType.PIXEL_BGR, np_img.shape[1], np_img.shape[0])
179
- mean = [0, 0, 0]
180
- std = [1 / 255, 1 / 255, 1 / 255]
181
- np_img.substract_mean_normalize(mean=mean, norm=std)
182
- else:
183
- np_img = np.expand_dims(np_img.astype("float32"), axis=0) # Add batch dimension
184
- np_img = np.ascontiguousarray(np_img.transpose((0, 3, 1, 2))) # Convert from BHWC to BCHW format
185
- np_img /= 255.0 # Normalize to [0, 1]
186
-
187
- return np_img, pad
188
-
189
- def post_process(self, pred: np.ndarray, pad: Tuple[int, int]) -> np.ndarray:
190
- """Post-process model predictions.
191
-
192
- Args:
193
- pred: Raw predictions from the model.
194
- pad: Padding information as (left_pad, top_pad).
195
-
196
- Returns:
197
- Processed predictions as a numpy array.
198
- """
199
- pred = pred[:, pred[-1, :] > self.conf] # Drop low-confidence predictions
200
- pred = np.transpose(pred)
201
- pred = xywh2xyxy(pred)
202
- pred = pred[pred[:, 4].argsort()] # Sort by confidence
203
- pred = nms(pred)
204
- pred = pred[::-1] # Reverse for highest confidence first
205
-
206
- if len(pred) > 0:
207
- left_pad, top_pad = pad # Unpack the tuple
208
- pred[:, :4:2] -= left_pad
209
- pred[:, 1:4:2] -= top_pad
210
- pred[:, :4:2] /= self.imgsz - 2 * left_pad
211
- pred[:, 1:4:2] /= self.imgsz - 2 * top_pad
212
- pred = np.clip(pred, 0, 1)
213
- else:
214
- pred = np.zeros((0, 5)) # Return empty prediction array
215
-
216
- return pred
217
-
218
- def _finalize_prediction(self, pred: np.ndarray, pad: Tuple[int, int], occlusion_bboxes: dict) -> np.ndarray:
219
- # Convert pad to a tuple if required
220
- if isinstance(pad, list):
221
- pad = tuple(pad)
222
-
223
- pred = self.post_process(pred, pad) # Ensure pad is passed as a tuple
224
-
225
- # drop big detections
226
- pred = np.clip(pred, 0, 1)
227
- pred = pred[(pred[:, 2] - pred[:, 0]) < self.max_bbox_size, :]
228
- pred = np.reshape(pred, (-1, 5))
229
-
230
- logging.debug("Model original pred : %s", pred)
231
-
232
- # Remove prediction in bbox occlusion mask
233
- if len(occlusion_bboxes):
234
- all_boxes = np.array([b[:4] for b in occlusion_bboxes.values()], dtype=pred.dtype)
235
-
236
- pred_boxes = pred[:, :4].astype(pred.dtype)
237
- ious = box_iou(pred_boxes, all_boxes)
238
- max_ious = ious.max(axis=0)
239
- keep = max_ious <= 0.1
240
- pred = pred[keep]
241
-
242
- return pred
243
-
244
- def infer_batch(self, pil_imgs: Sequence[Image.Image], occlusion_bboxes: dict = None, batch_size: int = 8):
245
- if not pil_imgs:
246
- return []
247
-
248
- if occlusion_bboxes is None:
249
- occlusion_bboxes = {}
250
-
251
- # NCNN path stays single-image.
252
- if self.format != "onnx":
253
- return [self(pil_img, occlusion_bboxes=occlusion_bboxes) for pil_img in pil_imgs]
254
-
255
- batch_size = max(1, int(batch_size))
256
- outputs = []
257
-
258
- for start in range(0, len(pil_imgs), batch_size):
259
- chunk = pil_imgs[start : start + batch_size]
260
- batch_imgs = []
261
- pads = []
262
- for pil_img in chunk:
263
- np_img, pad = self.prep_process(pil_img)
264
- batch_imgs.append(np_img)
265
- pads.append(pad)
266
-
267
- np_batch = np.concatenate(batch_imgs, axis=0)
268
- raw = self.ort_session.run(["output0"], {"images": np_batch})[0]
269
-
270
- if raw.ndim >= 3 and raw.shape[0] == len(chunk):
271
- raw_preds = [raw[i] for i in range(len(chunk))]
272
- elif len(chunk) == 1 and raw.ndim >= 3:
273
- raw_preds = [raw[0]]
274
- elif len(chunk) == 1:
275
- raw_preds = [raw]
276
- else:
277
- # Fallback for unexpected output shapes.
278
- raw_preds = [self.ort_session.run(["output0"], {"images": arr})[0][0] for arr in batch_imgs]
279
-
280
- for raw_pred, pad in zip(raw_preds, pads):
281
- outputs.append(self._finalize_prediction(raw_pred, pad, occlusion_bboxes))
282
-
283
- return outputs
284
-
285
- def __call__(self, pil_img: Image.Image, occlusion_bboxes: dict = {}) -> np.ndarray:
286
- """Run the classifier on an input image.
287
-
288
- Args:
289
- pil_img: The input PIL image.
290
- occlusion_mask: Optional occlusion mask to exclude certain areas.
291
-
292
- Returns:
293
- Processed predictions.
294
- """
295
- np_img, pad = self.prep_process(pil_img)
296
-
297
- if self.format == "ncnn":
298
- extractor = self.model.create_extractor()
299
- extractor.set_light_mode(True)
300
- extractor.input("in0", np_img)
301
- pred = ncnn.Mat()
302
- extractor.extract("out0", pred)
303
- pred = np.asarray(pred)
304
- else:
305
- pred = self.ort_session.run(["output0"], {"images": np_img})[0][0]
306
- return self._finalize_prediction(pred, pad, occlusion_bboxes)