emirkisa commited on
Commit
66c99d0
·
verified ·
1 Parent(s): 2e50215

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. .gitignore +4 -0
  2. README.md +40 -5
  3. app.py +980 -0
  4. packages.txt +1 -0
  5. requirements.txt +5 -0
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .env
README.md CHANGED
@@ -1,12 +1,47 @@
1
  ---
2
- title: DAVIS Dataset Explorer
3
- emoji: 🏆
4
- colorFrom: pink
5
- colorTo: pink
6
  sdk: gradio
7
  sdk_version: 6.9.0
8
  app_file: app.py
9
  pinned: false
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: DAVIS-Dataset-Explorer
3
+ emoji: 🎬
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.9.0
8
  app_file: app.py
9
  pinned: false
10
+ license: cc-by-nc-4.0
11
  ---
12
 
13
+ # DAVIS Dataset Explorer
14
+
15
+ Interactive browser for the **DAVIS 2017 Video Object Segmentation** benchmark (480p split).
16
+
17
+ ## Features
18
+
19
+ | Tab | What it does |
20
+ |-----|-------------|
21
+ | 📋 Browse | Filter & search all 90 sequences; click a row to inspect metadata |
22
+ | 🔍 Viewer | Frame-by-frame scrubber with DAVIS palette mask overlay; single-sequence video playback |
23
+ | 🖼 Gallery | Thumbnail grid of all sequences; click a thumbnail to instantly play it |
24
+ | 📺 Multi-Video | Paged 3×3 video grid — page through all 90 sequences, 9 at a time |
25
+ | ⚖️ Compare | Up to 6 sequences side-by-side |
26
+ | 📊 Statistics | Distribution plots (frames, objects, splits, resolution) |
27
+
28
+ ## First-run behaviour
29
+
30
+ On the first startup the app downloads **DAVIS-2017-trainval-480p.zip** (~800 MB)
31
+ from the official ETH Zurich server and extracts it to `DAVIS_ROOT`.
32
+
33
+ With HF Spaces **persistent storage** the data is stored under `/data/DAVIS` and
34
+ the MP4 cache under `/data/DAVIS_explorer_cache` — both survive restarts.
35
+
36
+ ## Environment variables
37
+
38
+ | Variable | Default | Description |
39
+ |----------|---------|-------------|
40
+ | `DAVIS_ROOT` | `/data/DAVIS` (Spaces) or local path | Dataset root directory |
41
+ | `DAVIS_CACHE_DIR` | `DAVIS_ROOT/../DAVIS_explorer_cache` | Where encoded MP4s are stored |
42
+
43
+ ## Dataset
44
+
45
+ DAVIS 2017 — *The 2017 DAVIS Challenge on Video Object Segmentation*
46
+ Pont-Tuset et al., arXiv:1704.00675
47
+ Licensed under [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/).
app.py ADDED
@@ -0,0 +1,980 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DAVIS Dataset Explorer
3
+ ======================
4
+ Interactive Gradio app for browsing, viewing and analysing the DAVIS 2017
5
+ video object segmentation dataset (480p split).
6
+
7
+ Usage (from repo root):
8
+ python scripts/davis_explorer/app.py
9
+
10
+ # Custom DAVIS root:
11
+ DAVIS_ROOT=/path/to/DAVIS python scripts/davis_explorer/app.py
12
+
13
+ # Public link:
14
+ python scripts/davis_explorer/app.py --share
15
+
16
+ Dataset layout expected:
17
+ <DAVIS_ROOT>/
18
+ JPEGImages/480p/<sequence>/%05d.jpg
19
+ Annotations/480p/<sequence>/%05d.png
20
+ ImageSets/2016/{train,val}.txt
21
+ ImageSets/2017/{train,val}.txt
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import os
28
+ import shutil
29
+ import subprocess
30
+ import threading
31
+ from concurrent.futures import ThreadPoolExecutor, as_completed
32
+ from functools import lru_cache
33
+ from pathlib import Path
34
+
35
+ import gradio as gr
36
+ import numpy as np
37
+ import pandas as pd
38
+ import plotly.express as px
39
+ from PIL import Image
40
+
41
+ # ── Configuration ──────────────────────────────────────────────────────────────
42
+
43
+ # Official ETH Zurich download — DAVIS 2017 trainval 480p (~800 MB zipped).
44
+ # The zip extracts to a top-level DAVIS/ directory.
45
+ DAVIS_ZIP_URL = (
46
+ "https://data.vision.ee.ethz.ch/csergi/share/davis/"
47
+ "DAVIS-2017-trainval-480p.zip"
48
+ )
49
+
50
+ IS_HF_SPACE = bool(os.environ.get("SPACE_ID"))
51
+
52
+ # Path resolution:
53
+ # • HF Spaces with persistent storage → /data/DAVIS
54
+ # • HF Spaces without persistent storage → /tmp/DAVIS
55
+ # • Local → workspace path (or DAVIS_ROOT env var)
56
+ if IS_HF_SPACE:
57
+ _hf_base = Path("/data") if Path("/data").exists() else Path("/tmp")
58
+ _local_root = _hf_base / "DAVIS"
59
+ else:
60
+ _local_root = Path("/workspace/diffusion-research/data/raw/DAVIS")
61
+
62
+ DAVIS_ROOT = Path(os.environ.get("DAVIS_ROOT", str(_local_root)))
63
+
64
+ IMG_DIR = DAVIS_ROOT / "JPEGImages" / "480p"
65
+ ANN_DIR = DAVIS_ROOT / "Annotations" / "480p"
66
+ SETS_DIR = DAVIS_ROOT / "ImageSets"
67
+
68
+ # Cache lives as a sibling of DAVIS_ROOT so the path is always valid.
69
+ CACHE_DIR = Path(os.environ.get(
70
+ "DAVIS_CACHE_DIR",
71
+ str(DAVIS_ROOT.parent / "DAVIS_explorer_cache"),
72
+ ))
73
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
74
+
75
+ DAVIS_PALETTE = np.array([
76
+ [ 0, 0, 0], [128, 0, 0], [ 0, 128, 0], [128, 128, 0],
77
+ [ 0, 0, 128], [128, 0, 128], [ 0, 128, 128], [128, 128, 128],
78
+ [ 64, 0, 0], [192, 0, 0], [ 64, 128, 0], [192, 128, 0],
79
+ [ 64, 0, 128], [192, 0, 128], [ 64, 128, 128], [192, 128, 128],
80
+ [ 0, 64, 0], [128, 64, 0], [ 0, 192, 0], [128, 192, 0],
81
+ ], dtype=np.uint8)
82
+
83
+ DEFAULT_FPS = 24
84
+ DEFAULT_ALPHA = 0.55
85
+ DEFAULT_CRF = 18
86
+ MAX_COMPARE = 6 # slots in the Compare tab
87
+ PAGE_SIZE = 9 # videos per page in Multi-Video tab
88
+ THUMB_W, THUMB_H = 320, 200 # thumbnail dimensions for Gallery
89
+
90
+ # ── Dataset download ───────────────────────────────────────────────────────────
91
+
92
+ def ensure_dataset() -> None:
93
+ """Download and extract DAVIS 2017 trainval (480p) if not already present.
94
+
95
+ Safe to call every startup — exits immediately when data is found.
96
+ The zip extracts into a top-level ``DAVIS/`` directory, so we extract
97
+ into ``DAVIS_ROOT.parent`` which gives the expected ``DAVIS_ROOT`` layout.
98
+ """
99
+ if IMG_DIR.exists() and any(IMG_DIR.iterdir()):
100
+ return # data already present
101
+
102
+ import urllib.request
103
+ import zipfile
104
+
105
+ DAVIS_ROOT.mkdir(parents=True, exist_ok=True)
106
+ zip_dst = DAVIS_ROOT.parent / "_davis_download.zip"
107
+
108
+ print(f"DAVIS dataset not found at {DAVIS_ROOT}")
109
+ print(f"Downloading {DAVIS_ZIP_URL} (~800 MB) …")
110
+
111
+ _last_pct: list[int] = [-1]
112
+
113
+ def _progress(count: int, block: int, total: int) -> None:
114
+ pct = min(100, int(count * block / total * 100))
115
+ if pct != _last_pct[0] and pct % 5 == 0:
116
+ bar = "█" * (pct // 5) + "░" * (20 - pct // 5)
117
+ print(f" [{bar}] {pct:3d}%", end="\r", flush=True)
118
+ _last_pct[0] = pct
119
+
120
+ try:
121
+ urllib.request.urlretrieve(DAVIS_ZIP_URL, zip_dst, _progress)
122
+ except Exception as exc:
123
+ zip_dst.unlink(missing_ok=True)
124
+ raise RuntimeError(f"Download failed: {exc}") from exc
125
+
126
+ print(f"\n Download complete ({zip_dst.stat().st_size // 1_048_576} MB). Extracting…")
127
+
128
+ with zipfile.ZipFile(zip_dst, "r") as zf:
129
+ zf.extractall(DAVIS_ROOT.parent)
130
+
131
+ zip_dst.unlink(missing_ok=True)
132
+
133
+ if not IMG_DIR.exists():
134
+ raise RuntimeError(
135
+ f"Extraction failed — expected {IMG_DIR} not found. "
136
+ "Check that the zip contains a top-level DAVIS/ directory."
137
+ )
138
+ print(f" DAVIS dataset ready at {DAVIS_ROOT}")
139
+
140
+
141
+ # ── Dataset loading ─────��──────────────────────────────────────────────────────
142
+
143
+ def _read_split(year: str, split: str) -> list[str]:
144
+ p = SETS_DIR / year / f"{split}.txt"
145
+ return p.read_text().strip().splitlines() if p.exists() else []
146
+
147
+
148
+ def _count_objects(seq: str) -> int:
149
+ ann_seq = ANN_DIR / seq
150
+ if not ann_seq.exists():
151
+ return 0
152
+ files = sorted(ann_seq.iterdir())
153
+ return int(np.max(np.array(Image.open(files[0])))) if files else 0
154
+
155
+
156
+ def build_dataframe() -> pd.DataFrame:
157
+ seqs = sorted(d.name for d in IMG_DIR.iterdir() if d.is_dir())
158
+ s16_train = set(_read_split("2016", "train"))
159
+ s16_val = set(_read_split("2016", "val"))
160
+ s17_train = set(_read_split("2017", "train"))
161
+ s17_val = set(_read_split("2017", "val"))
162
+ rows = []
163
+ for seq in seqs:
164
+ imgs = sorted((IMG_DIR / seq).glob("*.jpg"))
165
+ n = len(imgs)
166
+ n_obj = _count_objects(seq)
167
+ w, h = Image.open(imgs[0]).size if imgs else (0, 0)
168
+ in16t, in16v = seq in s16_train, seq in s16_val
169
+ in17t, in17v = seq in s17_train, seq in s17_val
170
+ splits = (["2016-train"] * in16t + ["2016-val"] * in16v +
171
+ ["2017-train"] * in17t + ["2017-val"] * in17v)
172
+ rows.append({
173
+ "sequence": seq, "frames": n, "n_objects": n_obj,
174
+ "width": w, "height": h, "resolution": f"{w}×{h}",
175
+ "split": ", ".join(splits) or "unlisted",
176
+ "in_2016": in16t or in16v, "in_2017": in17t or in17v,
177
+ "in_train": in16t or in17t, "in_val": in16v or in17v,
178
+ })
179
+ return pd.DataFrame(rows)
180
+
181
+
182
+ ensure_dataset()
183
+ print("Loading DAVIS metadata…")
184
+ DF = build_dataframe()
185
+ ALL_SEQUENCES = sorted(DF["sequence"].tolist())
186
+ print(f" {len(DF)} sequences · frames {DF['frames'].min()}–{DF['frames'].max()} "
187
+ f"· objects {DF['n_objects'].min()}–{DF['n_objects'].max()}")
188
+
189
+ DISPLAY_COLS = ["sequence", "frames", "n_objects", "resolution", "split"]
190
+
191
+ # ── Frame helpers ──────────────────────────────────────────────────────────────
192
+
193
+ @lru_cache(maxsize=16)
194
+ def _get_frame_paths(seq: str) -> list[Path]:
195
+ return sorted((IMG_DIR / seq).glob("*.jpg"))
196
+
197
+
198
+ @lru_cache(maxsize=16)
199
+ def _get_ann_paths(seq: str) -> list[Path]:
200
+ d = ANN_DIR / seq
201
+ return sorted(d.glob("*.png")) if d.exists() else []
202
+
203
+
204
+ def _blend(img_f32: np.ndarray, ann: np.ndarray, alpha: float) -> np.ndarray:
205
+ ov = DAVIS_PALETTE[np.clip(ann, 0, len(DAVIS_PALETTE) - 1)].astype(np.float32)
206
+ a = np.where(ann == 0, 0.0, alpha).astype(np.float32)[:, :, None]
207
+ return (img_f32 * (1 - a) + ov * a).clip(0, 255).astype(np.uint8)
208
+
209
+
210
+ def render_frame(seq: str, idx: int, overlay: bool, alpha: float) -> Image.Image:
211
+ fps = _get_frame_paths(seq)
212
+ if not fps:
213
+ return Image.new("RGB", (854, 480), 20)
214
+ idx = min(max(0, idx), len(fps) - 1)
215
+ arr = np.array(Image.open(fps[idx]).convert("RGB"), dtype=np.float32)
216
+ if overlay:
217
+ anns = _get_ann_paths(seq)
218
+ if idx < len(anns):
219
+ arr = _blend(arr, np.array(Image.open(anns[idx])), alpha).astype(np.float32)
220
+ return Image.fromarray(arr.clip(0, 255).astype(np.uint8))
221
+
222
+
223
+ def render_mask(seq: str, idx: int) -> Image.Image:
224
+ anns = _get_ann_paths(seq)
225
+ if not anns:
226
+ return Image.new("RGB", (854, 480), 20)
227
+ idx = min(max(0, idx), len(anns) - 1)
228
+ ann = np.array(Image.open(anns[idx]))
229
+ rgb = np.zeros((*ann.shape, 3), dtype=np.uint8)
230
+ for oid in range(1, len(DAVIS_PALETTE)):
231
+ m = ann == oid
232
+ if m.any():
233
+ rgb[m] = DAVIS_PALETTE[oid]
234
+ return Image.fromarray(rgb)
235
+
236
+
237
+ # ── MP4 helpers ────────────────────────────────────────────────────────────────
238
+
239
+ def _mp4_path(seq: str, overlay: bool, alpha: float, fps: int) -> Path:
240
+ tag = f"ov{int(alpha * 100):03d}" if overlay else "raw"
241
+ return CACHE_DIR / f"{seq}_{tag}_{fps}fps.mp4"
242
+
243
+
244
+ def _ffmpeg(pattern: str, out: Path, fps: int) -> None:
245
+ cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", pattern,
246
+ "-c:v", "libx264", "-preset", "fast", "-pix_fmt", "yuv420p",
247
+ "-crf", str(DEFAULT_CRF), "-movflags", "+faststart",
248
+ "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", str(out)]
249
+ r = subprocess.run(cmd, capture_output=True, text=True)
250
+ if r.returncode != 0:
251
+ raise RuntimeError(r.stderr[-600:])
252
+
253
+
254
+ def encode_sequence(seq: str, overlay: bool, alpha: float, fps: int) -> Path:
255
+ out = _mp4_path(seq, overlay, round(alpha, 2), fps)
256
+ if out.exists():
257
+ return out
258
+ fps_paths = _get_frame_paths(seq)
259
+ if not fps_paths:
260
+ raise FileNotFoundError(f"No frames for {seq}")
261
+ if not overlay:
262
+ _ffmpeg(str(IMG_DIR / seq / "%05d.jpg"), out, fps)
263
+ return out
264
+ anns = _get_ann_paths(seq)
265
+ tmp = CACHE_DIR / f"_tmp_{seq}_{int(alpha*100):03d}"
266
+ tmp.mkdir(exist_ok=True)
267
+ try:
268
+ for i, fp in enumerate(fps_paths):
269
+ arr = np.array(Image.open(fp).convert("RGB"), dtype=np.float32)
270
+ if i < len(anns):
271
+ arr = _blend(arr, np.array(Image.open(anns[i])), alpha).astype(np.float32)
272
+ Image.fromarray(arr.clip(0, 255).astype(np.uint8)).save(
273
+ tmp / f"{i:05d}.png", optimize=False)
274
+ _ffmpeg(str(tmp / "%05d.png"), out, fps)
275
+ finally:
276
+ shutil.rmtree(tmp, ignore_errors=True)
277
+ return out
278
+
279
+
280
+ def get_video(seq: str, overlay: bool, alpha: float, fps: int) -> tuple[str | None, str]:
281
+ if not seq or seq not in ALL_SEQUENCES:
282
+ return None, "No sequence selected."
283
+ try:
284
+ p = encode_sequence(seq, overlay, round(alpha, 2), fps)
285
+ n = int(DF[DF["sequence"] == seq].iloc[0]["frames"])
286
+ size = p.stat().st_size // 1024
287
+ mode = "overlay" if overlay else "raw"
288
+ return str(p), f"✅ **{seq}** · {n} frames · {fps} fps · {mode} · {size} KB"
289
+ except Exception as e:
290
+ return None, f"❌ {e}"
291
+
292
+
293
+ # ── Background pre-cache ───────────────────────────────────────────────────────
294
+
295
+ _cache_progress: dict[str, str] = {}
296
+ _cache_lock = threading.Lock()
297
+
298
+
299
+ def _precache_worker(seq: str, fps: int) -> None:
300
+ with _cache_lock:
301
+ _cache_progress[seq] = "encoding…"
302
+ try:
303
+ encode_sequence(seq, False, DEFAULT_ALPHA, fps)
304
+ encode_sequence(seq, True, DEFAULT_ALPHA, fps)
305
+ with _cache_lock:
306
+ _cache_progress[seq] = "done"
307
+ except Exception as e:
308
+ with _cache_lock:
309
+ _cache_progress[seq] = f"error: {e}"
310
+
311
+
312
+ def start_precache(fps: int = DEFAULT_FPS, workers: int = 4) -> None:
313
+ missing = [s for s in ALL_SEQUENCES
314
+ if not _mp4_path(s, False, DEFAULT_ALPHA, fps).exists()
315
+ or not _mp4_path(s, True, DEFAULT_ALPHA, fps).exists()]
316
+ if not missing:
317
+ print(f" MP4 cache complete ({len(ALL_SEQUENCES)}×2 already exist)")
318
+ for s in ALL_SEQUENCES:
319
+ _cache_progress[s] = "done"
320
+ return
321
+ print(f" Pre-caching {len(missing)} sequences (workers={workers})…")
322
+ def _run():
323
+ with ThreadPoolExecutor(max_workers=workers) as pool:
324
+ futs = {pool.submit(_precache_worker, s, fps): s for s in missing}
325
+ done = 0
326
+ for f in as_completed(futs):
327
+ done += 1
328
+ s = futs[f]
329
+ if done % 10 == 0 or done == len(missing):
330
+ print(f" Cache {done}/{len(missing)} ({s}: {_cache_progress.get(s)})")
331
+ threading.Thread(target=_run, daemon=True).start()
332
+
333
+
334
+ # ── Gallery helpers ────────────────────────────────────────────────────────────
335
+
336
+ def _make_thumb(seq: str, overlay: bool = False, alpha: float = 0.0) -> Image.Image:
337
+ fps = _get_frame_paths(seq)
338
+ if not fps:
339
+ return Image.new("RGB", (THUMB_W, THUMB_H), 30)
340
+ img = render_frame(seq, 0, overlay, alpha) if overlay else Image.open(fps[0]).convert("RGB")
341
+ img = img.copy()
342
+ img.thumbnail((THUMB_W, THUMB_H), Image.LANCZOS)
343
+ return img
344
+
345
+
346
+ def build_gallery_items(seqs: list[str], overlay: bool = False) -> list[tuple]:
347
+ items = []
348
+ for seq in seqs:
349
+ row = DF[DF["sequence"] == seq].iloc[0]
350
+ caption = f"{seq} [{row['frames']}f · {row['n_objects']}obj]"
351
+ items.append((_make_thumb(seq, overlay), caption))
352
+ return items
353
+
354
+
355
+ print("Building gallery thumbnails…")
356
+ _ALL_THUMBS: list[tuple] = build_gallery_items(ALL_SEQUENCES)
357
+ print(" Done.")
358
+
359
+
360
+ # ── Filter helpers ─────────────────────────────────────────────────────────────
361
+
362
+ def filter_df(year_f, split_f, obj_f, fmin, fmax, search) -> pd.DataFrame:
363
+ d = DF.copy()
364
+ if year_f == "2016 only": d = d[d["in_2016"]]
365
+ elif year_f == "2017 only": d = d[d["in_2017"]]
366
+ if split_f == "Train only": d = d[d["in_train"]]
367
+ elif split_f == "Val only": d = d[d["in_val"]]
368
+ if obj_f == "1 object": d = d[d["n_objects"] == 1]
369
+ elif obj_f == "2 objects": d = d[d["n_objects"] == 2]
370
+ elif obj_f == "3+ objects": d = d[d["n_objects"] >= 3]
371
+ d = d[(d["frames"] >= fmin) & (d["frames"] <= fmax)]
372
+ if search.strip():
373
+ d = d[d["sequence"].str.lower().str.contains(search.strip().lower(), na=False)]
374
+ return d[DISPLAY_COLS].reset_index(drop=True)
375
+
376
+
377
+ def _seq_info(seq: str) -> str:
378
+ if seq not in ALL_SEQUENCES:
379
+ return ""
380
+ r = DF[DF["sequence"] == seq].iloc[0]
381
+ return (f"**{seq}** �� {r['frames']} frames · {r['n_objects']} obj · "
382
+ f"{r['resolution']} · _{r['split']}_")
383
+
384
+
385
+ def get_legend(seq: str) -> str:
386
+ if seq not in ALL_SEQUENCES:
387
+ return ""
388
+ n = int(DF[DF["sequence"] == seq].iloc[0]["n_objects"])
389
+ if n == 0:
390
+ return "*No annotated objects.*"
391
+ lines = ["**Objects:**"]
392
+ for i in range(1, min(n + 1, len(DAVIS_PALETTE))):
393
+ hx = "#{:02X}{:02X}{:02X}".format(*DAVIS_PALETTE[i])
394
+ lines.append(f"- <span style='color:{hx};font-weight:bold'>■</span> Object {i}")
395
+ return "\n".join(lines)
396
+
397
+
398
+ def cache_status_md() -> str:
399
+ with _cache_lock:
400
+ done = sum(1 for v in _cache_progress.values() if v == "done")
401
+ total = len(ALL_SEQUENCES)
402
+ pct = done / total * 100 if total else 0
403
+ bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5))
404
+ return f"`[{bar}]` **{done}/{total}** cached ({pct:.0f}%)"
405
+
406
+
407
+ # ── Stats plots ────────────────────────────────────────────────────────────────
408
+
409
+ def make_stats_plots():
410
+ d = DF.copy()
411
+ fig_frames = px.histogram(d, x="frames", nbins=30, title="Frame Count Distribution",
412
+ color_discrete_sequence=["#3B82F6"], labels={"frames": "Frames"})
413
+ fig_frames.update_layout(margin=dict(t=45, b=40))
414
+
415
+ oc = d["n_objects"].value_counts().sort_index().reset_index()
416
+ oc.columns = ["n_objects", "count"]
417
+ fig_objs = px.bar(oc, x="n_objects", y="count", title="Sequences by Object Count",
418
+ color="count", color_continuous_scale="Teal",
419
+ labels={"n_objects": "Objects", "count": "# Sequences"})
420
+ fig_objs.update_layout(coloraxis_showscale=False, margin=dict(t=45, b=40))
421
+ fig_objs.update_xaxes(tickmode="linear", dtick=1)
422
+
423
+ sp = {"2016-train": int(d["split"].str.contains("2016-train").sum()),
424
+ "2016-val": int(d["split"].str.contains("2016-val").sum()),
425
+ "2017-train": int(d["split"].str.contains("2017-train").sum()),
426
+ "2017-val": int(d["split"].str.contains("2017-val").sum())}
427
+ fig_splits = px.bar(x=list(sp.keys()), y=list(sp.values()), title="Sequences per Split",
428
+ color=list(sp.keys()),
429
+ color_discrete_sequence=["#3B82F6","#6366F1","#F59E0B","#EF4444"],
430
+ labels={"x": "Split", "y": "# Sequences"})
431
+ fig_splits.update_layout(showlegend=False, margin=dict(t=45, b=40))
432
+
433
+ rc = d["resolution"].value_counts().reset_index()
434
+ rc.columns = ["resolution", "count"]
435
+ fig_res = px.pie(rc, names="resolution", values="count", title="Resolution Distribution",
436
+ color_discrete_sequence=px.colors.qualitative.Pastel)
437
+ fig_res.update_layout(margin=dict(t=45, b=20))
438
+
439
+ fig_scatter = px.scatter(d, x="frames", y="n_objects", text="sequence",
440
+ title="Frames vs. Object Count",
441
+ color="n_objects", color_continuous_scale="Viridis",
442
+ size="frames", size_max=18,
443
+ labels={"frames": "Frames", "n_objects": "Objects"},
444
+ hover_data=["sequence", "frames", "n_objects", "resolution", "split"])
445
+ fig_scatter.update_traces(textposition="top center", textfont_size=8)
446
+ fig_scatter.update_layout(coloraxis_showscale=False, margin=dict(t=45, b=40))
447
+
448
+ return fig_frames, fig_objs, fig_splits, fig_res, fig_scatter
449
+
450
+
451
+ # ── Build UI ───────────────────────────────────────────────────────────────────
452
+
453
+ def build_ui():
454
+ figs = make_stats_plots()
455
+ n_multi = int((DF["n_objects"] > 1).sum())
456
+ n_2016 = int(DF["in_2016"].sum())
457
+ n_2017 = int(DF["in_2017"].sum())
458
+ _first = ALL_SEQUENCES[0]
459
+ _first_n = len(_get_frame_paths(_first))
460
+ total_pages = (len(ALL_SEQUENCES) + PAGE_SIZE - 1) // PAGE_SIZE
461
+
462
+ with gr.Blocks(title="DAVIS Dataset Explorer") as demo:
463
+
464
+ gr.Markdown(
465
+ "# 🎬 DAVIS Dataset Explorer\n"
466
+ f"**DAVIS 2017 · 480p** — {len(DF)} sequences · "
467
+ f"frames {DF['frames'].min()}–{DF['frames'].max()} · "
468
+ f"{n_2016} in DAVIS-2016 · {n_2017} in DAVIS-2017 · "
469
+ f"{n_multi} multi-object"
470
+ )
471
+
472
+ with gr.Tabs():
473
+
474
+ # ──────────────────────────────────────────────────────────────
475
+ # Tab 1 · Browse
476
+ # ──────────────────────────────────────────────────────────────
477
+ with gr.TabItem("📋 Browse"):
478
+ with gr.Row():
479
+ dd_year = gr.Dropdown(["All years","2016 only","2017 only"],
480
+ value="All years", label="Year", scale=1)
481
+ dd_split = gr.Dropdown(["All splits","Train only","Val only"],
482
+ value="All splits", label="Split", scale=1)
483
+ dd_obj = gr.Dropdown(["Any # objects","1 object","2 objects","3+ objects"],
484
+ value="Any # objects", label="Objects", scale=1)
485
+ txt_srch = gr.Textbox(placeholder="Search…", label="Search", scale=2)
486
+ with gr.Row():
487
+ fmin_sl = gr.Slider(int(DF["frames"].min()), int(DF["frames"].max()),
488
+ int(DF["frames"].min()), step=1, label="Min frames", scale=3)
489
+ fmax_sl = gr.Slider(int(DF["frames"].min()), int(DF["frames"].max()),
490
+ int(DF["frames"].max()), step=1, label="Max frames", scale=3)
491
+ count_md = gr.Markdown(f"**{len(DF)} sequences** match.")
492
+ with gr.Row(equal_height=False):
493
+ with gr.Column(scale=3):
494
+ tbl = gr.DataFrame(value=DF[DISPLAY_COLS], interactive=False, wrap=False)
495
+ with gr.Column(scale=2):
496
+ detail_md = gr.Markdown("*Select a row to see details.*")
497
+
498
+ filtered_state = gr.State(DF[DISPLAY_COLS].copy())
499
+ selected_seq = gr.State("")
500
+ f_inputs = [dd_year, dd_split, dd_obj, fmin_sl, fmax_sl, txt_srch]
501
+
502
+ def _on_filter(*a):
503
+ df = filter_df(*a)
504
+ return df, f"**{len(df)} sequences** match."
505
+ for inp in f_inputs:
506
+ inp.change(_on_filter, f_inputs, [tbl, count_md])
507
+ inp.change(lambda *a: filter_df(*a), f_inputs, filtered_state)
508
+
509
+ def _on_row(evt: gr.SelectData, fdf):
510
+ if evt is None or fdf is None or len(fdf) == 0:
511
+ return gr.update(), "Select a row."
512
+ seq = fdf.iloc[evt.index[0]]["sequence"]
513
+ r = DF[DF["sequence"] == seq].iloc[0]
514
+ sc = r["split"].replace(", ", "\n• ")
515
+ md = (f"### `{seq}`\n| Field | Value |\n|---|---|\n"
516
+ f"| Frames | **{r['frames']}** |\n"
517
+ f"| Objects | **{r['n_objects']}** |\n"
518
+ f"| Resolution | {r['resolution']} |\n"
519
+ f"| Splits | • {sc} |\n\n"
520
+ f"> Open the **Viewer** or **Gallery** tab to watch.")
521
+ return seq, md
522
+ tbl.select(_on_row, filtered_state, [selected_seq, detail_md])
523
+
524
+ # ──────────────────────────────────────────────────────────────
525
+ # Tab 2 · Viewer (frame scrubber + single video)
526
+ # ──────────────────────────────────────────────────────────────
527
+ with gr.TabItem("🔍 Viewer"):
528
+ with gr.Row():
529
+ seq_dd = gr.Dropdown(ALL_SEQUENCES, value=_first,
530
+ label="Sequence", scale=5)
531
+ seq_info_md = gr.Markdown(_seq_info(_first))
532
+
533
+ gr.Markdown("#### Frame Scrubber")
534
+ with gr.Row():
535
+ ov_cb = gr.Checkbox(value=True, label="Mask overlay")
536
+ alpha_sl = gr.Slider(0.1, 1.0, DEFAULT_ALPHA, step=0.05,
537
+ label="Overlay opacity")
538
+ frame_sl = gr.Slider(0, _first_n - 1, 0, step=1,
539
+ label=f"Frame (0 – {_first_n - 1})")
540
+ with gr.Row():
541
+ img_out = gr.Image(label="Frame (+overlay)", type="pil", height=360,
542
+ value=render_frame(_first, 0, True, DEFAULT_ALPHA))
543
+ ann_out = gr.Image(label="Annotation mask", type="pil", height=360,
544
+ value=render_mask(_first, 0))
545
+ legend_md = gr.Markdown(get_legend(_first))
546
+
547
+ gr.Markdown("---\n#### Video Playback")
548
+ gr.Markdown(
549
+ "Raw encodes directly from JPEGs (instant). "
550
+ "Overlay uses vectorised numpy. Both variants are cached permanently."
551
+ )
552
+ with gr.Row():
553
+ v_fps = gr.Slider(1, 30, DEFAULT_FPS, step=1, label="FPS", scale=2)
554
+ v_ov = gr.Checkbox(value=True, label="Burn overlay", scale=1)
555
+ v_a = gr.Slider(0.1, 1.0, DEFAULT_ALPHA, step=0.05,
556
+ label="Overlay opacity", scale=2)
557
+ with gr.Row():
558
+ btn_play = gr.Button("▶ Generate & Play", variant="primary", scale=1)
559
+ with gr.Column(scale=4):
560
+ v_status = gr.Markdown("*Click Generate & Play.*")
561
+ video_out = gr.Video(label="Playback", height=390, autoplay=True)
562
+ cache_md = gr.Markdown(cache_status_md())
563
+ gr.Button("↻ Refresh cache status", size="sm").click(
564
+ cache_status_md, outputs=cache_md)
565
+
566
+ # wiring
567
+ selected_seq.change(
568
+ lambda s: gr.Dropdown(value=s) if s and s in ALL_SEQUENCES else gr.Dropdown(),
569
+ selected_seq, seq_dd)
570
+
571
+ def _on_seq(seq):
572
+ if seq not in ALL_SEQUENCES:
573
+ return gr.Slider(), None, None, "", ""
574
+ n = len(_get_frame_paths(seq))
575
+ fi = render_frame(seq, 0, True, DEFAULT_ALPHA)
576
+ ai = render_mask(seq, 0)
577
+ sl = gr.Slider(minimum=0, maximum=n-1, value=0, step=1,
578
+ label=f"Frame (0 – {n-1})")
579
+ return sl, fi, ai, _seq_info(seq), get_legend(seq)
580
+
581
+ seq_dd.change(_on_seq, seq_dd,
582
+ [frame_sl, img_out, ann_out, seq_info_md, legend_md])
583
+ seq_dd.change(lambda *_: (None, "*Click Generate & Play.*"),
584
+ seq_dd, [video_out, v_status])
585
+
586
+ def _fr(seq, idx, ov, a):
587
+ return render_frame(seq, int(idx), ov, a), render_mask(seq, int(idx))
588
+ frame_sl.change(_fr, [seq_dd, frame_sl, ov_cb, alpha_sl], [img_out, ann_out])
589
+ ov_cb.change(_fr, [seq_dd, frame_sl, ov_cb, alpha_sl], [img_out, ann_out])
590
+ alpha_sl.change(_fr, [seq_dd, frame_sl, ov_cb, alpha_sl], [img_out, ann_out])
591
+ btn_play.click(get_video, [seq_dd, v_ov, v_a, v_fps], [video_out, v_status])
592
+
593
+ # ──────────────────────────────────────────────────────────────
594
+ # Tab 3 · Gallery (thumbnail grid of all sequences)
595
+ # ──────────────────────────────────────────────────────────────
596
+ with gr.TabItem("🖼 Gallery"):
597
+ gr.Markdown(
598
+ "Thumbnails of all sequences (first frame). "
599
+ "Use the filters to narrow down, then **click any thumbnail** "
600
+ "to instantly play that sequence below."
601
+ )
602
+ with gr.Row():
603
+ g_year = gr.Dropdown(["All years","2016 only","2017 only"],
604
+ value="All years", label="Year", scale=1)
605
+ g_split = gr.Dropdown(["All splits","Train only","Val only"],
606
+ value="All splits", label="Split", scale=1)
607
+ g_obj = gr.Dropdown(["Any # objects","1 object","2 objects","3+ objects"],
608
+ value="Any # objects", label="Objects", scale=1)
609
+ g_srch = gr.Textbox(placeholder="Search…", label="Search", scale=2)
610
+ with gr.Row():
611
+ g_fmin = gr.Slider(int(DF["frames"].min()), int(DF["frames"].max()),
612
+ int(DF["frames"].min()), step=1, label="Min frames", scale=3)
613
+ g_fmax = gr.Slider(int(DF["frames"].min()), int(DF["frames"].max()),
614
+ int(DF["frames"].max()), step=1, label="Max frames", scale=3)
615
+ with gr.Row():
616
+ g_ov = gr.Checkbox(value=False, label="Show mask overlay on thumbnails")
617
+
618
+ g_count_md = gr.Markdown(f"**{len(ALL_SEQUENCES)} sequences**")
619
+
620
+ # Gallery component
621
+ gallery = gr.Gallery(
622
+ value=_ALL_THUMBS,
623
+ label="Sequences",
624
+ columns=5,
625
+ rows=None,
626
+ height="auto",
627
+ allow_preview=False,
628
+ show_label=False,
629
+ )
630
+
631
+ # State holding the sequence names matching current filter (in gallery order)
632
+ g_seq_state = gr.State(ALL_SEQUENCES.copy())
633
+
634
+ gr.Markdown("---")
635
+ with gr.Row():
636
+ g_info_md = gr.Markdown("*Click a thumbnail to play.*")
637
+ with gr.Row():
638
+ g_fps = gr.Slider(1, 30, DEFAULT_FPS, step=1,
639
+ label="FPS", scale=2)
640
+ g_vid_ov = gr.Checkbox(value=True, label="Burn overlay", scale=1)
641
+ g_vid_a = gr.Slider(0.1, 1.0, DEFAULT_ALPHA, step=0.05,
642
+ label="Opacity", scale=2)
643
+ g_btn_play = gr.Button("▶ Play selected", variant="primary", scale=1)
644
+
645
+ with gr.Column(scale=5):
646
+ g_vid_status = gr.Markdown("")
647
+ g_video = gr.Video(label="Playback", height=400, autoplay=True)
648
+ g_selected = gr.State("")
649
+
650
+ # Filter → rebuild gallery
651
+ g_f_inputs = [g_year, g_split, g_obj, g_fmin, g_fmax, g_srch]
652
+
653
+ def _on_g_filter(*args):
654
+ ov = args[-1] # last arg is the overlay checkbox
655
+ fargs = args[:-1] # filter args
656
+ fdf = filter_df(*fargs)
657
+ seqs = fdf["sequence"].tolist()
658
+ items = build_gallery_items(seqs, overlay=ov)
659
+ return items, seqs, f"**{len(seqs)} sequences**"
660
+
661
+ for inp in g_f_inputs + [g_ov]:
662
+ inp.change(_on_g_filter, g_f_inputs + [g_ov],
663
+ [gallery, g_seq_state, g_count_md])
664
+
665
+ # Click thumbnail → load info + auto-generate video
666
+ def _on_gallery_click(evt: gr.SelectData, seqs, ov, a, fps):
667
+ if evt is None or not seqs:
668
+ return "", gr.update(), None, ""
669
+ seq = seqs[evt.index]
670
+ info = _seq_info(seq)
671
+ path, status = get_video(seq, ov, a, fps)
672
+ return info, seq, path, status
673
+
674
+ gallery.select(
675
+ _on_gallery_click,
676
+ inputs=[g_seq_state, g_vid_ov, g_vid_a, g_fps],
677
+ outputs=[g_info_md, g_selected, g_video, g_vid_status],
678
+ )
679
+ g_btn_play.click(
680
+ lambda seq, ov, a, fps: get_video(seq, ov, a, fps),
681
+ inputs=[g_selected, g_vid_ov, g_vid_a, g_fps],
682
+ outputs=[g_video, g_vid_status],
683
+ )
684
+
685
+ # ──────────────────────────────────────────────────────────────
686
+ # Tab 4 · Multi-Video (paged 3×3 grid, all as MP4)
687
+ # ──────────────────────────────────────────────────────────────
688
+ with gr.TabItem("📺 Multi-Video"):
689
+ gr.Markdown(
690
+ f"Watch **{PAGE_SIZE} sequences at once** in a 3×3 grid. "
691
+ "Use Prev/Next to page through all {len(ALL_SEQUENCES)} sequences, "
692
+ "or filter first. Videos are encoded once and cached permanently."
693
+ )
694
+ with gr.Row():
695
+ mv_year = gr.Dropdown(["All years","2016 only","2017 only"],
696
+ value="All years", label="Year", scale=1)
697
+ mv_split = gr.Dropdown(["All splits","Train only","Val only"],
698
+ value="All splits", label="Split", scale=1)
699
+ mv_obj = gr.Dropdown(["Any # objects","1 object","2 objects","3+ objects"],
700
+ value="Any # objects", label="Objects", scale=1)
701
+ mv_srch = gr.Textbox(placeholder="Search…", label="Search", scale=2)
702
+ with gr.Row():
703
+ mv_fps = gr.Slider(1, 30, DEFAULT_FPS, step=1, label="FPS", scale=2)
704
+ mv_ov = gr.Checkbox(value=True, label="Burn overlay", scale=1)
705
+ mv_a = gr.Slider(0.1, 1.0, DEFAULT_ALPHA, step=0.05,
706
+ label="Opacity", scale=2)
707
+ mv_load = gr.Button("▶ Load Page", variant="primary", scale=1)
708
+
709
+ with gr.Row():
710
+ mv_prev = gr.Button("◀ Prev", scale=1)
711
+ with gr.Column(scale=3):
712
+ mv_page_lbl = gr.Markdown(f"**Page 1 / {total_pages}**")
713
+ mv_next = gr.Button("Next ▶", scale=1)
714
+
715
+ mv_status = gr.Markdown("")
716
+
717
+ # 9 fixed video slots, 3 rows × 3 cols
718
+ mv_vids = []
719
+ mv_lbls = []
720
+ for row_i in range(3):
721
+ with gr.Row():
722
+ for col_i in range(3):
723
+ with gr.Column():
724
+ lbl = gr.Markdown("—")
725
+ vid = gr.Video(height=260, autoplay=True, label="")
726
+ mv_lbls.append(lbl)
727
+ mv_vids.append(vid)
728
+
729
+ # State: list of sequences currently matching filter, page index
730
+ mv_seq_state = gr.State(ALL_SEQUENCES.copy())
731
+ mv_page_state = gr.State(0)
732
+
733
+ def _mv_filter(*args):
734
+ fdf = filter_df(*args)
735
+ seqs = fdf["sequence"].tolist()
736
+ tp = max(1, (len(seqs) + PAGE_SIZE - 1) // PAGE_SIZE)
737
+ return seqs, 0, f"**Page 1 / {tp}**"
738
+
739
+ mv_f_inputs = [mv_year, mv_split, mv_obj,
740
+ gr.Slider(int(DF["frames"].min()), int(DF["frames"].max()),
741
+ int(DF["frames"].min()), step=1, label=""),
742
+ gr.Slider(int(DF["frames"].min()), int(DF["frames"].max()),
743
+ int(DF["frames"].max()), step=1, label=""),
744
+ mv_srch]
745
+
746
+ # Simpler: just use the three dropdowns + search for multi-video filter
747
+ def _mv_filter_simple(yr, sp, ob, sr):
748
+ fdf = filter_df(yr, sp, ob,
749
+ int(DF["frames"].min()), int(DF["frames"].max()), sr)
750
+ seqs = fdf["sequence"].tolist()
751
+ tp = max(1, (len(seqs) + PAGE_SIZE - 1) // PAGE_SIZE)
752
+ return seqs, 0, f"**Page 1 / {tp}**"
753
+
754
+ mv_simple_f = [mv_year, mv_split, mv_obj, mv_srch]
755
+ for inp in mv_simple_f:
756
+ inp.change(_mv_filter_simple, mv_simple_f,
757
+ [mv_seq_state, mv_page_state, mv_page_lbl])
758
+
759
+ def _load_page(seqs, page, ov, a, fps):
760
+ start = page * PAGE_SIZE
761
+ chunk = seqs[start: start + PAGE_SIZE]
762
+ tp = max(1, (len(seqs) + PAGE_SIZE - 1) // PAGE_SIZE)
763
+ pg_lbl = f"**Page {page + 1} / {tp}**"
764
+ vids = []
765
+ labels = []
766
+
767
+ def _enc(seq):
768
+ p, _ = get_video(seq, ov, a, fps)
769
+ return seq, str(p) if p else None
770
+
771
+ with ThreadPoolExecutor(max_workers=PAGE_SIZE) as pool:
772
+ futs = {pool.submit(_enc, s): i for i, s in enumerate(chunk)}
773
+ res = [None] * PAGE_SIZE
774
+ lbs = [""] * PAGE_SIZE
775
+ for fut in as_completed(futs):
776
+ i = futs[fut]
777
+ seq, path = fut.result()
778
+ res[i] = path
779
+ lbs[i] = seq
780
+
781
+ # Pad to PAGE_SIZE
782
+ while len(res) < PAGE_SIZE:
783
+ res.append(None)
784
+ lbs.append("—")
785
+
786
+ n_loaded = sum(1 for r in res if r)
787
+ status = f"✅ Loaded {n_loaded}/{len(chunk)} videos (page {page+1}/{tp})"
788
+
789
+ # Build flat output: [lbl0, vid0, lbl1, vid1, …, status, pg_lbl]
790
+ out = []
791
+ for lb, r in zip(lbs, res):
792
+ out.append(f"**{lb}**" if lb and lb != "—" else "—")
793
+ out.append(r)
794
+ out.append(status)
795
+ out.append(pg_lbl)
796
+ return out
797
+
798
+ mv_page_outputs = []
799
+ for l, v in zip(mv_lbls, mv_vids):
800
+ mv_page_outputs.append(l)
801
+ mv_page_outputs.append(v)
802
+ mv_page_outputs.append(mv_status)
803
+ mv_page_outputs.append(mv_page_lbl)
804
+
805
+ mv_load.click(
806
+ _load_page,
807
+ inputs=[mv_seq_state, mv_page_state, mv_ov, mv_a, mv_fps],
808
+ outputs=mv_page_outputs,
809
+ )
810
+
811
+ def _prev(seqs, page):
812
+ new_p = max(0, page - 1)
813
+ tp = max(1, (len(seqs) + PAGE_SIZE - 1) // PAGE_SIZE)
814
+ return new_p, f"**Page {new_p+1} / {tp}**"
815
+
816
+ def _next(seqs, page):
817
+ tp = max(1, (len(seqs) + PAGE_SIZE - 1) // PAGE_SIZE)
818
+ new_p = min(tp - 1, page + 1)
819
+ return new_p, f"**Page {new_p+1} / {tp}**"
820
+
821
+ mv_prev.click(_prev, [mv_seq_state, mv_page_state],
822
+ [mv_page_state, mv_page_lbl])
823
+ mv_next.click(_next, [mv_seq_state, mv_page_state],
824
+ [mv_page_state, mv_page_lbl])
825
+
826
+ # ──────────────────────────────────────────────────────────────
827
+ # Tab 5 · Compare (up to 6 side-by-side)
828
+ # ──────────────────────────────────────────────────────────────
829
+ with gr.TabItem("⚖️ Compare"):
830
+ gr.Markdown(
831
+ "Pick up to **6 sequences**, set FPS/overlay, "
832
+ "then **Load All** — encoded in parallel and cached."
833
+ )
834
+ with gr.Row():
835
+ cmp_fps = gr.Slider(1, 30, DEFAULT_FPS, step=1, label="FPS", scale=2)
836
+ cmp_ov = gr.Checkbox(value=True, label="Burn overlay", scale=1)
837
+ cmp_a = gr.Slider(0.1, 1.0, DEFAULT_ALPHA, step=0.05,
838
+ label="Opacity", scale=2)
839
+ cmp_btn = gr.Button("▶ Load All", variant="primary", scale=1)
840
+
841
+ cmp_dds = []
842
+ cmp_vids = []
843
+ cmp_lbls = []
844
+ default_seqs = (ALL_SEQUENCES + [None] * MAX_COMPARE)[:MAX_COMPARE]
845
+
846
+ for row_i in range(2):
847
+ with gr.Row():
848
+ for col_i in range(3):
849
+ si = row_i * 3 + col_i
850
+ with gr.Column():
851
+ dd = gr.Dropdown([""] + ALL_SEQUENCES,
852
+ value=default_seqs[si] or "",
853
+ label=f"Slot {si+1}")
854
+ vid = gr.Video(height=270, autoplay=True, label="")
855
+ lbl = gr.Markdown(
856
+ f"*{default_seqs[si]}*" if default_seqs[si] else "*empty*")
857
+ cmp_dds.append(dd)
858
+ cmp_vids.append(vid)
859
+ cmp_lbls.append(lbl)
860
+
861
+ cmp_status = gr.Markdown("")
862
+
863
+ cmp_outputs = []
864
+ for v, l in zip(cmp_vids, cmp_lbls):
865
+ cmp_outputs.append(v)
866
+ cmp_outputs.append(l)
867
+ cmp_outputs.append(cmp_status)
868
+
869
+ def _load_all(*args):
870
+ ov, a, fps = args[0], args[1], args[2]
871
+ slots = list(args[3:])
872
+ res = [None] * MAX_COMPARE
873
+ lbs = [""] * MAX_COMPARE
874
+
875
+ def _enc(i, seq):
876
+ if seq:
877
+ p, _ = get_video(seq, ov, a, fps)
878
+ res[i] = str(p) if p else None
879
+ lbs[i] = seq
880
+
881
+ with ThreadPoolExecutor(max_workers=MAX_COMPARE) as pool:
882
+ futs = [pool.submit(_enc, i, s) for i, s in enumerate(slots)]
883
+ for f in as_completed(futs):
884
+ f.result()
885
+
886
+ n_ok = sum(1 for r in res if r)
887
+ out = []
888
+ for r, l in zip(res, lbs):
889
+ out.append(r)
890
+ out.append(f"**{l}**" if l else "*empty*")
891
+ out.append(f"✅ Loaded {n_ok}/{len([s for s in slots if s])} slots")
892
+ return out
893
+
894
+ cmp_btn.click(_load_all,
895
+ inputs=[cmp_ov, cmp_a, cmp_fps] + cmp_dds,
896
+ outputs=cmp_outputs)
897
+
898
+ for i, (dd, vid, lbl) in enumerate(zip(cmp_dds, cmp_vids, cmp_lbls)):
899
+ def _mk(idx):
900
+ def _single(seq, ov, a, fps):
901
+ p, _ = get_video(seq, ov, a, fps)
902
+ return p, f"**{seq}**" if seq else "*empty*"
903
+ return _single
904
+ dd.change(_mk(i), [dd, cmp_ov, cmp_a, cmp_fps], [vid, lbl])
905
+
906
+ # ──────────────────────────────────────────────────────────────
907
+ # Tab 6 · Statistics
908
+ # ──────────────────────────────────────────────────────────────
909
+ with gr.TabItem("📊 Statistics"):
910
+ gr.Markdown("### Dataset Overview")
911
+ with gr.Row():
912
+ gr.Plot(value=figs[0], label="Frame count")
913
+ gr.Plot(value=figs[1], label="Object count")
914
+ with gr.Row():
915
+ gr.Plot(value=figs[2], label="Splits")
916
+ gr.Plot(value=figs[3], label="Resolution")
917
+ with gr.Row():
918
+ gr.Plot(value=figs[4], label="Frames vs. Objects")
919
+ gr.Markdown(f"""
920
+ **Quick facts**
921
+ - Total sequences: **{len(DF):,}** | Frame range: **{DF['frames'].min()}–{DF['frames'].max()}** (avg {DF['frames'].mean():.1f})
922
+ - Objects/seq: **{DF['n_objects'].min()}–{DF['n_objects'].max()}** (avg {DF['n_objects'].mean():.2f}) | Single-obj: **{int((DF['n_objects']==1).sum())}** · Multi-obj: **{int((DF['n_objects']>1).sum())}**
923
+ - DAVIS-2016: **{n_2016}** (30 train + 20 val) | DAVIS-2017: **{n_2017}** (60 train + 30 val)
924
+ - MP4 cache: `{CACHE_DIR}`
925
+ """)
926
+
927
+ # ──────────────────────────────────────────────────────────────
928
+ # Tab 7 · About
929
+ # ──────────────────────────────────────────────────────���───────
930
+ with gr.TabItem("ℹ️ About"):
931
+ gr.Markdown(f"""
932
+ ## DAVIS — Densely Annotated VIdeo Segmentation
933
+
934
+ | Version | Train | Val | Total |
935
+ |---------|-------|-----|-------|
936
+ | DAVIS-2016 | 30 | 20 | 50 |
937
+ | DAVIS-2017 | 60 | 30 | 90 |
938
+
939
+ ### Dataset structure
940
+ ```
941
+ DAVIS/
942
+ ├── JPEGImages/480p/<seq>/%05d.jpg RGB frames
943
+ ├── Annotations/480p/<seq>/%05d.png palette-indexed masks (value = object ID)
944
+ └── ImageSets/2016|2017/train|val.txt
945
+ ```
946
+
947
+ ### MP4 cache (`{CACHE_DIR}`)
948
+ - `<seq>_raw_<fps>fps.mp4` — raw frames
949
+ - `<seq>_ov055_<fps>fps.mp4` — DAVIS palette overlay @ 55 % opacity
950
+
951
+ ### Annotation format
952
+ Pixel value = object ID. Rendered with the official DAVIS 20-colour palette.
953
+
954
+ ### Citation
955
+ ```bibtex
956
+ @article{{Pont-Tuset_arXiv_2017,
957
+ author = {{Jordi Pont-Tuset et al.}},
958
+ title = {{The 2017 DAVIS Challenge on Video Object Segmentation}},
959
+ journal = {{arXiv:1704.00675}}, year = {{2017}}
960
+ }}
961
+ ```
962
+ **Data root:** `{DAVIS_ROOT}`
963
+ """)
964
+
965
+ return demo
966
+
967
+
968
+ # ── Entry point ────────────────────────────────────────────────────────────────
969
+
970
+ demo = build_ui()
971
+ start_precache(fps=DEFAULT_FPS, workers=4)
972
+
973
+ if __name__ == "__main__":
974
+ parser = argparse.ArgumentParser(description="DAVIS Dataset Explorer")
975
+ parser.add_argument("--share", action="store_true")
976
+ parser.add_argument("--port", type=int, default=7860)
977
+ parser.add_argument("--host", default="0.0.0.0")
978
+ args = parser.parse_args()
979
+ demo.launch(server_name=args.host, server_port=args.port,
980
+ share=args.share, theme=gr.themes.Soft())
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=6.0.0
2
+ numpy
3
+ pandas
4
+ pillow
5
+ plotly