""" Wedding Burst Detection — Gradio Web App Run: python burst_detection_app.py Requires: pip install gradio timm faiss-cpu imagehash umap-learn hdbscan tqdm pillow exifread pandas scikit-learn torch torchvision """ import os, re, json, shutil, logging, zipfile, tempfile from pathlib import Path from datetime import datetime from typing import List, Optional from math import ceil import numpy as np import pandas as pd import torch import torch.nn.functional as F import timm from torchvision import transforms from torch.utils.data import Dataset, DataLoader from PIL import Image, UnidentifiedImageError, ImageDraw, ImageFont import exifread import imagehash import faiss from sklearn.mixture import GaussianMixture from sklearn.cluster import DBSCAN from tqdm import tqdm import gradio as gr import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.patches as mpatches # ─── Logging ─────────────────────────────────────────────────────────────────── logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logger = logging.getLogger("BurstApp") DEVICE = "cuda" if torch.cuda.is_available() else "cpu" SUPPORTED_EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".heic"} # ─── Global model cache ──────────────────────────────────────────────────────── _model = None _transform = None def get_model(): global _model, _transform if _model is None: logger.info("Loading DINOv2...") _model = timm.create_model( "vit_base_patch14_dinov2.lvd142m", pretrained=True, num_classes=0 ) _model.eval().to(DEVICE) _transform = transforms.Compose([ transforms.Resize(518, interpolation=transforms.InterpolationMode.BICUBIC, antialias=True), transforms.CenterCrop(518), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) logger.info("DINOv2 ready.") return _model, _transform # ─── Image Discovery ────────────────────────────────────────────────────────── def discover_images(root: str) -> List[Path]: paths = sorted( p for p in Path(root).rglob("*") if p.suffix.lower() in SUPPORTED_EXTS ) return paths def _parse_exif_dt(tags): for field in ("EXIF DateTimeOriginal", "EXIF DateTimeDigitized", "Image DateTime"): if field in tags: try: return datetime.strptime(str(tags[field]), "%Y:%m:%d %H:%M:%S") except ValueError: pass return None def build_metadata(paths: List[Path]) -> pd.DataFrame: records = [] for idx, p in enumerate(paths): dt = None try: with open(p, "rb") as fh: tags = exifread.process_file(fh, stop_tag="DateTimeOriginal", details=False) dt = _parse_exif_dt(tags) except Exception: pass nums = re.findall(r"\d+", p.stem) fn_order = int(nums[-1]) if nums else idx records.append({"file_index": idx, "path": str(p), "filename": p.name, "folder": str(p.parent), "exif_datetime": dt, "fn_order": fn_order}) df = pd.DataFrame(records) has_exif = df["exif_datetime"].notna().mean() if has_exif > 0.5: df = df.sort_values("exif_datetime", na_position="last").reset_index(drop=True) ts = pd.to_datetime(df["exif_datetime"]) gaps = ts.diff().dt.total_seconds().fillna(0).abs() else: df = df.sort_values("fn_order").reset_index(drop=True) gaps = pd.Series([0.0] * len(df)) df["seq_index"] = range(len(df)) df["gap_to_prev_secs"] = gaps.values return df # ─── Embeddings ─────────────────────────────────────────────────────────────── class _ImgDataset(Dataset): def __init__(self, paths, transform): self.paths = paths self.transform = transform self._blank = Image.new("RGB", (518, 518)) def __len__(self): return len(self.paths) def __getitem__(self, idx): try: img = Image.open(self.paths[idx]).convert("RGB") except Exception: img = self._blank return self.transform(img), idx @torch.no_grad() def extract_embeddings(paths, batch_size=16, progress_cb=None): model, transform = get_model() ds = _ImgDataset(paths, transform) dl = DataLoader(ds, batch_size=batch_size, num_workers=0, pin_memory=(DEVICE == "cuda")) embeds = np.zeros((len(paths), 768), dtype=np.float32) for i, (imgs, idxs) in enumerate(dl): out = F.normalize(model(imgs.to(DEVICE)), dim=1).cpu().numpy() for li, gi in enumerate(idxs.tolist()): embeds[gi] = out[li] if progress_cb: progress_cb((i + 1) / len(dl)) return embeds # ─── Similarity + Burst Logic ───────────────────────────────────────────────── def compute_phashes(paths): out = [] for p in paths: try: out.append(imagehash.phash(Image.open(p).convert("RGB"))) except Exception: out.append(None) return out def phash_sim(h1, h2): if h1 is None or h2 is None: return 0.0 return 1.0 - (h1 - h2) / 64.0 def temp_score(gap, max_gap=5.0): if max_gap <= 0: return 0.5 return float(np.exp(-3.0 * gap / max_gap)) def fuse_scores(embeddings, metadata, phashes, cfg): N = len(embeddings) fused = np.zeros(N, dtype=np.float32) cosines = np.zeros(N, dtype=np.float32) gaps = metadata["gap_to_prev_secs"].values vw, tw, pw = cfg["visual_weight"], cfg["temporal_weight"], 0.10 if cfg["use_phash"] else 0.0 total_w = vw + tw + pw for i in range(N - 1): cos_s = float(np.dot(embeddings[i], embeddings[i + 1])) ph_s = phash_sim(phashes[i], phashes[i + 1]) if cfg["use_phash"] else 0.0 ts = temp_score(gaps[i + 1], cfg["temporal_gap_secs"]) cosines[i] = cos_s fused[i] = (vw * cos_s + tw * ts + pw * ph_s) / total_w return fused, cosines def adaptive_threshold(scores, base=0.88): try: gm = GaussianMixture(n_components=2, random_state=42).fit(scores.reshape(-1, 1)) means = sorted(gm.means_.flatten()) return float(np.clip(np.mean(means), 0.70, 0.97)) except Exception: return base def segment_bursts(fused, metadata, threshold, max_gap): N = len(metadata) gaps = metadata["gap_to_prev_secs"].values ids, cur = [0] * N, 0 for i in range(N - 1): ids[i] = cur if not (fused[i] >= threshold and (gaps[i + 1] <= max_gap or gaps[i + 1] == 0)): cur += 1 ids[N - 1] = cur return ids def refine_dbscan(embeddings, burst_ids, metadata, eps=0.10): refined = list(burst_ids) max_id = max(burst_ids) for bid, group in metadata.groupby("burst_id"): idxs = list(group.index) if len(idxs) <= 8: continue sub = embeddings[idxs] dist = np.clip(1.0 - sub @ sub.T, 0, 2) labels = DBSCAN(eps=eps, min_samples=2, metric="precomputed").fit(dist).labels_ for li, gi in enumerate(idxs): if labels[li] > 0: max_id += 1 refined[gi] = max_id elif labels[li] == -1: max_id += 1 refined[gi] = max_id return refined def select_representative(burst_df, embeddings): idxs = burst_df.index.tolist() sub = embeddings[idxs] c = sub.mean(0, keepdims=True) c /= np.linalg.norm(c) return idxs[int(np.argmax((sub * c).sum(1)))] # ─── Cluster Image Copying ──────────────────────────────────────────────────── def export_clusters(metadata, output_root): output_root = Path(output_root) if output_root.exists(): shutil.rmtree(output_root) output_root.mkdir(parents=True) burst_sizes = metadata.groupby("burst_id").size() for bid, group in metadata.groupby("burst_id"): size = burst_sizes[bid] tag = "singleton" if size == 1 else f"burst_{int(bid):04d}_n{size}" folder = output_root / tag folder.mkdir(exist_ok=True) for _, row in group.iterrows(): src = Path(row["path"]) dst = folder / src.name try: shutil.copy2(src, dst) except Exception as e: logger.warning(f"Copy failed {src}: {e}") return str(output_root) def zip_output(output_root): zip_path = str(output_root).rstrip("/") + ".zip" with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: for f in Path(output_root).rglob("*"): if f.is_file(): zf.write(f, f.relative_to(Path(output_root).parent)) return zip_path # ─── Visualization ──────────────────────────────────────────────────────────── def make_cluster_previews(metadata, n_bursts=6, n_per_burst=5): """Return a list of PIL grid images, one per burst.""" burst_sizes = metadata.groupby("burst_id").size() top_bursts = burst_sizes[burst_sizes > 1].sort_values(ascending=False).head(n_bursts).index.tolist() grids = [] for bid in top_bursts: group = metadata[metadata["burst_id"] == bid].head(n_per_burst) n = len(group) W, H = 160, 120 PAD = 6 HEADER = 32 canvas_w = n * W + (n + 1) * PAD canvas_h = H + 2 * PAD + HEADER canvas = Image.new("RGB", (canvas_w, canvas_h), (18, 18, 24)) draw = ImageDraw.Draw(canvas) # Header text draw.rectangle([0, 0, canvas_w, HEADER], fill=(35, 35, 50)) draw.text((PAD, 7), f"Burst {int(bid)} · {burst_sizes[bid]} images", fill=(200, 200, 220)) for i, (_, row) in enumerate(group.iterrows()): x = PAD + i * (W + PAD) y = HEADER + PAD try: img = Image.open(row["path"]).convert("RGB") img.thumbnail((W, H), Image.LANCZOS) # center-paste on slot slot = Image.new("RGB", (W, H), (30, 30, 40)) ox = (W - img.width) // 2 oy = (H - img.height) // 2 slot.paste(img, (ox, oy)) except Exception: slot = Image.new("RGB", (W, H), (60, 30, 30)) canvas.paste(slot, (x, y)) # Rep badge if row.get("is_representative", False): draw.rectangle([x, y, x + 28, y + 14], fill=(255, 180, 0)) draw.text((x + 3, y + 2), "REP", fill=(0, 0, 0)) grids.append(canvas) return grids def make_histogram(fused_scores, threshold): fig, ax = plt.subplots(figsize=(7, 3), facecolor="#12121a") ax.set_facecolor("#1a1a28") ax.hist(fused_scores[fused_scores > 0], bins=60, color="#6C63FF", edgecolor="none", alpha=0.85) ax.axvline(threshold, color="#FF6584", lw=2, linestyle="--", label=f"Threshold {threshold:.3f}") ax.set_xlabel("Fused Similarity Score", color="#aaa", fontsize=9) ax.set_ylabel("Pairs", color="#aaa", fontsize=9) ax.tick_params(colors="#888") for spine in ax.spines.values(): spine.set_visible(False) ax.legend(fontsize=8, facecolor="#1a1a28", labelcolor="#ccc") plt.tight_layout(pad=0.5) fig.canvas.draw() buf = np.frombuffer(fig.canvas.buffer_rgba(), dtype=np.uint8) buf = buf.reshape(fig.canvas.get_width_height()[::-1] + (4,)) plt.close(fig) return Image.fromarray(buf, mode="RGBA").convert("RGB") # ─── ZIP Extraction ─────────────────────────────────────────────────────────── # Persistent temp dir so extracted files survive the whole pipeline call _TMP_EXTRACT_DIR: Optional[str] = None def extract_zip(zip_path: str) -> str: """ Extract uploaded ZIP into a fresh temp directory. Returns the path to the extraction root. """ global _TMP_EXTRACT_DIR # Clean up previous extraction if _TMP_EXTRACT_DIR and os.path.isdir(_TMP_EXTRACT_DIR): shutil.rmtree(_TMP_EXTRACT_DIR, ignore_errors=True) _TMP_EXTRACT_DIR = tempfile.mkdtemp(prefix="burst_upload_") with zipfile.ZipFile(zip_path, "r") as zf: zf.extractall(_TMP_EXTRACT_DIR) logger.info(f"ZIP extracted to {_TMP_EXTRACT_DIR}") return _TMP_EXTRACT_DIR # ─── Main Pipeline ──────────────────────────────────────────────────────────── def run_pipeline( zip_upload, cosine_threshold, temporal_gap_secs, visual_weight, temporal_weight, use_phash, batch_size, progress=gr.Progress(track_tqdm=True), ): if zip_upload is None: return None, [], "❌ Please upload a ZIP file containing your images.", None cfg = dict( cosine_threshold = cosine_threshold, temporal_gap_secs = temporal_gap_secs, visual_weight = visual_weight, temporal_weight = temporal_weight, use_phash = use_phash, ) # 1. Extract ZIP # Gradio 4.x passes a file-like object with a .name attribute (temp path) progress(0.02, desc="Extracting ZIP…") try: zip_path = zip_upload.name if hasattr(zip_upload, "name") else str(zip_upload) input_folder = extract_zip(zip_path) except Exception as e: return None, [], f"❌ Failed to extract ZIP: {e}", None # Output lives next to the extracted temp dir output_folder = os.path.join(tempfile.gettempdir(), "burst_output") os.makedirs(output_folder, exist_ok=True) # 2. Discover progress(0.05, desc="Scanning images…") paths = discover_images(input_folder) if not paths: return None, [], "❌ No supported images found in the ZIP.", None # 3. Metadata progress(0.08, desc="Reading EXIF…") meta = build_metadata(paths) # 4. Embeddings progress(0.12, desc="Extracting DINOv2 embeddings…") path_strs = meta["path"].tolist() embeddings = extract_embeddings(path_strs, batch_size=int(batch_size)) # 4. pHash phashes = [] if use_phash: progress(0.55, desc="Computing perceptual hashes…") phashes = compute_phashes(path_strs) else: phashes = [None] * len(path_strs) # 5. Fuse progress(0.62, desc="Fusing similarity scores…") fused, cosines = fuse_scores(embeddings, meta, phashes, cfg) meta["fused_score_to_next"] = fused meta["cosine_to_next"] = cosines # 6. Threshold progress(0.68, desc="Computing adaptive threshold…") threshold = adaptive_threshold(fused, base=cosine_threshold) # 7. Segment progress(0.72, desc="Segmenting bursts…") burst_ids = segment_bursts(fused, meta, threshold, temporal_gap_secs) meta["burst_id"] = burst_ids # 8. DBSCAN refine progress(0.78, desc="DBSCAN refinement…") burst_ids = refine_dbscan(embeddings, burst_ids, meta) uid_map = {o: n for n, o in enumerate(sorted(set(burst_ids)))} meta["burst_id"] = [uid_map[b] for b in burst_ids] # 9. Representatives progress(0.83, desc="Selecting representatives…") rep_flags = [False] * len(meta) for bid, group in meta.groupby("burst_id"): if len(group) == 1: rep_flags[group.index[0]] = True else: rep_flags[select_representative(group, embeddings)] = True meta["is_representative"] = rep_flags # 10. Export clusters progress(0.88, desc="Copying images to cluster folders…") cluster_root = os.path.join(output_folder, "burst_clusters") export_clusters(meta, cluster_root) # 11. CSV / JSON progress(0.92, desc="Saving CSV & JSON…") os.makedirs(output_folder, exist_ok=True) csv_path = os.path.join(output_folder, "burst_results.csv") json_path = os.path.join(output_folder, "burst_results.json") meta.drop(columns=["phash"], errors="ignore").to_csv(csv_path, index=False) bursts_json = {} bsizes = meta.groupby("burst_id").size() for bid, group in meta.groupby("burst_id"): rep = group[group["is_representative"]] bursts_json[int(bid)] = { "burst_id": int(bid), "size": len(group), "is_singleton": len(group) == 1, "representative": rep["filename"].iloc[0] if len(rep) else group["filename"].iloc[0], "images": group["filename"].tolist(), } with open(json_path, "w") as f: json.dump(bursts_json, f, indent=2, default=str) # 12. Zip progress(0.95, desc="Zipping cluster folders…") zip_path = zip_output(cluster_root) # 13. Visualize progress(0.97, desc="Generating previews…") grids = make_cluster_previews(meta, n_bursts=8, n_per_burst=6) hist_img = make_histogram(fused, threshold) # Summary bsizes = meta.groupby("burst_id").size() n_multi = int((bsizes > 1).sum()) n_single = int((bsizes == 1).sum()) reduction = (1 - meta["is_representative"].sum() / len(meta)) * 100 summary = ( f"✅ Done!\n\n" f"**Images processed:** {len(meta):,}\n" f"**Bursts detected:** {len(bsizes):,}\n" f"**Multi-image bursts:** {n_multi:,}\n" f"**Singletons:** {n_single:,}\n" f"**Dataset reduction:** {reduction:.1f}%\n" f"**Threshold used:** {threshold:.4f}\n" f"**Download the ZIP below ↓**" ) progress(1.0, desc="Complete!") return hist_img, grids[:6], summary, zip_path # ─── Gradio UI ──────────────────────────────────────────────────────────────── CSS = """ @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;800&display=swap'); body, .gradio-container { background: #0d0d14 !important; font-family: 'Syne', sans-serif !important; color: #d0d0e0 !important; } .gr-panel, .gr-box, .gr-form, .panel { background: #13131f !important; border: 1px solid #2a2a3f !important; border-radius: 12px !important; } h1, h2, h3 { font-family: 'Syne', sans-serif !important; } .title-block { text-align: center; padding: 28px 0 8px; } .title-block h1 { font-size: 2.4rem; font-weight: 800; letter-spacing: -1px; background: linear-gradient(135deg, #a78bfa, #f472b6, #fb923c); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin: 0; } .title-block p { color: #6b6b8a; font-family: 'DM Mono', monospace; font-size: 0.82rem; margin-top: 6px; } .run-btn { background: linear-gradient(135deg, #7c3aed, #db2777) !important; border: none !important; color: white !important; font-family: 'Syne', sans-serif !important; font-weight: 600 !important; font-size: 1.05rem !important; border-radius: 10px !important; padding: 14px !important; transition: opacity 0.2s !important; } .run-btn:hover { opacity: 0.85 !important; } .dl-btn button { background: #1e1e30 !important; border: 1px solid #4c4c7a !important; color: #a78bfa !important; font-family: 'DM Mono', monospace !important; border-radius: 8px !important; } label { color: #8888aa !important; font-size: 0.82rem !important; font-family: 'DM Mono', monospace !important; } input, textarea, select { background: #1a1a28 !important; color: #d0d0e0 !important; border-color: #2a2a3f !important; } .gr-slider input { accent-color: #a78bfa; } """ def build_ui(): with gr.Blocks(css=CSS, title="Wedding Burst Detector") as app: gr.HTML("""
DINOv2 · Temporal Fusion · DBSCAN · Production-Grade