Spaces:
Sleeping
Sleeping
| # 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() | |