"""Caption Preference Study — Gradio Space. Participants register with their full name + email, then see an image and two captions (human vs. model) and pick a preference. Per-participant results are stored as ``firstname-lastname.csv`` in a private HF dataset. If a participant returns later their session resumes from wherever they left off, and if they have already completed the study they are told so. """ from __future__ import annotations import io import json import os import random import re import threading import time from datetime import datetime, timezone from pathlib import Path from typing import Any import gradio as gr import pandas as pd from huggingface_hub import HfApi, hf_hub_download, snapshot_download from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError HF_USER = "pmadinei" IMAGES_REPO = f"{HF_USER}/caption-preference-images" RESULTS_REPO = f"{HF_USER}/caption-preference-results" HF_TOKEN = os.environ.get("HF_TOKEN") RESPONSE_TIME_CAP = 100.0 CSV_PATH = Path(__file__).parent / "Qwen3-VL-8B-Instruct.csv" IMAGE_DIR = Path(os.environ.get("IMAGE_DIR", "/tmp/caption_experiment_images")) IMAGE_DIR.mkdir(parents=True, exist_ok=True) RESULTS_COLUMNS = [ "id", "image_id", "filename", "type", "human_caption", "model_caption", "preference", "response_time", ] EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") SLUG_RE = re.compile(r"[^a-z0-9]+") api = HfApi(token=HF_TOKEN) # --------------------------------------------------------------------------- # Data loading # --------------------------------------------------------------------------- def _clean_caption(value: Any) -> str: if value is None: return "" text = str(value) if len(text) >= 2 and text[0] == text[-1] and text[0] in ('"', "'"): text = text[1:-1] return text print(f"[startup] Loading CSV from {CSV_PATH}") df = pd.read_csv(CSV_PATH) df["human_caption"] = df["human_caption"].map(_clean_caption) df["model_caption"] = df["model_caption"].map(_clean_caption) _test_mask = df["image_id"].astype(str).str.contains("test", case=False, na=False) TEST_DF = df[_test_mask].reset_index(drop=True) NONTEST_DF = df[~_test_mask].reset_index(drop=True) NONTEST_IMAGE_IDS: list = list(NONTEST_DF["image_id"].unique()) NONTEST_IMAGE_ID_SET = set(NONTEST_IMAGE_IDS) IMAGE_ID_TO_FILENAMES: dict = { img_id: list(NONTEST_DF[NONTEST_DF["image_id"] == img_id]["filename"].unique()) for img_id in NONTEST_IMAGE_IDS } TEST_ROW_IDS = set(int(x) for x in TEST_DF["id"]) if len(TEST_DF) else set() TOTAL_TRIALS_PER_PARTICIPANT = len(NONTEST_IMAGE_IDS) + len(TEST_DF) print( f"[startup] {len(df)} rows | {len(NONTEST_IMAGE_IDS)} non-test image_ids | " f"{len(TEST_DF)} test rows | {TOTAL_TRIALS_PER_PARTICIPANT} trials per participant" ) # --------------------------------------------------------------------------- # Image download # --------------------------------------------------------------------------- def _ensure_images_downloaded() -> None: if not HF_TOKEN: print("[startup] WARNING: HF_TOKEN is not set; cannot download images.") return print(f"[startup] Downloading images from {IMAGES_REPO} to {IMAGE_DIR}...") snapshot_download( repo_id=IMAGES_REPO, repo_type="dataset", local_dir=str(IMAGE_DIR), token=HF_TOKEN, max_workers=16, ) print("[startup] Image download complete.") _ensure_images_downloaded() # --------------------------------------------------------------------------- # Round-robin state (persisted to RESULTS_REPO/state.json) # --------------------------------------------------------------------------- _STATE_LOCK = threading.Lock() _STATE: dict = {"image_id_used": {}} def _state_key(image_id: Any) -> str: return str(image_id) def _load_state() -> None: global _STATE if not HF_TOKEN: return try: path = hf_hub_download( repo_id=RESULTS_REPO, repo_type="dataset", filename="state.json", token=HF_TOKEN, force_download=True, ) with open(path) as f: loaded = json.load(f) _STATE = {"image_id_used": loaded.get("image_id_used", {})} print( f"[state] Loaded round-robin state with " f"{len(_STATE['image_id_used'])} image_ids tracked." ) except (EntryNotFoundError, RepositoryNotFoundError, FileNotFoundError): print("[state] No existing state.json found, starting fresh.") _STATE = {"image_id_used": {}} except Exception as exc: # noqa: BLE001 print(f"[state] Could not load state.json ({exc}); starting fresh.") _STATE = {"image_id_used": {}} def _save_state() -> None: if not HF_TOKEN: return payload = json.dumps(_STATE, indent=2).encode() api.upload_file( path_or_fileobj=io.BytesIO(payload), path_in_repo="state.json", repo_id=RESULTS_REPO, repo_type="dataset", commit_message="Update round-robin state", ) _load_state() def _assign_filenames(image_ids_to_assign: list) -> dict: """Round-robin filename pick for a given set of image_ids. For each image_id, choose uniformly from filenames not yet used since the last reset. When all filenames have been used, reset and start a fresh cycle. Independent per image_id. """ with _STATE_LOCK: assignments: dict = {} for img_id in image_ids_to_assign: all_fns = IMAGE_ID_TO_FILENAMES[img_id] key = _state_key(img_id) used = list(_STATE["image_id_used"].get(key, [])) available = [fn for fn in all_fns if fn not in used] if not available: used = [] available = list(all_fns) chosen = random.choice(available) used.append(chosen) _STATE["image_id_used"][key] = used assignments[img_id] = chosen if assignments: try: _save_state() except Exception as exc: # noqa: BLE001 print(f"[state] WARNING: could not persist state.json ({exc}).") return assignments # --------------------------------------------------------------------------- # Per-participant CSV + registry # --------------------------------------------------------------------------- def _slugify(s: str) -> str: s = (s or "").strip().lower() s = SLUG_RE.sub("-", s) return s.strip("-") def _participant_filename(first: str, last: str) -> str: return f"results/{_slugify(first)}-{_slugify(last)}.csv" def _load_participant_results(participant_file: str) -> list[dict]: if not HF_TOKEN: return [] try: path = hf_hub_download( repo_id=RESULTS_REPO, repo_type="dataset", filename=participant_file, token=HF_TOKEN, force_download=True, ) frame = pd.read_csv(path) return frame.to_dict(orient="records") except (EntryNotFoundError, RepositoryNotFoundError, FileNotFoundError): return [] except Exception as exc: # noqa: BLE001 print(f"[participant] Could not load {participant_file} ({exc})") return [] def _completed_keys(prior_results: list[dict]) -> tuple[set, set]: """Return (done_nontest_image_ids, done_test_row_ids) from a CSV-loaded list.""" done_image_ids = set() done_test_ids = set() for r in prior_results: try: row_id = int(r["id"]) except (KeyError, TypeError, ValueError): continue if row_id in TEST_ROW_IDS: done_test_ids.add(row_id) continue img_id_str = str(r.get("image_id")) if "test" in img_id_str.lower(): done_test_ids.add(row_id) continue img_id_val = r.get("image_id") if img_id_val in NONTEST_IMAGE_ID_SET: done_image_ids.add(img_id_val) else: try: coerced = int(img_id_val) if coerced in NONTEST_IMAGE_ID_SET: done_image_ids.add(coerced) except (TypeError, ValueError): pass return done_image_ids, done_test_ids def _is_complete(prior_results: list[dict]) -> bool: done_image_ids, done_test_ids = _completed_keys(prior_results) return done_image_ids >= NONTEST_IMAGE_ID_SET and done_test_ids >= TEST_ROW_IDS def _build_remaining_trials(prior_results: list[dict]) -> list[dict]: done_image_ids, done_test_ids = _completed_keys(prior_results) remaining_image_ids = [ iid for iid in NONTEST_IMAGE_IDS if iid not in done_image_ids ] assignments = _assign_filenames(remaining_image_ids) trials: list[dict] = [] for img_id in remaining_image_ids: fn = assignments[img_id] match = NONTEST_DF[ (NONTEST_DF["image_id"] == img_id) & (NONTEST_DF["filename"] == fn) ] if match.empty: continue trials.append(_row_to_trial(match.iloc[0])) for _, row in TEST_DF.iterrows(): if int(row["id"]) in done_test_ids: continue trials.append(_row_to_trial(row)) random.shuffle(trials) return trials def _row_to_trial(row: pd.Series) -> dict: raw_image_id = row["image_id"] if isinstance(raw_image_id, (int,)) or ( isinstance(raw_image_id, str) and raw_image_id.lstrip("-").isdigit() ): image_id_out: Any = int(raw_image_id) else: image_id_out = str(raw_image_id) return { "id": int(row["id"]), "image_id": image_id_out, "filename": str(row["filename"]), "type": str(row["type"]), "human_caption": str(row["human_caption"]), "model_caption": str(row["model_caption"]), "human_on_left": random.choice([True, False]), } def _save_results(participant_file: str, results: list[dict]) -> None: if not HF_TOKEN or not results: return frame = pd.DataFrame(results, columns=RESULTS_COLUMNS) buf = io.BytesIO() frame.to_csv(buf, index=False) buf.seek(0) api.upload_file( path_or_fileobj=buf, path_in_repo=participant_file, repo_id=RESULTS_REPO, repo_type="dataset", commit_message=f"Update {participant_file} (n={len(results)})", ) def _load_participants_registry() -> dict: if not HF_TOKEN: return {} try: path = hf_hub_download( repo_id=RESULTS_REPO, repo_type="dataset", filename="participants.json", token=HF_TOKEN, force_download=True, ) with open(path) as f: return json.load(f) except (EntryNotFoundError, RepositoryNotFoundError, FileNotFoundError): return {} except Exception as exc: # noqa: BLE001 print(f"[participants] Could not load registry ({exc})") return {} _REGISTRY_LOCK = threading.Lock() def _register_participant(slug: str, first: str, last: str, email: str) -> None: if not HF_TOKEN: return with _REGISTRY_LOCK: registry = _load_participants_registry() entry = registry.get(slug, {}) now_iso = datetime.now(timezone.utc).isoformat() if not entry: entry = { "full_name": f"{first} {last}".strip(), "first_name": first, "last_name": last, "email": email, "registered_at": now_iso, "last_session_at": now_iso, } else: entry.setdefault("first_name", first) entry.setdefault("last_name", last) entry.setdefault("registered_at", now_iso) entry["full_name"] = f"{first} {last}".strip() entry["email"] = email entry["last_session_at"] = now_iso registry[slug] = entry payload = json.dumps(registry, indent=2).encode() try: api.upload_file( path_or_fileobj=io.BytesIO(payload), path_in_repo="participants.json", repo_id=RESULTS_REPO, repo_type="dataset", commit_message=f"Register/update participant {slug}", ) except Exception as exc: # noqa: BLE001 print(f"[participants] WARNING: could not save registry ({exc}).") # --------------------------------------------------------------------------- # Gradio handlers # --------------------------------------------------------------------------- WELCOME_HTML = """
You will see images with two captions. Click the caption that better describes the image.
You can close this tab now.
Thank you, {name}! Our records show you finished all {total} trials. There's nothing more to do — feel free to close this tab.