Spaces:
Sleeping
Sleeping
| """ | |
| Religious Art Space Navigator β FastAPI backend. | |
| Startup: loads features.parquet, normalises feature groups, concatenates them, | |
| and fits a single openTSNE projection. | |
| Serves: initial embedding, CLIP label editor (GET/POST /api/clip_labels), | |
| and on-demand t-SNE recompute (POST /api/recompute). | |
| Run: | |
| uvicorn app.main:app --reload --port 8000 | |
| """ | |
| import hashlib | |
| import json | |
| import os | |
| import time | |
| from contextlib import asynccontextmanager | |
| import clip as openai_clip | |
| import cv2 | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.responses import FileResponse, Response | |
| from fastapi.staticfiles import StaticFiles | |
| from openTSNE import TSNE | |
| from pydantic import BaseModel | |
| from scipy.spatial import ConvexHull, Delaunay | |
| from sklearn.cluster import HDBSCAN | |
| from sklearn.decomposition import PCA | |
| from sklearn.preprocessing import StandardScaler | |
| FEATURES_PARQUET = "data/features/handcrafted.parquet" | |
| DINO_PARQUET = "data/features/dino.parquet" | |
| CLIP_PARQUET = "data/features/clip.parquet" | |
| POSE_PARQUET = "data/features/pose.parquet" | |
| FACES_PARQUET = "data/features/faces.parquet" | |
| METADATA_CSV = "data/artwork_metadata.csv" | |
| IMAGES_DIR = "data/images" | |
| CACHE_PATH = "data/.tsne_cache.npz" | |
| CLIP_LABELS_PATH = "app/clip_labels.json" | |
| CLIP_SCORE_THRESHOLD = 0.22 # zero out scores below this before normalisation | |
| _CACHE_VERSION = "v5" | |
| FACE_FEATURE_COLS = [ | |
| "face_count", "face_detected", "face_coverage", | |
| "mean_face_size", "max_face_size", "face_centroid_x", | |
| "face_centroid_y", "face_size_std", "mean_face_angle", | |
| ] | |
| HC_SUBGROUPS = { | |
| "hc_color": ["hc_hist_", "hc_norm_hist_", "hc_h_hist_", "hc_s_hist_", "hc_v_hist_", | |
| "hc_avg_hue", "hc_avg_sat"], | |
| "hc_flat": ["hc_flatness_"], | |
| "hc_geom": ["hc_lbp_", "hc_fft_band_"], | |
| "hc_lines": ["hc_angle_hist_", "hc_hough_", "hc_straight_ratio"], | |
| "hc_light": ["hc_brightness", "hc_contrast", "hc_darkness", "hc_edge_density"], | |
| "hc_symmetry": ["hc_sym_"], | |
| } | |
| POSE_CONNECTIONS = [ | |
| (0,1),(1,2),(2,3),(3,7),(0,4),(4,5),(5,6),(6,8),(9,10), | |
| (11,12),(11,13),(13,15),(15,17),(15,19),(17,19), | |
| (12,14),(14,16),(16,18),(16,20),(18,20), | |
| (11,23),(12,24),(23,24), | |
| (23,25),(25,27),(27,29),(27,31),(29,31), | |
| (24,26),(26,28),(28,30),(28,32),(30,32), | |
| ] | |
| def _load_clip_labels() -> tuple[list[str], list[str], dict]: | |
| """Read clip_labels.json β (CLIP_LABELS flat, ATTRIBUTES flat, raw dict).""" | |
| with open(CLIP_LABELS_PATH) as f: | |
| raw = json.load(f) | |
| labels, prompts = [], [] | |
| for entries in raw.values(): | |
| for e in entries: | |
| labels.append(e["label"]) | |
| prompts.append(e["prompt"]) | |
| return labels, prompts, raw | |
| CLIP_LABELS, _CLIP_ATTRIBUTES, _ = _load_clip_labels() | |
| state = {} | |
| def _norm(arr: np.ndarray) -> np.ndarray: | |
| scaled = StandardScaler().fit_transform(arr.astype(np.float32)) | |
| return scaled / np.sqrt(scaled.shape[1]) | |
| def _file_hash(path: str) -> str: | |
| h = hashlib.sha256() | |
| with open(path, "rb") as f: | |
| for chunk in iter(lambda: f.read(1 << 20), b""): | |
| h.update(chunk) | |
| return h.hexdigest()[:16] | |
| def _cache_key() -> str: | |
| parts = [_CACHE_VERSION] | |
| for p in (FEATURES_PARQUET, DINO_PARQUET, CLIP_PARQUET, POSE_PARQUET, FACES_PARQUET, CLIP_LABELS_PATH): | |
| if os.path.exists(p): | |
| parts.append(_file_hash(p)) | |
| return ":".join(parts) | |
| def _encode_clip_prompts(prompts: list[str]) -> np.ndarray: | |
| """Encode text prompts with CLIP text encoder β (n_prompts, 512) float32.""" | |
| model, _ = state.get("_clip_model_pair") or (None, None) | |
| if model is None: | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| model, _ = openai_clip.load("ViT-B/16", device=device) | |
| state["_clip_model_pair"] = (model, device) | |
| device = state["_clip_model_pair"][1] | |
| with torch.no_grad(): | |
| tokens = openai_clip.tokenize(prompts).to(device) | |
| text_feats = model.encode_text(tokens).float() | |
| text_feats /= text_feats.norm(dim=-1, keepdim=True) | |
| return text_feats.cpu().numpy() | |
| def _recompute_clip_scores(image_vecs: np.ndarray, prompts: list[str], | |
| threshold: float = 0.0) -> np.ndarray: | |
| """Dot-product stored image embeddings against re-encoded prompts. | |
| Scores below threshold are zeroed out (sparse, discriminative representation).""" | |
| text_feats = _encode_clip_prompts(prompts) | |
| scores = (image_vecs @ text_feats.T).astype(np.float32) | |
| if threshold > 0: | |
| scores[scores < threshold] = 0.0 | |
| return scores | |
| REDUCE_MIN_DIMS = 60 # groups wider than this get their own PCA once at startup | |
| def _prepare_reduced() -> None: | |
| """Pre-reduce wide, static feature groups to 50 PCA dims, once at load. | |
| Slider reprojections then concatenate ~300 instead of ~1,800 columns, so | |
| their cost stops scaling with raw group width. Narrow groups are used | |
| as-is, and clip_s is excluded because it is rebuilt on label edits. | |
| The canonical default fit (_run_tsne) still uses the full-width groups. | |
| """ | |
| red = {} | |
| for name in [*HC_SUBGROUPS.keys(), "dino", "clip_v"]: | |
| arr = state.get(name) | |
| if arr is not None and arr.shape[1] > REDUCE_MIN_DIMS: | |
| red[name] = PCA(n_components=50, random_state=42).fit_transform(arr).astype(np.float32) | |
| state["_reduced"] = red | |
| def _warm_kernels() -> None: | |
| """Run a throwaway t-SNE at startup so numba compiles openTSNE's kernels | |
| while the container boots β otherwise the first user reprojection pays a | |
| ~30s one-time JIT cost. Mirrors the production path (array init, no early | |
| exaggeration) on tiny dummy data.""" | |
| t0 = time.time() | |
| rng = np.random.default_rng(0) | |
| TSNE(n_components=2, perplexity=30, n_jobs=-1, | |
| initialization=rng.normal(size=(1000, 2)), | |
| early_exaggeration_iter=0, n_iter=8, random_state=0).fit( | |
| rng.normal(size=(1000, 50))) | |
| print(f" t-SNE kernels warmed in {time.time() - t0:.1f}s") | |
| def _fit_tsne(X50: np.ndarray) -> np.ndarray: | |
| """Fit t-SNE on the PCA-reduced matrix. | |
| Warm path (a layout already exists): initialize from the current coords, | |
| skip early exaggeration and run a shorter schedule β several times faster | |
| than a cold fit, and the map morphs smoothly instead of rearranging. | |
| Cold path (first fit): canonical PCA init + full schedule, so the startup | |
| layout (and the shipped .tsne_cache.npz) stays reproducible. | |
| """ | |
| prev = state.get("coords") | |
| if prev is not None and len(prev) == len(X50): | |
| tsne = TSNE(n_components=2, perplexity=40, n_jobs=-1, | |
| initialization=np.ascontiguousarray(prev, dtype=np.float64), | |
| early_exaggeration_iter=0, n_iter=250, random_state=42) | |
| else: | |
| tsne = TSNE(n_components=2, perplexity=40, n_jobs=-1, | |
| initialization="pca", random_state=42) | |
| return np.array(tsne.fit(X50), dtype=np.float32) | |
| def _run_tsne() -> None: | |
| """Rebuild concatenated feature matrix and fit a single openTSNE. Updates state['coords'].""" | |
| t0 = time.time() | |
| blocks = [] | |
| group_names = [] | |
| for name in HC_SUBGROUPS: | |
| if state.get(name) is not None: | |
| blocks.append(state[name]) | |
| group_names.append(name) | |
| for name in ("dino", "clip_v"): | |
| if state.get(name) is not None: | |
| blocks.append(state[name]) | |
| group_names.append(name) | |
| # CLIP scores: always recompute from current prompts so label edits take effect | |
| _, prompts, _ = _load_clip_labels() | |
| image_vecs = state.get("_clip_image_vecs") | |
| if image_vecs is not None and len(prompts) > 0: | |
| thr = state.get("clip_threshold", CLIP_SCORE_THRESHOLD) | |
| clip_s = _norm(_recompute_clip_scores(image_vecs, prompts, threshold=thr)) | |
| blocks.append(clip_s) | |
| state["clip_s"] = clip_s | |
| group_names.append("clip_s") | |
| else: | |
| state["clip_s"] = None | |
| if len(prompts) == 0: | |
| print(" clip_s: skipped (no labels defined)") | |
| for name in ("pose", "faces"): | |
| if state.get(name) is not None: | |
| blocks.append(state[name]) | |
| group_names.append(name) | |
| X = np.concatenate(blocks, axis=1) | |
| print(f" t-SNE input: {X.shape} ({len(group_names)} groups: {group_names})") | |
| X50 = PCA(n_components=min(50, X.shape[1]), random_state=42).fit_transform(X) | |
| state["coords"] = _fit_tsne(X50) | |
| state["coords_default"] = state["coords"] # canonical all-groups-at-1 layout | |
| print(f" t-SNE done in {time.time() - t0:.1f}s") | |
| def load_and_fit(): | |
| # ββ CLIP (required β has filename + religion) ββββββββββββββββββββββββββββββ | |
| print("Loading CLIP features ...") | |
| clip_df = pd.read_parquet(CLIP_PARQUET) | |
| base_df = clip_df[["filename", "religion"]].copy() | |
| state["clip_v"] = _norm(np.stack(clip_df["clip_vector"].tolist())) | |
| state["_clip_image_vecs"] = np.stack(clip_df["clip_vector"].tolist()).astype(np.float32) | |
| state["clip_s"] = _norm(np.stack(clip_df["clip_scores"].tolist())) | |
| print(f" CLIP: {len(clip_df)} images") | |
| # ββ DINO (required) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("Loading DINO features ...") | |
| dino_df = pd.read_parquet(DINO_PARQUET)[["filename", "dino_vector"]] | |
| base_df = base_df.merge(dino_df, on="filename", how="left") | |
| state["dino"] = _norm(np.stack(base_df["dino_vector"].tolist())) | |
| print(f" DINO: {state['dino'].shape[1]} dims") | |
| base_df = base_df.drop(columns=["dino_vector"]) | |
| # ββ Handcrafted (optional β from features.parquet when Timotej adds it) βββ | |
| for name in HC_SUBGROUPS: | |
| state[name] = None | |
| state[name + "_cols"] = [] | |
| if os.path.exists(FEATURES_PARQUET): | |
| print("Loading handcrafted features ...") | |
| hc_df = pd.read_parquet(FEATURES_PARQUET) | |
| # Drop non-feature columns before merge | |
| hc_df = hc_df.drop(columns=["hc_fg_applied"], errors="ignore") | |
| base_df = base_df.merge(hc_df, on="filename", how="left") | |
| hc_cols = [c for c in base_df.columns if c.startswith("hc_")] | |
| for name, prefixes in HC_SUBGROUPS.items(): | |
| cols = [c for c in hc_cols if any(c.startswith(p) for p in prefixes)] | |
| if not cols: | |
| print(f" {name}: no columns matched") | |
| continue | |
| state[name] = _norm(base_df[cols].fillna(0).values) | |
| state[name + "_cols"] = cols | |
| print(f" {name}: {len(cols)} dims") | |
| else: | |
| print(" handcrafted_gold.parquet not found β HC features skipped") | |
| # ββ Pose βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| state["pose"] = None | |
| if os.path.exists(POSE_PARQUET): | |
| pose_cols = ["filename", "pose_vector"] | |
| _pdf = pd.read_parquet(POSE_PARQUET) | |
| if "pose_detected" in _pdf.columns: | |
| pose_cols.append("pose_detected") | |
| pose_df = _pdf[pose_cols] | |
| base_df = base_df.merge(pose_df, on="filename", how="left") | |
| # pose_vector stays in base_df for the viz fast-reject; pose_detected too | |
| pose_raw = np.stack(base_df["pose_vector"].tolist()) | |
| if pose_raw.std() > 0: | |
| state["pose"] = _norm(pose_raw) | |
| print(f" pose: {state['pose'].shape[1]} dims") | |
| # ββ Faces ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| state["faces"] = None | |
| if os.path.exists(FACES_PARQUET): | |
| # Load face_vector for features + face_detected/face_count for viz fast-reject | |
| face_df = pd.read_parquet(FACES_PARQUET)[["filename", "face_vector", "face_detected", "face_count"]] | |
| base_df = base_df.merge(face_df, on="filename", how="left") | |
| if "face_vector" in base_df.columns: | |
| face_raw = np.stack(base_df["face_vector"].tolist()) | |
| # Zero-face rows (no detection) would all collapse to the same point | |
| # after StandardScaler β PCA line artifact. Add tiny jitter so they | |
| # spread naturally in t-SNE while still being distinct from real faces. | |
| zero_mask = (face_raw == 0).all(axis=1) | |
| if zero_mask.any(): | |
| rng = np.random.default_rng(42) | |
| face_raw[zero_mask] += rng.normal(0, 1e-3, (zero_mask.sum(), face_raw.shape[1])) | |
| if face_raw.std() > 0: | |
| state["faces"] = _norm(face_raw) | |
| n_det = int((~zero_mask).sum()) | |
| print(f" faces: {state['faces'].shape[1]} dims ({n_det} detected, {zero_mask.sum()} no-face jittered)") | |
| # face_detected/face_count stay in base_df for viz fast-reject via _raw_df | |
| # ββ Metadata βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| meta_cols = ["filename", "title", "artist", "year"] | |
| try: | |
| meta = pd.read_csv(METADATA_CSV, dtype=str)[meta_cols] | |
| base_df = base_df.merge(meta, on="filename", how="left") | |
| except Exception as e: | |
| print(f" metadata.csv join skipped: {e}") | |
| keep = ["filename", "religion", "sub_religion", "source", "title", "artist", "year"] | |
| state["meta"] = base_df[[c for c in keep if c in base_df.columns]].reset_index(drop=True) | |
| state["_raw_df"] = base_df.reset_index(drop=True) | |
| _prepare_reduced() | |
| _warm_kernels() | |
| # Try cache (keyed on parquet hashes + clip_labels.json) | |
| cache_key = _cache_key() | |
| if os.path.exists(CACHE_PATH): | |
| try: | |
| z = np.load(CACHE_PATH) | |
| if str(z["__key__"]) == cache_key: | |
| state["coords"] = z["coords"] | |
| state["coords_default"] = state["coords"] | |
| print(f"Loaded t-SNE from cache ({CACHE_PATH})") | |
| print(f"Ready β {len(base_df)} images.") | |
| return | |
| else: | |
| print(" cache key mismatch β will recompute t-SNE") | |
| except Exception as e: | |
| print(f" cache read failed ({e}) β will recompute") | |
| _run_tsne() | |
| np.savez_compressed(CACHE_PATH, __key__=cache_key, coords=state["coords"]) | |
| print(f" saved t-SNE cache β {CACHE_PATH}") | |
| print(f"Ready β {len(base_df)} images.") | |
| async def lifespan(app: FastAPI): | |
| load_and_fit() | |
| yield | |
| app = FastAPI(lifespan=lifespan) | |
| app.mount("/static", StaticFiles(directory="app/static"), name="static") | |
| app.mount("/images", StaticFiles(directory=IMAGES_DIR), name="images") | |
| def root(): | |
| return FileResponse("app/static/index.html") | |
| def embeddings(): | |
| meta = state["meta"] | |
| coords = state["coords"] | |
| rows = meta.to_dict("records") | |
| for i, row in enumerate(rows): | |
| row["x"] = float(coords[i, 0]) | |
| row["y"] = float(coords[i, 1]) | |
| for k, v in row.items(): | |
| if isinstance(v, float) and np.isnan(v): | |
| row[k] = "" | |
| return rows | |
| class Weights(BaseModel): | |
| hc_color: float = 1.0 | |
| hc_flat: float = 1.0 | |
| hc_geom: float = 1.0 | |
| hc_lines: float = 1.0 | |
| hc_light: float = 1.0 | |
| hc_symmetry: float = 1.0 | |
| dino: float = 1.0 | |
| clip_v: float = 1.0 | |
| clip_s: float = 1.0 | |
| pose: float = 1.0 | |
| faces: float = 1.0 | |
| def reproject(w: Weights): | |
| weights = w.model_dump() | |
| t0 = time.time() | |
| # All weights at 1 == the default layout the startup fit already computed | |
| # (refreshed by every _run_tsne) β return it instantly instead of refitting. | |
| if all(v == 1.0 for v in weights.values()) and state.get("coords_default") is not None: | |
| coords = state["coords_default"] | |
| state["coords"] = coords # keep in sync so clusters match | |
| print(" reproject: default weights β canonical layout, no refit") | |
| return [{"x": float(x), "y": float(y)} for x, y in coords] | |
| blocks = [] | |
| reduced = state.get("_reduced", {}) | |
| for name in [*HC_SUBGROUPS.keys(), "dino", "clip_v", "clip_s", "pose", "faces"]: | |
| arr = state.get(name) | |
| if arr is None: | |
| continue | |
| weight = weights.get(name, 1.0) | |
| if weight <= 0: | |
| continue | |
| blocks.append(reduced.get(name, arr) * weight) | |
| if not blocks: | |
| coords = state["coords"] | |
| else: | |
| X = np.concatenate(blocks, axis=1) | |
| X50 = PCA(n_components=min(50, X.shape[1]), random_state=42).fit_transform(X) | |
| coords = _fit_tsne(X50) | |
| state["coords"] = coords # keep in sync so clusters match | |
| print(f" reproject done in {time.time() - t0:.1f}s") | |
| return [{"x": float(x), "y": float(y)} for x, y in coords] | |
| class ClipLabelEntry(BaseModel): | |
| label: str | |
| prompt: str | |
| class ClipLabelsBody(BaseModel): | |
| christianity: list[ClipLabelEntry] = [] | |
| islam: list[ClipLabelEntry] = [] | |
| buddhism: list[ClipLabelEntry] = [] | |
| hinduism: list[ClipLabelEntry] = [] | |
| general: list[ClipLabelEntry] = [] | |
| def get_clip_labels(): | |
| with open(CLIP_LABELS_PATH) as f: | |
| return json.load(f) | |
| def set_clip_labels(body: ClipLabelsBody): | |
| raw = body.model_dump() | |
| # Validate: every entry must have non-empty label and prompt | |
| for religion, entries in raw.items(): | |
| for e in entries: | |
| if not e["label"].strip() or not e["prompt"].strip(): | |
| raise HTTPException(400, f"Empty label or prompt in {religion}") | |
| with open(CLIP_LABELS_PATH, "w") as f: | |
| json.dump(raw, f, indent=2) | |
| # Refresh global CLIP_LABELS list | |
| global CLIP_LABELS, _CLIP_ATTRIBUTES | |
| CLIP_LABELS, _CLIP_ATTRIBUTES, _ = _load_clip_labels() | |
| t0 = time.time() | |
| _run_tsne() | |
| np.savez_compressed(CACHE_PATH, __key__=_cache_key(), coords=state["coords"]) | |
| coords = state["coords"] | |
| return { | |
| "ok": True, | |
| "n_labels": len(CLIP_LABELS), | |
| "duration_s": round(time.time() - t0, 1), | |
| "points": [{"x": float(x), "y": float(y)} for x, y in coords], | |
| } | |
| def get_clip_threshold(): | |
| return {"threshold": state.get("clip_threshold", CLIP_SCORE_THRESHOLD)} | |
| class ThresholdBody(BaseModel): | |
| threshold: float | |
| def set_clip_threshold(body: ThresholdBody): | |
| thr = max(0.0, min(float(body.threshold), 1.0)) | |
| state["clip_threshold"] = thr | |
| _run_tsne() | |
| np.savez_compressed(CACHE_PATH, __key__=_cache_key(), coords=state["coords"]) | |
| coords = state["coords"] | |
| return {"ok": True, "points": [{"x": float(x), "y": float(y)} for x, y in coords]} | |
| def recompute(): | |
| t0 = time.time() | |
| _run_tsne() | |
| np.savez_compressed(CACHE_PATH, __key__=_cache_key(), coords=state["coords"]) | |
| coords = state["coords"] | |
| return { | |
| "ok": True, | |
| "duration_s": round(time.time() - t0, 1), | |
| "points": [{"x": float(x), "y": float(y)} for x, y in coords], | |
| } | |
| def _smart_cluster(coords: np.ndarray) -> np.ndarray: | |
| """Density-based clustering on the 2D blended layout β matches what the | |
| user sees. HDBSCAN with EOM selection gives variable cluster sizes; small | |
| `min_samples` + moderate `min_cluster_size` surfaces the visible islands | |
| (including elongated/curved shapes) without fragmenting the dense cores. | |
| Returns int labels where -1 = noise.""" | |
| n = len(coords) | |
| min_cluster_size = max(30, n // 100) | |
| hdb = HDBSCAN( | |
| min_cluster_size=min_cluster_size, | |
| min_samples=8, | |
| cluster_selection_method="eom", | |
| ) | |
| labels = hdb.fit_predict(coords) | |
| n_clusters = int(labels.max()) + 1 if labels.max() >= 0 else 0 | |
| n_noise = int((labels == -1).sum()) | |
| print(f" HDBSCAN: {n_clusters} clusters, {n_noise}/{n} noise " | |
| f"(min_cluster_size={min_cluster_size})") | |
| return labels | |
| def _grow_clusters(coords: np.ndarray, labels: np.ndarray, | |
| factor: float = 3.0) -> np.ndarray: | |
| """Halo pass: any orphan (noise) point whose nearest cluster member is | |
| within `factor` Γ the global median nearest-neighbour distance gets | |
| absorbed by that cluster. Two iterations, so once-removed neighbours | |
| can join via the newly-grown halo β but the threshold is frozen on | |
| iteration 1 to prevent runaway growth across density gaps.""" | |
| if labels.max() < 0: | |
| return labels | |
| from sklearn.neighbors import NearestNeighbors | |
| nn_global = NearestNeighbors(n_neighbors=2).fit(coords) | |
| d_global, _ = nn_global.kneighbors(coords) | |
| threshold = float(np.median(d_global[:, 1])) * factor | |
| new_labels = labels.copy() | |
| for it in range(2): | |
| core_mask = new_labels >= 0 | |
| if not core_mask.any(): | |
| break | |
| noise_idx = np.flatnonzero(new_labels == -1) | |
| if len(noise_idx) == 0: | |
| break | |
| nn = NearestNeighbors(n_neighbors=1).fit(coords[core_mask]) | |
| core_labels = new_labels[core_mask] | |
| dists, idxs = nn.kneighbors(coords[noise_idx]) | |
| grown = 0 | |
| for ni, dist, nearest in zip(noise_idx, dists[:, 0], idxs[:, 0]): | |
| if dist <= threshold: | |
| new_labels[ni] = int(core_labels[nearest]) | |
| grown += 1 | |
| print(f" halo pass {it+1}: pulled in {grown}/{len(noise_idx)} orphans " | |
| f"(threshold={threshold:.4f})") | |
| if grown == 0: | |
| break | |
| return new_labels | |
| def _circumradius(a, b, c) -> float: | |
| """Circumradius of triangle (a, b, c). Returns inf for degenerate ones.""" | |
| ax, ay = a; bx, by = b; cx, cy = c | |
| d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) | |
| if abs(d) < 1e-12: | |
| return float("inf") | |
| a2 = ax * ax + ay * ay | |
| b2 = bx * bx + by * by | |
| c2 = cx * cx + cy * cy | |
| ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d | |
| uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d | |
| return float(np.hypot(ux - ax, uy - ay)) | |
| def _alpha_ring(pts: np.ndarray, alpha_mult: float = 2.0): | |
| """Concave-hull outline via alpha-shape (Delaunay triangles with small | |
| circumradius). Returns a closed ring of [x, y] vertices, or None on | |
| failure β caller should fall back to convex hull.""" | |
| if len(pts) < 4: | |
| return None | |
| try: | |
| tri = Delaunay(pts) | |
| except Exception: | |
| return None | |
| radii = np.array([_circumradius(*pts[s]) for s in tri.simplices]) | |
| finite = radii[np.isfinite(radii)] | |
| if len(finite) == 0: | |
| return None | |
| threshold = float(np.median(finite)) * alpha_mult | |
| edge_count: dict[tuple[int, int], int] = {} | |
| for ti, s in enumerate(tri.simplices): | |
| if radii[ti] > threshold: | |
| continue | |
| for i, j in ((0, 1), (1, 2), (2, 0)): | |
| a, b = int(s[i]), int(s[j]) | |
| key = (a, b) if a < b else (b, a) | |
| edge_count[key] = edge_count.get(key, 0) + 1 | |
| boundary = [e for e, c in edge_count.items() if c == 1] | |
| if len(boundary) < 3: | |
| return None | |
| # Walk the boundary edges into rings; return the longest ring. | |
| adj: dict[int, list[int]] = {} | |
| for a, b in boundary: | |
| adj.setdefault(a, []).append(b) | |
| adj.setdefault(b, []).append(a) | |
| used: set[tuple[int, int]] = set() | |
| rings: list[list[int]] = [] | |
| for start in list(adj): | |
| # find any unused edge starting at this vertex | |
| for first in adj[start]: | |
| key = (start, first) if start < first else (first, start) | |
| if key in used: | |
| continue | |
| used.add(key) | |
| path = [start, first] | |
| cur, prev = first, start | |
| while cur != start: | |
| nxt = None | |
| for n in adj.get(cur, ()): | |
| if n == prev: | |
| continue | |
| k = (cur, n) if cur < n else (n, cur) | |
| if k in used: | |
| continue | |
| nxt = n | |
| used.add(k) | |
| break | |
| if nxt is None: | |
| break | |
| path.append(nxt) | |
| prev, cur = cur, nxt | |
| if len(path) >= 4 and path[-1] == start: | |
| rings.append(path) | |
| if not rings: | |
| return None | |
| rings.sort(key=len, reverse=True) | |
| return [pts[i].tolist() for i in rings[0]] | |
| def _core_hull(pts: np.ndarray, pct: float = 75.0): | |
| """Convex hull using only points within the pct-th percentile distance | |
| from the cluster centroid, avoiding outlier-stretched polygons.""" | |
| centroid = pts.mean(axis=0) | |
| dists = np.linalg.norm(pts - centroid, axis=1) | |
| threshold = np.percentile(dists, pct) | |
| core = pts[dists <= threshold] | |
| if len(core) < 3: | |
| core = pts # fall back to all points | |
| try: | |
| hull = ConvexHull(core) | |
| verts = core[hull.vertices].tolist() | |
| verts.append(verts[0]) | |
| return verts | |
| except Exception: | |
| mn, mx = core.min(axis=0), core.max(axis=0) | |
| return [[mn[0],mn[1]],[mx[0],mn[1]],[mx[0],mx[1]],[mn[0],mn[1]]] | |
| def _cluster_outline(pts: np.ndarray): | |
| """Prefer alpha-shape (concave); fall back to convex hull if it fails.""" | |
| ring = _alpha_ring(pts) | |
| if ring is not None and len(ring) >= 4: | |
| return ring | |
| return _core_hull(pts) | |
| # ββ Per-group cluster label generators βββββββββββββββββββββββββββββββββββββββ | |
| def _label_color(mask: np.ndarray) -> str | None: | |
| df = state["_raw_df"] | |
| cols = state.get("hc_color_cols", []) | |
| hue_cols = [c for c in cols if "avg_hue" in c] | |
| sat_cols = [c for c in cols if "avg_sat" in c] | |
| parts = [] | |
| if hue_cols: | |
| hue = df[hue_cols[0]].values[mask].mean() | |
| if hue < 30 or hue > 330: | |
| parts.append("warm reds/oranges") | |
| elif hue < 90: | |
| parts.append("yellows/greens") | |
| elif hue < 180: | |
| parts.append("cool greens/cyans") | |
| else: | |
| parts.append("blues/purples") | |
| if sat_cols: | |
| sat = df[sat_cols[0]].values[mask].mean() | |
| parts.append("vibrant" if sat > 0.45 else "muted/desaturated") | |
| return " Β· ".join(parts) if parts else None | |
| def _label_flat(mask: np.ndarray) -> str | None: | |
| df = state["_raw_df"] | |
| cols = [c for c in state.get("hc_flat_cols", []) if "flatness" in c] | |
| if not cols: | |
| return None | |
| val = df[cols].values[mask].mean() | |
| # higher flatness std β more painterly; lower β flatter fills | |
| return "flat color fills" if val < 0.08 else "painterly gradients" | |
| def _label_geom(mask: np.ndarray) -> str | None: | |
| df = state["_raw_df"] | |
| cols = state.get("hc_geom_cols", []) | |
| fft_cols = [c for c in cols if "fft_band" in c] | |
| lbp_cols = [c for c in cols if "lbp_" in c] | |
| parts = [] | |
| if fft_cols: | |
| bands = df[fft_cols].values[mask].mean(axis=0) | |
| mid = bands[2:5].sum() | |
| low = bands[:2].sum() + 1e-10 | |
| if mid / low > 1.5: | |
| parts.append("repeating geometric patterns") | |
| if lbp_cols: | |
| lbp = df[lbp_cols].values[mask].mean(axis=0) | |
| # entropy as proxy for texture complexity | |
| p = lbp / (lbp.sum() + 1e-10) | |
| entropy = float(-np.sum(p * np.log(p + 1e-10))) | |
| parts.append("complex texture" if entropy > 3.5 else "uniform texture") | |
| return " Β· ".join(parts) if parts else None | |
| def _label_lines(mask: np.ndarray) -> str | None: | |
| df = state["_raw_df"] | |
| cols = state.get("hc_lines_cols", []) | |
| count_cols = [c for c in cols if "hough_count" in c] | |
| ratio_cols = [c for c in cols if "straight_ratio" in c] | |
| angle_cols = [c for c in cols if "angle_hist_" in c] | |
| parts = [] | |
| if count_cols and ratio_cols: | |
| count = df[count_cols[0]].values[mask].mean() | |
| ratio = df[ratio_cols[0]].values[mask].mean() | |
| if count > 5 and ratio > 0.4: | |
| parts.append("strong straight lines") | |
| elif ratio < 0.2: | |
| parts.append("curved/organic lines") | |
| if angle_cols: | |
| angles = df[angle_cols].values[mask].mean(axis=0) | |
| dom = int(np.argmax(angles)) | |
| direction = ["horizontal","diagonalβ","vertical","diagonalβ", | |
| "horizontal","diagonalβ","vertical","diagonalβ"] | |
| parts.append(f"dominant {direction[dom]} lines") | |
| return " Β· ".join(parts) if parts else None | |
| def _label_light(mask: np.ndarray) -> str | None: | |
| df = state["_raw_df"] | |
| cols = state.get("hc_light_cols", []) | |
| bright_cols = [c for c in cols if "brightness" in c] | |
| contrast_cols= [c for c in cols if "contrast" in c] | |
| dark_cols = [c for c in cols if "darkness" in c] | |
| parts = [] | |
| if bright_cols: | |
| b = df[bright_cols[0]].values[mask].mean() | |
| if b > 0.6: parts.append("bright") | |
| elif b < 0.3: parts.append("dark") | |
| if dark_cols: | |
| d = df[dark_cols[0]].values[mask].mean() | |
| if d > 0.5: parts.append("heavy shadows") | |
| if contrast_cols: | |
| c = df[contrast_cols[0]].values[mask].mean() | |
| parts.append("high contrast" if c > 0.5 else "low contrast") | |
| return " Β· ".join(parts) if parts else None | |
| def _label_symmetry(mask: np.ndarray) -> str | None: | |
| df = state["_raw_df"] | |
| cols = state.get("hc_symmetry_cols", []) | |
| lr_cols = [c for c in cols if "sym_lr" in c or "sym_h" in c] | |
| tb_cols = [c for c in cols if "sym_tb" in c or "sym_v" in c] | |
| parts = [] | |
| if lr_cols: | |
| s = df[lr_cols[0]].values[mask].mean() | |
| parts.append("left-right symmetric" if s > 0.85 else "asymmetric") | |
| if tb_cols: | |
| s = df[tb_cols[0]].values[mask].mean() | |
| if s > 0.85: parts.append("top-bottom symmetric") | |
| return " Β· ".join(parts) if parts else None | |
| def _label_religion(mask: np.ndarray) -> str | None: | |
| meta = state["meta"] | |
| if "religion" not in meta.columns: | |
| return None | |
| counts = meta["religion"].values[mask] | |
| unique, cnts = np.unique(counts, return_counts=True) | |
| top_frac = cnts.max() / cnts.sum() | |
| if top_frac > 0.65: | |
| return f"predominantly {unique[cnts.argmax()]}" | |
| return None | |
| def _label_clip(mask: np.ndarray) -> str | None: | |
| clip_s = state.get("clip_s") | |
| if clip_s is None: | |
| return None | |
| dev = clip_s[mask].mean(axis=0) | |
| order = np.argsort(dev)[::-1] | |
| labels = CLIP_LABELS # always read the global (updated on label save) | |
| picks = [i for i in order if i < len(labels) and dev[i] > 0.05][:2] | |
| if not picks: | |
| return None | |
| return " Β· ".join(labels[i] for i in picks) | |
| def _label_pose(mask: np.ndarray) -> str | None: | |
| df = state["_raw_df"] | |
| if "pose_detected" not in df.columns: | |
| return None | |
| ratio = df["pose_detected"].values[mask].mean() | |
| if ratio > 0.6: | |
| return "figurative (bodies present)" | |
| if ratio < 0.2: | |
| return "non-figurative" | |
| return None | |
| def _label_faces(mask: np.ndarray) -> str | None: | |
| df = state["_raw_df"] | |
| face_cols = [c for c in FACE_FEATURE_COLS if c in df.columns] | |
| if not face_cols: | |
| return None | |
| count_col = [c for c in face_cols if "face_count" in c] | |
| if not count_col: | |
| return None | |
| avg = df[count_col[0]].values[mask].mean() | |
| if avg >= 3: | |
| return "crowd/group scenes" | |
| if avg >= 1: | |
| return "portrait/close-up" | |
| return "no faces" | |
| # Map each group name to its labeller (receives boolean mask, returns str|None) | |
| _GROUP_LABELLERS = { | |
| "hc_color": _label_color, | |
| "hc_flat": _label_flat, | |
| "hc_geom": _label_geom, | |
| "hc_lines": _label_lines, | |
| "hc_light": _label_light, | |
| "hc_symmetry": _label_symmetry, | |
| "dino": _label_religion, | |
| "clip_v": _label_religion, | |
| "clip_s": _label_clip, | |
| "pose": _label_pose, | |
| "faces": _label_faces, | |
| } | |
| def _cluster_label(mask: np.ndarray) -> str: | |
| """Collect labels from all active labellers, take top 3 unique.""" | |
| scores = [] | |
| for name, labeller in _GROUP_LABELLERS.items(): | |
| if state.get(name) is None: | |
| continue | |
| label = labeller(mask) | |
| if label: | |
| scores.append(label) | |
| seen, picks = set(), [] | |
| for lbl in scores: | |
| if lbl not in seen: | |
| seen.add(lbl) | |
| picks.append(lbl) | |
| if len(picks) == 3: | |
| break | |
| return " Β· ".join(picks) if picks else "cluster" | |
| _CLUSTER_GROUP_SPECS = [ | |
| ("hc_color", "Color", _label_color), | |
| ("hc_flat", "Flatness", _label_flat), | |
| ("hc_geom", "Geometry", _label_geom), | |
| ("hc_lines", "Lines", _label_lines), | |
| ("hc_light", "Light", _label_light), | |
| ("hc_symmetry", "Symmetry", _label_symmetry), | |
| ("clip_s", "CLIP attr", _label_clip), | |
| ("pose", "Pose", _label_pose), | |
| ("faces", "Faces", _label_faces), | |
| ] | |
| def cluster(): | |
| coords = state["coords"] | |
| labels = _smart_cluster(coords) | |
| labels = _grow_clusters(coords, labels) | |
| out = [] | |
| if labels.max() < 0: | |
| return {"clusters": out, "point_labels": [-1] * len(coords)} | |
| meta = state["meta"] | |
| has_religion = "religion" in meta.columns | |
| for cid in range(int(labels.max()) + 1): | |
| mask = labels == cid | |
| pts = coords[mask] | |
| if len(pts) < 3: | |
| continue | |
| verts = _cluster_outline(pts) | |
| centroid = pts.mean(axis=0) | |
| label = _cluster_label(mask) | |
| religion_counts = {} | |
| if has_religion: | |
| vals, counts = np.unique(meta["religion"].values[mask], return_counts=True) | |
| religion_counts = {str(v): int(c) for v, c in zip(vals, counts)} | |
| descriptions = [] | |
| for _, human_name, labeller in _CLUSTER_GROUP_SPECS: | |
| lbl = labeller(mask) | |
| if lbl: | |
| descriptions.append({"group": human_name, "label": lbl}) | |
| member_idx = np.flatnonzero(mask) | |
| rng = np.random.default_rng(cid) | |
| k = int(min(6, len(member_idx))) | |
| sample_pick = rng.choice(member_idx, size=k, replace=False) | |
| samples = [str(meta.iloc[int(i)]["filename"]) for i in sample_pick] | |
| out.append({ | |
| "id": cid, | |
| "hull": verts, | |
| "cx": float(centroid[0]), | |
| "cy": float(centroid[1]), | |
| "size": int(mask.sum()), | |
| "label": label, | |
| "religion_counts": religion_counts, | |
| "descriptions": descriptions, | |
| "samples": samples, | |
| }) | |
| # point_labels lets the frontend route any point-click to its cluster | |
| # while in cluster mode (-1 = noise β not clickable). | |
| return {"clusters": out, "point_labels": [int(x) for x in labels]} | |
| # ββ Subset stats endpoint (overview / lasso / zoom) βββββββββββββββββββββββββββ | |
| class SubsetReq(BaseModel): | |
| indices: list[int] | None = None # None or empty β all points | |
| def subset_info(req: SubsetReq): | |
| meta = state["meta"] | |
| total = len(meta) | |
| if not req.indices: | |
| mask = np.ones(total, dtype=bool) | |
| else: | |
| mask = np.zeros(total, dtype=bool) | |
| valid = [i for i in req.indices if 0 <= i < total] | |
| if valid: | |
| mask[valid] = True | |
| n = int(mask.sum()) | |
| if n == 0: | |
| return {"count": 0, "religion_counts": {}, "descriptions": [], "samples": []} | |
| religion_counts = {} | |
| if "religion" in meta.columns: | |
| vals, counts = np.unique(meta["religion"].values[mask], return_counts=True) | |
| religion_counts = {str(v): int(c) for v, c in zip(vals, counts)} | |
| descriptions = [] | |
| for _, human_name, labeller in _CLUSTER_GROUP_SPECS: | |
| lbl = labeller(mask) | |
| if lbl: | |
| descriptions.append({"group": human_name, "label": lbl}) | |
| member_idx = np.flatnonzero(mask) | |
| rng = np.random.default_rng(int(n)) # deterministic by subset size | |
| k = int(min(6, n)) | |
| pick = rng.choice(member_idx, size=k, replace=False) | |
| samples = [str(meta.iloc[int(i)]["filename"]) for i in pick] | |
| return { | |
| "count": n, | |
| "total": total, | |
| "religion_counts": religion_counts, | |
| "descriptions": descriptions, | |
| "samples": samples, | |
| } | |
| # ββ Per-image analysis endpoint βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def image_info(filename: str): | |
| df = state["_raw_df"] | |
| mask_series = df["filename"] == filename | |
| if not mask_series.any(): | |
| return {} | |
| mask = mask_series.values # numpy bool array, same length as coords | |
| group_specs = [ | |
| ("hc_color", "Color", _label_color), | |
| ("hc_flat", "Flatness", _label_flat), | |
| ("hc_geom", "Geometry", _label_geom), | |
| ("hc_lines", "Lines", _label_lines), | |
| ("hc_light", "Light", _label_light), | |
| ("hc_symmetry", "Symmetry", _label_symmetry), | |
| ("clip_s", "CLIP attr", _label_clip), | |
| ("pose", "Pose", _label_pose), | |
| ("faces", "Faces", _label_faces), | |
| ] | |
| descriptions = [] | |
| for _, human_name, labeller in group_specs: | |
| lbl = labeller(mask) | |
| if lbl: | |
| descriptions.append({"group": human_name, "label": lbl}) | |
| # Key scalar values (raw, un-normalised) | |
| idx = mask_series[mask_series].index[0] | |
| scalars = {} | |
| wanted = { | |
| "hc_brightness": "Brightness", | |
| "hc_contrast": "Contrast", | |
| "hc_darkness": "Darkness", | |
| "hc_edge_density": "Edge density", | |
| "hc_avg_hue": "Avg hue", | |
| "hc_avg_sat": "Avg saturation", | |
| "hc_straight_ratio":"Straight ratio", | |
| "hc_hough_count": "Hough lines", | |
| "face_count": "Faces", | |
| "face_coverage": "Face coverage", | |
| "pose_detected": "Pose detected", | |
| } | |
| for col, name in wanted.items(): | |
| if col in df.columns: | |
| val = df.loc[idx, col] | |
| try: | |
| fval = float(val) | |
| if not np.isnan(fval): | |
| scalars[name] = round(fval, 3) | |
| except (TypeError, ValueError): | |
| pass | |
| return {"descriptions": descriptions, "scalars": scalars} | |
| # ββ Visualisation image endpoints βββββββββββββββββββββββββββββββββββββββββββββ | |
| YUNET_MODEL_PATH = os.path.join("features", "models", "face_detection_yunet_2023mar.onnx") | |
| _yolo_pose_model = None | |
| def _get_yolo(): | |
| global _yolo_pose_model | |
| if _yolo_pose_model is not None: | |
| return _yolo_pose_model | |
| try: | |
| from ultralytics import YOLO | |
| _yolo_pose_model = YOLO("yolov8m-pose.pt") | |
| print(" YOLOv8-Pose ready (viz)") | |
| except Exception as e: | |
| print(f" YOLOv8 init failed: {e}") | |
| return _yolo_pose_model | |
| def _load_image(filename: str): | |
| path = os.path.join(IMAGES_DIR, filename) | |
| img = cv2.imread(path) | |
| return img # BGR or None | |
| def _encode_jpg(img: np.ndarray, max_px: int = 800) -> bytes: | |
| h, w = img.shape[:2] | |
| scale = min(max_px / w, max_px / h, 1.0) | |
| if scale < 1.0: | |
| img = cv2.resize(img, (int(w * scale), int(h * scale)), | |
| interpolation=cv2.INTER_AREA) | |
| _, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 82]) | |
| return buf.tobytes() | |
| def viz_canny(filename: str): | |
| img = _load_image(filename) | |
| if img is None: | |
| return Response(status_code=404) | |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) | |
| gray = cv2.GaussianBlur(gray, (5, 5), 0) | |
| edges = cv2.Canny(gray, 80, 200) | |
| out = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR) | |
| return Response(content=_encode_jpg(out), media_type="image/jpeg") | |
| def viz_pose(filename: str): | |
| # Fast-reject using stored pose_detected flag | |
| df = state["_raw_df"] | |
| rows = df[df["filename"] == filename] | |
| if rows.empty: | |
| return Response(status_code=404) | |
| if "pose_detected" in df.columns and not bool(rows.iloc[0].get("pose_detected", 1)): | |
| return Response(status_code=404) | |
| img = _load_image(filename) | |
| if img is None: | |
| return Response(status_code=404) | |
| model = _get_yolo() | |
| if model is None: | |
| return Response(status_code=404) | |
| # Re-run YOLOv8 on the original image β same as preview_pose.py | |
| results = model(img, verbose=False, conf=0.15) | |
| annotated = results[0].plot(kpt_radius=4, line_width=2) # BGR | |
| if not any(r.keypoints is not None and len(r.keypoints) > 0 for r in results): | |
| return Response(status_code=404) | |
| return Response(content=_encode_jpg(annotated), media_type="image/jpeg") | |
| # YuNet landmark colour order: R.eye, L.eye, nose, R.mouth, L.mouth | |
| _LM_COLORS = [(0, 255, 0), (0, 0, 255), (255, 0, 0), (0, 255, 255), (255, 255, 0)] | |
| def viz_faces(filename: str): | |
| # Fast-reject using stored face_detected flag | |
| df = state["_raw_df"] | |
| rows = df[df["filename"] == filename] | |
| if rows.empty: | |
| return Response(status_code=404) | |
| if "face_detected" in df.columns and not bool(rows.iloc[0].get("face_detected", 1)): | |
| return Response(status_code=404) | |
| if not os.path.exists(YUNET_MODEL_PATH): | |
| return Response(status_code=404) | |
| img = _load_image(filename) | |
| if img is None: | |
| return Response(status_code=404) | |
| h, w = img.shape[:2] | |
| # YuNet must be initialised at the actual image size β same as preview_faces.py | |
| det = cv2.FaceDetectorYN.create( | |
| YUNET_MODEL_PATH, "", (w, h), | |
| score_threshold=0.7, nms_threshold=0.3, | |
| ) | |
| _, faces = det.detect(img) | |
| if faces is None or len(faces) == 0: | |
| return Response(status_code=404) | |
| # Desaturate background so boxes pop | |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) | |
| bg = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) | |
| bg = cv2.addWeighted(bg, 0.82, img, 0.18, 0) | |
| for f in faces: | |
| x, y, fw, fh = int(f[0]), int(f[1]), int(f[2]), int(f[3]) | |
| cv2.rectangle(bg, (x, y), (x + fw, y + fh), (60, 200, 255), 2, cv2.LINE_AA) | |
| for k in range(5): | |
| lx, ly = int(f[4 + k * 2]), int(f[5 + k * 2]) | |
| cv2.circle(bg, (lx, ly), 3, _LM_COLORS[k], -1, cv2.LINE_AA) | |
| return Response(content=_encode_jpg(bg), media_type="image/jpeg") | |