Mateo commited on
Commit
2f6aeaf
·
1 Parent(s): f74272d

split videos

Browse files
Files changed (3) hide show
  1. README.md +1 -2
  2. app.py +545 -95
  3. requirements.txt +0 -2
README.md CHANGED
@@ -36,5 +36,4 @@ in your browser, upload an MP4, and click "Detect".
36
 
37
  ## Notes
38
  - The first run downloads the wildfire detection model from Hugging Face.
39
- - The app prefers OpenCV for random frame access and falls back to imageio if
40
- OpenCV is not available.
 
36
 
37
  ## Notes
38
  - The first run downloads the wildfire detection model from Hugging Face.
39
+ - OpenCV is required for video decoding and frame sampling.
 
app.py CHANGED
@@ -1,8 +1,14 @@
 
1
  import os
 
 
 
 
 
 
2
 
3
  import cv2
4
  import gradio as gr
5
- import imageio.v2 as imageio
6
  import numpy as np
7
  from PIL import Image, ImageDraw
8
 
@@ -10,6 +16,26 @@ from vision import Classifier
10
  from utils import box_iou, nms
11
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  def _sample_indices(total, n):
14
  if total <= 0:
15
  return []
@@ -18,86 +44,488 @@ def _sample_indices(total, n):
18
  return np.linspace(0, total - 1, n).astype(int).tolist()
19
 
20
 
21
- def _select_from_list(frames, n):
22
- if not frames:
23
- return []
24
- indices = _sample_indices(len(frames), n)
25
- return [frames[i] for i in indices]
26
-
 
 
 
27
 
28
- def _extract_with_cv2(video_path, n):
29
- cap = cv2.VideoCapture(video_path)
30
- if not cap.isOpened():
31
- raise ValueError("Could not open video file.")
32
 
33
- frames = []
34
- try:
35
- total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
36
- if total > 0:
37
- for idx in _sample_indices(total, n):
38
- cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx))
39
- ok, frame = cap.read()
40
- if not ok:
41
- continue
42
- frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
43
- frames.append(Image.fromarray(frame))
44
- return frames
45
-
46
- all_frames = []
47
- while True:
48
- ok, frame = cap.read()
49
- if not ok:
50
- break
51
- frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
52
- all_frames.append(Image.fromarray(frame))
53
- return _select_from_list(all_frames, n)
54
- finally:
55
- cap.release()
56
-
57
-
58
- def _get_imageio_count(reader):
59
- try:
60
- return reader.count_frames()
61
- except Exception:
62
- pass
63
  try:
64
- meta = reader.get_meta_data()
65
- count = meta.get("nframes")
66
- if count and count != float("inf"):
67
- return int(count)
68
  except Exception:
69
- pass
70
- return None
71
 
72
 
73
- def _extract_with_imageio(video_path, n):
74
- reader = imageio.get_reader(video_path)
75
- try:
76
- total = _get_imageio_count(reader)
77
- if total:
78
- frames = []
79
- for idx in _sample_indices(total, n):
80
- frame = reader.get_data(idx)
81
- frames.append(Image.fromarray(frame))
82
- return frames
83
 
84
- all_frames = [Image.fromarray(frame) for frame in reader]
85
- return _select_from_list(all_frames, n)
86
- finally:
87
- reader.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
 
90
  def split_video(video_path, n=8):
91
  if not video_path or not os.path.exists(video_path):
92
  return []
93
- # Prefer OpenCV for random access; use imageio as a fallback path if needed.
94
- try:
95
- return _extract_with_cv2(video_path, n)
96
- except Exception:
97
- return _extract_with_imageio(video_path, n)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
 
 
 
 
 
 
99
 
100
- model = Classifier(format="onnx")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
 
103
  def _resolve_video_path(video_input):
@@ -117,7 +545,7 @@ def _resolve_video_path(video_input):
117
  return None
118
 
119
 
120
- def _draw_detections(pil_img, preds):
121
  img = pil_img.copy()
122
  draw = ImageDraw.Draw(img)
123
  width, height = img.size
@@ -133,32 +561,28 @@ def _draw_detections(pil_img, preds):
133
  draw.text((x1 + 4, y1 + 4), f"{conf:.2f}", fill=color)
134
 
135
  draw.text((6, 6), f"detections: {len(preds)}", fill=color)
 
 
136
  return img
137
 
138
 
139
- def infer(video_file):
140
- video_path = _resolve_video_path(video_file)
141
- frames = split_video(video_path, n=8)
142
- if not frames:
143
- return []
144
- n_frames = len(frames)
145
- boxes = np.zeros((0, 5), dtype=np.float64)
146
- frame_preds = []
147
 
148
- for frame in frames:
149
- bbox = np.asarray(model(frame), dtype=np.float64)
150
- frame_preds.append(bbox)
151
  if bbox.size > 0:
152
  boxes = np.vstack([boxes, bbox])
153
 
154
  if boxes.size == 0:
155
- return []
156
 
157
  main_bboxes = np.asarray(nms(boxes), dtype=np.float64)
158
  if main_bboxes.size == 0:
159
- return []
160
 
161
- # Keep main boxes that appear in enough frames.
162
  matches_per_main = np.zeros(len(main_bboxes), dtype=int)
163
  for bbox in frame_preds:
164
  if bbox.size == 0:
@@ -166,23 +590,49 @@ def infer(video_file):
166
  ious = box_iou(bbox[:, :4], main_bboxes[:, :4])
167
  matches_per_main += (ious > 0).any(axis=1).astype(int)
168
 
169
- keep_main = matches_per_main > n_frames // 2
170
- kept_main = main_bboxes[keep_main] if np.any(keep_main) else np.zeros((0, 5), dtype=np.float64)
 
 
171
 
172
- if kept_main.size == 0:
 
 
 
 
 
 
173
  return []
174
 
175
  outputs = []
176
- for main_box in kept_main:
177
- for frame, bbox in zip(frames, frame_preds):
178
- if bbox.size == 0:
179
- continue
180
- ious = box_iou(bbox[:, :4], main_box[:4].reshape(1, 4))
181
- if (ious > 0).any():
182
- match_idx = int(np.argmax(ious[0]))
183
- outputs.append(_draw_detections(frame, bbox[match_idx : match_idx + 1]))
184
- break
 
 
 
 
 
 
185
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  return outputs
187
 
188
 
@@ -190,7 +640,7 @@ with gr.Blocks() as demo:
190
  gr.Markdown("## Pyronear Wildfire Detection")
191
  with gr.Row():
192
  video_in = gr.Video(label="Upload MP4")
193
- gallery_out = gr.Gallery(label="Wildfire detected", columns=2, height=360)
194
  run_btn = gr.Button("Detect")
195
  run_btn.click(fn=infer, inputs=video_in, outputs=gallery_out)
196
 
 
1
+ import logging
2
  import os
3
+ import shutil
4
+ import subprocess
5
+ import tempfile
6
+ import time
7
+ from collections import deque
8
+ from contextlib import contextmanager
9
 
10
  import cv2
11
  import gradio as gr
 
12
  import numpy as np
13
  from PIL import Image, ImageDraw
14
 
 
16
  from utils import box_iou, nms
17
 
18
 
19
+ LOGGER = logging.getLogger(__name__)
20
+
21
+
22
+ DEFAULT_SPLIT_CFG = {
23
+ "n_samples": 30,
24
+ "max_w": 400,
25
+ "crop_y": (0.25, 0.90),
26
+ "dx_threshold_px": 1.5,
27
+ "min_inlier_ratio": 0.20,
28
+ "min_stable_frames": 2,
29
+ "smooth_window": 2,
30
+ "orb_nfeatures": 800,
31
+ "orb_fast_threshold": 12,
32
+ "min_matches": 25,
33
+ "keep_ratio": 0.4,
34
+ "jump_meanabs_threshold": 18.0,
35
+ "progress_every": 0,
36
+ }
37
+
38
+
39
  def _sample_indices(total, n):
40
  if total <= 0:
41
  return []
 
44
  return np.linspace(0, total - 1, n).astype(int).tolist()
45
 
46
 
47
+ def _format_idx_list(indices, max_items=40):
48
+ if not indices:
49
+ return "[]"
50
+ values = [int(i) for i in indices]
51
+ if len(values) <= max_items:
52
+ return str(values)
53
+ head = values[: max_items // 2]
54
+ tail = values[-(max_items // 2) :]
55
+ return f"{head} ... {tail} (len={len(values)})"
56
 
 
 
 
 
57
 
58
+ def _parse_fraction(value):
59
+ if not value:
60
+ return None
61
+ txt = str(value).strip()
62
+ if not txt or txt == "0/0":
63
+ return None
64
+ if "/" in txt:
65
+ num, den = txt.split("/", 1)
66
+ try:
67
+ den_f = float(den)
68
+ if den_f == 0:
69
+ return None
70
+ return float(num) / den_f
71
+ except Exception:
72
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  try:
74
+ return float(txt)
 
 
 
75
  except Exception:
76
+ return None
 
77
 
78
 
79
+ def _probe_total_frames_ffprobe(video_path):
80
+ ffprobe = shutil.which("ffprobe")
81
+ if ffprobe is None:
82
+ return None
 
 
 
 
 
 
83
 
84
+ # Try direct frame count first.
85
+ cmd = [
86
+ ffprobe,
87
+ "-v",
88
+ "error",
89
+ "-select_streams",
90
+ "v:0",
91
+ "-show_entries",
92
+ "stream=nb_frames",
93
+ "-of",
94
+ "default=noprint_wrappers=1:nokey=1",
95
+ video_path,
96
+ ]
97
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
98
+ if proc.returncode == 0:
99
+ raw = proc.stdout.strip()
100
+ if raw.isdigit():
101
+ val = int(raw)
102
+ if val > 0:
103
+ return val
104
+
105
+ # Fallback: estimate from duration * avg frame rate.
106
+ cmd = [
107
+ ffprobe,
108
+ "-v",
109
+ "error",
110
+ "-select_streams",
111
+ "v:0",
112
+ "-show_entries",
113
+ "stream=avg_frame_rate,duration",
114
+ "-of",
115
+ "default=noprint_wrappers=1:nokey=1",
116
+ video_path,
117
+ ]
118
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
119
+ if proc.returncode != 0:
120
+ return None
121
+
122
+ lines = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
123
+ if len(lines) < 2:
124
+ return None
125
+
126
+ fps = _parse_fraction(lines[0])
127
+ duration = _parse_fraction(lines[1])
128
+ if fps is None or duration is None:
129
+ return None
130
+
131
+ estimate = int(round(fps * duration))
132
+ return estimate if estimate > 0 else None
133
+
134
+
135
+ def _extract_bgr_with_ffmpeg(video_path, n):
136
+ ffmpeg = shutil.which("ffmpeg")
137
+ if ffmpeg is None:
138
+ raise RuntimeError("ffmpeg is not available")
139
+
140
+ total = _probe_total_frames_ffprobe(video_path)
141
+ if total is None or total <= 0:
142
+ raise RuntimeError("ffprobe could not determine total frame count")
143
+
144
+ indices = _sample_indices(total, int(n))
145
+ if not indices:
146
+ return []
147
+
148
+ LOGGER.info(
149
+ "Frame extraction | video=%s total_frames=%d n_samples=%d sampled_indices=%s",
150
+ os.path.basename(video_path),
151
+ total,
152
+ len(indices),
153
+ _format_idx_list(indices),
154
+ )
155
+
156
+ select_expr = "+".join(f"eq(n\\,{int(i)})" for i in indices)
157
+ vf = f"select={select_expr}"
158
+
159
+ with tempfile.TemporaryDirectory(prefix="ffmpeg_frames_") as tmpdir:
160
+ pattern = os.path.join(tmpdir, "frame_%06d.jpg")
161
+ cmd = [
162
+ ffmpeg,
163
+ "-hide_banner",
164
+ "-loglevel",
165
+ "error",
166
+ "-i",
167
+ video_path,
168
+ "-vf",
169
+ vf,
170
+ "-vsync",
171
+ "vfr",
172
+ "-q:v",
173
+ "2",
174
+ pattern,
175
+ ]
176
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
177
+ if proc.returncode != 0:
178
+ raise RuntimeError(proc.stderr.strip() or "ffmpeg extraction failed")
179
+
180
+ frames = []
181
+ for name in sorted(os.listdir(tmpdir)):
182
+ if not name.lower().endswith(".jpg"):
183
+ continue
184
+ frame = cv2.imread(os.path.join(tmpdir, name), cv2.IMREAD_COLOR)
185
+ if frame is not None:
186
+ frames.append(frame)
187
+ LOGGER.info(
188
+ "Frame extraction done | video=%s extracted=%d requested=%d",
189
+ os.path.basename(video_path),
190
+ len(frames),
191
+ len(indices),
192
+ )
193
+ return frames
194
+
195
+
196
+ def _extract_with_ffmpeg(video_path, n):
197
+ frames = _extract_bgr_with_ffmpeg(video_path, n)
198
+ return [Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) for frame in frames]
199
 
200
 
201
  def split_video(video_path, n=8):
202
  if not video_path or not os.path.exists(video_path):
203
  return []
204
+ return _extract_with_ffmpeg(video_path, n)
205
+
206
+
207
+ @contextmanager
208
+ def timer(name, stats):
209
+ t0 = time.perf_counter()
210
+ yield
211
+ stats[name] = stats.get(name, 0.0) + (time.perf_counter() - t0)
212
+
213
+
214
+ def _iter_sampled_frames(video_path, n_samples):
215
+ frames = _extract_bgr_with_ffmpeg(video_path, int(n_samples))
216
+ for out_idx, frame in enumerate(frames):
217
+ yield out_idx, frame
218
+
219
+
220
+ def iter_frames(video_path, n_samples, max_w, crop_y):
221
+ for out_idx, frame in _iter_sampled_frames(video_path, n_samples):
222
+ proc = frame
223
+ if max_w > 0 and proc.shape[1] != max_w:
224
+ scale = max_w / float(proc.shape[1])
225
+ proc = cv2.resize(
226
+ proc,
227
+ (max_w, int(proc.shape[0] * scale)),
228
+ interpolation=cv2.INTER_AREA,
229
+ )
230
 
231
+ if crop_y is not None:
232
+ h = proc.shape[0]
233
+ y0 = int(max(0.0, min(1.0, float(crop_y[0]))) * h)
234
+ y1 = int(max(0.0, min(1.0, float(crop_y[1]))) * h)
235
+ if y1 > y0:
236
+ proc = proc[y0:y1, :]
237
 
238
+ yield out_idx, proc
239
+
240
+
241
+ def quick_jump_score(prev_gray, gray, small_w=160):
242
+ h, w = prev_gray.shape[:2]
243
+ if w > small_w:
244
+ scale = small_w / float(w)
245
+ prev_s = cv2.resize(prev_gray, (small_w, int(h * scale)), interpolation=cv2.INTER_AREA)
246
+ gray_s = cv2.resize(gray, (small_w, int(h * scale)), interpolation=cv2.INTER_AREA)
247
+ else:
248
+ prev_s = prev_gray
249
+ gray_s = gray
250
+
251
+ diff = cv2.absdiff(prev_s, gray_s)
252
+ return float(np.mean(diff))
253
+
254
+
255
+ def estimate_dx_orb_affine(prev_gray, gray, orb, bf, min_matches, keep_ratio, timing_pair):
256
+ with timer("orb_detect_compute", timing_pair):
257
+ kp1, des1 = orb.detectAndCompute(prev_gray, None)
258
+ kp2, des2 = orb.detectAndCompute(gray, None)
259
+
260
+ if des1 is None or des2 is None or len(kp1) < 8 or len(kp2) < 8:
261
+ return None
262
+
263
+ with timer("bf_match", timing_pair):
264
+ matches = bf.match(des1, des2)
265
+
266
+ if len(matches) < min_matches:
267
+ return None
268
+
269
+ with timer("match_sort_filter", timing_pair):
270
+ matches = sorted(matches, key=lambda m: m.distance)
271
+ keep_n = max(8, int(len(matches) * keep_ratio))
272
+ matches = matches[:keep_n]
273
+
274
+ pts1 = np.float32([kp1[m.queryIdx].pt for m in matches])
275
+ pts2 = np.float32([kp2[m.trainIdx].pt for m in matches])
276
+
277
+ with timer("ransac_affine", timing_pair):
278
+ M, inliers = cv2.estimateAffinePartial2D(
279
+ pts1,
280
+ pts2,
281
+ method=cv2.RANSAC,
282
+ ransacReprojThreshold=3.0,
283
+ maxIters=1500,
284
+ confidence=0.99,
285
+ )
286
+
287
+ if M is None:
288
+ return None
289
+
290
+ dx = float(M[0, 2])
291
+ dy = float(M[1, 2])
292
+ inlier_ratio = float(np.mean(inliers)) if inliers is not None else 0.0
293
+
294
+ return {
295
+ "dx": dx,
296
+ "dy": dy,
297
+ "score_dx": float(abs(dx)),
298
+ "score_px": float(np.hypot(dx, dy)),
299
+ "inlier_ratio": inlier_ratio,
300
+ "matches": len(matches),
301
+ "M": M,
302
+ }
303
+
304
+
305
+ def split_video_into_stable_segments_fast(
306
+ video_path,
307
+ n_samples=30,
308
+ max_w=400,
309
+ crop_y=(0.25, 0.90),
310
+ dx_threshold_px=1.5,
311
+ min_inlier_ratio=0.20,
312
+ min_stable_frames=2,
313
+ smooth_window=2,
314
+ orb_nfeatures=800,
315
+ orb_fast_threshold=12,
316
+ min_matches=25,
317
+ keep_ratio=0.4,
318
+ jump_meanabs_threshold=18.0,
319
+ progress_every=200,
320
+ ):
321
+ timing_total = {}
322
+ timing_pair = {}
323
+
324
+ with timer("setup", timing_total):
325
+ orb = cv2.ORB_create(nfeatures=orb_nfeatures, fastThreshold=orb_fast_threshold)
326
+ bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
327
+
328
+ metrics = []
329
+ prev_gray = None
330
+ frame_count = 0
331
+
332
+ with timer("loop_total", timing_total):
333
+ for _, frame in iter_frames(video_path, n_samples=n_samples, max_w=max_w, crop_y=crop_y):
334
+ frame_count += 1
335
+
336
+ with timer("to_gray", timing_total):
337
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
338
+
339
+ if prev_gray is not None:
340
+ with timer("quick_jump", timing_total):
341
+ q = quick_jump_score(prev_gray, gray)
342
+
343
+ if q >= jump_meanabs_threshold:
344
+ metrics.append(
345
+ {
346
+ "dx": np.nan,
347
+ "dy": np.nan,
348
+ "score_dx": 1e9,
349
+ "score_px": 1e9,
350
+ "inlier_ratio": 0.0,
351
+ "matches": 0,
352
+ "M": None,
353
+ "quick_jump": q,
354
+ }
355
+ )
356
+ else:
357
+ m = estimate_dx_orb_affine(
358
+ prev_gray,
359
+ gray,
360
+ orb=orb,
361
+ bf=bf,
362
+ min_matches=min_matches,
363
+ keep_ratio=keep_ratio,
364
+ timing_pair=timing_pair,
365
+ )
366
+ if m is None:
367
+ metrics.append(
368
+ {
369
+ "dx": np.nan,
370
+ "dy": np.nan,
371
+ "score_dx": 1e9,
372
+ "score_px": 1e9,
373
+ "inlier_ratio": 0.0,
374
+ "matches": 0,
375
+ "M": None,
376
+ "quick_jump": q,
377
+ }
378
+ )
379
+ else:
380
+ m["quick_jump"] = q
381
+ metrics.append(m)
382
+
383
+ if progress_every and (len(metrics) % progress_every == 0):
384
+ print(f"processed pairs: {len(metrics)}")
385
+
386
+ prev_gray = gray
387
+
388
+ if frame_count < 2:
389
+ return [], metrics, [], {"total": timing_total, "per_pair": timing_pair}
390
+
391
+ with timer("post_smooth", timing_total):
392
+ raw_dx = [m["score_dx"] for m in metrics]
393
+ raw_inlier = [m["inlier_ratio"] for m in metrics]
394
+
395
+ smoothed_dx = []
396
+ q = deque(maxlen=max(1, int(smooth_window)))
397
+ for v in raw_dx:
398
+ if not np.isfinite(v):
399
+ q.clear()
400
+ smoothed_dx.append(np.nan)
401
+ else:
402
+ q.append(v)
403
+ smoothed_dx.append(float(np.mean(q)))
404
+
405
+ with timer("post_segments", timing_total):
406
+ min_len = max(1, int(min_stable_frames))
407
+
408
+ stable_flags = []
409
+ for dx_s, r in zip(smoothed_dx, raw_inlier):
410
+ if not np.isfinite(dx_s):
411
+ stable_flags.append(False)
412
+ else:
413
+ stable_flags.append((dx_s < dx_threshold_px) and (r >= min_inlier_ratio))
414
+
415
+ segments = []
416
+ start = None
417
+ for i, is_stable in enumerate(stable_flags):
418
+ if is_stable and start is None:
419
+ start = i
420
+ if (not is_stable) and start is not None:
421
+ end = i
422
+ if (end - start) >= min_len:
423
+ segments.append((start, end))
424
+ start = None
425
+
426
+ if start is not None:
427
+ end = len(stable_flags)
428
+ if (end - start) >= min_len:
429
+ segments.append((start, end))
430
+
431
+ LOGGER.info(
432
+ "Segmentation summary | sampled_frames=%d pair_metrics=%d stable_segments=%d",
433
+ frame_count,
434
+ len(metrics),
435
+ len(segments),
436
+ )
437
+ if segments:
438
+ LOGGER.info("Segment ranges (sample indices) | %s", segments)
439
+
440
+ return segments, metrics, smoothed_dx, {"total": timing_total, "per_pair": timing_pair}
441
+
442
+
443
+ def _bgr_to_pil(frame):
444
+ return Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
445
+
446
+
447
+ def extract_segment_frames(video_path, segments, n_samples):
448
+ if not segments:
449
+ LOGGER.info("Segment frame extraction | no segments found")
450
+ return []
451
+
452
+ normalized_segments = []
453
+ for start, end in segments:
454
+ s = max(0, int(start))
455
+ e = max(s, int(end))
456
+ normalized_segments.append((s, e))
457
+
458
+ normalized_segments.sort(key=lambda x: x[0])
459
+ grouped_frames = [[] for _ in normalized_segments]
460
+ grouped_indices = [[] for _ in normalized_segments]
461
+ segment_idx = 0
462
+
463
+ # Detection runs on original sampled frames (no resize / no crop).
464
+ for frame_idx, frame in _iter_sampled_frames(video_path, n_samples=n_samples):
465
+ while segment_idx < len(normalized_segments) and frame_idx > normalized_segments[segment_idx][1]:
466
+ segment_idx += 1
467
+
468
+ if segment_idx >= len(normalized_segments):
469
+ break
470
+
471
+ seg_start, seg_end = normalized_segments[segment_idx]
472
+ if seg_start <= frame_idx <= seg_end:
473
+ grouped_frames[segment_idx].append(_bgr_to_pil(frame))
474
+ grouped_indices[segment_idx].append(frame_idx)
475
+
476
+ LOGGER.info(
477
+ "Segment frame extraction summary | segments=%d n_samples=%d",
478
+ len(normalized_segments),
479
+ n_samples,
480
+ )
481
+ for seg_i, ((seg_start, seg_end), idx_list, frames) in enumerate(
482
+ zip(normalized_segments, grouped_indices, grouped_frames),
483
+ start=1,
484
+ ):
485
+ LOGGER.info(
486
+ "Segment %d | requested_range=[%d,%d] matched_frames=%d matched_indices=%s",
487
+ seg_i,
488
+ seg_start,
489
+ seg_end,
490
+ len(frames),
491
+ _format_idx_list(idx_list),
492
+ )
493
+
494
+ return [frames for frames in grouped_frames if frames]
495
+
496
+
497
+ def split_video_stable(video_path, split_cfg=None, fallback_n=30):
498
+ if not video_path or not os.path.exists(video_path):
499
+ return []
500
+
501
+ cfg = DEFAULT_SPLIT_CFG.copy()
502
+ if split_cfg:
503
+ cfg.update(split_cfg)
504
+
505
+ LOGGER.info("Split config | %s", cfg)
506
+
507
+ segments, _, _, _ = split_video_into_stable_segments_fast(video_path, **cfg)
508
+ frame_groups = extract_segment_frames(
509
+ video_path,
510
+ segments,
511
+ n_samples=cfg["n_samples"],
512
+ )
513
+
514
+ if frame_groups:
515
+ LOGGER.info(
516
+ "Split result | stable_splits=%d split_frame_counts=%s",
517
+ len(frame_groups),
518
+ [len(group) for group in frame_groups],
519
+ )
520
+ return frame_groups
521
+
522
+ LOGGER.info("Split result | no stable segment, using fallback sampling n=%d", fallback_n)
523
+ fallback_frames = split_video(video_path, n=fallback_n)
524
+ LOGGER.info("Fallback frame count | %d", len(fallback_frames))
525
+ return [fallback_frames] if fallback_frames else []
526
+
527
+
528
+ model = Classifier(format="onnx", conf=0.05)
529
 
530
 
531
  def _resolve_video_path(video_input):
 
545
  return None
546
 
547
 
548
+ def _draw_detections(pil_img, preds, subtitle=None):
549
  img = pil_img.copy()
550
  draw = ImageDraw.Draw(img)
551
  width, height = img.size
 
561
  draw.text((x1 + 4, y1 + 4), f"{conf:.2f}", fill=color)
562
 
563
  draw.text((6, 6), f"detections: {len(preds)}", fill=color)
564
+ if subtitle:
565
+ draw.text((6, 26), subtitle, fill=color)
566
  return img
567
 
568
 
569
+ def _combine_predictions_per_split(frame_preds):
570
+ n_frames = len(frame_preds)
571
+ if n_frames == 0:
572
+ return np.zeros((0, 5), dtype=np.float64)
 
 
 
 
573
 
574
+ boxes = np.zeros((0, 5), dtype=np.float64)
575
+ for bbox in frame_preds:
 
576
  if bbox.size > 0:
577
  boxes = np.vstack([boxes, bbox])
578
 
579
  if boxes.size == 0:
580
+ return np.zeros((0, 5), dtype=np.float64)
581
 
582
  main_bboxes = np.asarray(nms(boxes), dtype=np.float64)
583
  if main_bboxes.size == 0:
584
+ return np.zeros((0, 5), dtype=np.float64)
585
 
 
586
  matches_per_main = np.zeros(len(main_bboxes), dtype=int)
587
  for bbox in frame_preds:
588
  if bbox.size == 0:
 
590
  ious = box_iou(bbox[:, :4], main_bboxes[:, :4])
591
  matches_per_main += (ious > 0).any(axis=1).astype(int)
592
 
593
+ keep_main = matches_per_main > n_frames // 4
594
+ if np.any(keep_main):
595
+ return main_bboxes[keep_main]
596
+ return np.zeros((0, 5), dtype=np.float64)
597
 
598
+
599
+ def infer(video_file):
600
+ video_path = _resolve_video_path(video_file)
601
+ LOGGER.info("Inference start | video=%s", video_path)
602
+ split_frames = split_video_stable(video_path)
603
+ if not split_frames:
604
+ LOGGER.info("Inference stop | no frames available")
605
  return []
606
 
607
  outputs = []
608
+ for split_idx, frames in enumerate(split_frames):
609
+ LOGGER.info("Inference split %d | frames=%d", split_idx + 1, len(frames))
610
+ frame_preds = []
611
+ for frame in frames:
612
+ bbox = np.asarray(model(frame), dtype=np.float64).reshape(-1, 5)
613
+ frame_preds.append(bbox)
614
+
615
+ kept_main = _combine_predictions_per_split(frame_preds)
616
+ LOGGER.info(
617
+ "Inference split %d | combined_detections=%d",
618
+ split_idx + 1,
619
+ len(kept_main),
620
+ )
621
+ if kept_main.size == 0:
622
+ continue
623
 
624
+ for det_idx, main_box in enumerate(kept_main):
625
+ for frame, bbox in zip(frames, frame_preds):
626
+ if bbox.size == 0:
627
+ continue
628
+ ious = box_iou(bbox[:, :4], main_box[:4].reshape(1, 4))
629
+ if (ious > 0).any():
630
+ match_idx = int(np.argmax(ious[0]))
631
+ subtitle = f"split {split_idx + 1} / det {det_idx + 1}"
632
+ outputs.append(_draw_detections(frame, bbox[match_idx : match_idx + 1], subtitle=subtitle))
633
+ break
634
+
635
+ LOGGER.info("Inference done | output_images=%d", len(outputs))
636
  return outputs
637
 
638
 
 
640
  gr.Markdown("## Pyronear Wildfire Detection")
641
  with gr.Row():
642
  video_in = gr.Video(label="Upload MP4")
643
+ gallery_out = gr.Gallery(label="Wildfire detected (per split)", columns=2, height=360)
644
  run_btn = gr.Button("Detect")
645
  run_btn.click(fn=infer, inputs=video_in, outputs=gallery_out)
646
 
requirements.txt CHANGED
@@ -2,7 +2,5 @@ gradio
2
  numpy
3
  Pillow
4
  opencv-python
5
- imageio
6
- imageio-ffmpeg
7
  onnxruntime
8
  tqdm
 
2
  numpy
3
  Pillow
4
  opencv-python
 
 
5
  onnxruntime
6
  tqdm