|
|
import gradio as gr |
|
|
import cv2 |
|
|
import numpy as np |
|
|
import os |
|
|
import random |
|
|
import time |
|
|
import csv |
|
|
import uuid |
|
|
from datetime import datetime |
|
|
|
|
|
|
|
|
AI_FOLDER = "./AI" |
|
|
HUMAN_FOLDER = "./Human" |
|
|
CSV_FILE = "emotion_responses.csv" |
|
|
METADATA_FILE = "stimuli_metadata.csv" |
|
|
DEBLUR_DURATION_S = 5 |
|
|
|
|
|
|
|
|
URL_PARAM_PARTICIPANT_ID = "pid" |
|
|
RANDOMIZE_EMOTION_ORDER_DEFAULT = True |
|
|
RANDOMIZE_EMOTION_ORDER_PARAM = "randomize" |
|
|
CHOICE_PLACEHOLDER = "Select an emotion..." |
|
|
|
|
|
|
|
|
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; |
|
|
}} |
|
|
|
|
|
""" |
|
|
|
|
|
|
|
|
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", |
|
|
] |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
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): |
|
|
|
|
|
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: |
|
|
|
|
|
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, |
|
|
cropped_image, |
|
|
f"Image {index + 1} of {len(state['all_images'])}", |
|
|
gr.update(visible=True, interactive=False), |
|
|
gr.update(visible=False), |
|
|
gr.update(choices=choices_with_placeholder, value=CHOICE_PLACEHOLDER, visible=True, interactive=True), |
|
|
) |
|
|
|
|
|
def on_emotion_select(state, selected_emotion): |
|
|
|
|
|
if not state or not selected_emotion or normalize_label(selected_emotion) == normalize_label(CHOICE_PLACEHOLDER): |
|
|
|
|
|
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}") |
|
|
|
|
|
|
|
|
return gr.update(visible=False), gr.update(visible=True), gr.update(interactive=False), gr.update(interactive=True) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
}; |
|
|
} |
|
|
""" |
|
|
|
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
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("") |
|
|
|
|
|
|
|
|
with gr.Column(visible=False) as main_section: |
|
|
|
|
|
with gr.Group(): |
|
|
|
|
|
image_anim = gr.Image(label="", elem_id="img_anim", height=400, width=400, interactive=False, show_label=False, visible=True) |
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.load(fn=initialize_experiment, outputs=[state, status_text, start_btn]).then(fn=None, js=js_functions) |
|
|
|
|
|
|
|
|
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_choice.change( |
|
|
fn=on_emotion_select, |
|
|
inputs=[state, emotion_choice], |
|
|
outputs=[image_anim, image_static, emotion_choice, next_image_btn] |
|
|
) |
|
|
|
|
|
|
|
|
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() |
|
|
|