Spaces:
Sleeping
Sleeping
V3.1: QA automatique + confidence naming + manifest JSON téléchargeable
Browse files- Dockerfile +1 -1
- frontend/index.html +2 -0
- main.py +3 -0
- pipeline_v3_sam2.py +56 -3
- qa_manifest.py +182 -0
Dockerfile
CHANGED
|
@@ -10,7 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 10 |
COPY requirements.txt .
|
| 11 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
|
| 13 |
-
COPY pipeline.py main.py storage_r2.py painterly.py superpixels.py pipeline_v2.py pipeline_v3_sam2.py ./
|
| 14 |
COPY frontend /app/frontend
|
| 15 |
|
| 16 |
ENV STORAGE_DIR=/data
|
|
|
|
| 10 |
COPY requirements.txt .
|
| 11 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
|
| 13 |
+
COPY pipeline.py main.py storage_r2.py painterly.py superpixels.py pipeline_v2.py pipeline_v3_sam2.py qa_manifest.py ./
|
| 14 |
COPY frontend /app/frontend
|
| 15 |
|
| 16 |
ENV STORAGE_DIR=/data
|
frontend/index.html
CHANGED
|
@@ -329,10 +329,12 @@ async function pollJob(id) {
|
|
| 329 |
<div style="font-size:12px;color:#7a5a3a;margin-bottom:4px;margin-top:12px;">Aperçu illustration HD</div>
|
| 330 |
<img src="${hdUrl}">
|
| 331 |
`;
|
|
|
|
| 332 |
actionsEl.innerHTML = `
|
| 333 |
<a href="${photopeaUrl}" target="_blank" rel="noopener" class="primary">Ouvrir dans Photopea</a>
|
| 334 |
<a href="${psdUrl}" download class="dark">Télécharger PSD</a>
|
| 335 |
<a href="${hdUrl}" download class="secondary">Télécharger HD</a>
|
|
|
|
| 336 |
`;
|
| 337 |
window.open(photopeaUrl, "_blank");
|
| 338 |
break;
|
|
|
|
| 329 |
<div style="font-size:12px;color:#7a5a3a;margin-bottom:4px;margin-top:12px;">Aperçu illustration HD</div>
|
| 330 |
<img src="${hdUrl}">
|
| 331 |
`;
|
| 332 |
+
const manifestUrl = `${API || location.origin}/jobs/${id}/download/manifest`;
|
| 333 |
actionsEl.innerHTML = `
|
| 334 |
<a href="${photopeaUrl}" target="_blank" rel="noopener" class="primary">Ouvrir dans Photopea</a>
|
| 335 |
<a href="${psdUrl}" download class="dark">Télécharger PSD</a>
|
| 336 |
<a href="${hdUrl}" download class="secondary">Télécharger HD</a>
|
| 337 |
+
<a href="${manifestUrl}" download style="background:#5a8a3a;">Télécharger manifest.json</a>
|
| 338 |
`;
|
| 339 |
window.open(photopeaUrl, "_blank");
|
| 340 |
break;
|
main.py
CHANGED
|
@@ -158,6 +158,9 @@ async def download(job_id: str, kind: str):
|
|
| 158 |
raise HTTPException(404, "job non terminé")
|
| 159 |
r = JOBS[job_id]["result"]
|
| 160 |
paths = {"psd": r["psd"], "illu_hd": r["illu_hd"]}
|
|
|
|
|
|
|
|
|
|
| 161 |
if kind not in paths:
|
| 162 |
raise HTTPException(400, f"kind doit être l'un de {list(paths)}")
|
| 163 |
return FileResponse(paths[kind])
|
|
|
|
| 158 |
raise HTTPException(404, "job non terminé")
|
| 159 |
r = JOBS[job_id]["result"]
|
| 160 |
paths = {"psd": r["psd"], "illu_hd": r["illu_hd"]}
|
| 161 |
+
meta = r.get("meta", {})
|
| 162 |
+
if meta.get("manifest_path"):
|
| 163 |
+
paths["manifest"] = meta["manifest_path"]
|
| 164 |
if kind not in paths:
|
| 165 |
raise HTTPException(400, f"kind doit être l'un de {list(paths)}")
|
| 166 |
return FileResponse(paths[kind])
|
pipeline_v3_sam2.py
CHANGED
|
@@ -228,14 +228,26 @@ class CadrimagesV3SAM2:
|
|
| 228 |
for i, m in enumerate(grid_cells):
|
| 229 |
all_blobs.append({"mask": m, "source": "grid", "idx": i})
|
| 230 |
|
| 231 |
-
# Nommage intelligent
|
|
|
|
| 232 |
if progress_cb: progress_cb(78, f"Nommage intelligent ({len(all_blobs)} calques)")
|
| 233 |
for i, blob in enumerate(all_blobs):
|
| 234 |
if blob["source"] == "grid":
|
| 235 |
blob["name"] = f"Cellule grille {blob['idx']+1:02d}"
|
|
|
|
|
|
|
| 236 |
else:
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
# Construction PSD avec groupes thématiques
|
| 241 |
if progress_cb: progress_cb(90, "Construction PSD groupé")
|
|
@@ -281,12 +293,53 @@ class CadrimagesV3SAM2:
|
|
| 281 |
|
| 282 |
if progress_cb: progress_cb(95, "Sauvegarde PSD")
|
| 283 |
psd.save(out_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
return {
|
| 285 |
"path": out_path,
|
|
|
|
| 286 |
"size_mb": os.path.getsize(out_path) // 1024 // 1024,
|
| 287 |
"width": W, "height": H,
|
| 288 |
"n_layers": total_layers,
|
| 289 |
"n_sam": len(sam_masks),
|
| 290 |
"n_grid": len(grid_cells),
|
| 291 |
"groups": group_stats,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
}
|
|
|
|
| 228 |
for i, m in enumerate(grid_cells):
|
| 229 |
all_blobs.append({"mask": m, "source": "grid", "idx": i})
|
| 230 |
|
| 231 |
+
# Nommage intelligent avec confidence score et fallback
|
| 232 |
+
from qa_manifest import name_confidence
|
| 233 |
if progress_cb: progress_cb(78, f"Nommage intelligent ({len(all_blobs)} calques)")
|
| 234 |
for i, blob in enumerate(all_blobs):
|
| 235 |
if blob["source"] == "grid":
|
| 236 |
blob["name"] = f"Cellule grille {blob['idx']+1:02d}"
|
| 237 |
+
blob["confidence"] = 1.0
|
| 238 |
+
blob["naming_source"] = "grid"
|
| 239 |
else:
|
| 240 |
+
raw_name = self.name_blob_claude(illu_pil, blob["mask"])
|
| 241 |
+
conf = name_confidence(raw_name)
|
| 242 |
+
if conf < 0.4:
|
| 243 |
+
blob["name"] = "Élément"
|
| 244 |
+
blob["confidence"] = 0.0
|
| 245 |
+
blob["naming_source"] = "fallback"
|
| 246 |
+
else:
|
| 247 |
+
blob["name"] = raw_name
|
| 248 |
+
blob["confidence"] = conf
|
| 249 |
+
blob["naming_source"] = "vlm"
|
| 250 |
+
log(f" [{i+1}/{len(all_blobs)}] {blob['source']} → {blob['name']} (conf={blob['confidence']:.2f}, {blob['naming_source']})")
|
| 251 |
|
| 252 |
# Construction PSD avec groupes thématiques
|
| 253 |
if progress_cb: progress_cb(90, "Construction PSD groupé")
|
|
|
|
| 293 |
|
| 294 |
if progress_cb: progress_cb(95, "Sauvegarde PSD")
|
| 295 |
psd.save(out_path)
|
| 296 |
+
|
| 297 |
+
# Manifest JSON exporté à côté du PSD
|
| 298 |
+
from qa_manifest import qa_illustration, build_manifest, write_manifest
|
| 299 |
+
qa = qa_illustration(illu_pil, ref_photo)
|
| 300 |
+
layers_named = []
|
| 301 |
+
for theme, blobs in themed.items():
|
| 302 |
+
for b in blobs:
|
| 303 |
+
layers_named.append({
|
| 304 |
+
"group": theme,
|
| 305 |
+
"name": b["display_name"],
|
| 306 |
+
"raw_name": b["name"],
|
| 307 |
+
"vlm_confidence": b.get("confidence", 0),
|
| 308 |
+
"source": b["source"],
|
| 309 |
+
"naming_source": b.get("naming_source", "fallback"),
|
| 310 |
+
})
|
| 311 |
+
manifest = build_manifest(
|
| 312 |
+
job_id=os.path.basename(out_path).replace("_FINAL.psd", ""),
|
| 313 |
+
style="cadrimages_fidele",
|
| 314 |
+
src_size=ref_photo.size,
|
| 315 |
+
illu_size=illu_pil.size,
|
| 316 |
+
psd_path=out_path,
|
| 317 |
+
psd_meta={"size_mb": os.path.getsize(out_path) // 1024 // 1024,
|
| 318 |
+
"n_layers": total_layers},
|
| 319 |
+
qa=qa,
|
| 320 |
+
layers_named=layers_named,
|
| 321 |
+
sources_used={
|
| 322 |
+
"painterly": "cadrimages_fidele",
|
| 323 |
+
"segmentation_primary": "sam2_fal" if len(sam_masks) > 0 else "fallback",
|
| 324 |
+
"grid_detection": "hough" if len(grid_cells) > 0 else "none",
|
| 325 |
+
"naming_primary": "nemotron_vision_openrouter",
|
| 326 |
+
"vlm_model": VLM_MODEL,
|
| 327 |
+
},
|
| 328 |
+
)
|
| 329 |
+
manifest_path = out_path.replace(".psd", "_manifest.json")
|
| 330 |
+
write_manifest(manifest, manifest_path)
|
| 331 |
+
|
| 332 |
return {
|
| 333 |
"path": out_path,
|
| 334 |
+
"manifest_path": manifest_path,
|
| 335 |
"size_mb": os.path.getsize(out_path) // 1024 // 1024,
|
| 336 |
"width": W, "height": H,
|
| 337 |
"n_layers": total_layers,
|
| 338 |
"n_sam": len(sam_masks),
|
| 339 |
"n_grid": len(grid_cells),
|
| 340 |
"groups": group_stats,
|
| 341 |
+
"qa_status": qa["status"],
|
| 342 |
+
"qa_warnings": qa["warnings"],
|
| 343 |
+
"naming_avg_confidence": manifest["naming"]["vlm_average_confidence"],
|
| 344 |
+
"naming_fallback_count": manifest["naming"]["fallback_count"],
|
| 345 |
}
|
qa_manifest.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
QA automatique + manifest JSON pour chaque génération Cadrimages.
|
| 3 |
+
|
| 4 |
+
Patterns appliqués :
|
| 5 |
+
- QA visuel post-génération : flag les images pathologiques (trop de noir, trop simple, etc.)
|
| 6 |
+
- Confidence score sur naming : flag les noms VLM peu plausibles, fallback nom sémantique
|
| 7 |
+
- Manifest JSON exporté avec le PSD : traçabilité complète (sources, fallbacks utilisés, scores)
|
| 8 |
+
"""
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
import re
|
| 12 |
+
from typing import Optional, Dict, Any, List
|
| 13 |
+
import numpy as np
|
| 14 |
+
from PIL import Image
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Mots français basiques attendus pour un naming acceptable
|
| 18 |
+
COMMON_FR_WORDS = {
|
| 19 |
+
"tour", "bâtiment", "mur", "fenêtre", "porte", "volet", "balcon", "toit", "cheminée",
|
| 20 |
+
"arbre", "cyprès", "plante", "fleur", "buisson", "herbe", "feuillage", "branche",
|
| 21 |
+
"chaise", "table", "fauteuil", "banc", "banquette", "coussin", "lampe", "lampadaire",
|
| 22 |
+
"ciel", "nuage", "lune", "soleil", "étoile", "eau", "mer", "rivière", "rocher",
|
| 23 |
+
"sol", "pavé", "carreau", "trottoir", "route", "rambarde", "garde-corps", "clôture",
|
| 24 |
+
"treillis", "maille", "barreau", "personne", "homme", "femme", "client", "serveur",
|
| 25 |
+
"vase", "pot", "lanterne", "ornement", "colonne", "arche", "tour", "abbaye", "maison",
|
| 26 |
+
"rouge", "vert", "bleu", "jaune", "blanc", "noir", "marbre", "bois", "métal", "verre",
|
| 27 |
+
"rotin", "rayée", "central", "droit", "gauche", "centre", "premier", "fond",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def name_confidence(name: str) -> float:
|
| 32 |
+
"""
|
| 33 |
+
Score 0-1 sur la qualité du nom retourné par le VLM.
|
| 34 |
+
Plus c'est haut, plus le nom est crédible.
|
| 35 |
+
"""
|
| 36 |
+
if not name or name == "Élément":
|
| 37 |
+
return 0.0
|
| 38 |
+
s = name.lower().strip()
|
| 39 |
+
if len(s) < 2 or len(s) > 60:
|
| 40 |
+
return 0.0
|
| 41 |
+
# Pénalité : caractères non-ASCII/français bizarres
|
| 42 |
+
weird_chars = sum(1 for c in s if not (c.isalpha() or c in " -'éèêëàâäôöûüçîï"))
|
| 43 |
+
if weird_chars > 2:
|
| 44 |
+
return 0.2
|
| 45 |
+
# Bonus : contient au moins un mot du vocabulaire attendu
|
| 46 |
+
words = re.findall(r"[a-zA-Zéèêëàâäôöûüçîï]+", s)
|
| 47 |
+
if not words:
|
| 48 |
+
return 0.1
|
| 49 |
+
matches = sum(1 for w in words if w.lower() in COMMON_FR_WORDS)
|
| 50 |
+
if matches >= 1:
|
| 51 |
+
return min(1.0, 0.6 + 0.2 * matches)
|
| 52 |
+
# Cas pas de match mais semble plausible (lettres FR sensées)
|
| 53 |
+
return 0.3
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def qa_illustration(illu_pil: Image.Image, ref_pil: Optional[Image.Image] = None) -> Dict[str, Any]:
|
| 57 |
+
"""
|
| 58 |
+
QA visuel post-painterly. Détecte les pathologies courantes :
|
| 59 |
+
- Trop de noir (cas hachures massives)
|
| 60 |
+
- Palette trop pauvre (mode "encre" non voulu)
|
| 61 |
+
- Trop monochrome (mode "tinté" raté)
|
| 62 |
+
- Surface utile insuffisante
|
| 63 |
+
- Dimensions divergentes vs source
|
| 64 |
+
Retourne un dict avec status et warnings.
|
| 65 |
+
"""
|
| 66 |
+
arr = np.array(illu_pil.convert("RGB"))
|
| 67 |
+
H, W = arr.shape[:2]
|
| 68 |
+
total_px = H * W
|
| 69 |
+
|
| 70 |
+
flat = arr.reshape(-1, 3)
|
| 71 |
+
luminance = 0.299 * flat[:, 0] + 0.587 * flat[:, 1] + 0.114 * flat[:, 2]
|
| 72 |
+
|
| 73 |
+
pct_black = float((luminance < 30).sum() / total_px * 100)
|
| 74 |
+
pct_very_dark = float((luminance < 60).sum() / total_px * 100)
|
| 75 |
+
|
| 76 |
+
# Diversité chromatique : nombre de teintes distinctes (quantif x16)
|
| 77 |
+
quantized = (flat // 16) * 16
|
| 78 |
+
unique_colors = len(np.unique(quantized.view(np.dtype((np.void, 3 * 1))), axis=0))
|
| 79 |
+
|
| 80 |
+
# Saturation moyenne
|
| 81 |
+
max_c = flat.max(axis=1).astype(np.float32)
|
| 82 |
+
min_c = flat.min(axis=1).astype(np.float32)
|
| 83 |
+
saturation = np.where(max_c > 0, (max_c - min_c) / max_c, 0)
|
| 84 |
+
mean_saturation = float(saturation.mean())
|
| 85 |
+
|
| 86 |
+
# Comparaison dimensions vs ref si dispo
|
| 87 |
+
dim_match = True
|
| 88 |
+
if ref_pil is not None:
|
| 89 |
+
dim_match = (illu_pil.size == ref_pil.size)
|
| 90 |
+
|
| 91 |
+
warnings = []
|
| 92 |
+
if pct_black > 25:
|
| 93 |
+
warnings.append(f"BLACK_EXCESS: {pct_black:.1f}% pixels quasi-noirs (>25%) — hachures suspectes")
|
| 94 |
+
if pct_very_dark > 50:
|
| 95 |
+
warnings.append(f"DARK_EXCESS: {pct_very_dark:.1f}% pixels sombres (>50%) — rendu trop sombre")
|
| 96 |
+
if unique_colors < 60:
|
| 97 |
+
warnings.append(f"LOW_PALETTE: {unique_colors} teintes distinctes — palette trop pauvre")
|
| 98 |
+
if mean_saturation < 0.08:
|
| 99 |
+
warnings.append(f"MONOCHROME: saturation moyenne {mean_saturation:.2f} — image quasi N&B")
|
| 100 |
+
if mean_saturation > 0.65:
|
| 101 |
+
warnings.append(f"OVER_SATURATED: saturation moyenne {mean_saturation:.2f} — couleurs criardes")
|
| 102 |
+
if not dim_match:
|
| 103 |
+
warnings.append(f"DIM_MISMATCH: illu {illu_pil.size} != ref {ref_pil.size if ref_pil else '?'}")
|
| 104 |
+
|
| 105 |
+
status = "ok" if not warnings else "warning"
|
| 106 |
+
return {
|
| 107 |
+
"status": status,
|
| 108 |
+
"warnings": warnings,
|
| 109 |
+
"metrics": {
|
| 110 |
+
"size": list(illu_pil.size),
|
| 111 |
+
"pct_black": round(pct_black, 2),
|
| 112 |
+
"pct_very_dark": round(pct_very_dark, 2),
|
| 113 |
+
"unique_colors": int(unique_colors),
|
| 114 |
+
"mean_saturation": round(mean_saturation, 3),
|
| 115 |
+
"dim_match_source": dim_match,
|
| 116 |
+
},
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def build_manifest(
|
| 121 |
+
job_id: str,
|
| 122 |
+
style: str,
|
| 123 |
+
src_size: tuple,
|
| 124 |
+
illu_size: tuple,
|
| 125 |
+
psd_path: str,
|
| 126 |
+
psd_meta: Dict[str, Any],
|
| 127 |
+
qa: Dict[str, Any],
|
| 128 |
+
layers_named: List[Dict[str, Any]],
|
| 129 |
+
sources_used: Dict[str, Any],
|
| 130 |
+
) -> Dict[str, Any]:
|
| 131 |
+
"""
|
| 132 |
+
Construit un manifest JSON exporté à côté du PSD.
|
| 133 |
+
|
| 134 |
+
layers_named : [{ "group": "Architecture", "name": "Bâtiment", "vlm_confidence": 0.8, "source": "sam2|grid|sem" }, ...]
|
| 135 |
+
sources_used : { "painterly": "cadrimages_fidele", "segmentation": "sam2", "naming": "nemotron|fallback", "lora": "cadrimages_lora_url" }
|
| 136 |
+
"""
|
| 137 |
+
# Score qualité naming global
|
| 138 |
+
confidences = [l.get("vlm_confidence", 0) for l in layers_named if l.get("vlm_confidence") is not None]
|
| 139 |
+
naming_score = round(sum(confidences) / len(confidences), 2) if confidences else 0
|
| 140 |
+
fallback_used = sum(1 for l in layers_named if l.get("source_naming") == "fallback")
|
| 141 |
+
|
| 142 |
+
# Group counts
|
| 143 |
+
group_counts = {}
|
| 144 |
+
for l in layers_named:
|
| 145 |
+
g = l.get("group", "?")
|
| 146 |
+
group_counts[g] = group_counts.get(g, 0) + 1
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
"manifest_version": "1.0",
|
| 150 |
+
"job_id": job_id,
|
| 151 |
+
"style": style,
|
| 152 |
+
"source": {"width": src_size[0], "height": src_size[1]},
|
| 153 |
+
"illustration": {"width": illu_size[0], "height": illu_size[1]},
|
| 154 |
+
"psd": {
|
| 155 |
+
"path": os.path.basename(psd_path),
|
| 156 |
+
"size_mb": psd_meta.get("size_mb"),
|
| 157 |
+
"total_layers": psd_meta.get("n_layers"),
|
| 158 |
+
"groups": group_counts,
|
| 159 |
+
},
|
| 160 |
+
"qa": qa,
|
| 161 |
+
"naming": {
|
| 162 |
+
"vlm_average_confidence": naming_score,
|
| 163 |
+
"fallback_count": fallback_used,
|
| 164 |
+
"total": len(layers_named),
|
| 165 |
+
},
|
| 166 |
+
"sources_used": sources_used,
|
| 167 |
+
"layers": layers_named,
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def write_manifest(manifest: Dict[str, Any], path: str) -> str:
|
| 172 |
+
"""Écrit le manifest JSON et retourne le chemin."""
|
| 173 |
+
with open(path, "w") as f:
|
| 174 |
+
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
| 175 |
+
return path
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
if __name__ == "__main__":
|
| 179 |
+
import sys
|
| 180 |
+
img = Image.open(sys.argv[1]).convert("RGB")
|
| 181 |
+
qa = qa_illustration(img)
|
| 182 |
+
print(json.dumps(qa, indent=2, ensure_ascii=False))
|