José Eliel Camargo Molina commited on
Commit
660ea47
·
1 Parent(s): 65245d5

Refactored metadata input values, mapping and added uncapped number of emotions + other stuff

Browse files
Files changed (3) hide show
  1. __pycache__/app.cpython-39.pyc +0 -0
  2. app.py +328 -117
  3. emotion_responses.csv +22 -0
__pycache__/app.cpython-39.pyc ADDED
Binary file (14.1 kB). View file
 
app.py CHANGED
@@ -7,23 +7,246 @@ import time
7
  import csv
8
  import uuid
9
  from datetime import datetime
10
- from PIL import Image
11
 
12
  # --- Configuration ---
13
  AI_FOLDER = "./AI"
14
  HUMAN_FOLDER = "./Human"
15
  CSV_FILE = "emotion_responses.csv"
 
16
  DEBLUR_DURATION_S = 10
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  # --- Data Structure ---
19
  class ImageData:
20
  """A simple class to hold information about each image."""
21
- def __init__(self, path, source, emotion):
22
  self.path = path
23
  self.source = source
24
  self.emotion = emotion
 
 
 
 
25
  self.name = os.path.basename(path)
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  # --- Backend Functions ---
28
 
29
  def crop_face(image_path, target_size=512):
@@ -82,155 +305,165 @@ def crop_face(image_path, target_size=512):
82
  # 4. Convert to RGB for Gradio display
83
  return cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB)
84
 
85
- def initialize_experiment():
86
- """Scans folders for images, creates dummy files if needed, and prepares the experiment state."""
87
- # Create demo folders/images if missing
88
  os.makedirs(AI_FOLDER, exist_ok=True)
89
  os.makedirs(HUMAN_FOLDER, exist_ok=True)
90
-
91
- images = []
92
- emotions = set()
93
-
94
- for folder, source in [(AI_FOLDER, "AI"), (HUMAN_FOLDER, "Human")]:
95
- if not os.path.exists(folder):
96
- continue
97
- for filename in os.listdir(folder):
98
- if filename.lower().endswith(('.jpg', '.jpeg', '.png')):
99
- parts = os.path.splitext(filename)[0].split('_')
100
- if len(parts) < 2:
101
- continue
102
- emotion = parts[-1].lower()
103
- emotions.add(emotion)
104
- path = os.path.join(folder, filename)
105
- images.append(ImageData(path, source, emotion))
106
 
 
107
  if not images:
108
- return None, "Error: No images found. Please add images to 'AI' and 'Human' folders with names like 'name_emotion.jpg'"
109
 
110
- random.shuffle(images)
111
  sorted_emotions = sorted(list(emotions))
112
- # we only have 4 buttons; trim if more
113
- sorted_emotions = sorted_emotions[:4] if sorted_emotions else ["happy", "sad", "angry", "surprised"]
114
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  initial_state = {
116
- "user_id": str(uuid.uuid4()),
 
 
117
  "all_images": images,
118
  "emotions": sorted_emotions,
119
  "current_index": -1,
120
- "start_time": None
 
 
121
  }
122
-
123
- # Create the CSV file with headers if it doesn't exist
124
- if not os.path.exists(CSV_FILE):
125
- with open(CSV_FILE, 'w', newline='') as f:
126
- writer = csv.writer(f)
127
- writer.writerow([
128
- 'user_id', 'image_name', 'image_source', 'correct_emotion',
129
- 'selected_emotion', 'response_time_s', 'timestamp'
130
- ])
131
-
132
- return initial_state, ""
133
 
134
  def start_interface(state):
135
  """Hides instructions and shows the main experiment UI."""
136
- num_emotions = len(state["emotions"])
137
- button_updates = [gr.update(visible=True, value=state["emotions"][i]) for i in range(num_emotions)]
138
- button_updates += [gr.update(visible=False)] * (4 - num_emotions) # Hide unused buttons
139
-
 
 
140
  return (
141
  gr.update(visible=False), # instructions_section
142
  gr.update(visible=False), # start_btn
143
  gr.update(visible=True), # main_section
144
- gr.update(visible=True), # emotion_buttons_row
145
- *button_updates
146
  )
147
 
148
  def show_next_image(state):
149
  """Loads the next image and updates the state."""
 
 
 
 
 
 
 
 
 
150
  state["current_index"] += 1
151
  index = state["current_index"]
152
 
153
- num_emotions = len(state["emotions"])
154
-
155
  if index >= len(state["all_images"]):
156
- btn_updates = [gr.update(visible=False, interactive=False)] * 4
157
  return (
158
  state,
159
  None,
160
  "Experiment complete! Thank you for participating.",
161
  gr.update(visible=False), # next_image_btn
162
- gr.update(visible=False), # emotion_buttons_row
163
- *btn_updates
164
  )
165
 
166
  image_data = state["all_images"][index]
167
  cropped_image = crop_face(image_data.path)
168
 
169
  if cropped_image is None:
170
- btn_updates = [gr.update(visible=False, interactive=False)] * 4
171
  return (
172
  state,
173
  None,
174
  f"Error loading image: {image_data.name}",
175
  gr.update(visible=True), # show Next so user can skip the broken one
176
- gr.update(visible=False),
177
- *btn_updates
178
  )
179
 
180
- state["start_time"] = time.time()
181
  print(f"[DEBUG] Showing image {index+1}/{len(state['all_images'])}: {image_data.name}")
182
 
183
- # Enable only the number of active emotion buttons
184
- button_interactivity = [gr.update(visible=True, interactive=True)] * num_emotions
185
- button_interactivity += [gr.update(visible=False, interactive=False)] * (4 - num_emotions)
 
186
 
187
  return (
188
  state,
189
  cropped_image,
190
  f"Image {index + 1} of {len(state['all_images'])}",
191
  gr.update(visible=False), # hide Next until a choice is made
192
- gr.update(visible=True), # show emotion buttons row
193
- *button_interactivity
194
  )
195
 
196
- def on_emotion_click(state, selected_emotion):
197
- """Handles emotion button click and records data, then shows Next."""
 
 
 
 
198
  # Try to save; don't let errors block UI updates
199
  try:
200
- response_time = time.time() - (state.get("start_time") or time.time())
 
201
  image_data = state["all_images"][state["current_index"]]
202
- with open(CSV_FILE, 'a', newline='') as f:
 
203
  writer = csv.writer(f)
204
  writer.writerow([
205
- state["user_id"], image_data.name, image_data.source, image_data.emotion,
206
- selected_emotion, f"{response_time:.4f}", datetime.now().isoformat()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  ])
208
- print(f"[DEBUG] Clicked '{selected_emotion}' for {image_data.name} in {response_time:.3f}s")
209
  except Exception as e:
210
  print("-----------!! ERROR: Could not save data to CSV. !!-----------")
211
  print(e)
212
  print("----------------------------------------------------------------")
213
 
214
- # Disable buttons and reveal Next
215
- num_emotions = len(state["emotions"])
216
- button_interactivity = [gr.update(interactive=False)] * num_emotions
217
- button_interactivity += [gr.update()] * (4 - num_emotions)
218
-
219
  return (
220
- gr.update(visible=False), # emotion_buttons_row
221
  gr.update(visible=True), # next_image_btn
222
- *button_interactivity
223
  )
224
 
225
- def on_emotion_click_idx(state, idx):
226
- """Map a fixed button index to an emotion label."""
227
- # Guard in case fewer than 4 emotions exist
228
- if idx >= len(state["emotions"]):
229
- print(f"[DEBUG] Ignored click for idx {idx}; only {len(state['emotions'])} emotions configured.")
230
- return gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
231
- selected_emotion = state["emotions"][idx]
232
- return on_emotion_click(state, selected_emotion)
233
-
234
  # --- Gradio UI Layout ---
235
  with gr.Blocks(theme=gr.themes.Soft()) as app:
236
  state = gr.State()
@@ -243,7 +476,7 @@ with gr.Blocks(theme=gr.themes.Soft()) as app:
243
  ## Instructions
244
  1. An image of a face will appear. It will start very blurry.
245
  2. The image will gradually become clear over 10 seconds.
246
- 3. As soon as you recognize the emotion, click the corresponding button below.
247
  4. The image will become fully clear, and a "Next Image" button will appear.
248
  5. Click "Next Image" to continue the study.
249
 
@@ -256,20 +489,14 @@ with gr.Blocks(theme=gr.themes.Soft()) as app:
256
  with gr.Column(visible=False) as main_section:
257
  image_display = gr.Image(label="", elem_id="image_display", height=400, width=400, interactive=False)
258
  progress_text = gr.Markdown("")
259
-
260
- with gr.Row(visible=False) as emotion_buttons_row:
261
- emotion_btn_1 = gr.Button(size="lg", interactive=True)
262
- emotion_btn_2 = gr.Button(size="lg", interactive=True)
263
- emotion_btn_3 = gr.Button(size="lg", interactive=True)
264
- emotion_btn_4 = gr.Button(size="lg", interactive=True)
265
- emotion_buttons = [emotion_btn_1, emotion_btn_2, emotion_btn_3, emotion_btn_4]
266
 
267
  next_image_btn = gr.Button("Next Image ▶", variant="secondary", visible=False)
268
 
269
  # --- Event Handlers ---
270
  app.load(
271
  fn=initialize_experiment,
272
- outputs=[state, status_text]
273
  ).then(
274
  fn=None,
275
  js=f"""() => {{
@@ -307,46 +534,28 @@ with gr.Blocks(theme=gr.themes.Soft()) as app:
307
  start_btn.click(
308
  fn=start_interface,
309
  inputs=[state],
310
- outputs=[instructions_section, start_btn, main_section, emotion_buttons_row, *emotion_buttons]
311
  ).then(
312
  fn=show_next_image,
313
  inputs=[state],
314
- outputs=[state, image_display, progress_text, next_image_btn, emotion_buttons_row, *emotion_buttons]
315
  ).then(
316
  fn=None,
317
  js="() => window.deblurImage()"
318
  )
319
 
320
- # IMPORTANT: bind JS + Python in the SAME click call (no .then)
321
- emotion_btn_1.click(
322
- fn=lambda s: on_emotion_click_idx(s, 0),
323
- inputs=[state],
324
- outputs=[emotion_buttons_row, next_image_btn, *emotion_buttons],
325
- js="() => window.unblurImmediately()"
326
- )
327
- emotion_btn_2.click(
328
- fn=lambda s: on_emotion_click_idx(s, 1),
329
- inputs=[state],
330
- outputs=[emotion_buttons_row, next_image_btn, *emotion_buttons],
331
- js="() => window.unblurImmediately()"
332
- )
333
- emotion_btn_3.click(
334
- fn=lambda s: on_emotion_click_idx(s, 2),
335
- inputs=[state],
336
- outputs=[emotion_buttons_row, next_image_btn, *emotion_buttons],
337
- js="() => window.unblurImmediately()"
338
- )
339
- emotion_btn_4.click(
340
- fn=lambda s: on_emotion_click_idx(s, 3),
341
- inputs=[state],
342
- outputs=[emotion_buttons_row, next_image_btn, *emotion_buttons],
343
  js="() => window.unblurImmediately()"
344
  )
345
 
346
  next_image_btn.click(
347
  fn=show_next_image,
348
  inputs=[state],
349
- outputs=[state, image_display, progress_text, next_image_btn, emotion_buttons_row, *emotion_buttons]
350
  ).then(
351
  fn=None,
352
  js="() => window.deblurImage()"
@@ -356,4 +565,6 @@ if __name__ == "__main__":
356
  print("Starting Gradio app...")
357
  print("Please create two folders: './AI' and './Human'")
358
  print("Place images in them named like 'any_name_happy.jpg', 'some_face_sad.png', etc.")
 
 
359
  app.launch()
 
7
  import csv
8
  import uuid
9
  from datetime import datetime
 
10
 
11
  # --- Configuration ---
12
  AI_FOLDER = "./AI"
13
  HUMAN_FOLDER = "./Human"
14
  CSV_FILE = "emotion_responses.csv"
15
+ METADATA_FILE = "stimuli_metadata.csv"
16
  DEBLUR_DURATION_S = 10
17
 
18
+ # Query param used in URLs like: https://.../app?pid=12345
19
+ URL_PARAM_PARTICIPANT_ID = "pid"
20
+ # Randomize emotion choice order per trial (can be overridden by URL param).
21
+ RANDOMIZE_EMOTION_ORDER_DEFAULT = True
22
+ RANDOMIZE_EMOTION_ORDER_PARAM = "randomize"
23
+
24
+ # Label normalization defaults.
25
+ UNKNOWN_LABEL = "unknown"
26
+ UNKNOWN_CODE = 0
27
+
28
+ # Filename parsing order from the RIGHT side. Extend if you encode more fields in filenames.
29
+ # Example filename if you extend: "subject_happy_female_asian_front-left.png"
30
+ FILENAME_FIELD_ORDER = ["emotion"]
31
+
32
+ # Code mappings (edit here when your coding scheme changes).
33
+ EMOTION_CODE_MAP = {
34
+ "happy": 1,
35
+ "sad": 2,
36
+ "angry": 3,
37
+ "surprised": 4,
38
+ "disgusted": 5,
39
+ "fearful": 6,
40
+ "neutral": 7,
41
+ "unknown": 0,
42
+ }
43
+ SEX_CODE_MAP = {
44
+ "male": 1,
45
+ "female": 2,
46
+ "other": 3,
47
+ "unknown": 0,
48
+ }
49
+ ETHNICITY_CODE_MAP = {
50
+ "caucasian": 1,
51
+ "black": 2,
52
+ "asian": 3,
53
+ "latino": 4,
54
+ "middle-eastern": 5,
55
+ "indigenous": 6,
56
+ "other": 7,
57
+ "unknown": 0,
58
+ }
59
+ ANGLE_CODE_MAP = {
60
+ "forward": 1,
61
+ "front-left": 2,
62
+ "front-right": 3,
63
+ "left": 4,
64
+ "right": 5,
65
+ "up": 6,
66
+ "down": 7,
67
+ "unknown": 0,
68
+ }
69
+ TYPE_CODE_MAP = {
70
+ "human": 1,
71
+ "ai": 2,
72
+ "unknown": 0,
73
+ }
74
+
75
+ CSV_HEADERS = [
76
+ "participant_id",
77
+ "session_id",
78
+ "image_name",
79
+ "image_source",
80
+ "face_type",
81
+ "face_type_code",
82
+ "correct_emotion",
83
+ "correct_emotion_code",
84
+ "face_sex",
85
+ "face_sex_code",
86
+ "face_ethnicity",
87
+ "face_ethnicity_code",
88
+ "face_angle",
89
+ "face_angle_code",
90
+ "selected_emotion",
91
+ "selected_emotion_code",
92
+ "accuracy",
93
+ "response_time_ms",
94
+ "button_order",
95
+ "timestamp",
96
+ ]
97
+
98
  # --- Data Structure ---
99
  class ImageData:
100
  """A simple class to hold information about each image."""
101
+ def __init__(self, path, source, emotion, sex=UNKNOWN_LABEL, ethnicity=UNKNOWN_LABEL, angle=UNKNOWN_LABEL, face_type=UNKNOWN_LABEL):
102
  self.path = path
103
  self.source = source
104
  self.emotion = emotion
105
+ self.sex = sex
106
+ self.ethnicity = ethnicity
107
+ self.angle = angle
108
+ self.face_type = face_type
109
  self.name = os.path.basename(path)
110
 
111
+ # --- Helper Functions ---
112
+
113
+ def normalize_label(value):
114
+ if value is None:
115
+ return ""
116
+ value = str(value).strip().lower()
117
+ value = value.replace(" ", "-")
118
+ return value
119
+
120
+ def get_code(code_map, label):
121
+ label = normalize_label(label)
122
+ if not label:
123
+ return UNKNOWN_CODE
124
+ return code_map.get(label, UNKNOWN_CODE)
125
+
126
+ def load_metadata(metadata_path):
127
+ if not os.path.exists(metadata_path):
128
+ return {}
129
+ metadata = {}
130
+ with open(metadata_path, newline='') as f:
131
+ reader = csv.DictReader(f)
132
+ for row in reader:
133
+ name = row.get("image_name") or row.get("filename") or row.get("image")
134
+ if not name:
135
+ continue
136
+ key = name.strip().lower()
137
+ entry = {
138
+ "emotion": normalize_label(row.get("emotion")),
139
+ "sex": normalize_label(row.get("sex")),
140
+ "ethnicity": normalize_label(row.get("ethnicity")),
141
+ "angle": normalize_label(row.get("angle")),
142
+ "face_type": normalize_label(row.get("face_type") or row.get("type") or row.get("source")),
143
+ }
144
+ metadata[key] = entry
145
+ stem = os.path.splitext(key)[0]
146
+ metadata.setdefault(stem, entry)
147
+ return metadata
148
+
149
+ def parse_filename_fields(image_path):
150
+ base_name = os.path.splitext(os.path.basename(image_path))[0]
151
+ parts = base_name.split('_')
152
+ if len(parts) < 2:
153
+ return {}
154
+ fields = {}
155
+ for field in FILENAME_FIELD_ORDER:
156
+ if not parts:
157
+ break
158
+ fields[field] = normalize_label(parts.pop())
159
+ return fields
160
+
161
+ def resolve_field(metadata, filename_fields, key, default=UNKNOWN_LABEL):
162
+ value = ""
163
+ if metadata:
164
+ value = normalize_label(metadata.get(key))
165
+ if not value:
166
+ value = filename_fields.get(key, "")
167
+ return value or default
168
+
169
+ def resolve_face_type(metadata, source):
170
+ if metadata:
171
+ face_type = metadata.get("face_type")
172
+ if face_type:
173
+ return normalize_label(face_type)
174
+ return normalize_label(source)
175
+
176
+ def ensure_csv_file():
177
+ if not os.path.exists(CSV_FILE):
178
+ with open(CSV_FILE, 'w', newline='') as f:
179
+ writer = csv.writer(f)
180
+ writer.writerow(CSV_HEADERS)
181
+ return CSV_FILE, ""
182
+
183
+ with open(CSV_FILE, newline='') as f:
184
+ reader = csv.reader(f)
185
+ existing_header = next(reader, None)
186
+ if existing_header != CSV_HEADERS:
187
+ base, ext = os.path.splitext(CSV_FILE)
188
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
189
+ new_file = f"{base}_{timestamp}{ext or '.csv'}"
190
+ with open(new_file, 'w', newline='') as f:
191
+ writer = csv.writer(f)
192
+ writer.writerow(CSV_HEADERS)
193
+ return new_file, f"Using new results file: {new_file}"
194
+
195
+ return CSV_FILE, ""
196
+
197
+ def parse_randomize_param(value):
198
+ if value is None:
199
+ return None
200
+ value = str(value).strip().lower()
201
+ if value in ("0", "false", "no", "off"):
202
+ return False
203
+ if value in ("1", "true", "yes", "on"):
204
+ return True
205
+ return None
206
+
207
+ def get_participant_id(request):
208
+ if request is None:
209
+ return ""
210
+ participant_id = request.query_params.get(URL_PARAM_PARTICIPANT_ID)
211
+ if participant_id is None:
212
+ return ""
213
+ return str(participant_id).strip()
214
+
215
+ def scan_images():
216
+ images = []
217
+ emotions = set()
218
+ metadata = load_metadata(METADATA_FILE)
219
+ skipped = []
220
+
221
+ for folder, source in [(AI_FOLDER, "AI"), (HUMAN_FOLDER, "Human")]:
222
+ if not os.path.exists(folder):
223
+ continue
224
+ for filename in os.listdir(folder):
225
+ if not filename.lower().endswith(('.jpg', '.jpeg', '.png')):
226
+ continue
227
+ path = os.path.join(folder, filename)
228
+ meta_key = filename.lower()
229
+ meta = metadata.get(meta_key) or metadata.get(os.path.splitext(meta_key)[0]) or {}
230
+ filename_fields = parse_filename_fields(path)
231
+
232
+ emotion = resolve_field(meta, filename_fields, "emotion", "")
233
+ if not emotion or emotion == UNKNOWN_LABEL:
234
+ skipped.append(filename)
235
+ continue
236
+
237
+ sex = resolve_field(meta, filename_fields, "sex", UNKNOWN_LABEL)
238
+ ethnicity = resolve_field(meta, filename_fields, "ethnicity", UNKNOWN_LABEL)
239
+ angle = resolve_field(meta, filename_fields, "angle", UNKNOWN_LABEL)
240
+ face_type = resolve_face_type(meta, source) or UNKNOWN_LABEL
241
+
242
+ emotions.add(emotion)
243
+ images.append(ImageData(path, source, emotion, sex=sex, ethnicity=ethnicity, angle=angle, face_type=face_type))
244
+
245
+ if skipped:
246
+ print(f"[DEBUG] Skipped {len(skipped)} images without an emotion label.")
247
+
248
+ return images, emotions
249
+
250
  # --- Backend Functions ---
251
 
252
  def crop_face(image_path, target_size=512):
 
305
  # 4. Convert to RGB for Gradio display
306
  return cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB)
307
 
308
+ def initialize_experiment(request: gr.Request):
309
+ """Scans folders for images and prepares the experiment state."""
 
310
  os.makedirs(AI_FOLDER, exist_ok=True)
311
  os.makedirs(HUMAN_FOLDER, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
+ images, emotions = scan_images()
314
  if not images:
315
+ return None, "Error: No images found. Please add images to 'AI' and 'Human' folders.", gr.update(interactive=False)
316
 
 
317
  sorted_emotions = sorted(list(emotions))
318
+ if not sorted_emotions:
319
+ return None, "Error: No valid emotion labels found in image names or metadata.", gr.update(interactive=False)
320
+
321
+ session_id = str(uuid.uuid4())
322
+ participant_id = get_participant_id(request)
323
+ if not participant_id:
324
+ participant_id = f"anon-{session_id}"
325
+ participant_msg = f"Participant ID: {participant_id} (auto-generated; add ?{URL_PARAM_PARTICIPANT_ID}=... to URL)"
326
+ else:
327
+ participant_msg = f"Participant ID: {participant_id}"
328
+
329
+ randomize_emotions = RANDOMIZE_EMOTION_ORDER_DEFAULT
330
+ if request is not None:
331
+ override = parse_randomize_param(request.query_params.get(RANDOMIZE_EMOTION_ORDER_PARAM))
332
+ if override is not None:
333
+ randomize_emotions = override
334
+
335
+ csv_file, csv_status = ensure_csv_file()
336
+ status_lines = [participant_msg]
337
+ if csv_status:
338
+ status_lines.append(csv_status)
339
+
340
+ random.shuffle(images)
341
  initial_state = {
342
+ "participant_id": participant_id,
343
+ "session_id": session_id,
344
+ "csv_file": csv_file,
345
  "all_images": images,
346
  "emotions": sorted_emotions,
347
  "current_index": -1,
348
+ "current_choices": [],
349
+ "randomize_emotions": randomize_emotions,
350
+ "start_time": None,
351
  }
352
+
353
+ return initial_state, "\n\n".join(status_lines), gr.update(interactive=True)
 
 
 
 
 
 
 
 
 
354
 
355
  def start_interface(state):
356
  """Hides instructions and shows the main experiment UI."""
357
+ if not state:
358
+ return (
359
+ gr.update(visible=True), # instructions_section
360
+ gr.update(visible=True), # start_btn
361
+ gr.update(visible=False), # main_section
362
+ )
363
  return (
364
  gr.update(visible=False), # instructions_section
365
  gr.update(visible=False), # start_btn
366
  gr.update(visible=True), # main_section
 
 
367
  )
368
 
369
  def show_next_image(state):
370
  """Loads the next image and updates the state."""
371
+ if not state:
372
+ return (
373
+ state,
374
+ None,
375
+ "No experiment state available.",
376
+ gr.update(visible=False),
377
+ gr.update(visible=False),
378
+ )
379
+
380
  state["current_index"] += 1
381
  index = state["current_index"]
382
 
 
 
383
  if index >= len(state["all_images"]):
 
384
  return (
385
  state,
386
  None,
387
  "Experiment complete! Thank you for participating.",
388
  gr.update(visible=False), # next_image_btn
389
+ gr.update(visible=False), # emotion_choice
 
390
  )
391
 
392
  image_data = state["all_images"][index]
393
  cropped_image = crop_face(image_data.path)
394
 
395
  if cropped_image is None:
 
396
  return (
397
  state,
398
  None,
399
  f"Error loading image: {image_data.name}",
400
  gr.update(visible=True), # show Next so user can skip the broken one
401
+ gr.update(visible=False), # emotion_choice
 
402
  )
403
 
404
+ state["start_time"] = time.monotonic()
405
  print(f"[DEBUG] Showing image {index+1}/{len(state['all_images'])}: {image_data.name}")
406
 
407
+ choices = list(state["emotions"])
408
+ if state.get("randomize_emotions"):
409
+ choices = random.sample(choices, k=len(choices))
410
+ state["current_choices"] = choices
411
 
412
  return (
413
  state,
414
  cropped_image,
415
  f"Image {index + 1} of {len(state['all_images'])}",
416
  gr.update(visible=False), # hide Next until a choice is made
417
+ gr.update(choices=choices, value=None, visible=True, interactive=True),
 
418
  )
419
 
420
+ def on_emotion_select(state, selected_emotion):
421
+ """Handles emotion selection and records data, then shows Next."""
422
+ if not state or not selected_emotion:
423
+ return gr.update(), gr.update()
424
+
425
+ selected_emotion = normalize_label(selected_emotion)
426
  # Try to save; don't let errors block UI updates
427
  try:
428
+ start_time = state.get("start_time") or time.monotonic()
429
+ response_time_ms = int(round((time.monotonic() - start_time) * 1000))
430
  image_data = state["all_images"][state["current_index"]]
431
+ accuracy = "correct" if selected_emotion == image_data.emotion else "incorrect"
432
+ with open(state["csv_file"], 'a', newline='') as f:
433
  writer = csv.writer(f)
434
  writer.writerow([
435
+ state["participant_id"],
436
+ state["session_id"],
437
+ image_data.name,
438
+ image_data.source,
439
+ image_data.face_type,
440
+ get_code(TYPE_CODE_MAP, image_data.face_type),
441
+ image_data.emotion,
442
+ get_code(EMOTION_CODE_MAP, image_data.emotion),
443
+ image_data.sex,
444
+ get_code(SEX_CODE_MAP, image_data.sex),
445
+ image_data.ethnicity,
446
+ get_code(ETHNICITY_CODE_MAP, image_data.ethnicity),
447
+ image_data.angle,
448
+ get_code(ANGLE_CODE_MAP, image_data.angle),
449
+ selected_emotion,
450
+ get_code(EMOTION_CODE_MAP, selected_emotion),
451
+ accuracy,
452
+ response_time_ms,
453
+ "|".join(state.get("current_choices", [])),
454
+ datetime.now().isoformat(),
455
  ])
456
+ print(f"[DEBUG] Selected '{selected_emotion}' for {image_data.name} in {response_time_ms}ms")
457
  except Exception as e:
458
  print("-----------!! ERROR: Could not save data to CSV. !!-----------")
459
  print(e)
460
  print("----------------------------------------------------------------")
461
 
 
 
 
 
 
462
  return (
463
+ gr.update(visible=False, interactive=False), # emotion_choice
464
  gr.update(visible=True), # next_image_btn
 
465
  )
466
 
 
 
 
 
 
 
 
 
 
467
  # --- Gradio UI Layout ---
468
  with gr.Blocks(theme=gr.themes.Soft()) as app:
469
  state = gr.State()
 
476
  ## Instructions
477
  1. An image of a face will appear. It will start very blurry.
478
  2. The image will gradually become clear over 10 seconds.
479
+ 3. As soon as you recognize the emotion, select the corresponding option below.
480
  4. The image will become fully clear, and a "Next Image" button will appear.
481
  5. Click "Next Image" to continue the study.
482
 
 
489
  with gr.Column(visible=False) as main_section:
490
  image_display = gr.Image(label="", elem_id="image_display", height=400, width=400, interactive=False)
491
  progress_text = gr.Markdown("")
492
+ emotion_choice = gr.Radio(choices=[], label="Select the emotion", visible=False, interactive=True)
 
 
 
 
 
 
493
 
494
  next_image_btn = gr.Button("Next Image ▶", variant="secondary", visible=False)
495
 
496
  # --- Event Handlers ---
497
  app.load(
498
  fn=initialize_experiment,
499
+ outputs=[state, status_text, start_btn]
500
  ).then(
501
  fn=None,
502
  js=f"""() => {{
 
534
  start_btn.click(
535
  fn=start_interface,
536
  inputs=[state],
537
+ outputs=[instructions_section, start_btn, main_section]
538
  ).then(
539
  fn=show_next_image,
540
  inputs=[state],
541
+ outputs=[state, image_display, progress_text, next_image_btn, emotion_choice]
542
  ).then(
543
  fn=None,
544
  js="() => window.deblurImage()"
545
  )
546
 
547
+ # IMPORTANT: bind JS + Python in the SAME change call (no .then)
548
+ emotion_choice.change(
549
+ fn=on_emotion_select,
550
+ inputs=[state, emotion_choice],
551
+ outputs=[emotion_choice, next_image_btn],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  js="() => window.unblurImmediately()"
553
  )
554
 
555
  next_image_btn.click(
556
  fn=show_next_image,
557
  inputs=[state],
558
+ outputs=[state, image_display, progress_text, next_image_btn, emotion_choice]
559
  ).then(
560
  fn=None,
561
  js="() => window.deblurImage()"
 
565
  print("Starting Gradio app...")
566
  print("Please create two folders: './AI' and './Human'")
567
  print("Place images in them named like 'any_name_happy.jpg', 'some_face_sad.png', etc.")
568
+ print(f"Optional metadata file: '{METADATA_FILE}' with columns image_name, emotion, sex, ethnicity, angle, face_type.")
569
+ print(f"Participant ID via URL param '?{URL_PARAM_PARTICIPANT_ID}=...'")
570
  app.launch()
emotion_responses.csv ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ user_id,image_name,image_source,correct_emotion,selected_emotion,response_time_s,timestamp
2
+ b77b35d7-98ed-4d65-bd7f-5600406b2d13,woman_surprised.png,Human,surprised,disgusted,4.0170,2025-10-08T19:02:10.348995
3
+ b77b35d7-98ed-4d65-bd7f-5600406b2d13,man_angry.png,Human,angry,happy,2.5259,2025-10-08T19:02:14.334210
4
+ b77b35d7-98ed-4d65-bd7f-5600406b2d13,woman2_happy.png,AI,happy,angry,1.1268,2025-10-08T19:02:16.489351
5
+ b77b35d7-98ed-4d65-bd7f-5600406b2d13,woman_disgusted.png,Human,disgusted,disgusted,0.8650,2025-10-08T19:02:18.073450
6
+ b77b35d7-98ed-4d65-bd7f-5600406b2d13,oldman_angry.png,AI,angry,disgusted,1.5342,2025-10-08T19:02:20.375208
7
+ b77b35d7-98ed-4d65-bd7f-5600406b2d13,kid_surprised.png,AI,surprised,surprised,0.8176,2025-10-08T19:02:22.014076
8
+ 9b307667-0e5a-47a7-b590-e86eb19b8877,woman2_happy.png,AI,happy,disgusted,7730.2715,2025-10-15T12:52:36.645373
9
+ 9b307667-0e5a-47a7-b590-e86eb19b8877,oldman_angry.png,AI,angry,happy,1.2217,2025-10-15T12:52:39.146115
10
+ 9b307667-0e5a-47a7-b590-e86eb19b8877,woman_disgusted.png,Human,disgusted,surprised,8.0417,2025-10-15T12:52:48.261584
11
+ 8cef88bf-4fa4-4937-8ff1-a4de8690caab,man_angry.png,Human,angry,angry,10.0966,2025-10-16T10:09:00.573603
12
+ 8cef88bf-4fa4-4937-8ff1-a4de8690caab,kid_surprised.png,AI,surprised,happy,4.3949,2025-10-16T10:09:06.290271
13
+ 5beeb812-65bb-4d73-9e74-b5745e50d53c,oldman_angry.png,AI,angry,happy,30.9841,2025-10-16T10:27:33.040255
14
+ 5beeb812-65bb-4d73-9e74-b5745e50d53c,woman_surprised.png,Human,surprised,happy,2.9925,2025-10-16T10:27:36.935171
15
+ 5beeb812-65bb-4d73-9e74-b5745e50d53c,kid_surprised.png,AI,surprised,disgusted,1.0123,2025-10-16T10:27:42.854288
16
+ 5beeb812-65bb-4d73-9e74-b5745e50d53c,woman_disgusted.png,Human,disgusted,disgusted,0.4319,2025-10-16T10:27:45.272861
17
+ 5beeb812-65bb-4d73-9e74-b5745e50d53c,man_angry.png,Human,angry,disgusted,0.5874,2025-10-16T10:27:48.702015
18
+ 5beeb812-65bb-4d73-9e74-b5745e50d53c,woman2_happy.png,AI,happy,disgusted,0.6159,2025-10-16T10:27:53.459924
19
+ cb09bc25-3d8c-400b-857b-8d208cd03a7a,oldman_angry.png,AI,angry,disgusted,4.7478,2025-10-23T11:03:26.899999
20
+ cb09bc25-3d8c-400b-857b-8d208cd03a7a,kid_surprised.png,AI,surprised,happy,0.9752,2025-10-23T11:03:28.795161
21
+ cb09bc25-3d8c-400b-857b-8d208cd03a7a,woman_surprised.png,Human,surprised,happy,23.7807,2025-10-23T11:03:53.726751
22
+ cb09bc25-3d8c-400b-857b-8d208cd03a7a,woman_disgusted.png,Human,disgusted,happy,0.8153,2025-10-23T11:03:55.614613