mbarnig's picture
Upload 2 files
f204d24 verified
# 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()