backup: WIP Windows avant repart propre (GUI core installer splash spec)

This commit is contained in:
2026-06-05 12:11:21 +02:00
parent 39db675052
commit 1abee3e089
27 changed files with 4628 additions and 775 deletions

View File

@@ -8,6 +8,8 @@ from tkinter import ttk, messagebox
from pathlib import Path
import threading
import logging
import contextlib
import time
# pyi_splash : module injecté par PyInstaller quand --splash est utilisé.
# Permet d'actualiser / fermer le splash natif affiché au démarrage de l'exe
@@ -38,6 +40,216 @@ def _splash_close() -> None:
except Exception:
pass
class BrandedSplash:
"""Splash applicatif avec le visuel existant + progression détaillée.
PyInstaller affiche d'abord le splash natif pendant l'extraction du onefile.
Dès que Python est démarré, cette fenêtre prend le relais pour montrer des
étapes lisibles et un petit journal de chargement.
"""
def __init__(self, total_steps: int = 6):
self.total_steps = max(total_steps, 1)
self.current_step = 0
self.enabled = False
self.root = None
self.status_var = None
self.progress = None
self.log_box = None
self._image = None
self._lines = []
try:
self.root = tk.Tk()
self.root.withdraw()
self.root.title("aivanonym")
self.root.resizable(False, False)
self.root.overrideredirect(True)
self.root.configure(bg="white")
container = tk.Frame(
self.root,
bg="white",
highlightthickness=1,
highlightbackground="#d8d8d8",
)
container.pack(fill="both", expand=True)
splash_path = APP_DIR / "assets" / "splash.png"
if splash_path.exists():
self._image = tk.PhotoImage(file=str(splash_path))
tk.Label(container, image=self._image, bg="white", bd=0).pack()
else:
fallback = tk.Frame(container, bg="white", width=500, height=170)
fallback.pack_propagate(False)
fallback.pack()
tk.Frame(fallback, bg="#cc0000", height=4).pack(fill="x")
tk.Label(
fallback,
text="aivanonym",
bg="white",
fg="#222222",
font=("Segoe UI", 28),
).pack(expand=True)
body = tk.Frame(container, bg="white", padx=24, pady=14)
body.pack(fill="x")
self.status_var = tk.StringVar(value="Initialisation...")
tk.Label(
body,
textvariable=self.status_var,
bg="white",
fg="#222222",
font=("Segoe UI", 10, "bold"),
anchor="w",
).pack(fill="x")
self.progress = ttk.Progressbar(
body,
mode="determinate",
maximum=self.total_steps,
length=452,
)
self.progress.pack(fill="x", pady=(8, 10))
tk.Label(
body,
text="Chargements en cours",
bg="white",
fg="#666666",
font=("Segoe UI", 8),
anchor="w",
).pack(fill="x")
self.log_box = tk.Listbox(
body,
height=5,
activestyle="none",
bg="#f7f7f7",
fg="#333333",
bd=0,
highlightthickness=1,
highlightbackground="#e7e7e7",
font=("Consolas", 8),
)
self.log_box.pack(fill="x", pady=(4, 0))
self._center()
self.root.deiconify()
self.root.lift()
self.root.update_idletasks()
self.root.update()
self.enabled = True
# Le splash natif PyInstaller n'a qu'une ligne de texte. Une fois
# cette fenêtre prête, elle prend le relais sans changer le visuel.
_splash_close()
except Exception as exc:
try:
if self.root is not None:
self.root.destroy()
except Exception:
pass
self.root = None
log.warning(f"Branded splash unavailable: {exc}")
def _center(self) -> None:
if self.root is None:
return
self.root.update_idletasks()
width = self.root.winfo_reqwidth()
height = self.root.winfo_reqheight()
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
x = max(0, int((screen_width - width) / 2))
y = max(0, int((screen_height - height) / 2))
self.root.geometry(f"{width}x{height}+{x}+{y}")
def step(self, message: str) -> None:
self.current_step = min(self.current_step + 1, self.total_steps)
status = f"[{self.current_step}/{self.total_steps}] {message}"
self.message(status)
if self.progress is not None:
self.progress["value"] = self.current_step
self._pump()
def message(self, message: str) -> None:
_splash_update(message)
if self.enabled and self.status_var is not None:
self.status_var.set(message)
self._pump()
def detail(self, message: str) -> None:
_splash_update(message)
clean = " ".join(str(message).split())
if not clean:
return
if len(clean) > 150:
clean = clean[:147] + "..."
if self.enabled and self.log_box is not None:
self._lines.append(clean)
self._lines = self._lines[-7:]
self.log_box.delete(0, tk.END)
for line in self._lines:
self.log_box.insert(tk.END, line)
self.log_box.see(tk.END)
self._pump()
def close(self) -> None:
_splash_close()
if self.root is not None:
try:
self.root.destroy()
except Exception:
pass
self.root = None
self.enabled = False
def _pump(self) -> None:
if self.root is None:
return
try:
self.root.update_idletasks()
self.root.update()
except Exception:
self.enabled = False
class ModelProgressStream:
"""Redirige les sorties type tqdm vers une callback UI."""
def __init__(self, callback, prefix: str):
self.callback = callback
self.prefix = prefix
self.buffer = ""
self.last_line = ""
self.last_emit = 0.0
def write(self, data) -> int:
text = str(data)
self.buffer += text.replace("\r", "\n")
while "\n" in self.buffer:
line, self.buffer = self.buffer.split("\n", 1)
self._emit(line)
return len(text)
def flush(self) -> None:
if self.buffer:
self._emit(self.buffer)
self.buffer = ""
def _emit(self, line: str) -> None:
clean = " ".join(line.split())
if len(clean) < 3:
return
now = time.monotonic()
if clean == self.last_line and now - self.last_emit < 1.0:
return
self.last_line = clean
self.last_emit = now
self.callback(f"{self.prefix} : {clean}")
# ---------------------------------------------------------------------------
# Single-instance guard (lock file in user's temp directory)
# ---------------------------------------------------------------------------
@@ -105,23 +317,10 @@ def check_models_ready():
def launch_gui():
"""Launch the main GUI — étapes de chargement affichées DANS le splash natif.
Le splash natif PyInstaller (image avec logo + texte dynamique) reste
visible pendant TOUTE la phase de chargement. On intercepte les log.info()
du core via un logging.Handler et on pousse chaque étape traduite dans
le splash natif via pyi_splash.update_text(). L'utilisateur voit défiler
sous le logo :
"Chargement des prénoms français (INSEE)…"
"Chargement des noms de famille (INSEE)…"
"Chargement des numéros FINESS…"
Puis le splash se ferme et la GUI s'ouvre — pas de fenêtre intermédiaire.
En mode dev (pas frozen), pyi_splash n'existe pas ; on ajoute un
mini-splash tkinter temporaire pour voir le même rendu pendant le test.
"""
"""Launch the main GUI with visible startup progress."""
log.info("Launching GUI...")
progress = BrandedSplash(total_steps=5)
progress.step("Préparation de l'environnement")
# Traductions log.info() → libellés "prod" lisibles pour l'utilisateur.
_LOG_TRANSLATIONS = [
@@ -158,7 +357,7 @@ def launch_gui():
class _SplashHandler(logging.Handler):
def emit(self, record):
try:
_splash_update(_translate(record.getMessage()))
progress.detail(_translate(record.getMessage()))
except Exception:
pass
@@ -167,17 +366,24 @@ def launch_gui():
logging.getLogger().addHandler(_handler)
# Afficher tout de suite un message initial sous le logo
_splash_update("Démarrage…")
progress.detail("Démarrage du moteur applicatif")
# Import du core et de la GUI (synchrone : pas besoin de thread puisque
# le splash natif tourne dans son propre processus bootloader).
result = {"error": None}
try:
_splash_update("Chargement des dictionnaires médicaux")
progress.step("Chargement des dictionnaires médicaux")
import anonymizer_core_refactored_onnx # noqa
log.info("Core imported OK")
progress.step("Chargement du moteur d'anonymisation")
import Pseudonymisation_Gui_V5 # noqa
log.info("GUI module imported OK")
progress.step("Vérification des modèles locaux")
if check_models_ready():
progress.detail("CamemBERT-bio ONNX local disponible")
else:
progress.detail("CamemBERT-bio ONNX non trouvé dans le bundle")
progress.step("Ouverture de l'interface")
except Exception as e:
result["error"] = f"{e}\n{traceback.format_exc()}"
log.error(f"Import error: {result['error']}")
@@ -188,8 +394,8 @@ def launch_gui():
except Exception:
pass
# Fermer le splash natif maintenant que tout est prêt
_splash_close()
# Fermer le splash maintenant que tout est prêt
progress.close()
if result["error"]:
try:
@@ -239,12 +445,19 @@ class SetupWindow:
def __init__(self):
self.root = tk.Tk()
self.root.title("Anonymisation — Configuration initiale")
self.root.geometry("620x450")
self.root.geometry("660x700")
self.root.resizable(False, False)
self._logo_image = None
self._log_lines = []
frame = ttk.Frame(self.root, padding=20)
frame = ttk.Frame(self.root, padding=18)
frame.pack(fill="both", expand=True)
splash_path = APP_DIR / "assets" / "splash.png"
if splash_path.exists():
self._logo_image = tk.PhotoImage(file=str(splash_path))
ttk.Label(frame, image=self._logo_image).pack(pady=(0, 8))
ttk.Label(frame, text="Préparation des modèles d'intelligence artificielle",
font=("", 13, "bold")).pack(pady=(0, 4))
ttk.Label(
@@ -278,6 +491,22 @@ class SetupWindow:
font=("", 8)).pack(side="left")
self.step_labels[key] = icon
log_frame = ttk.LabelFrame(frame, text=" Détail du chargement ", padding=8)
log_frame.pack(fill="x", pady=(0, 12))
self.log_text = tk.Text(
log_frame,
height=7,
wrap="word",
state="disabled",
bg="#f7f7f7",
fg="#333333",
bd=0,
padx=8,
pady=6,
font=("Consolas", 8),
)
self.log_text.pack(fill="x")
# Bouton relance (caché au début)
self.btn = ttk.Button(frame, text="Relancer", command=self.start_download)
self.btn.pack(pady=6)
@@ -321,43 +550,54 @@ class SetupWindow:
try:
# 1. EDS-Pseudo
self._update("Téléchargement d'EDS-Pseudo… (modèle CamemBERT clinique)")
self._append_log("EDS-Pseudo : téléchargement/chargement du modèle AP-HP")
self._set_step("eds_pseudo", "running")
log.info("Downloading EDS-Pseudo...")
try:
from eds_pseudo_manager import EdsPseudoManager
mgr = EdsPseudoManager()
mgr.load()
with self._capture_model_output("EDS-Pseudo"):
mgr.load()
self._set_step("eds_pseudo", "ok")
self._append_log("EDS-Pseudo : modèle prêt")
log.info("EDS-Pseudo OK")
except Exception as e:
self._set_step("eds_pseudo", "fail")
self._append_log(f"EDS-Pseudo : échec - {e}")
failures.append(("EDS-Pseudo", str(e)))
log.warning(f"EDS-Pseudo failed: {e}")
self._advance()
# 2. GLiNER
self._update("Téléchargement de GLiNER… (détection zero-shot)")
self._append_log("GLiNER : téléchargement/chargement du modèle PII")
self._set_step("gliner", "running")
log.info("Downloading GLiNER...")
try:
from gliner_manager import GlinerManager
mgr = GlinerManager()
mgr.load()
with self._capture_model_output("GLiNER"):
mgr.load()
self._set_step("gliner", "ok")
self._append_log("GLiNER : modèle prêt")
log.info("GLiNER OK")
except Exception as e:
self._set_step("gliner", "fail")
self._append_log(f"GLiNER : échec - {e}")
failures.append(("GLiNER", str(e)))
log.warning(f"GLiNER failed: {e}")
self._advance()
# 3. CamemBERT-bio ONNX
self._update("Vérification CamemBERT-bio ONNX (modèle embarqué)…")
self._append_log("CamemBERT-bio ONNX : vérification du modèle embarqué")
self._set_step("camembert_onnx", "running")
if check_models_ready():
self._set_step("camembert_onnx", "ok")
self._append_log("CamemBERT-bio ONNX : modèle local présent")
else:
self._set_step("camembert_onnx", "fail")
self._append_log("CamemBERT-bio ONNX : fichier ONNX introuvable")
failures.append(("CamemBERT-bio ONNX", "fichier ONNX introuvable dans le bundle"))
log.error("CamemBERT-bio ONNX not found")
self._advance()
@@ -384,6 +624,31 @@ class SetupWindow:
def _update(self, msg):
self.root.after(0, lambda: self.status_var.set(msg))
def _append_log(self, msg):
clean = " ".join(str(msg).split())
if not clean:
return
if len(clean) > 180:
clean = clean[:177] + "..."
def _apply():
self._log_lines.append(clean)
self._log_lines = self._log_lines[-80:]
self.log_text.configure(state="normal")
self.log_text.delete("1.0", tk.END)
self.log_text.insert("end", "\n".join(self._log_lines))
self.log_text.configure(state="disabled")
self.log_text.see("end")
self.root.after(0, _apply)
@contextlib.contextmanager
def _capture_model_output(self, label):
stream = ModelProgressStream(self._append_log, label)
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream):
yield
stream.flush()
def _finish(self):
try:
self.root.destroy()