pmadinei commited on
Commit
26479a7
·
verified ·
1 Parent(s): 7001ff6

Initial deployment of caption preference study

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ .venv/bin/python filter=lfs diff=lfs merge=lfs -text
37
+ .venv/bin/python3 filter=lfs diff=lfs merge=lfs -text
38
+ .venv/bin/python3.12 filter=lfs diff=lfs merge=lfs -text
.venv/bin/python ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2845674b3fae665f5f6c3f01674fefa4e495f15f5d755ee8f7f1a40575abdc8a
3
+ size 8021824
.venv/bin/python3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2845674b3fae665f5f6c3f01674fefa4e495f15f5d755ee8f7f1a40575abdc8a
3
+ size 8021824
.venv/bin/python3.12 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2845674b3fae665f5f6c3f01674fefa4e495f15f5d755ee8f7f1a40575abdc8a
3
+ size 8021824
.venv/pyvenv.cfg ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ home = /usr/bin
2
+ include-system-site-packages = false
3
+ version = 3.12.3
4
+ executable = /usr/bin/python3.12
5
+ command = /usr/bin/python3 -m venv /scratch/Parsa2/Project-Pete/hf_experiment/.venv
Qwen3-VL-8B-Instruct.csv ADDED
The diff for this file is too large to render. See raw diff
 
README.md CHANGED
@@ -1,13 +1,17 @@
1
  ---
2
  title: Caption Preference Study
3
- emoji: 🐨
4
  colorFrom: indigo
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
  ---
2
  title: Caption Preference Study
3
+ emoji: 🖼️
4
  colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 5.49.1
 
8
  app_file: app.py
9
  pinned: false
10
+ short_description: A human-vs-AI image caption preference study.
11
  ---
12
 
13
+ # Caption Preference Study
14
+
15
+ A short experiment that shows you an image and two captions. Pick whichever caption better describes the image.
16
+
17
+ Your responses are stored privately by the study organizer.
__pycache__/app.cpython-312.pyc ADDED
Binary file (16.8 kB). View file
 
app.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
8
+
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
19
+
20
+ import gradio as gr
21
+ import pandas as pd
22
+ from huggingface_hub import HfApi, hf_hub_download, snapshot_download
23
+ from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError
24
+
25
+
26
+ HF_USER = "pmadinei"
27
+ IMAGES_REPO = f"{HF_USER}/caption-preference-images"
28
+ RESULTS_REPO = f"{HF_USER}/caption-preference-results"
29
+
30
+ HF_TOKEN = os.environ.get("HF_TOKEN")
31
+ RESPONSE_TIME_CAP = 100.0
32
+ 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
+
39
+ # ---------------------------------------------------------------------------
40
+ # Data loading
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)
53
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in ('"', "'"):
54
+ text = text[1:-1]
55
+ return text
56
+
57
+
58
+ print(f"[startup] Loading CSV from {CSV_PATH}")
59
+ df = pd.read_csv(CSV_PATH)
60
+ df["human_caption"] = df["human_caption"].map(_clean_caption)
61
+ df["model_caption"] = df["model_caption"].map(_clean_caption)
62
+
63
+ _test_mask = df["image_id"].astype(str).str.contains("test", case=False, na=False)
64
+ 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
+
78
+ # ---------------------------------------------------------------------------
79
+ # Image download
80
+ # ---------------------------------------------------------------------------
81
+
82
+ def _ensure_images_downloaded() -> None:
83
+ if not HF_TOKEN:
84
+ print("[startup] WARNING: HF_TOKEN is not set; cannot download images.")
85
+ return
86
+ print(f"[startup] Downloading images from {IMAGES_REPO} to {IMAGE_DIR}...")
87
+ snapshot_download(
88
+ repo_id=IMAGES_REPO,
89
+ repo_type="dataset",
90
+ local_dir=str(IMAGE_DIR),
91
+ token=HF_TOKEN,
92
+ max_workers=16,
93
+ )
94
+ print("[startup] Image download complete.")
95
+
96
+
97
+ _ensure_images_downloaded()
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Round-robin state (persisted to RESULTS_REPO/state.json)
102
+ # ---------------------------------------------------------------------------
103
+
104
+ _STATE_LOCK = threading.Lock()
105
+ _STATE: dict = {"image_id_used": {}}
106
+
107
+
108
+ def _state_key(image_id: Any) -> str:
109
+ return str(image_id)
110
+
111
+
112
+ def _load_state() -> None:
113
+ global _STATE
114
+ if not HF_TOKEN:
115
+ return
116
+ try:
117
+ path = hf_hub_download(
118
+ repo_id=RESULTS_REPO,
119
+ repo_type="dataset",
120
+ filename="state.json",
121
+ token=HF_TOKEN,
122
+ force_download=True,
123
+ )
124
+ with open(path) as f:
125
+ loaded = json.load(f)
126
+ _STATE = {"image_id_used": loaded.get("image_id_used", {})}
127
+ print(
128
+ f"[state] Loaded round-robin state with "
129
+ f"{len(_STATE['image_id_used'])} image_ids tracked."
130
+ )
131
+ except (EntryNotFoundError, RepositoryNotFoundError, FileNotFoundError):
132
+ print("[state] No existing state.json found, starting fresh.")
133
+ _STATE = {"image_id_used": {}}
134
+ except Exception as exc: # noqa: BLE001
135
+ print(f"[state] Could not load state.json ({exc}); starting fresh.")
136
+ _STATE = {"image_id_used": {}}
137
+
138
+
139
+ def _save_state() -> None:
140
+ if not HF_TOKEN:
141
+ return
142
+ payload = json.dumps(_STATE, indent=2).encode()
143
+ api.upload_file(
144
+ path_or_fileobj=io.BytesIO(payload),
145
+ path_in_repo="state.json",
146
+ repo_id=RESULTS_REPO,
147
+ repo_type="dataset",
148
+ commit_message="Update round-robin state",
149
+ )
150
+
151
+
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, []))
168
+ available = [fn for fn in all_fns if fn not in used]
169
+ if not available:
170
+ used = []
171
+ available = list(all_fns)
172
+ chosen = random.choice(available)
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)
205
+ return trials
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"]),
218
+ "model_caption": str(row["model_caption"]),
219
+ "human_on_left": random.choice([True, False]),
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
+
311
+ def _current_display(state: dict) -> tuple:
312
+ if state is None or state["current_idx"] >= len(state["trials"]):
313
+ return None, "", "", ""
314
+ trial = state["trials"][state["current_idx"]]
315
+ img_path = str(IMAGE_DIR / trial["filename"])
316
+ if trial["human_on_left"]:
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"]
330
+ state["results"].append(
331
+ {
332
+ "id": trial["id"],
333
+ "image_id": trial["image_id"],
334
+ "filename": trial["filename"],
335
+ "type": trial["type"],
336
+ "human_caption": trial["human_caption"],
337
+ "model_caption": trial["model_caption"],
338
+ "preference": "H" if chose_human else "M",
339
+ "response_time": round(elapsed, 3),
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()
364
+ img_path, left, right, progress = _current_display(state)
365
+ return (
366
+ state,
367
+ gr.update(visible=True),
368
+ gr.update(visible=False),
369
+ img_path,
370
+ left,
371
+ right,
372
+ progress,
373
+ )
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # UI
378
+ # ---------------------------------------------------------------------------
379
+
380
+ custom_css = """
381
+ .caption-btn {
382
+ min-height: 140px !important;
383
+ font-size: 1.05em !important;
384
+ white-space: normal !important;
385
+ line-height: 1.4 !important;
386
+ padding: 16px !important;
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:
393
+ state = gr.State()
394
+
395
+ intro = gr.Group(visible=True)
396
+ with intro:
397
+ gr.HTML(WELCOME_HTML)
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
+
406
+ trial_group = gr.Group(visible=False)
407
+ with trial_group:
408
+ progress = gr.Markdown("")
409
+ image = gr.Image(
410
+ label=None,
411
+ show_label=False,
412
+ interactive=False,
413
+ elem_classes=["center-img"],
414
+ )
415
+ with gr.Row():
416
+ left_btn = gr.Button("", elem_classes=["caption-btn"])
417
+ right_btn = gr.Button("", elem_classes=["caption-btn"])
418
+
419
+ done_panel = gr.HTML(visible=False)
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(
428
+ lambda s: _make_choice(s, "left"),
429
+ inputs=[state],
430
+ outputs=[state, trial_group, done_panel, image, left_btn, right_btn, progress],
431
+ )
432
+ right_btn.click(
433
+ lambda s: _make_choice(s, "right"),
434
+ inputs=[state],
435
+ outputs=[state, trial_group, done_panel, image, left_btn, right_btn, progress],
436
+ )
437
+
438
+
439
+ if __name__ == "__main__":
440
+ demo.queue(default_concurrency_limit=8).launch()
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio==5.49.1
2
+ pandas==2.2.3
3
+ huggingface_hub==0.35.3
4
+ Pillow==11.0.0