Burst / burst_detection_app.py
ShashankFPM's picture
Upload 2 files
6aa11a2 verified
Raw
History Blame Contribute Delete
23.9 kB
"""
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("""
<div class="title-block">
<h1>📷 Burst Detector</h1>
<p>DINOv2 · Temporal Fusion · DBSCAN · Production-Grade</p>
</div>
""")
with gr.Row():
# ── Left: controls ────────────────────────────────────────────
with gr.Column(scale=1):
gr.Markdown("### ⚙️ Configuration")
zip_upload = gr.File(
label="Upload Images ZIP",
file_types=[".zip"],
file_count="single",
)
gr.Markdown(
"<small style='color:#6b6b8a;font-family:DM Mono,monospace'>"
"ZIP your wedding photo folder(s) and drop here. "
"Subfolders are scanned recursively.</small>"
)
gr.Markdown("#### Similarity")
cosine_thr = gr.Slider(0.70, 0.98, value=0.88, step=0.01,
label="Cosine Threshold (↑ stricter)")
temporal_gap = gr.Slider(1.0, 30.0, value=5.0, step=0.5,
label="Max Temporal Gap (seconds)")
gr.Markdown("#### Weights")
with gr.Row():
vis_w = gr.Slider(0.0, 1.0, value=0.75, step=0.05, label="Visual Weight")
temp_w = gr.Slider(0.0, 1.0, value=0.25, step=0.05, label="Temporal Weight")
gr.Markdown("#### Performance")
with gr.Row():
batch_sz = gr.Slider(4, 64, value=16, step=4, label="Batch Size")
use_ph = gr.Checkbox(value=True, label="Use pHash")
run_btn = gr.Button("🚀 Run Burst Detection", elem_classes=["run-btn"])
# ── Right: outputs ────────────────────────────────────────────
with gr.Column(scale=2):
gr.Markdown("### 📊 Results")
summary_md = gr.Markdown("*Upload a ZIP and click Run.*")
histogram = gr.Image(label="Similarity Score Distribution", type="pil", height=220)
gr.Markdown("### 🖼️ Cluster Previews (top bursts by size)")
gallery = gr.Gallery(
label="Burst Grids",
columns=2,
height=420,
object_fit="contain",
)
gr.Markdown("### 💾 Download Clustered ZIP")
zip_out = gr.File(label="burst_clusters.zip", elem_classes=["dl-btn"])
# ── Wire up ───────────────────────────────────────────────────────
run_btn.click(
fn=run_pipeline,
inputs=[zip_upload, cosine_thr, temporal_gap, vis_w, temp_w, use_ph, batch_sz],
outputs=[histogram, gallery, summary_md, zip_out],
)
return app
if __name__ == "__main__":
app = build_ui()
app.launch(
server_name="0.0.0.0",
server_port=7860,
share=False,
inbrowser=True,
)