# app.py # Quiz “Classement par étiquettes” – labels configurables via assets/labels.json # Gradio app pour Hugging Face Spaces import gradio as gr import os, json, random, glob from typing import List, Dict, Any from PIL import Image, ImageDraw, ImageFont IMAGE_DIR = os.getenv("IMAGE_DIR", "assets") TEXTS_PATH = os.getenv("TEXTS_PATH", os.path.join(IMAGE_DIR, "texts.json")) LABELS_PATH = os.path.join(IMAGE_DIR, "labels.json") IMG_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif") DEFAULT_LABELS = [f"étiquette {i}" for i in range(1, 25)] def load_labels() -> List[str]: try: if os.path.exists(LABELS_PATH): with open(LABELS_PATH, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, list) and all(isinstance(x, str) for x in data) and data: return data elif isinstance(data, dict) and isinstance(data.get("labels"), list): labels = data["labels"] if all(isinstance(x, str) for x in labels) and labels: return labels except Exception as e: print("[warn] labels.json illisible:", e) return DEFAULT_LABELS LABELS = load_labels() _env_n_images = os.getenv("N_IMAGES") N_IMAGES = int(_env_n_images) if _env_n_images else len(LABELS) DEFAULT_TEXTS = { "title": "Classez les images par étiquette", "instructions": "Pour chaque image, choisissez une **étiquette** dans le menu déroulant, puis cliquez sur **Valider mes choix**.", "btn_shuffle": "🔀 Mélanger l’ordre", "btn_reset": "♻️ Réinitialiser les choix", "btn_submit": "✅ Valider mes choix", "results_title": "Résultats", "results_score_prefix": "Score : ", "results_table_headers": ["#", "Fichier", "Étiquette correcte", "Votre étiquette", "Créateur", "Objet", "Titre", "✓"], "results_gallery_ok": "Réponses correctes", "results_gallery_ko": "Réponses incorrectes", "btn_again_same": "↩️ Rejouer (même ordre)", "btn_again_shuffle": "🔁 Rejouer & mélanger", "warn_missing": "❗ Merci de sélectionner une étiquette pour les **{missing}** élément(s)." } def _centered_multiline(draw: ImageDraw.ImageDraw, xy, text: str, font: ImageFont.ImageFont, img_w: int): x, y = xy line_h = int(font.size * 1.2) for i, line in enumerate(text.split("\n")): bbox = draw.textbbox((0, 0), line, font=font) w = bbox[2] - bbox[0] draw.text(((img_w - w) // 2, y + i * line_h), line, fill=(0, 0, 0), font=font) def load_texts(): try: if os.path.exists(TEXTS_PATH): with open(TEXTS_PATH, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): merged = DEFAULT_TEXTS.copy() merged.update({k: v for k, v in data.items() if v is not None}) return merged except Exception as e: print("[warn] texts.json illisible:", e) return DEFAULT_TEXTS def generate_demo_assets(): os.makedirs(IMAGE_DIR, exist_ok=True) files = [p for p in glob.glob(os.path.join(IMAGE_DIR, "*")) if p.lower().endswith(IMG_EXTS)] if len(files) >= N_IMAGES and os.path.exists(os.path.join(IMAGE_DIR, "answer_key.json")): if not os.path.exists(TEXTS_PATH): with open(TEXTS_PATH, "w", encoding="utf-8") as f: json.dump(DEFAULT_TEXTS, f, ensure_ascii=False, indent=2) if not os.path.exists(LABELS_PATH): with open(LABELS_PATH, "w", encoding="utf-8") as f: json.dump(LABELS, f, ensure_ascii=False, indent=2) return print("[setup] Génération d’images de démonstration (étiquettes configurables)…") w, h = 640, 640 try: font = ImageFont.truetype("DejaVuSans-Bold.ttf", 40) except Exception: font = ImageFont.load_default() rows = [] for i in range(N_IMAGES): label = LABELS[i % len(LABELS)] img = Image.new("RGB", (w, h), (230, 180 + (i*7) % 55, 180 + (i*11) % 60)) d = ImageDraw.Draw(img) text = f"Image {i+1}\n{label}" line_h = int(font.size * 1.2) lines = text.split("\n") total_h = line_h * len(lines) y0 = (h - total_h) // 2 for j, line in enumerate(lines): tw = d.textlength(line, font=font) if hasattr(d, "textlength") else d.textsize(line, font=font)[0] d.text(((w - tw)//2, y0 + j*line_h), line, fill=(0,0,0), font=font) fname = f"image_{i+1:02d}.png" path = os.path.join(IMAGE_DIR, fname) img.save(path) rows.append({ "file": os.path.basename(path), "label": label, "creator": "DEMO", "object": f"Image démo {i+1}", "title": f"Démo {i+1:02d}", "year": 2025, "source": None, "notes": "Généré automatiquement (étiquettes)" }) with open(os.path.join(IMAGE_DIR, "answer_key.json"), "w", encoding="utf-8") as f: json.dump(rows, f, ensure_ascii=False, indent=2) if not os.path.exists(TEXTS_PATH): with open(TEXTS_PATH, "w", encoding="utf-8") as f: json.dump(DEFAULT_TEXTS, f, ensure_ascii=False, indent=2) if not os.path.exists(LABELS_PATH): with open(LABELS_PATH, "w", encoding="utf-8") as f: json.dump(LABELS, f, ensure_ascii=False, indent=2) def load_items(): os.makedirs(IMAGE_DIR, exist_ok=True) generate_demo_assets() files = [p for p in glob.glob(os.path.join(IMAGE_DIR, "*")) if p.lower().endswith(IMG_EXTS)] files.sort() if len(files) < N_IMAGES: raise RuntimeError(f"Il faut au moins {N_IMAGES} images dans '{IMAGE_DIR}'. Trouvé : {len(files)}.") files = files[:N_IMAGES] answer_key_path = os.path.join(IMAGE_DIR, "answer_key.json") meta_map = {} if os.path.exists(answer_key_path): try: with open(answer_key_path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): for k, v in data.items(): meta_map[os.path.basename(k)] = { "label": str(v), "creator": None, "object": None, "title": None, "year": None, "source": None, "notes": None, } elif isinstance(data, list): for row in data: fname = (row.get("file") or row.get("name") or row.get("path") or "").strip() if not fname: continue meta_map[os.path.basename(fname)] = { "label": row.get("label"), "creator": row.get("creator"), "object": row.get("object") or row.get("subject"), "title": row.get("title"), "year": row.get("year"), "source": row.get("source"), "notes": row.get("notes"), } except Exception as e: print("[warn] Impossible de lire answer_key.json :", e) items = [] for p in files: fname = os.path.basename(p) meta = meta_map.get(fname, {}) truth = meta.get("label") or "" if truth not in LABELS: idx = len(items) % len(LABELS) truth = LABELS[idx] items.append({ "path": p, "file": fname, "truth": truth, "creator": meta.get("creator"), "object": meta.get("object"), "title": meta.get("title"), "year": meta.get("year"), "source": meta.get("source"), "notes": meta.get("notes"), }) return items TEXTS = load_texts() ITEMS = load_items() COLS = 4 def build_interface(items, texts): labels = LABELS with gr.Blocks(theme=gr.themes.Soft(), css=""" .quiz-grid .gr-image {max-height: 220px} .score {font-size: 1.2rem; font-weight: 700} """) as demo: gr.Markdown(f""" # {texts['title']} {texts['instructions']} """) state_items = gr.State(items) with gr.Group(visible=True) as quiz_group: with gr.Row(): btn_shuffle = gr.Button(texts["btn_shuffle"]) btn_reset = gr.Button(texts["btn_reset"]) btn_submit = gr.Button(texts["btn_submit"], variant="primary") warn_md = gr.Markdown(visible=False) image_comps = [] dropdown_comps = [] rows = (len(items) + COLS - 1) // COLS idx = 0 with gr.Column(elem_classes=["quiz-grid"]): for r in range(rows): with gr.Row(): for c in range(COLS): if idx >= len(items): break with gr.Column(): img = gr.Image(value=items[idx]["path"], label=f"Image {idx+1}", interactive=False) image_comps.append(img) dd = gr.Dropdown(choices=labels, label="Votre choix", value=None) dropdown_comps.append(dd) idx += 1 with gr.Group(visible=False) as result_group: gr.Markdown(f"## {texts['results_title']}") score_md = gr.Markdown(elem_classes=["score"]) df = gr.Dataframe(headers=texts["results_table_headers"], row_count=(len(items), "fixed"), interactive=False) with gr.Row(): gallery_ok = gr.Gallery(label=texts["results_gallery_ok"], columns=6, height=180) gallery_ko = gr.Gallery(label=texts["results_gallery_ko"], columns=6, height=180) with gr.Row(): btn_again_same = gr.Button(texts["btn_again_same"]) btn_again_shuffle = gr.Button(texts["btn_again_shuffle"]) def on_shuffle(state): items = list(state) random.shuffle(items) img_updates = [gr.update(value=items[i]["path"], label=f"Image {i+1}") for i in range(len(items))] dd_updates = [gr.update(value=None, choices=labels) for _ in range(len(items))] warn_upd = gr.update(value="", visible=False) return [*img_updates, *dd_updates, warn_upd, items] btn_shuffle.click(on_shuffle, inputs=[state_items], outputs=[*image_comps, *dropdown_comps, warn_md, state_items]) def on_reset(): return [gr.update(value=None) for _ in range(len(items))] + [gr.update(value="", visible=False)] btn_reset.click(on_reset, inputs=None, outputs=[*dropdown_comps, warn_md]) def on_submit(*args): state = args[-1] answers = list(args[:-1]) if any(a in (None, "") for a in answers): missing = sum(1 for a in answers if a in (None, "")) msg = texts["warn_missing"].format(missing=missing) return ( gr.update(value="", visible=False), gr.update(value=None), gr.update(value=None), gr.update(value=None), gr.update(visible=True), gr.update(visible=False), gr.update(value=msg, visible=True), ) items = list(state) rows = [] ok_imgs, ko_imgs = [], [] ok = 0 for i, choice in enumerate(answers): it = items[i] truth = it["truth"] path = it["path"] is_ok = (choice == truth) ok += 1 if is_ok else 0 rows.append([ i+1, it["file"], truth, choice, (it.get("creator") or "—"), (it.get("object") or "—"), (it.get("title") or "—"), "✅" if is_ok else "❌", ]) (ok_imgs if is_ok else ko_imgs).append(path) score_txt = f"**{texts['results_score_prefix']}{ok}/{len(items)} ({round(100*ok/len(items))}%)**" return ( gr.update(value=score_txt, visible=True), gr.update(value=rows), gr.update(value=ok_imgs), gr.update(value=ko_imgs), gr.update(visible=False), gr.update(visible=True), gr.update(value="", visible=False), ) btn_submit.click(on_submit, inputs=[*dropdown_comps, state_items], outputs=[score_md, df, gallery_ok, gallery_ko, quiz_group, result_group, warn_md]) def restart(state, do_shuffle: bool): items = list(state) if do_shuffle: random.shuffle(items) img_updates = [gr.update(value=items[i]["path"], label=f"Image {i+1}") for i in range(len(items))] dd_updates = [gr.update(value=None, choices=labels) for _ in range(len(items))] return [*img_updates, *dd_updates, gr.update(visible=True), gr.update(visible=False), items] btn_again_same.click(lambda state: restart(state, False), inputs=[state_items], outputs=[*image_comps, *dropdown_comps, quiz_group, result_group, state_items]) btn_again_shuffle.click(lambda state: restart(state, True), inputs=[state_items], outputs=[*image_comps, *dropdown_comps, quiz_group, result_group, state_items]) return demo demo = build_interface(ITEMS, TEXTS) if __name__ == "__main__": demo.launch()