mbarnig commited on
Commit
f204d24
·
verified ·
1 Parent(s): 0d4fd33

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +328 -0
  2. requirements.txt +2 -0
app.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ # Quiz “Classement par étiquettes” – labels configurables via assets/labels.json
3
+ # Gradio app pour Hugging Face Spaces
4
+
5
+ import gradio as gr
6
+ import os, json, random, glob
7
+ from typing import List, Dict, Any
8
+ from PIL import Image, ImageDraw, ImageFont
9
+
10
+ IMAGE_DIR = os.getenv("IMAGE_DIR", "assets")
11
+ TEXTS_PATH = os.getenv("TEXTS_PATH", os.path.join(IMAGE_DIR, "texts.json"))
12
+ LABELS_PATH = os.path.join(IMAGE_DIR, "labels.json")
13
+ IMG_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif")
14
+
15
+ DEFAULT_LABELS = [f"étiquette {i}" for i in range(1, 25)]
16
+ def load_labels() -> List[str]:
17
+ try:
18
+ if os.path.exists(LABELS_PATH):
19
+ with open(LABELS_PATH, "r", encoding="utf-8") as f:
20
+ data = json.load(f)
21
+ if isinstance(data, list) and all(isinstance(x, str) for x in data) and data:
22
+ return data
23
+ elif isinstance(data, dict) and isinstance(data.get("labels"), list):
24
+ labels = data["labels"]
25
+ if all(isinstance(x, str) for x in labels) and labels:
26
+ return labels
27
+ except Exception as e:
28
+ print("[warn] labels.json illisible:", e)
29
+ return DEFAULT_LABELS
30
+
31
+ LABELS = load_labels()
32
+
33
+ _env_n_images = os.getenv("N_IMAGES")
34
+ N_IMAGES = int(_env_n_images) if _env_n_images else len(LABELS)
35
+
36
+ DEFAULT_TEXTS = {
37
+ "title": "Classez les images par étiquette",
38
+ "instructions": "Pour chaque image, choisissez une **étiquette** dans le menu déroulant, puis cliquez sur **Valider mes choix**.",
39
+ "btn_shuffle": "🔀 Mélanger l’ordre",
40
+ "btn_reset": "♻️ Réinitialiser les choix",
41
+ "btn_submit": "✅ Valider mes choix",
42
+ "results_title": "Résultats",
43
+ "results_score_prefix": "Score : ",
44
+ "results_table_headers": ["#", "Fichier", "Étiquette correcte", "Votre étiquette", "Créateur", "Objet", "Titre", "✓"],
45
+ "results_gallery_ok": "Réponses correctes",
46
+ "results_gallery_ko": "Réponses incorrectes",
47
+ "btn_again_same": "↩️ Rejouer (même ordre)",
48
+ "btn_again_shuffle": "🔁 Rejouer & mélanger",
49
+ "warn_missing": "❗ Merci de sélectionner une étiquette pour les **{missing}** élément(s)."
50
+ }
51
+
52
+ def _centered_multiline(draw: ImageDraw.ImageDraw, xy, text: str, font: ImageFont.ImageFont, img_w: int):
53
+ x, y = xy
54
+ line_h = int(font.size * 1.2)
55
+ for i, line in enumerate(text.split("\n")):
56
+ bbox = draw.textbbox((0, 0), line, font=font)
57
+ w = bbox[2] - bbox[0]
58
+ draw.text(((img_w - w) // 2, y + i * line_h), line, fill=(0, 0, 0), font=font)
59
+
60
+ def load_texts():
61
+ try:
62
+ if os.path.exists(TEXTS_PATH):
63
+ with open(TEXTS_PATH, "r", encoding="utf-8") as f:
64
+ data = json.load(f)
65
+ if isinstance(data, dict):
66
+ merged = DEFAULT_TEXTS.copy()
67
+ merged.update({k: v for k, v in data.items() if v is not None})
68
+ return merged
69
+ except Exception as e:
70
+ print("[warn] texts.json illisible:", e)
71
+ return DEFAULT_TEXTS
72
+
73
+ def generate_demo_assets():
74
+ os.makedirs(IMAGE_DIR, exist_ok=True)
75
+ files = [p for p in glob.glob(os.path.join(IMAGE_DIR, "*")) if p.lower().endswith(IMG_EXTS)]
76
+ if len(files) >= N_IMAGES and os.path.exists(os.path.join(IMAGE_DIR, "answer_key.json")):
77
+ if not os.path.exists(TEXTS_PATH):
78
+ with open(TEXTS_PATH, "w", encoding="utf-8") as f:
79
+ json.dump(DEFAULT_TEXTS, f, ensure_ascii=False, indent=2)
80
+ if not os.path.exists(LABELS_PATH):
81
+ with open(LABELS_PATH, "w", encoding="utf-8") as f:
82
+ json.dump(LABELS, f, ensure_ascii=False, indent=2)
83
+ return
84
+
85
+ print("[setup] Génération d’images de démonstration (étiquettes configurables)…")
86
+ w, h = 640, 640
87
+ try:
88
+ font = ImageFont.truetype("DejaVuSans-Bold.ttf", 40)
89
+ except Exception:
90
+ font = ImageFont.load_default()
91
+
92
+ rows = []
93
+ for i in range(N_IMAGES):
94
+ label = LABELS[i % len(LABELS)]
95
+ img = Image.new("RGB", (w, h), (230, 180 + (i*7) % 55, 180 + (i*11) % 60))
96
+ d = ImageDraw.Draw(img)
97
+ text = f"Image {i+1}\n{label}"
98
+ line_h = int(font.size * 1.2)
99
+ lines = text.split("\n")
100
+ total_h = line_h * len(lines)
101
+ y0 = (h - total_h) // 2
102
+ for j, line in enumerate(lines):
103
+ tw = d.textlength(line, font=font) if hasattr(d, "textlength") else d.textsize(line, font=font)[0]
104
+ d.text(((w - tw)//2, y0 + j*line_h), line, fill=(0,0,0), font=font)
105
+ fname = f"image_{i+1:02d}.png"
106
+ path = os.path.join(IMAGE_DIR, fname)
107
+ img.save(path)
108
+ rows.append({
109
+ "file": os.path.basename(path),
110
+ "label": label,
111
+ "creator": "DEMO",
112
+ "object": f"Image démo {i+1}",
113
+ "title": f"Démo {i+1:02d}",
114
+ "year": 2025,
115
+ "source": None,
116
+ "notes": "Généré automatiquement (étiquettes)"
117
+ })
118
+
119
+ with open(os.path.join(IMAGE_DIR, "answer_key.json"), "w", encoding="utf-8") as f:
120
+ json.dump(rows, f, ensure_ascii=False, indent=2)
121
+
122
+ if not os.path.exists(TEXTS_PATH):
123
+ with open(TEXTS_PATH, "w", encoding="utf-8") as f:
124
+ json.dump(DEFAULT_TEXTS, f, ensure_ascii=False, indent=2)
125
+
126
+ if not os.path.exists(LABELS_PATH):
127
+ with open(LABELS_PATH, "w", encoding="utf-8") as f:
128
+ json.dump(LABELS, f, ensure_ascii=False, indent=2)
129
+
130
+ def load_items():
131
+ os.makedirs(IMAGE_DIR, exist_ok=True)
132
+ generate_demo_assets()
133
+
134
+ files = [p for p in glob.glob(os.path.join(IMAGE_DIR, "*")) if p.lower().endswith(IMG_EXTS)]
135
+ files.sort()
136
+ if len(files) < N_IMAGES:
137
+ raise RuntimeError(f"Il faut au moins {N_IMAGES} images dans '{IMAGE_DIR}'. Trouvé : {len(files)}.")
138
+ files = files[:N_IMAGES]
139
+
140
+ answer_key_path = os.path.join(IMAGE_DIR, "answer_key.json")
141
+ meta_map = {}
142
+ if os.path.exists(answer_key_path):
143
+ try:
144
+ with open(answer_key_path, "r", encoding="utf-8") as f:
145
+ data = json.load(f)
146
+ if isinstance(data, dict):
147
+ for k, v in data.items():
148
+ meta_map[os.path.basename(k)] = {
149
+ "label": str(v),
150
+ "creator": None, "object": None, "title": None,
151
+ "year": None, "source": None, "notes": None,
152
+ }
153
+ elif isinstance(data, list):
154
+ for row in data:
155
+ fname = (row.get("file") or row.get("name") or row.get("path") or "").strip()
156
+ if not fname:
157
+ continue
158
+ meta_map[os.path.basename(fname)] = {
159
+ "label": row.get("label"),
160
+ "creator": row.get("creator"),
161
+ "object": row.get("object") or row.get("subject"),
162
+ "title": row.get("title"),
163
+ "year": row.get("year"),
164
+ "source": row.get("source"),
165
+ "notes": row.get("notes"),
166
+ }
167
+ except Exception as e:
168
+ print("[warn] Impossible de lire answer_key.json :", e)
169
+
170
+ items = []
171
+ for p in files:
172
+ fname = os.path.basename(p)
173
+ meta = meta_map.get(fname, {})
174
+ truth = meta.get("label") or ""
175
+ if truth not in LABELS:
176
+ idx = len(items) % len(LABELS)
177
+ truth = LABELS[idx]
178
+ items.append({
179
+ "path": p,
180
+ "file": fname,
181
+ "truth": truth,
182
+ "creator": meta.get("creator"),
183
+ "object": meta.get("object"),
184
+ "title": meta.get("title"),
185
+ "year": meta.get("year"),
186
+ "source": meta.get("source"),
187
+ "notes": meta.get("notes"),
188
+ })
189
+ return items
190
+
191
+ TEXTS = load_texts()
192
+ ITEMS = load_items()
193
+
194
+ COLS = 4
195
+
196
+ def build_interface(items, texts):
197
+ labels = LABELS
198
+ with gr.Blocks(theme=gr.themes.Soft(), css="""
199
+ .quiz-grid .gr-image {max-height: 220px}
200
+ .score {font-size: 1.2rem; font-weight: 700}
201
+ """) as demo:
202
+ gr.Markdown(f"""
203
+ # {texts['title']}
204
+ {texts['instructions']}
205
+ """)
206
+
207
+ state_items = gr.State(items)
208
+
209
+ with gr.Group(visible=True) as quiz_group:
210
+ with gr.Row():
211
+ btn_shuffle = gr.Button(texts["btn_shuffle"])
212
+ btn_reset = gr.Button(texts["btn_reset"])
213
+ btn_submit = gr.Button(texts["btn_submit"], variant="primary")
214
+
215
+ warn_md = gr.Markdown(visible=False)
216
+
217
+ image_comps = []
218
+ dropdown_comps = []
219
+
220
+ rows = (len(items) + COLS - 1) // COLS
221
+ idx = 0
222
+ with gr.Column(elem_classes=["quiz-grid"]):
223
+ for r in range(rows):
224
+ with gr.Row():
225
+ for c in range(COLS):
226
+ if idx >= len(items):
227
+ break
228
+ with gr.Column():
229
+ img = gr.Image(value=items[idx]["path"], label=f"Image {idx+1}", interactive=False)
230
+ image_comps.append(img)
231
+ dd = gr.Dropdown(choices=labels, label="Votre choix", value=None)
232
+ dropdown_comps.append(dd)
233
+ idx += 1
234
+
235
+ with gr.Group(visible=False) as result_group:
236
+ gr.Markdown(f"## {texts['results_title']}")
237
+ score_md = gr.Markdown(elem_classes=["score"])
238
+ df = gr.Dataframe(headers=texts["results_table_headers"], row_count=(len(items), "fixed"), interactive=False)
239
+ with gr.Row():
240
+ gallery_ok = gr.Gallery(label=texts["results_gallery_ok"], columns=6, height=180)
241
+ gallery_ko = gr.Gallery(label=texts["results_gallery_ko"], columns=6, height=180)
242
+ with gr.Row():
243
+ btn_again_same = gr.Button(texts["btn_again_same"])
244
+ btn_again_shuffle = gr.Button(texts["btn_again_shuffle"])
245
+
246
+ def on_shuffle(state):
247
+ items = list(state)
248
+ random.shuffle(items)
249
+ img_updates = [gr.update(value=items[i]["path"], label=f"Image {i+1}") for i in range(len(items))]
250
+ dd_updates = [gr.update(value=None, choices=labels) for _ in range(len(items))]
251
+ warn_upd = gr.update(value="", visible=False)
252
+ return [*img_updates, *dd_updates, warn_upd, items]
253
+
254
+ btn_shuffle.click(on_shuffle, inputs=[state_items], outputs=[*image_comps, *dropdown_comps, warn_md, state_items])
255
+
256
+ def on_reset():
257
+ return [gr.update(value=None) for _ in range(len(items))] + [gr.update(value="", visible=False)]
258
+
259
+ btn_reset.click(on_reset, inputs=None, outputs=[*dropdown_comps, warn_md])
260
+
261
+ def on_submit(*args):
262
+ state = args[-1]
263
+ answers = list(args[:-1])
264
+ if any(a in (None, "") for a in answers):
265
+ missing = sum(1 for a in answers if a in (None, ""))
266
+ msg = texts["warn_missing"].format(missing=missing)
267
+ return (
268
+ gr.update(value="", visible=False),
269
+ gr.update(value=None),
270
+ gr.update(value=None),
271
+ gr.update(value=None),
272
+ gr.update(visible=True),
273
+ gr.update(visible=False),
274
+ gr.update(value=msg, visible=True),
275
+ )
276
+
277
+ items = list(state)
278
+ rows = []
279
+ ok_imgs, ko_imgs = [], []
280
+ ok = 0
281
+ for i, choice in enumerate(answers):
282
+ it = items[i]
283
+ truth = it["truth"]
284
+ path = it["path"]
285
+ is_ok = (choice == truth)
286
+ ok += 1 if is_ok else 0
287
+ rows.append([
288
+ i+1,
289
+ it["file"],
290
+ truth,
291
+ choice,
292
+ (it.get("creator") or "—"),
293
+ (it.get("object") or "—"),
294
+ (it.get("title") or "—"),
295
+ "✅" if is_ok else "❌",
296
+ ])
297
+ (ok_imgs if is_ok else ko_imgs).append(path)
298
+ score_txt = f"**{texts['results_score_prefix']}{ok}/{len(items)} ({round(100*ok/len(items))}%)**"
299
+
300
+ return (
301
+ gr.update(value=score_txt, visible=True),
302
+ gr.update(value=rows),
303
+ gr.update(value=ok_imgs),
304
+ gr.update(value=ko_imgs),
305
+ gr.update(visible=False),
306
+ gr.update(visible=True),
307
+ gr.update(value="", visible=False),
308
+ )
309
+
310
+ btn_submit.click(on_submit, inputs=[*dropdown_comps, state_items], outputs=[score_md, df, gallery_ok, gallery_ko, quiz_group, result_group, warn_md])
311
+
312
+ def restart(state, do_shuffle: bool):
313
+ items = list(state)
314
+ if do_shuffle:
315
+ random.shuffle(items)
316
+ img_updates = [gr.update(value=items[i]["path"], label=f"Image {i+1}") for i in range(len(items))]
317
+ dd_updates = [gr.update(value=None, choices=labels) for _ in range(len(items))]
318
+ return [*img_updates, *dd_updates, gr.update(visible=True), gr.update(visible=False), items]
319
+
320
+ btn_again_same.click(lambda state: restart(state, False), inputs=[state_items], outputs=[*image_comps, *dropdown_comps, quiz_group, result_group, state_items])
321
+ btn_again_shuffle.click(lambda state: restart(state, True), inputs=[state_items], outputs=[*image_comps, *dropdown_comps, quiz_group, result_group, state_items])
322
+
323
+ return demo
324
+
325
+ demo = build_interface(ITEMS, TEXTS)
326
+
327
+ if __name__ == "__main__":
328
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio>=4.39.0
2
+ pillow>=10.0.0