| """ |
| 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 <img src="…"> 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 |
|
|
| |
| |
| |
| |
| os.environ.setdefault("USE_TF", "0") |
| os.environ.setdefault("USE_FLAX", "0") |
| os.environ.setdefault("USE_TORCH", "1") |
| os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") |
|
|
| |
| try: |
| from logos_b64 import LOGOS as _LOGOS |
| except Exception: |
| _LOGOS = "" |
|
|
| |
| |
| |
| |
| |
| DATASET_REPO = os.environ.get("DATASET_REPO", "") |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") |
| CSV_FILENAME = "exemple.csv" |
|
|
| |
| 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)." |
| ) |
|
|
| |
| 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 = """ |
| ## 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.** |
| """ |
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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"] |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
| |
| _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 <img> 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'<div style="height:{height}px;display:flex;align-items:center;' |
| f'justify-content:center;background:#f5f5f5;border-radius:8px;' |
| f'color:#999;font-size:14px;">No image URL</div>' |
| ) |
| src = iiif_resize(url, width) |
| return ( |
| f'<img src="{src}" loading="lazy" decoding="async" ' |
| f'style="max-height:{height}px;max-width:100%;' |
| f'object-fit:contain;border-radius:8px;background:#f5f5f5;" ' |
| f'onerror="this.style.display=\'none\';this.nextSibling.style.display=\'flex\'" />' |
| f'<div style="display:none;height:{height}px;align-items:center;' |
| f'justify-content:center;background:#f5f5f5;border-radius:8px;' |
| f'color:#c00;font-size:13px;">⚠ Could not load image from URL</div>' |
| ) |
|
|
|
|
| def row_info_predefined(df: pd.DataFrame, idx: int, tag_col: str, val_col: str) -> str: |
| if df.empty: |
| return "*No data loaded.*" |
| row = df.iloc[int(idx)] |
| val = row.get(tag_col, "—") |
| val = val if pd.notna(val) and str(val).strip() else "—" |
| val_str = str(val)[:80] + ("…" if len(str(val)) > 80 else "") |
| done = "✅ Validated" if row.get(val_col) is True else "⏳ Pending" |
| return ( |
| f"**Current value:** `{val_str}` | {done} | " |
| f"Title: {row.get('title', 'N/A')} | Cluster: {row.get('clustering_id', 'N/A')}" |
| ) |
|
|
|
|
| |
|
|
| def cluster_list(df: pd.DataFrame) -> list: |
| if df.empty or "clustering_id" not in df.columns: |
| return [] |
| all_c = df["clustering_id"].dropna().unique().tolist() |
| return sorted([str(c) for c in all_c if str(c).lower() not in ("nan", "")]) |
|
|
|
|
| def cluster_gallery_html(df: pd.DataFrame, cl_idx: int) -> str: |
| """Build an HTML gallery for the cluster at position cl_idx. |
| |
| Images are rendered as <img src="..."> — no download — with a CSS grid |
| layout (5 columns, auto rows). |
| """ |
| clusters = cluster_list(df) |
| if not clusters: |
| return "<p><em>No clusters found.</em></p>" |
| 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'<div style="text-align:center;">' |
| f'<img src="{thumb}" loading="lazy" decoding="async" ' |
| f'style="width:100%;height:120px;object-fit:cover;' |
| f'border-radius:6px;background:#eee;" title="{title}" />' |
| f'<div style="font-size:11px;color:#666;margin-top:4px;overflow:hidden;' |
| f'white-space:nowrap;text-overflow:ellipsis;">{title}</div>' |
| f'</div>' |
| ) |
| return ( |
| f'<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:8px;">' |
| f'{cards}</div>' |
| ) if cards else "<p><em>No images available for this cluster.</em></p>" |
|
|
|
|
| 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" |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| GC_N_CONCEPTS = 15 |
|
|
|
|
| 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 |
| 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 |
|
|
|
|
| |
|
|
| 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'<circle cx="{fx(i):.1f}" cy="{fy(v):.1f}" r="3" fill="#7c6f5e"/>' |
| for i, v in enumerate(vals)) |
| labs = "".join(f'<text x="{fx(i):.1f}" y="{fy(v) - 8:.1f}" font-size="9" ' |
| f'text-anchor="middle" fill="#888">{v:.2f}</text>' |
| for i, v in enumerate(vals)) |
| return ( |
| '<div class="ia-section">Silhouette score across iterations ' |
| '(the iterative search at work)</div>' |
| f'<svg viewBox="0 0 {W} {H}" width="100%" style="max-width:{W}px;' |
| f'background:#faf9f7;border-radius:8px;border:1px solid #e5e2db;">' |
| f'<polyline points="{pts}" fill="none" stroke="#7c6f5e" stroke-width="2"/>' |
| f'{dots}{labs}' |
| f'<text x="{pad}" y="{H - 6}" font-size="10" fill="#888">iter 1</text>' |
| f'<text x="{W - pad}" y="{H - 6}" font-size="10" fill="#888" ' |
| f'text-anchor="end">iter {n}</text></svg>' |
| ) |
|
|
|
|
| def gc_clusters_viz_html(df: pd.DataFrame, per_cluster: dict, names: dict) -> str: |
| """Per-cluster cards, image-first: a full-width thumbnail gallery of the actual |
| images in the cluster, with the distinctive concepts shown as compact chips.""" |
| clusters = cluster_list(df) |
| if not clusters: |
| return "<p><em>No clusters yet — run guided clustering.</em></p>" |
| 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'<span style="display:inline-block;background:#efece5;color:#5a5346;' |
| f'border-radius:12px;padding:2px 9px;margin:2px 4px 2px 0;font-size:12px;">' |
| f'{cname} <b style="color:#7c6f5e;">{cmean:.1f}</b></span>' |
| for cname, cmean, _d in info.get("top", []) |
| ) |
| chips_div = f'<div style="margin-bottom:10px;">{chips}</div>' if chips else '' |
| gallery = cluster_gallery_html(df, pos) |
| blocks.append( |
| '<div style="border:1px solid #e5e2db;border-radius:10px;padding:14px;' |
| 'margin-bottom:16px;">' |
| f'<div style="font-weight:600;font-size:16px;margin-bottom:4px;">🗂️ {name} ' |
| f'<span style="color:#888;font-weight:400;">· cluster {cid} · ' |
| f'{size} images</span></div>' |
| f'{chips_div}{gallery}</div>' |
| ) |
| return "".join(blocks) |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| _SIGLIP_MODEL = os.environ.get("SIGLIP_MODEL", "google/siglip2-base-patch16-224") |
| _SIGLIP = {"model": None, "proc": None} |
| _GC_IMG_EMB = {} |
|
|
| |
| |
| |
| |
| _GC_PROMPT_TEMPLATES = [ |
| "This is a photo of {}.", |
| "a photo of {}", |
| "an old photograph of {}", |
| "a black and white archival photograph of {}", |
| "une photo de {}", |
| ] |
|
|
| _GC_ENUM_RE = re.compile(r"^\s*\d+(?:\.\d+)*[.)]?\s*") |
|
|
|
|
| def _gc_prompt_text(label: str, hints: dict | None = None) -> str: |
| """Turn a user category label into the text used in prompts: prefer the |
| user-supplied hint if any, strip leading enumeration ('2.1. Architecture' |
| -> 'Architecture'), lowercase.""" |
| if hints and hints.get(label): |
| return hints[label].strip().lower() |
| txt = _GC_ENUM_RE.sub("", label).strip() |
| return (txt or label).lower() |
|
|
|
|
| def _siglip_load(): |
| """Lazily load SigLIP2. First call downloads the weights from HF (~1 GB) and |
| HF caches them on disk; subsequent calls (and Space restarts with a warm |
| cache) are fast.""" |
| if _SIGLIP["model"] is None: |
| from transformers import AutoModel, AutoProcessor |
| _SIGLIP["proc"] = AutoProcessor.from_pretrained(_SIGLIP_MODEL, use_fast=True) |
| _SIGLIP["model"] = AutoModel.from_pretrained(_SIGLIP_MODEL).eval() |
| return _SIGLIP["model"], _SIGLIP["proc"] |
|
|
|
|
| def gc_siglip_text_emb(texts): |
| """Return (embeddings (n,d) L2-normalised, logit_scale, logit_bias) for texts.""" |
| import torch |
| model, proc = _siglip_load() |
| |
| |
| inp = proc(text=list(texts), padding="max_length", max_length=64, |
| truncation=True, return_tensors="pt") |
| with torch.no_grad(): |
| e = model.get_text_features(**inp) |
| e = e / e.norm(dim=-1, keepdim=True) |
| try: |
| scale = float(model.logit_scale.detach().exp()) |
| except Exception: |
| scale = 1.0 |
| try: |
| bias = float(model.logit_bias.detach()) |
| except Exception: |
| bias = 0.0 |
| return e.cpu().numpy(), scale, bias |
|
|
|
|
| def _gc_fetch_pil(url, width=384): |
| """Fetch one image URL (IIIF-downscaled, with a browser-like UA) as an RGB |
| PIL image, or None on any failure. Shared by all local encoders.""" |
| from PIL import Image |
| try: |
| src = iiif_resize(url, width) if url else url |
| r = requests.get(src, timeout=15, headers=_HTTP_HEADERS) |
| r.raise_for_status() |
| return Image.open(io.BytesIO(r.content)).convert("RGB") |
| except Exception: |
| return None |
|
|
|
|
| def gc_siglip_embed_images(urls, on_batch=None, batch_size=16, fetch_workers=8): |
| """Embed images with SigLIP2 (L2-normalised), fetched in parallel and encoded |
| in batches, with a per-URL session cache. Returns a list aligned to `urls`; |
| unreachable/failed images are None. `on_batch(n)` drives the progress bar.""" |
| import torch |
| model, proc = _siglip_load() |
|
|
| out = [None] * len(urls) |
| for start in range(0, len(urls), batch_size): |
| idxs = list(range(start, min(start + batch_size, len(urls)))) |
| todo = [i for i in idxs if urls[i] not in _GC_IMG_EMB] |
| if todo: |
| with ThreadPoolExecutor(max_workers=fetch_workers) as ex: |
| imgs = list(ex.map(lambda i: _gc_fetch_pil(urls[i]), todo)) |
| valid = [(i, im) for i, im in zip(todo, imgs) if im is not None] |
| if valid: |
| inp = proc(images=[im for _, im in valid], return_tensors="pt") |
| with torch.no_grad(): |
| fe = model.get_image_features(**inp) |
| fe = (fe / fe.norm(dim=-1, keepdim=True)).cpu().numpy() |
| for (i, _im), e in zip(valid, fe): |
| _GC_IMG_EMB[urls[i]] = e |
| for i in idxs: |
| out[i] = _GC_IMG_EMB.get(urls[i]) |
| if on_batch: |
| on_batch(len(idxs)) |
| return out |
|
|
|
|
| def gc_classify_run_local(df, categories, progress, hints=None): |
| """Generator: classify every image into one fixed category with SigLIP2, fully |
| offline. Yields the 7 gc outputs, with images appearing progressively.""" |
| total = len(df) |
| urls = [str(u) for u in df["image_url"].tolist()] |
| log = [f"**Local SigLIP2 classification** — {total} images → one of " |
| f"{len(categories)} categories: {', '.join(categories)}. " |
| f"Prompt-ensemble zero-shot, fully offline, no API."] |
| progress(0.0, desc="Loading SigLIP2 (first run downloads the model)…") |
| yield (df, {}, "\n".join(log), "", gr.update(), gr.update(), gr.update()) |
|
|
| |
| |
| texts = [_gc_prompt_text(c, hints) for c in categories] |
| Ts, scale, bias = [], 1.0, 0.0 |
| for tpl in _GC_PROMPT_TEMPLATES: |
| T_t, scale, bias = gc_siglip_text_emb([tpl.format(t) for t in texts]) |
| Ts.append(T_t) |
| T = np.mean(Ts, axis=0) |
| T = T / np.linalg.norm(T, axis=1, keepdims=True) |
| assigned = {} |
| done = [0] |
|
|
| def _bar(n): |
| done[0] += n |
| progress(min(done[0] / max(total, 1), 0.99), |
| desc=f"Embedding & classifying… {done[0]}/{total}") |
|
|
| CHUNK = 48 |
| for start in range(0, total, CHUNK): |
| idxs = list(range(start, min(start + CHUNK, total))) |
| embs = gc_siglip_embed_images([urls[i] for i in idxs], on_batch=_bar) |
| for k, i in enumerate(idxs): |
| e = embs[k] |
| if e is None: |
| continue |
| logits = e @ T.T * scale + bias |
| j = int(np.argmax(logits)) |
| |
| |
| p = np.exp(logits - logits.max()) |
| p = p / p.sum() |
| assigned[i] = (categories[j], float(p[j]) * 10.0) |
| out_df = _ensure_predefined_columns(df.copy()) |
| out_df["clustering_id"] = [assigned[i][0] if i in assigned else "" |
| for i in range(total)] |
| per, names, md = _gc_classify_view(assigned, categories) |
| last = start + CHUNK >= total |
| yield ((out_df if last else df), ({"categories": categories} if last else {}), |
| "\n".join(log + [f"- {len(assigned)}/{total} images classified…"]), |
| "", md, gc_clusters_viz_html(out_df, per, names), tbl_preview(out_df)) |
|
|
| n_fail = total - len(assigned) |
| log.append(f"\n✅ Done — {len(assigned)}/{total} classified" |
| + (f", {n_fail} unreachable/skipped" if n_fail else "") |
| + ". Use the **📊 Export** tab.") |
| progress(1.0, desc="Done") |
| out_df = _ensure_predefined_columns(df.copy()) |
| out_df["clustering_id"] = [assigned[i][0] if i in assigned else "" |
| for i in range(total)] |
| per, names, md = _gc_classify_view(assigned, categories) |
| yield (out_df, {"categories": categories}, "\n".join(log), "", md, |
| gc_clusters_viz_html(out_df, per, names), tbl_preview(out_df)) |
|
|
|
|
| def gc_discover_run_local(df, k, progress, embed_fn=None, model_label="SigLIP2"): |
| """Generator: fully-offline visual clustering — k-means on local image |
| embeddings (no LLM, no concepts). Clusters are shown as image galleries. |
| `embed_fn` selects the encoder (SigLIP2 by default, or DINOv2).""" |
| embed_fn = embed_fn or gc_siglip_embed_images |
| total = len(df) |
| urls = [str(u) for u in df["image_url"].tolist()] |
| k = max(2, min(int(k), total)) |
| log = [f"**Local {model_label} clustering** — {total} images into k={k} visual " |
| f"groups (k-means on embeddings). Fully offline, no API."] |
| progress(0.0, desc=f"Loading {model_label} (first run downloads the model)…") |
| yield (df, {}, "\n".join(log), "", gr.update(), gr.update(), gr.update()) |
|
|
| done = [0] |
|
|
| def _bar(n): |
| done[0] += n |
| progress(min(done[0] / max(total, 1), 0.98), |
| desc=f"Embedding images… {done[0]}/{total}") |
|
|
| embs = embed_fn(urls, on_batch=_bar) |
| valid = [(i, e) for i, e in enumerate(embs) if e is not None] |
| if len(valid) < 2: |
| yield (df, {}, "\n".join(log + ["⚠️ Not enough images could be embedded."]), |
| "", gr.update(), gr.update(), gr.update()) |
| return |
| progress(0.99, desc="Clustering…") |
| E = np.vstack([e for _, e in valid]) |
| labels = gc_kmeans(E, k) |
|
|
| out_df = _ensure_predefined_columns(df.copy()) |
| cid = ["" for _ in range(total)] |
| for (i, _e), lab in zip(valid, labels): |
| cid[i] = str(int(lab)) |
| out_df["clustering_id"] = cid |
| per = {str(j): {"size": int((labels == j).sum()), "top": []} |
| for j in sorted({int(l) for l in labels})} |
| names = {c: f"Cluster {c}" for c in per} |
| md = (f"### Visual clusters ({model_label})\n{len(per)} clusters · " |
| f"{len(valid)}/{total} images embedded") |
| progress(1.0, desc="Done") |
| yield (out_df, {"kind": "local_visual"}, "\n".join(log + ["\n✅ Done."]), |
| "", md, gc_clusters_viz_html(out_df, per, names), tbl_preview(out_df)) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| _DINO_MODEL = os.environ.get("DINOV2_MODEL", "facebook/dinov2-with-registers-base") |
| _DINO = {"model": None, "proc": None} |
| _GC_DINO_EMB = {} |
|
|
|
|
| def _dino_load(): |
| if _DINO["model"] is None: |
| from transformers import AutoModel, AutoImageProcessor |
| _DINO["proc"] = AutoImageProcessor.from_pretrained(_DINO_MODEL, use_fast=True) |
| _DINO["model"] = AutoModel.from_pretrained(_DINO_MODEL).eval() |
| return _DINO["model"], _DINO["proc"] |
|
|
|
|
| def gc_dino_embed_images(urls, on_batch=None, batch_size=16, fetch_workers=8): |
| """Embed images with DINOv2 (L2-normalised CLS descriptor), batched + cached. |
| Same contract as gc_siglip_embed_images.""" |
| import torch |
| model, proc = _dino_load() |
|
|
| out = [None] * len(urls) |
| for start in range(0, len(urls), batch_size): |
| idxs = list(range(start, min(start + batch_size, len(urls)))) |
| todo = [i for i in idxs if urls[i] not in _GC_DINO_EMB] |
| if todo: |
| with ThreadPoolExecutor(max_workers=fetch_workers) as ex: |
| imgs = list(ex.map(lambda i: _gc_fetch_pil(urls[i]), todo)) |
| valid = [(i, im) for i, im in zip(todo, imgs) if im is not None] |
| if valid: |
| inp = proc(images=[im for _, im in valid], return_tensors="pt") |
| with torch.no_grad(): |
| o = model(**inp) |
| pooled = getattr(o, "pooler_output", None) |
| fe = pooled if pooled is not None else o.last_hidden_state[:, 0] |
| fe = (fe / fe.norm(dim=-1, keepdim=True)).cpu().numpy() |
| for (i, _im), e in zip(valid, fe): |
| _GC_DINO_EMB[urls[i]] = e |
| for i in idxs: |
| out[i] = _GC_DINO_EMB.get(urls[i]) |
| if on_batch: |
| on_batch(len(idxs)) |
| return out |
|
|
|
|
| def gc_fewshot_run_local(df, labeled, progress): |
| """Generator: few-shot classification. `labeled` = list of (url, category). |
| Build one L2-normalised DINOv2 centroid per category from the examples, then |
| assign every dataset image to its nearest centroid (cosine). Fully offline.""" |
| total = len(df) |
| cats0 = sorted({lab for _, lab in labeled}) |
| log = [f"**Local DINOv2 few-shot classification** — {total} images into " |
| f"{len(cats0)} categories from {len(labeled)} labelled examples: " |
| f"{', '.join(cats0)}. Fully offline, no API."] |
| progress(0.0, desc="Loading DINOv2 (first run downloads the model)…") |
| yield (df, {}, "\n".join(log), "", gr.update(), gr.update(), gr.update()) |
|
|
| |
| lab_urls = [str(u) for u, _ in labeled] |
| lab_embs = gc_dino_embed_images( |
| lab_urls, on_batch=lambda n: progress(0.05, desc="Embedding labelled examples…")) |
| proto = {} |
| for (u, lab), e in zip(labeled, lab_embs): |
| if e is not None: |
| proto.setdefault(lab, []).append(e) |
| cats = [c for c in cats0 if proto.get(c)] |
| if len(cats) < 2: |
| yield (df, {}, "\n".join(log + ["⚠️ Need ≥2 categories with reachable " |
| "labelled examples — check the example URLs."]), |
| "", gr.update(), gr.update(), gr.update()) |
| return |
| C = np.vstack([np.mean(proto[c], axis=0) for c in cats]) |
| C = C / np.linalg.norm(C, axis=1, keepdims=True) |
| n_ex = {c: len(proto[c]) for c in cats} |
| log.append("- prototypes: " + ", ".join(f"{c}×{n_ex[c]}" for c in cats)) |
|
|
| |
| urls = [str(u) for u in df["image_url"].tolist()] |
| assigned, done = {}, [0] |
|
|
| def _bar(n): |
| done[0] += n |
| progress(min(0.1 + 0.89 * done[0] / max(total, 1), 0.99), |
| desc=f"Embedding & classifying… {done[0]}/{total}") |
|
|
| CHUNK = 48 |
| for start in range(0, total, CHUNK): |
| idxs = list(range(start, min(start + CHUNK, total))) |
| embs = gc_dino_embed_images([urls[i] for i in idxs], on_batch=_bar) |
| for k, i in enumerate(idxs): |
| e = embs[k] |
| if e is None: |
| continue |
| sims = C @ e |
| j = int(np.argmax(sims)) |
| p = np.exp((sims - sims.max()) / 0.1) |
| p = p / p.sum() |
| assigned[i] = (cats[j], float(p[j]) * 10.0) |
| out_df = _ensure_predefined_columns(df.copy()) |
| out_df["clustering_id"] = [assigned[i][0] if i in assigned else "" |
| for i in range(total)] |
| per, names, md = _gc_classify_view(assigned, cats) |
| last = start + CHUNK >= total |
| yield ((out_df if last else df), ({"categories": cats} if last else {}), |
| "\n".join(log + [f"- {len(assigned)}/{total} images classified…"]), |
| "", md, gc_clusters_viz_html(out_df, per, names), tbl_preview(out_df)) |
|
|
| n_fail = total - len(assigned) |
| log.append(f"\n✅ Done — {len(assigned)}/{total} classified" |
| + (f", {n_fail} unreachable/skipped" if n_fail else "") |
| + ". Use the **📊 Export** tab.") |
| progress(1.0, desc="Done") |
| out_df = _ensure_predefined_columns(df.copy()) |
| out_df["clustering_id"] = [assigned[i][0] if i in assigned else "" |
| for i in range(total)] |
| per, names, md = _gc_classify_view(assigned, cats) |
| yield (out_df, {"categories": cats}, "\n".join(log), "", md, |
| gc_clusters_viz_html(out_df, per, names), tbl_preview(out_df)) |
|
|
|
|
| def _gc_load_labeled(path): |
| """Parse a labelled-examples file (CSV/JSON): rows with image_url + a label |
| column (label/category/tag_2/class/clustering_id, else the 2nd column). |
| Returns a list of (url, label).""" |
| ext = os.path.splitext(path)[1].lower() |
| if ext == ".json": |
| data = json.load(open(path, encoding="utf-8")) |
| rows = data.get("records", data) if isinstance(data, dict) else data |
| rows = [r for r in rows if isinstance(r, dict)] |
| else: |
| with open(path, "r", encoding="utf-8") as f: |
| sep = ";" if ";" in f.readline() else "," |
| rows = pd.read_csv(path, sep=sep, encoding="utf-8", |
| on_bad_lines="warn").to_dict("records") |
| label_keys = ["label", "category", "categorie", "catégorie", "tag_2", "tag2", |
| "class", "clustering_id"] |
| out = [] |
| for r in rows: |
| r = {str(k).strip(): v for k, v in r.items()} |
| url = str(r.get("image_url", "")).strip() |
| lab = "" |
| for k in label_keys: |
| if k in r and str(r[k]).strip() and str(r[k]).strip().lower() != "nan": |
| lab = str(r[k]).strip(); break |
| if not lab: |
| rest = [v for k, v in r.items() if k != "image_url"] |
| lab = str(rest[0]).strip() if rest else "" |
| if url and url.lower() != "nan" and lab and lab.lower() != "nan": |
| out.append((url, lab)) |
| return out |
|
|
|
|
| def gc_load_labeled(file_obj): |
| """Gradio handler: parse a labelled-examples file → (state, status markdown).""" |
| if file_obj is None: |
| return [], "*No labelled examples loaded.*" |
| try: |
| path = file_obj.name if hasattr(file_obj, "name") else file_obj |
| labeled = _gc_load_labeled(path) |
| except Exception as e: |
| return [], f"❌ Could not read labelled file: {e}" |
| if not labeled: |
| return [], "⚠️ No usable rows found (need an `image_url` column + a label column)." |
| counts = {} |
| for _u, lab in labeled: |
| counts[lab] = counts.get(lab, 0) + 1 |
| summary = ", ".join(f"{c}×{n}" for c, n in sorted(counts.items())) |
| warn = "" if len(counts) >= 2 else " ⚠️ need ≥2 categories for few-shot" |
| return labeled, f"✅ {len(labeled)} examples · {len(counts)} categories: {summary}{warn}" |
|
|
|
|
| def gc_classify_image(url: str, categories: list, api_key: str, |
| vision_model: str, cache: dict) -> tuple: |
| """Ask a Mistral vision model to put one image in EXACTLY ONE of `categories`. |
| |
| Returns (category, confidence 0-10). `category` is normalised to the exact |
| provided text (case-insensitive match) or 'Unclassified'. Cached per |
| (url, categories) so re-runs are instant. |
| """ |
| key = (url, tuple(categories)) |
| if key in cache: |
| return cache[key] |
| try: |
| b64 = _url_to_b64(url, width=512) |
| except Exception: |
| return (None, 0.0) |
| cats_txt = "\n".join(f"- {c}" for c in categories) |
| prompt = ( |
| "Classify the image into EXACTLY ONE of the following categories, choosing " |
| "the single best match based only on what is visible:\n" |
| f"{cats_txt}\n\n" |
| "Return ONLY a JSON object: " |
| "{\"category\": \"<exact category text>\", \"confidence\": <integer 0-10>}. " |
| "If none clearly applies, use \"category\": \"Unclassified\"." |
| ) |
| 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)) |
| raw = str(data.get("category", "")).strip() |
| conf = float(data.get("confidence", 0) or 0) |
| except Exception: |
| return (None, 0.0) |
| match = next((c for c in categories if c.strip().lower() == raw.lower()), None) |
| result = (match or "Unclassified", max(0.0, min(10.0, conf))) |
| cache[key] = result |
| return result |
|
|
|
|
| def _gc_classify_view(assigned: dict, categories: list) -> tuple: |
| """Build (per_cluster, names, concepts_md) for the classification results so far.""" |
| by_cat = {} |
| for _i, (cat, conf) in assigned.items(): |
| by_cat.setdefault(cat, []).append(conf) |
| order = [c for c in categories if c in by_cat] + \ |
| [c for c in by_cat if c not in categories] |
| per, names = {}, {} |
| for cat in order: |
| confs = by_cat.get(cat, []) |
| avg = sum(confs) / len(confs) if confs else 0.0 |
| per[cat] = {"size": len(confs), "top": [("avg confidence", avg, 0.0)]} |
| names[cat] = cat |
| counts = " · ".join(f"**{c}**: {len(by_cat.get(c, []))}" for c in order) |
| md = ("### Classification into fixed categories\n" + counts + |
| f"\n\n{len(assigned)} images classified · {len(categories)} categories") |
| return per, names, md |
|
|
|
|
| def gc_classify_run(df, categories, api_key, vmodel, progress): |
| """Generator: classify every image into one fixed category, with a progress bar |
| and image results that fill in progressively. Yields the 7 gc outputs.""" |
| cache, assigned = {}, {} |
| total = len(df) |
| urls = [str(u) for u in df["image_url"].tolist()] |
| log = [f"**Guided classification** — {total} images → one of {len(categories)} " |
| f"categories: {', '.join(categories)}.\n"] |
| progress(0.0, desc="Starting…") |
| yield (df, {}, "\n".join(log), "", gr.update(), gr.update(), gr.update()) |
|
|
| CHUNK = 40 |
| done = 0 |
| for start in range(0, total, CHUNK): |
| idxs = list(range(start, min(start + CHUNK, total))) |
| with ThreadPoolExecutor(max_workers=12) as ex: |
| futs = {ex.submit(gc_classify_image, urls[i], categories, api_key, vmodel, cache): i |
| for i in idxs} |
| for fut in as_completed(futs): |
| i = futs[fut] |
| cat, conf = fut.result() |
| if cat is not None: |
| assigned[i] = (cat, conf) |
| done += 1 |
| progress(min(done / max(total, 1), 0.99), |
| desc=f"Classifying images… {done}/{total}") |
| out_df = _ensure_predefined_columns(df.copy()) |
| out_df["clustering_id"] = [assigned[i][0] if i in assigned else "" |
| for i in range(total)] |
| per, names, md = _gc_classify_view(assigned, categories) |
| log_now = "\n".join(log + [f"- Classified {done}/{total} images…"]) |
| last = start + CHUNK >= total |
| meta = ({"categories": categories, "assigned_counts": {c: n for c, n in |
| [(k2, sum(1 for v in assigned.values() if v[0] == k2)) for k2 in names]}} |
| if last else {}) |
| yield ((out_df if last else df), meta, log_now, "", md, |
| gc_clusters_viz_html(out_df, per, names), tbl_preview(out_df)) |
|
|
| n_unc = sum(1 for v in assigned.values() if v[0] == "Unclassified") |
| n_fail = total - len(assigned) |
| log.append(f"\n✅ Done — {len(assigned)}/{total} classified" |
| + (f", {n_unc} unclassified" if n_unc else "") |
| + (f", {n_fail} unreachable/skipped" if n_fail else "") |
| + ". Use the **📊 Export** tab.") |
| progress(1.0, desc="Done") |
| out_df = _ensure_predefined_columns(df.copy()) |
| out_df["clustering_id"] = [assigned[i][0] if i in assigned else "" |
| for i in range(total)] |
| per, names, md = _gc_classify_view(assigned, categories) |
| yield (out_df, {"categories": categories}, "\n".join(log), "", md, |
| gc_clusters_viz_html(out_df, per, names), tbl_preview(out_df)) |
|
|
|
|
| GC_MAX_LABEL_BTNS = 12 |
|
|
|
|
| def _gc_parse_categories(text): |
| """Parse the categories textbox -> (ordered unique labels, {label: hint}). |
| A line may be 'Label | optional description' for a richer prompt.""" |
| seen, cats, hints = set(), [], {} |
| for line in str(text or "").splitlines(): |
| raw = line.strip().lstrip("-•*").strip() |
| if not raw: |
| continue |
| label, _, hint = raw.partition("|") |
| label, hint = label.strip(), hint.strip() |
| if label and label.lower() not in seen: |
| seen.add(label.lower()) |
| cats.append(label) |
| if hint: |
| hints[label] = hint |
| return cats, hints |
|
|
|
|
| def _gc_labeled_counts_md(labeled): |
| """Running summary of the interactively/loaded labelled set.""" |
| if not labeled: |
| return "*No examples yet — click a category for each image (~10-15 per category).*" |
| counts = {} |
| for _u, lab in labeled: |
| counts[lab] = counts.get(lab, 0) + 1 |
| body = " · ".join(f"**{c}** ×{n}" for c, n in sorted(counts.items())) |
| return f"🏷️ {body} — total {len(labeled)} examples" |
|
|
|
|
| def _gc_lbl_view(df, idx): |
| """(image HTML, 'Image i / N' caption) for the interactive labeller.""" |
| if df is None or df.empty: |
| return "<p><em>Load an image list first (📁 Data tab).</em></p>", "—" |
| 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 = "<p><em>Load an image list first (📁 Data tab).</em></p>", "—" |
| 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 |
|
|
| |
| cats, hints = _gc_parse_categories(categories) |
|
|
| bl = str(backend).lower() |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 |
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| 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 ''}") |
| |
| 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 |
|
|
| |
| 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, |
| 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), "" |
|
|
|
|
| |
|
|
| 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: |
| |
| 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}" |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| 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() |
|
|
| |
| |
| |
|
|
| 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 <body> 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; } |
| """ |
|
|
| |
| |
| _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: |
|
|
| |
| mode_s = gr.State("none") |
| pre_df_s = gr.State(_default_df) |
| 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()) |
| cust_schema_s = gr.State([]) |
| cust_idx_s = gr.State(0) |
| cust_cap_s = gr.State(0) |
| cust_cl_s = gr.State(0) |
|
|
| gc_df_s = gr.State(pd.DataFrame()) |
| gc_meta_s = gr.State({}) |
| gc_labeled_s = gr.State([]) |
| gc_lbl_idx_s = gr.State(0) |
| gc_lblcats_s = gr.State([]) |
|
|
| gr.Markdown("# 🔍 IAnahid — Heritage Image Annotator") |
|
|
| |
| |
| |
| with gr.Group(visible=True) as home_view: |
| with gr.Row(equal_height=False): |
| |
| with gr.Column(scale=2, elem_classes=["home-guidelines"]): |
| gr.Markdown(GUIDELINES) |
|
|
| |
| 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" |
| ) |
|
|
| |
| |
| |
| 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: |
|
|
| |
| with gr.Tab("📁 Data", id="pre_data"): |
| gr.HTML('<div class="ia-section">Load images or resume previous work</div>') |
| with gr.Row(elem_classes=["upload-btn-row"]): |
| with gr.Column(elem_classes=["upload-btn-col"]): |
| gr.Markdown( |
| "#### 🌐 New URL list\n" |
| "Upload a `.txt`, `.csv`, or `.json` file listing image URLs. " |
| "A new annotation file will be created." |
| ) |
| pre_url_load = gr.UploadButton( |
| "📤 Load URL list", file_types=[".txt", ".csv", ".json"], |
| variant="primary", size="lg", |
| ) |
| with gr.Column(elem_classes=["upload-btn-col"]): |
| gr.Markdown( |
| "#### ☁️ HuggingFace dataset\n" |
| "Load the pre-configured `exemple.csv` dataset. " |
| ) |
| pre_hf_load = gr.Button( |
| "☁️ Load from HuggingFace", variant="secondary", size="lg" |
| ) |
| with gr.Column(elem_classes=["upload-btn-col"]): |
| gr.Markdown( |
| "#### 📂 Resume work\n" |
| "Upload a previously exported `.csv` or `.json` annotation file. " |
| "The app resumes from the first unvalidated item." |
| ) |
| pre_ann_load = gr.UploadButton( |
| "📂 Resume work", file_types=[".csv", ".json"], |
| variant="secondary", size="lg", |
| ) |
|
|
| pre_data_msg = gr.Markdown("") |
|
|
| |
| with gr.Tab("🏷️ Tag 1 — Document type", id="pre_t1"): |
| gr.HTML('<div class="ia-section">Classify the document type for each image</div>') |
| with gr.Row(): |
| pre_t1_prev = gr.Button("⬅️ Previous", scale=1) |
| pre_t1_skip = gr.Button("⏭️ Skip", scale=1) |
| pre_t1_cnt = gr.Markdown(counter(0, max(len(_default_df), 1))) |
| with gr.Row(): |
| with gr.Column(scale=1, elem_classes=["ia-image-col"]): |
| pre_t1_img = gr.HTML(img_html(img_url_at(_default_df, 0))) |
| with gr.Column(scale=1, elem_classes=["ia-value-col"]): |
| pre_t1_info = gr.Markdown("*No data loaded.*") |
| with gr.Row(): |
| pre_t1_ok = gr.Button("✅ Validate as-is", variant="secondary", scale=1) |
| pre_t1_mod = gr.Button("✏️ Modify category", scale=1) |
| with gr.Group(visible=False) as pre_t1_edit: |
| pre_t1_dd = gr.Dropdown(VOCAB_TAG_1, label="New category") |
| pre_t1_sv = gr.Button("💾 Save & continue") |
| pre_t1_msg = gr.Markdown("") |
|
|
| |
| with gr.Tab("🏷️ Tag 2 — Subject", id="pre_t2"): |
| gr.HTML('<div class="ia-section">Classify the main subject of each image</div>') |
| with gr.Row(): |
| pre_t2_prev = gr.Button("⬅️ Previous", scale=1) |
| pre_t2_skip = gr.Button("⏭️ Skip", scale=1) |
| pre_t2_cnt = gr.Markdown(counter(0, max(len(_default_df), 1))) |
| with gr.Row(): |
| with gr.Column(scale=1, elem_classes=["ia-image-col"]): |
| pre_t2_img = gr.HTML(img_html(img_url_at(_default_df, 0))) |
| with gr.Column(scale=1, elem_classes=["ia-value-col"]): |
| pre_t2_info = gr.Markdown("*No data loaded.*") |
| with gr.Row(): |
| pre_t2_ok = gr.Button("✅ Validate as-is", variant="secondary", scale=1) |
| pre_t2_mod = gr.Button("✏️ Modify category", scale=1) |
| with gr.Group(visible=False) as pre_t2_edit: |
| pre_t2_dd = gr.Dropdown(VOCAB_TAG_2, label="New category") |
| pre_t2_sv = gr.Button("💾 Save & continue") |
| pre_t2_msg = gr.Markdown("") |
|
|
| |
| with gr.Tab("📝 Caption — AI-assisted", id="pre_cap"): |
| gr.HTML('<div class="ia-section">Generate or write a catalogue caption for each image</div>') |
| with gr.Row(): |
| pre_cap_prev = gr.Button("⬅️ Previous", scale=1) |
| pre_cap_skip = gr.Button("⏭️ Skip", scale=1) |
| pre_cap_cnt = gr.Markdown(counter(0, max(len(_default_df), 1))) |
| with gr.Row(): |
| with gr.Column(scale=1, elem_classes=["ia-image-col"]): |
| pre_cap_img = gr.HTML(img_html(img_url_at(_default_df, 0))) |
| with gr.Column(scale=1, elem_classes=["ia-value-col"]): |
| pre_cap_info = gr.Markdown("*No data loaded.*") |
| with gr.Row(): |
| pre_cap_key = gr.Textbox( |
| label="🔑 Mistral API key", type="password", |
| placeholder="Paste your key here", scale=2, |
| ) |
| pre_cap_mdl = gr.Dropdown( |
| ["ministral-14b-2512", "mistral-medium-latest", "ministral-8b-2512"], |
| value="ministral-14b-2512", label="Model", scale=1, |
| ) |
| pre_cap_gen = gr.Button("🤖 Generate caption with Mistral", variant="primary") |
| pre_cap_txt = gr.Textbox(label="Caption", lines=4, |
| placeholder="Edit or generate a caption here") |
| pre_cap_sv = gr.Button("💾 Save & continue", variant="secondary") |
| pre_cap_msg = gr.Markdown("") |
|
|
| |
| with gr.Tab("🗂️ Clusters — Batch validation", id="pre_cl"): |
| gr.HTML('<div class="ia-section">Assign a Tag 2 to an entire cluster at once</div>') |
| gr.Markdown( |
| "Images are grouped by visual similarity. Assign one **Tag 2** value to " |
| "all images in a cluster with a single click. Up to 10 sample images shown." |
| ) |
| with gr.Row(): |
| pre_cl_prev = gr.Button("⬅️ Previous cluster", scale=1) |
| pre_cl_skip = gr.Button("⏭️ Next cluster", scale=1) |
| pre_cl_cnt = gr.Markdown("—") |
| pre_cl_title = gr.Markdown("*No clusters loaded.*") |
| pre_cl_html = gr.HTML("<p><em>No images.</em></p>") |
| 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("") |
|
|
| |
| 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('<div class="ia-section">Export annotations</div>') |
| gr.Markdown("Export your current annotations at any time. " |
| "**Always export before closing the window** — annotations are not saved automatically.") |
| with gr.Row(): |
| pre_exp_csv = gr.Button("⬇️ Export as CSV", variant="primary", size="lg") |
| pre_exp_json = gr.Button("⬇️ Export as JSON", variant="primary", size="lg") |
| pre_exp_msg = gr.Markdown("") |
| pre_exp_file = gr.File(label="⬇️ Download", visible=False) |
|
|
| |
| |
| |
| with gr.Group(visible=False) as custom_view: |
| with gr.Row(): |
| btn_cust_home = gr.Button("← Back to Home", size="sm", scale=0) |
| gr.Markdown("### 🛠️ Custom Schema Mode") |
|
|
| with gr.Tabs() as cust_tabs: |
|
|
| |
| with gr.Tab("📐 Schema builder", id="cust_schema"): |
| gr.HTML('<div class="ia-section">① Define your metadata fields</div>') |
| gr.Markdown( |
| "Add the fields you want to annotate. Each field needs a **name** and a **type**.\n" |
| "For *dropdown* fields, list the allowed values separated by commas.\n\n" |
| "When ready, go to the **📁 Data** tab to load your images." |
| ) |
| with gr.Group(elem_classes=["schema-builder-box"]): |
| gr.Markdown("**Add a new field**") |
| with gr.Row(): |
| sch_name = gr.Textbox(label="Field name", placeholder="e.g. technique", scale=2) |
| sch_type = gr.Dropdown(["text", "dropdown"], value="text", label="Type", scale=1) |
| sch_options = gr.Textbox(label="Dropdown options (comma-separated)", |
| placeholder="oil painting, watercolour, engraving", |
| scale=3, visible=False) |
| sch_add = gr.Button("➕ Add field", variant="primary", scale=1) |
| sch_msg = gr.Markdown("") |
| gr.HTML('<div class="ia-section">② Current schema</div>') |
| sch_display = gr.Dataframe( |
| headers=["Field name", "Type", "Options"], |
| datatype=["str", "str", "str"], |
| interactive=False, |
| label="", |
| ) |
| sch_clear = gr.Button("🗑️ Clear all fields", size="sm") |
|
|
| |
| with gr.Tab("📁 Data", id="cust_data"): |
| gr.HTML('<div class="ia-section">Load images or resume previous work</div>') |
| with gr.Row(elem_classes=["upload-btn-row"]): |
| with gr.Column(elem_classes=["upload-btn-col"]): |
| gr.Markdown( |
| "#### 🌐 New URL list\n" |
| "Upload a `.txt`, `.csv`, or `.json` file listing image URLs. " |
| "Define your schema first in the **📐 Schema builder** tab." |
| ) |
| cust_url_load = gr.UploadButton( |
| "📤 Load URL list", file_types=[".txt", ".csv", ".json"], |
| variant="primary", size="lg", |
| ) |
| with gr.Column(elem_classes=["upload-btn-col"]): |
| gr.Markdown( |
| "#### 📂 Resume work\n" |
| "Upload a previously exported `.json` or `.csv` file. " |
| "For JSON files, the schema is restored automatically." |
| ) |
| cust_ann_load = gr.UploadButton( |
| "📂 Resume work", file_types=[".json", ".csv"], |
| variant="secondary", size="lg", |
| ) |
| cust_data_msg = gr.Markdown("") |
|
|
| |
| with gr.Tab("✏️ Annotate", id="cust_ann"): |
| gr.HTML('<div class="ia-section">Fill in your custom fields for each image</div>') |
| with gr.Row(): |
| cust_prev = gr.Button("⬅️ Previous", scale=1) |
| cust_skip = gr.Button("⏭️ Skip", scale=1) |
| cust_cnt = gr.Markdown("**0 / 0**") |
| with gr.Row(): |
| with gr.Column(scale=1, elem_classes=["ia-image-col"]): |
| cust_img = gr.HTML(img_html(None)) |
| with gr.Column(scale=1, elem_classes=["ia-value-col"]): |
| cust_info = gr.Markdown("*No data loaded.*") |
| |
| |
| cust_field_rows = [] |
| for i in range(10): |
| with gr.Group(visible=False) as field_grp: |
| lbl = gr.Markdown(f"Field {i}") |
| txt = gr.Textbox(label="", visible=True) |
| drp = gr.Dropdown([], label="", visible=False) |
| cust_field_rows.append({"grp": field_grp, "lbl": lbl, "txt": txt, "drp": drp}) |
|
|
| cust_sv = gr.Button("💾 Save & continue", variant="primary") |
| cust_msg = gr.Markdown("") |
|
|
| |
| with gr.Tab("📝 Caption — AI-assisted"): |
| gr.HTML('<div class="ia-section">Generate AI captions and save them to a custom field</div>') |
| gr.Markdown( |
| "Generate a caption with Mistral and save it to any text field in your schema. " |
| "The caption is only a starting point — edit it freely before saving." |
| ) |
| with gr.Row(): |
| cust_cap_prev = gr.Button("⬅️ Previous", scale=1) |
| cust_cap_skip = gr.Button("⏭️ Skip", scale=1) |
| cust_cap_cnt = gr.Markdown("**0 / 0**") |
| with gr.Row(): |
| with gr.Column(scale=1, elem_classes=["ia-image-col"]): |
| cust_cap_img = gr.HTML(img_html(None)) |
| with gr.Column(scale=1, elem_classes=["ia-value-col"]): |
| cust_cap_txt = gr.Textbox(label="Caption", lines=8, |
| placeholder="Generated caption appears here — edit freely") |
| with gr.Row(): |
| cust_cap_key = gr.Textbox( |
| label="🔑 Mistral API key", type="password", |
| placeholder="Paste your key here", scale=2, |
| ) |
| cust_cap_mdl = gr.Dropdown( |
| ["ministral-14b-2512", "mistral-medium-latest", "ministral-8b-2512"], |
| value="ministral-14b-2512", label="Model", scale=1, |
| ) |
| cust_cap_field = gr.Dropdown([], label="Save to field", scale=1) |
| cust_cap_gen = gr.Button("🤖 Generate caption with Mistral", variant="primary") |
| cust_cap_sv = gr.Button("💾 Save to selected field & continue", variant="secondary") |
| cust_cap_msg = gr.Markdown("") |
|
|
| |
| with gr.Tab("🗂️ Clusters — Batch validation", id="cust_cl"): |
| gr.HTML('<div class="ia-section">Assign a field value to an entire cluster at once</div>') |
| gr.Markdown( |
| "Choose a **field** and a **value**, then apply to all images in the cluster. " |
| "The field must be a dropdown field defined in your schema. " |
| "Up to 10 sample images are shown per cluster.\n\n" |
| "**Note:** clustering requires a `clustering_id` column in your data." |
| ) |
| with gr.Row(): |
| cust_cl_prev = gr.Button("⬅️ Previous cluster", scale=1) |
| cust_cl_skip = gr.Button("⏭️ Next cluster", scale=1) |
| cust_cl_cnt = gr.Markdown("—") |
| cust_cl_title = gr.Markdown("*No clusters loaded.*") |
| cust_cl_html = gr.HTML("<p><em>No images.</em></p>") |
| 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("") |
|
|
| |
| with gr.Tab("📊 Export file", id="cust_export"): |
| gr.HTML('<div class="ia-section">Overview of all annotations in the current session</div>') |
| cust_tbl_refresh = gr.Button("🔄 Refresh") |
| cust_tbl_note = gr.Markdown("") |
| cust_tbl = gr.Dataframe(value=pd.DataFrame(), interactive=False) |
| gr.HTML('<div class="ia-section">Export annotations</div>') |
| gr.Markdown("Export at any time. **Always export before closing the window.**") |
| with gr.Row(): |
| cust_exp_csv = gr.Button("⬇️ Export as CSV", variant="primary", size="lg") |
| cust_exp_json = gr.Button("⬇️ Export as JSON (with schema)", variant="primary", size="lg") |
| cust_exp_msg = gr.Markdown("") |
| cust_exp_file = gr.File(label="⬇️ Download", visible=False) |
|
|
| |
| |
| |
| with gr.Group(visible=False) as clustering_view: |
| with gr.Row(): |
| btn_gc_home = gr.Button("← Back to Home", size="sm", scale=0) |
| gr.Markdown("### 🧭 Guided Clustering (ITGC)") |
|
|
| with gr.Tabs(): |
| |
| with gr.Tab("📁 Data"): |
| gr.HTML('<div class="ia-section">Load images & pick an encoding backend</div>') |
| gr.Markdown( |
| "Upload a **URL list** (`.txt`, `.csv`, or `.json`) — images are previewed " |
| "directly from their source, never downloaded." |
| ) |
| gc_url_load = gr.UploadButton( |
| "📤 Load URL list", file_types=[".txt", ".csv", ".json"], |
| variant="primary", |
| ) |
| gc_backend = gr.Radio( |
| ["Local DINOv2 — few-shot / similarity (CPU · offline · recommended)", |
| "Local SigLIP2 — zero-shot text labels (CPU · offline)", |
| "Mistral Vision API"], |
| value="Local DINOv2 — few-shot / similarity (CPU · offline · recommended)", |
| label="Encoding backend", |
| info=("DINOv2 = best visual features: give a few labelled examples per " |
| "category (🧭 Cluster tab) for few-shot classification, or none for " |
| "pure similarity clusters. SigLIP2 = zero-shot from category names " |
| "(no examples). Mistral API = a VLM (needs a key), finer but slower. " |
| "All local options are offline and cache embeddings per image."), |
| ) |
| gc_key = gr.Textbox(label="🔑 Mistral API key (only for the Mistral API backend)", |
| type="password", placeholder="sk-…") |
| with gr.Row(): |
| |
| |
| gc_vmodel = gr.Dropdown( |
| ["ministral-14b-2512", "mistral-medium-latest", "mistral-large-2512"], |
| value="ministral-14b-2512", |
| label="Vision model — Mistral API backend", |
| ) |
| gc_tmodel = gr.Dropdown( |
| ["mistral-medium-latest", "mistral-small-latest", "ministral-8b-2512"], |
| value="mistral-medium-latest", |
| label="Text model — Mistral API discovery mode", |
| ) |
| gc_status = gr.Markdown("*No data loaded.*") |
|
|
| |
| with gr.Tab("🧭 Cluster"): |
| gr.HTML('<div class="ia-section">Two ways to group your images</div>') |
| gc_categories = gr.Textbox( |
| label="① Fixed categories — one per line (recommended if you know them)", |
| placeholder="Architecture\nObjects\nPeople\nLandscape\nAnimal\nPlants", |
| lines=6, |
| info=("If filled, each image is classified into EXACTLY ONE of these " |
| "categories (no k-means; the k / iterations sliders are ignored). " |
| "Tip: add a description after a pipe to sharpen a category, e.g. " |
| "“People | portraits and group photographs of people”."), |
| ) |
| gc_query = gr.Textbox( |
| label="② Or: discovery criterion (leave categories empty to use this)", |
| placeholder="e.g. group by decoration style / by scene / by period", |
| lines=2, |
| info=("Used only when no fixed categories are given: the AI invents its " |
| "own concepts and discovers clusters via the iterative ITGC search."), |
| ) |
| gr.HTML('<div class="ia-section">③ Few-shot examples (DINOv2 backend)</div>') |
| gr.Markdown( |
| "Give ~10-15 example images per category. Assigning is **instant** — the " |
| "DINOv2 embedding runs only when you press **Run**. *Leave empty to get pure " |
| "DINOv2 similarity clusters instead.*" |
| ) |
| gc_labeled_status = gr.Markdown( |
| "*No examples yet — click a category for each image (~10-15 per category).*") |
|
|
| with gr.Accordion("🖱️ Label interactively — click the category of each image", |
| open=True): |
| gr.Markdown( |
| "Type your categories in **① Fixed categories** above, then press " |
| "**Prepare**. Browse with ⬅️ / ⏭️ and click the matching category — " |
| "it's saved and the next image appears (no waiting)." |
| ) |
| gc_lbl_prepare = gr.Button("🔄 Prepare / refresh from ① categories", |
| variant="secondary") |
| gc_lbl_img = gr.HTML("<p><em>Load images (📁 Data) and press Prepare.</em></p>") |
| 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("") |
|
|
| |
| with gr.Tab("🔎 Results"): |
| gr.HTML('<div class="ia-section">Interpretable clusters</div>') |
| gc_sil_html = gr.HTML("") |
| gc_concepts_md = gr.Markdown("*Run guided clustering to see results.*") |
| gc_clusters_html = gr.HTML( |
| "<p><em>No clusters yet — run guided clustering.</em></p>" |
| ) |
|
|
| |
| with gr.Tab("📊 Export"): |
| gr.HTML('<div class="ia-section">Export the computed clusters</div>') |
| gr.Markdown( |
| "The `clustering_id` column now holds the guided clusters. Export, then " |
| "**Resume work** in Predefined or Custom mode to batch-validate them." |
| ) |
| gc_tbl = gr.Dataframe(value=pd.DataFrame(), interactive=False) |
| with gr.Row(): |
| gc_exp_csv = gr.Button("⬇️ Export as CSV", variant="primary", size="lg") |
| gc_exp_json = gr.Button("⬇️ Export as JSON", variant="primary", size="lg") |
| gc_exp_msg = gr.Markdown("") |
| gc_exp_file = gr.File(label="⬇️ Download", visible=False) |
|
|
| |
| gr.HTML( |
| f""" |
| <div style="text-align:center;margin-top:30px;padding-top:20px; |
| border-top:1px solid #ddd;"> |
| {'<img src="data:image/png;base64,' + _LOGOS + '" style="display:block;margin:0 auto;max-width:700px;width:90%;height:auto;" alt="Logos"/><br/>' if _LOGOS else ''} |
| <p style="font-size:0.85em;color:#888;max-width:820px;margin:0 auto;line-height:1.5;"> |
| IAnahid — developed during the 2026 |
| <a href="https://www.chartes.psl.eu/" target="_blank" rel="noopener"> |
| École nationale des chartes – PSL</a> hackathon for the |
| <a href="https://www.inha.fr/recherche/projets/histoire-art-iv-xv-siecle/le-fonds-thierry/" |
| target="_blank" rel="noopener">ThierryNum</a> project. |
| </p> |
| </div> |
| """ |
| ) |
|
|
| |
| |
| |
|
|
| |
| def go_predefined(): |
| return (gr.update(visible=False), gr.update(visible=True), |
| gr.update(visible=False), gr.update(visible=False)) |
|
|
| def go_custom(): |
| return (gr.update(visible=False), gr.update(visible=False), |
| gr.update(visible=True), gr.update(visible=False)) |
|
|
| def go_clustering(): |
| return (gr.update(visible=False), gr.update(visible=False), |
| gr.update(visible=False), gr.update(visible=True)) |
|
|
| def go_home(): |
| return (gr.update(visible=True), gr.update(visible=False), |
| gr.update(visible=False), gr.update(visible=False)) |
|
|
| _views = [home_view, predefined_view, custom_view, clustering_view] |
| btn_go_predefined.click(go_predefined, [], _views) |
| btn_go_custom.click(go_custom, [], _views) |
| btn_go_clustering.click(go_clustering, [], _views) |
| btn_pre_home.click(go_home, [], _views) |
| btn_cust_home.click(go_home, [], _views) |
| btn_gc_home.click(go_home, [], _views) |
|
|
| |
| |
| |
|
|
| def _pre_t1_view(df, idx): |
| n = max(len(df), 1) |
| return ( |
| img_html(img_url_at(df, idx)), |
| row_info_predefined(df, idx, "tag_1", "validated_tag_1"), |
| counter(idx, n), |
| gr.update(visible=False), |
| "", |
| ) |
|
|
| def _pre_t2_view(df, idx): |
| n = max(len(df), 1) |
| return ( |
| img_html(img_url_at(df, idx)), |
| row_info_predefined(df, idx, "tag_2", "validated_tag_2"), |
| counter(idx, n), |
| gr.update(visible=False), |
| "", |
| ) |
|
|
| def _pre_cap_view(df, idx): |
| n = max(len(df), 1) |
| row = df.iloc[int(idx)] if not df.empty else None |
| cap = str(row["image_caption"]) if row is not None and pd.notna(row.get("image_caption")) else "" |
| return ( |
| img_html(img_url_at(df, idx)), |
| row_info_predefined(df, idx, "image_caption", "validated_caption"), |
| counter(idx, n), |
| cap, |
| "", |
| ) |
|
|
| def _pre_cl_view(df, cl_idx): |
| clusters = cluster_list(df) |
| if not clusters: |
| return 0, "No clusters found.", "<p><em>No data.</em></p>", "—", "" |
| 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,) |
|
|
| |
| 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, |
| t1i, img_html(img_url_at(df, t1i)), |
| row_info_predefined(df, t1i, "tag_1", "validated_tag_1"), |
| counter(t1i, n), |
| 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), |
| gr.Tabs(selected="pre_t1"), |
| 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) |
|
|
| |
| |
| 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) |
|
|
| |
| 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)) |
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| pre_tbl_refresh.click(lambda df: (tbl_note(df), tbl_preview(df)), |
| [pre_df_s], [pre_tbl_note, pre_tbl], api_name=False) |
|
|
| |
| |
| |
|
|
| |
|
|
| 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." |
| |
| 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) |
|
|
| |
|
|
| 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 |
| |
| 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), |
| gr.update(value=f"**{field['label']}**"), |
| gr.update(value=val if not is_dd else "", visible=not is_dd), |
| gr.update(choices=opts, value=val if is_dd and val in opts else None, |
| visible=is_dd), |
| ] |
| 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.", "<p><em>No data or no clustering_id column.</em></p>", "—", "" |
| 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) |
| |
| cap_cnt = f"**{idx + 1} / {n}**" |
| cap_img = img_html(img_url_at(df, idx)) |
| |
| text_fields = _cust_text_fields(schema) |
| dd_fields = _cust_dropdown_fields(schema) |
| |
| 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), |
| "", |
| "", |
| cl_idx, cl_ttl, cl_html, cl_cnt_txt, |
| gr.update(choices=dd_fields, value=None), |
| gr.update(choices=[], value=None), |
| "", |
| tbl_note(df), tbl_preview(df), |
| gr.Tabs(selected="cust_ann"), |
| ) |
|
|
| _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) |
|
|
| |
| |
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| |
| _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() |
| |
| 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()): |
| |
| 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, |
| ) |
|
|
| |
|
|
| 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) |
|
|
| |
| 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) |
|
|
| |
|
|
| 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) |
|
|
| |
| cust_tbl_refresh.click(lambda df: (tbl_note(df), tbl_preview(df)), |
| [cust_df_s], [cust_tbl_note, cust_tbl], api_name=False) |
|
|
| |
| |
| |
| 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) |
|
|
| |
| _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__": |
| |
| |
| demo.launch(ssr_mode=False, **(_STYLE_KWARGS if _GR_MAJOR >= 6 else {})) |
|
|