import gradio as gr import cv2 import numpy as np import os import random import time import csv import uuid from datetime import datetime # --- Configuration --- AI_FOLDER = "./AI" HUMAN_FOLDER = "./Human" CSV_FILE = "emotion_responses.csv" METADATA_FILE = "stimuli_metadata.csv" DEBLUR_DURATION_S = 5 # Seconds to go from Blur -> Clear # --- Advanced Features Config --- URL_PARAM_PARTICIPANT_ID = "pid" RANDOMIZE_EMOTION_ORDER_DEFAULT = True RANDOMIZE_EMOTION_ORDER_PARAM = "randomize" CHOICE_PLACEHOLDER = "Select an emotion..." # --- CSS STYLES --- APP_CSS = f""" #emotion_choice, #emotion_choice .wrap {{ max-height: 260px; overflow-y: auto; }} #next_btn {{ margin: 8px 0 12px 0; }} #start_btn, #start_btn button, #start_btn .gr-button, #next_btn, #next_btn button, #next_btn .gr-button {{ font-size: 20px !important; padding: 12px 22px !important; min-height: 48px !important; }} #emotion_choice label, #emotion_choice .wrap label, #emotion_choice .wrap span {{ font-size: 20px !important; }} #emotion_choice .wrap {{ display: flex !important; flex-direction: row !important; flex-wrap: wrap !important; justify-content: center !important; align-items: center !important; gap: 8px 12px; }} #emotion_choice .wrap label {{ justify-content: center; width: auto; margin: 4px 0; }} #emotion_choice input[type="radio"] {{ transform: scale(1.2); margin-right: 8px; }} #emotion_choice .wrap label {{ padding: 8px 12px !important; }} @media (max-width: 640px) {{ #img_anim img, #img_static img {{ max-height: 280px; object-fit: contain; }} }} /* --- ANIMATED IMAGE (The Test) --- */ /* 1. Start HEAVILY BLURRED by default */ #img_anim img {{ filter: blur(50px); display: block; transform: scale(1.0); }} /* 2. The JS adds this class to animate it to clear */ .image-clear {{ transition: filter {DEBLUR_DURATION_S}s linear !important; filter: blur(0px) !important; }} /* --- STATIC IMAGE (The Result) --- */ /* No special CSS needed. It will just be a normal, clear image. We ensure it aligns perfectly with the animated one. */ #img_static img {{ display: block; filter: blur(0px); }} #progress_text {{ font-size: 36px; text-align: center; line-height: 1.2; }} #app_title {{ text-align: center; margin-bottom: 16px; }} #app_title h1, #app_title p {{ font-weight: 700; font-size: 56px; margin: 0; }} """ # --- Constants & Mappings --- UNKNOWN_LABEL = "unknown" UNKNOWN_CODE = 0 FILENAME_FIELD_ORDER = ["emotion"] EMOTION_CODE_MAP = {"happy": 1, "sad": 2, "fearful": 3, "exuberant": 4, "unknown": 0} SEX_CODE_MAP = {"male": 1, "female": 2, "other": 3, "unknown": 0} ETHNICITY_CODE_MAP = {"caucasian": 1, "black": 2, "asian": 3, "latino": 4, "middle-eastern": 5, "indigenous": 6, "other": 7, "unknown": 0} ANGLE_CODE_MAP = {"forward": 1, "front-left": 2, "front-right": 3, "left": 4, "right": 5, "up": 6, "down": 7, "unknown": 0} TYPE_CODE_MAP = {"human": 1, "ai": 2, "unknown": 0} CSV_HEADERS = [ "participant_id", "session_id", "image_name", "image_source", "face_type", "face_type_code", "correct_emotion", "correct_emotion_code", "face_sex", "face_sex_code", "face_ethnicity", "face_ethnicity_code", "face_angle", "face_angle_code", "selected_emotion", "selected_emotion_code", "accuracy", "response_time_ms", "button_order", "timestamp", ] # --- Data Structure --- class ImageData: def __init__(self, path, source, emotion, sex=UNKNOWN_LABEL, ethnicity=UNKNOWN_LABEL, angle=UNKNOWN_LABEL, face_type=UNKNOWN_LABEL): self.path = path self.source = source self.emotion = emotion self.sex = sex self.ethnicity = ethnicity self.angle = angle self.face_type = face_type self.name = os.path.basename(path) # --- Helper Functions --- def normalize_label(value): if value is None: return "" return str(value).strip().lower().replace(" ", "-") def get_code(code_map, label): return code_map.get(normalize_label(label), UNKNOWN_CODE) def load_metadata(metadata_path): if not os.path.exists(metadata_path): return {} metadata = {} with open(metadata_path, newline='') as f: reader = csv.DictReader(f) for row in reader: name = row.get("image_name") or row.get("filename") or row.get("image") if not name: continue key = name.strip().lower() entry = { "emotion": normalize_label(row.get("emotion")), "sex": normalize_label(row.get("sex")), "ethnicity": normalize_label(row.get("ethnicity")), "angle": normalize_label(row.get("angle")), "face_type": normalize_label(row.get("face_type") or row.get("type") or row.get("source")), } metadata[key] = entry stem = os.path.splitext(key)[0] metadata.setdefault(stem, entry) return metadata def parse_filename_fields(image_path): base_name = os.path.splitext(os.path.basename(image_path))[0] parts = base_name.split('_') if len(parts) < 2: return {} fields = {} for field in FILENAME_FIELD_ORDER: if not parts: break fields[field] = normalize_label(parts.pop()) return fields def resolve_field(metadata, filename_fields, key, default=UNKNOWN_LABEL): value = "" if metadata: value = normalize_label(metadata.get(key)) if not value: value = filename_fields.get(key, "") return value or default def resolve_face_type(metadata, source): if metadata and metadata.get("face_type"): return normalize_label(metadata.get("face_type")) return normalize_label(source) def ensure_csv_file(): if not os.path.exists(CSV_FILE): with open(CSV_FILE, 'w', newline='') as f: writer = csv.writer(f) writer.writerow(CSV_HEADERS) return CSV_FILE, "" with open(CSV_FILE, newline='') as f: reader = csv.reader(f) existing_header = next(reader, None) if existing_header != CSV_HEADERS: base, ext = os.path.splitext(CSV_FILE) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") new_file = f"{base}_{timestamp}{ext or '.csv'}" with open(new_file, 'w', newline='') as f: writer = csv.writer(f) writer.writerow(CSV_HEADERS) return new_file, f"Using new results file: {new_file}" return CSV_FILE, "" def get_participant_id(request): if request is None: return "" pid = request.query_params.get(URL_PARAM_PARTICIPANT_ID) return str(pid).strip() if pid else "" def scan_images(): images = [] emotions = set() metadata = load_metadata(METADATA_FILE) skipped = [] for folder, source in [(AI_FOLDER, "AI"), (HUMAN_FOLDER, "Human")]: if not os.path.exists(folder): continue for filename in os.listdir(folder): if not filename.lower().endswith(('.jpg', '.jpeg', '.png')): continue path = os.path.join(folder, filename) meta_key = filename.lower() meta = metadata.get(meta_key) or metadata.get(os.path.splitext(meta_key)[0]) or {} filename_fields = parse_filename_fields(path) emotion = resolve_field(meta, filename_fields, "emotion", "") if not emotion or emotion == UNKNOWN_LABEL: skipped.append(filename) continue sex = resolve_field(meta, filename_fields, "sex", UNKNOWN_LABEL) ethnicity = resolve_field(meta, filename_fields, "ethnicity", UNKNOWN_LABEL) angle = resolve_field(meta, filename_fields, "angle", UNKNOWN_LABEL) face_type = resolve_face_type(meta, source) or UNKNOWN_LABEL emotions.add(emotion) images.append(ImageData(path, source, emotion, sex=sex, ethnicity=ethnicity, angle=angle, face_type=face_type)) if skipped: print(f"[DEBUG] Skipped {len(skipped)} images without emotion label.") return images, emotions def crop_face(image_path, target_size=512): if not os.path.exists(image_path): return None img = cv2.imread(image_path) if img is None: return None gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' cropped = img if os.path.exists(cascade_path): face_cascade = cv2.CascadeClassifier(cascade_path) faces = face_cascade.detectMultiScale(gray, 1.3, 5) if len(faces) > 0: x, y, w, h = max(faces, key=lambda f: f[2] * f[3]) padding = int(0.3 * w) x, y = max(0, x - padding), max(0, y - padding) w, h = min(img.shape[1] - x, w + 2 * padding), min(img.shape[0] - y, h + 2 * padding) cropped = img[y:y+h, x:x+w] h, w, _ = cropped.shape if h > w: new_h = target_size new_w = int(w * (target_size / h)) else: new_w = target_size new_h = int(h * (target_size / w)) resized_img = cv2.resize(cropped, (new_w, new_h), interpolation=cv2.INTER_AREA) canvas = np.zeros((target_size, target_size, 3), dtype=np.uint8) y_offset = (target_size - new_h) // 2 x_offset = (target_size - new_w) // 2 canvas[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = resized_img return cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB) # --- Backend Logic --- def initialize_experiment(request: gr.Request): os.makedirs(AI_FOLDER, exist_ok=True) os.makedirs(HUMAN_FOLDER, exist_ok=True) images, emotions = scan_images() if not images: return None, "Error: No images found.", gr.update(interactive=False) session_id = str(uuid.uuid4()) participant_id = get_participant_id(request) if not participant_id: participant_id = f"anon-{session_id}" msg = f"Participant ID: {participant_id}" else: msg = f"Participant ID: {participant_id}" csv_file, csv_status = ensure_csv_file() random.shuffle(images) initial_state = { "participant_id": participant_id, "session_id": session_id, "csv_file": csv_file, "all_images": images, "emotions": sorted(list(emotions)), "current_index": -1, "current_choices": [], "randomize_emotions": RANDOMIZE_EMOTION_ORDER_DEFAULT, "start_time": None, } if request: val = request.query_params.get(RANDOMIZE_EMOTION_ORDER_PARAM) if val and val.lower() in ['0','false','no']: initial_state["randomize_emotions"] = False return initial_state, f"{msg}\n{csv_status}", gr.update(interactive=True) def start_interface(state): if not state: return gr.update(visible=True), gr.update(visible=True), gr.update(visible=False) return gr.update(visible=False), gr.update(visible=False), gr.update(visible=True) def show_next_image(state): # Returns: [state, img_anim, img_static, progress_text, anim_visible, static_visible, choices_update] if not state: return state, None, None, "Error", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) state["current_index"] += 1 index = state["current_index"] if index >= len(state["all_images"]): return state, None, None, "# Experiment complete!", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) image_data = state["all_images"][index] cropped_image = crop_face(image_data.path) if cropped_image is None: # Recursive skip if image fails to load return show_next_image(state) state["start_time"] = time.monotonic() choices = list(state["emotions"]) if state.get("randomize_emotions"): choices = random.sample(choices, k=len(choices)) state["current_choices"] = choices choices_with_placeholder = [CHOICE_PLACEHOLDER] + choices return ( state, cropped_image, # For Animated Component cropped_image, # For Static Component f"Image {index + 1} of {len(state['all_images'])}", gr.update(visible=True, interactive=False), # Show Animated gr.update(visible=False), # Hide Static gr.update(choices=choices_with_placeholder, value=CHOICE_PLACEHOLDER, visible=True, interactive=True), ) def on_emotion_select(state, selected_emotion): # Returns: [anim_visible, static_visible, choices_interactive, next_btn_interactive] if not state or not selected_emotion or normalize_label(selected_emotion) == normalize_label(CHOICE_PLACEHOLDER): # Do nothing if placeholder selected return gr.update(), gr.update(), gr.update(), gr.update() try: start_time = state.get("start_time") or time.monotonic() response_time_ms = int(round((time.monotonic() - start_time) * 1000)) image_data = state["all_images"][state["current_index"]] normalized_sel = normalize_label(selected_emotion) accuracy = "correct" if normalized_sel == image_data.emotion else "incorrect" with open(state["csv_file"], 'a', newline='') as f: writer = csv.writer(f) writer.writerow([ state["participant_id"], state["session_id"], image_data.name, image_data.source, image_data.face_type, get_code(TYPE_CODE_MAP, image_data.face_type), image_data.emotion, get_code(EMOTION_CODE_MAP, image_data.emotion), image_data.sex, get_code(SEX_CODE_MAP, image_data.sex), image_data.ethnicity, get_code(ETHNICITY_CODE_MAP, image_data.ethnicity), image_data.angle, get_code(ANGLE_CODE_MAP, image_data.angle), normalized_sel, get_code(EMOTION_CODE_MAP, normalized_sel), accuracy, response_time_ms, "|".join(state.get("current_choices", [])), datetime.now().isoformat(), ]) print(f"[DEBUG] Saved {normalized_sel} ({response_time_ms}ms)") except Exception as e: print(f"Error saving CSV: {e}") # Hide Animated, Show Static (Snap), Disable Dropdown, Enable Next return gr.update(visible=False), gr.update(visible=True), gr.update(interactive=False), gr.update(interactive=True) # --- JAVASCRIPT --- # Logic: Find the animated image element, reset its class to remove 'image-clear', # force a reflow, then add 'image-clear' to start the transition. js_functions = """ () => { window.triggerDeblur = function() { const el = document.querySelector("#img_anim img"); if (el) { // 1. Reset to start state (Blurred) el.classList.remove('image-clear'); // 2. Force Browser Reflow (Crucial for restarting CSS animations) void el.offsetWidth; // 3. Start Animation setTimeout(() => { el.classList.add('image-clear'); }, 100); } }; } """ # --- Gradio App --- with gr.Blocks(theme=gr.themes.Soft(), css=APP_CSS) as app: state = gr.State() gr.Markdown("Face Emotion Recognition Study", elem_id="app_title") # 1. Landing Page with gr.Column(visible=True) as instructions_section: gr.Markdown(f"# Instructions\n ## Identify the emotion as the image becomes clear ({DEBLUR_DURATION_S}s).") start_btn = gr.Button("START STUDY", variant="primary", elem_id="start_btn") status_text = gr.Markdown("") # 2. Main Experiment Interface with gr.Column(visible=False) as main_section: # Image Stack: Two images occupy the same conceptual space with gr.Group(): # Animated Image: Visible initially, performs blur->clear image_anim = gr.Image(label="", elem_id="img_anim", height=400, width=400, interactive=False, show_label=False, visible=True) # Static Image: Hidden initially, shows instantly when user selects answer image_static = gr.Image(label="", elem_id="img_static", height=400, width=400, interactive=False, show_label=False, visible=False) progress_text = gr.Markdown("", elem_id="progress_text") # Controls emotion_choice = gr.Radio(choices=[], label="Select the emotion", visible=False, interactive=True, elem_id="emotion_choice") next_image_btn = gr.Button("Next Image ▶", variant="secondary", visible=True, interactive=False, elem_id="next_btn") # --- Event Wiring --- # App Load app.load(fn=initialize_experiment, outputs=[state, status_text, start_btn]).then(fn=None, js=js_functions) # Start Button -> Show Interface -> Load First Image -> Trigger Animation start_btn.click( fn=start_interface, inputs=[state], outputs=[instructions_section, start_btn, main_section] ).then( fn=show_next_image, inputs=[state], outputs=[state, image_anim, image_static, progress_text, image_anim, image_static, emotion_choice] ).then( fn=None, js="() => window.triggerDeblur()" ) # Emotion Selected -> Swap Images (Snap to Clear) -> Save Data emotion_choice.change( fn=on_emotion_select, inputs=[state, emotion_choice], outputs=[image_anim, image_static, emotion_choice, next_image_btn] ) # Next Button -> Load New Image -> Reset Layout -> Trigger Animation next_image_btn.click( fn=show_next_image, inputs=[state], outputs=[state, image_anim, image_static, progress_text, image_anim, image_static, emotion_choice] ).then( fn=None, js="() => window.triggerDeblur()" ) if __name__ == "__main__": app.launch()