José Eliel Camargo Molina commited on
Commit
dedc13e
·
0 Parent(s):

First commit

Browse files
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
AI/kid_surprised.png ADDED

Git LFS Details

  • SHA256: 8a5d0453128ac8d3f5d4e842038340828b5590e3ba1dfc062a221777f71498b2
  • Pointer size: 131 Bytes
  • Size of remote file: 783 kB
AI/oldman_angry.png ADDED

Git LFS Details

  • SHA256: c374592b4e94c13d284fa51eb5c24b8a4594d8b76f1e1c32257daa56999b4201
  • Pointer size: 131 Bytes
  • Size of remote file: 746 kB
AI/woman2_happy.png ADDED

Git LFS Details

  • SHA256: c6fa4a3dbfac3048b3b28163ea06de70a49a28613d872b7cdd70a26e271f92d3
  • Pointer size: 131 Bytes
  • Size of remote file: 747 kB
Human/man_angry.png ADDED

Git LFS Details

  • SHA256: d56d1e8334c4b48aabc3eb6831024eabac655036e2e6e5d844737e9094a8d3ae
  • Pointer size: 130 Bytes
  • Size of remote file: 95 kB
Human/woman_disgusted.png ADDED

Git LFS Details

  • SHA256: 32b94bd67d0d9faa66f0015f7fb9114e26a24908e06f349089dcb1906bd6c406
  • Pointer size: 131 Bytes
  • Size of remote file: 642 kB
Human/woman_surprised.png ADDED

Git LFS Details

  • SHA256: 857afc63fa76e78f97865caed2d8f61f89dfcf7296b35b0f2d8fa58a192bf72a
  • Pointer size: 130 Bytes
  • Size of remote file: 97.4 kB
app.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import cv2
3
+ import numpy as np
4
+ import os
5
+ import random
6
+ 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):
30
+ """Crops the image to the largest detected face. Returns original if no face is found."""
31
+ if not os.path.exists(image_path):
32
+ return None
33
+ img = cv2.imread(image_path)
34
+ if img is None:
35
+ return None
36
+
37
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
38
+
39
+ cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
40
+ if not os.path.exists(cascade_path):
41
+ print(f"ERROR: Haar Cascade file not found at {cascade_path}")
42
+ return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
43
+
44
+ face_cascade = cv2.CascadeClassifier(cascade_path)
45
+ faces = face_cascade.detectMultiScale(gray, 1.3, 5)
46
+
47
+ if len(faces) == 0:
48
+ return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
49
+
50
+ x, y, w, h = max(faces, key=lambda f: f[2] * f[3])
51
+ padding = int(0.3 * w)
52
+ x, y = max(0, x - padding), max(0, y - padding)
53
+ w, h = min(img.shape[1] - x, w + 2 * padding), min(img.shape[0] - y, h + 2 * padding)
54
+
55
+ cropped = img[y:y+h, x:x+w]
56
+ return cv2.cvtColor(cropped, cv2.COLOR_BGR2RGB)
57
+
58
+ def initialize_experiment():
59
+ """Scans folders for images, creates dummy files if needed, and prepares the experiment state."""
60
+ # Create demo folders/images if missing
61
+ os.makedirs(AI_FOLDER, exist_ok=True)
62
+ os.makedirs(HUMAN_FOLDER, exist_ok=True)
63
+
64
+ images = []
65
+ emotions = set()
66
+
67
+ for folder, source in [(AI_FOLDER, "AI"), (HUMAN_FOLDER, "Human")]:
68
+ if not os.path.exists(folder):
69
+ continue
70
+ for filename in os.listdir(folder):
71
+ if filename.lower().endswith(('.jpg', '.jpeg', '.png')):
72
+ parts = os.path.splitext(filename)[0].split('_')
73
+ if len(parts) < 2:
74
+ continue
75
+ emotion = parts[-1].lower()
76
+ emotions.add(emotion)
77
+ path = os.path.join(folder, filename)
78
+ images.append(ImageData(path, source, emotion))
79
+
80
+ if not images:
81
+ return None, "Error: No images found. Please add images to 'AI' and 'Human' folders with names like 'name_emotion.jpg'"
82
+
83
+ random.shuffle(images)
84
+ sorted_emotions = sorted(list(emotions))
85
+ # we only have 4 buttons; trim if more
86
+ sorted_emotions = sorted_emotions[:4] if sorted_emotions else ["happy", "sad", "angry", "surprised"]
87
+
88
+ initial_state = {
89
+ "user_id": str(uuid.uuid4()),
90
+ "all_images": images,
91
+ "emotions": sorted_emotions,
92
+ "current_index": -1,
93
+ "start_time": None
94
+ }
95
+
96
+ # Create the CSV file with headers if it doesn't exist
97
+ if not os.path.exists(CSV_FILE):
98
+ with open(CSV_FILE, 'w', newline='') as f:
99
+ writer = csv.writer(f)
100
+ writer.writerow([
101
+ 'user_id', 'image_name', 'image_source', 'correct_emotion',
102
+ 'selected_emotion', 'response_time_s', 'timestamp'
103
+ ])
104
+
105
+ return initial_state, ""
106
+
107
+ def start_interface(state):
108
+ """Hides instructions and shows the main experiment UI."""
109
+ num_emotions = len(state["emotions"])
110
+ button_updates = [gr.update(visible=True, value=state["emotions"][i]) for i in range(num_emotions)]
111
+ button_updates += [gr.update(visible=False)] * (4 - num_emotions) # Hide unused buttons
112
+
113
+ return (
114
+ gr.update(visible=False), # instructions_section
115
+ gr.update(visible=False), # start_btn
116
+ gr.update(visible=True), # main_section
117
+ gr.update(visible=True), # emotion_buttons_row
118
+ *button_updates
119
+ )
120
+
121
+ def show_next_image(state):
122
+ """Loads the next image and updates the state."""
123
+ state["current_index"] += 1
124
+ index = state["current_index"]
125
+
126
+ num_emotions = len(state["emotions"])
127
+
128
+ if index >= len(state["all_images"]):
129
+ btn_updates = [gr.update(visible=False, interactive=False)] * 4
130
+ return (
131
+ state,
132
+ None,
133
+ "Experiment complete! Thank you for participating.",
134
+ gr.update(visible=False), # next_image_btn
135
+ gr.update(visible=False), # emotion_buttons_row
136
+ *btn_updates
137
+ )
138
+
139
+ image_data = state["all_images"][index]
140
+ cropped_image = crop_face(image_data.path)
141
+
142
+ if cropped_image is None:
143
+ btn_updates = [gr.update(visible=False, interactive=False)] * 4
144
+ return (
145
+ state,
146
+ None,
147
+ f"Error loading image: {image_data.name}",
148
+ gr.update(visible=True), # show Next so user can skip the broken one
149
+ gr.update(visible=False),
150
+ *btn_updates
151
+ )
152
+
153
+ state["start_time"] = time.time()
154
+ print(f"[DEBUG] Showing image {index+1}/{len(state['all_images'])}: {image_data.name}")
155
+
156
+ # Enable only the number of active emotion buttons
157
+ button_interactivity = [gr.update(visible=True, interactive=True)] * num_emotions
158
+ button_interactivity += [gr.update(visible=False, interactive=False)] * (4 - num_emotions)
159
+
160
+ return (
161
+ state,
162
+ cropped_image,
163
+ f"Image {index + 1} of {len(state['all_images'])}",
164
+ gr.update(visible=False), # hide Next until a choice is made
165
+ gr.update(visible=True), # show emotion buttons row
166
+ *button_interactivity
167
+ )
168
+
169
+ def on_emotion_click(state, selected_emotion):
170
+ """Handles emotion button click and records data, then shows Next."""
171
+ # Try to save; don't let errors block UI updates
172
+ try:
173
+ response_time = time.time() - (state.get("start_time") or time.time())
174
+ image_data = state["all_images"][state["current_index"]]
175
+ with open(CSV_FILE, 'a', newline='') as f:
176
+ writer = csv.writer(f)
177
+ writer.writerow([
178
+ state["user_id"], image_data.name, image_data.source, image_data.emotion,
179
+ selected_emotion, f"{response_time:.4f}", datetime.now().isoformat()
180
+ ])
181
+ print(f"[DEBUG] Clicked '{selected_emotion}' for {image_data.name} in {response_time:.3f}s")
182
+ except Exception as e:
183
+ print("-----------!! ERROR: Could not save data to CSV. !!-----------")
184
+ print(e)
185
+ print("----------------------------------------------------------------")
186
+
187
+ # Disable buttons and reveal Next
188
+ num_emotions = len(state["emotions"])
189
+ button_interactivity = [gr.update(interactive=False)] * num_emotions
190
+ button_interactivity += [gr.update()] * (4 - num_emotions)
191
+
192
+ return (
193
+ gr.update(visible=False), # emotion_buttons_row
194
+ gr.update(visible=True), # next_image_btn
195
+ *button_interactivity
196
+ )
197
+
198
+ def on_emotion_click_idx(state, idx):
199
+ """Map a fixed button index to an emotion label."""
200
+ # Guard in case fewer than 4 emotions exist
201
+ if idx >= len(state["emotions"]):
202
+ print(f"[DEBUG] Ignored click for idx {idx}; only {len(state['emotions'])} emotions configured.")
203
+ return gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
204
+ selected_emotion = state["emotions"][idx]
205
+ return on_emotion_click(state, selected_emotion)
206
+
207
+ # --- Gradio UI Layout ---
208
+ with gr.Blocks(theme=gr.themes.Soft()) as app:
209
+ state = gr.State()
210
+
211
+ gr.Markdown("# Face Emotion Recognition Study")
212
+
213
+ with gr.Column(visible=True) as instructions_section:
214
+ gr.Markdown(
215
+ """
216
+ ## Instructions
217
+ 1. An image of a face will appear. It will start very blurry.
218
+ 2. The image will gradually become clear over 10 seconds.
219
+ 3. As soon as you recognize the emotion, click the corresponding button below.
220
+ 4. The image will become fully clear, and a "Next Image" button will appear.
221
+ 5. Click "Next Image" to continue the study.
222
+
223
+ **Please respond as quickly and accurately as you can. Your response time is being measured.**
224
+ """
225
+ )
226
+ start_btn = gr.Button("START STUDY", variant="primary")
227
+ status_text = gr.Markdown("")
228
+
229
+ with gr.Column(visible=False) as main_section:
230
+ image_display = gr.Image(label="", elem_id="image_display", height=400, width=400, interactive=False)
231
+ progress_text = gr.Markdown("")
232
+
233
+ with gr.Row(visible=False) as emotion_buttons_row:
234
+ emotion_btn_1 = gr.Button(size="lg", interactive=True)
235
+ emotion_btn_2 = gr.Button(size="lg", interactive=True)
236
+ emotion_btn_3 = gr.Button(size="lg", interactive=True)
237
+ emotion_btn_4 = gr.Button(size="lg", interactive=True)
238
+ emotion_buttons = [emotion_btn_1, emotion_btn_2, emotion_btn_3, emotion_btn_4]
239
+
240
+ next_image_btn = gr.Button("Next Image ▶", variant="secondary", visible=False)
241
+
242
+ # --- Event Handlers ---
243
+ app.load(
244
+ fn=initialize_experiment,
245
+ outputs=[state, status_text]
246
+ ).then(
247
+ fn=None,
248
+ js=f"""() => {{
249
+ // define animation helpers once per session
250
+ window.animationFrameId = null;
251
+ window.deblurImage = function() {{
252
+ const img = document.querySelector("#image_display img");
253
+ if (!img) return;
254
+ const duration = {DEBLUR_DURATION_S * 1000};
255
+ const initialBlur = 20;
256
+ let startTime = null;
257
+ function animate(currentTime) {{
258
+ if (!startTime) startTime = currentTime;
259
+ const elapsedTime = currentTime - startTime;
260
+ const progress = Math.min(elapsedTime / duration, 1);
261
+ const currentBlur = initialBlur * (1 - progress);
262
+ img.style.filter = 'blur(' + currentBlur + 'px)';
263
+ if (progress < 1) {{
264
+ window.animationFrameId = requestAnimationFrame(animate);
265
+ }}
266
+ }}
267
+ cancelAnimationFrame(window.animationFrameId);
268
+ const img2 = document.querySelector("#image_display img");
269
+ if (img2) img2.style.filter = 'blur(' + initialBlur + 'px)';
270
+ window.animationFrameId = requestAnimationFrame(animate);
271
+ }};
272
+ window.unblurImmediately = function() {{
273
+ cancelAnimationFrame(window.animationFrameId);
274
+ const img = document.querySelector("#image_display img");
275
+ if (img) img.style.filter = 'blur(0px)';
276
+ }};
277
+ }}"""
278
+ )
279
+
280
+ start_btn.click(
281
+ fn=start_interface,
282
+ inputs=[state],
283
+ outputs=[instructions_section, start_btn, main_section, emotion_buttons_row, *emotion_buttons]
284
+ ).then(
285
+ fn=show_next_image,
286
+ inputs=[state],
287
+ outputs=[state, image_display, progress_text, next_image_btn, emotion_buttons_row, *emotion_buttons]
288
+ ).then(
289
+ fn=None,
290
+ js="() => window.deblurImage()"
291
+ )
292
+
293
+ # IMPORTANT: bind JS + Python in the SAME click call (no .then)
294
+ emotion_btn_1.click(
295
+ fn=lambda s: on_emotion_click_idx(s, 0),
296
+ inputs=[state],
297
+ outputs=[emotion_buttons_row, next_image_btn, *emotion_buttons],
298
+ js="() => window.unblurImmediately()"
299
+ )
300
+ emotion_btn_2.click(
301
+ fn=lambda s: on_emotion_click_idx(s, 1),
302
+ inputs=[state],
303
+ outputs=[emotion_buttons_row, next_image_btn, *emotion_buttons],
304
+ js="() => window.unblurImmediately()"
305
+ )
306
+ emotion_btn_3.click(
307
+ fn=lambda s: on_emotion_click_idx(s, 2),
308
+ inputs=[state],
309
+ outputs=[emotion_buttons_row, next_image_btn, *emotion_buttons],
310
+ js="() => window.unblurImmediately()"
311
+ )
312
+ emotion_btn_4.click(
313
+ fn=lambda s: on_emotion_click_idx(s, 3),
314
+ inputs=[state],
315
+ outputs=[emotion_buttons_row, next_image_btn, *emotion_buttons],
316
+ js="() => window.unblurImmediately()"
317
+ )
318
+
319
+ next_image_btn.click(
320
+ fn=show_next_image,
321
+ inputs=[state],
322
+ outputs=[state, image_display, progress_text, next_image_btn, emotion_buttons_row, *emotion_buttons]
323
+ ).then(
324
+ fn=None,
325
+ js="() => window.deblurImage()"
326
+ )
327
+
328
+ if __name__ == "__main__":
329
+ print("Starting Gradio app...")
330
+ print("Please create two folders: './AI' and './Human'")
331
+ print("Place images in them named like 'any_name_happy.jpg', 'some_face_sad.png', etc.")
332
+ app.launch()
env.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: facelab
2
+ channels:
3
+ - conda-forge
4
+ - defaults
5
+ dependencies:
6
+ - python=3.10
7
+ - pip
8
+ - pip:
9
+ - gradio>=4.0.0
10
+ - opencv-python>=4.8.0
11
+ - numpy>=1.24.0
12
+ - pillow>=10.0.0