Atef-le-viking commited on
Commit
15c7a0b
·
verified ·
1 Parent(s): b97069a

V3.1: QA automatique + confidence naming + manifest JSON téléchargeable

Browse files
Files changed (5) hide show
  1. Dockerfile +1 -1
  2. frontend/index.html +2 -0
  3. main.py +3 -0
  4. pipeline_v3_sam2.py +56 -3
  5. 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
- blob["name"] = self.name_blob_claude(illu_pil, blob["mask"])
238
- log(f" [{i+1}/{len(all_blobs)}] {blob['source']} → {blob['name']}")
 
 
 
 
 
 
 
 
 
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))