fix(frozen): data/*.txt dans bundle, feedback UI pendant chargement modèles

Plantages signalés sous Windows : causes identifiées et corrigées.

1. anonymisation_onefile.spec : les fichiers data/stopwords_manuels.txt,
   villes_blacklist.txt, dpi_labels_blacklist.txt, companion_blacklist.txt
   n'étaient PAS inclus dans le bundle PyInstaller (seuls les sous-dossiers
   data/bdpm, data/finess, data/insee l'étaient). Résultat en frozen : sets
   vides, qualité dégradée, plus de faux positifs.

2. anonymizer_core_refactored_onnx.py : chargements robustifiés.
   - Helper _load_txt_set avec try/except et logging WARNING si fichier absent
   - Fallbacks intégrés (_DPI_LABELS_FALLBACK, _COMPANION_BLACKLIST_FALLBACK)
     pour continuer à fonctionner si bundle partiel
   - try/except sur stopwords_manuels.txt, villes_blacklist.txt, BDPM

3. launcher.py : UX repensée pour le chargement des modèles.
   - SetupWindow (premier lancement) : auto-démarrage (plus de clic nécessaire),
     progress bar avec étapes visuelles (/✓/✗ par modèle), bouton relance si
     échec, bouton "continuer malgré tout" pour modèles optionnels.
   - Splash screen ajouté dans launch_gui() : le chargement des gazetteers
     (INSEE 200k+ noms, FINESS 100k+ établissements) prend 15-30 s au démarrage
     normal. Sans feedback, l'utilisateur croyait l'app plantée. Le splash
     tourne pendant l'import (thread séparé, poll avec splash.after).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 08:50:42 +02:00
parent 4b5925306e
commit 8e458c16ca
3 changed files with 293 additions and 84 deletions

View File

@@ -76,112 +76,264 @@ def check_models_ready():
def launch_gui():
"""Launch the main GUI."""
log.info("Launching GUI...")
try:
import anonymizer_core_refactored_onnx # noqa — pre-import the core
log.info("Core imported OK")
except Exception as e:
log.error(f"Core import error: {e}\n{traceback.format_exc()}")
"""Launch the main GUI with a splash screen during the slow core import.
try:
import Pseudonymisation_Gui_V5 # noqa
log.info("GUI imported OK, starting mainloop...")
root = tk.Tk()
Pseudonymisation_Gui_V5.App(root)
root.mainloop()
except Exception as e:
log.error(f"GUI error: {e}\n{traceback.format_exc()}")
Le chargement des gazetteers (INSEE 200k+ noms, FINESS 100k+ établissements,
BDPM 7k+ médicaments) prend 1030 s. Sans feedback visuel, l'utilisateur
croit que l'application est plantée. Le splash permet d'indiquer l'avancée.
"""
log.info("Launching GUI...")
splash = tk.Tk()
splash.title("Anonymisation")
splash.geometry("440x200")
splash.resizable(False, False)
frame = ttk.Frame(splash, padding=20)
frame.pack(fill="both", expand=True)
ttk.Label(frame, text="Anonymisation", font=("", 14, "bold")).pack(pady=(0, 4))
ttk.Label(frame, text="Pseudonymisation de documents médicaux",
foreground="#666666").pack(pady=(0, 12))
status_var = tk.StringVar(value="Chargement des dictionnaires médicaux…")
ttk.Label(frame, textvariable=status_var, foreground="#1a568e").pack(pady=(0, 8))
pb = ttk.Progressbar(frame, mode="indeterminate", length=380)
pb.pack(pady=4)
pb.start(10)
ttk.Label(frame, text="Première phase : ~1530 s",
foreground="#999999", font=("", 8)).pack(pady=(6, 0))
result = {"done": False, "error": None}
def _do_import():
try:
messagebox.showerror("Erreur", f"Erreur au lancement de l'interface:\n\n{e}\n\nVoir {LOG_FILE}")
except:
pass
import anonymizer_core_refactored_onnx # noqa
log.info("Core imported OK")
result["step"] = "gui"
import Pseudonymisation_Gui_V5 # noqa
log.info("GUI module imported OK")
except Exception as e:
result["error"] = f"{e}\n{traceback.format_exc()}"
log.error(f"Import error: {result['error']}")
finally:
result["done"] = True
threading.Thread(target=_do_import, daemon=True).start()
def _poll():
# Met à jour le message selon l'étape atteinte par le thread d'import
if result.get("step") == "gui" and not result["done"]:
status_var.set("Chargement de l'interface…")
if result["done"]:
pb.stop()
try:
splash.destroy()
except Exception:
pass
if result["error"]:
try:
messagebox.showerror(
"Erreur",
f"Erreur au lancement :\n\n{result['error'].splitlines()[0]}\n\n"
f"Voir {LOG_FILE} pour les détails.",
)
except Exception:
pass
return
# Lancement de la GUI principale
try:
import Pseudonymisation_Gui_V5
log.info("Starting mainloop…")
root = tk.Tk()
Pseudonymisation_Gui_V5.App(root)
root.mainloop()
except Exception as e:
log.error(f"GUI error: {e}\n{traceback.format_exc()}")
try:
messagebox.showerror(
"Erreur",
f"Erreur de l'interface :\n\n{e}\n\nVoir {LOG_FILE}",
)
except Exception:
pass
else:
splash.after(200, _poll)
splash.after(200, _poll)
splash.mainloop()
class SetupWindow:
"""Setup window for first launch (model download)."""
"""Setup window for first launch — auto-démarre le téléchargement des modèles.
Affiche un suivi détaillé par modèle (EDS-Pseudo, GLiNER, CamemBERT-bio) avec
indicateurs visuels (⏳ en cours, ✓ succès, ✗ échec). Permet de relancer en
cas d'erreur. Lancement auto de la GUI une fois tous les modèles prêts.
"""
# Liste ordonnée des étapes de chargement. Chaque entrée :
# (clé interne, libellé, taille approx, fonction de chargement)
STEPS = [
("eds_pseudo", "EDS-Pseudo (CamemBERT clinique)", "~450 Mo"),
("gliner", "GLiNER (détection PII zero-shot)", "~300 Mo"),
("camembert_onnx", "CamemBERT-bio ONNX (embarqué)", "local"),
]
def __init__(self):
self.root = tk.Tk()
self.root.title("Anonymisation — Premier lancement")
self.root.geometry("520x320")
self.root.title("Anonymisation — Configuration initiale")
self.root.geometry("620x450")
self.root.resizable(False, False)
frame = ttk.Frame(self.root, padding=20)
frame.pack(fill="both", expand=True)
ttk.Label(frame, text="Configuration initiale", font=("", 14, "bold")).pack(pady=(0, 10))
ttk.Label(frame, text="Préparation des modèles d'intelligence artificielle",
font=("", 13, "bold")).pack(pady=(0, 4))
ttk.Label(
frame,
text="Les modeles NER vont etre telecharges depuis HuggingFace.\nCela peut prendre quelques minutes selon la connexion.",
justify="center",
).pack(pady=(0, 15))
text=("Au premier lancement, les modèles de détection doivent être téléchargés\n"
"depuis HuggingFace. Cette opération est unique — durée 3 à 10 minutes\n"
"selon votre connexion internet. Merci de patienter."),
justify="center", foreground="#555555",
).pack(pady=(0, 12))
self.progress = ttk.Progressbar(frame, mode="indeterminate", length=420)
self.progress.pack(pady=10)
# Barre de progression globale
self.progress = ttk.Progressbar(frame, mode="determinate",
length=560, maximum=len(self.STEPS))
self.progress.pack(pady=(0, 4))
self.status_var = tk.StringVar(value="En attente...")
ttk.Label(frame, textvariable=self.status_var).pack(pady=5)
self.status_var = tk.StringVar(value="Démarrage…")
ttk.Label(frame, textvariable=self.status_var, foreground="#1a568e").pack(pady=(0, 12))
self.btn = ttk.Button(frame, text="Demarrer le telechargement", command=self.start_download)
self.btn.pack(pady=15)
# Zone détail par modèle
detail_frame = ttk.LabelFrame(frame, text=" Modèles ", padding=10)
detail_frame.pack(fill="x", pady=(0, 12))
self.step_labels = {}
for key, title, size in self.STEPS:
row = ttk.Frame(detail_frame)
row.pack(fill="x", pady=3)
icon = ttk.Label(row, text="", width=3, font=("", 12))
icon.pack(side="left")
ttk.Label(row, text=title).pack(side="left")
ttk.Label(row, text=f" ({size})", foreground="#999999",
font=("", 8)).pack(side="left")
self.step_labels[key] = icon
# Bouton relance (caché au début)
self.btn = ttk.Button(frame, text="Relancer", command=self.start_download)
self.btn.pack(pady=6)
self.btn.pack_forget()
# Bouton ignorer/continuer (affiché si échec partiel)
self.btn_skip = ttk.Button(
frame, text="Continuer malgré tout",
command=self._finish,
)
self.btn_skip.pack(pady=(0, 4))
self.btn_skip.pack_forget()
# Auto-démarrage du téléchargement (pas besoin de cliquer)
self.root.after(500, self.start_download)
def start_download(self):
self.btn.configure(state="disabled")
self.progress.start(10)
self.btn.pack_forget()
self.btn_skip.pack_forget()
self.progress["value"] = 0
self.status_var.set("Démarrage du téléchargement…")
for icon in self.step_labels.values():
icon.configure(text="", foreground="#999999")
threading.Thread(target=self._download_thread, daemon=True).start()
def _set_step(self, key, state):
"""state : 'pending' | 'running' | 'ok' | 'fail'"""
mapping = {
"pending": ("", "#999999"),
"running": ("", "#f57c00"),
"ok": ("", "#2e7d32"),
"fail": ("", "#c62828"),
}
char, color = mapping.get(state, ("", "#999999"))
icon = self.step_labels.get(key)
if icon is not None:
self.root.after(0, lambda: icon.configure(text=char, foreground=color))
def _download_thread(self):
failures = []
try:
# 1. EDS-Pseudo
self._update("Chargement EDS-Pseudo (telechargement HuggingFace)...")
self._update("Téléchargement d'EDS-Pseudo (modèle CamemBERT clinique)")
self._set_step("eds_pseudo", "running")
log.info("Downloading EDS-Pseudo...")
try:
from eds_pseudo_manager import EdsPseudoManager
mgr = EdsPseudoManager()
mgr.load()
self._update("EDS-Pseudo OK")
self._set_step("eds_pseudo", "ok")
log.info("EDS-Pseudo OK")
except Exception as e:
self._update(f"EDS-Pseudo: {e}")
self._set_step("eds_pseudo", "fail")
failures.append(("EDS-Pseudo", str(e)))
log.warning(f"EDS-Pseudo failed: {e}")
self._advance()
# 2. GLiNER
self._update("Chargement GLiNER (telechargement HuggingFace)...")
self._update("Téléchargement de GLiNER (détection zero-shot)")
self._set_step("gliner", "running")
log.info("Downloading GLiNER...")
try:
from gliner_manager import GlinerManager
mgr = GlinerManager()
mgr.load()
self._update("GLiNER OK")
self._set_step("gliner", "ok")
log.info("GLiNER OK")
except Exception as e:
self._update(f"GLiNER: {e}")
self._set_step("gliner", "fail")
failures.append(("GLiNER", str(e)))
log.warning(f"GLiNER failed: {e}")
self._advance()
# 3. CamemBERT-bio ONNX
self._update("Verification CamemBERT-bio ONNX...")
self._update("Vérification CamemBERT-bio ONNX (modèle embarqué)…")
self._set_step("camembert_onnx", "running")
if check_models_ready():
self._update("CamemBERT-bio ONNX OK")
self._set_step("camembert_onnx", "ok")
else:
self._update("CamemBERT-bio ONNX manquant")
self._set_step("camembert_onnx", "fail")
failures.append(("CamemBERT-bio ONNX", "fichier ONNX introuvable dans le bundle"))
log.error("CamemBERT-bio ONNX not found")
self._advance()
self._update("Configuration terminee ! Lancement de l'interface...")
log.info("Setup complete, launching GUI in 2s")
self.root.after(2000, self._finish)
if failures:
lines = "\n".join(f"{name} : {err[:60]}" for name, err in failures)
self._update(f"Certains modèles ont échoué ({len(failures)}/{len(self.STEPS)}).")
log.warning(f"Setup partial failure: {len(failures)} model(s) failed\n{lines}")
self.root.after(0, lambda: self.btn.pack(pady=6))
self.root.after(0, lambda: self.btn_skip.pack(pady=(0, 4)))
else:
self._update("Tous les modèles sont prêts. Lancement de l'interface…")
log.info("Setup complete, launching GUI in 1.5s")
self.root.after(1500, self._finish)
except Exception as e:
log.error(f"Setup error: {e}\n{traceback.format_exc()}")
self._update(f"Erreur: {e}")
self.root.after(0, lambda: self.btn.configure(state="normal"))
self._update(f"Erreur inattendue : {e}")
self.root.after(0, lambda: self.btn.pack(pady=6))
def _advance(self):
self.root.after(0, lambda: self.progress.step(1))
def _update(self, msg):
self.root.after(0, lambda: self.status_var.set(msg))
def _finish(self):
self.progress.stop()
self.root.destroy()
try:
self.root.destroy()
except Exception:
pass
launch_gui()
def run(self):