"""
app.py — IAnahid v2
====================
A Gradio web application for semi-automatic image annotation for heritage archives.
Features:
- Home page with guidelines sidebar and mode selection
- Two annotation modes:
* Predefined Schema — fixed Tag 1 / Tag 2 / Caption workflow (original mode)
* Custom Schema — user defines their own metadata fields
- Image loading via URL list file (CSV, TXT or JSON); images are never downloaded,
only previewed via to keep the app fast
- Resume work by loading a previously exported annotation file (CSV or JSON)
- Export annotations as CSV or JSON
- Mistral-powered caption generation (both modes)
- Cluster-level batch validation (both modes)
- Predefined mode can load the HuggingFace-hosted exemple.csv directly
"""
import gradio as gr
import pandas as pd
import numpy as np
import os
import re
import io
import json
import base64
import tempfile
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from huggingface_hub import hf_hub_download
# Force `transformers` to a torch-only backend. Some environments also have
# TensorFlow/Flax installed; letting transformers import them is slow and makes
# Abseil print "[mutex.cc] RAW: Lock blocking …" diagnostics (and can stall model
# loading). Torch-only avoids both. Must be set before transformers is imported.
os.environ.setdefault("USE_TF", "0")
os.environ.setdefault("USE_FLAX", "0")
os.environ.setdefault("USE_TORCH", "1")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
# ── Optional logos footer ─────────────────────────────────────────────────────
try:
from logos_b64 import LOGOS as _LOGOS
except Exception:
_LOGOS = ""
# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# Set DATASET_REPO and HF_TOKEN as HuggingFace Space Secrets if you want to
# pre-load a dataset from HuggingFace Hub. Otherwise the app starts empty.
# ══════════════════════════════════════════════════════════════════════════════
DATASET_REPO = os.environ.get("DATASET_REPO", "")
HF_TOKEN = os.environ.get("HF_TOKEN", "")
CSV_FILENAME = "exemple.csv"
# Mistral prompt for automated caption generation (predefined mode)
PROMPT_MISTRAL = (
"Rules:\n"
"* Describe only the visually observable elements "
"(architecture, materials, spatial organization, objects, people, "
"clothing, gestures, inscriptions, landscape).\n"
"* Do not interpret the meaning, the symbolism, or the historical context.\n"
"* Use precise, neutral, and documentary language.\n"
"* Write in complete sentences (paragraph), 2-3 lines maximum.\n"
"Output: A single caption suited to a heritage archive catalogue.\n"
"Structure: Built environment and architectural elements, "
"Spatial context and surrounding landscape, Objects and materials, "
"Human figures (where applicable), Animals (where applicable).\n"
"Expected output: A single developed caption (2-3 lines max)."
)
# Controlled vocabularies for the predefined schema
VOCAB_TAG_1 = [
"1.1. Photograph",
"1.2. Written document",
"1.3. Other reproduced document",
"1.4. Conditioning / packaging material",
]
VOCAB_TAG_2 = [
"2.1. Architecture",
"2.2. Objects",
"2.3. People",
"2.4. Landscape",
"2.5. Animal",
"2.6. Plants",
]
# ══════════════════════════════════════════════════════════════════════════════
# GUIDELINES TEXT (shown in the Home sidebar)
# ══════════════════════════════════════════════════════════════════════════════
GUIDELINES = """
## How to use IAnahid
### Step 1 · Choose a mode
**🏛️ Predefined Schema** uses a fixed annotation workflow (Tag 1 / Tag 2 /
Caption) designed for heritage archive cataloguing. The ThierryNum dataset
can be loaded directly from HuggingFace with one click.
**🛠️ Custom Schema** lets you define your own metadata fields before you
start. You decide the field names, types (free text or dropdown), and
dropdown options.
Both modes support **AI captions** (Mistral) and **cluster batch validation**.
---
### Step 2 · Load your images
Provide a **URL list file** — a file listing image URLs (one per entry).
Images are **never downloaded**: they display directly from their source,
keeping the app fast.
| Format | Expected structure |
|--------|--------------------|
| `.txt` | One URL per line |
| `.csv` | Column named `image_url` (or first column) |
| `.json` | `["url1", …]` or `[{"image_url": "…"}, …]` |
In **Predefined** mode you can also click **Load from HuggingFace** to use
the pre-configured `exemple.csv` dataset (requires `DATASET_REPO` and
`HF_TOKEN` Space secrets).
---
### Step 3 · Build your schema (Custom mode only)
Go to the **📐 Schema builder** tab first. Add fields one by one — give each
a name, choose *text* or *dropdown*, and (for dropdowns) list the allowed
values separated by commas.
---
### Step 4 · Start fresh or resume
- **Start fresh** — load a URL list; a blank annotation file is created.
- **Resume** — load a previously exported file (CSV or JSON). Existing
annotations are restored and the app jumps to the first unvalidated item.
For Custom JSON files the schema is restored automatically.
---
### Step 5 · Annotate
Use **Previous / Skip** to navigate. In Custom mode fill the fields you
defined and click **Save & continue**. In Predefined mode use the dedicated
tabs for Tag 1, Tag 2, and Caption.
The **🗂️ Clusters** tab (available in both modes) lets you assign a value to
all images in a cluster at once — useful for large visually similar groups.
The **📝 Caption** tab uses the **Mistral** multimodal API to generate a
draft description. Paste your API key, pick a model, and click Generate.
---
### Step 6 · Export
Go to the **📁 Data** tab at any time and choose:
- **CSV** — spreadsheet-compatible, importable in most tools
- **JSON** — structured format; for Custom mode, your schema is embedded so
the file can be fully reloaded later
⚠️ Annotations live only in your session. **Always export before closing.**
"""
# ══════════════════════════════════════════════════════════════════════════════
# DATA HELPERS — shared utilities
# ══════════════════════════════════════════════════════════════════════════════
def _ensure_predefined_columns(df: pd.DataFrame) -> pd.DataFrame:
"""Add missing annotation columns for the predefined schema."""
if not df.empty:
df.columns = [c.strip() for c in df.columns]
defaults = {
"image_url": "",
"title": "",
"clustering_id": "0",
"tag_1": "",
"tag_2": "",
"image_caption": "",
"validated_tag_1": False,
"validated_tag_2": False,
"validated_caption": False,
}
for col, val in defaults.items():
if col not in df.columns:
df[col] = val
for col in ["image_url", "title", "clustering_id", "tag_1", "tag_2", "image_caption"]:
if col in df.columns:
df[col] = df[col].astype(object).where(df[col].notna(), "")
return df
def _ensure_custom_columns(df: pd.DataFrame, schema: list) -> pd.DataFrame:
"""Add missing annotation columns for a custom schema."""
if not df.empty:
df.columns = [c.strip() for c in df.columns]
if "image_url" not in df.columns:
df["image_url"] = ""
if "title" not in df.columns:
df["title"] = ""
if "clustering_id" not in df.columns:
df["clustering_id"] = "0"
df["clustering_id"] = df["clustering_id"].astype(object).where(df["clustering_id"].notna(), "0")
for field in schema:
col = field["name"]
if col not in df.columns:
df[col] = ""
val_col = f"validated_{col}"
if val_col not in df.columns:
df[val_col] = False
df[col] = df[col].astype(object).where(df[col].notna(), "")
return df
# ── URL list loaders ──────────────────────────────────────────────────────────
def load_url_list(path: str) -> list:
"""Parse a URL list file (.txt, .csv, .json) and return a list of URL strings."""
ext = os.path.splitext(path)[1].lower()
if ext == ".txt":
with open(path, "r", encoding="utf-8") as f:
urls = [line.strip() for line in f if line.strip()]
elif ext == ".csv":
with open(path, "r", encoding="utf-8") as f:
sep = ";" if ";" in f.readline() else ","
tmp = pd.read_csv(path, sep=sep, encoding="utf-8", on_bad_lines="warn")
if "image_url" in tmp.columns:
urls = tmp["image_url"].dropna().astype(str).tolist()
else:
urls = tmp.iloc[:, 0].dropna().astype(str).tolist()
elif ext == ".json":
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
if data and isinstance(data[0], str):
urls = data
else:
urls = [str(d.get("image_url", "")) for d in data if "image_url" in d]
else:
urls = []
else:
urls = []
return [u for u in urls if u and u.lower() != "nan"]
# ── Annotation file loaders ───────────────────────────────────────────────────
def load_annotation_file(path: str):
"""Load a previously exported annotation file (CSV or JSON).
Returns (df, schema_or_None).
For JSON files saved by the custom mode, the embedded schema is also returned.
"""
ext = os.path.splitext(path)[1].lower()
schema = None
if ext == ".json":
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict) and "schema" in data and "records" in data:
schema = data["schema"]
df = pd.DataFrame(data["records"])
else:
df = pd.DataFrame(data if isinstance(data, list) else [data])
else:
with open(path, "r", encoding="utf-8") as f:
sep = ";" if ";" in f.readline() else ","
df = pd.read_csv(path, sep=sep, quotechar='"', on_bad_lines="warn", encoding="utf-8")
return df, schema
# ── Export helpers ────────────────────────────────────────────────────────────
def export_as_csv(df: pd.DataFrame) -> str:
"""Write df to a temp CSV file and return its path.
A fresh directory is used per export so concurrent users on a shared
server (e.g. a HuggingFace Space) never overwrite each other's files.
"""
out = os.path.join(tempfile.mkdtemp(prefix="ianahid_"), "ianahid_export.csv")
df.to_csv(out, sep=",", index=False, quoting=1, encoding="utf-8")
return out
def export_as_json(df: pd.DataFrame, schema=None) -> str:
"""Write df to a temp JSON file and return its path.
If a custom schema is provided, it is embedded in the output so the file
can be reloaded and the schema restored automatically.
"""
out = os.path.join(tempfile.mkdtemp(prefix="ianahid_"), "ianahid_export.json")
records = df.to_dict(orient="records")
if schema:
payload = {"schema": schema, "records": records}
else:
payload = records
with open(out, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
return out
# ── Navigation & display helpers ──────────────────────────────────────────────
TBL_PREVIEW_ROWS = 200
def tbl_preview(df: pd.DataFrame) -> pd.DataFrame:
"""Cap the overview table sent to the browser.
Shipping thousands of rows through the Space's proxy on every refresh makes
the UI hang; the full DataFrame stays server-side and exports are unaffected.
"""
if df is None:
return pd.DataFrame()
return df.head(TBL_PREVIEW_ROWS)
def tbl_note(df: pd.DataFrame) -> str:
if df is None or len(df) <= TBL_PREVIEW_ROWS:
return ""
return (f"*Table preview limited to the first {TBL_PREVIEW_ROWS} of {len(df)} rows "
f"— exports always contain all rows.*")
def counter(idx: int, total: int) -> str:
return f"**{int(idx) + 1} / {total}**"
def first_unvalidated(df: pd.DataFrame, col: str) -> int:
if df.empty or col not in df.columns:
return 0
mask = (df[col] == False) | (df[col].isna()) | (df[col] == "")
idxs = df[mask].index
return int(idxs[0]) if not idxs.empty else 0
def img_url_at(df: pd.DataFrame, idx: int) -> str | None:
try:
v = df.iloc[int(idx)]["image_url"]
return str(v) if pd.notna(v) and str(v).strip() else None
except Exception:
return None
# Some image hosts (Wikimedia, certain IIIF servers) reject the default
# python-requests User-Agent; identify ourselves so fetches don't silently fail.
_HTTP_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; IAnahid/1.0; heritage image annotator)"}
_IIIF_TAIL = re.compile(r"/full/(?:full|max)/(\d+)/(default|color|gray|bitonal)\.(\w+)$")
def iiif_resize(url: str, width: int) -> str:
"""If url is a IIIF Image API request (…/{region}/{size}/{rot}/{quality}.{fmt}),
rewrite the size segment to request `width` px instead of full resolution.
Handles IIIF 2.x (`/full/full/…`) and 3.0 (`/full/max/…`). Non-IIIF URLs
are returned unchanged, so images from any other host still load normally.
"""
if not url:
return url
return _IIIF_TAIL.sub(rf"/full/{width},/\1/\2.\3", url)
def img_html(url: str | None, height: int = 420, width: int = 800) -> str:
"""Return an HTML
tag for the given URL.
Images are loaded directly from their online source — no server-side
download. For IIIF sources the URL is downscaled to `width` px to keep
loading fast. A placeholder is shown when URL is None.
"""
if not url:
return (
f'
No clusters found.
" cl_idx = min(int(cl_idx), len(clusters) - 1) c_id = clusters[cl_idx] mask = df["clustering_id"].astype(str) == c_id sample = df[mask].sample(n=min(10, int(mask.sum())), random_state=42) cards = "" for _, r in sample.iterrows(): url = str(r.get("image_url", "")).strip() if not url or url.lower() == "nan": continue title = str(r.get("title", "")) if pd.notna(r.get("title", "")) else "" thumb = iiif_resize(url, 300) cards += ( f'No images available for this cluster.
" def cluster_title(df: pd.DataFrame, cl_idx: int) -> str: clusters = cluster_list(df) if not clusters: return "No clusters found." cl_idx = min(int(cl_idx), len(clusters) - 1) c_id = clusters[cl_idx] n = int((df["clustering_id"].astype(str) == c_id).sum()) return f"### Cluster **{c_id}** ({cl_idx + 1} / {len(clusters)}) — {n} images" # ── Shared Mistral / image helpers ──────────────────────────────────────────── def _url_to_b64(url: str, width: int | None = None) -> str: """Fetch an image URL and return a base64-encoded JPEG string. If `width` is given and the URL is a IIIF request, the image is downscaled server-side (via iiif_resize) to keep the payload small — useful for the concept-scoring calls that run over many images. Raises on network/decode errors so callers can decide how to handle failures. """ from PIL import Image src = iiif_resize(url, width) if width else url r = requests.get(src, timeout=15, headers=_HTTP_HEADERS) r.raise_for_status() img = Image.open(io.BytesIO(r.content)) buf = io.BytesIO() img.convert("RGB").save(buf, format="JPEG") return base64.b64encode(buf.getvalue()).decode("utf-8") def _mistral_chat(api_key: str, model: str, content, json_mode: bool = False) -> str: """Call the Mistral chat API once and return the raw text of the reply. `content` may be a plain string (text-only) or a list of content parts (multimodal, e.g. text + image_url). When `json_mode` is set we request a JSON object, retrying without the flag if the SDK/model rejects it. """ from mistralai import Mistral client = Mistral(api_key=api_key) msgs = [{"role": "user", "content": content}] try: if json_mode: res = client.chat.complete( model=model, messages=msgs, response_format={"type": "json_object"}, ) else: res = client.chat.complete(model=model, messages=msgs) except Exception: res = client.chat.complete(model=model, messages=msgs) return res.choices[0].message.content def _robust_json(text: str): """Parse a JSON value out of an LLM reply, tolerating code fences / prose.""" if not text: raise ValueError("empty response") t = re.sub(r"```(?:json)?|```", "", text.strip()).strip() try: return json.loads(t) except Exception: m = re.search(r"(\{.*\}|\[.*\])", t, flags=re.DOTALL) if m: return json.loads(m.group(1)) raise # ══════════════════════════════════════════════════════════════════════════════ # GUIDED CLUSTERING (ITGC) — Interpretable Text-Guided Image Clustering # Reference: Zhao & Mac Aodha, "Interpretable Text-Guided Image Clustering via # Iterative Search" (arXiv:2506.12514). An LLM proposes textual concepts from a # user criterion, a Mistral vision model scores each image against those concepts to # build a concept-space embedding, k-means clusters it, the silhouette score # rates the partition, and the LLM refines the concepts over several iterations. # Everything runs through the existing Mistral API + numpy — no extra deps. # ══════════════════════════════════════════════════════════════════════════════ GC_N_CONCEPTS = 15 # target number of concepts proposed per iteration def gc_generate_concepts(criterion: str, k: int, history: list, api_key: str, text_model: str) -> list: """Ask the LLM for a set of short, visually-checkable concepts for `criterion`. `history` is a list of (concepts, silhouette) from previous iterations; it is fed back so the model can improve on what scored well (the iterative-search core of ITGC). Returns a de-duplicated list of concept strings. """ hist_txt = "" if history: lines = [f" Attempt {i} (silhouette={s:.3f}): {', '.join(cs)}" for i, (cs, s) in enumerate(history, 1)] hist_txt = ( "\n\nPrevious attempts and their clustering quality " "(silhouette, higher is better):\n" + "\n".join(lines) + "\n\nPropose an IMPROVED set: keep concepts that helped, drop weak or " "redundant ones, and add more discriminative visual attributes." ) prompt = ( f"You are helping cluster a set of heritage / archive images according to " f"this criterion:\n \"{criterion}\"\n\n" f"Propose about {GC_N_CONCEPTS} short, concrete, VISUALLY-CHECKABLE concepts " f"(attributes an image either clearly shows or not) that best separate the " f"images along this criterion into roughly {k} groups. Each concept must be a " f"short noun phrase (2-5 words), in English, unambiguous from a single image." f"{hist_txt}\n\n" f"Return ONLY a JSON object of the form " f"{{\"concepts\": [\"concept one\", \"concept two\"]}}." ) data = _robust_json(_mistral_chat(api_key, text_model, prompt, json_mode=True)) concepts = data.get("concepts", []) if isinstance(data, dict) else data seen, out = set(), [] for c in (concepts or []): c = str(c).strip() if c and c.lower() not in seen: seen.add(c.lower()) out.append(c) return out[:GC_N_CONCEPTS] or ["text", "architecture", "person", "landscape", "object"] def gc_score_image(url: str, concepts: list, api_key: str, vision_model: str, cache: dict) -> np.ndarray: """Score one image 0-10 against each concept via a Mistral vision model (ITGC's VLM encoder). Uses a per-(url, concept) cache so concepts repeated across iterations are not re-scored. Returns a vector aligned to `concepts`; failed scores stay as 0. """ scores = np.zeros(len(concepts), dtype=float) missing = [i for i, c in enumerate(concepts) if (url, c) not in cache] for i, c in enumerate(concepts): if (url, c) in cache: scores[i] = cache[(url, c)] if not missing: return scores try: b64 = _url_to_b64(url, width=512) except Exception: return scores # image unreachable -> leave missing concepts at 0 ask = [concepts[i] for i in missing] prompt = ( "Look at the image. For EACH concept below, rate from 0 to 10 how strongly " "the concept is present in / applies to what is visible (0 = not at all, " "10 = clearly present). Judge only from visible content.\n" f"Concepts: {json.dumps(ask, ensure_ascii=False)}\n" "Return ONLY a JSON object mapping each concept EXACTLY to its integer score, " "e.g. {\"concept one\": 7, \"concept two\": 0}." ) content = [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": f"data:image/jpeg;base64,{b64}"}, ] try: data = _robust_json(_mistral_chat(api_key, vision_model, content, json_mode=True)) except Exception: return scores if isinstance(data, dict): low = {str(kk).strip().lower(): vv for kk, vv in data.items()} for i in missing: try: v = float(low.get(concepts[i].strip().lower(), 0)) except (TypeError, ValueError): v = 0.0 v = max(0.0, min(10.0, v)) scores[i] = v cache[(url, concepts[i])] = v return scores def gc_encode(sample_df: pd.DataFrame, concepts: list, api_key: str, vision_model: str, cache: dict, max_workers: int = 12, on_done=None) -> np.ndarray: """Build the concept-space matrix Z (n_images × n_concepts) via parallel scoring. `on_done`, if given, is called (from the main thread) once per finished image — used to drive the progress bar. """ urls = [str(u) for u in sample_df["image_url"].tolist()] rows = [None] * len(urls) with ThreadPoolExecutor(max_workers=max_workers) as ex: futs = {ex.submit(gc_score_image, u, concepts, api_key, vision_model, cache): i for i, u in enumerate(urls)} for fut in as_completed(futs): i = futs[fut] try: rows[i] = fut.result() except Exception: rows[i] = np.zeros(len(concepts), dtype=float) if on_done is not None: on_done() return np.vstack(rows) if rows else np.zeros((0, len(concepts))) def gc_standardize(Z: np.ndarray) -> np.ndarray: """Z-score each concept column so k-means is not dominated by a single scale.""" mu = Z.mean(axis=0) sd = Z.std(axis=0) sd = np.where(sd < 1e-8, 1.0, sd) return (Z - mu) / sd def gc_kmeans(Z: np.ndarray, k: int, n_init: int = 5, max_iter: int = 100, seed: int = 42) -> np.ndarray: """Minimal k-means++ (numpy). Returns integer labels 0..k-1. Small-n only.""" n = Z.shape[0] k = max(1, min(int(k), n)) rng = np.random.default_rng(seed) best_labels, best_inertia = np.zeros(n, dtype=int), np.inf for _ in range(n_init): centers = [Z[rng.integers(n)]] for _ in range(1, k): d2 = np.min([np.sum((Z - c) ** 2, axis=1) for c in centers], axis=0) total = d2.sum() probs = d2 / total if total > 0 else None centers.append(Z[rng.choice(n, p=probs)]) C = np.array(centers, dtype=float) labels = np.full(n, -1, dtype=int) for _it in range(max_iter): dists = np.linalg.norm(Z[:, None, :] - C[None, :, :], axis=2) new_labels = dists.argmin(axis=1) if np.array_equal(new_labels, labels): break labels = new_labels for j in range(k): pts = Z[labels == j] C[j] = pts.mean(axis=0) if len(pts) else Z[rng.integers(n)] inertia = float(np.sum((Z - C[labels]) ** 2)) if inertia < best_inertia: best_inertia, best_labels = inertia, labels.copy() return best_labels def gc_silhouette(Z: np.ndarray, labels: np.ndarray) -> float: """Mean silhouette score (numpy). Returns 0.0 for degenerate partitions.""" n = len(labels) uniq = np.unique(labels) if len(uniq) < 2 or n <= len(uniq): return 0.0 D = np.linalg.norm(Z[:, None, :] - Z[None, :, :], axis=2) sil = np.zeros(n) for i in range(n): same = labels == labels[i] same[i] = False a = D[i, same].mean() if same.any() else 0.0 b = np.inf for lab in uniq: if lab == labels[i]: continue mask = labels == lab if mask.any(): b = min(b, D[i, mask].mean()) denom = max(a, b) sil[i] = 0.0 if denom == 0 else (b - a) / denom return float(sil.mean()) def gc_name_clusters(criterion: str, per_cluster: dict, api_key: str, text_model: str) -> dict: """Ask the LLM for a short human-readable label per cluster (best-effort).""" desc = {cid: [t[0] for t in info["top"]] for cid, info in per_cluster.items()} prompt = ( f"We clustered heritage images by: \"{criterion}\". Each cluster is described " f"by its most distinctive visual concepts. Give each cluster a SHORT label " f"(2-4 words, English).\n" f"Clusters and their top concepts: {json.dumps(desc, ensure_ascii=False)}\n" f"Return ONLY JSON mapping each cluster id to its label, e.g. " f"{{\"0\": \"illuminated initials\"}}." ) try: data = _robust_json(_mistral_chat(api_key, text_model, prompt, json_mode=True)) return {str(kk): str(vv) for kk, vv in data.items()} except Exception: return {cid: f"Cluster {cid}" for cid in per_cluster} def _gc_per_cluster(Z: np.ndarray, labels: np.ndarray, concepts: list, top_n: int = 5) -> dict: """For each cluster, the concepts whose mean score is most ABOVE the global mean (i.e. what makes the cluster distinctive), plus its size.""" gm = Z.mean(axis=0) per = {} for j in sorted({int(l) for l in labels}): mask = labels == j cm = Z[mask].mean(axis=0) diff = cm - gm order = np.argsort(diff)[::-1][:top_n] per[str(j)] = {"size": int(mask.sum()), "top": [(concepts[i], float(cm[i]), float(diff[i])) for i in order]} return per # ── Guided-clustering visualisation (HTML/SVG, no plotting dependency) ───────── def gc_silhouette_svg(history: list, final_sil: float) -> str: """Hand-rolled SVG line chart of the silhouette score across iterations.""" if not history: return "" vals = [s for _, s in history] W, H, pad = 440, 130, 26 n = len(vals) lo, hi = min(vals + [0.0]), max(vals + [0.0001]) span = (hi - lo) or 1.0 fx = lambda i: pad + (W - 2 * pad) * (i / max(n - 1, 1)) fy = lambda v: H - pad - (H - 2 * pad) * ((v - lo) / span) pts = " ".join(f"{fx(i):.1f},{fy(v):.1f}" for i, v in enumerate(vals)) dots = "".join(f'No clusters yet — run guided clustering.
" blocks = [] for pos, cid in enumerate(clusters): info = per_cluster.get(cid, {}) name = names.get(cid, f"Cluster {cid}") size = info.get("size", int((df["clustering_id"].astype(str) == cid).sum())) chips = "".join( f'' f'{cname} {cmean:.1f}' for cname, cmean, _d in info.get("top", []) ) chips_div = f'Load an image list first (📁 Data tab).
", "—" n = len(df) idx = max(0, min(int(idx), n - 1)) return img_html(img_url_at(df, idx)), f"Image **{idx + 1} / {n}**" def _gc_lbl_prepare(df, categories_text, labeled): """Read the ① categories, label the buttons, and show the first image. Returns [cats, idx, img, info, status] + one update per label button.""" cats, _h = _gc_parse_categories(categories_text) btns = [gr.update(value=f"🏷️ {cats[i]}", visible=True) if i < len(cats) else gr.update(visible=False) for i in range(GC_MAX_LABEL_BTNS)] if df is None or df.empty: img, info = "Load an image list first (📁 Data tab).
", "—" else: img, info = _gc_lbl_view(df, 0) status = (_gc_labeled_counts_md(labeled) if cats else "⚠️ Type your categories in ① first, then press Prepare.") return [cats, 0, img, info, status] + btns def _gc_lbl_nav(df, idx, delta): n = max(len(df), 1) if df is not None else 1 nidx = max(0, min(int(idx) + int(delta), n - 1)) img, info = _gc_lbl_view(df, nidx) return nidx, img, info def _gc_lbl_undo(labeled): labeled = list(labeled or []) if labeled: labeled.pop() return labeled, _gc_labeled_counts_md(labeled) def _gc_lbl_assign(i, df, idx, labeled, cats): """Assign the current image to category `cats[i]` and advance. Instant — no embedding here; DINOv2 runs only at Run time.""" labeled = list(labeled or []) cats = cats or [] if df is not None and not df.empty and 0 <= i < len(cats): url = str(img_url_at(df, idx)) if url and url.lower() != "nan": labeled.append((url, cats[i])) n = max(len(df), 1) if df is not None else 1 nidx = min(int(idx) + 1, n - 1) img, info = _gc_lbl_view(df, nidx) return labeled, nidx, img, info, _gc_labeled_counts_md(labeled) def gc_run(df, criterion, categories, labeled, backend, k, n_iters, sample_n, api_key, vmodel, tmodel, progress=gr.Progress()): """Generator: run guided clustering. Dispatches on the chosen encoding backend, on whether labelled examples were supplied, and on whether fixed categories were typed: • DINOv2 + labelled examples → few-shot nearest-centroid classification (no key) • DINOv2 + none → offline visual k-means similarity clustering • SigLIP2 + categories → offline zero-shot classification (no key) • SigLIP2 + none → offline visual k-means (no key) • Mistral API + categories → per-image VLM classification • Mistral API + none → the full iterative ITGC discovery loop Yields tuples matching [gc_df_s, gc_meta_s, gc_log, gc_sil_html, gc_concepts_md, gc_clusters_html, gc_tbl]. """ def _warn(m): return (df, {}, m, gr.update(), gr.update(), gr.update(), gr.update()) if df is None or df.empty: yield _warn("⚠️ Load an image list first (📁 Data tab)."); return # Parse the optional fixed-category list (label | optional description). cats, hints = _gc_parse_categories(categories) bl = str(backend).lower() # ── Local DINOv2 backend: few-shot (with labelled examples) or similarity ─ if "dino" in bl: labeled = labeled or [] if len(labeled) >= 2 and len({lab for _, lab in labeled}) >= 2: yield from gc_fewshot_run_local(df, labeled, progress) else: yield from gc_discover_run_local(df, k, progress, embed_fn=gc_dino_embed_images, model_label="DINOv2") return # ── Local SigLIP2 backend: fully offline, no API key required ───────────── if "siglip" in bl: if len(cats) >= 2: yield from gc_classify_run_local(df, cats, progress, hints) else: yield from gc_discover_run_local(df, k, progress) return # ── Mistral API backend ─────────────────────────────────────────────────── if not api_key: yield _warn("⚠️ Enter your Mistral API key, or switch the backend to " "**Local SigLIP2** (no key needed) in the 📁 Data tab.") return # Fail fast with a readable message if the key or model id is bad (e.g. a # retired model like pixtral-12b-2409) — otherwise per-image errors are # swallowed and every image just looks "unreachable/skipped". try: _mistral_chat(api_key, vmodel, "Reply with the single word: ok") except Exception as e: yield _warn(f"❌ Mistral API check failed for model `{vmodel}`: {e}\n\n" "The model id may be retired or the key invalid — pick another " "vision model in the 📁 Data tab, or switch to the **Local " "SigLIP2** backend (no API needed).") return if len(cats) >= 2: yield from gc_classify_run(df, cats, api_key, vmodel, progress) return if not str(criterion).strip(): yield _warn("⚠️ Enter a list of fixed categories (≥2), or a discovery criterion.") return k, n_iters, sample_n = int(k), int(n_iters), int(sample_n) if len(df) <= sample_n: work = df.reset_index(drop=True) else: work = df.sample(sample_n, random_state=42).reset_index(drop=True) k = max(2, min(k, len(work))) history, cache, best = [], {}, None no_improve = 0 # Progress accounting: one tick per image scored across all iterations + the # final full-set pass. The vision model scores ALL concepts of an image in one call, so # cost scales with the number of images, not the number of concepts. total_ticks = n_iters * len(work) + len(df) done = 0 def _tick(phase): nonlocal done done += 1 progress(min(done / max(total_ticks, 1), 0.99), desc=f"{phase} · {done}/{total_ticks} images scored") def _render(b): """Build (sil_svg, concepts_md, clusters_html, tbl) from the best-so-far run over the sampled `work` set — this is what makes images show up early.""" sub = _ensure_predefined_columns(work.copy()) sub["clustering_id"] = [str(int(l)) for l in b["labels"]] per = _gc_per_cluster(b["Z"], b["labels"], b["concepts"]) names = b.get("names") or {cid: f"Cluster {cid}" for cid in per} md = ("### Concepts (best so far)\n" + ", ".join(f"`{c}`" for c in b["concepts"]) + f"\n\n**Silhouette: {b['sil']:.3f}** · {len(per)} clusters · " f"{len(sub)} images shown") return (gc_silhouette_svg(history, b["sil"]), md, gc_clusters_viz_html(sub, per, names), tbl_preview(sub)) log = [f"**Guided clustering** — {len(work)} images · k={k} · up to {n_iters} " f"iterations. Clusters appear below after the first iteration."] progress(0.0, desc="Starting…") yield _warn("\n".join(log)) for it in range(n_iters): progress(done / max(total_ticks, 1), desc=f"Iteration {it + 1}/{n_iters}: generating concepts…") log.append(f"- Iteration {it + 1}/{n_iters}: generating concepts…") yield _warn("\n".join(log)) try: concepts = gc_generate_concepts(criterion, k, history, api_key, tmodel) except Exception as e: log.append(f" ⚠️ concept generation failed: {e}") yield _warn("\n".join(log)) if best is None: return break log[-1] = (f"- Iteration {it + 1}/{n_iters}: scoring {len(work)} images " f"on {len(concepts)} concepts…") yield _warn("\n".join(log)) try: Z = gc_encode(work, concepts, api_key, vmodel, cache, on_done=lambda: _tick(f"Iteration {it + 1}/{n_iters}")) except Exception as e: log.append(f" ⚠️ scoring failed: {e}") yield _warn("\n".join(log)) if best is None: return break Zs = gc_standardize(Z) labels = gc_kmeans(Zs, k) sil = gc_silhouette(Zs, labels) history.append((concepts, sil)) improved = best is None or sil > best["sil"] if improved: best = {"labels": labels, "concepts": concepts, "sil": sil, "Z": Z} no_improve = 0 else: no_improve += 1 log[-1] = (f"- Iteration {it + 1}/{n_iters}: silhouette = **{sil:.3f}**" f"{' ⭐ best' if improved else ''}") # Progressive image results after every iteration (best-so-far). yield (df, {}, "\n".join(log), *_render(best)) if no_improve >= 2: log.append("- Converged (no improvement for 2 iterations) — stopping early.") yield (df, {}, "\n".join(log), *_render(best)) break # ── Finalize on the best concept set: label EVERY image in the dataset ──── concepts = best["concepts"] log.append(f"\n**Best silhouette = {best['sil']:.3f}**. Assigning all " f"{len(df)} images…") progress(done / max(total_ticks, 1), desc=f"Finalizing: scoring all {len(df)} images…") yield (df, {}, "\n".join(log), *_render(best)) Zf = gc_encode(df, concepts, api_key, vmodel, cache, # cached rows are instant on_done=lambda: _tick("Finalizing")) Zfs = gc_standardize(Zf) labels_full = best["labels"] if len(df) == len(work) else gc_kmeans(Zfs, k) sil_full = gc_silhouette(Zfs, labels_full) out_df = _ensure_predefined_columns(df.copy()) out_df["clustering_id"] = [str(int(l)) for l in labels_full] per_cluster = _gc_per_cluster(Zf, labels_full, concepts) progress(0.99, desc="Naming clusters…") names = gc_name_clusters(criterion, per_cluster, api_key, tmodel) meta = {"concepts": concepts, "history": history, "per_cluster": per_cluster, "names": names, "sil": sil_full, "criterion": criterion} concepts_md = ( "### Final concepts\n" + ", ".join(f"`{c}`" for c in concepts) + f"\n\n**Silhouette (full set): {sil_full:.3f}** · " f"{len(per_cluster)} clusters · {len(out_df)} images" ) log.append("\n✅ Done. Clusters below cover the full set — use the **📊 Export** tab.") progress(1.0, desc="Done") yield (out_df, meta, "\n".join(log), gc_silhouette_svg(history, sil_full), concepts_md, gc_clusters_viz_html(out_df, per_cluster, names), tbl_preview(out_df)) def gc_load_urls(file_obj): """Load a URL-list file for the guided-clustering mode.""" if file_obj is None: return gr.update(), gr.update(), "⚠️ Select a URL list file first." try: path = file_obj.name if hasattr(file_obj, "name") else file_obj urls = load_url_list(path) if not urls: return gr.update(), gr.update(), "⚠️ No URLs found in the file." df = _ensure_predefined_columns(pd.DataFrame({"image_url": urls})) except Exception as e: return gr.update(), gr.update(), f"❌ Error: {e}" return df, tbl_preview(df), (f"✅ Loaded {len(df)} images. Go to the " f"**🧭 Cluster** tab to run guided clustering.") def do_gc_export_csv(df): if df is None or df.empty: return gr.update(visible=False), "⚠️ Nothing to export — run clustering first." return gr.update(value=export_as_csv(df), visible=True), "" def do_gc_export_json(df): if df is None or df.empty: return gr.update(visible=False), "⚠️ Nothing to export — run clustering first." return gr.update(value=export_as_json(df), visible=True), "" # ── Mistral caption helper ──────────────────────────────────────────────────── def generate_mistral_caption(url: str, api_key: str, model: str) -> tuple: """Call the Mistral multimodal API and return (caption_text, status_message).""" if not api_key: return "", "⚠️ Please enter your Mistral API key." if not url: return "", "⚠️ No image URL for this row." try: # Encode the image URL as a base64 JPEG for the Mistral API b64 = _url_to_b64(url) except Exception as e: return "", f"❌ Could not fetch image: {e}" try: from mistralai import Mistral client = Mistral(api_key=api_key) res = client.chat.complete( model=model, messages=[{"role": "user", "content": [ {"type": "text", "text": PROMPT_MISTRAL}, {"type": "image_url", "image_url": f"data:image/jpeg;base64,{b64}"}, ]}], ) return res.choices[0].message.content.strip(), "✅ Caption generated." except Exception as e: return "", f"❌ Mistral error: {e}" # ══════════════════════════════════════════════════════════════════════════════ # INITIAL STATE — load a default dataset if configured via env vars # ══════════════════════════════════════════════════════════════════════════════ def _read_predefined_csv(path: str) -> pd.DataFrame: """Read a predefined-schema CSV file and ensure its annotation columns.""" with open(path, "r", encoding="utf-8") as f: sep = ";" if ";" in f.readline() else "," df = pd.read_csv(path, sep=sep, quotechar='"', on_bad_lines="warn", encoding="utf-8") return _ensure_predefined_columns(df) def _load_default_df(): """Load the example dataset. Prefers the HuggingFace-hosted copy when DATASET_REPO and HF_TOKEN secrets are set; otherwise (or if the download fails) falls back to the bundled exemple.csv shipped alongside this script. Returns an empty DataFrame only when neither source is available. """ if DATASET_REPO and HF_TOKEN: try: path = hf_hub_download( repo_id=DATASET_REPO, filename=CSV_FILENAME, repo_type="dataset", token=HF_TOKEN, ) return _read_predefined_csv(path) except Exception: pass # fall back to the bundled file below try: local_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), CSV_FILENAME) if os.path.exists(local_path): return _read_predefined_csv(local_path) except Exception: pass return pd.DataFrame() _default_df = _load_default_df() # ══════════════════════════════════════════════════════════════════════════════ # GRADIO UI # ══════════════════════════════════════════════════════════════════════════════ CSS = """ /* ══ Dark-mode-safe color tokens ══════════════════════════════════════════ */ :root { --ia-bg-subtle: #f7f6f3; --ia-border: #e5e2db; --ia-text: #2c2825; --ia-text-muted: #6b6560; --ia-accent: #7c6f5e; --ia-card-bg: #ffffff; --ia-section-bg: #f0ede8; --ia-section-hd: #3a3330; } @media (prefers-color-scheme: dark) { :root { --ia-bg-subtle: #2a2825; --ia-border: #4a4640; --ia-text: #e8e4df; --ia-text-muted: #a09890; --ia-accent: #c4a882; --ia-card-bg: #332f2c; --ia-section-bg: #302d2a; --ia-section-hd: #e8e4df; } } /* Gradio also injects .dark class on when dark mode is toggled */ .dark { --ia-bg-subtle: #2a2825; --ia-border: #4a4640; --ia-text: #e8e4df; --ia-text-muted: #a09890; --ia-accent: #c4a882; --ia-card-bg: #332f2c; --ia-section-bg: #302d2a; --ia-section-hd: #e8e4df; } /* ══ Home layout ══════════════════════════════════════════════════════════ */ .home-guidelines { background: var(--ia-bg-subtle); border-right: 1px solid var(--ia-border); padding: 24px 20px; border-radius: 12px 0 0 12px; min-height: 600px; color: var(--ia-text); } .home-guidelines p, .home-guidelines li, .home-guidelines td, .home-guidelines th { color: var(--ia-text) !important; } .home-modes { padding: 24px 20px; } /* ══ Mode cards ═══════════════════════════════════════════════════════════ */ .mode-card { border: 2px solid var(--ia-border); border-radius: 12px; padding: 28px 24px; margin-bottom: 20px; background: var(--ia-card-bg); transition: border-color 0.2s, box-shadow 0.2s; } .mode-card:hover { border-color: var(--ia-accent); box-shadow: 0 4px 16px rgba(0,0,0,0.12); } .mode-card h3 { margin: 0 0 8px 0; font-size: 18px; color: var(--ia-text) !important; } .mode-card p { margin: 0 0 16px 0; font-size: 13px; color: var(--ia-text-muted) !important; line-height: 1.5; } /* ══ Section headers inside annotation views ══════════════════════════════ */ .ia-section { background: var(--ia-section-bg); border-left: 4px solid var(--ia-accent); border-radius: 0 8px 8px 0; padding: 10px 16px; margin: 16px 0 12px 0; color: var(--ia-section-hd) !important; font-weight: 600; font-size: 15px; } /* Make all Markdown text inside annotation views respect dark mode */ .predefined-view p, .predefined-view span, .predefined-view label, .custom-view p, .custom-view span, .custom-view label { color: var(--ia-text); } /* ══ Schema builder ════════════════════════════════════════════════════════ */ .schema-builder-box { background: var(--ia-section-bg); border: 1px solid var(--ia-border); border-radius: 10px; padding: 16px; margin-bottom: 12px; } /* ══ Step badges on Data tab ══════════════════════════════════════════════ */ .step-box { border: 1px solid var(--ia-border); border-radius: 10px; padding: 16px 18px; background: var(--ia-card-bg); color: var(--ia-text); } .step-box h4 { margin: 0 0 8px 0; color: var(--ia-text) !important; } /* ══ Cluster gallery images: dark bg placeholder ══════════════════════════ */ .cl-gallery img { background: var(--ia-bg-subtle); } /* ══ Tab strip: make active tab more prominent ════════════════════════════ */ .tabs > .tab-nav > button.selected { font-weight: 700; border-bottom: 3px solid var(--ia-accent) !important; } /* ══ More evident, user-friendly buttons ══════════════════════════════════ */ button.gr-button, .gr-button, button[class*="button"] { border-radius: 10px !important; font-weight: 600 !important; letter-spacing: 0.2px; transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease; } button.gr-button:hover, .gr-button:hover, button[class*="button"]:hover { transform: translateY(-1px); filter: brightness(1.05); box-shadow: 0 4px 12px rgba(0,0,0,0.18); } button.gr-button:active, .gr-button:active, button[class*="button"]:active { transform: translateY(0); filter: brightness(0.97); } button.primary { box-shadow: 0 2px 8px rgba(124, 111, 94, 0.35) !important; border: 2px solid var(--ia-accent) !important; } button.primary:hover { box-shadow: 0 6px 16px rgba(124, 111, 94, 0.45) !important; } button.secondary { border: 2px solid var(--ia-border) !important; font-weight: 600 !important; } .upload-btn-row { gap: 16px; } .upload-btn-col { background: var(--ia-card-bg); border: 1px solid var(--ia-border); border-radius: 10px; padding: 16px; } .upload-btn-col h4 { margin: 0 0 8px 0; color: var(--ia-text) !important; } .upload-btn-col p { margin: 0 0 12px 0; font-size: 13px; color: var(--ia-text-muted) !important; } /* ══ Side-by-side image / value layout in annotation views ═══════════════ */ .ia-value-col { padding-left: 12px; } """ # Gradio 6 moved theme/css from the Blocks constructor to launch(); passing them # to the constructor there is silently ignored. Route them by version. _GR_MAJOR = int(gr.__version__.split(".")[0]) _STYLE_KWARGS = dict(theme=gr.themes.Soft(), css=CSS) with gr.Blocks(title="IAnahid", **({} if _GR_MAJOR >= 6 else _STYLE_KWARGS)) as demo: # ── Shared session state ────────────────────────────────────────────────── mode_s = gr.State("none") # "predefined" | "custom" | "none" pre_df_s = gr.State(_default_df) # predefined mode DataFrame pre_t1_s = gr.State(0) pre_t2_s = gr.State(0) pre_cap_s = gr.State(0) pre_cl_s = gr.State(0) cust_df_s = gr.State(pd.DataFrame()) # custom mode DataFrame cust_schema_s = gr.State([]) # list of {name, type, options} cust_idx_s = gr.State(0) cust_cap_s = gr.State(0) # caption tab index (custom) cust_cl_s = gr.State(0) # cluster tab index (custom) gc_df_s = gr.State(pd.DataFrame()) # guided-clustering (ITGC) DataFrame gc_meta_s = gr.State({}) # {concepts, history, per_cluster, names, sil} gc_labeled_s = gr.State([]) # [(url, label)] few-shot examples (DINOv2) gc_lbl_idx_s = gr.State(0) # current image index in the interactive labeller gc_lblcats_s = gr.State([]) # ordered category labels for the labeller buttons gr.Markdown("# 🔍 IAnahid — Heritage Image Annotator") # ══════════════════════════════════════════════════════════════════════════ # HOME PAGE # ══════════════════════════════════════════════════════════════════════════ with gr.Group(visible=True) as home_view: with gr.Row(equal_height=False): # Left column: guidelines with gr.Column(scale=2, elem_classes=["home-guidelines"]): gr.Markdown(GUIDELINES) # Right column: mode selection with gr.Column(scale=3, elem_classes=["home-modes"]): gr.Markdown("## Select an annotation mode") gr.Markdown( "IAnahid supports two workflows. Choose the one that fits your project:" ) with gr.Group(elem_classes=["mode-card"]): gr.Markdown( "### 🏛️ Predefined Schema\n" "Use the built-in annotation schema designed for heritage archive " "cataloguing: **Tag 1** (document type), **Tag 2** (subject), and " "an **AI-assisted caption**. Includes cluster-level batch validation.\n\n" "*Best for: ThierryNum datasets and heritage cataloguing projects.*" ) btn_go_predefined = gr.Button( "Start with Predefined Schema →", variant="primary", size="lg" ) with gr.Group(elem_classes=["mode-card"]): gr.Markdown( "### 🛠️ Custom Schema\n" "Define your own metadata fields — names, types (free text or " "controlled dropdown), and options. Annotate at your own pace " "and export in CSV or JSON.\n\n" "*Best for: original research projects with custom annotation needs.*" ) btn_go_custom = gr.Button( "Start with Custom Schema →", variant="secondary", size="lg" ) with gr.Group(elem_classes=["mode-card"]): gr.Markdown( "### 🧭 Guided Clustering (ITGC)\n" "Let an AI **discover interpretable clusters** from a plain-language " "criterion (e.g. *“group by document type”*). A text model proposes " "visual concepts, a **vision model** scores each image against them, and the " "groups are refined over several iterations — each cluster is explained " "by the concepts that define it. Results feed straight into cluster " "batch validation.\n\n" "*Best for: exploring a fresh image set and building meaningful clusters.*" ) btn_go_clustering = gr.Button( "Start Guided Clustering →", variant="secondary", size="lg" ) # ══════════════════════════════════════════════════════════════════════════ # PREDEFINED SCHEMA VIEW # ══════════════════════════════════════════════════════════════════════════ with gr.Group(visible=False) as predefined_view: with gr.Row(): btn_pre_home = gr.Button("← Back to Home", size="sm", scale=0) gr.Markdown("### 🏛️ Predefined Schema Mode") with gr.Tabs() as pre_tabs: # ── Data tab ───────────────────────────────────────────────────── with gr.Tab("📁 Data", id="pre_data"): gr.HTML('No images.
") pre_cl_dd = gr.Dropdown(VOCAB_TAG_2, label="Assign Tag 2 to entire cluster") pre_cl_apply = gr.Button("✅ Apply to cluster & continue", variant="primary") pre_cl_msg = gr.Markdown("") # ── Export file tab (table + export) ────────────────────────────── with gr.Tab("📊 Export file", id="pre_export"): pre_tbl_refresh = gr.Button("🔄 Refresh") pre_tbl_note = gr.Markdown(tbl_note(_default_df)) pre_tbl = gr.Dataframe(value=tbl_preview(_default_df), interactive=False) gr.HTML('No images.
") with gr.Row(): cust_cl_field = gr.Dropdown([], label="Field to assign") cust_cl_val = gr.Dropdown([], label="Value to apply") cust_cl_apply = gr.Button("✅ Apply to cluster & continue", variant="primary") cust_cl_msg = gr.Markdown("") # ── Export file tab (table + export) ────────────────────────────── with gr.Tab("📊 Export file", id="cust_export"): gr.HTML('Load images (📁 Data) and press Prepare.
") gc_lbl_info = gr.Markdown("—") with gr.Row(): gc_lbl_prev = gr.Button("⬅️ Previous", scale=1) gc_lbl_skip = gr.Button("⏭️ Skip", scale=1) gc_lbl_undo = gr.Button("↩️ Remove last example", scale=1) gc_lbl_btns = [] with gr.Row(): for _i in range(GC_MAX_LABEL_BTNS): gc_lbl_btns.append(gr.Button(f"cat {_i}", visible=False, variant="primary", size="sm")) with gr.Accordion("📄 …or upload a labelled file (CSV/JSON)", open=False): gr.Markdown( "A `.csv`/`.json` with an `image_url` column and a label column " "(`label`, `category`, `tag_2`, or `clustering_id`). An annotation file " "exported from Predefined/Custom mode works directly." ) gc_labeled_load = gr.UploadButton( "🏷️ Load labelled examples", file_types=[".csv", ".json"], variant="secondary", ) with gr.Row(): gc_k = gr.Slider(2, 12, value=6, step=1, label="Number of clusters k (discovery mode only)") gc_iters = gr.Slider(1, 10, value=4, step=1, label="Refinement iterations (discovery mode only)") gc_sample = gr.Slider(20, 200, value=60, step=10, label="Max images scored / iteration (discovery)") gr.Markdown( "*Time scales with the image count. With the **Local SigLIP2** backend, " "images are embedded once (cached) then classified/clustered instantly; " "re-runs and category changes are immediate. With the **Mistral API**, every " "image is a fresh API call. Results appear progressively with a progress bar; " "the API discovery loop refines over iterations and stops early once the " "silhouette score plateaus (k / iterations sliders apply to discovery).*" ) gc_run_btn = gr.Button("🚀 Run", variant="primary", size="lg") gc_log = gr.Markdown("") # ── Results & Visualisation tab ────────────────────────────────── with gr.Tab("🔎 Results"): gr.HTML('No clusters yet — run guided clustering.
" ) # ── Export tab ─────────────────────────────────────────────────── with gr.Tab("📊 Export"): gr.HTML('IAnahid — developed during the 2026 École nationale des chartes – PSL hackathon for the ThierryNum project.
No data.
", "—", "" cl_idx = min(int(cl_idx), len(clusters) - 1) return ( cl_idx, cluster_title(df, cl_idx), cluster_gallery_html(df, cl_idx), f"Cluster {cl_idx + 1} / {len(clusters)}", "", ) _pre_refresh_outputs = [ pre_df_s, pre_t1_s, pre_t1_img, pre_t1_info, pre_t1_cnt, pre_t2_s, pre_t2_img, pre_t2_info, pre_t2_cnt, pre_cap_s, pre_cap_img, pre_cap_info, pre_cap_cnt, pre_cl_s, pre_cl_title, pre_cl_html, pre_cl_cnt, pre_tbl_note, pre_tbl, pre_tabs, pre_data_msg, ] def _pre_load_error(msg): """Return a no-op tuple of the right length for _pre_refresh_outputs on error.""" n = len(_pre_refresh_outputs) return (gr.update(),) * (n - 1) + (msg,) # ── Predefined: load URL list ───────────────────────────────────────────── def pre_load_urls(file_obj): if file_obj is None: return _pre_load_error("⚠️ Select a URL list file first.") try: path = file_obj.name if hasattr(file_obj, "name") else file_obj urls = load_url_list(path) if not urls: return _pre_load_error("⚠️ No URLs found in the file.") df = pd.DataFrame({"image_url": urls}) df = _ensure_predefined_columns(df) except Exception as e: return _pre_load_error(f"❌ Error: {e}") return _pre_full_refresh(df) def pre_load_annotation(file_obj): if file_obj is None: return _pre_load_error("⚠️ Select an annotation file first.") try: path = file_obj.name if hasattr(file_obj, "name") else file_obj df, _ = load_annotation_file(path) df = _ensure_predefined_columns(df) except Exception as e: return _pre_load_error(f"❌ Error: {e}") return _pre_full_refresh(df) def _pre_full_refresh(df): """Return all outputs needed to refresh every predefined tab, and automatically switch to the next annotation tab (Tag 1).""" n = max(len(df), 1) t1i = first_unvalidated(df, "validated_tag_1") t2i = first_unvalidated(df, "validated_tag_2") capi = first_unvalidated(df, "validated_caption") cl_idx, cl_ttl, cl_html, cl_cnt_txt, _ = _pre_cl_view(df, 0) return ( df, # pre_df_s t1i, img_html(img_url_at(df, t1i)), # t1_s, t1_img row_info_predefined(df, t1i, "tag_1", "validated_tag_1"), counter(t1i, n), # t1_info, t1_cnt t2i, img_html(img_url_at(df, t2i)), row_info_predefined(df, t2i, "tag_2", "validated_tag_2"), counter(t2i, n), capi, img_html(img_url_at(df, capi)), row_info_predefined(df, capi, "image_caption", "validated_caption"), counter(capi, n), cl_idx, cl_ttl, cl_html, cl_cnt_txt, tbl_note(df), tbl_preview(df), # tbl (browser preview only) gr.Tabs(selected="pre_t1"), # auto-advance to next tab f"✅ Loaded {len(df)} rows, {len(cluster_list(df))} clusters.", ) def pre_load_from_hf(): """Load the example dataset — the HuggingFace-hosted copy if DATASET_REPO and HF_TOKEN are set, otherwise the bundled exemple.csv (no upload needed).""" df = _load_default_df() if df.empty: return (gr.update(),) * (len(_pre_refresh_outputs) - 1) + ( f"⚠️ Could not load the example dataset. The bundled «{CSV_FILENAME}» " "was not found and no valid DATASET_REPO / HF_TOKEN secrets are set.", ) return _pre_full_refresh(df) pre_url_load.upload(pre_load_urls, [pre_url_load], _pre_refresh_outputs, api_name=False) pre_ann_load.upload(pre_load_annotation, [pre_ann_load], _pre_refresh_outputs, api_name=False) pre_hf_load.click(pre_load_from_hf, [], _pre_refresh_outputs, api_name=False) # ── Predefined: export ───────────────────────────────────────────────────── # Clicking a button generates the file and reveals a download link below. def do_pre_export_csv(df): if df is None or df.empty: return gr.update(visible=False), "⚠️ Nothing to export — load data first." return gr.update(value=export_as_csv(df), visible=True), "" def do_pre_export_json(df): if df is None or df.empty: return gr.update(visible=False), "⚠️ Nothing to export — load data first." return gr.update(value=export_as_json(df), visible=True), "" pre_exp_csv.click(do_pre_export_csv, [pre_df_s], [pre_exp_file, pre_exp_msg], api_name=False) pre_exp_json.click(do_pre_export_json, [pre_df_s], [pre_exp_file, pre_exp_msg], api_name=False) # ── Predefined: Tag 1 events ────────────────────────────────────────────── def on_pre_t1_prev(df, idx): ni = max(int(idx) - 1, 0) return (ni,) + _pre_t1_view(df, ni) def on_pre_t1_skip(df, idx): ni = min(int(idx) + 1, max(len(df) - 1, 0)) return (ni,) + _pre_t1_view(df, ni) def on_pre_t1_mod(): return gr.update(visible=True) def on_pre_t1_ok(df, idx): idx = int(idx) current = df.at[idx, "tag_1"] if not df.empty else "" if not str(current).strip(): return (df, idx) + _pre_t1_view(df, idx)[:-1] + ("⚠️ Cannot validate an empty tag. Choose a value first.",) df = df.copy() df.at[idx, "validated_tag_1"] = True ni = min(idx + 1, max(len(df) - 1, 0)) # Message is cleared on advance so the tick doesn't appear to belong to the next photo. return (df, ni) + _pre_t1_view(df, ni)[:-1] + ("",) def on_pre_t1_sv(df, idx, new_val): if not new_val: return df, int(idx), gr.update(), gr.update(), gr.update(), gr.update(visible=True), "⚠️ Select a category." idx = int(idx); df = df.copy() df.at[idx, "tag_1"] = new_val; df.at[idx, "validated_tag_1"] = True ni = min(idx + 1, max(len(df) - 1, 0)) return (df, ni) + _pre_t1_view(df, ni)[:-1] + ("",) _t1_outs = [pre_t1_s, pre_t1_img, pre_t1_info, pre_t1_cnt, pre_t1_edit, pre_t1_msg] pre_t1_prev.click(on_pre_t1_prev, [pre_df_s, pre_t1_s], _t1_outs, api_name=False) pre_t1_skip.click(on_pre_t1_skip, [pre_df_s, pre_t1_s], _t1_outs, api_name=False) pre_t1_mod.click(on_pre_t1_mod, [], [pre_t1_edit], api_name=False) pre_t1_ok.click(on_pre_t1_ok, [pre_df_s, pre_t1_s], [pre_df_s] + _t1_outs, api_name=False) pre_t1_sv.click(on_pre_t1_sv, [pre_df_s, pre_t1_s, pre_t1_dd], [pre_df_s] + _t1_outs, api_name=False) # ── Predefined: Tag 2 events ────────────────────────────────────────────── def on_pre_t2_prev(df, idx): ni = max(int(idx) - 1, 0) return (ni,) + _pre_t2_view(df, ni) def on_pre_t2_skip(df, idx): ni = min(int(idx) + 1, max(len(df) - 1, 0)) return (ni,) + _pre_t2_view(df, ni) def on_pre_t2_mod(): return gr.update(visible=True) def on_pre_t2_ok(df, idx): idx = int(idx) current = df.at[idx, "tag_2"] if not df.empty else "" if not str(current).strip(): return (df, idx) + _pre_t2_view(df, idx)[:-1] + ("⚠️ Cannot validate an empty tag. Choose a value first.",) df = df.copy() df.at[idx, "validated_tag_2"] = True ni = min(idx + 1, max(len(df) - 1, 0)) return (df, ni) + _pre_t2_view(df, ni)[:-1] + ("",) def on_pre_t2_sv(df, idx, new_val): if not new_val: return df, int(idx), gr.update(), gr.update(), gr.update(), gr.update(visible=True), "⚠️ Select a category." idx = int(idx); df = df.copy() df.at[idx, "tag_2"] = new_val; df.at[idx, "validated_tag_2"] = True ni = min(idx + 1, max(len(df) - 1, 0)) return (df, ni) + _pre_t2_view(df, ni)[:-1] + ("",) _t2_outs = [pre_t2_s, pre_t2_img, pre_t2_info, pre_t2_cnt, pre_t2_edit, pre_t2_msg] pre_t2_prev.click(on_pre_t2_prev, [pre_df_s, pre_t2_s], _t2_outs, api_name=False) pre_t2_skip.click(on_pre_t2_skip, [pre_df_s, pre_t2_s], _t2_outs, api_name=False) pre_t2_mod.click(on_pre_t2_mod, [], [pre_t2_edit], api_name=False) pre_t2_ok.click(on_pre_t2_ok, [pre_df_s, pre_t2_s], [pre_df_s] + _t2_outs, api_name=False) pre_t2_sv.click(on_pre_t2_sv, [pre_df_s, pre_t2_s, pre_t2_dd], [pre_df_s] + _t2_outs, api_name=False) # ── Predefined: Caption events ──────────────────────────────────────────── def on_pre_cap_prev(df, idx): ni = max(int(idx) - 1, 0) return (ni,) + _pre_cap_view(df, ni) def on_pre_cap_skip(df, idx): ni = min(int(idx) + 1, max(len(df) - 1, 0)) return (ni,) + _pre_cap_view(df, ni) def on_pre_cap_gen(df, idx, api_key, model): url = img_url_at(df, int(idx)) text, msg = generate_mistral_caption(url, api_key, model) return gr.update(value=text) if text else gr.update(), msg def on_pre_cap_sv(df, idx, text): if not str(text).strip(): return (df, int(idx),) + (gr.update(),) * 4 + ("⚠️ Caption is empty.",) idx = int(idx); df = df.copy() df.at[idx, "image_caption"] = text; df.at[idx, "validated_caption"] = True ni = min(idx + 1, max(len(df) - 1, 0)) return (df, ni) + _pre_cap_view(df, ni)[:-1] + ("",) _cap_outs = [pre_cap_s, pre_cap_img, pre_cap_info, pre_cap_cnt, pre_cap_txt, pre_cap_msg] pre_cap_prev.click(on_pre_cap_prev, [pre_df_s, pre_cap_s], _cap_outs, api_name=False) pre_cap_skip.click(on_pre_cap_skip, [pre_df_s, pre_cap_s], _cap_outs, api_name=False) pre_cap_gen.click(on_pre_cap_gen, [pre_df_s, pre_cap_s, pre_cap_key, pre_cap_mdl], [pre_cap_txt, pre_cap_msg], api_name=False) pre_cap_sv.click(on_pre_cap_sv, [pre_df_s, pre_cap_s, pre_cap_txt], [pre_df_s] + _cap_outs, api_name=False) # ── Predefined: Cluster events ──────────────────────────────────────────── def on_pre_cl_prev(df, cl_idx): ni = max(int(cl_idx) - 1, 0) return _pre_cl_view(df, ni) def on_pre_cl_skip(df, cl_idx): clusters = cluster_list(df) ni = min(int(cl_idx) + 1, max(len(clusters) - 1, 0)) return _pre_cl_view(df, ni) def on_pre_cl_apply(df, cl_idx, chosen): if not chosen: return (df,) + _pre_cl_view(df, cl_idx)[:-1] + ("⚠️ Select a class first.",) clusters = cluster_list(df); cl_idx = int(cl_idx) c_id = clusters[cl_idx] df = df.copy() mask = df["clustering_id"].astype(str) == c_id df.loc[mask, "tag_2"] = chosen; df.loc[mask, "validated_tag_2"] = True ni = min(cl_idx + 1, max(len(clusters) - 1, 0)) return (df,) + _pre_cl_view(df, ni)[:-1] + (f"✅ «{chosen}» applied to cluster {c_id}.",) _cl_outs = [pre_cl_s, pre_cl_title, pre_cl_html, pre_cl_cnt, pre_cl_msg] pre_cl_prev.click(on_pre_cl_prev, [pre_df_s, pre_cl_s], _cl_outs, api_name=False) pre_cl_skip.click(on_pre_cl_skip, [pre_df_s, pre_cl_s], _cl_outs, api_name=False) pre_cl_apply.click(on_pre_cl_apply, [pre_df_s, pre_cl_s, pre_cl_dd], [pre_df_s] + _cl_outs, api_name=False) # ── Predefined: Table refresh ───────────────────────────────────────────── pre_tbl_refresh.click(lambda df: (tbl_note(df), tbl_preview(df)), [pre_df_s], [pre_tbl_note, pre_tbl], api_name=False) # ══════════════════════════════════════════════════════════════════════════ # EVENT WIRING — Custom schema mode # ══════════════════════════════════════════════════════════════════════════ # ── Schema builder ──────────────────────────────────────────────────────── def toggle_options(field_type): return gr.update(visible=(field_type == "dropdown")) sch_type.change(toggle_options, [sch_type], [sch_options]) def add_field(schema, name, ftype, options): name = name.strip() if not name: return schema, _schema_df(schema), "⚠️ Field name is required." # Sanitise: replace spaces with underscores col_name = name.lower().replace(" ", "_") if any(f["name"] == col_name for f in schema): return schema, _schema_df(schema), f"⚠️ Field «{col_name}» already exists." opts = [o.strip() for o in options.split(",") if o.strip()] if ftype == "dropdown" else [] schema = schema + [{"name": col_name, "label": name, "type": ftype, "options": opts}] return schema, _schema_df(schema), f"✅ Field «{col_name}» added." def _schema_df(schema): rows = [[f["label"], f["type"], ", ".join(f.get("options", []))] for f in schema] return pd.DataFrame(rows, columns=["Field name", "Type", "Options"]) def clear_schema(): return [], _schema_df([]), "" sch_add.click( add_field, [cust_schema_s, sch_name, sch_type, sch_options], [cust_schema_s, sch_display, sch_msg], api_name=False, ) sch_clear.click(clear_schema, [], [cust_schema_s, sch_display, sch_msg], api_name=False) # ── Custom: load URL list ───────────────────────────────────────────────── def _cust_load_error(msg): """Return a no-op tuple of the right length for _cust_load_outs on error.""" n = len(_cust_load_outs) return (gr.update(),) * (n - 1) + (msg,) def cust_load_urls(file_obj, schema): if file_obj is None: return _cust_load_error("⚠️ Select a URL list file first.") if not schema: return _cust_load_error("⚠️ Define your schema first in the 📐 Schema builder tab.") try: path = file_obj.name if hasattr(file_obj, "name") else file_obj urls = load_url_list(path) if not urls: return _cust_load_error("⚠️ No URLs found in the file.") df = pd.DataFrame({"image_url": urls}) df = _ensure_custom_columns(df, schema) except Exception as e: return _cust_load_error(f"❌ Error: {e}") return _cust_full_refresh(df, schema, 0) + (f"✅ Loaded {len(df)} image URLs.",) def cust_load_annotation(file_obj, schema): if file_obj is None: return _cust_load_error("⚠️ Select an annotation file first.") try: path = file_obj.name if hasattr(file_obj, "name") else file_obj df, saved_schema = load_annotation_file(path) if saved_schema: schema = saved_schema if not schema: return _cust_load_error("⚠️ No schema found. Define one in the 📐 Schema builder first.") df = _ensure_custom_columns(df, schema) except Exception as e: return _cust_load_error(f"❌ Error: {e}") idx = _first_cust_unvalidated(df, schema) return _cust_full_refresh(df, schema, idx) + (f"✅ Resumed — {len(df)} rows.",) def _first_cust_unvalidated(df, schema): if df.empty or not schema: return 0 # Find first row where any field is unvalidated for i in range(len(df)): row = df.iloc[i] for field in schema: val_col = f"validated_{field['name']}" if val_col not in df.columns or row.get(val_col) != True: return i return 0 def _cust_field_updates(schema, df, idx): """Return gr.update() tuples for all 10 field slots.""" updates = [] row = df.iloc[int(idx)] if not df.empty and idx < len(df) else None for i, fr in enumerate(cust_field_rows): if i < len(schema): field = schema[i] val = str(row[field["name"]]) if row is not None and pd.notna(row.get(field["name"])) else "" is_dd = field["type"] == "dropdown" opts = field.get("options", []) updates += [ gr.update(visible=True), # grp gr.update(value=f"**{field['label']}**"), # lbl gr.update(value=val if not is_dd else "", visible=not is_dd), # txt gr.update(choices=opts, value=val if is_dd and val in opts else None, visible=is_dd), # drp ] else: updates += [ gr.update(visible=False), gr.update(value=""), gr.update(value="", visible=False), gr.update(choices=[], value=None, visible=False), ] return updates def _cust_dropdown_fields(schema): """Return list of dropdown-type field names for cluster/caption field selectors.""" return [f["name"] for f in schema if f["type"] == "dropdown"] def _cust_text_fields(schema): """Return list of text-type field names for caption target selector.""" return [f["name"] for f in schema if f["type"] == "text"] def _cust_all_fields(schema): return [f["name"] for f in schema] def _cust_cl_view(df, schema, cl_idx): clusters = cluster_list(df) if not clusters: return 0, "No clusters found.", "No data or no clustering_id column.
", "—", "" cl_idx = min(int(cl_idx), len(clusters) - 1) return ( cl_idx, cluster_title(df, cl_idx), cluster_gallery_html(df, cl_idx), f"Cluster {cl_idx + 1} / {len(clusters)}", "", ) def _cust_full_refresh(df, schema, idx): n = max(len(df), 1) field_upds = _cust_field_updates(schema, df, idx) # caption tab cap_cnt = f"**{idx + 1} / {n}**" cap_img = img_html(img_url_at(df, idx)) # field choices for caption and cluster dropdowns text_fields = _cust_text_fields(schema) dd_fields = _cust_dropdown_fields(schema) # cluster cl_idx, cl_ttl, cl_html, cl_cnt_txt, _ = _cust_cl_view(df, schema, 0) return ( df, schema, idx, 0, 0, img_html(img_url_at(df, idx)), f"**{idx + 1} / {n}** — fill in the fields below and click Save.", *field_upds, cap_cnt, cap_img, gr.update(choices=text_fields + dd_fields, value=None), # cust_cap_field "", # cust_cap_txt "", # cust_cap_msg cl_idx, cl_ttl, cl_html, cl_cnt_txt, gr.update(choices=dd_fields, value=None), # cust_cl_field gr.update(choices=[], value=None), # cust_cl_val "", # cust_cl_msg tbl_note(df), tbl_preview(df), # tbl (browser preview only) gr.Tabs(selected="cust_ann"), # auto-advance to Annotate tab ) _cust_all_field_outs = [] for fr in cust_field_rows: _cust_all_field_outs += [fr["grp"], fr["lbl"], fr["txt"], fr["drp"]] _cust_load_outs = ( [cust_df_s, cust_schema_s, cust_idx_s, cust_cap_s, cust_cl_s, cust_img, cust_info] + _cust_all_field_outs + [cust_cap_cnt, cust_cap_img, cust_cap_field, cust_cap_txt, cust_cap_msg, cust_cl_s, cust_cl_title, cust_cl_html, cust_cl_cnt, cust_cl_field, cust_cl_val, cust_cl_msg, cust_tbl_note, cust_tbl, cust_tabs, cust_data_msg] ) cust_url_load.upload(cust_load_urls, [cust_url_load, cust_schema_s], _cust_load_outs, api_name=False) cust_ann_load.upload(cust_load_annotation, [cust_ann_load, cust_schema_s], _cust_load_outs, api_name=False) # ── Custom: export ───────────────────────────────────────────────────────── # Clicking a button generates the file and reveals a download link below. # The JSON export embeds the schema so the file can be fully reloaded later. def do_cust_export_csv(df): if df is None or df.empty: return gr.update(visible=False), "⚠️ Nothing to export — load data first." return gr.update(value=export_as_csv(df), visible=True), "" def do_cust_export_json(df, schema): if df is None or df.empty: return gr.update(visible=False), "⚠️ Nothing to export — load data first." return gr.update(value=export_as_json(df, schema), visible=True), "" cust_exp_csv.click(do_cust_export_csv, [cust_df_s], [cust_exp_file, cust_exp_msg], api_name=False) cust_exp_json.click(do_cust_export_json, [cust_df_s, cust_schema_s], [cust_exp_file, cust_exp_msg], api_name=False) # ── Custom: annotation navigation ──────────────────────────────────────── def _cust_nav_outs(df, schema, ni): n = max(len(df), 1) field_upds = _cust_field_updates(schema, df, ni) return (ni, img_html(img_url_at(df, ni)), f"**{ni + 1} / {n}**", *field_upds, "") def on_cust_prev(df, schema, idx): ni = max(int(idx) - 1, 0) return _cust_nav_outs(df, schema, ni) def on_cust_skip(df, schema, idx): ni = min(int(idx) + 1, max(len(df) - 1, 0)) return _cust_nav_outs(df, schema, ni) _cust_nav_out_keys = [cust_idx_s, cust_img, cust_cnt] + _cust_all_field_outs + [cust_msg] cust_prev.click(on_cust_prev, [cust_df_s, cust_schema_s, cust_idx_s], _cust_nav_out_keys, api_name=False) cust_skip.click(on_cust_skip, [cust_df_s, cust_schema_s, cust_idx_s], _cust_nav_out_keys, api_name=False) # ── Custom: save ────────────────────────────────────────────────────────── # We collect all text and dropdown values for the 10 field slots _cust_field_vals = [] for fr in cust_field_rows: _cust_field_vals += [fr["txt"], fr["drp"]] def on_cust_sv(df, schema, idx, *field_vals): if df.empty or not schema: return (df,) + _cust_nav_outs(df, schema, int(idx))[:-1] + ("⚠️ No data loaded.",) idx = int(idx); df = df.copy() # field_vals alternates: txt0, drp0, txt1, drp1, … values = {} for i, field in enumerate(schema): txt_val = field_vals[i * 2] drp_val = field_vals[i * 2 + 1] value = drp_val if field["type"] == "dropdown" else txt_val values[field["name"]] = str(value).strip() if value else "" for name, v in values.items(): df.at[idx, name] = v if any(v == "" for v in values.values()): # Do not validate or advance while any field is still empty. return (df,) + _cust_nav_outs(df, schema, idx)[:-1] + ("⚠️ Fill in all fields before continuing.",) for name in values: df.at[idx, f"validated_{name}"] = True ni = min(idx + 1, max(len(df) - 1, 0)) return (df,) + _cust_nav_outs(df, schema, ni)[:-1] + ("",) cust_sv.click( on_cust_sv, [cust_df_s, cust_schema_s, cust_idx_s] + _cust_field_vals, [cust_df_s] + _cust_nav_out_keys, api_name=False, ) # ── Custom: caption events ──────────────────────────────────────────────── def on_cust_cap_prev(df, idx): ni = max(int(idx) - 1, 0) n = max(len(df), 1) return ni, img_html(img_url_at(df, ni)), f"**{ni + 1} / {n}**", "", "" def on_cust_cap_skip(df, idx): ni = min(int(idx) + 1, max(len(df) - 1, 0)) n = max(len(df), 1) return ni, img_html(img_url_at(df, ni)), f"**{ni + 1} / {n}**", "", "" def on_cust_cap_gen(df, idx, api_key, model): url = img_url_at(df, int(idx)) text, msg = generate_mistral_caption(url, api_key, model) return gr.update(value=text) if text else gr.update(), msg def on_cust_cap_sv(df, schema, idx, text, target_field): if not text.strip(): return df, int(idx), gr.update(), gr.update(), "⚠️ Caption is empty." if not target_field: return df, int(idx), gr.update(), gr.update(), "⚠️ Select a field to save the caption to." idx = int(idx); df = df.copy() if target_field in df.columns: df.at[idx, target_field] = text val_col = f"validated_{target_field}" if val_col in df.columns: df.at[idx, val_col] = True ni = min(idx + 1, max(len(df) - 1, 0)) n = max(len(df), 1) return df, ni, img_html(img_url_at(df, ni)), f"**{ni + 1} / {n}**", "" _cust_cap_nav = [cust_cap_s, cust_cap_img, cust_cap_cnt, cust_cap_txt, cust_cap_msg] cust_cap_prev.click(on_cust_cap_prev, [cust_df_s, cust_cap_s], _cust_cap_nav, api_name=False) cust_cap_skip.click(on_cust_cap_skip, [cust_df_s, cust_cap_s], _cust_cap_nav, api_name=False) cust_cap_gen.click(on_cust_cap_gen, [cust_df_s, cust_cap_s, cust_cap_key, cust_cap_mdl], [cust_cap_txt, cust_cap_msg], api_name=False) cust_cap_sv.click(on_cust_cap_sv, [cust_df_s, cust_schema_s, cust_cap_s, cust_cap_txt, cust_cap_field], [cust_df_s, cust_cap_s, cust_cap_img, cust_cap_cnt, cust_cap_msg], api_name=False) # When user picks a dropdown field in the cluster tab, populate its allowed values def on_cust_cl_field_change(schema, field_name): if not field_name or not schema: return gr.update(choices=[], value=None) for f in schema: if f["name"] == field_name: return gr.update(choices=f.get("options", []), value=None) return gr.update(choices=[], value=None) cust_cl_field.change(on_cust_cl_field_change, [cust_schema_s, cust_cl_field], [cust_cl_val], api_name=False) # ── Custom: cluster events ──────────────────────────────────────────────── def on_cust_cl_prev(df, schema, cl_idx): ni = max(int(cl_idx) - 1, 0) return _cust_cl_view(df, schema, ni) def on_cust_cl_skip(df, schema, cl_idx): clusters = cluster_list(df) ni = min(int(cl_idx) + 1, max(len(clusters) - 1, 0)) return _cust_cl_view(df, schema, ni) def on_cust_cl_apply(df, schema, cl_idx, field_name, value): if not field_name: return (df,) + _cust_cl_view(df, schema, cl_idx)[:-1] + ("⚠️ Select a field.",) if not value: return (df,) + _cust_cl_view(df, schema, cl_idx)[:-1] + ("⚠️ Select a value.",) clusters = cluster_list(df) if not clusters: return (df,) + _cust_cl_view(df, schema, cl_idx)[:-1] + ("⚠️ No clusters found.",) cl_idx = int(cl_idx) c_id = clusters[cl_idx] df = df.copy() mask = df["clustering_id"].astype(str) == c_id if field_name in df.columns: df.loc[mask, field_name] = value val_col = f"validated_{field_name}" if val_col in df.columns: df.loc[mask, val_col] = True ni = min(cl_idx + 1, max(len(clusters) - 1, 0)) return (df,) + _cust_cl_view(df, schema, ni)[:-1] + (f"✅ «{value}» applied to cluster {c_id}.",) _cust_cl_outs = [cust_cl_s, cust_cl_title, cust_cl_html, cust_cl_cnt, cust_cl_msg] cust_cl_prev.click(on_cust_cl_prev, [cust_df_s, cust_schema_s, cust_cl_s], _cust_cl_outs, api_name=False) cust_cl_skip.click(on_cust_cl_skip, [cust_df_s, cust_schema_s, cust_cl_s], _cust_cl_outs, api_name=False) cust_cl_apply.click(on_cust_cl_apply, [cust_df_s, cust_schema_s, cust_cl_s, cust_cl_field, cust_cl_val], [cust_df_s] + _cust_cl_outs, api_name=False) # ── Custom: table refresh ───────────────────────────────────────────────── cust_tbl_refresh.click(lambda df: (tbl_note(df), tbl_preview(df)), [cust_df_s], [cust_tbl_note, cust_tbl], api_name=False) # ══════════════════════════════════════════════════════════════════════════ # EVENT WIRING — Guided clustering (ITGC) mode # ══════════════════════════════════════════════════════════════════════════ gc_url_load.upload(gc_load_urls, [gc_url_load], [gc_df_s, gc_tbl, gc_status], api_name=False) gc_labeled_load.upload(gc_load_labeled, [gc_labeled_load], [gc_labeled_s, gc_labeled_status], api_name=False) # Interactive few-shot labeller: click a category → assign + advance (instant). _lbl_prep_outs = ([gc_lblcats_s, gc_lbl_idx_s, gc_lbl_img, gc_lbl_info, gc_labeled_status] + gc_lbl_btns) gc_lbl_prepare.click(_gc_lbl_prepare, [gc_df_s, gc_categories, gc_labeled_s], _lbl_prep_outs, api_name=False) gc_lbl_prev.click(lambda df, idx: _gc_lbl_nav(df, idx, -1), [gc_df_s, gc_lbl_idx_s], [gc_lbl_idx_s, gc_lbl_img, gc_lbl_info], api_name=False) gc_lbl_skip.click(lambda df, idx: _gc_lbl_nav(df, idx, 1), [gc_df_s, gc_lbl_idx_s], [gc_lbl_idx_s, gc_lbl_img, gc_lbl_info], api_name=False) gc_lbl_undo.click(_gc_lbl_undo, [gc_labeled_s], [gc_labeled_s, gc_labeled_status], api_name=False) _assign_outs = [gc_labeled_s, gc_lbl_idx_s, gc_lbl_img, gc_lbl_info, gc_labeled_status] for _bi, _btn in enumerate(gc_lbl_btns): _btn.click((lambda i: (lambda df, idx, labeled, cats: _gc_lbl_assign(i, df, idx, labeled, cats)))(_bi), [gc_df_s, gc_lbl_idx_s, gc_labeled_s, gc_lblcats_s], _assign_outs, api_name=False) gc_run_btn.click( gc_run, [gc_df_s, gc_query, gc_categories, gc_labeled_s, gc_backend, gc_k, gc_iters, gc_sample, gc_key, gc_vmodel, gc_tmodel], [gc_df_s, gc_meta_s, gc_log, gc_sil_html, gc_concepts_md, gc_clusters_html, gc_tbl], api_name=False, ) gc_exp_csv.click(do_gc_export_csv, [gc_df_s], [gc_exp_file, gc_exp_msg], api_name=False) gc_exp_json.click(do_gc_export_json, [gc_df_s], [gc_exp_file, gc_exp_msg], api_name=False) # ══════════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": # ssr_mode=False: Gradio's node-based SSR (enabled by default on HF Spaces) # is a known cause of apps hanging on an endless loading screen. demo.launch(ssr_mode=False, **(_STYLE_KWARGS if _GR_MAJOR >= 6 else {}))