pmadinei commited on
Commit
ace0c7f
·
verified ·
1 Parent(s): e290783

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +325 -83
app.py CHANGED
@@ -1,7 +1,10 @@
1
  """Caption Preference Study — Gradio Space.
2
 
3
- Participants see an image and two captions (human vs. model) and pick a
4
- preference. State is persisted across Space restarts via a private HF dataset.
 
 
 
5
  """
6
 
7
  from __future__ import annotations
@@ -10,9 +13,9 @@ import io
10
  import json
11
  import os
12
  import random
 
13
  import threading
14
  import time
15
- import uuid
16
  from datetime import datetime, timezone
17
  from pathlib import Path
18
  from typing import Any
@@ -33,6 +36,19 @@ CSV_PATH = Path(__file__).parent / "Qwen3-VL-8B-Instruct.csv"
33
  IMAGE_DIR = Path(os.environ.get("IMAGE_DIR", "/tmp/caption_experiment_images"))
34
  IMAGE_DIR.mkdir(parents=True, exist_ok=True)
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  api = HfApi(token=HF_TOKEN)
37
 
38
 
@@ -41,12 +57,6 @@ api = HfApi(token=HF_TOKEN)
41
  # ---------------------------------------------------------------------------
42
 
43
  def _clean_caption(value: Any) -> str:
44
- """Display captions verbatim without outer string-delimiter quotes.
45
-
46
- pandas already unwraps CSV double-quote delimiters, but defensively strip a
47
- single layer of matching outer single/double quotes if present so all
48
- captions render uniformly.
49
- """
50
  if value is None:
51
  return ""
52
  text = str(value)
@@ -65,13 +75,16 @@ TEST_DF = df[_test_mask].reset_index(drop=True)
65
  NONTEST_DF = df[~_test_mask].reset_index(drop=True)
66
 
67
  NONTEST_IMAGE_IDS: list = list(NONTEST_DF["image_id"].unique())
 
68
  IMAGE_ID_TO_FILENAMES: dict = {
69
  img_id: list(NONTEST_DF[NONTEST_DF["image_id"] == img_id]["filename"].unique())
70
  for img_id in NONTEST_IMAGE_IDS
71
  }
 
 
72
  print(
73
  f"[startup] {len(df)} rows | {len(NONTEST_IMAGE_IDS)} non-test image_ids | "
74
- f"{len(TEST_DF)} test rows"
75
  )
76
 
77
 
@@ -152,16 +165,16 @@ def _save_state() -> None:
152
  _load_state()
153
 
154
 
155
- def _assign_filenames_for_participant() -> dict:
156
- """Pick one filename per non-test image_id using round-robin across participants.
157
 
158
- For each image_id, keep a list of filenames that have been used since the last
159
- reset. Choose uniformly at random from filenames NOT yet used. When all
160
- filenames for an image_id have been used, reset and start a fresh cycle.
161
  """
162
  with _STATE_LOCK:
163
  assignments: dict = {}
164
- for img_id in NONTEST_IMAGE_IDS:
165
  all_fns = IMAGE_ID_TO_FILENAMES[img_id]
166
  key = _state_key(img_id)
167
  used = list(_STATE["image_id_used"].get(key, []))
@@ -173,32 +186,103 @@ def _assign_filenames_for_participant() -> dict:
173
  used.append(chosen)
174
  _STATE["image_id_used"][key] = used
175
  assignments[img_id] = chosen
176
- try:
177
- _save_state()
178
- except Exception as exc: # noqa: BLE001
179
- print(f"[state] WARNING: could not persist state.json ({exc}).")
 
180
  return assignments
181
 
182
 
183
  # ---------------------------------------------------------------------------
184
- # Trial construction
185
  # ---------------------------------------------------------------------------
186
 
187
- def _build_trials_for_participant() -> list[dict]:
188
- assignments = _assign_filenames_for_participant()
189
- trials: list[dict] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
- for img_id in NONTEST_IMAGE_IDS:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  fn = assignments[img_id]
193
  match = NONTEST_DF[
194
  (NONTEST_DF["image_id"] == img_id) & (NONTEST_DF["filename"] == fn)
195
  ]
196
  if match.empty:
197
  continue
198
- row = match.iloc[0]
199
- trials.append(_row_to_trial(row))
200
 
201
  for _, row in TEST_DF.iterrows():
 
 
202
  trials.append(_row_to_trial(row))
203
 
204
  random.shuffle(trials)
@@ -206,12 +290,16 @@ def _build_trials_for_participant() -> list[dict]:
206
 
207
 
208
  def _row_to_trial(row: pd.Series) -> dict:
 
 
 
 
 
 
 
209
  return {
210
  "id": int(row["id"]),
211
- "image_id": (
212
- int(row["image_id"]) if str(row["image_id"]).lstrip("-").isdigit()
213
- else str(row["image_id"])
214
- ),
215
  "filename": str(row["filename"]),
216
  "type": str(row["type"]),
217
  "human_caption": str(row["human_caption"]),
@@ -220,91 +308,213 @@ def _row_to_trial(row: pd.Series) -> dict:
220
  }
221
 
222
 
223
- # ---------------------------------------------------------------------------
224
- # Results persistence
225
- # ---------------------------------------------------------------------------
226
-
227
- def _save_results(session_id: str, results: list[dict]) -> None:
228
  if not HF_TOKEN or not results:
229
  return
230
- frame = pd.DataFrame(
231
- results,
232
- columns=[
233
- "id",
234
- "image_id",
235
- "filename",
236
- "type",
237
- "human_caption",
238
- "model_caption",
239
- "preference",
240
- "response_time",
241
- ],
242
- )
243
  buf = io.BytesIO()
244
  frame.to_csv(buf, index=False)
245
  buf.seek(0)
246
  api.upload_file(
247
  path_or_fileobj=buf,
248
- path_in_repo=f"results/{session_id}.csv",
249
  repo_id=RESULTS_REPO,
250
  repo_type="dataset",
251
- commit_message=f"Update results for {session_id} (n={len(results)})",
252
  )
253
 
254
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  # ---------------------------------------------------------------------------
256
  # Gradio handlers
257
  # ---------------------------------------------------------------------------
258
 
259
  WELCOME_HTML = """
260
- <div style="text-align:center; padding: 16px;">
261
  <h2 style="margin-bottom: 8px;">Caption Preference Study</h2>
262
- <p style="font-size: 1.05em;">
263
  You will see images with two captions. Click the caption that better
264
  describes the image.
265
  </p>
266
  </div>
267
  """
268
 
269
- DONE_HTML = """
270
  <div style="text-align:center; padding: 32px;">
271
  <h2>All done — thank you for participating!</h2>
272
  <p>You can close this tab now.</p>
273
  </div>
274
  """
275
 
 
 
 
 
 
 
 
276
 
277
- def start_session():
278
- session_id = str(uuid.uuid4())
279
- trials = _build_trials_for_participant()
280
- if not trials:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  return (
282
  None,
283
  gr.update(visible=False),
284
  gr.update(visible=False),
285
- gr.update(value="<h3>No trials available.</h3>", visible=True),
286
  None,
 
 
287
  "",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  "",
289
- "",
290
  )
 
 
 
291
  state = {
292
- "session_id": session_id,
293
  "trials": trials,
294
  "current_idx": 0,
295
  "trial_start_time": time.time(),
296
- "results": [],
 
 
297
  }
298
  img_path, left, right, progress = _current_display(state)
299
  return (
300
  state,
301
- gr.update(visible=False),
302
- gr.update(visible=True),
303
- gr.update(visible=False),
304
- img_path,
305
- left,
306
- right,
307
- progress,
 
308
  )
309
 
310
 
@@ -317,13 +527,23 @@ def _current_display(state: dict) -> tuple:
317
  left, right = trial["human_caption"], trial["model_caption"]
318
  else:
319
  left, right = trial["model_caption"], trial["human_caption"]
320
- progress = f"Trial {state['current_idx'] + 1} of {len(state['trials'])}"
 
 
321
  return img_path, left, right, progress
322
 
323
 
324
  def _make_choice(state: dict, side: str):
325
  if state is None:
326
- return state, gr.update(), gr.update(), None, "", "", ""
 
 
 
 
 
 
 
 
327
  elapsed = min(time.time() - state["trial_start_time"], RESPONSE_TIME_CAP)
328
  trial = state["trials"][state["current_idx"]]
329
  chose_human = trial["human_on_left"] if side == "left" else not trial["human_on_left"]
@@ -340,24 +560,23 @@ def _make_choice(state: dict, side: str):
340
  }
341
  )
342
 
343
- # Persist after each trial. Fire-and-forget on a background thread so the UI
344
- # advances immediately; failures are logged but don't block the participant.
345
  threading.Thread(
346
  target=_save_results,
347
- args=(state["session_id"], list(state["results"])),
348
  daemon=True,
349
  ).start()
350
 
351
  state["current_idx"] += 1
352
  if state["current_idx"] >= len(state["trials"]):
 
353
  return (
354
  state,
355
  gr.update(visible=False),
356
- gr.update(visible=True, value=DONE_HTML),
357
  None,
358
- "",
359
- "",
360
- f"Done — {len(state['trials'])} / {len(state['trials'])}",
361
  )
362
 
363
  state["trial_start_time"] = time.time()
@@ -367,8 +586,8 @@ def _make_choice(state: dict, side: str):
367
  gr.update(visible=True),
368
  gr.update(visible=False),
369
  img_path,
370
- left,
371
- right,
372
  progress,
373
  )
374
 
@@ -387,6 +606,7 @@ custom_css = """
387
  text-align: left !important;
388
  }
389
  .center-img img { max-height: 60vh !important; object-fit: contain !important; }
 
390
  """
391
 
392
  with gr.Blocks(title="Caption Preference Study", css=custom_css) as demo:
@@ -398,8 +618,20 @@ with gr.Blocks(title="Caption Preference Study", css=custom_css) as demo:
398
  with gr.Row():
399
  with gr.Column(scale=1):
400
  pass
401
- with gr.Column(scale=1):
 
 
 
 
 
 
 
 
 
 
 
402
  start_btn = gr.Button("Start", variant="primary", size="lg")
 
403
  with gr.Column(scale=1):
404
  pass
405
 
@@ -420,8 +652,18 @@ with gr.Blocks(title="Caption Preference Study", css=custom_css) as demo:
420
 
421
  start_btn.click(
422
  start_session,
423
- inputs=[],
424
- outputs=[state, intro, trial_group, done_panel, image, left_btn, right_btn, progress],
 
 
 
 
 
 
 
 
 
 
425
  )
426
 
427
  left_btn.click(
 
1
  """Caption Preference Study — Gradio Space.
2
 
3
+ Participants register with their full name + email, then see an image and two
4
+ captions (human vs. model) and pick a preference. Per-participant results are
5
+ stored as ``firstname-lastname.csv`` in a private HF dataset. If a participant
6
+ returns later their session resumes from wherever they left off, and if they
7
+ have already completed the study they are told so.
8
  """
9
 
10
  from __future__ import annotations
 
13
  import json
14
  import os
15
  import random
16
+ import re
17
  import threading
18
  import time
 
19
  from datetime import datetime, timezone
20
  from pathlib import Path
21
  from typing import Any
 
36
  IMAGE_DIR = Path(os.environ.get("IMAGE_DIR", "/tmp/caption_experiment_images"))
37
  IMAGE_DIR.mkdir(parents=True, exist_ok=True)
38
 
39
+ RESULTS_COLUMNS = [
40
+ "id",
41
+ "image_id",
42
+ "filename",
43
+ "type",
44
+ "human_caption",
45
+ "model_caption",
46
+ "preference",
47
+ "response_time",
48
+ ]
49
+ EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
50
+ SLUG_RE = re.compile(r"[^a-z0-9]+")
51
+
52
  api = HfApi(token=HF_TOKEN)
53
 
54
 
 
57
  # ---------------------------------------------------------------------------
58
 
59
  def _clean_caption(value: Any) -> str:
 
 
 
 
 
 
60
  if value is None:
61
  return ""
62
  text = str(value)
 
75
  NONTEST_DF = df[~_test_mask].reset_index(drop=True)
76
 
77
  NONTEST_IMAGE_IDS: list = list(NONTEST_DF["image_id"].unique())
78
+ NONTEST_IMAGE_ID_SET = set(NONTEST_IMAGE_IDS)
79
  IMAGE_ID_TO_FILENAMES: dict = {
80
  img_id: list(NONTEST_DF[NONTEST_DF["image_id"] == img_id]["filename"].unique())
81
  for img_id in NONTEST_IMAGE_IDS
82
  }
83
+ TEST_ROW_IDS = set(int(x) for x in TEST_DF["id"]) if len(TEST_DF) else set()
84
+ TOTAL_TRIALS_PER_PARTICIPANT = len(NONTEST_IMAGE_IDS) + len(TEST_DF)
85
  print(
86
  f"[startup] {len(df)} rows | {len(NONTEST_IMAGE_IDS)} non-test image_ids | "
87
+ f"{len(TEST_DF)} test rows | {TOTAL_TRIALS_PER_PARTICIPANT} trials per participant"
88
  )
89
 
90
 
 
165
  _load_state()
166
 
167
 
168
+ def _assign_filenames(image_ids_to_assign: list) -> dict:
169
+ """Round-robin filename pick for a given set of image_ids.
170
 
171
+ For each image_id, choose uniformly from filenames not yet used since the
172
+ last reset. When all filenames have been used, reset and start a fresh
173
+ cycle. Independent per image_id.
174
  """
175
  with _STATE_LOCK:
176
  assignments: dict = {}
177
+ for img_id in image_ids_to_assign:
178
  all_fns = IMAGE_ID_TO_FILENAMES[img_id]
179
  key = _state_key(img_id)
180
  used = list(_STATE["image_id_used"].get(key, []))
 
186
  used.append(chosen)
187
  _STATE["image_id_used"][key] = used
188
  assignments[img_id] = chosen
189
+ if assignments:
190
+ try:
191
+ _save_state()
192
+ except Exception as exc: # noqa: BLE001
193
+ print(f"[state] WARNING: could not persist state.json ({exc}).")
194
  return assignments
195
 
196
 
197
  # ---------------------------------------------------------------------------
198
+ # Per-participant CSV + registry
199
  # ---------------------------------------------------------------------------
200
 
201
+ def _slugify(s: str) -> str:
202
+ s = (s or "").strip().lower()
203
+ s = SLUG_RE.sub("-", s)
204
+ return s.strip("-")
205
+
206
+
207
+ def _participant_filename(first: str, last: str) -> str:
208
+ return f"results/{_slugify(first)}-{_slugify(last)}.csv"
209
+
210
+
211
+ def _load_participant_results(participant_file: str) -> list[dict]:
212
+ if not HF_TOKEN:
213
+ return []
214
+ try:
215
+ path = hf_hub_download(
216
+ repo_id=RESULTS_REPO,
217
+ repo_type="dataset",
218
+ filename=participant_file,
219
+ token=HF_TOKEN,
220
+ force_download=True,
221
+ )
222
+ frame = pd.read_csv(path)
223
+ return frame.to_dict(orient="records")
224
+ except (EntryNotFoundError, RepositoryNotFoundError, FileNotFoundError):
225
+ return []
226
+ except Exception as exc: # noqa: BLE001
227
+ print(f"[participant] Could not load {participant_file} ({exc})")
228
+ return []
229
+
230
+
231
+ def _completed_keys(prior_results: list[dict]) -> tuple[set, set]:
232
+ """Return (done_nontest_image_ids, done_test_row_ids) from a CSV-loaded list."""
233
+ done_image_ids = set()
234
+ done_test_ids = set()
235
+ for r in prior_results:
236
+ try:
237
+ row_id = int(r["id"])
238
+ except (KeyError, TypeError, ValueError):
239
+ continue
240
+ if row_id in TEST_ROW_IDS:
241
+ done_test_ids.add(row_id)
242
+ continue
243
+ img_id_str = str(r.get("image_id"))
244
+ if "test" in img_id_str.lower():
245
+ done_test_ids.add(row_id)
246
+ continue
247
+ img_id_val = r.get("image_id")
248
+ if img_id_val in NONTEST_IMAGE_ID_SET:
249
+ done_image_ids.add(img_id_val)
250
+ else:
251
+ try:
252
+ coerced = int(img_id_val)
253
+ if coerced in NONTEST_IMAGE_ID_SET:
254
+ done_image_ids.add(coerced)
255
+ except (TypeError, ValueError):
256
+ pass
257
+ return done_image_ids, done_test_ids
258
 
259
+
260
+ def _is_complete(prior_results: list[dict]) -> bool:
261
+ done_image_ids, done_test_ids = _completed_keys(prior_results)
262
+ return done_image_ids >= NONTEST_IMAGE_ID_SET and done_test_ids >= TEST_ROW_IDS
263
+
264
+
265
+ def _build_remaining_trials(prior_results: list[dict]) -> list[dict]:
266
+ done_image_ids, done_test_ids = _completed_keys(prior_results)
267
+
268
+ remaining_image_ids = [
269
+ iid for iid in NONTEST_IMAGE_IDS if iid not in done_image_ids
270
+ ]
271
+ assignments = _assign_filenames(remaining_image_ids)
272
+
273
+ trials: list[dict] = []
274
+ for img_id in remaining_image_ids:
275
  fn = assignments[img_id]
276
  match = NONTEST_DF[
277
  (NONTEST_DF["image_id"] == img_id) & (NONTEST_DF["filename"] == fn)
278
  ]
279
  if match.empty:
280
  continue
281
+ trials.append(_row_to_trial(match.iloc[0]))
 
282
 
283
  for _, row in TEST_DF.iterrows():
284
+ if int(row["id"]) in done_test_ids:
285
+ continue
286
  trials.append(_row_to_trial(row))
287
 
288
  random.shuffle(trials)
 
290
 
291
 
292
  def _row_to_trial(row: pd.Series) -> dict:
293
+ raw_image_id = row["image_id"]
294
+ if isinstance(raw_image_id, (int,)) or (
295
+ isinstance(raw_image_id, str) and raw_image_id.lstrip("-").isdigit()
296
+ ):
297
+ image_id_out: Any = int(raw_image_id)
298
+ else:
299
+ image_id_out = str(raw_image_id)
300
  return {
301
  "id": int(row["id"]),
302
+ "image_id": image_id_out,
 
 
 
303
  "filename": str(row["filename"]),
304
  "type": str(row["type"]),
305
  "human_caption": str(row["human_caption"]),
 
308
  }
309
 
310
 
311
+ def _save_results(participant_file: str, results: list[dict]) -> None:
 
 
 
 
312
  if not HF_TOKEN or not results:
313
  return
314
+ frame = pd.DataFrame(results, columns=RESULTS_COLUMNS)
 
 
 
 
 
 
 
 
 
 
 
 
315
  buf = io.BytesIO()
316
  frame.to_csv(buf, index=False)
317
  buf.seek(0)
318
  api.upload_file(
319
  path_or_fileobj=buf,
320
+ path_in_repo=participant_file,
321
  repo_id=RESULTS_REPO,
322
  repo_type="dataset",
323
+ commit_message=f"Update {participant_file} (n={len(results)})",
324
  )
325
 
326
 
327
+ def _load_participants_registry() -> dict:
328
+ if not HF_TOKEN:
329
+ return {}
330
+ try:
331
+ path = hf_hub_download(
332
+ repo_id=RESULTS_REPO,
333
+ repo_type="dataset",
334
+ filename="participants.json",
335
+ token=HF_TOKEN,
336
+ force_download=True,
337
+ )
338
+ with open(path) as f:
339
+ return json.load(f)
340
+ except (EntryNotFoundError, RepositoryNotFoundError, FileNotFoundError):
341
+ return {}
342
+ except Exception as exc: # noqa: BLE001
343
+ print(f"[participants] Could not load registry ({exc})")
344
+ return {}
345
+
346
+
347
+ _REGISTRY_LOCK = threading.Lock()
348
+
349
+
350
+ def _register_participant(slug: str, first: str, last: str, email: str) -> None:
351
+ if not HF_TOKEN:
352
+ return
353
+ with _REGISTRY_LOCK:
354
+ registry = _load_participants_registry()
355
+ entry = registry.get(slug, {})
356
+ now_iso = datetime.now(timezone.utc).isoformat()
357
+ if not entry:
358
+ entry = {
359
+ "full_name": f"{first} {last}".strip(),
360
+ "first_name": first,
361
+ "last_name": last,
362
+ "email": email,
363
+ "registered_at": now_iso,
364
+ "last_session_at": now_iso,
365
+ }
366
+ else:
367
+ entry.setdefault("first_name", first)
368
+ entry.setdefault("last_name", last)
369
+ entry.setdefault("registered_at", now_iso)
370
+ entry["full_name"] = f"{first} {last}".strip()
371
+ entry["email"] = email
372
+ entry["last_session_at"] = now_iso
373
+ registry[slug] = entry
374
+ payload = json.dumps(registry, indent=2).encode()
375
+ try:
376
+ api.upload_file(
377
+ path_or_fileobj=io.BytesIO(payload),
378
+ path_in_repo="participants.json",
379
+ repo_id=RESULTS_REPO,
380
+ repo_type="dataset",
381
+ commit_message=f"Register/update participant {slug}",
382
+ )
383
+ except Exception as exc: # noqa: BLE001
384
+ print(f"[participants] WARNING: could not save registry ({exc}).")
385
+
386
+
387
  # ---------------------------------------------------------------------------
388
  # Gradio handlers
389
  # ---------------------------------------------------------------------------
390
 
391
  WELCOME_HTML = """
392
+ <div style="text-align:center; padding: 12px 16px 4px;">
393
  <h2 style="margin-bottom: 8px;">Caption Preference Study</h2>
394
+ <p style="font-size: 1.05em; margin: 0;">
395
  You will see images with two captions. Click the caption that better
396
  describes the image.
397
  </p>
398
  </div>
399
  """
400
 
401
+ DONE_NEW_HTML = """
402
  <div style="text-align:center; padding: 32px;">
403
  <h2>All done — thank you for participating!</h2>
404
  <p>You can close this tab now.</p>
405
  </div>
406
  """
407
 
408
+ DONE_ALREADY_HTML_TMPL = """
409
+ <div style="text-align:center; padding: 32px;">
410
+ <h2>You've already completed this study.</h2>
411
+ <p>Thank you, {name}! Our records show you finished all
412
+ {total} trials. There's nothing more to do — feel free to close this tab.</p>
413
+ </div>
414
+ """
415
 
416
+
417
+ def _validation_error(message: str):
418
+ return (
419
+ None, # state
420
+ gr.update(visible=True), # intro
421
+ gr.update(visible=False), # trial group
422
+ gr.update(visible=False, value=""), # done panel
423
+ None, # image
424
+ gr.update(value=""), # left button
425
+ gr.update(value=""), # right button
426
+ "", # progress
427
+ gr.update(value=message, visible=True), # error markdown
428
+ )
429
+
430
+
431
+ def start_session(first_name: str, last_name: str, email: str):
432
+ first = (first_name or "").strip()
433
+ last = (last_name or "").strip()
434
+ email_v = (email or "").strip()
435
+
436
+ if not first:
437
+ return _validation_error("Please enter your **first name**.")
438
+ if not last:
439
+ return _validation_error("Please enter your **last name**.")
440
+ if not EMAIL_RE.match(email_v):
441
+ return _validation_error("Please enter a valid **email address**.")
442
+
443
+ slug_first = _slugify(first)
444
+ slug_last = _slugify(last)
445
+ if not slug_first or not slug_last:
446
+ return _validation_error(
447
+ "Your name must include at least one letter or digit."
448
+ )
449
+
450
+ participant_file = _participant_filename(first, last)
451
+ prior = _load_participant_results(participant_file)
452
+
453
+ if _is_complete(prior):
454
+ msg = DONE_ALREADY_HTML_TMPL.format(
455
+ name=f"{first} {last}",
456
+ total=TOTAL_TRIALS_PER_PARTICIPANT,
457
+ )
458
+ # Still log that they came back (no overwrite of prior CSV).
459
+ threading.Thread(
460
+ target=_register_participant,
461
+ args=(f"{slug_first}-{slug_last}", first, last, email_v),
462
+ daemon=True,
463
+ ).start()
464
  return (
465
  None,
466
  gr.update(visible=False),
467
  gr.update(visible=False),
468
+ gr.update(value=msg, visible=True),
469
  None,
470
+ gr.update(value=""),
471
+ gr.update(value=""),
472
  "",
473
+ gr.update(value="", visible=False),
474
+ )
475
+
476
+ trials = _build_remaining_trials(prior)
477
+ if not trials:
478
+ # Defensive: no trials remaining but not "complete" by the strict
479
+ # check — treat as done so the participant isn't stuck.
480
+ msg = DONE_ALREADY_HTML_TMPL.format(
481
+ name=f"{first} {last}",
482
+ total=TOTAL_TRIALS_PER_PARTICIPANT,
483
+ )
484
+ return (
485
+ None,
486
+ gr.update(visible=False),
487
+ gr.update(visible=False),
488
+ gr.update(value=msg, visible=True),
489
+ None,
490
+ gr.update(value=""),
491
+ gr.update(value=""),
492
  "",
493
+ gr.update(value="", visible=False),
494
  )
495
+
496
+ _register_participant(f"{slug_first}-{slug_last}", first, last, email_v)
497
+
498
  state = {
499
+ "participant_file": participant_file,
500
  "trials": trials,
501
  "current_idx": 0,
502
  "trial_start_time": time.time(),
503
+ "results": list(prior),
504
+ "prior_count": len(prior),
505
+ "total_trials": TOTAL_TRIALS_PER_PARTICIPANT,
506
  }
507
  img_path, left, right, progress = _current_display(state)
508
  return (
509
  state,
510
+ gr.update(visible=False), # intro
511
+ gr.update(visible=True), # trial group
512
+ gr.update(value="", visible=False), # done panel
513
+ img_path, # image
514
+ gr.update(value=left), # left button
515
+ gr.update(value=right), # right button
516
+ progress, # progress
517
+ gr.update(value="", visible=False), # error
518
  )
519
 
520
 
 
527
  left, right = trial["human_caption"], trial["model_caption"]
528
  else:
529
  left, right = trial["model_caption"], trial["human_caption"]
530
+ completed = state["prior_count"] + state["current_idx"]
531
+ total = state["total_trials"]
532
+ progress = f"Trial {completed + 1} of {total}"
533
  return img_path, left, right, progress
534
 
535
 
536
  def _make_choice(state: dict, side: str):
537
  if state is None:
538
+ return (
539
+ state,
540
+ gr.update(visible=False),
541
+ gr.update(visible=False),
542
+ None,
543
+ gr.update(value=""),
544
+ gr.update(value=""),
545
+ "",
546
+ )
547
  elapsed = min(time.time() - state["trial_start_time"], RESPONSE_TIME_CAP)
548
  trial = state["trials"][state["current_idx"]]
549
  chose_human = trial["human_on_left"] if side == "left" else not trial["human_on_left"]
 
560
  }
561
  )
562
 
 
 
563
  threading.Thread(
564
  target=_save_results,
565
+ args=(state["participant_file"], list(state["results"])),
566
  daemon=True,
567
  ).start()
568
 
569
  state["current_idx"] += 1
570
  if state["current_idx"] >= len(state["trials"]):
571
+ total = state["total_trials"]
572
  return (
573
  state,
574
  gr.update(visible=False),
575
+ gr.update(value=DONE_NEW_HTML, visible=True),
576
  None,
577
+ gr.update(value=""),
578
+ gr.update(value=""),
579
+ f"Done — {total} / {total}",
580
  )
581
 
582
  state["trial_start_time"] = time.time()
 
586
  gr.update(visible=True),
587
  gr.update(visible=False),
588
  img_path,
589
+ gr.update(value=left),
590
+ gr.update(value=right),
591
  progress,
592
  )
593
 
 
606
  text-align: left !important;
607
  }
608
  .center-img img { max-height: 60vh !important; object-fit: contain !important; }
609
+ .form-error { color: #b91c1c !important; }
610
  """
611
 
612
  with gr.Blocks(title="Caption Preference Study", css=custom_css) as demo:
 
618
  with gr.Row():
619
  with gr.Column(scale=1):
620
  pass
621
+ with gr.Column(scale=2):
622
+ first_input = gr.Textbox(
623
+ label="First name", placeholder="e.g. Jane", max_lines=1
624
+ )
625
+ last_input = gr.Textbox(
626
+ label="Last name", placeholder="e.g. Smith", max_lines=1
627
+ )
628
+ email_input = gr.Textbox(
629
+ label="Email address",
630
+ placeholder="you@example.com",
631
+ max_lines=1,
632
+ )
633
  start_btn = gr.Button("Start", variant="primary", size="lg")
634
+ error_md = gr.Markdown("", visible=False, elem_classes=["form-error"])
635
  with gr.Column(scale=1):
636
  pass
637
 
 
652
 
653
  start_btn.click(
654
  start_session,
655
+ inputs=[first_input, last_input, email_input],
656
+ outputs=[
657
+ state,
658
+ intro,
659
+ trial_group,
660
+ done_panel,
661
+ image,
662
+ left_btn,
663
+ right_btn,
664
+ progress,
665
+ error_md,
666
+ ],
667
  )
668
 
669
  left_btn.click(