
import locale
import os
import sys
import math
import threading
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, colorchooser
from PIL import Image, ImageTk, ImageEnhance, ImageFilter, ImageDraw, ImageFont, ImageOps, ImageChops

os.environ.setdefault("OMP_NUM_THREADS", str(max(2, min(6, os.cpu_count() or 4))))

try:
    import cv2
    import numpy as np
    HAS_CV2 = True
except Exception:
    cv2 = None
    np = None
    HAS_CV2 = False

try:
    from tkinterdnd2 import DND_FILES, TkinterDnD
    HAS_DND = True
except Exception:
    DND_FILES = None
    TkinterDnD = None
    HAS_DND = False

REMBG_IMPORT_ERROR = ""
rembg_remove = None
rembg_new_session = None
HAS_REMBG = True

def ensure_rembg_loaded():
    global rembg_remove, rembg_new_session, HAS_REMBG, REMBG_IMPORT_ERROR
    if rembg_remove is not None and rembg_new_session is not None:
        return True
    try:
        package_model_dir = os.path.join(getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))), "models")
        if os.path.isdir(package_model_dir):
            os.environ.setdefault("U2NET_HOME", package_model_dir)
        from rembg import remove as loaded_remove, new_session as loaded_new_session
        rembg_remove = loaded_remove
        rembg_new_session = loaded_new_session
        HAS_REMBG = True
        REMBG_IMPORT_ERROR = ""
        return True
    except BaseException as exc:
        rembg_remove = None
        rembg_new_session = None
        HAS_REMBG = False
        REMBG_IMPORT_ERROR = repr(exc)
        return False

APP_TITLE = "Latex Vamp Photo Tool"
APP_BUILD = "2026-05-20 pro clean tabs"

COLORS = {
    "bg": "#050609",
    "header": "#0A0B10",
    "panel": "#11131A",
    "panel2": "#181B24",
    "canvas": "#0E1017",
    "text": "#F4F2F0",
    "muted": "#B8B4B6",
    "accent": "#860014",
    "accent_hover": "#B80022",
    "secondary": "#252A34",
    "secondary_hover": "#323846",
    "border": "#3B404C"
}

def resource_path(relative_path):
    base_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
    return os.path.join(base_path, relative_path)


LANGS = {
    "fr": {
        "language_name": "Français",
        "title": "Latex Vamp Photo Tool",
        "subtitle": "Ouvrir • Corriger • Ajouter texte/logo • Enregistrer",
        "open_photo": "Ouvrir une photo",
        "new_photo": "Nouvelle photo",
        "new_photo_warning": "Si vous ouvrez une nouvelle photo sans enregistrer la précédente vous perdrez vos modifications.",
        "continue_action": "Continuer",
        "cancel_action": "Annuler",
        "save_as": "Enregistrer sous...",
        "save_jpeg": "Enregistrer JPEG",
        "save_png": "Enregistrer PNG",
        "save_webp": "Enregistrer WebP",
        "save_pdf": "Enregistrer PDF",
        "quick_save": "Enregistrer vite en PNG",
        "undo": "Annuler",
        "reset": "Revenir à l’image originale",
        "return_original": "Revenir à l’image originale",
        "checkpoint_save": "Créer point de reprise",
        "checkpoint_restore": "Revenir au point de reprise",
        "checkpoint_saved": "Point de reprise enregistré",
        "checkpoint_missing": "Aucun point de reprise enregistré",
        "checkpoint_restored": "Retour au point de reprise",
        "file_tab": "Fichier",
        "basic_tab": "Simple",
        "crop_tab": "Crop + Taille",
        "text_logo_tab": "Texte + Logo",
        "background_tab": "Fond",
        "advanced_tab": "Avancé",
        "help_tab": "Aide",
        "skin_advanced_tab": "Peau avancée",
        "quick_guide": "Guide rapide",
        "quick_guide_text": "1) Ouvre une photo\n2) Choisis un outil\n3) Clique ou glisse sur la photo\n4) Enregistre à la fin\n\nLogo : choisir → placer → glisser → molette → valider",
        "zoom": "Zoom",
        "zoom_in": "Zoom +",
        "zoom_out": "Zoom -",
        "zoom_100": "Zoom 100%",
        "auto_enhance": "Amélioration automatique",
        "auto_enhance_strength": "Intensité amélioration auto %",
        "simple_brightness": "Luminosité simple %",
        "apply_simple_brightness": "Appliquer luminosité",
        "simple_contrast": "Contraste simple %",
        "apply_simple_contrast": "Appliquer contraste",
        "simple_sharpness": "Netteté simple %",
        "apply_simple_sharpness": "Appliquer netteté",
        "simple_saturation": "Saturation simple %",
        "simple_bw": "Noir et blanc %",
        "apply_simple_bw": "Appliquer noir et blanc",
        "apply_simple_bw_contrast": "Appliquer N&B contrasté",
        "skin_redness_strength": "Anti rouge / orange / rosé peau %",
        "skin_redness_neutralize": "Neutraliser rouge / orange / rosé peau",
        "skin_redness_done": "Tons rouges/orangés/rosés de la peau neutralisés",
        "light": "Éclairage",
        "contrast": "Contraste",
        "sharp": "Netteté",
        "bw": "Noir et blanc",
        "bw_contrast": "Noir et blanc contrasté",
        "saturation": "Saturation",
        "apply_saturation": "Appliquer la saturation",
        "sat_50": "Réduire à 50%",
        "sat_150": "Augmenter à 150%",
        "sat_reset": "Remettre à 100%",
        "crop": "Activer le recadrage",
        "apply_crop": "Valider le recadrage",
        "crop_aspect": "Format de cadrage",
        "crop_aspect_free": "Libre",
        "crop_aspect_square": "Carre 1:1",
        "crop_aspect_4_5": "Portrait 4:5",
        "crop_aspect_5_4": "Paysage 5:4",
        "crop_aspect_3_2": "Photo 3:2",
        "crop_aspect_2_3": "Portrait 2:3",
        "crop_aspect_16_9": "Ecran 16:9",
        "crop_aspect_9_16": "Vertical 9:16",
        "rotation": "Rotation",
        "rotate_left": "Rotation gauche 90°",
        "rotate_right": "Rotation droite 90°",
        "manual_rotation": "Rotation manuelle en degrés",
        "apply_rotation": "Appliquer la rotation",
        "reset_rotation": "Remettre rotation à 0°",
        "resize": "Taille de la photo",
        "resize_label": "Redimensionnement en %",
        "apply_resize": "Redimensionner la photo",
        "resize_50": "Réduire à 50%",
        "resize_200": "Agrandir à 200%",
        "resize_reset": "Remettre la taille à 100%",
        "skin": "Retouche peau / visage",
        "skin_perfect": "Pinceau peau parfaite",
        "skin_perfect_power": "Intensité peau parfaite",
        "skin_perfect_active": "Pinceau peau parfaite activé : peins sur la peau. L’outil continue jusqu’à ce que tu l’arrêtes.",
        "skin_tone": "Uniformiser zone de peau",
        "skin_tone_power": "Intensité uniformisation zone",
        "skin_tone_active": "Uniformiser zone de peau activé : peins sur un bleu, une tache ou une zone de couleur irrégulière. La couleur de référence vient juste autour du cercle.",
        "skin_perfect_auto_face": "Peau visage",
        "skin_perfect_auto_body": "Peau corps",
        "skin_perfect_auto_legs": "Peau jambes",
        "skin_perfect_adjust": "Effet peau parfaite",
        "skin_tone_auto_face": "Teint visage",
        "skin_tone_auto_body": "Teint corps",
        "skin_tone_auto_legs": "Teint jambes",
        "skin_face_strength": "Intensité visage",
        "skin_body_strength": "Intensité corps",
        "skin_legs_strength": "Intensité jambes",
        "white_bg_auto": "Améliorer fond blanc",
        "black_bg_auto": "Améliorer fond noir",
        "bg_white_done": "Fond blanc amélioré",
        "bg_black_done": "Fond noir amélioré",
        "bg_no_white": "Aucun fond blanc probable détecté",
        "bg_no_black": "Aucun fond noir probable détecté",
        "skin_tone_adjust": "Teint peau",
        "brush_small": "Petite zone",
        "brush_medium": "Zone moyenne",
        "brush_large": "Grande zone",
        "skin_auto_face_done": "Correction peau visage appliquée",
        "skin_auto_done": "Correction peau corps appliquée",
        "skin_auto_legs_done": "Correction peau jambes appliquée",
        "skin_tone_auto_face_done": "Teint visage appliqué",
        "skin_tone_auto_done": "Teint corps appliqué",
        "skin_tone_auto_legs_done": "Teint jambes appliqué",
        "smooth": "Lisser la peau au pinceau",
        "heal": "Corriger imperfection",
        "skin_beauty": "Embellir la peau automatiquement",
        "skin_beauty_power": "Niveau embellissement peau",
        "skin_beauty_done": "Peau embellie automatiquement",
        "skin_makeup_all": "Teint uniforme / maquillage peau",
        "skin_makeup_area": "Peindre / ajouter zone peau",
        "skin_makeup_area_erase": "Effacer zone peau au pinceau",
        "skin_makeup_area_apply": "Appliquer maquillage sur zone peau choisie",
        "skin_makeup_area_clear": "Réinitialiser zone peau",
        "skin_makeup_power": "Niveau maquillage peau",
        "skin_makeup_done": "Teint de peau uniformisé",
        "skin_makeup_area_active": "Peins sur la peau à corriger avec le cercle du pinceau. La taille du cercle se règle avec « Taille pinceau ». La zone reste mémorisée : tu peux appliquer plusieurs fois sans recommencer.",
        "skin_makeup_area_erase_active": "Peins avec le cercle sur la partie à enlever de la zone peau.",
        "skin_makeup_area_empty": "Peins d’abord une zone peau, puis applique le maquillage.",
        "skin_makeup_area_cleared": "Zone peau réinitialisée",
        "skin_makeup_area_painted": "Zone peau mémorisée",
        "brush_size": "Taille pinceau",
        "heal_strength": "Intensité corriger imperfection",
        "smooth_power": "Force lissage pinceau",
        "stop_tool": "Arrêter l'outil en cours",
        "text": "Texte",
        "text_to_write": "Texte à écrire",
        "font": "Police de caractères",
        "font_note": "Les polices fournies sont intégrées au Photo Tool. Les polices système apparaissent seulement si elles sont disponibles sur l'appareil.",
        "text_size": "Grosseur des caractères",
        "text_color": "Choisir la couleur du texte",
        "text_mode": "Ajouter du texte (aperçu souris + clic gauche)",
        "clear_text": "Retirer le dernier texte posé",
        "logo": "Ajout: Logo / Copyright / Objet",
        "logo_choice": "Choix du logo ou de l'objet",
        "logo_simple_round": "Logo Simple rond",
        "logo_minimal_mouth": "Logo Minimal bouche",
        "logo_glossy_gothic": "Logo Glossy gothique",
        "logo_wide_banner": "Logo large / bannière",
        "custom_logo": "Télécharger votre Logo / Objet.",
        "logo_size": "Taille du logo / objet",
        "logo_opacity": "Transparence du logo / objet",
        "logo_black_to_white": "Inverser noir en blanc pour le logo / objet",
        "clear_logo_preview": "Retirer l'Aperçu du Logo",
        "apply_logo": "Appliquer Logo / Objet",
        "undo_logo_apply": "Annuler application du logo",
        "logo_preview_empty": "Aucun logo / objet choisi",
        "bg": "Changer le fond",
        "bg_help": "Détourez d'abord la modèle avec l'IA, puis choisissez une couleur ou une photo de fond.",
        "bg_tolerance": "Tolérance du détourage",
        "bg_color": "Choisir la couleur unie de fond",
        "bg_photo": "Choisir une photo de fond",
        "remove_bg_photo": "Oublier la photo de fond",
        "replace_white": "Remplacer le fond blanc",
        "replace_black": "Remplacer le fond noir",
        "replace_green": "Remplacer le fond vert",
        "replace_auto": "Remplacer le fond auto (coins)",
        "replace_ai_matte": "Découpage / Fond",
        "replace_ai_matte_alt": "Découpage alternatif",
        "bg_background_rotation": "Rotation du fond",
        "bg_subject_size": "Taille de la modèle sur le fond",
        "bg_subject_rotation": "Rotation de la modèle",
        "bg_apply_placement": "Appliquer fond",
        "bg_cancel_placement": "Annuler fond",
        "bg_placement_active": "Fond actif : glissez la modèle sur le fond, ajustez sa taille, puis appliquez.",
        "bg_placement_done": "Fond appliqué",
        "bg_placement_cancelled": "Fond annulé",
        "bg_no_placement": "Aucun fond actif",
        "bg_ai_start": "Découpage IA local en cours... La première fois peut être plus longue.",
        "bg_ai_done": "Découpage / fond prêt",
        "bg_ai_alt_done": "Découpage alternatif prêt",
        "bg_ai_missing": "Le module IA local rembg n'est pas installé dans cette version.",
        "cleanup_color": "Choisir couleur de correction",
        "cleanup_pick_color": "Pipette couleur",
        "cleanup_pick_active": "Pipette active : cliquez dans la photo pour choisir la couleur de correction",
        "cleanup_color_set": "Couleur de correction choisie",
        "white_brush_beta": "Pinceau couleur",
        "white_rect_beta": "Rectangle couleur",
        "white_ellipse_beta": "Cercle / ellipse couleur",
        "white_lasso_beta": "Lasso couleur",
        "white_bucket_beta": "Seau couleur",
        "cleanup_opacity": "Opacité correction",
        "transparent_lasso_beta": "Lasso transparent",
        "recover_lasso_beta": "Lasso recuperer sujet",
        "cleanup_lasso_expand": "Marge lasso",
        "cleanup_bucket_tolerance": "Tolérance seau",
        "white_cleanup_active": "Outil couleur activé : corrige le fond avec la couleur choisie",
        "white_shape_active": "Trace la zone à remplir, puis relâche la souris",
        "white_lasso_active": "Trace une boucle autour de la zone à remplir, puis relâche",
        "white_bucket_active": "Seau actif : cliquez dans une zone enfermée pour la remplir avec la couleur choisie",
        "transparent_lasso_active": "Trace une boucle autour du fond a enlever, puis relache",
        "recover_lasso_active": "Trace une boucle sur une partie du sujet coupee a recuperer",
        "white_cleanup_done": "Zone remplie",
        "transparent_cleanup_done": "Zone rendue transparente",
        "recover_cleanup_done": "Zone du sujet recuperee",
        "recover_source_missing": "Aucune source de recuperation disponible. Lance d'abord un decoupage IA et clique Appliquer fond.",
        "advanced_colors": "Réglages couleur avancés",
        "auto_color": "Couleur automatique douce",
        "manual_saturation": "Saturation avancée",
        "advanced_brightness": "Luminosité avancée",
        "advanced_contrast": "Contraste avancé",
        "advanced_sharpness": "Netteté avancée",
        "advanced_clarity": "Clarté / relief",
        "advanced_denoise": "Réduction du grain",
        "advanced_vibrance": "Vibrance",
        "advanced_shadows": "Ombres",
        "advanced_highlights": "Hautes lumières",
        "temperature": "Température chaud / froid",
        "tint": "Teinte vert / magenta",
        "red": "Rouge",
        "green": "Vert",
        "blue": "Bleu",
        "apply_colors": "Appliquer couleurs manuelles",
        "reset_colors": "Remettre curseurs couleurs à zéro",

        "help_title": "Aide interactive",
        "help_intro": "Clique un sujet à gauche. Chaque section explique à quoi sert l’outil et comment l’utiliser, étape par étape.",
        "help_topic_open": "Fichier : ouvrir / enregistrer",
        "help_topic_open_text": "Utilise l’onglet Fichier pour commencer et terminer ton travail.\n\n1. Clique « Ouvrir une photo » et choisis ton image.\n2. Si une photo est déjà ouverte, le bouton devient « Nouvelle photo » et te demande confirmation avant de remplacer l’image.\n3. Quand tu as terminé, choisis le format voulu : JPEG, PNG, WebP ou PDF.\n4. Si tu veux repartir de la photo chargée au départ, clique « Revenir à l’image originale ».\n\nConseil : JPEG est très compatible, PNG garde une excellente qualité, WebP est pratique pour le web et PDF crée une page avec la photo.",
        "help_topic_basic": "Corrections simples",
        "help_topic_basic_text": "L’onglet Corrections simples sert aux retouches rapides sans doublonner les réglages avancés.\n\nAmélioration automatique : règle l’intensité, puis clique une fois. L’outil améliore couleur, contraste et netteté sans forcer le fond.\n\nAnti rouge / orange / rosé peau : règle l’intensité, puis clique le bouton pour enlever une dominante trop rouge, orange ou rose sur la peau. L’outil prend une référence dans la peau détectée, surtout le visage si possible, pour respecter la pigmentation de la personne au lieu de transformer tous les teints vers la même couleur.\n\nPeau visage : uniformise le visage avec un léger effet porcelaine et réduit les ombres du bas du visage qui peuvent donner un effet barbe, tout en protégeant nez, yeux et bouche. Le curseur Intensité visage règle la force.\n\nPeau corps et Peau jambes : utilisent leurs propres intensités. Peau jambes est surtout utile si le modèle ne porte pas de bas nylon couleur peau.\n\nTeint visage, Teint corps et Teint jambes : donnent un effet fond de teint/bronzage progressif basé sur la couleur de peau détectée, moins orangé et plus naturel.\n\nCorriger imperfection : ajuste « Taille pinceau » et « Intensité corriger imperfection », clique « Corriger imperfection », puis clique directement sur une petite marque. Travaille par petits clics.\n\nPour luminosité, contraste, saturation et netteté, utilise Réglages avancés. Pour le noir et blanc, utilise Filtres premium.\n\nArrêter l’outil en cours : clique ce bouton quand tu as fini de peindre ou de corriger.",
        "help_topic_crop": "Crop + Rotation",
        "help_topic_crop_text": "L’onglet Crop + Rotation sert à cadrer et redresser la photo.\n\nFormat de cadrage : choisis Libre si tu veux dessiner n’importe quelle forme, ou choisis un format standard comme 1:1, 4:5, 16:9 ou 9:16. Le rectangle garde alors automatiquement le bon ratio pendant que tu glisses.\n\nRecadrage : clique « Activer le recadrage », garde le bouton de souris enfoncé et glisse pour dessiner la zone à garder. Relâche, vérifie le cadre, puis ajuste-le au besoin : glisse dans le cadre pour le déplacer, ou glisse un des quatre coins rouges pour l’agrandir ou le réduire. Clique ensuite « Valider le recadrage ».\n\nRotation rapide : utilise « Rotation gauche 90° » ou « Rotation droite 90° » si la photo est complètement tournée.\n\nRotation manuelle : déplace le curseur en degrés pour redresser une photo légèrement inclinée. Vérifie l’aperçu, puis clique « Appliquer la rotation ». « Remettre rotation à 0° » annule l’aperçu de rotation avant application.",
    "help_topic_size_tab": "Taille",
    "help_topic_size_tab_text": "L’onglet Taille sert à changer les dimensions de la photo.\n\nDéplace le curseur « Redimensionnement en % », puis clique « Redimensionner la photo ». 100 % garde la taille actuelle, 50 % réduit la photo et 200 % l’agrandit.\n\nLes boutons 50 %, 200 % et 100 % servent de raccourcis. Utilise-les si tu veux aller vite, puis valide avec le bouton de redimensionnement.",
    "help_topic_filters": "Filtres premium",
    "help_topic_filters_text": "L’onglet Filtres premium applique des styles déjà préparés.\n\n1. Clique le filtre voulu.\n2. Ajuste « Intensité du filtre ».\n3. Regarde l’aperçu directement sur la photo.\n4. Clique « Valider le filtre » seulement si le résultat te plaît.\n5. Clique « Effacer l’aperçu filtre » pour enlever l’aperçu sans modifier la photo.\n\nNoir et blanc donne un rendu sobre et classique. Vampire Noir B&W est plus contrasté, plus dramatique et plus net. Les filtres sont faits pour donner une ambiance rapidement; garde une intensité modérée si tu veux un rendu plus naturel.",
        "help_topic_retouch": "Corriger imperfection",
        "help_topic_retouch_text": "Corriger imperfection sert à retirer de petites marques locales : bouton, poussière, petite tache ou détail gênant.\n\n1. Va dans Corrections simples.\n2. Ajuste « Taille pinceau » un peu plus grand que la marque.\n3. Clique « Corriger imperfection ».\n4. Clique une fois sur la marque. Pour une zone plus grande, fais plusieurs petits clics au lieu d’un gros passage.\n5. Clique « Arrêter l’outil en cours » quand tu as terminé.\n\nTravaille petit à petit : c’est ce qui donne le résultat le plus propre.",
        "help_topic_text": "Texte",
        "help_topic_text_text": "L’onglet Texte sert à poser une inscription sur la photo.\n\n1. Écris ton texte dans le champ.\n2. Choisis la police. Les polices normales ajoutées sont Lora, Playfair Display, Montserrat, Libre Baskerville et Great Vibes.\n3. Ajuste la grosseur des caractères.\n4. Choisis la couleur du texte.\n5. Clique « Ajouter du texte ».\n6. Déplace la souris sur la photo : le texte suit le curseur en aperçu.\n7. Clique à l’endroit exact où tu veux le poser.\n\n« Retirer le dernier texte posé » enlève le texte seulement si la dernière modification appliquée était un texte. Sinon, utilise « Annuler la modification » pour reculer étape par étape.",
        "help_topic_logo": "Ajout: Logo / Copyright / Objet",
        "help_topic_logo_tab": "Onglet Logo / Objet",
    "help_topic_logo_tab_text": "L’onglet Logo / Objet sert à ajouter un logo, un copyright, une signature, un accessoire ou un objet décoratif.",
    "help_topic_logo_text": "L’onglet Logo / Objet sert à poser un logo, une signature, un copyright, un accessoire ou un objet décoratif sur la photo.\n\n1. Clique un des 4 boutons de logos intégrés, ou clique « Télécharger votre Logo / Objet. » pour choisir ton propre fichier. Cliquer une deuxième fois sur le logo sélectionné le désélectionne.\n2. Dès qu’un logo ou objet est choisi, il apparaît automatiquement au centre de la photo avec un cadre visible. Glisse-le directement sur l’image pour le déplacer.\n3. Utilise « Taille du logo / objet » et « Transparence du logo / objet » pour ajuster l’aperçu. La molette de la souris peut aussi changer la taille pendant le placement.\n4. Active « Inverser noir en blanc » seulement si un logo sombre doit rester lisible sur une photo sombre.\n5. Si tu quittes l’onglet Logo / Objet pour utiliser une autre fonction, le choix et l’aperçu disparaissent automatiquement. Quand tu reviens, l’onglet est vide comme au premier affichage.\n6. Clique « Appliquer Logo / Objet » pour l’inscrire dans la photo. Le choix et l’aperçu sont ensuite retirés volontairement : il faut resélectionner un logo pour en poser un autre.\n7. « Retirer l’Aperçu du Logo » enlève seulement le choix actuel et l’aperçu, sans retirer un logo déjà appliqué. « Annuler application du logo » annule le dernier logo appliqué même si l’aperçu a été retiré.",
        "help_topic_skin_advanced": "Peau avancée",
        "help_topic_skin_advanced_text": "L’onglet Peau avancée sert aux corrections plus contrôlées.\n\nPinceau peau parfaite : règle l’intensité, choisis une taille de pinceau, puis peins doucement sur la peau. Utilise-le par petites zones pour garder les traits du visage.\n\nUniformiser zone de peau : utile pour corriger une zone qui tire vers le bleu, le rouge ou une couleur irrégulière. Peins sur la zone à corriger; l’application prend une référence proche autour du cercle.\n\nAuto peau corps entier et Auto teint corps entier : appliquent une correction globale sur la peau. Clique une fois, regarde le résultat, puis recommence seulement si nécessaire.\n\nZone peau à peindre / effacer : peins la zone à corriger, efface les débordements au besoin, puis applique le maquillage/uniformisation sur cette zone.\n\nArrête l’outil quand tu as fini pour éviter de peindre par accident.",
        "help_topic_background": "Découpage / Fond",
        "help_topic_background_text": "L’onglet Découpage / Fond sert à isoler le modèle et à remplacer le fond.\n\n1. Clique « Découpage / Fond » pour lancer le découpage IA local principal. Cette version utilise d’abord BiRefNet local, meilleur sur les fonds blancs, les cheveux, les vêtements pâles, les bottes et les ouvertures comme entre les jambes, sous les bras ou entre les doigts.\n2. La première utilisation peut être plus longue si le modèle IA doit être téléchargé une fois. Ensuite, il reste sur l’ordinateur.\n3. Si le résultat coupe trop ou si la photo est ambiguë, clique « Découpage alternatif ». Ce deuxième résultat utilise l’ancien modèle plus rapide et peut parfois mieux garder une silhouette complète.\n4. Choisis ensuite une couleur de fond avec « Choisir la couleur unie de fond », ou choisis une image avec « Choisir une photo de fond ».\n5. Si tu utilises une photo de fond, règle « Rotation du fond » avant d’appliquer.\n6. Glisse le modèle sur le fond pour le placer.\n7. Ajuste « Taille de la modèle sur le fond » et « Rotation de la modèle ».\n8. Clique « Appliquer fond » quand le résultat est bon.\n9. Clique « Annuler fond » si tu veux revenir avant d’appliquer.\n\nCet onglet sert au gros travail de découpage et de fond. Les retouches manuelles du fond sont maintenant dans l’onglet Corrections de fond.",
        "help_topic_background_correction": "Corrections de fond",
        "help_topic_background_correction_text": "L’onglet Corrections de fond sert à nettoyer manuellement les petites zones après le découpage.\n\nChoisir couleur de correction : choisis la couleur qui va remplir les zones à corriger. Exemple : blanc pour un fond blanc, noir pour un fond noir, ou une couleur proche du décor.\n\nPipette couleur : clique dans la photo pour prendre une couleur moyenne autour du curseur. C’est utile pour garder une texture ou une teinte proche du fond au lieu d’utiliser une couleur trop plate.\n\nPinceau couleur : peins directement sur les petites zones à corriger. Commence avec une petite taille et passe plusieurs fois si nécessaire.\n\nRectangle couleur : glisse pour remplir une zone rectangulaire. Utile pour les coins ou les bandes droites.\n\nCercle / ellipse couleur : glisse pour remplir une zone ronde ou ovale. Utile autour d’une forme douce.\n\nLasso couleur : trace autour d’une zone irrégulière, puis relâche pour la remplir. C’est l’outil le plus pratique pour les contours moins droits.\n\nPetite zone, Zone moyenne, Grande zone et Taille pinceau : choisis la taille selon la précision voulue. Petite zone pour les bords, grande zone pour nettoyer plus vite.\n\nArrêter l’outil en cours : désactive le pinceau, la pipette, les formes ou le lasso quand tu as terminé.",
        "help_topic_advanced": "Réglages avancés",
        "help_topic_save": "Enregistrer",
        "help_topic_advanced_text": "L’onglet Réglages avancés sert aux ajustements précis de l’image complète.\n\nSaturation avancée : augmente ou réduit l’intensité de toutes les couleurs. Trop haut peut rendre la peau artificielle; avance graduellement.\n\nVibrance : augmente surtout les couleurs faibles en protégeant mieux les couleurs déjà fortes. C’est souvent plus naturel que la saturation.\n\nOmbres : éclaircit ou assombrit surtout les zones foncées, sans changer toute la photo.\n\nHautes lumières : récupère ou accentue surtout les zones très claires.\n\nLuminosité avancée : éclaircit ou assombrit la photo au complet.\n\nContraste avancé : augmente la différence entre zones claires et foncées. Utile si la photo semble fade.\n\nNetteté avancée : rend les détails plus visibles. Utilise-la avec modération pour éviter un effet dur.\n\nTempérature chaud / froid : vers chaud, la photo devient plus dorée; vers froid, elle devient plus bleutée.\n\nTeinte vert / magenta : corrige une dominante générale. Vers vert, l’image tire plus vert; vers magenta, elle tire plus rose. C’est une correction de balance couleur.\n\nRouge, Vert, Bleu : ajustent directement chaque canal couleur. C’est plus technique que la teinte vert/magenta; utilise ces curseurs pour des corrections fines.\n\nRemettre curseurs couleurs à zéro : replace les réglages avancés au neutre.",
        "help_topic_save_text": "Pour sauvegarder, retourne dans l’onglet Fichier et choisis directement le format voulu : « Enregistrer JPEG », « Enregistrer PNG », « Enregistrer WebP » ou « Enregistrer PDF ». Le WebP est pratique pour le web, le PNG garde une excellente qualité, le JPEG est largement compatible et le PDF crée une page contenant la photo.",
        "help_topic_undo": "Annuler / image originale",
        "help_topic_undo_text": "Le bouton ↶ Annuler la modification annule la dernière étape. Si tu recliques, il recule encore d’une étape, comme un Ctrl+Z classique. « Revenir à l’image originale » remet la photo comme au moment de l’ouverture.",        "no_photo": "Aucune photo chargée",
        "load_first": "Charge une photo avant d'utiliser cet outil.",
        "photo_loaded": "Photo chargée",
        "saved": "Image sauvegardée",
        "nothing_to_undo": "Rien à annuler",
        "undo_done": "Dernière modification annulée",
        "original_restored": "Image originale restaurée",
        "tool_stopped": "Outil arrêté",
        "crop_active": "Recadrage activé : glisse sur la photo puis valide le recadrage",
        "smooth_active": "Lissage pinceau activé : peins doucement sur la peau",
        "heal_active": "Correction boutons activée : clique sur les boutons",
        "text_active": "Mode texte activé : survole la photo puis clique gauche pour poser le texte",
        "logo_active": "Aperçu du logo activé : glisse le logo puis utilise la molette",
        "logo_applied": "Logo / copyright ajouté sur la photo",
        "write_text_first": "Écris un texte avant de cliquer sur la photo.",
        "last_edit_not_text": "La dernière modification n’est pas un texte. Utilise Annuler pour reculer étape par étape.",
        "choose_logo_first": "Choisis d'abord un logo copyright.",
    },
    "en": {
        "language_name": "English",
        "title": "Latex Vamp Photo Tool",
        "subtitle": "Open • Retouch • Add text/logo • Save",
        "open_photo": "Open photo",
        "new_photo": "New photo",
        "new_photo_warning": "If you open a new photo without saving the previous one, you will lose your edits.",
        "continue_action": "Continue",
        "cancel_action": "Cancel",
        "save_as": "Save as...",
        "save_jpeg": "Save JPEG",
        "save_png": "Save PNG",
        "save_webp": "Save WebP",
        "save_pdf": "Save PDF",
        "quick_save": "Quick save PNG",
        "undo": "Undo",
        "reset": "Return to original image",
        "return_original": "Return to original image",
        "checkpoint_save": "Create restore point",
        "checkpoint_restore": "Return to restore point",
        "checkpoint_saved": "Restore point saved",
        "checkpoint_missing": "No restore point saved",
        "checkpoint_restored": "Returned to restore point",
        "file_tab": "File",
        "basic_tab": "Simple",
        "crop_tab": "Crop + Size",
        "text_logo_tab": "Text + Logo",
        "background_tab": "Background",
        "advanced_tab": "Advanced",
        "help_tab": "Help",
        "skin_advanced_tab": "Advanced skin",
        "quick_guide": "Quick guide",
        "quick_guide_text": "1) Open a photo\n2) Choose a tool\n3) Click or drag on the photo\n4) Save at the end\n\nLogo: choose → place → drag → mouse wheel → apply",
        "zoom": "Zoom",
        "zoom_in": "Zoom +",
        "zoom_out": "Zoom -",
        "zoom_100": "Zoom 100%",
        "auto_enhance": "Auto enhance",
        "auto_enhance_strength": "Auto enhance intensity %",
        "simple_brightness": "Simple brightness %",
        "apply_simple_brightness": "Apply brightness",
        "simple_contrast": "Simple contrast %",
        "apply_simple_contrast": "Apply contrast",
        "simple_sharpness": "Simple sharpness %",
        "apply_simple_sharpness": "Apply sharpness",
        "simple_saturation": "Simple saturation %",
        "simple_bw": "Black and white %",
        "apply_simple_bw": "Apply black and white",
        "apply_simple_bw_contrast": "Apply contrast B&W",
        "skin_redness_strength": "Anti red / orange / pink skin %",
        "skin_redness_neutralize": "Neutralize red / orange / pink skin",
        "skin_redness_done": "Red/orange/pink skin tones neutralized",
        "light": "Light",
        "contrast": "Contrast",
        "sharp": "Sharpness",
        "bw": "Black and white",
        "bw_contrast": "High contrast black and white",
        "saturation": "Saturation",
        "apply_saturation": "Apply saturation",
        "sat_50": "Reduce to 50%",
        "sat_150": "Increase to 150%",
        "sat_reset": "Reset to 100%",
        "crop": "Enable crop",
        "apply_crop": "Apply crop",
        "crop_aspect": "Crop format",
        "crop_aspect_free": "Free",
        "crop_aspect_square": "Square 1:1",
        "crop_aspect_4_5": "Portrait 4:5",
        "crop_aspect_5_4": "Landscape 5:4",
        "crop_aspect_3_2": "Photo 3:2",
        "crop_aspect_2_3": "Portrait 2:3",
        "crop_aspect_16_9": "Screen 16:9",
        "crop_aspect_9_16": "Vertical 9:16",
        "rotation": "Rotation",
        "rotate_left": "Rotate left 90°",
        "rotate_right": "Rotate right 90°",
        "manual_rotation": "Manual rotation in degrees",
        "apply_rotation": "Apply rotation",
        "reset_rotation": "Reset rotation to 0°",
        "resize": "Photo size",
        "resize_label": "Resize in %",
        "apply_resize": "Resize photo",
        "resize_50": "Reduce to 50%",
        "resize_200": "Increase to 200%",
        "resize_reset": "Reset size to 100%",
        "skin": "Skin / face retouch",
        "skin_perfect": "Perfect skin brush",
        "skin_perfect_power": "Perfect skin intensity",
        "skin_perfect_active": "Perfect skin brush enabled: paint over skin. The tool stays active until you stop it.",
        "skin_tone": "Even out skin area",
        "skin_tone_power": "Area evening intensity",
        "skin_tone_active": "Even out skin area enabled: paint over a bruise, mark, or uneven skin color. The reference color is sampled close around the brush circle.",
        "skin_perfect_auto_face": "Face skin",
        "skin_perfect_auto_body": "Body skin",
        "skin_perfect_auto_legs": "Legs skin",
        "skin_perfect_adjust": "Perfect skin effect",
        "skin_tone_auto_face": "Face tone",
        "skin_tone_auto_body": "Body tone",
        "skin_tone_auto_legs": "Legs tone",
        "skin_face_strength": "Face intensity",
        "skin_body_strength": "Body intensity",
        "skin_legs_strength": "Legs intensity",
        "white_bg_auto": "Improve white background",
        "black_bg_auto": "Improve black background",
        "bg_white_done": "White background improved",
        "bg_black_done": "Black background improved",
        "bg_no_white": "No likely white background detected",
        "bg_no_black": "No likely black background detected",
        "skin_tone_adjust": "Skin tone",
        "brush_small": "Small area",
        "brush_medium": "Medium area",
        "brush_large": "Large area",
        "skin_auto_face_done": "Face skin correction applied",
        "skin_auto_done": "Body skin correction applied",
        "skin_auto_legs_done": "Legs skin correction applied",
        "skin_tone_auto_face_done": "Face tone applied",
        "skin_tone_auto_done": "Body tone applied",
        "skin_tone_auto_legs_done": "Legs tone applied",
        "smooth": "Brush skin smoothing",
        "heal": "Correct imperfection",
        "skin_beauty": "Auto beautify skin",
        "skin_beauty_power": "Skin beauty level",
        "skin_beauty_done": "Skin automatically beautified",
        "skin_makeup_all": "Even skin tone / makeup finish",
        "skin_makeup_area": "Paint / add skin area",
        "skin_makeup_area_erase": "Erase skin area with brush",
        "skin_makeup_area_apply": "Apply makeup to selected skin area",
        "skin_makeup_area_clear": "Reset skin area",
        "skin_makeup_power": "Skin makeup level",
        "skin_makeup_done": "Skin tone evened",
        "skin_makeup_area_active": "Paint over the skin with the brush circle. The circle size follows Brush size. The area stays saved, so you can apply the effect again without starting over.",
        "skin_makeup_area_erase_active": "Paint with the circle over the part to remove from the saved skin area.",
        "skin_makeup_area_empty": "Paint a skin area first, then apply the makeup effect.",
        "skin_makeup_area_cleared": "Skin area reset",
        "skin_makeup_area_painted": "Skin area saved",
        "brush_size": "Brush size",
        "heal_strength": "Correct imperfection strength",
        "smooth_power": "Brush smoothing strength",
        "stop_tool": "Stop current tool",
        "text": "Text",
        "text_to_write": "Text to write",
        "font": "Font",
        "font_note": "Supplied fonts are bundled with Photo Tool. System fonts appear only when they are available on the device.",
        "text_size": "Character size",
        "text_color": "Choose text color",
        "text_mode": "Add text (click on photo)",
        "clear_text": "Remove last placed text",
        "logo": "Add: Logo / Copyright / Object",
        "logo_choice": "Logo or object choice",
        "logo_simple_round": "Round Simple Logo",
        "logo_minimal_mouth": "Minimal Mouth Logo",
        "logo_glossy_gothic": "Glossy Gothic Logo",
        "logo_wide_banner": "Wide Banner Logo",
        "custom_logo": "Upload your Logo / Object.",
        "logo_size": "Logo / object size",
        "logo_opacity": "Logo / object opacity",
        "logo_black_to_white": "Invert black to white for logo / object",
        "clear_logo_preview": "Remove Logo Preview",
        "apply_logo": "Apply Logo / Object",
        "undo_logo_apply": "Undo logo application",
        "logo_preview_empty": "No logo / object selected",
        "bg": "Change background",
        "bg_help": "First cut out the model with AI, then choose a solid color or background photo.",
        "bg_tolerance": "Cutout tolerance",
        "bg_color": "Choose solid background color",
        "bg_photo": "Choose background photo",
        "remove_bg_photo": "Forget background photo",
        "replace_white": "Replace white background",
        "replace_black": "Replace black background",
        "replace_green": "Replace green background",
        "replace_auto": "Replace auto background (corners)",
        "replace_ai_matte": "Cutout / Background",
        "replace_ai_matte_alt": "Alternative cutout",
        "bg_background_rotation": "Background rotation",
        "bg_subject_size": "Model size on background",
        "bg_subject_rotation": "Model rotation",
        "bg_apply_placement": "Apply background",
        "bg_cancel_placement": "Cancel background",
        "bg_placement_active": "Background active: drag the model on the background, adjust size, then apply.",
        "bg_placement_done": "Background applied",
        "bg_placement_cancelled": "Background cancelled",
        "bg_no_placement": "No active background",
        "bg_ai_start": "Local AI cutout running... First run can take longer.",
        "bg_ai_done": "Cutout / background ready",
        "bg_ai_alt_done": "Alternative cutout ready",
        "bg_ai_missing": "The local AI rembg module is not installed in this version.",
        "cleanup_color": "Choose correction color",
        "cleanup_pick_color": "Color picker",
        "cleanup_pick_active": "Color picker active: click the photo to choose the correction color",
        "cleanup_color_set": "Correction color selected",
        "white_brush_beta": "Color brush",
        "white_rect_beta": "Color rectangle",
        "white_ellipse_beta": "Color circle / ellipse",
        "white_lasso_beta": "Color lasso",
        "white_bucket_beta": "Color bucket",
        "cleanup_opacity": "Correction opacity",
        "cleanup_bucket_tolerance": "Bucket tolerance",
        "transparent_lasso_beta": "Transparent lasso",
        "recover_lasso_beta": "Recover subject lasso",
        "cleanup_lasso_expand": "Lasso margin",
        "white_cleanup_active": "Color tool enabled: clean the background with the selected color",
        "white_shape_active": "Draw the area to fill, then release the mouse",
        "white_lasso_active": "Trace a loop around the area to fill, then release",
        "white_bucket_active": "Bucket active: click inside an enclosed area to fill it with the selected color",
        "transparent_lasso_active": "Trace a loop around the background to remove, then release",
        "recover_lasso_active": "Trace a loop over a cut-off subject area to recover",
        "white_cleanup_done": "Zone filled",
        "transparent_cleanup_done": "Zone made transparent",
        "recover_cleanup_done": "Subject area recovered",
        "recover_source_missing": "No recovery source available. Run an AI cutout first and click Apply background.",
        "advanced_colors": "Advanced color settings",
        "auto_color": "Soft auto color",
        "manual_saturation": "Advanced saturation",
        "advanced_brightness": "Advanced brightness",
        "advanced_contrast": "Advanced contrast",
        "advanced_sharpness": "Advanced sharpness",
        "advanced_clarity": "Clarity / depth",
        "advanced_denoise": "Noise reduction",
        "advanced_vibrance": "Vibrance",
        "advanced_shadows": "Shadows",
        "advanced_highlights": "Highlights",
        "temperature": "Warm / cool temperature",
        "tint": "Green / magenta tint",
        "red": "Red",
        "green": "Green",
        "blue": "Blue",
        "apply_colors": "Apply manual colors",
        "reset_colors": "Reset color sliders",

        "help_title": "Interactive help",
        "help_intro": "Click a topic on the left. Each section explains what the tool is for and how to use it step by step.",
        "help_topic_open": "File: open / save",
        "help_topic_open_text": "Use the File tab to start and finish your work.\n\n1. Click “Open photo” and choose your image.\n2. If a photo is already open, the button becomes “New photo” and asks before replacing the image.\n3. When you are done, choose the format you want: JPEG, PNG, WebP, or PDF.\n4. To return to the photo as it was when opened, click “Return to original image”.\n\nTip: JPEG is widely compatible, PNG keeps excellent quality, WebP is useful for the web, and PDF creates a page containing the photo.",
        "help_topic_basic": "Simple corrections",
        "help_topic_basic_text": "The Simple corrections tab is for quick retouching without duplicating the advanced controls.\n\nAuto enhancement: set the intensity, then click once. It improves color, contrast, and sharpness without forcing the background.\n\nAnti red / orange / pink skin: set the intensity, then click the button to reduce an excessive red, orange, or pink cast on skin. The tool estimates a reference from detected skin, preferably the face, so it respects the person’s pigmentation instead of forcing every skin tone toward one color.\n\nFace skin: evens the face with a light porcelain finish and reduces lower-face shadows that can look like beard shadow, while protecting nose, eyes, and mouth. The Face intensity slider controls strength.\n\nBody skin and Legs skin use their own intensities. Legs skin is mainly useful when the model is not wearing skin-tone nylon stockings.\n\nFace tone, Body tone, and Legs tone create a gradual foundation/tan effect based on detected skin color, with less orange and a more natural result.\n\nCorrect imperfection: set Brush size and Correct imperfection strength, click Correct imperfection, then click directly on a small mark. Work in small clicks.\n\nFor brightness, contrast, saturation, and sharpness, use Advanced settings. For black and white, use Premium filters.\n\nStop current tool disables the active brush or correction tool.",
        "help_topic_crop": "Crop + Rotation",
        "help_topic_crop_text": "The Crop + Rotation tab is for framing and straightening the photo.\n\nCrop format: choose Free for any shape, or choose a standard format such as 1:1, 4:5, 16:9, or 9:16. The crop rectangle then keeps the correct ratio while you drag.\n\nCrop: click “Enable crop”, hold the mouse button, and drag to draw the area you want to keep. Release, check the frame, then adjust it if needed: drag inside the frame to move it, or drag one of the four red corners to resize it. Then click “Apply crop”.\n\nQuick rotation: use “Rotate left 90°” or “Rotate right 90°” if the photo is fully turned.\n\nManual rotation: move the degree slider to straighten a slightly tilted photo. Check the preview, then click “Apply rotation”. “Reset rotation to 0°” cancels the rotation preview before applying.",
    "help_topic_size_tab": "Size",
    "help_topic_size_tab_text": "The Size tab changes the dimensions of the photo.\n\nMove the “Resize in %” slider, then click “Resize photo”. 100% keeps the current size, 50% reduces the photo, and 200% enlarges it.\n\nThe 50%, 200%, and 100% buttons are shortcuts. Use them when you want to go faster, then apply the resize.",
    "help_topic_filters": "Premium filters",
    "help_topic_filters_text": "The Premium filters tab applies prepared visual styles.\n\n1. Click the filter you want.\n2. Adjust “Filter intensity”.\n3. Check the preview directly on the photo.\n4. Click “Apply filter” only if you like the result.\n5. Click “Clear filter preview” to remove the preview without changing the photo.\n\nBlack and white gives a clean classic monochrome look. Vampire Noir B&W is stronger, more dramatic, higher contrast, and sharper. Filters are made to create a mood quickly; keep the intensity moderate if you want a more natural result.",
        "help_topic_retouch": "Correct imperfection",
        "help_topic_retouch_text": "Correct imperfection is for removing small local marks: pimples, dust, tiny spots, or distracting details.\n\n1. Go to Simple corrections.\n2. Set “Brush size” slightly larger than the mark.\n3. Click “Correct imperfection”.\n4. Click once on the mark. For a larger area, use several small clicks instead of one large pass.\n5. Click “Stop current tool” when finished.\n\nWork gradually. That is what gives the cleanest result.",
        "help_topic_text": "Text",
        "help_topic_text_text": "The Text tab places writing on the photo.\n\n1. Type your text in the field.\n2. Choose the font. The added normal fonts are Lora, Playfair Display, Montserrat, Libre Baskerville, and Great Vibes.\n3. Adjust the character size.\n4. Choose the text color.\n5. Click “Add text”.\n6. Move the mouse over the photo: the text follows the cursor as a preview.\n7. Click exactly where you want to place it.\n\n“Remove last placed text” removes the text only if the latest applied edit was text. Otherwise, use “Undo last edit” to step backward.",
        "help_topic_logo": "Add: Logo / Copyright / Object",
        "help_topic_logo_tab": "Logo / Object tab",
    "help_topic_logo_tab_text": "The Logo / Object tab adds a logo, copyright mark, signature, accessory, or decorative object.",
    "help_topic_logo_text": "The Logo / Object tab places a logo, signature, copyright mark, accessory, or decorative object on the photo.\n\n1. Click one of the 4 built-in logo buttons, or click “Upload your Logo / Object.” to choose your own file. Clicking the selected logo again deselects it.\n2. As soon as a logo or object is selected, it appears centered on the photo with a visible frame. Drag it directly on the image to move it.\n3. Use “Logo / object size” and “Logo / object opacity” to adjust the preview. The mouse wheel can also change size while placing it.\n4. Enable “Invert black to white” only when a dark logo needs to stay visible on a dark photo.\n5. If you leave the Logo / Object tab to use another function, the selection and preview are cleared automatically. When you return, the tab is empty like the first time.\n6. Click “Apply Logo / Object” to write it into the photo. The selection and preview are then cleared on purpose: select a logo again to place another one.\n7. “Remove Logo Preview” clears only the current selection and preview; it does not remove an already applied logo. “Undo logo application” cancels the last applied logo even after the preview was cleared.",
        "help_topic_skin_advanced": "Advanced skin",
        "help_topic_skin_advanced_text": "The Advanced skin tab is for more controlled skin corrections.\n\nPerfect skin brush: set the intensity, choose a brush size, then paint gently over the skin. Work in small zones to preserve facial features.\n\nEven out skin area: useful for correcting a zone that looks too blue, red, or uneven. Paint over the area; the app samples a nearby reference color around the brush circle.\n\nAuto full-body skin and Auto full-body tone: apply a global correction to skin. Click once, check the result, then repeat only if needed.\n\nPaint / erase skin area: paint the zone to correct, erase overflow if needed, then apply the makeup/evening effect to that selected zone.\n\nStop the tool when finished so you do not paint by accident.",
        "help_topic_background": "Cutout / Background",
        "help_topic_background_text": "The Cutout / Background tab isolates the model and replaces the background.\n\n1. Click “Cutout / Background” to start the main local AI cutout. This version tries local BiRefNet first, which is stronger on white backgrounds, hair, pale clothing, boots, and openings such as between legs, under arms, or between fingers.\n2. The first use can take longer if the AI model has to download once. After that, it stays on the computer.\n3. If the result cuts too much or the photo is ambiguous, click “Alternative cutout”. This second result uses the older faster model and can sometimes keep a full silhouette better.\n4. Choose a solid background color with “Choose solid background color”, or choose an image with “Choose background photo”.\n5. If you use a background photo, set “Background rotation” before applying.\n6. Drag the model on the background to position it.\n7. Adjust “Model size on background” and “Model rotation”.\n8. Click “Apply background” when the result looks right.\n9. Click “Cancel background” if you want to return before applying.\n\nThis tab is for the main cutout and background work. Manual background cleanup is now in the Background corrections tab.",
        "help_topic_background_correction": "Background corrections",
        "help_topic_background_correction_text": "The Background corrections tab is for manually cleaning small zones after the cutout.\n\nChoose correction color: choose the color used to fill areas. Example: white for a white background, black for a black background, or a color close to the scene.\n\nColor picker: click the photo to sample an average color around the cursor. This helps keep a color or texture close to the background instead of using a flat color that stands out.\n\nColor brush: paint directly over small areas to correct them. Start with a small size and pass several times if needed.\n\nColor rectangle: drag to fill a rectangular area. Useful for corners or straight bands.\n\nColor circle / ellipse: drag to fill a round or oval area. Useful around softer shapes.\n\nColor lasso: trace around an irregular zone, then release to fill it. This is the best tool for uneven contours.\n\nSmall area, Medium area, Large area, and Brush size: choose the size based on the precision you need. Small for edges, large for faster cleanup.\n\nStop current tool: disables the brush, picker, shapes, or lasso when you are done.",
        "help_topic_advanced": "Advanced settings",
        "help_topic_save": "Save",
        "help_topic_advanced_text": "The Advanced settings tab is for precise adjustments to the full image.\n\nAdvanced saturation: increases or reduces all color intensity. Too high can make skin look artificial, so move gradually.\n\nVibrance: mainly increases weaker colors while protecting colors that are already strong. It is often more natural than saturation.\n\nShadows: lightens or darkens mainly the dark areas without changing the whole photo.\n\nHighlights: recovers or emphasizes mainly the very bright areas.\n\nAdvanced brightness: makes the whole photo brighter or darker.\n\nAdvanced contrast: increases the difference between light and dark areas. Useful when the photo looks flat.\n\nAdvanced sharpness: makes very fine details more visible. Use moderately to avoid a harsh look.\n\nClarity / depth: increases local contrast to add depth to details and shapes. It is useful for hair, clothing, latex, and textures without changing the whole brightness.\n\nNoise reduction: softens grain and harsh small pixels. Use lightly on portraits to keep the result natural.\n\nWarm / cool temperature: warmer makes the photo more golden; cooler makes it more blue.\n\nGreen / magenta tint: corrects a general color cast. Toward green, the image becomes greener; toward magenta, it becomes pinker. This is a color-balance correction.\n\nRed, Green, Blue: directly adjust each color channel. This is more technical than green/magenta tint; use these sliders for fine corrections.\n\nReset color sliders: returns advanced settings to neutral.",
        "help_topic_save_text": "To save, return to the File tab and choose the format directly: “Save JPEG”, “Save PNG”, “Save WebP” or “Save PDF”. WebP is useful for the web, PNG keeps excellent quality, JPEG is widely compatible and PDF creates a page containing the photo.",
        "help_topic_undo": "Undo / original image",
        "help_topic_undo_text": "The ↶ Undo last edit button cancels the most recent step. Clicking it again continues going back step by step, like standard Ctrl+Z. “Return to original image” restores the photo as it was when opened.",        "no_photo": "No photo loaded",
        "load_first": "Load a photo before using this tool.",
        "photo_loaded": "Photo loaded",
        "saved": "Image saved",
        "nothing_to_undo": "Nothing to undo",
        "undo_done": "Last edit undone",
        "original_restored": "Original image restored",
        "tool_stopped": "Tool stopped",
        "crop_active": "Crop enabled: drag on the photo, then apply crop",
        "smooth_active": "Brush smoothing enabled: paint gently on the skin",
        "heal_active": "Pimple removal enabled: click on pimples",
        "text_active": "Text mode enabled: click on the photo to place text",
        "logo_active": "Logo preview enabled: drag the logo, then use the mouse wheel",
        "logo_applied": "Logo / copyright added to photo",
        "write_text_first": "Write text before clicking on the photo.",
        "last_edit_not_text": "The latest edit is not text. Use Undo to step backward.",
        "choose_logo_first": "Choose a copyright logo first.",
    },
    "es": {
        "language_name": "Español",
        "title": "Latex Vamp Photo Tool",
        "subtitle": "Abrir • Retocar • Añadir texto/logo • Guardar",
        "open_photo": "Abrir foto",
        "new_photo": "Nueva foto",
        "new_photo_warning": "Si abres una nueva foto sin guardar la anterior, perderás tus modificaciones.",
        "continue_action": "Continuar",
        "cancel_action": "Cancelar",
        "save_as": "Guardar como...",
        "save_jpeg": "Guardar JPEG",
        "save_png": "Guardar PNG",
        "save_webp": "Guardar WebP",
        "save_pdf": "Guardar PDF",
        "quick_save": "Guardar rápido PNG",
        "undo": "Deshacer",
        "reset": "Volver a la imagen original",
        "return_original": "Volver a la imagen original",
        "checkpoint_save": "Crear punto de retorno",
        "checkpoint_restore": "Volver al punto de retorno",
        "checkpoint_saved": "Punto de retorno guardado",
        "checkpoint_missing": "No hay punto de retorno guardado",
        "checkpoint_restored": "Vuelto al punto de retorno",
        "file_tab": "Archivo",
        "basic_tab": "Simple",
        "crop_tab": "Recorte + Tamaño",
        "text_logo_tab": "Texto + Logo",
        "background_tab": "Fondo",
        "advanced_tab": "Avanzado",
        "help_tab": "Ayuda",
        "skin_advanced_tab": "Piel avanzada",
        "quick_guide": "Guía rápida",
        "quick_guide_text": "1) Abre una foto\n2) Elige una herramienta\n3) Haz clic o arrastra sobre la foto\n4) Guarda al final\n\nLogo: elegir → colocar → arrastrar → rueda del ratón → aplicar",
        "zoom": "Zoom",
        "zoom_in": "Zoom +",
        "zoom_out": "Zoom -",
        "zoom_100": "Zoom 100%",
        "auto_enhance": "Mejora automática",
        "auto_enhance_strength": "Intensidad mejora automática %",
        "simple_brightness": "Luminosidad simple %",
        "apply_simple_brightness": "Aplicar luminosidad",
        "simple_contrast": "Contraste simple %",
        "apply_simple_contrast": "Aplicar contraste",
        "simple_sharpness": "Nitidez simple %",
        "apply_simple_sharpness": "Aplicar nitidez",
        "simple_saturation": "Saturación simple %",
        "simple_bw": "Blanco y negro %",
        "apply_simple_bw": "Aplicar blanco y negro",
        "apply_simple_bw_contrast": "Aplicar B/N contrastado",
        "skin_redness_strength": "Anti rojo / naranja / rosado piel %",
        "skin_redness_neutralize": "Neutralizar rojo / naranja / rosado piel",
        "skin_redness_done": "Tonos rojos/naranjas/rosados de la piel neutralizados",
        "light": "Luz",
        "contrast": "Contraste",
        "sharp": "Nitidez",
        "bw": "Blanco y negro",
        "bw_contrast": "Blanco y negro contrastado",
        "saturation": "Saturación",
        "apply_saturation": "Aplicar saturación",
        "sat_50": "Reducir a 50%",
        "sat_150": "Aumentar a 150%",
        "sat_reset": "Restablecer a 100%",
        "crop": "Activar recorte",
        "apply_crop": "Aplicar recorte",
        "crop_aspect": "Formato de recorte",
        "crop_aspect_free": "Libre",
        "crop_aspect_square": "Cuadrado 1:1",
        "crop_aspect_4_5": "Retrato 4:5",
        "crop_aspect_5_4": "Paisaje 5:4",
        "crop_aspect_3_2": "Foto 3:2",
        "crop_aspect_2_3": "Retrato 2:3",
        "crop_aspect_16_9": "Pantalla 16:9",
        "crop_aspect_9_16": "Vertical 9:16",
        "rotation": "Rotación",
        "rotate_left": "Girar izquierda 90°",
        "rotate_right": "Girar derecha 90°",
        "manual_rotation": "Rotación manual en grados",
        "apply_rotation": "Aplicar rotación",
        "reset_rotation": "Restablecer rotación a 0°",
        "resize": "Tamaño de la foto",
        "resize_label": "Redimensionar en %",
        "apply_resize": "Redimensionar foto",
        "resize_50": "Reducir a 50%",
        "resize_200": "Aumentar a 200%",
        "resize_reset": "Restablecer tamaño a 100%",
        "skin": "Retoque piel / rostro",
        "skin_perfect": "Pincel piel perfecta",
        "skin_perfect_power": "Intensidad piel perfecta",
        "skin_perfect_active": "Pincel piel perfecta activado: pinta sobre la piel. La herramienta sigue activa hasta que la detengas.",
        "skin_tone": "Uniformizar zona de piel",
        "skin_tone_power": "Intensidad uniformizar zona",
        "skin_tone_active": "Uniformizar zona de piel activado: pinta sobre un moretón, una mancha o una zona de color irregular. El color de referencia se toma cerca del círculo.",
        "skin_perfect_auto_face": "Piel rostro",
        "skin_perfect_auto_body": "Piel cuerpo",
        "skin_perfect_auto_legs": "Piel piernas",
        "skin_perfect_adjust": "Efecto piel perfecta",
        "skin_tone_auto_face": "Tono rostro",
        "skin_tone_auto_body": "Tono cuerpo",
        "skin_tone_auto_legs": "Tono piernas",
        "skin_face_strength": "Intensidad rostro",
        "skin_body_strength": "Intensidad cuerpo",
        "skin_legs_strength": "Intensidad piernas",
        "white_bg_auto": "Mejorar fondo blanco",
        "black_bg_auto": "Mejorar fondo negro",
        "bg_white_done": "Fondo blanco mejorado",
        "bg_black_done": "Fondo negro mejorado",
        "bg_no_white": "No se detectó un fondo blanco probable",
        "bg_no_black": "No se detectó un fondo negro probable",
        "skin_tone_adjust": "Tono de piel",
        "brush_small": "Zona pequeña",
        "brush_medium": "Zona mediana",
        "brush_large": "Zona grande",
        "skin_auto_face_done": "Corrección piel rostro aplicada",
        "skin_auto_done": "Corrección piel cuerpo aplicada",
        "skin_auto_legs_done": "Corrección piel piernas aplicada",
        "skin_tone_auto_face_done": "Tono rostro aplicado",
        "skin_tone_auto_done": "Tono cuerpo aplicado",
        "skin_tone_auto_legs_done": "Tono piernas aplicado",
        "smooth": "Suavizar piel con pincel",
        "heal": "Corregir imperfección",
        "skin_beauty": "Embellecer piel automáticamente",
        "skin_beauty_power": "Nivel embellecimiento piel",
        "skin_beauty_done": "Piel embellecida automáticamente",
        "skin_makeup_all": "Tono uniforme / acabado maquillaje",
        "skin_makeup_area": "Pintar / añadir zona de piel",
        "skin_makeup_area_erase": "Borrar zona de piel con pincel",
        "skin_makeup_area_apply": "Aplicar maquillaje a zona de piel elegida",
        "skin_makeup_area_clear": "Reiniciar zona de piel",
        "skin_makeup_power": "Nivel maquillaje piel",
        "skin_makeup_done": "Tono de piel uniformizado",
        "skin_makeup_area_active": "Pinta sobre la piel con el círculo del pincel. El tamaño del círculo sigue Tamaño pincel. La zona queda guardada para aplicar el efecto varias veces sin empezar de nuevo.",
        "skin_makeup_area_erase_active": "Pinta con el círculo sobre la parte que quieres quitar de la zona de piel guardada.",
        "skin_makeup_area_empty": "Primero pinta una zona de piel y luego aplica el maquillaje.",
        "skin_makeup_area_cleared": "Zona de piel reiniciada",
        "skin_makeup_area_painted": "Zona de piel guardada",
        "brush_size": "Tamaño del pincel",
        "heal_strength": "Intensidad corregir imperfección",
        "smooth_power": "Fuerza del suavizado con pincel",
        "stop_tool": "Detener herramienta actual",
        "text": "Texto",
        "text_to_write": "Texto a escribir",
        "font": "Fuente",
        "font_note": "Las fuentes suministradas están integradas en Photo Tool. Las fuentes del sistema aparecen solo si están disponibles en el equipo.",
        "text_size": "Tamaño de caracteres",
        "text_color": "Elegir color del texto",
        "text_mode": "Añadir texto (clic en la foto)",
        "clear_text": "Quitar último texto colocado",
        "logo": "Añadir: Logo / Copyright / Objeto",
        "logo_choice": "Elegir logo u objeto",
        "logo_simple_round": "Logo Simple redondo",
        "logo_minimal_mouth": "Logo Minimal boca",
        "logo_glossy_gothic": "Logo Glossy gótico",
        "logo_wide_banner": "Logo ancho / banda",
        "custom_logo": "Subir tu Logo / Objeto.",
        "logo_size": "Tamaño del logo / objeto",
        "logo_opacity": "Transparencia del logo / objeto",
        "logo_black_to_white": "Invertir negro a blanco para logo / objeto",
        "clear_logo_preview": "Quitar vista previa del Logo",
        "apply_logo": "Aplicar Logo / Objeto",
        "undo_logo_apply": "Deshacer aplicación del logo",
        "logo_preview_empty": "Ningún logo / objeto elegido",
        "bg": "Cambiar fondo",
        "bg_help": "Primero recorta la modelo con IA y luego elige un color o una foto de fondo.",
        "bg_tolerance": "Tolerancia del recorte",
        "bg_color": "Elegir color sólido de fondo",
        "bg_photo": "Elegir foto de fondo",
        "remove_bg_photo": "Olvidar foto de fondo",
        "replace_white": "Reemplazar fondo blanco",
        "replace_black": "Reemplazar fondo negro",
        "replace_green": "Reemplazar fondo verde",
        "replace_auto": "Reemplazar fondo auto (esquinas)",
        "replace_ai_matte": "Recorte / Fondo",
        "replace_ai_matte_alt": "Recorte alternativo",
        "bg_background_rotation": "Rotación del fondo",
        "bg_subject_size": "Tamaño de la modelo en el fondo",
        "bg_subject_rotation": "Rotación de la modelo",
        "bg_apply_placement": "Aplicar fondo",
        "bg_cancel_placement": "Cancelar fondo",
        "bg_placement_active": "Fondo activo: arrastra la modelo sobre el fondo, ajusta el tamano y aplica.",
        "bg_placement_done": "Fondo aplicado",
        "bg_placement_cancelled": "Fondo cancelado",
        "bg_no_placement": "No hay fondo activo",
        "bg_ai_start": "Recorte IA local en curso... La primera vez puede tardar mas.",
        "bg_ai_done": "Recorte / fondo listo",
        "bg_ai_alt_done": "Recorte alternativo listo",
        "bg_ai_missing": "El modulo IA local rembg no esta instalado en esta version.",
        "cleanup_color": "Elegir color de correccion",
        "cleanup_pick_color": "Pipeta de color",
        "cleanup_pick_active": "Pipeta activa: haz clic en la foto para elegir el color de correccion",
        "cleanup_color_set": "Color de correccion elegido",
        "white_brush_beta": "Pincel de color",
        "white_rect_beta": "Rectangulo de color",
        "white_ellipse_beta": "Circulo / elipse de color",
        "white_lasso_beta": "Lazo de color",
        "white_bucket_beta": "Bote de color",
        "cleanup_opacity": "Opacidad de correccion",
        "cleanup_bucket_tolerance": "Tolerancia del bote",
        "transparent_lasso_beta": "Lazo transparente",
        "recover_lasso_beta": "Lazo recuperar sujeto",
        "cleanup_lasso_expand": "Margen del lazo",
        "white_cleanup_active": "Herramienta de color activada: corrige el fondo con el color elegido",
        "white_shape_active": "Dibuja la zona para rellenar y suelta el mouse",
        "white_lasso_active": "Traza un bucle alrededor de la zona para rellenar y suelta",
        "white_bucket_active": "Bote activo: haz clic dentro de una zona cerrada para rellenarla con el color elegido",
        "transparent_lasso_active": "Traza un bucle alrededor del fondo que quieres quitar y suelta",
        "recover_lasso_active": "Traza un bucle sobre una parte cortada del sujeto para recuperarla",
        "white_cleanup_done": "Zona rellenada",
        "transparent_cleanup_done": "Zona convertida en transparente",
        "recover_cleanup_done": "Zona del sujeto recuperada",
        "recover_source_missing": "No hay fuente de recuperacion disponible. Ejecuta primero un recorte IA y haz clic en Aplicar fondo.",
        "advanced_colors": "Ajustes avanzados de color",
        "auto_color": "Color automático suave",
        "manual_saturation": "Saturación avanzada",
        "advanced_brightness": "Luminosidad avanzada",
        "advanced_contrast": "Contraste avanzado",
        "advanced_sharpness": "Nitidez avanzada",
        "advanced_clarity": "Claridad / relieve",
        "advanced_denoise": "Reducción de grano",
        "advanced_vibrance": "Vibrancia",
        "advanced_shadows": "Sombras",
        "advanced_highlights": "Altas luces",
        "temperature": "Temperatura cálida / fría",
        "tint": "Tinte verde / magenta",
        "red": "Rojo",
        "green": "Verde",
        "blue": "Azul",
        "apply_colors": "Aplicar colores manuales",
        "reset_colors": "Restablecer controles de color",

        "help_title": "Ayuda interactiva",
        "help_intro": "Haz clic en un tema a la izquierda. Cada sección explica para qué sirve la herramienta y cómo usarla paso a paso.",
        "help_topic_open": "Archivo: abrir / guardar",
        "help_topic_open_text": "Usa la pestaña Archivo para empezar y terminar el trabajo.\n\n1. Haz clic en “Abrir foto” y elige tu imagen.\n2. Si ya hay una foto abierta, el botón cambia a “Nueva foto” y pide confirmación antes de reemplazarla.\n3. Cuando termines, elige el formato que quieres: JPEG, PNG, WebP o PDF.\n4. Si quieres volver a la foto tal como estaba al abrirla, haz clic en “Volver a la imagen original”.\n\nConsejo: JPEG es muy compatible, PNG conserva una excelente calidad, WebP es práctico para la web y PDF crea una página con la foto.",
        "help_topic_basic": "Correcciones simples",
        "help_topic_basic_text": "La pestaña Correcciones simples sirve para retoques rápidos sin duplicar los ajustes avanzados.\n\nMejora automática: ajusta la intensidad y haz clic una vez. Mejora color, contraste y nitidez sin forzar el fondo.\n\nAnti rojo / naranja / rosado piel: ajusta la intensidad y haz clic para reducir una dominante demasiado roja, naranja o rosada en la piel. La herramienta toma una referencia de la piel detectada, preferentemente del rostro, para respetar la pigmentación de la persona.\n\nPiel rostro uniformiza el rostro con un ligero acabado porcelana y protege nariz, ojos y boca. El control Intensidad rostro regula la fuerza.\n\nPiel cuerpo y Piel piernas usan sus propias intensidades. Piel piernas sirve sobre todo si la modelo no lleva medias de nylon color piel.\n\nTono rostro, Tono cuerpo y Tono piernas dan un efecto de base/bronceado progresivo basado en la piel detectada, menos naranja y más natural.\n\nCorregir imperfección: ajusta Tamaño pincel e Intensidad, activa la herramienta y haz clic sobre la marca con pequeños toques.\n\nPara luminosidad, contraste, saturación y nitidez, usa Ajustes avanzados. Para blanco y negro, usa Filtros premium.\n\nDetener herramienta desactiva la herramienta activa.",
        "help_topic_crop": "Recorte + Rotación",
        "help_topic_crop_text": "La pestaña Recorte + Rotación sirve para encuadrar y enderezar la foto.\n\nFormato de recorte: elige Libre si quieres dibujar cualquier forma, o elige un formato estándar como 1:1, 4:5, 16:9 o 9:16. El rectángulo mantiene automáticamente la proporción mientras arrastras.\n\nRecorte: haz clic en “Activar recorte”, mantén presionado el botón del ratón y arrastra para dibujar la zona que quieres conservar. Suelta, revisa el marco y ajústalo si hace falta: arrastra dentro del marco para moverlo, o arrastra una de las cuatro esquinas rojas para cambiar su tamaño. Luego haz clic en “Aplicar recorte”.\n\nRotación rápida: usa “Rotación izquierda 90°” o “Rotación derecha 90°” si la foto está completamente girada.\n\nRotación manual: mueve el control en grados para enderezar una foto un poco inclinada. Revisa la vista previa y luego haz clic en “Aplicar rotación”. “Restablecer rotación a 0°” cancela la vista previa antes de aplicar.",
    "help_topic_size_tab": "Tamaño",
    "help_topic_size_tab_text": "La pestaña Tamaño cambia las dimensiones de la foto.\n\nMueve el control “Redimensionar en %” y luego haz clic en “Redimensionar la foto”. 100 % mantiene el tamaño actual, 50 % reduce la foto y 200 % la amplía.\n\nLos botones 50 %, 200 % y 100 % son atajos. Úsalos si quieres ir más rápido y luego aplica el redimensionamiento.",
    "help_topic_filters": "Filtros premium",
    "help_topic_filters_text": "La pestaña Filtros premium aplica estilos visuales preparados.\n\n1. Haz clic en el filtro que quieres.\n2. Ajusta “Intensidad del filtro”.\n3. Revisa la vista previa directamente en la foto.\n4. Haz clic en “Aplicar filtro” solo si te gusta el resultado.\n5. Haz clic en “Borrar vista previa del filtro” para quitar la vista previa sin modificar la foto.\n\nBlanco y negro da un resultado monocromo clasico. Vampire Noir B&W es mas fuerte, mas dramatico, con mas contraste y nitidez. Los filtros sirven para crear ambiente rapidamente; manten una intensidad moderada si quieres un resultado mas natural.",
        "help_topic_retouch": "Corregir imperfección",
        "help_topic_retouch_text": "Corregir imperfección sirve para quitar pequeñas marcas locales: granos, polvo, manchas pequeñas o detalles molestos.\n\n1. Ve a Correcciones simples.\n2. Ajusta “Tamaño pincel” un poco más grande que la marca.\n3. Haz clic en “Corregir imperfección”.\n4. Haz clic una vez sobre la marca. Para una zona más grande, usa varios clics pequeños en vez de una pasada grande.\n5. Haz clic en “Detener herramienta” cuando termines.\n\nTrabaja poco a poco. Así se obtiene el resultado más limpio.",
        "help_topic_text": "Texto",
        "help_topic_text_text": "La pestaña Texto coloca una inscripción sobre la foto.\n\n1. Escribe el texto en el campo.\n2. Elige la fuente. Las fuentes normales añadidas son Lora, Playfair Display, Montserrat, Libre Baskerville y Great Vibes.\n3. Ajusta el tamaño de los caracteres.\n4. Elige el color del texto.\n5. Haz clic en “Añadir texto”.\n6. Mueve el ratón sobre la foto: el texto sigue el cursor como vista previa.\n7. Haz clic exactamente donde quieres colocarlo.\n\n“Quitar último texto colocado” quita el texto solo si la última modificación aplicada fue texto. Si no, usa “Deshacer modificación” para retroceder paso a paso.",
        "help_topic_logo": "Añadir: Logo / Copyright / Objeto",
        "help_topic_logo_tab": "Pestaña Logo / Objeto",
    "help_topic_logo_tab_text": "La pestaña Logo / Objeto añade un logo, copyright, firma, accesorio u objeto decorativo.",
    "help_topic_logo_text": "La pestaña Logo / Objeto coloca un logo, firma, copyright, accesorio u objeto decorativo sobre la foto.\n\n1. Haz clic en uno de los 4 botones de logos incluidos, o haz clic en “Subir tu Logo / Objeto.” para elegir tu propio archivo. Si vuelves a hacer clic en el logo seleccionado, se deselecciona.\n2. En cuanto eliges un logo u objeto, aparece centrado sobre la foto con un marco visible. Arrástralo directamente sobre la imagen para moverlo.\n3. Usa “Tamaño del logo / objeto” y “Transparencia del logo / objeto” para ajustar la vista previa. La rueda del ratón también puede cambiar el tamaño durante la colocación.\n4. Activa “Invertir negro a blanco” solo si un logo oscuro debe verse sobre una foto oscura.\n5. Si sales de la pestaña Logo / Objeto para usar otra función, la selección y la vista previa desaparecen automáticamente. Cuando vuelves, la pestaña está vacía como la primera vez.\n6. Haz clic en “Aplicar Logo / Objeto” para fijarlo en la foto. La selección y la vista previa se limpian a propósito: elige otro logo para colocar uno nuevo.\n7. “Quitar vista previa del Logo” elimina solo la selección y la vista previa actuales; no quita un logo ya aplicado. “Deshacer aplicación del logo” cancela el último logo aplicado aunque la vista previa ya haya desaparecido.",
        "help_topic_skin_advanced": "Piel avanzada",
        "help_topic_skin_advanced_text": "La pestaña Piel avanzada sirve para correcciones de piel más controladas.\n\nPincel de piel perfecta: ajusta la intensidad, elige un tamaño de pincel y pinta suavemente sobre la piel. Trabaja por zonas pequeñas para conservar los rasgos del rostro.\n\nIgualar zona de piel: útil para corregir una zona demasiado azul, roja o irregular. Pinta sobre la zona; la aplicación toma una referencia cercana alrededor del círculo del pincel.\n\nPiel automática cuerpo entero y tono automático cuerpo entero: aplican una corrección global sobre la piel. Haz clic una vez, revisa el resultado y repite solo si hace falta.\n\nPintar / borrar zona de piel: pinta la zona que quieres corregir, borra los excesos si es necesario y luego aplica el efecto de maquillaje/uniformización sobre esa zona.\n\nDetén la herramienta cuando termines para no pintar por accidente.",
        "help_topic_background": "Recorte / Fondo",
        "help_topic_background_text": "La pestaña Recorte / Fondo aísla el modelo y reemplaza el fondo.\n\n1. Haz clic en “Recorte / Fondo” para iniciar el recorte principal con IA local. Esta versión prueba primero BiRefNet local, que es más fuerte con fondos blancos, cabello, ropa clara, botas y aberturas como entre las piernas, bajo los brazos o entre los dedos.\n2. El primer uso puede tardar más si el modelo IA debe descargarse una vez. Después queda guardado en la computadora.\n3. Si el resultado corta demasiado o la foto es ambigua, haz clic en “Recorte alternativo”. Este segundo resultado usa el modelo anterior más rápido y a veces puede conservar mejor la silueta completa.\n4. Elige un color de fondo con “Elegir color sólido de fondo”, o elige una imagen con “Elegir foto de fondo”.\n5. Si usas una foto de fondo, ajusta “Rotación del fondo” antes de aplicar.\n6. Arrastra el modelo sobre el fondo para colocarlo.\n7. Ajusta “Tamaño de la modelo sobre el fondo” y “Rotación de la modelo”.\n8. Haz clic en “Aplicar fondo” cuando el resultado esté bien.\n9. Haz clic en “Cancelar fondo” si quieres volver atrás antes de aplicar.\n\nEsta pestaña sirve para el recorte principal y el fondo. La limpieza manual del fondo está ahora en la pestaña Correcciones de fondo.",
        "help_topic_background_correction": "Correcciones de fondo",
        "help_topic_background_correction_text": "La pestaña Correcciones de fondo sirve para limpiar manualmente pequeñas zonas después del recorte.\n\nElegir color de corrección: elige el color que rellenará las zonas a corregir. Ejemplo: blanco para fondo blanco, negro para fondo negro, o un color parecido al decorado.\n\nPipeta de color: haz clic en la foto para tomar un color promedio alrededor del cursor. Esto ayuda a conservar una tonalidad o textura cercana al fondo en vez de usar un color plano que se note.\n\nPincel de color: pinta directamente sobre pequeñas zonas para corregirlas. Empieza con un tamaño pequeño y pasa varias veces si hace falta.\n\nRectángulo de color: arrastra para rellenar una zona rectangular. Útil para esquinas o bandas rectas.\n\nCírculo / elipse de color: arrastra para rellenar una zona redonda u ovalada. Útil cerca de formas suaves.\n\nLazo de color: traza alrededor de una zona irregular y suelta para rellenarla. Es la mejor herramienta para contornos desiguales.\n\nZona pequeña, Zona media, Zona grande y Tamaño pincel: elige el tamaño según la precisión necesaria. Pequeño para bordes, grande para limpiar más rápido.\n\nDetener herramienta: desactiva el pincel, la pipeta, las formas o el lazo cuando termines.",
        "help_topic_advanced": "Ajustes avanzados",
        "help_topic_save": "Guardar",
        "help_topic_advanced_text": "La pestaña Ajustes avanzados sirve para ajustes precisos de toda la imagen.\n\nSaturación avanzada: aumenta o reduce la intensidad de todos los colores. Demasiado alto puede hacer que la piel parezca artificial; muévelo gradualmente.\n\nVibrancia: aumenta sobre todo los colores débiles y protege mejor los colores que ya son fuertes. Suele verse más natural que la saturación.\n\nSombras: aclara u oscurece principalmente las zonas oscuras sin cambiar toda la foto.\n\nAltas luces: recupera o acentúa principalmente las zonas muy claras.\n\nLuminosidad avanzada: aclara u oscurece toda la foto.\n\nContraste avanzado: aumenta la diferencia entre zonas claras y oscuras. Útil cuando la foto se ve plana.\n\nNitidez avanzada: hace que los detalles sean más visibles. Úsalo con moderación para evitar un resultado duro.\n\nTemperatura cálido / frío: cálido vuelve la foto más dorada; frío la vuelve más azulada.\n\nTinte verde / magenta: corrige una dominante general de color. Hacia verde, la imagen se vuelve más verde; hacia magenta, más rosada. Es una corrección de balance de color.\n\nRojo, Verde, Azul: ajustan directamente cada canal de color. Es más técnico que el tinte verde/magenta; usa estos controles para correcciones finas.\n\nRestablecer controles de color: devuelve los ajustes avanzados al punto neutro.",
        "help_topic_save_text": "Para guardar, vuelve a la pestaña Archivo y elige directamente el formato: “Guardar JPEG”, “Guardar PNG”, “Guardar WebP” o “Guardar PDF”. WebP es práctico para la web, PNG conserva una excelente calidad, JPEG es muy compatible y PDF crea una página con la foto.",
        "help_topic_undo": "Deshacer / imagen original",
        "help_topic_undo_text": "El botón ↶ Deshacer modificación cancela el paso más reciente. Si vuelves a hacer clic, seguirá retrocediendo paso a paso, como Ctrl+Z. “Volver a la imagen original” restaura la foto tal como estaba al abrirla.",        "no_photo": "No hay foto cargada",
        "load_first": "Carga una foto antes de usar esta herramienta.",
        "photo_loaded": "Foto cargada",
        "saved": "Imagen guardada",
        "nothing_to_undo": "Nada que deshacer",
        "undo_done": "Última modificación deshecha",
        "original_restored": "Imagen original restaurada",
        "tool_stopped": "Herramienta detenida",
        "crop_active": "Recorte activado: arrastra sobre la foto y aplica el recorte",
        "smooth_active": "Suavizado con pincel activado: pinta suavemente sobre la piel",
        "heal_active": "Corrección de granos activada: haz clic en los granos",
        "text_active": "Modo texto activado: haz clic en la foto para poner el texto",
        "logo_active": "Vista previa del logo activada: arrastra el logo y usa la rueda",
        "logo_applied": "Logo / copyright añadido a la foto",
        "write_text_first": "Escribe un texto antes de hacer clic en la foto.",
        "last_edit_not_text": "La última modificación no es texto. Usa Deshacer para retroceder paso a paso.",
        "choose_logo_first": "Elige primero un logo de copyright.",
    }
}


def hex_to_rgb(hex_color):
    hex_color = hex_color.strip().lstrip("#")
    if len(hex_color) != 6:
        return (255, 255, 255)
    return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))


LANGS["fr"].update({
    "zoom_bar": "Zoom photo",
    "nav_file": "Fichier",
    "nav_basic": "Corrections simples",
    "nav_filters": "Filtres premium",
    "nav_crop": "Crop + Rotation",
    "nav_size": "Taille",
    "nav_text_logo": "Texte",
    "nav_logo": "Logo / Objet",
    "nav_background_beta": "Découpage / Fond",
    "nav_background_correction": "Corrections de fond",
    "nav_advanced": "Réglages avancés",
    "nav_help": "Aide",
    "nav_skin_advanced": "Peau avancée",
    "undo_mod": "↶ Annuler la modification",
    "quick_guide_text": LANGS["fr"]["quick_guide_text"] + "\n\nTu peux aussi glisser une photo directement dans la fenêtre de modification.",
    "photo_dropped": "Photo chargée par glisser-déposer",
    "drop_ready": "Glisser-déposer activé : tu peux déposer une photo dans la fenêtre",
    "drop_not_available": "Glisser-déposer disponible après installation de tkinterdnd2",
    "photo_filters": "Filtres photo",
    "bg_correction": "Corrections de fond",
    "filter_help": "Choisis un filtre premium orienté portrait, goth ou latex, ajuste l’intensité, puis valide si le résultat te plaît.",
    "filter_intensity": "Intensité du filtre",
    "apply_filter": "Valider le filtre",
    "clear_filter_preview": "Effacer l’aperçu filtre",
    "choose_filter_first": "Choisir un filtre d'abord",
    "filter_preview_active": "Aperçu filtre actif",
    "filter_preview_cleared": "Aperçu filtre effacé",
    "filter_applied": "Filtre appliqué",
    "filter_cinematic_dark": "Portrait Luxe",
    "filter_gothic_portrait": "Velvet Gothic",
    "filter_latex_shine": "Latex Obsidian",
    "filter_soft_beauty": "Porcelain Glow",
    "filter_high_fashion": "Editorial Luxe",
    "filter_black_red": "Crimson Noir",
    "filter_vintage_gothic": "Moonlit Gothic",
    "filter_black_white": "Noir et blanc",
    "filter_noir_premium": "Vampire Noir B&W",
})

LANGS["en"].update({
    "zoom_bar": "Photo zoom",
    "nav_file": "File",
    "nav_basic": "Simple corrections",
    "nav_filters": "Premium filters",
    "nav_crop": "Crop + Rotation",
    "nav_size": "Size",
    "nav_text_logo": "Text",
    "nav_logo": "Logo / Object",
    "nav_background_beta": "Background Beta",
    "nav_background_correction": "Background corrections",
    "nav_advanced": "Advanced settings",
    "nav_help": "Help",
    "nav_skin_advanced": "Advanced skin",
    "undo_mod": "↶ Undo last edit",
    "quick_guide_text": LANGS["en"]["quick_guide_text"] + "\n\nYou can also drag and drop a photo directly into the editing window.",
    "photo_dropped": "Photo loaded by drag and drop",
    "drop_ready": "Drag and drop enabled: you can drop a photo into the window",
    "drop_not_available": "Drag and drop becomes available after installing tkinterdnd2",
    "photo_filters": "Photo filters",
    "bg_correction": "Background corrections",
    "filter_help": "Choose a premium portrait, gothic or latex filter, adjust the intensity, then apply it if you like the result.",
    "filter_intensity": "Filter intensity",
    "apply_filter": "Apply filter",
    "clear_filter_preview": "Clear filter preview",
    "choose_filter_first": "Choose a filter first",
    "filter_preview_active": "Filter preview active",
    "filter_preview_cleared": "Filter preview cleared",
    "filter_applied": "Filter applied",
    "filter_cinematic_dark": "Portrait Luxe",
    "filter_gothic_portrait": "Velvet Gothic",
    "filter_latex_shine": "Latex Obsidian",
    "filter_soft_beauty": "Porcelain Glow",
    "filter_high_fashion": "Editorial Luxe",
    "filter_black_red": "Crimson Noir",
    "filter_vintage_gothic": "Moonlit Gothic",
    "filter_black_white": "Black and white",
    "filter_noir_premium": "Vampire Noir B&W",
})

LANGS["es"].update({
    "zoom_bar": "Zoom foto",
    "nav_file": "Archivo",
    "nav_basic": "Correcciones simples",
    "nav_filters": "Filtros premium",
    "nav_crop": "Recorte + Rotación",
    "nav_size": "Tamaño",
    "nav_text_logo": "Texto",
    "nav_logo": "Logo / Objeto",
    "nav_background_beta": "Fondo Beta",
    "nav_background_correction": "Correcciones de fondo",
    "nav_advanced": "Ajustes avanzados",
    "nav_help": "Ayuda",
    "nav_skin_advanced": "Piel avanzada",
    "undo_mod": "↶ Deshacer modificación",
    "quick_guide_text": LANGS["es"]["quick_guide_text"] + "\n\nTambién puedes arrastrar y soltar una foto directamente en la ventana de edición.",
    "photo_dropped": "Foto cargada por arrastrar y soltar",
    "drop_ready": "Arrastrar y soltar activado: puedes soltar una foto en la ventana",
    "drop_not_available": "Arrastrar y soltar disponible después de instalar tkinterdnd2",
    "photo_filters": "Filtros fotográficos",
    "bg_correction": "Correcciones de fondo",
    "filter_help": "Elige un filtro premium orientado a retrato, gótico o látex, ajusta la intensidad y luego aplícalo si te gusta el resultado.",
    "filter_intensity": "Intensidad del filtro",
    "apply_filter": "Aplicar filtro",
    "clear_filter_preview": "Borrar vista previa del filtro",
    "choose_filter_first": "Primero elige un filtro",
    "filter_preview_active": "Vista previa del filtro activa",
    "filter_preview_cleared": "Vista previa del filtro borrada",
    "filter_applied": "Filtro aplicado",
    "filter_cinematic_dark": "Portrait Luxe",
    "filter_gothic_portrait": "Velvet Gothic",
    "filter_latex_shine": "Latex Obsidian",
    "filter_soft_beauty": "Porcelain Glow",
    "filter_high_fashion": "Editorial Luxe",
    "filter_black_red": "Crimson Noir",
    "filter_vintage_gothic": "Moonlit Gothic",
    "filter_black_white": "Blanco y negro",
    "filter_noir_premium": "Vampire Noir B&W",
})

LANGS["fr"].update({
    "help_topic_background_correction_text": "L'onglet Corrections de fond sert a nettoyer manuellement les petites zones apres le decoupage.\n\nChoisir couleur de correction : choisis la couleur qui va remplir les zones a corriger. Exemple : blanc pour un fond blanc, noir pour un fond noir, ou une couleur proche du decor.\n\nPipette couleur : clique dans la photo pour prendre une couleur moyenne autour du curseur. C'est utile pour garder une teinte proche du fond.\n\nPinceau couleur : peins directement sur les petites zones. Commence petit et repasse si necessaire.\n\nSeau couleur : clique dans une zone enfermee par une autre couleur pour la remplir. Il suit les degradés proches de la couleur cliquee, mais il reste volontairement local pour eviter de repeindre toute la photo. Augmente Tolérance seau si la zone ne se remplit pas assez.\n\nRectangle, cercle / ellipse et lasso couleur : trace une forme, puis relache pour remplir cette zone avec la couleur choisie tout en gardant un peu de texture.\n\nLasso transparent : trace une boucle autour d'un morceau de fond a enlever. En relachant la souris, cette zone devient transparente, pratique pour enlever un fond reste entre les jambes, sous les bras, entre les doigts ou dans une boucle de cheveux/accessoire.\n\nLasso recuperer sujet : apres un decoupage IA et Appliquer fond, trace une boucle sur une partie du modele que l'IA a coupee par erreur. L'outil remet les pixels originaux a cet endroit. Utilise-le pour recuperer un corset blanc, une jupe, un bras, une botte ou une manche coupee.\n\nMarge lasso : agrandit legerement la zone dessinee vers l'exterieur. Mets 0 pour un contour exact, ou augmente si tu veux couvrir plus large sans retracer.\n\nArrêter l'outil en cours : desactive l'outil quand tu as termine."
})
LANGS["en"].update({
    "help_topic_background_correction_text": "The Background corrections tab is for manually cleaning small zones after the cutout.\n\nChoose correction color: choose the color used to fill areas. Example: white for a white background, black for a black background, or a color close to the scene.\n\nColor picker: click the photo to sample an average color around the cursor.\n\nColor brush: paint directly over small zones. Start small and pass again if needed.\n\nColor bucket: click inside an enclosed area to fill it. It follows nearby gradients of the clicked color, but stays local to avoid repainting the whole photo. Increase Bucket tolerance if the area does not fill enough.\n\nColor rectangle, circle / ellipse and lasso: draw a shape, then release to fill that area with the selected color while keeping some texture.\n\nTransparent lasso: trace a loop around a piece of background to remove. When you release the mouse, that zone becomes transparent. It is useful for background left between legs, under arms, between fingers, or inside a hair/accessory loop.\n\nRecover subject lasso: after an AI cutout and Apply background, trace a loop over a model area that the AI cut off by mistake. The tool restores the original pixels in that zone. Use it to recover a white corset, skirt, arm, boot, or sleeve.\n\nLasso margin: slightly expands the drawn zone outward. Use 0 for an exact contour, or increase it to cover wider without retracing.\n\nStop current tool: disables the tool when you are done."
})
LANGS["es"].update({
    "help_topic_background_correction_text": "La pestana Correcciones de fondo sirve para limpiar manualmente pequenas zonas despues del recorte.\n\nElegir color de correccion: elige el color que rellenara las zonas a corregir. Ejemplo: blanco para un fondo blanco, negro para un fondo negro, o un color parecido al decorado.\n\nPipeta de color: haz clic en la foto para tomar un color promedio alrededor del cursor.\n\nPincel de color: pinta directamente sobre pequenas zonas. Empieza pequeno y repite si hace falta.\n\nBote de color: haz clic dentro de una zona cerrada para rellenarla. Sigue degradados cercanos al color clicado, pero se mantiene local para evitar repintar toda la foto. Aumenta Tolerancia del bote si no rellena suficiente.\n\nRectangulo, circulo / elipse y lazo de color: dibuja una forma y suelta para rellenar esa zona con el color elegido conservando algo de textura.\n\nLazo transparente: traza un bucle alrededor de una parte del fondo que quieres quitar. Al soltar el mouse, esa zona se vuelve transparente. Sirve para fondo que queda entre piernas, bajo brazos, entre dedos o dentro de un bucle de pelo/accesorio.\n\nLazo recuperar sujeto: despues de un recorte IA y Aplicar fondo, traza un bucle sobre una parte del modelo que la IA corto por error. La herramienta restaura los pixeles originales en esa zona. Usalo para recuperar corset blanco, falda, brazo, bota o manga cortada.\n\nMargen del lazo: agranda un poco la zona dibujada hacia afuera. Usa 0 para un contorno exacto, o aumentalo para cubrir mas sin volver a dibujar.\n\nDetener herramienta: desactiva la herramienta cuando termines."
})

LANGS["fr"].update({
    "help_topic_basic_text": "L'onglet Corrections simples sert aux retouches rapides sans doublonner les reglages avances.\n\nAmelioration automatique : regle l'intensite, puis clique une fois. L'outil ameliore couleur, contraste et nettete sans forcer le fond.\n\nAnti rouge / orange / rose peau : reduit une dominante trop rouge, orange ou rosee sur la peau. L'outil prend une reference dans la peau detectee, surtout le visage si possible, pour respecter la pigmentation naturelle au lieu de rendre un teint pale orange.\n\nPeau visage, Peau corps et Peau jambes utilisent leurs intensites en %. Teint visage, Teint corps et Teint jambes donnent un effet fond de teint ou bronzage progressif base sur la couleur de peau detectee.\n\nCorriger imperfection : ajuste Taille pinceau et Intensite corriger imperfection, active l'outil, puis clique par petites touches.\n\nPour luminosite, contraste, saturation et nettete, utilise Reglages avances. Pour le noir et blanc, utilise Filtres premium.",
    "help_topic_background_text": "L'onglet Decoupage / Fond sert a isoler le modele et remplacer le fond.\n\n1. Clique Decoupage / Fond pour lancer le decoupage IA local principal. Cette version utilise BiRefNet local quand disponible et ajoute un polissage anti-halo de transparence et de couleur de bord pour reduire les franges claires visibles sur les fonds sombres.\n2. La premiere utilisation peut etre plus longue si le modele IA doit etre telecharge une fois. Ensuite, il reste sur l'ordinateur.\n3. Si le resultat coupe trop ou si la photo est ambigue, clique Decoupage alternatif.\n4. Choisis une couleur ou une photo de fond.\n5. Ajuste Rotation du fond avant de valider si tu utilises une photo de fond.\n6. Glisse le modele sur le fond, ajuste Taille de la modele et Rotation de la modele.\n7. Clique Appliquer fond quand le resultat est bon, ou Annuler fond pour revenir avant d'appliquer.",
    "help_topic_background_correction_text": "L'onglet Corrections de fond sert a nettoyer manuellement les petites zones apres le decoupage.\n\nPipette couleur prend une couleur moyenne autour du curseur. Pinceau, Seau, Rectangle, Cercle / ellipse et Lasso couleur remplissent les zones choisies avec la couleur de correction tout en gardant un peu de texture.\n\nOpacite correction est a 100 % par defaut pour une correction visible. Baisse-la seulement si tu veux un effet plus doux ou progressif.\n\nLasso transparent enleve une zone de fond tracee en boucle. Lasso recuperer sujet remet les pixels originaux dans une zone que l'IA a coupee par erreur. Marge lasso agrandit la zone tracee vers l'exterieur.",
    "help_topic_undo_text": "Annuler la modification retire la derniere action. Revenir a l'image originale restaure la photo comme au moment de l'ouverture.\n\nCreer point de reprise garde en memoire le resultat actuel sans creer de fichier. Revenir au point de reprise ramene directement la photo a ce resultat, utile quand tu es satisfaite d'une etape et veux essayer autre chose ensuite."
})
LANGS["en"].update({
    "help_topic_basic_text": "The Simple corrections tab is for quick retouching without duplicating the advanced controls.\n\nAuto enhancement: set intensity, then click once. It improves color, contrast, and sharpness without forcing the background.\n\nAnti red / orange / pink skin reduces excessive red, orange, or pink cast on skin. It estimates a reference from detected skin, preferably the face, so it respects natural pigmentation instead of turning pale skin orange.\n\nFace skin, Body skin, and Legs skin use percentage intensities. Face tone, Body tone, and Legs tone create a gradual foundation/tan effect based on detected skin color.\n\nCorrect imperfection: set Brush size and Correct imperfection strength, enable the tool, then click in small touches.\n\nFor brightness, contrast, saturation, and sharpness, use Advanced settings. For black and white, use Premium filters.",
    "help_topic_background_text": "The Cutout / Background tab isolates the model and replaces the background.\n\n1. Click Cutout / Background to start the main local AI cutout. This version uses local BiRefNet when available and adds anti-halo alpha and edge-color polishing to reduce light fringes visible on dark backgrounds.\n2. The first use can take longer if the AI model has to download once. After that, it stays on the computer.\n3. If the result cuts too much or the photo is ambiguous, click Alternative cutout.\n4. Choose a solid color or background photo.\n5. Set Background rotation first if you use a background photo.\n6. Drag the model, adjust Model size and Model rotation.\n7. Click Apply background when the result looks right, or Cancel background to return before applying.",
    "help_topic_background_correction_text": "The Background corrections tab manually cleans small zones after cutout.\n\nColor picker samples an average color around the cursor. Brush, Bucket, Rectangle, Circle / ellipse, and Color lasso fill chosen zones with the correction color while preserving some texture.\n\nCorrection opacity is 100% by default for a visible correction. Lower it only for a softer or more gradual effect.\n\nTransparent lasso removes a loop-traced background zone. Recover subject lasso restores original pixels in an area the AI cut off by mistake. Lasso margin expands the traced zone outward.",
    "help_topic_undo_text": "Undo last edit removes the most recent action. Return to original image restores the photo as it was when opened.\n\nCreate restore point keeps the current result in memory without creating a file. Return to restore point brings the photo directly back to that result, useful when you like one step and want to experiment after it."
})
LANGS["es"].update({
    "help_topic_basic_text": "La pestana Correcciones simples sirve para retoques rapidos sin duplicar los ajustes avanzados.\n\nMejora automatica: ajusta la intensidad y haz clic una vez. Mejora color, contraste y nitidez sin forzar el fondo.\n\nAnti rojo / naranja / rosado piel reduce una dominante demasiado roja, naranja o rosada. Toma una referencia de la piel detectada, preferentemente del rostro, para respetar la pigmentacion natural.\n\nPiel rostro, Piel cuerpo y Piel piernas usan intensidades en %. Tono rostro, Tono cuerpo y Tono piernas dan un efecto de base/bronceado progresivo basado en la piel detectada.\n\nCorregir imperfeccion: ajusta Tamano pincel e Intensidad, activa la herramienta y haz clic con pequenos toques.\n\nPara luminosidad, contraste, saturacion y nitidez, usa Ajustes avanzados. Para blanco y negro, usa Filtros premium.",
    "help_topic_background_text": "La pestana Recorte / Fondo aisla el modelo y reemplaza el fondo.\n\n1. Haz clic en Recorte / Fondo para iniciar el recorte IA local principal. Esta version usa BiRefNet local cuando esta disponible y agrega pulido anti-halo de transparencia y color de borde para reducir bordes claros visibles sobre fondos oscuros.\n2. El primer uso puede tardar mas si el modelo IA debe descargarse una vez. Despues queda guardado.\n3. Si el resultado corta demasiado o la foto es ambigua, haz clic en Recorte alternativo.\n4. Elige un color o una foto de fondo.\n5. Ajusta Rotacion del fondo primero si usas una foto de fondo.\n6. Arrastra el modelo, ajusta Tamano de la modelo y Rotacion de la modelo.\n7. Haz clic en Aplicar fondo cuando el resultado este bien, o Cancelar fondo para volver antes de aplicar.",
    "help_topic_background_correction_text": "La pestana Correcciones de fondo limpia manualmente pequenas zonas despues del recorte.\n\nLa pipeta toma un color promedio alrededor del cursor. Pincel, Bote, Rectangulo, Circulo / elipse y Lazo de color rellenan zonas elegidas con el color de correccion conservando algo de textura.\n\nOpacidad de correccion esta en 100 % por defecto para una correccion visible. Bajala solo si quieres un efecto mas suave o progresivo.\n\nLazo transparente quita una zona de fondo. Lazo recuperar sujeto restaura pixeles originales en una zona que la IA corto por error. Margen del lazo expande la zona trazada hacia afuera.",
    "help_topic_undo_text": "Deshacer modificacion elimina la accion mas reciente. Volver a la imagen original restaura la foto como estaba al abrirla.\n\nCrear punto de retorno guarda en memoria el resultado actual sin crear archivo. Volver al punto de retorno devuelve directamente la foto a ese resultado."
})

def get_initial_language():
    try:
        language_code = (locale.getlocale()[0] or locale.getdefaultlocale()[0] or "").lower()
    except Exception:
        language_code = ""

    if language_code.startswith("fr"):
        return "fr"
    if language_code.startswith("es"):
        return "es"
    if language_code.startswith("en"):
        return "en"
    return "en"

class LatexVampPhotoTool:
    def __init__(self, root):
        self.root = root
        self.lang = tk.StringVar(value=get_initial_language())
        self.brand_logo_tk = None

        self.image = None
        self.original = None
        self.tk_image = None
        self.display_scale = 1.0
        self.zoom = 1.0
        self.last_path = None
        self.image_dpi = (300, 300)
        self.image_icc_profile = None
        self.undo_stack = []
        self.max_undo = 12
        self.checkpoint_image = None
        self.checkpoint_advanced_state = None

        self.tool = None
        self.crop_start = None
        self.crop_end = None
        self.crop_rect_id = None
        self.crop_preview_ids = []
        self.crop_aspect_value = tk.StringVar(value="free")
        self.crop_drag_mode = None
        self.crop_drag_origin = None
        self.crop_drag_bounds = None

        self.brush_cursor_ids = []
        self.brush_size = tk.IntVar(value=28)
        self.heal_strength = tk.IntVar(value=90)
        self.smooth_strength = tk.IntVar(value=45)
        self.skin_beauty_strength = tk.IntVar(value=1)
        self.skin_makeup_strength = tk.IntVar(value=1)
        self.skin_perfect_strength = tk.IntVar(value=3)
        self.skin_face_strength = tk.IntVar(value=75)
        self.skin_body_strength = tk.IntVar(value=60)
        self.skin_legs_strength = tk.IntVar(value=55)
        self.skin_tone_shift = tk.IntVar(value=0)
        self.skin_even_strength = tk.IntVar(value=3)
        self.bg_targeted_strength = tk.IntVar(value=3)
        self.skin_auto_base_image = None
        self.skin_auto_kind = None
        self.skin_selection_mask = None
        self.skin_mask_overlay_tk = None

        self.rotation_value = tk.IntVar(value=0)
        self.resize_percent_value = tk.IntVar(value=100)
        self.auto_enhance_strength_value = tk.IntVar(value=75)
        self.simple_brightness_value = tk.IntVar(value=0)
        self.simple_contrast_value = tk.IntVar(value=0)
        self.simple_sharpness_value = tk.IntVar(value=55)
        self.simple_bw_value = tk.IntVar(value=100)
        self.skin_redness_strength_value = tk.IntVar(value=55)
        self.simple_saturation_value = tk.IntVar(value=100)
        self.saturation_live_base = None
        self.rotation_preview_angle = 0
        self.filter_intensity_value = tk.IntVar(value=100)
        self.filter_preview_name = None
        self.saturation_live_base = None

        self.text_size_value = tk.IntVar(value=72)
        self.text_color_value = tk.StringVar(value="#ffffff")
        self.text_font_value = tk.StringVar(value="Cinzel Decorative")
        self.text_font_choices = [
            "Lora",
            "Playfair Display",
            "Montserrat",
            "Libre Baskerville",
            "Great Vibes",
            "Cinzel Decorative",
            "Cormorant Garamond",
            "Spectral SC",
            "UnifrakturCook",
            "Cloister Black",
            "Old English",
            "Nosifer",
            "Copperplate Gothic",
            "Arial",
            "Georgia",
            "Times New Roman",
            "Serif"
        ]
        self.text_preview_x = None
        self.text_preview_y = None
        self.text_preview_after_id = None
        self.text_preview_item_ids = []
        self.text_font_value.trace_add("write", lambda *_: self.on_text_font_preview_change())
        self.text_color_value.trace_add("write", lambda *_: self.draw_text_preview_items())
        self.font_preview_tk = None

        self.logo_path = None
        self.logo_choice_value = tk.StringVar(value="")
        self.logo_choice_buttons = {}
        self.logo_choice_choices = [
            ("simple_round", "logo_simple_round", "choix_1_simple_rond.png"),
            ("minimal_mouth", "logo_minimal_mouth", "choix_2_minimal_bouche.png"),
            ("glossy_gothic", "logo_glossy_gothic", "choix_3_glossy_gothique.png"),
            ("wide_banner", "logo_wide_banner", "choix_4_horizontal.png"),
        ]
        self.preset_logo_files = {
            choice_id: filename for choice_id, _label_key, filename in self.logo_choice_choices
        }
        self.logo_size_value = tk.IntVar(value=22)
        self.logo_opacity_value = tk.IntVar(value=75)
        self.logo_black_to_white_value = tk.BooleanVar(value=False)
        self.logo_preview_active = False
        self.logo_dragging = False
        self.logo_drag_offset = (0, 0)
        self.logo_preview_x = 0
        self.logo_preview_y = 0
        self.logo_preview_tk = None
        self.logo_source_cache_key = None
        self.logo_source_cache_image = None
        self.logo_cache_key = None
        self.logo_cache_image = None
        self.logo_display_cache_key = None
        self.logo_display_cache_tk = None
        self.logo_preview_item_id = None
        self.logo_preview_rect_id = None
        self.logo_preview_rect_shadow_id = None
        self.logo_preview_after_id = None
        self.logo_sample_tk = None

        self.drag_drop_enabled = HAS_DND
        self.panning_active = False
        self.rembg_session = None
        self.rembg_sessions = {}
        self.bg_ai_processing = False
        self.white_lasso_path = []
        self.white_lasso_active = False
        self.white_lasso_preview_ids = []
        self.overlay_preview_active = False
        self.overlay_dragging = False
        self.overlay_drag_offset = (0, 0)
        self.overlay_layers = []
        self.active_overlay_index = -1
        self.last_cutout_restore_layer = None

        self.bg_tolerance_value = tk.IntVar(value=60)
        self.cleanup_bucket_tolerance_value = tk.IntVar(value=54)
        self.cleanup_lasso_expand_value = tk.IntVar(value=2)
        self.cleanup_opacity_value = tk.IntVar(value=100)
        self.bg_replace_color_value = tk.StringVar(value="#ffffff")
        self.cleanup_fill_color_value = tk.StringVar(value="#ffffff")
        self.bg_replace_image_path = None
        self.bg_background_rotation_value = tk.IntVar(value=0)
        self.bg_subject_size_value = tk.IntVar(value=100)
        self.bg_subject_rotation_value = tk.IntVar(value=0)
        self.bg_placement_active = False
        self.bg_placement_dragging = False
        self.bg_placement_original = None
        self.bg_placement_bg = None
        self.bg_placement_subject = None
        self.bg_placement_subject_base_scale = 1.0
        self.bg_placement_subject_x = 0
        self.bg_placement_subject_y = 0
        self.bg_placement_drag_offset = (0, 0)
        self.bg_placement_last_subject_size = None
        self.bg_placement_subject_cache_key = None
        self.bg_placement_subject_cache = None
        self.bg_placement_preview_after_id = None
        self.bg_background_update_after_id = None
        self.rembg_session_model_name = None
        try:
            self.bg_subject_rotation_value.set(0)
        except Exception:
            pass

        self.saturation_value = tk.IntVar(value=0)
        self.advanced_brightness_value = tk.IntVar(value=0)
        self.advanced_contrast_value = tk.IntVar(value=0)
        self.advanced_sharpness_value = tk.IntVar(value=0)
        self.advanced_vibrance_value = tk.IntVar(value=0)
        self.advanced_shadows_value = tk.IntVar(value=0)
        self.advanced_highlights_value = tk.IntVar(value=0)
        self.advanced_clarity_value = tk.IntVar(value=0)
        self.advanced_denoise_value = tk.IntVar(value=0)
        self.advanced_live_base_array = None
        self.temperature_value = tk.IntVar(value=0)
        self.tint_value = tk.IntVar(value=0)
        self.red_value = tk.IntVar(value=0)
        self.green_value = tk.IntVar(value=0)
        self.blue_value = tk.IntVar(value=0)
        self.advanced_live_base = None
        self.advanced_preview_after_id = None
        self.busy_depth = 0
        self.busy_cursor_widgets = []

        self.widgets = {}
        self.scrollable_menu_frames = []
        self.tab_ids = {}
        self.init_verified_program_fonts()
        self.build_ui()
        self.apply_language()


    def init_verified_program_fonts(self):
        self.program_font_specs = [
            {
                "name": "Lora",
                "filename": "Lora-Regular.ttf",
                "urls": [
                    "https://raw.githubusercontent.com/google/fonts/main/ofl/lora/Lora%5Bwght%5D.ttf",
                    "https://github.com/google/fonts/raw/main/ofl/lora/Lora%5Bwght%5D.ttf",
                ],
                "source": "Google Fonts / OFL"
            },
            {
                "name": "Playfair Display",
                "filename": "PlayfairDisplay-Regular.ttf",
                "urls": [
                    "https://raw.githubusercontent.com/google/fonts/main/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf",
                    "https://github.com/google/fonts/raw/main/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf",
                ],
                "source": "Google Fonts / OFL"
            },
            {
                "name": "Montserrat",
                "filename": "Montserrat-Regular.ttf",
                "urls": [
                    "https://raw.githubusercontent.com/google/fonts/main/ofl/montserrat/Montserrat%5Bwght%5D.ttf",
                    "https://github.com/google/fonts/raw/main/ofl/montserrat/Montserrat%5Bwght%5D.ttf",
                ],
                "source": "Google Fonts / OFL"
            },
            {
                "name": "Libre Baskerville",
                "filename": "LibreBaskerville-Regular.ttf",
                "urls": [
                    "https://raw.githubusercontent.com/google/fonts/main/ofl/librebaskerville/LibreBaskerville%5Bwght%5D.ttf",
                    "https://github.com/google/fonts/raw/main/ofl/librebaskerville/LibreBaskerville%5Bwght%5D.ttf",
                ],
                "source": "Google Fonts / OFL"
            },
            {
                "name": "Great Vibes",
                "filename": "GreatVibes-Regular.ttf",
                "urls": [
                    "https://raw.githubusercontent.com/google/fonts/main/ofl/greatvibes/GreatVibes-Regular.ttf",
                    "https://github.com/google/fonts/raw/main/ofl/greatvibes/GreatVibes-Regular.ttf",
                ],
                "source": "Google Fonts / OFL"
            },
            {
                "name": "Cinzel Decorative",
                "filename": "CinzelDecorative-Regular.ttf",
                "urls": [
                    "https://raw.githubusercontent.com/google/fonts/main/ofl/cinzeldecorative/CinzelDecorative-Regular.ttf",
                    "https://github.com/google/fonts/raw/main/ofl/cinzeldecorative/CinzelDecorative-Regular.ttf",
                ],
                "source": "Google Fonts / OFL"
            },
            {
                "name": "Cormorant Garamond",
                "filename": "CormorantGaramond-Regular.ttf",
                "urls": [
                    "https://raw.githubusercontent.com/google/fonts/main/ofl/cormorantgaramond/CormorantGaramond%5Bwght%5D.ttf",
                    "https://github.com/google/fonts/raw/main/ofl/cormorantgaramond/CormorantGaramond%5Bwght%5D.ttf",
                ],
                "source": "Google Fonts / OFL"
            },
            {
                "name": "Spectral SC",
                "filename": "SpectralSC-Regular.ttf",
                "urls": [
                    "https://raw.githubusercontent.com/google/fonts/main/ofl/spectralsc/SpectralSC-Regular.ttf",
                    "https://github.com/google/fonts/raw/main/ofl/spectralsc/SpectralSC-Regular.ttf",
                ],
                "source": "Google Fonts / OFL"
            },
            {
                "name": "UnifrakturCook",
                "filename": "UnifrakturCook-Bold.ttf",
                "urls": [
                    "https://raw.githubusercontent.com/google/fonts/main/ofl/unifrakturcook/UnifrakturCook-Bold.ttf",
                    "https://github.com/google/fonts/raw/main/ofl/unifrakturcook/UnifrakturCook-Bold.ttf",
                ],
                "source": "Google Fonts / OFL"
            },
            {
                "name": "Cloister Black",
                "filename": "CloisterBlack.ttf",
                "urls": [
                    "https://raw.githubusercontent.com/nickklosterman/Fonts/master/CloisterBlack.ttf",
                    "https://github.com/nickklosterman/Fonts/raw/master/CloisterBlack.ttf",
                ],
                "source": "Free font source"
            },
            {
                "name": "Nosifer",
                "filename": "Nosifer-Regular.ttf",
                "urls": [
                    "https://raw.githubusercontent.com/google/fonts/main/apache/nosifer/Nosifer-Regular.ttf",
                    "https://github.com/google/fonts/raw/main/apache/nosifer/Nosifer-Regular.ttf",
                    "https://raw.githubusercontent.com/google/fonts/main/ofl/nosifer/Nosifer-Regular.ttf",
                    "https://github.com/google/fonts/raw/main/ofl/nosifer/Nosifer-Regular.ttf",
                ],
                "source": "Google Fonts / OFL"
            },
            {
                "name": "Butler",
                "filename": "Butler-Regular.ttf",
                "urls": [],
                "source": "Owner supplied / Butler package"
            },
            {
                "name": "Fette Fraktur",
                "filename": "FetteFraktur.ttf",
                "urls": [],
                "source": "SIL Open Font License 1.1"
            },
            {
                "name": "Gothic Ultra OT",
                "filename": "GothicUltraOT.otf",
                "urls": [],
                "source": "Blue Vinyl Fonts license"
            },
            {
                "name": "VladTepes",
                "filename": "VladTepesII.ttf",
                "urls": [],
                "source": "BoltCutterDesign GPL license"
            },
            {
                "name": "Old English Text MT",
                "filename": "OldEnglishTextMT-Regular.ttf",
                "urls": [],
                "source": "Owner supplied"
            },
            {
                "name": "Copperplate Gothic",
                "filename": "CopperplateGothic-Regular.otf",
                "urls": [],
                "source": "Owner supplied"
            },
        ]
        self.system_text_fonts = ["Arial", "Georgia", "Times New Roman", "Serif"]
        self.available_program_fonts = []
        self.font_download_errors = []
        self.ensure_program_fonts_available()
        self.refresh_text_font_choices()

    def get_program_base_dir(self):
        if getattr(sys, "frozen", False):
            return os.path.dirname(sys.executable)
        return os.path.dirname(os.path.abspath(__file__))

    def get_program_fonts_dir(self):
        path = os.path.join(self.get_program_base_dir(), "assets", "fonts")
        os.makedirs(path, exist_ok=True)
        return path

    def download_file(self, urls, destination):
        import urllib.request
        import urllib.error

        headers = {"User-Agent": "LatexVampPhotoTool/1.0"}
        last_error = None
        for url in urls:
            try:
                req = urllib.request.Request(url, headers=headers)
                with urllib.request.urlopen(req, timeout=20) as response:
                    data = response.read()
                if data:
                    with open(destination, "wb") as f:
                        f.write(data)
                    return True
            except Exception as e:
                last_error = str(e)
        if last_error:
            raise RuntimeError(last_error)
        return False

    def ensure_program_fonts_available(self):
        fonts_dir = self.get_program_fonts_dir()

        licenses_path = os.path.join(fonts_dir, "FONT_SOURCES.txt")
        try:
            with open(licenses_path, "w", encoding="utf-8") as f:
                f.write("Latex Vamp Photo Tool - automatic font sources\n\n")
                for spec in self.program_font_specs:
                    f.write(f'{spec["name"]} -> {spec["source"]}\n')
                    for url in spec["urls"]:
                        f.write(f'  {url}\n')
                    f.write("\n")
        except Exception:
            pass

        for spec in self.program_font_specs:
            font_path = os.path.join(fonts_dir, spec["filename"])
            if os.path.exists(font_path) and os.path.getsize(font_path) > 0:
                self.available_program_fonts.append(spec["name"])
                continue
            if not spec.get("urls"):
                self.font_download_errors.append(f'{spec["name"]}: missing local font file')
                continue
            try:
                ok = self.download_file(spec["urls"], font_path)
                if ok and os.path.exists(font_path) and os.path.getsize(font_path) > 0:
                    self.available_program_fonts.append(spec["name"])
                else:
                    self.font_download_errors.append(spec["name"])
            except Exception as e:
                self.font_download_errors.append(f'{spec["name"]}: {e}')

    def refresh_text_font_choices(self):
        ordered = []

        for spec in self.program_font_specs:
            if spec["name"] in self.available_program_fonts:
                ordered.append(spec["name"])

        for name in self.system_text_fonts:
            if name in ordered:
                continue

            if name == "Serif":
                ordered.append(name)
                continue

            if self.get_font_path(name):
                ordered.append(name)

        if not ordered:
            ordered = ["Arial", "Georgia", "Times New Roman", "Serif"]

        self.text_font_choices = ordered

        if self.text_font_value.get() not in self.text_font_choices:
            self.text_font_value.set(self.text_font_choices[0])


    def t(self, key):
        return LANGS[self.lang.get()].get(key, key)

    def start_maximized(self):
        """Open the app maximized/full screen depending on the operating system."""
        try:
            self.root.state("zoomed")
            return
        except Exception:
            pass
        try:
            self.root.attributes("-zoomed", True)
            return
        except Exception:
            pass
        try:
            w = self.root.winfo_screenwidth()
            h = self.root.winfo_screenheight()
            self.root.geometry(f"{w}x{h}+0+0")
        except Exception:
            pass

    def build_ui(self):
        self.root.title(f"{APP_TITLE} - {APP_BUILD}")
        self.root.geometry("1640x980")
        self.root.minsize(1400, 840)
        self.root.after(200, self.start_maximized)
        self.root.configure(bg=COLORS["bg"])

        self.style = ttk.Style()
        try:
            self.style.theme_use("clam")
        except Exception:
            pass

        self.style.configure(
            "TNotebook",
            background=COLORS["panel"],
            borderwidth=0,
            tabmargins=(10, 10, 10, 0)
        )
        self.style.configure(
            "TNotebook.Tab",
            background=COLORS["panel2"],
            foreground=COLORS["muted"],
            padding=(22, 13),
            font=("Segoe UI", 10, "bold")
        )
        self.style.map(
            "TNotebook.Tab",
            background=[("selected", COLORS["accent"])],
            foreground=[("selected", COLORS["text"])]
        )

        top = tk.Frame(self.root, bg=COLORS["header"], bd=0, highlightthickness=1, highlightbackground=COLORS["border"])
        top.pack(fill=tk.X, padx=10, pady=(3, 3), ipady=0)

        self.brand_logo_label = tk.Label(top, bg=COLORS["header"])
        self.brand_logo_label.pack(side=tk.LEFT, padx=(4, 10), pady=0)

        title_stack = tk.Frame(top, bg=COLORS["header"])
        title_stack.pack(side=tk.LEFT, fill=tk.Y)

        self.title_label = tk.Label(title_stack, bg=COLORS["header"], fg=COLORS["text"], font=("Georgia", 22, "bold"))
        self.title_label.pack(anchor="w")

        self.build_label = tk.Label(title_stack, bg=COLORS["header"], fg=COLORS["muted"], font=("Segoe UI", 8, "bold"))
        self.build_label.pack(anchor="w")

        right_controls = tk.Frame(top, bg=COLORS["header"])
        right_controls.pack(side=tk.RIGHT, padx=(8, 8), pady=0, anchor="e")

        self.top_help_button = tk.Button(
            right_controls,
            command=lambda: self.show_tab("help_tab"),
            text="",
            bg=COLORS["secondary"],
            fg=COLORS["text"],
            activebackground=COLORS["accent_hover"],
            activeforeground=COLORS["text"],
            bd=0,
            relief=tk.FLAT,
            font=("Segoe UI", 9, "bold"),
            padx=9,
            pady=5,
            cursor="hand2",
            highlightthickness=0
        )
        self.top_help_button.pack(side=tk.LEFT, padx=(0, 8), pady=0)

        lang_frame = tk.Frame(right_controls, bg=COLORS["header"])
        lang_frame.pack(side=tk.LEFT, anchor="e")

        tk.Label(lang_frame, text="Langue", bg=COLORS["header"], fg=COLORS["muted"], font=("Segoe UI", 8)).pack(side=tk.LEFT, padx=(0, 5))

        self.language_menu = tk.OptionMenu(lang_frame, self.lang, "fr", "en", "es", command=lambda _: self.apply_language())
        self.language_menu.config(bg=COLORS["secondary"], fg=COLORS["text"], activebackground=COLORS["secondary_hover"], activeforeground=COLORS["text"], bd=0, highlightthickness=0, font=("Segoe UI", 9, "bold"))
        self.language_menu["menu"].config(bg=COLORS["panel2"], fg=COLORS["text"], activebackground=COLORS["accent"], activeforeground=COLORS["text"])
        self.language_menu.pack(side=tk.LEFT)

        self.subtitle_label = tk.Label(self.root, bg=COLORS["bg"], fg=COLORS["muted"], font=("Segoe UI", 8, "italic"))

        main = tk.Frame(self.root, bg=COLORS["bg"])
        main.pack(fill=tk.BOTH, expand=True, padx=10, pady=(1, 8))

        sidebar = tk.Frame(main, bg=COLORS["panel"], width=500, highlightthickness=1, highlightbackground=COLORS["border"])
        sidebar.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
        sidebar.pack_propagate(False)

        self.nav_frame = tk.Frame(sidebar, bg=COLORS["header"], highlightthickness=1, highlightbackground=COLORS["border"])
        self.nav_frame.pack(fill=tk.X, padx=8, pady=(7, 5))

        self.tab_content = tk.Frame(sidebar, bg=COLORS["panel"])
        self.tab_content.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8))

        self.tabs = {}
        self.nav_buttons = {}
        self.active_tab_key = "file_tab"

        nav_items = [
            ("file_tab", "nav_file"),
            ("basic_tab", "nav_basic"),
            ("filters_tab", "nav_filters"),
            ("crop_tab", "nav_crop"),
            ("size_tab", "nav_size"),
            ("text_logo_tab", "nav_text_logo"),
            ("logo_tab", "nav_logo"),
            ("background_tab", "nav_background_beta"),
            ("background_correction_tab", "nav_background_correction"),
            ("advanced_tab", "nav_advanced"),
        ]

        nav_columns = 2
        for col in range(nav_columns):
            self.nav_frame.grid_columnconfigure(col, weight=1)

        for index, (tab_key, label_key) in enumerate(nav_items):
            btn = tk.Button(
                self.nav_frame,
                command=lambda k=tab_key: self.show_tab(k),
                bg=COLORS["secondary"],
                fg=COLORS["text"],
                activebackground=COLORS["accent_hover"],
                activeforeground=COLORS["text"],
                bd=0,
                relief=tk.FLAT,
                anchor="center",
                justify=tk.CENTER,
                padx=7,
                pady=8,
                cursor="hand2",
                font=("Segoe UI", 9, "bold"),
                wraplength=185
            )
            btn.grid(row=index // nav_columns, column=index % nav_columns, sticky="ew", padx=3, pady=3)
            self.nav_buttons[tab_key] = (btn, label_key)

            frame = tk.Frame(self.tab_content, bg=COLORS["panel"])
            frame.place(relx=0, rely=0, relwidth=1, relheight=1)
            self.tabs[tab_key] = frame

        # Help is no longer a sidebar tab. It is opened from the top-left Help button.
        help_frame = tk.Frame(self.tab_content, bg=COLORS["panel"])
        help_frame.place(relx=0, rely=0, relwidth=1, relheight=1)
        self.tabs["help_tab"] = help_frame

        self.viewer = tk.Frame(main, bg=COLORS["panel"], bd=0, highlightthickness=1, highlightbackground=COLORS["border"])
        self.viewer.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)

        self.zoom_toolbar = tk.Frame(self.viewer, bg=COLORS["header"], highlightthickness=1, highlightbackground=COLORS["border"])
        self.zoom_toolbar.grid(row=0, column=0, columnspan=2, sticky="ew")
        self.zoom_toolbar.grid_columnconfigure(0, weight=1)
        self.zoom_toolbar.grid_columnconfigure(1, weight=1)
        self.zoom_toolbar.grid_columnconfigure(2, weight=1)

        self.toolbar_left = tk.Frame(self.zoom_toolbar, bg=COLORS["header"])
        self.toolbar_left.grid(row=0, column=0, sticky="w", padx=(14, 0))

        self.toolbar_center = tk.Frame(self.zoom_toolbar, bg=COLORS["header"])
        self.toolbar_center.grid(row=0, column=1, sticky="")

        self.toolbar_right = tk.Frame(self.zoom_toolbar, bg=COLORS["header"])
        self.toolbar_right.grid(row=0, column=2, sticky="e")

        self.photo_original_button = tk.Button(
            self.toolbar_left,
            command=self.reset_image,
            text="",
            bg=COLORS["secondary"],
            fg=COLORS["text"],
            activebackground=COLORS["accent_hover"],
            activeforeground=COLORS["text"],
            bd=0,
            relief=tk.FLAT,
            font=("Segoe UI", 10, "bold"),
            padx=12,
            pady=9,
            cursor="hand2",
            highlightthickness=0
        )
        self.photo_original_button.pack(side=tk.LEFT, padx=(0, 5), pady=7)
        self.widgets.setdefault("return_original", []).append(self.photo_original_button)

        self.photo_undo_button = tk.Button(
            self.toolbar_left,
            command=self.undo_last,
            text="",
            bg=COLORS["secondary"],
            fg=COLORS["text"],
            activebackground=COLORS["accent_hover"],
            activeforeground=COLORS["text"],
            bd=0,
            relief=tk.FLAT,
            font=("Segoe UI", 10, "bold"),
            padx=12,
            pady=9,
            cursor="hand2",
            highlightthickness=0
        )
        self.photo_undo_button.pack(side=tk.LEFT, padx=(0, 5), pady=7)
        self.widgets.setdefault("undo_mod", []).append(self.photo_undo_button)

        self.checkpoint_save_button = tk.Button(
            self.toolbar_left,
            command=self.save_checkpoint,
            text="",
            bg=COLORS["secondary"],
            fg=COLORS["text"],
            activebackground=COLORS["accent_hover"],
            activeforeground=COLORS["text"],
            bd=0,
            relief=tk.FLAT,
            font=("Segoe UI", 10, "bold"),
            padx=10,
            pady=9,
            cursor="hand2",
            highlightthickness=0
        )
        self.checkpoint_save_button.pack(side=tk.LEFT, padx=(0, 5), pady=7)
        self.widgets.setdefault("checkpoint_save", []).append(self.checkpoint_save_button)

        self.checkpoint_restore_button = tk.Button(
            self.toolbar_left,
            command=self.restore_checkpoint,
            text="",
            bg=COLORS["secondary"],
            fg=COLORS["text"],
            activebackground=COLORS["accent_hover"],
            activeforeground=COLORS["text"],
            bd=0,
            relief=tk.FLAT,
            font=("Segoe UI", 10, "bold"),
            padx=10,
            pady=9,
            cursor="hand2",
            highlightthickness=0
        )
        self.checkpoint_restore_button.pack(side=tk.LEFT, padx=(0, 5), pady=7)
        self.widgets.setdefault("checkpoint_restore", []).append(self.checkpoint_restore_button)

        self.zoom_bar_label = tk.Label(
            self.toolbar_center,
            bg=COLORS["secondary"],
            fg=COLORS["text"],
            font=("Segoe UI", 10, "bold"),
            anchor="center",
            bd=0,
            relief=tk.FLAT,
            padx=12,
            pady=9
        )
        self.zoom_bar_label.pack(side=tk.LEFT, padx=(0, 8), pady=8)

        self.zoom_minus_top = tk.Button(
            self.toolbar_center,
            command=self.zoom_out,
            text="−",
            bg=COLORS["secondary"],
            fg=COLORS["text"],
            activebackground=COLORS["accent_hover"],
            activeforeground=COLORS["text"],
            bd=0,
            relief=tk.FLAT,
            font=("Segoe UI", 12, "bold"),
            width=4,
            padx=8,
            cursor="hand2"
        )
        self.zoom_minus_top.pack(side=tk.LEFT, padx=4, pady=8)

        self.zoom_100_top = tk.Button(
            self.toolbar_center,
            command=self.zoom_reset,
            text="100%",
            bg=COLORS["secondary"],
            fg=COLORS["text"],
            activebackground=COLORS["accent_hover"],
            activeforeground=COLORS["text"],
            bd=0,
            relief=tk.FLAT,
            font=("Segoe UI", 10, "bold"),
            width=7,
            cursor="hand2"
        )
        self.zoom_100_top.pack(side=tk.LEFT, padx=4, pady=8)

        self.zoom_plus_top = tk.Button(
            self.toolbar_center,
            command=self.zoom_in,
            text="+",
            bg=COLORS["secondary"],
            fg=COLORS["text"],
            activebackground=COLORS["accent_hover"],
            activeforeground=COLORS["text"],
            bd=0,
            relief=tk.FLAT,
            font=("Segoe UI", 12, "bold"),
            width=4,
            padx=8,
            cursor="hand2"
        )
        self.zoom_plus_top.pack(side=tk.LEFT, padx=4, pady=8)

        self.zoom_percent_label = tk.Label(
            self.toolbar_center,
            bg=COLORS["header"],
            fg=COLORS["text"],
            font=("Segoe UI", 10, "bold"),
            anchor="w",
            width=8
        )
        self.zoom_percent_label.pack(side=tk.LEFT, padx=(10, 14), pady=8)

        self.image_canvas = tk.Canvas(self.viewer, bg=COLORS["canvas"], highlightthickness=0)
        self.h_scroll = tk.Scrollbar(self.viewer, orient=tk.HORIZONTAL, command=self.image_canvas.xview)
        self.v_scroll = tk.Scrollbar(self.viewer, orient=tk.VERTICAL, command=self.image_canvas.yview)
        self.image_canvas.configure(xscrollcommand=self.h_scroll.set, yscrollcommand=self.v_scroll.set)

        self.viewer.grid_rowconfigure(1, weight=1)
        self.viewer.grid_columnconfigure(0, weight=1)
        self.image_canvas.grid(row=1, column=0, sticky="nsew")
        self.v_scroll.grid(row=1, column=1, sticky="ns")
        self.h_scroll.grid(row=2, column=0, sticky="ew")

        self.status = tk.Label(self.root, bg=COLORS["header"], fg=COLORS["muted"], anchor="w", font=("Segoe UI", 9), padx=12, pady=6, highlightthickness=1, highlightbackground=COLORS["border"])
        self.status.pack(fill=tk.X, padx=18, pady=(0, 12))

        self.build_file_tab()
        self.build_basic_tab()
        self.build_filters_tab()
        self.build_crop_tab()
        self.build_size_tab()
        self.build_text_logo_tab()
        self.build_logo_tab()
        self.build_background_tab()
        self.build_background_correction_tab()
        self.build_advanced_tab()
        self.build_help_tab()
        self.bind_mousewheel_to_menu_widgets()
        self.load_brand_logo()
        self.show_tab("file_tab")
        self.root.after(1800, self.preload_background_ai)

        self.image_canvas.bind("<ButtonPress-1>", self.on_mouse_down)
        self.image_canvas.bind("<B1-Motion>", self.on_mouse_drag)
        self.image_canvas.bind("<ButtonRelease-1>", self.on_mouse_up)
        self.image_canvas.bind("<Motion>", self.on_mouse_move)
        self.image_canvas.bind("<Leave>", self.on_canvas_leave)
        self.image_canvas.bind("<Configure>", lambda e: self.refresh_canvas())
        self.image_canvas.bind("<MouseWheel>", self.on_image_wheel)
        self.image_canvas.bind("<Shift-MouseWheel>", self.on_image_shift_wheel)
        self.image_canvas.bind("<Control-MouseWheel>", self.on_mousewheel_zoom)
        self.image_canvas.bind("<Button-4>", lambda e: self.on_linux_wheel(1))
        self.image_canvas.bind("<Button-5>", lambda e: self.on_linux_wheel(-1))
        self.setup_drag_and_drop()
        self.root.bind("<Control-z>", lambda e: self.undo_last())
        self.root.bind("<Control-Z>", lambda e: self.undo_last())

    def show_tab(self, tab_key):
        if tab_key not in self.tabs:
            return
        previous_tab_key = getattr(self, "active_tab_key", None)
        if previous_tab_key is not None:
            self.reset_temporary_states_for_tab_switch(tab_key)
        self.active_tab_key = tab_key
        self.tabs[tab_key].lift()
        self.refresh_canvas()
        self.refresh_nav_buttons()

    def reset_temporary_states_for_tab_switch(self, _next_tab_key):
        self.tool = None
        self.skin_auto_base_image = None
        self.skin_auto_kind = None
        self.panning_active = False
        self.clear_advanced_color_live_state()
        self.filter_preview_name = None
        self.rotation_preview_angle = 0
        if hasattr(self, "rotation_value"):
            self.rotation_value.set(0)
        if hasattr(self, "resize_percent_value"):
            self.resize_percent_value.set(100)
        if hasattr(self, "filter_intensity_value"):
            self.filter_intensity_value.set(100)
        self.crop_start = None
        self.crop_end = None
        if hasattr(self, "crop_aspect_value"):
            self.crop_aspect_value.set("free")
        self.crop_drag_mode = None
        self.crop_drag_origin = None
        self.crop_drag_bounds = None
        self.clear_crop_rect()
        self.clear_text_preview(refresh=False)
        if hasattr(self, "text_input"):
            try:
                self.text_input.delete("1.0", tk.END)
            except Exception:
                pass
        if hasattr(self, "text_size_value"):
            self.text_size_value.set(72)
        if hasattr(self, "text_color_value"):
            self.text_color_value.set("#ffffff")
        self.clear_white_lasso_preview()
        self.white_lasso_path = []
        self.white_lasso_active = False
        self.hide_brush_cursor()
        if getattr(self, "bg_placement_active", False):
            self.cancel_background_placement(show_status=False)
        if hasattr(self, "logo_choice_value"):
            self.reset_logo_selection(refresh=False)

    def refresh_nav_buttons(self):
        if not hasattr(self, "nav_buttons"):
            return
        for tab_key, pair in self.nav_buttons.items():
            btn, label_key = pair
            active = tab_key == getattr(self, "active_tab_key", "file_tab")
            btn.config(
                text=self.t(label_key),
                bg=COLORS["accent"] if active else COLORS["secondary"],
                activebackground=COLORS["accent_hover"] if active else COLORS["secondary_hover"]
            )

    def update_vertical_scrollbar(self, scrollbar, first, last):
        """Show the scrollbar only when the content is clearly taller than the visible area.
        A small tolerance avoids fake scrollbars caused by padding or 1-2 pixel geometry differences."""
        try:
            first_value = float(first)
            last_value = float(last)
        except Exception:
            first_value = 0.0
            last_value = 1.0

        if first_value <= 0.001 and last_value >= 0.999:
            if scrollbar.winfo_ismapped():
                scrollbar.pack_forget()
        else:
            if not scrollbar.winfo_ismapped():
                scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        scrollbar.set(first, last)

    def frame_scrolled(self, parent):
        canvas = tk.Canvas(parent, bg=COLORS["panel"], highlightthickness=0)
        scroll = tk.Scrollbar(parent, orient=tk.VERTICAL, command=canvas.yview)
        canvas.configure(yscrollcommand=lambda first, last, s=scroll: self.update_vertical_scrollbar(s, first, last))
        inner = tk.Frame(canvas, bg=COLORS["panel"])
        window = canvas.create_window((0, 0), window=inner, anchor="nw")

        def refresh_scroll_region(_event=None):
            bbox = canvas.bbox("all") or (0, 0, 0, 0)
            try:
                visible_h = max(int(canvas.winfo_height()), 1)
                visible_w = max(int(canvas.winfo_width()), 1)
                content_h = max(int(bbox[3] - bbox[1]), 0)
                # If the content is only slightly taller because of visual padding, do not create a scrollbar.
                # Real overflow on smaller screens still keeps the scrollbar.
                if visible_h > 1 and content_h <= visible_h + 28:
                    bbox = (0, 0, max(int(bbox[2]), visible_w), visible_h)
                    canvas.yview_moveto(0)
            except Exception:
                pass
            canvas.configure(scrollregion=bbox)
            canvas.after_idle(lambda: self.update_vertical_scrollbar(scroll, *canvas.yview()))

        def resize_inner(event):
            canvas.itemconfig(window, width=event.width)
            canvas.after_idle(refresh_scroll_region)

        inner.bind("<Configure>", refresh_scroll_region)
        canvas.bind("<Configure>", resize_inner)
        canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        canvas.after_idle(refresh_scroll_region)

        if hasattr(self, "scrollable_menu_frames"):
            self.scrollable_menu_frames.append((inner, canvas))

        return inner

    def bind_mousewheel_to_menu_widgets(self):
        """Allow mouse wheel scrolling even when the cursor is over buttons, labels, sliders or sub-sections."""
        if not hasattr(self, "scrollable_menu_frames"):
            return

        def bind_tree(widget, canvas):
            def _wheel(event, c=canvas):
                # Do not move the tab if there is no real overflow.
                try:
                    first, last = c.yview()
                    if first <= 0.001 and last >= 0.999:
                        return "break"
                except Exception:
                    pass
                # Windows/macOS
                delta = getattr(event, "delta", 0)
                if delta:
                    if abs(delta) < 120:
                        direction = -1 if delta > 0 else 1
                    else:
                        direction = int(-1 * (delta / 120))
                    c.yview_scroll(direction, "units")
                # Linux wheel buttons
                elif getattr(event, "num", None) == 4:
                    c.yview_scroll(-3, "units")
                elif getattr(event, "num", None) == 5:
                    c.yview_scroll(3, "units")
                return "break"

            try:
                widget.bind("<MouseWheel>", _wheel, add="+")
                widget.bind("<Button-4>", _wheel, add="+")
                widget.bind("<Button-5>", _wheel, add="+")
            except Exception:
                pass

            try:
                for child in widget.winfo_children():
                    bind_tree(child, canvas)
            except Exception:
                pass

        for inner, canvas in self.scrollable_menu_frames:
            bind_tree(inner, canvas)
            bind_tree(canvas, canvas)

    def add_label(self, parent, key, **kwargs):
        lbl = tk.Label(
            parent,
            bg=COLORS["panel"],
            fg=COLORS["muted"],
            anchor="w",
            justify=tk.LEFT,
            font=("Segoe UI", 10, "bold"),
            **kwargs
        )
        lbl.pack(fill=tk.X, padx=18, pady=(6, 2))
        self.widgets.setdefault(key, []).append(lbl)
        return lbl

    def add_button(self, parent, key, cmd, secondary=False):
        normal_bg = COLORS["secondary"] if secondary else COLORS["accent"]
        hover_bg = COLORS["secondary_hover"] if secondary else COLORS["accent_hover"]

        btn = tk.Button(
            parent,
            command=lambda c=cmd: self.run_button_command(c),
            bg=normal_bg,
            fg=COLORS["text"],
            activebackground=hover_bg,
            activeforeground=COLORS["text"],
            bd=0,
            relief=tk.FLAT,
            font=("Segoe UI", 10, "bold"),
            padx=12,
            pady=9,
            cursor="hand2",
            highlightthickness=0
        )
        btn.pack(fill=tk.X, padx=18, pady=3)

        btn.bind("<Enter>", lambda event, b=btn, color=hover_bg: b.config(bg=color))
        btn.bind("<Leave>", lambda event, b=btn, color=normal_bg: b.config(bg=color))

        self.widgets.setdefault(key, []).append(btn)
        return btn

    def create_circle_icon_button(self, parent, symbol, cmd):
        size = 34
        normal_fill = COLORS["panel2"]
        hover_fill = COLORS["secondary_hover"]

        canvas = tk.Canvas(
            parent,
            width=size,
            height=size,
            bg=COLORS["panel"],
            highlightthickness=0,
            bd=0,
            cursor="hand2"
        )
        oval = canvas.create_oval(2, 2, size - 2, size - 2, fill=normal_fill, outline=COLORS["border"], width=1)
        canvas.create_text(size / 2, size / 2, text=symbol, fill=COLORS["text"], font=("Segoe UI", 12, "bold"))

        def on_enter(_event):
            canvas.itemconfig(oval, fill=hover_fill)

        def on_leave(_event):
            canvas.itemconfig(oval, fill=normal_fill)

        def on_click(_event):
            cmd()

        canvas.bind("<Enter>", on_enter)
        canvas.bind("<Leave>", on_leave)
        canvas.bind("<Button-1>", on_click)
        return canvas

    def add_adjust_control(self, parent, key, minus_cmd, plus_cmd):
        row = tk.Frame(parent, bg=COLORS["panel"])
        row.pack(fill=tk.X, padx=18, pady=3)

        minus_btn = self.create_circle_icon_button(row, "−", minus_cmd)
        minus_btn.pack(side=tk.LEFT, padx=(0, 12))

        lbl = tk.Label(
            row,
            bg=COLORS["panel"],
            fg=COLORS["text"],
            anchor="center",
            justify=tk.CENTER,
            font=("Segoe UI", 10, "bold")
        )
        lbl.pack(side=tk.LEFT, fill=tk.X, expand=True)

        plus_btn = self.create_circle_icon_button(row, "+", plus_cmd)
        plus_btn.pack(side=tk.RIGHT, padx=(12, 0))

        self.widgets.setdefault(key, []).append(lbl)
        return row

    def add_slider(self, parent, label_key, variable, minv, maxv, command=None):
        self.add_label(parent, label_key)
        slider = tk.Scale(
            parent,
            from_=minv,
            to=maxv,
            orient=tk.HORIZONTAL,
            variable=variable,
            bg=COLORS["panel"],
            fg=COLORS["text"],
            troughcolor=COLORS["panel2"],
            highlightthickness=0,
            activebackground=COLORS["accent_hover"],
            font=("Segoe UI", 9),
            command=command
        )
        slider.pack(fill=tk.X, padx=18, pady=(0, 6))
        return slider

    def on_rotation_preview_change(self, *_):
        try:
            self.rotation_preview_angle = int(float(self.rotation_value.get()))
        except Exception:
            self.rotation_preview_angle = 0
        self.refresh_canvas()

    def build_file_tab(self):
        f = self.frame_scrolled(self.tabs["file_tab"])

        # Fichiers: content starts immediately, no wasted top space.
        self.open_photo_button = self.add_button(f, "open_photo", self.load_image)
        self.drop_hint_label = tk.Label(
            f,
            bg=COLORS["panel"],
            fg=COLORS["muted"],
            justify=tk.LEFT,
            anchor="w",
            wraplength=420,
            font=("Arial", 9, "italic")
        )
        self.drop_hint_label.pack(fill=tk.X, padx=12, pady=(0, 6))

        self.add_button(f, "save_jpeg", lambda: self.save_image_format("jpg"))
        self.add_button(f, "save_png", lambda: self.save_image_format("png"), secondary=True)
        self.add_button(f, "save_webp", lambda: self.save_image_format("webp"), secondary=True)
        self.add_button(f, "save_pdf", lambda: self.save_image_format("pdf"), secondary=True)
        self.add_button(f, "undo", self.undo_last, secondary=True)

        self.add_label(f, "quick_guide")
        self.quick_guide_text = tk.Label(
            f,
            bg=COLORS["panel"],
            fg=COLORS["muted"],
            justify=tk.LEFT,
            anchor="w",
            wraplength=420,
            font=("Arial", 9)
        )
        self.quick_guide_text.pack(fill=tk.X, padx=12, pady=(0, 6))

    def build_basic_tab(self):
        f = self.frame_scrolled(self.tabs["basic_tab"])

        self.add_label(f, "auto_enhance")
        self.add_slider(f, "auto_enhance_strength", self.auto_enhance_strength_value, 0, 100)
        self.add_button(f, "auto_enhance", self.auto_enhance)
        self.add_slider(f, "skin_redness_strength", self.skin_redness_strength_value, 0, 100)
        self.add_button(f, "skin_redness_neutralize", self.apply_skin_redness_neutralizer, secondary=True)
        self.add_slider(f, "skin_face_strength", self.skin_face_strength, 0, 100)
        self.add_button(f, "skin_perfect_auto_face", self.apply_perfect_skin_auto_face, secondary=True)
        self.add_button(f, "skin_tone_auto_face", self.apply_skin_tone_auto_face, secondary=True)
        self.add_slider(f, "skin_body_strength", self.skin_body_strength, 0, 100)
        self.add_button(f, "skin_perfect_auto_body", self.apply_perfect_skin_auto_body, secondary=True)
        self.add_button(f, "skin_tone_auto_body", self.apply_skin_tone_auto_body, secondary=True)
        self.add_slider(f, "skin_legs_strength", self.skin_legs_strength, 0, 100)
        self.add_button(f, "skin_perfect_auto_legs", self.apply_perfect_skin_auto_legs, secondary=True)
        self.add_button(f, "skin_tone_auto_legs", self.apply_skin_tone_auto_legs, secondary=True)
        self.add_button(f, "heal", lambda: self.set_tool("heal"), secondary=True)
        self.add_slider(f, "brush_size", self.brush_size, 8, 120)
        self.add_slider(f, "heal_strength", self.heal_strength, 10, 100)
        self.add_button(f, "stop_tool", lambda: self.set_tool(None), secondary=True)

    def build_skin_advanced_tab(self):
        f = self.frame_scrolled(self.tabs["skin_advanced_tab"])
        # Peau avancée vidé : Corriger imperfection a été déplacé dans Corrections simples.
        pass

    def build_filters_tab(self):
        f = self.frame_scrolled(self.tabs["filters_tab"])
        self.add_button(f, "filter_cinematic_dark", lambda: self.set_filter_preview("cinematic_dark"), secondary=True)
        self.add_button(f, "filter_gothic_portrait", lambda: self.set_filter_preview("gothic_portrait"), secondary=True)
        self.add_button(f, "filter_latex_shine", lambda: self.set_filter_preview("latex_shine"), secondary=True)
        self.add_button(f, "filter_soft_beauty", lambda: self.set_filter_preview("soft_beauty"), secondary=True)
        self.add_button(f, "filter_high_fashion", lambda: self.set_filter_preview("high_fashion"), secondary=True)
        self.add_button(f, "filter_black_red", lambda: self.set_filter_preview("black_red"), secondary=True)
        self.add_button(f, "filter_vintage_gothic", lambda: self.set_filter_preview("vintage_gothic"), secondary=True)
        self.add_button(f, "filter_black_white", lambda: self.set_filter_preview("black_white"), secondary=True)
        self.add_button(f, "filter_noir_premium", lambda: self.set_filter_preview("noir_premium"), secondary=True)
        self.add_slider(f, "filter_intensity", self.filter_intensity_value, 0, 100, command=self.on_filter_preview_change)
        self.add_button(f, "apply_filter", self.apply_filter_preview)
        self.add_button(f, "clear_filter_preview", self.clear_filter_preview, secondary=True)

    def build_crop_tab(self):
        f = self.frame_scrolled(self.tabs["crop_tab"])
        self.add_label(f, "crop")
        self.add_label(f, "crop_aspect")
        aspect_frame = tk.Frame(f, bg=COLORS["panel"])
        aspect_frame.pack(fill=tk.X, padx=18, pady=(2, 8))
        self.crop_aspect_buttons = []
        aspect_choices = [
            ("crop_aspect_free", "free"),
            ("crop_aspect_square", "1:1"),
            ("crop_aspect_4_5", "4:5"),
            ("crop_aspect_5_4", "5:4"),
            ("crop_aspect_3_2", "3:2"),
            ("crop_aspect_2_3", "2:3"),
            ("crop_aspect_16_9", "16:9"),
            ("crop_aspect_9_16", "9:16"),
        ]
        for index, (label_key, value) in enumerate(aspect_choices):
            btn = tk.Radiobutton(
                aspect_frame,
                variable=self.crop_aspect_value,
                value=value,
                command=self.on_crop_aspect_change,
                indicatoron=False,
                selectcolor=COLORS["accent"],
                bg=COLORS["secondary"],
                fg=COLORS["text"],
                activebackground=COLORS["secondary_hover"],
                activeforeground=COLORS["text"],
                bd=0,
                relief=tk.FLAT,
                font=("Segoe UI", 9, "bold"),
                padx=8,
                pady=7,
                cursor="hand2",
                highlightthickness=0
            )
            btn.grid(row=index // 2, column=index % 2, sticky="ew", padx=3, pady=3)
            aspect_frame.grid_columnconfigure(index % 2, weight=1)
            self.widgets.setdefault(label_key, []).append(btn)
            self.crop_aspect_buttons.append(btn)
        self.add_button(f, "crop", lambda: self.set_tool("crop"), secondary=True)
        self.add_button(f, "apply_crop", self.apply_crop)
        self.add_button(f, "stop_tool", lambda: self.set_tool(None), secondary=True)
        self.add_label(f, "rotation")
        self.add_button(f, "rotate_left", lambda: self.rotate_quick(-90), secondary=True)
        self.add_button(f, "rotate_right", lambda: self.rotate_quick(90), secondary=True)
        self.add_slider(f, "manual_rotation", self.rotation_value, -180, 180, command=self.on_rotation_preview_change)
        self.add_button(f, "apply_rotation", self.apply_rotation_slider)
        self.add_button(f, "reset_rotation", self.reset_rotation_slider, secondary=True)

    def build_size_tab(self):
        f = self.frame_scrolled(self.tabs["size_tab"])
        self.add_label(f, "resize")
        self.add_slider(f, "resize_label", self.resize_percent_value, 10, 300)
        self.add_button(f, "apply_resize", self.apply_resize_percentage)
        self.add_button(f, "resize_50", lambda: self.apply_quick_resize(50), secondary=True)
        self.add_button(f, "resize_200", lambda: self.apply_quick_resize(200), secondary=True)
        self.add_button(f, "resize_reset", self.reset_resize_slider, secondary=True)

    def build_text_logo_tab(self):
        f = self.frame_scrolled(self.tabs["text_logo_tab"])
        self.add_label(f, "text")
        self.add_label(f, "text_to_write")
        self.text_input = tk.Text(f, height=4, bg=COLORS["panel2"], fg=COLORS["text"], insertbackground="#ffffff", wrap="word", relief=tk.FLAT, font=("Arial", 10))
        self.text_input.pack(fill=tk.X, padx=12, pady=(2, 8))
        self.text_input.bind("<KeyRelease>", lambda _event: self.draw_text_preview_items())
        self.add_label(f, "font")
        self.font_menu = tk.OptionMenu(f, self.text_font_value, *self.text_font_choices)
        self.font_menu.config(bg=COLORS["secondary"], fg=COLORS["text"], bd=0, highlightthickness=0)
        self.font_menu.pack(fill=tk.X, padx=12, pady=4)
        self.text_font_note_label = tk.Label(
            f,
            text="",
            bg=COLORS["panel"],
            fg=COLORS["muted"],
            font=("Segoe UI", 8),
            anchor="w",
            justify=tk.LEFT,
            wraplength=300
        )
        self.text_font_note_label.pack(fill=tk.X, padx=12, pady=(0, 8))
        self.widgets.setdefault("font_note", []).append(self.text_font_note_label)
        self.font_preview_label = tk.Label(
            f,
            text="",
            bg=COLORS["panel2"],
            fg=COLORS["text"],
            bd=1,
            relief=tk.SOLID,
            padx=8,
            pady=6,
            anchor="w"
        )
        self.font_preview_label.pack(fill=tk.X, padx=12, pady=(0, 8))
        self.font_preview_label.bind("<Configure>", lambda _event: self.update_font_preview())
        self.update_font_preview()
        self.add_slider(f, "text_size", self.text_size_value, 10, 200, command=lambda *_: self.draw_text_preview_items())
        self.add_button(f, "text_color", self.choose_text_color, secondary=True)
        self.text_color_preview = tk.Label(f, text="#ffffff", bg=COLORS["panel"], fg=COLORS["text"], anchor="w", font=("Arial", 9))
        self.text_color_preview.pack(fill=tk.X, padx=12, pady=(0, 6))
        self.add_button(f, "text_mode", lambda: self.set_tool("text"))
        self.add_button(f, "clear_text", self.undo_last_text, secondary=True)
        self.add_button(f, "stop_tool", lambda: self.set_tool(None), secondary=True)

    def build_logo_tab(self):
        f = self.frame_scrolled(self.tabs["logo_tab"])
        logo_title = self.add_label(f, "logo")
        logo_title.pack_configure(pady=(0, 2))
        logo_choice = self.add_label(f, "logo_choice")
        logo_choice.pack_configure(pady=(3, 3))
        button_grid = tk.Frame(f, bg=COLORS["panel"])
        button_grid.pack(fill=tk.X, padx=12, pady=(2, 8))
        self.logo_choice_buttons = {}
        for index, (choice_id, label_key, _filename) in enumerate(self.logo_choice_choices):
            btn = tk.Button(
                button_grid,
                command=lambda c=choice_id: self.toggle_logo_choice(c),
                bg=COLORS["secondary"],
                fg=COLORS["text"],
                activebackground=COLORS["accent_hover"],
                activeforeground=COLORS["text"],
                bd=0,
                relief=tk.FLAT,
                font=("Segoe UI", 9, "bold"),
                padx=8,
                pady=9,
                cursor="hand2",
                highlightthickness=0,
                wraplength=150
            )
            btn.grid(row=index // 2, column=index % 2, sticky="ew", padx=3, pady=3)
            btn.bind("<Enter>", lambda event, b=btn, c=choice_id: self.on_logo_choice_button_hover(b, c, True))
            btn.bind("<Leave>", lambda event, b=btn, c=choice_id: self.on_logo_choice_button_hover(b, c, False))
            button_grid.grid_columnconfigure(index % 2, weight=1)
            self.logo_choice_buttons[choice_id] = (btn, label_key)
            self.widgets.setdefault(label_key, []).append(btn)
        self.add_button(f, "custom_logo", self.load_logo_image, secondary=True)
        self.logo_sample_label = tk.Label(
            f,
            text=self.t("logo_preview_empty"),
            bg=COLORS["panel2"],
            fg=COLORS["muted"],
            bd=1,
            relief=tk.SOLID,
            padx=8,
            pady=8,
            anchor="center"
        )
        self.logo_sample_label.pack(fill=tk.X, padx=12, pady=(2, 10))
        self.widgets.setdefault("logo_preview_empty", []).append(self.logo_sample_label)
        self.update_logo_sample_preview()
        self.logo_black_to_white_check = tk.Checkbutton(
            f,
            variable=self.logo_black_to_white_value,
            command=lambda: self.refresh_logo_preview(force_source=True),
            bg=COLORS["panel"],
            fg=COLORS["text"],
            activebackground=COLORS["panel"],
            activeforeground=COLORS["text"],
            selectcolor=COLORS["secondary"],
            bd=0,
            highlightthickness=0,
            anchor="w",
            font=("Segoe UI", 10, "bold")
        )
        self.logo_black_to_white_check.pack(fill=tk.X, padx=18, pady=(2, 6))
        self.widgets.setdefault("logo_black_to_white", []).append(self.logo_black_to_white_check)
        self.add_slider(f, "logo_size", self.logo_size_value, 5, 60, command=self.on_logo_slider_change)
        self.add_slider(f, "logo_opacity", self.logo_opacity_value, 10, 100, command=self.on_logo_slider_change)
        self.add_button(f, "clear_logo_preview", self.clear_logo_preview, secondary=True)
        self.add_button(f, "apply_logo", self.apply_logo_watermark)
        self.add_button(f, "undo_logo_apply", self.undo_last_logo_application, secondary=True)
        self.refresh_logo_choice_buttons()

    def build_background_tab(self):
        f = self.frame_scrolled(self.tabs["background_tab"])
        self.add_label(f, "bg")
        self.bg_help_label = tk.Label(f, bg=COLORS["panel"], fg=COLORS["muted"], justify=tk.LEFT, anchor="w", wraplength=420, font=("Arial", 9))
        self.bg_help_label.pack(fill=tk.X, padx=12, pady=(0, 8))
        self.add_button(f, "replace_ai_matte", self.replace_background_ai_matte)
        self.add_button(f, "replace_ai_matte_alt", self.replace_background_ai_matte_alternative, secondary=True)
        self.add_button(f, "bg_color", self.choose_bg_replace_color, secondary=True)
        self.bg_color_preview = tk.Label(f, text="#ffffff", bg=COLORS["panel"], fg=COLORS["text"], anchor="w", font=("Arial", 9))
        self.bg_color_preview.pack(fill=tk.X, padx=12, pady=(0, 6))
        self.add_button(f, "bg_photo", self.load_bg_replace_image, secondary=True)
        self.bg_photo_label = tk.Label(f, bg=COLORS["panel"], fg=COLORS["muted"], anchor="w", justify=tk.LEFT, wraplength=420, font=("Arial", 9))
        self.bg_photo_label.pack(fill=tk.X, padx=12, pady=(0, 6))
        self.add_button(f, "remove_bg_photo", self.clear_bg_replace_image, secondary=True)
        self.add_slider(f, "bg_background_rotation", self.bg_background_rotation_value, -180, 180, command=lambda *_: self.on_bg_background_rotation_change())
        self.add_slider(f, "bg_subject_size", self.bg_subject_size_value, 10, 220, command=lambda *_: self.on_bg_subject_size_change())
        self.add_slider(f, "bg_subject_rotation", self.bg_subject_rotation_value, -180, 180, command=lambda *_: self.on_bg_subject_rotation_change())
        self.add_button(f, "bg_apply_placement", self.apply_background_placement)
        self.add_button(f, "bg_cancel_placement", self.cancel_background_placement, secondary=True)

    def build_background_correction_tab(self):
        f = self.frame_scrolled(self.tabs["background_correction_tab"])
        self.add_label(f, "bg_correction")
        self.add_button(f, "cleanup_color", self.choose_cleanup_fill_color, secondary=True)
        self.cleanup_color_preview = tk.Label(f, text="#ffffff", bg=COLORS["panel"], fg=COLORS["text"], anchor="w", font=("Arial", 9))
        self.cleanup_color_preview.pack(fill=tk.X, padx=12, pady=(0, 6))
        self.add_button(f, "cleanup_pick_color", lambda: self.set_tool("cleanup_pick_color"), secondary=True)
        self.add_button(f, "white_brush_beta", lambda: self.set_tool("white_brush_beta"), secondary=True)
        self.add_button(f, "white_bucket_beta", lambda: self.set_tool("white_bucket_beta"), secondary=True)
        self.add_slider(f, "cleanup_opacity", self.cleanup_opacity_value, 10, 100)
        self.add_slider(f, "cleanup_bucket_tolerance", self.cleanup_bucket_tolerance_value, 5, 120)
        self.add_button(f, "white_rect_beta", lambda: self.set_tool("white_rect_beta"), secondary=True)
        self.add_button(f, "white_ellipse_beta", lambda: self.set_tool("white_ellipse_beta"), secondary=True)
        self.add_button(f, "white_lasso_beta", lambda: self.set_tool("white_lasso_beta"), secondary=True)
        self.add_button(f, "transparent_lasso_beta", lambda: self.set_tool("transparent_lasso_beta"), secondary=True)
        self.add_button(f, "recover_lasso_beta", lambda: self.set_tool("recover_lasso_beta"), secondary=True)
        self.add_slider(f, "cleanup_lasso_expand", self.cleanup_lasso_expand_value, 0, 40)
        self.add_button(f, "brush_small", lambda: self.set_brush_size_preset(18), secondary=True)
        self.add_button(f, "brush_medium", lambda: self.set_brush_size_preset(45), secondary=True)
        self.add_button(f, "brush_large", lambda: self.set_brush_size_preset(90), secondary=True)
        self.add_slider(f, "brush_size", self.brush_size, 4, 180)
        self.add_button(f, "stop_tool", lambda: self.set_tool(None), secondary=True)

    def build_advanced_tab(self):
        f = self.frame_scrolled(self.tabs["advanced_tab"])
        self.add_label(f, "advanced_colors")
        self.add_slider(f, "manual_saturation", self.saturation_value, -100, 100, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "advanced_brightness", self.advanced_brightness_value, -100, 100, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "advanced_contrast", self.advanced_contrast_value, -100, 100, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "advanced_vibrance", self.advanced_vibrance_value, -100, 100, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "advanced_shadows", self.advanced_shadows_value, -100, 100, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "advanced_highlights", self.advanced_highlights_value, -100, 100, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "advanced_sharpness", self.advanced_sharpness_value, -50, 100, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "advanced_clarity", self.advanced_clarity_value, -50, 100, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "advanced_denoise", self.advanced_denoise_value, 0, 100, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "temperature", self.temperature_value, -50, 50, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "tint", self.tint_value, -50, 50, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "red", self.red_value, -50, 50, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "green", self.green_value, -50, 50, command=self.on_advanced_color_slider_change)
        self.add_slider(f, "blue", self.blue_value, -50, 50, command=self.on_advanced_color_slider_change)
        self.add_button(f, "reset_colors", self.reset_color_sliders, secondary=True)


    def build_help_tab(self):
        f = self.tabs["help_tab"]

        self.help_title_label = tk.Label(
            f,
            bg=COLORS["panel"],
            fg=COLORS["text"],
            anchor="w",
            font=("Segoe UI", 15, "bold")
        )
        self.help_title_label.pack(fill=tk.X, padx=14, pady=(6, 2))

        self.help_intro_label = tk.Label(
            f,
            bg=COLORS["panel"],
            fg=COLORS["muted"],
            anchor="w",
            justify=tk.LEFT,
            wraplength=900,
            font=("Segoe UI", 9)
        )
        self.help_intro_label.pack(fill=tk.X, padx=14, pady=(0, 6))

        body = tk.Frame(f, bg=COLORS["panel"])
        body.pack(fill=tk.BOTH, expand=True, padx=14, pady=(0, 8))

        nav = tk.Frame(body, bg=COLORS["panel2"], highlightthickness=1, highlightbackground=COLORS["border"])
        nav.pack(side=tk.LEFT, fill=tk.Y)

        detail = tk.Frame(body, bg=COLORS["panel2"], highlightthickness=1, highlightbackground=COLORS["border"])
        detail.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(12, 0))

        self.help_topic_keys = [
            "help_topic_open",
            "help_topic_basic",
            "help_topic_retouch",
            "help_topic_skin_advanced",
            "help_topic_filters",
            "help_topic_crop",
            "help_topic_size_tab",
            "help_topic_text",
            "help_topic_logo",
            "help_topic_background",
            "help_topic_background_correction",
            "help_topic_advanced",
            "help_topic_save",
            "help_topic_undo",
        ]
        self.help_nav_buttons = {}
        for key in self.help_topic_keys:
            btn = tk.Button(
                nav,
                command=lambda k=key: self.show_help_topic(k),
                bg=COLORS["secondary"],
                fg=COLORS["text"],
                activebackground=COLORS["secondary_hover"],
                activeforeground=COLORS["text"],
                relief=tk.FLAT,
                bd=0,
                anchor="w",
                justify=tk.LEFT,
                padx=12,
                pady=7,
                wraplength=250,
                cursor="hand2"
            )
            btn.pack(fill=tk.X, padx=10, pady=3)
            self.help_nav_buttons[key] = btn

        self.help_desc_title = tk.Label(
            detail,
            bg=COLORS["panel2"],
            fg=COLORS["text"],
            anchor="w",
            justify=tk.LEFT,
            font=("Segoe UI", 14, "bold")
        )
        self.help_desc_title.pack(fill=tk.X, padx=14, pady=(8, 6))

        text_wrap = tk.Frame(detail, bg=COLORS["panel2"])
        text_wrap.pack(fill=tk.BOTH, expand=True, padx=14, pady=(0, 8))
        self.help_text_widget = tk.Text(
            text_wrap,
            bg=COLORS["panel2"],
            fg=COLORS["text"],
            wrap="word",
            relief=tk.FLAT,
            font=("Segoe UI", 10),
            padx=2,
            pady=2
        )
        help_scroll = tk.Scrollbar(text_wrap, orient=tk.VERTICAL, command=self.help_text_widget.yview)
        self.help_text_widget.configure(yscrollcommand=lambda first, last, s=help_scroll: self.update_vertical_scrollbar(s, first, last))
        self.help_text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.help_text_widget.after_idle(lambda: self.update_vertical_scrollbar(help_scroll, *self.help_text_widget.yview()))
        self.help_text_widget.config(state=tk.DISABLED)

        self.current_help_topic = self.help_topic_keys[0]
        self.refresh_help_topics()

    def refresh_help_topics(self):
        if not hasattr(self, "help_nav_buttons"):
            return
        self.help_title_label.config(text=self.t("help_title"))
        self.help_intro_label.config(text=self.t("help_intro"))
        current = getattr(self, "current_help_topic", self.help_topic_keys[0])
        for key, btn in self.help_nav_buttons.items():
            selected = key == current
            btn.config(
                text=self.t(key),
                bg=COLORS["accent"] if selected else COLORS["secondary"],
                activebackground=COLORS["accent_hover"] if selected else COLORS["secondary_hover"]
            )
        self.show_help_topic(current, refresh_only=True)

    def show_help_topic(self, key, refresh_only=False):
        self.current_help_topic = key
        if not refresh_only and hasattr(self, "help_nav_buttons"):
            for btn_key, btn in self.help_nav_buttons.items():
                selected = btn_key == key
                btn.config(
                    bg=COLORS["accent"] if selected else COLORS["secondary"],
                    activebackground=COLORS["accent_hover"] if selected else COLORS["secondary_hover"]
                )
        self.help_desc_title.config(text=self.t(key))
        self.help_text_widget.config(state=tk.NORMAL)
        self.help_text_widget.delete("1.0", tk.END)
        self.help_text_widget.insert("1.0", self.t(key + "_text"))
        self.help_text_widget.config(state=tk.DISABLED)

    def apply_gothic_title_font(self):
        candidates = [
            "Old English Text MT",
            "Blackletter",
            "Blackadder ITC",
            "UnifrakturMaguntia",
            "Cloister Black",
            "Georgia"
        ]
        try:
            available = set(self.root.tk.call("font", "families"))
        except Exception:
            available = set()
        chosen = "Georgia"
        for name in candidates:
            if name in available:
                chosen = name
                break
        try:
            self.title_label.config(font=(chosen, 22, "bold"))
        except Exception:
            self.title_label.config(font=("Georgia", 22, "bold"))

    def load_brand_logo(self):
        try:
            path = resource_path(os.path.join("logos", "brand_logo.png"))
            if not os.path.exists(path):
                self.brand_logo_label.config(text="LV", fg=COLORS["text"], bg=COLORS["accent"], font=("Segoe UI", 14, "bold"), width=4)
                return
            img = Image.open(path).convert("RGBA")
            img.thumbnail((58, 42), Image.LANCZOS)
            self.brand_logo_tk = ImageTk.PhotoImage(img)
            self.brand_logo_label.config(image=self.brand_logo_tk)
        except Exception:
            try:
                self.brand_logo_label.config(text="LV", fg=COLORS["text"], bg=COLORS["accent"], font=("Segoe UI", 14, "bold"), width=4)
            except Exception:
                pass

    def apply_language(self):
        self.root.title(f"{APP_TITLE} - {APP_BUILD}")
        self.title_label.config(text=self.t("title"))
        if hasattr(self, "build_label"):
            self.build_label.config(text=f"Version: {APP_BUILD}")
        self.subtitle_label.config(text=self.t("subtitle"))
        if hasattr(self, "top_help_button"):
            self.top_help_button.config(text=self.t("nav_help"))
        self.quick_guide_text.config(text=self.t("quick_guide_text"))
        self.zoom_bar_label.config(text=self.t("zoom_bar"))
        self.update_zoom_label()

        self.refresh_nav_buttons()

        for key, widgets in self.widgets.items():
            for widget in widgets:
                try:
                    widget.config(text=self.t(key))
                except Exception:
                    pass

        self.update_open_photo_button()
        self.refresh_help_topics()
        self.update_logo_source_label()
        self.refresh_logo_choice_buttons()
        self.update_logo_sample_preview()
        self.drop_hint_label.config(text=self.t("drop_ready") if self.drag_drop_enabled else self.t("drop_not_available"))
        if hasattr(self, "filter_help_label"):
            self.filter_help_label.config(text=self.t("filter_help"))
        self.set_status(self.t("no_photo") if self.image is None else self.status.cget("text"))

    def set_status(self, text):
        zoom_text = f" | Zoom {int(self.zoom * 100)}%" if self.image is not None else ""
        self.status.config(text=text + zoom_text)

    def collect_busy_cursor_widgets(self):
        widgets = []
        seen = set()

        def visit(widget):
            if widget is None:
                return
            try:
                ident = str(widget)
            except Exception:
                ident = id(widget)
            if ident in seen:
                return
            seen.add(ident)
            try:
                previous = widget.cget("cursor")
            except Exception:
                previous = ""
            widgets.append((widget, previous))
            try:
                children = widget.winfo_children()
            except Exception:
                children = []
            for child in children:
                visit(child)

        visit(self.root)
        return widgets

    def set_busy(self, active, text=None):
        if active:
            if self.busy_depth == 0:
                self.busy_cursor_widgets = self.collect_busy_cursor_widgets()
            self.busy_depth += 1
            if text:
                self.set_status(text)
        else:
            self.busy_depth = max(0, self.busy_depth - 1)
        if self.busy_depth > 0:
            for widget, _previous in getattr(self, "busy_cursor_widgets", []):
                try:
                    widget.configure(cursor="watch")
                except Exception:
                    pass
        else:
            for widget, previous in getattr(self, "busy_cursor_widgets", []):
                try:
                    widget.configure(cursor=previous or "")
                except Exception:
                    pass
            self.busy_cursor_widgets = []
        try:
            self.root.update_idletasks()
        except Exception:
            pass
        self.update_canvas_cursor()

    def run_button_command(self, cmd):
        self.set_busy(True)
        try:
            return cmd()
        finally:
            self.set_busy(False)

    def require_image(self):
        if self.image is None:
            messagebox.showwarning(APP_TITLE, self.t("load_first"))
            return False
        return True

    def push_undo(self, action=None, extra=None):
        if self.image is None:
            return
        undo_item = {
            "image": self.image.copy(),
            "advanced_state": self.get_advanced_slider_state(),
            "action": action
        }
        if extra:
            undo_item.update(extra)
        self.undo_stack.append(undo_item)
        if len(self.undo_stack) > self.max_undo:
            self.undo_stack.pop(0)

    def undo_last(self):
        if self.image is None:
            return
        if not self.undo_stack:
            self.set_status(self.t("nothing_to_undo"))
            return
        undo_item = self.undo_stack.pop()
        if isinstance(undo_item, dict):
            self.image = undo_item.get("image", self.image).copy()
            self.restore_advanced_slider_state(undo_item.get("advanced_state"))
        else:
            self.image = undo_item.copy()
        self.clear_advanced_color_live_state()
        self.skin_auto_base_image = None
        self.skin_auto_kind = None
        self.tool = None
        self.filter_preview_name = None
        self.reset_logo_selection(refresh=False)
        self.overlay_layers = []
        self.active_overlay_index = -1
        self.clear_background_placement_state()
        self.clear_crop_rect()
        self.hide_brush_cursor()
        self.refresh_canvas()
        self.set_status(self.t("undo_done"))

    def undo_last_text(self):
        if self.image is None:
            return
        if not self.undo_stack:
            self.set_status(self.t("nothing_to_undo"))
            return
        undo_item = self.undo_stack[-1]
        if isinstance(undo_item, dict) and undo_item.get("action") == "text":
            self.undo_last()
        else:
            self.set_status(self.t("last_edit_not_text"))

    def undo_last_logo_application(self):
        if self.image is None:
            return
        if not self.undo_stack:
            self.set_status(self.t("nothing_to_undo"))
            return
        undo_item = self.undo_stack[-1]
        if isinstance(undo_item, dict) and undo_item.get("action") == "logo":
            logo_state = undo_item.get("logo_state")
            self.undo_last()
            if logo_state:
                self.restore_logo_state(logo_state)
            self.refresh_canvas()
        else:
            self.set_status(self.t("nothing_to_undo"))

    def save_checkpoint(self):
        if not self.require_image():
            return
        self.checkpoint_image = self.image.copy()
        self.checkpoint_advanced_state = self.get_advanced_slider_state()
        self.set_status(self.t("checkpoint_saved"))

    def restore_checkpoint(self):
        if self.image is None:
            return
        if self.checkpoint_image is None:
            self.set_status(self.t("checkpoint_missing"))
            return
        self.push_undo()
        self.image = self.checkpoint_image.copy()
        self.restore_advanced_slider_state(self.checkpoint_advanced_state)
        self.clear_advanced_color_live_state()
        self.skin_auto_base_image = None
        self.skin_auto_kind = None
        self.tool = None
        self.filter_preview_name = None
        self.reset_logo_selection(refresh=False)
        self.overlay_layers = []
        self.active_overlay_index = -1
        self.clear_background_placement_state()
        self.clear_crop_rect()
        self.clear_white_lasso_preview()
        self.hide_brush_cursor()
        self.refresh_canvas()
        self.set_status(self.t("checkpoint_restored"))

    def update_logo_source_label(self):
        self.refresh_logo_choice_buttons()

    def refresh_logo_choice_buttons(self):
        if not hasattr(self, "logo_choice_buttons"):
            return
        selected = self.logo_choice_value.get()
        for choice_id, pair in self.logo_choice_buttons.items():
            btn, label_key = pair
            is_selected = selected == choice_id
            normal_bg = COLORS["accent"] if is_selected else COLORS["secondary"]
            active_bg = COLORS["accent_hover"] if is_selected else COLORS["secondary_hover"]
            try:
                btn.config(
                    text=self.t(label_key),
                    bg=normal_bg,
                    activebackground=active_bg,
                    relief=tk.SUNKEN if is_selected else tk.FLAT
                )
            except Exception:
                pass

    def on_logo_choice_button_hover(self, button, choice_id, hovering):
        if self.logo_choice_value.get() == choice_id:
            button.config(bg=COLORS["accent_hover"] if hovering else COLORS["accent"])
        else:
            button.config(bg=COLORS["secondary_hover"] if hovering else COLORS["secondary"])

    def toggle_logo_choice(self, choice_id):
        if self.logo_choice_value.get() == choice_id:
            self.reset_logo_selection(refresh=True)
            return
        self.logo_choice_value.set(choice_id)
        self.logo_path = None
        self.invalidate_logo_cache()
        self.refresh_logo_choice_buttons()
        self.update_logo_sample_preview()
        self.start_logo_preview()

    def reset_logo_selection(self, refresh=True):
        self.clear_logo_preview_items()
        self.logo_choice_value.set("")
        self.logo_path = None
        self.logo_preview_active = False
        self.logo_dragging = False
        self.invalidate_logo_cache()
        self.refresh_logo_choice_buttons()
        self.update_logo_sample_preview()
        if refresh:
            self.refresh_canvas()

    def update_bg_labels(self):
        self.bg_color_preview.config(text=self.bg_replace_color_value.get(), fg=self.bg_replace_color_value.get())
        if hasattr(self, "cleanup_color_preview"):
            cleanup_color = self.cleanup_fill_color_value.get()
            self.cleanup_color_preview.config(text=cleanup_color, fg=cleanup_color)
        if self.bg_replace_image_path:
            self.bg_photo_label.config(text=os.path.basename(self.bg_replace_image_path), fg=COLORS["text"])
        else:
            self.bg_photo_label.config(text="Aucune / None / Ninguna", fg=COLORS["muted"])

    def push_specific_undo_image(self, img, action=None):
        if img is None:
            return
        self.undo_stack.append({
            "image": img.copy(),
            "advanced_state": self.get_advanced_slider_state(),
            "action": action
        })
        if len(self.undo_stack) > self.max_undo:
            self.undo_stack.pop(0)

    def clear_background_placement_state(self):
        for after_id_name in ("bg_placement_preview_after_id", "bg_background_update_after_id"):
            after_id = getattr(self, after_id_name, None)
            if after_id is not None:
                try:
                    self.root.after_cancel(after_id)
                except Exception:
                    pass
                setattr(self, after_id_name, None)
        self.bg_placement_active = False
        self.bg_placement_dragging = False
        self.bg_placement_original = None
        self.bg_placement_bg = None
        self.bg_placement_subject = None
        self.bg_placement_subject_base_scale = 1.0
        self.bg_placement_subject_x = 0
        self.bg_placement_subject_y = 0
        self.bg_placement_drag_offset = (0, 0)
        self.bg_placement_last_subject_size = None
        self.bg_placement_subject_cache_key = None
        self.bg_placement_subject_cache = None

    def clear_crop_rect(self):
        ids = []
        if self.crop_rect_id:
            ids.append(self.crop_rect_id)
        ids.extend(getattr(self, "crop_preview_ids", []))
        for item in ids:
            try:
                self.image_canvas.delete(item)
            except Exception:
                pass
        self.crop_rect_id = None
        self.crop_preview_ids = []

    def hide_brush_cursor(self):
        for item in self.brush_cursor_ids:
            self.image_canvas.delete(item)
        self.brush_cursor_ids = []

    def clear_white_lasso_preview(self):
        for item in self.white_lasso_preview_ids:
            try:
                self.image_canvas.delete(item)
            except Exception:
                pass
        self.white_lasso_preview_ids = []


    def clear_advanced_color_live_state(self):
        self.advanced_live_base = None
        self.advanced_live_base_array = None

    def reset_sliders_for_new_image(self):
        self.reset_advanced_slider_values_only()
        self.clear_advanced_color_live_state()
        self.saturation_live_base = None
        self.rotation_preview_angle = 0
        slider_defaults = [
            (self.rotation_value, 0),
            (self.resize_percent_value, 100),
            (self.auto_enhance_strength_value, 75),
            (self.simple_brightness_value, 0),
            (self.simple_contrast_value, 0),
            (self.simple_sharpness_value, 55),
            (self.simple_bw_value, 100),
            (self.skin_redness_strength_value, 55),
            (self.simple_saturation_value, 100),
            (self.filter_intensity_value, 100),
            (self.brush_size, 28),
            (self.cleanup_bucket_tolerance_value, 54),
            (self.cleanup_lasso_expand_value, 2),
            (self.cleanup_opacity_value, 100),
            (self.heal_strength, 90),
            (self.smooth_strength, 45),
            (self.skin_beauty_strength, 1),
            (self.skin_makeup_strength, 1),
            (self.skin_perfect_strength, 3),
            (self.skin_face_strength, 75),
            (self.skin_body_strength, 60),
            (self.skin_legs_strength, 55),
            (self.skin_tone_shift, 0),
            (self.skin_even_strength, 3),
            (self.bg_targeted_strength, 3),
            (self.bg_tolerance_value, 60),
            (self.bg_background_rotation_value, 0),
            (self.bg_subject_size_value, 100),
            (self.bg_subject_rotation_value, 0),
            (self.text_size_value, 72),
            (self.logo_size_value, 22),
            (self.logo_opacity_value, 75),
        ]
        for variable, value in slider_defaults:
            try:
                variable.set(value)
            except Exception:
                pass

    def get_current_text_value(self):
        try:
            return self.text_input.get("1.0", "end-1c").strip()
        except Exception:
            return ""

    def clear_text_preview(self, refresh=False):
        if getattr(self, "text_preview_after_id", None) is not None:
            try:
                self.root.after_cancel(self.text_preview_after_id)
            except Exception:
                pass
            self.text_preview_after_id = None
        changed = self.text_preview_x is not None or self.text_preview_y is not None
        self.text_preview_x = None
        self.text_preview_y = None
        self.clear_text_preview_items()
        if refresh and changed:
            self.refresh_canvas()

    def clear_text_preview_items(self):
        if not hasattr(self, "image_canvas"):
            self.text_preview_item_ids = []
            return
        for item_id in getattr(self, "text_preview_item_ids", []):
            try:
                self.image_canvas.delete(item_id)
            except Exception:
                pass
        self.text_preview_item_ids = []

    def draw_text_preview_items(self):
        if not hasattr(self, "image_canvas"):
            return
        if (
            self.image is None
            or self.tool != "text"
            or getattr(self, "active_tab_key", None) != "text_logo_tab"
            or self.text_preview_x is None
            or self.text_preview_y is None
        ):
            self.clear_text_preview_items()
            return
        text = self.get_current_text_value()
        if not text:
            self.clear_text_preview_items()
            return
        self.clear_text_preview_items()
        x, y = self.image_to_canvas_coords(self.text_preview_x, self.text_preview_y)
        size = max(8, int(int(self.text_size_value.get()) * max(0.10, float(self.display_scale))))
        family = self.text_font_value.get() or "Georgia"
        font = (family, size, "normal")
        spacing = max(2, size // 10)
        try:
            shadow_id = self.image_canvas.create_text(
                x + max(1, size // 18),
                y + max(1, size // 18),
                text=text,
                fill="#000000",
                font=font,
                anchor=tk.CENTER,
                justify=tk.CENTER,
                width=max(160, int(self.disp_w * 0.92))
            )
            text_id = self.image_canvas.create_text(
                x,
                y,
                text=text,
                fill=self.text_color_value.get(),
                font=font,
                anchor=tk.CENTER,
                justify=tk.CENTER,
                width=max(160, int(self.disp_w * 0.92))
            )
            self.text_preview_item_ids = [shadow_id, text_id]
            self.image_canvas.itemconfigure(shadow_id, stipple="gray25")
            self.image_canvas.tag_raise(text_id)
        except Exception:
            self.text_preview_item_ids = []
            self.schedule_text_preview_refresh(delay=18)

    def schedule_text_preview_refresh(self, delay=18):
        if getattr(self, "text_preview_after_id", None) is not None:
            try:
                self.root.after_cancel(self.text_preview_after_id)
            except Exception:
                pass
        self.text_preview_after_id = self.root.after(delay, self.refresh_text_preview_canvas)

    def refresh_text_preview_canvas(self):
        self.text_preview_after_id = None
        if (
            self.tool == "text"
            and getattr(self, "active_tab_key", None) == "text_logo_tab"
            and self.text_preview_x is not None
            and self.text_preview_y is not None
        ):
            self.draw_text_preview_items()

    def on_text_font_preview_change(self):
        self.update_font_preview()
        self.draw_text_preview_items()

    def update_font_preview(self):
        if not hasattr(self, "font_preview_label"):
            return
        font_name = self.text_font_value.get() or "Georgia"
        sample = "Latex Vamp - Exemple 123"
        try:
            available_w = self.font_preview_label.winfo_width()
            if available_w <= 40:
                self.root.after(80, self.update_font_preview)
                return
            img_w = max(260, min(560, available_w - 8))
            img_h = 94
            font_size = 30
            font = self.get_pil_font_for_size(font_size)
            img = Image.new("RGBA", (img_w, img_h), COLORS["panel2"])
            draw = ImageDraw.Draw(img)
            draw.rounded_rectangle((1, 1, img_w - 2, img_h - 2), radius=8, outline=COLORS["border"], width=1)
            label_font = ImageFont.truetype("arial.ttf", 11) if sys.platform.startswith("win") else ImageFont.load_default()
            draw.text((16, 12), font_name, fill=COLORS["muted"], font=label_font)
            max_text_w = img_w - 32
            while font_size > 17:
                bbox = draw.textbbox((0, 0), sample, font=font)
                if bbox[2] - bbox[0] <= max_text_w:
                    break
                font_size -= 1
                font = self.get_pil_font_for_size(font_size)
            draw.text((16, 42), sample, fill=COLORS["text"], font=font)
            self.font_preview_tk = ImageTk.PhotoImage(img)
            self.font_preview_label.config(image=self.font_preview_tk, text="")
        except Exception:
            self.font_preview_tk = None
            self.font_preview_label.config(
                image="",
                text=f"{font_name}\n{sample}",
                font=(font_name, 20),
                justify=tk.LEFT,
                anchor="w"
            )

    def on_canvas_leave(self, _event=None):
        self.hide_brush_cursor()
        self.clear_text_preview(refresh=True)
        self.update_canvas_cursor()

    def stop_other_modes(self):
        self.clear_advanced_color_live_state()
        self.tool = None
        self.filter_preview_name = None
        self.reset_logo_selection(refresh=False)
        self.panning_active = False
        self.bg_placement_dragging = False
        self.clear_crop_rect()
        self.clear_text_preview(refresh=False)
        self.hide_brush_cursor()
        self.update_canvas_cursor()

    def setup_drag_and_drop(self):
        if not HAS_DND:
            return
        for target in (self.root, self.viewer, self.image_canvas):
            try:
                target.drop_target_register(DND_FILES)
                target.dnd_bind("<<Drop>>", self.on_file_drop)
            except Exception:
                pass

    def on_file_drop(self, event):
        try:
            paths = list(self.root.tk.splitlist(event.data))
        except Exception:
            raw = str(getattr(event, "data", "")).strip()
            paths = [raw.strip("{}") if raw else ""]
        for path in paths:
            clean = path.strip().strip("{}")
            if clean and os.path.isfile(clean):
                self.open_image_path(clean, dropped=True)
                return

    def open_image_path(self, path, dropped=False):
        try:
            with Image.open(path) as opened:
                self.image_dpi = self.normalize_dpi(opened.info.get("dpi"))
                self.image_icc_profile = opened.info.get("icc_profile")
                img = ImageOps.exif_transpose(opened).convert("RGB")
            self.image = img.copy()
            self.original = img.copy()
            self.undo_stack = []
            self.checkpoint_image = None
            self.checkpoint_advanced_state = None
            self.skin_selection_mask = None
            self.skin_auto_base_image = None
            self.skin_auto_kind = None
            self.clear_background_placement_state()
            self.reset_sliders_for_new_image()
            self.zoom = 1.0
            self.panning_active = False
            self.last_path = path
            self.stop_other_modes()
            self.refresh_canvas()
            prefix = self.t("photo_dropped") if dropped else self.t("photo_loaded")
            self.set_status(f"{prefix}: {os.path.basename(path)}")
            self.update_open_photo_button()
        except Exception as exc:
            messagebox.showerror(APP_TITLE, str(exc))

    def update_open_photo_button(self):
        if hasattr(self, "open_photo_button"):
            self.open_photo_button.config(text=self.t("new_photo") if self.image is not None else self.t("open_photo"))

    def confirm_new_photo_if_needed(self):
        if self.image is None:
            return True
        result = {"continue": False}

        dialog = tk.Toplevel(self.root)
        dialog.title(self.t("new_photo"))
        dialog.configure(bg=COLORS["panel"])
        dialog.transient(self.root)
        dialog.grab_set()
        dialog.resizable(False, False)

        message = tk.Label(
            dialog,
            text=self.t("new_photo_warning"),
            bg=COLORS["panel"],
            fg=COLORS["text"],
            justify=tk.LEFT,
            wraplength=440,
            font=("Segoe UI", 11),
            padx=18,
            pady=18
        )
        message.pack(fill=tk.BOTH, expand=True)

        buttons = tk.Frame(dialog, bg=COLORS["panel"])
        buttons.pack(fill=tk.X, padx=18, pady=(0, 16))

        def cancel():
            result["continue"] = False
            dialog.destroy()

        def proceed():
            result["continue"] = True
            dialog.destroy()

        cancel_btn = tk.Button(
            buttons,
            text=self.t("cancel_action"),
            command=cancel,
            bg=COLORS["secondary"],
            fg=COLORS["text"],
            activebackground=COLORS["secondary_hover"],
            activeforeground=COLORS["text"],
            bd=0,
            relief=tk.FLAT,
            padx=18,
            pady=9,
            font=("Segoe UI", 10, "bold"),
            cursor="hand2"
        )
        cancel_btn.pack(side=tk.LEFT, padx=(0, 8))

        continue_btn = tk.Button(
            buttons,
            text=self.t("continue_action"),
            command=proceed,
            bg=COLORS["accent"],
            fg=COLORS["text"],
            activebackground=COLORS["accent_hover"],
            activeforeground=COLORS["text"],
            bd=0,
            relief=tk.FLAT,
            padx=18,
            pady=9,
            font=("Segoe UI", 10, "bold"),
            cursor="hand2"
        )
        continue_btn.pack(side=tk.RIGHT)

        dialog.protocol("WM_DELETE_WINDOW", cancel)
        dialog.update_idletasks()
        x = self.root.winfo_rootx() + max(0, (self.root.winfo_width() - dialog.winfo_width()) // 2)
        y = self.root.winfo_rooty() + max(0, (self.root.winfo_height() - dialog.winfo_height()) // 2)
        dialog.geometry(f"+{x}+{y}")
        dialog.wait_window()
        return result["continue"]

    def get_open_image_filetypes(self):
        image_patterns = (
            "*.jpg *.jpeg *.png *.webp *.bmp *.tif *.tiff "
            "*.JPG *.JPEG *.PNG *.WEBP *.BMP *.TIF *.TIFF"
        )
        return [
            ("Images", image_patterns),
            ("WebP", "*.webp *.WEBP"),
            ("JPEG", "*.jpg *.jpeg *.JPG *.JPEG"),
            ("PNG", "*.png *.PNG"),
            ("TIFF", "*.tif *.tiff *.TIF *.TIFF"),
            ("All files", "*.*"),
        ]

    def load_image(self):
        if not self.confirm_new_photo_if_needed():
            return
        path = filedialog.askopenfilename(
            title=self.t("new_photo") if self.image is not None else self.t("open_photo"),
            filetypes=self.get_open_image_filetypes()
        )
        if not path:
            return
        self.open_image_path(path)

    def prepare_image_for_flat_export(self, img, background=(255, 255, 255)):
        """Convert image to RGB for JPEG/PDF, flattening transparency on white."""
        if img is None:
            return None
        if "A" in img.getbands():
            rgba = img.convert("RGBA")
            flat = Image.new("RGB", rgba.size, background)
            flat.paste(rgba, mask=rgba.getchannel("A"))
            return flat
        return img.convert("RGB")

    def normalize_dpi(self, dpi):
        try:
            if isinstance(dpi, (tuple, list)) and len(dpi) >= 2:
                x = max(1, int(round(float(dpi[0]))))
                y = max(1, int(round(float(dpi[1]))))
                return (x, y)
            if dpi:
                value = max(1, int(round(float(dpi))))
                return (value, value)
        except Exception:
            pass
        return (300, 300)

    def get_save_kwargs(self, include_icc=True):
        kwargs = {"dpi": self.image_dpi}
        if include_icc and self.image_icc_profile:
            kwargs["icc_profile"] = self.image_icc_profile
        return kwargs

    def get_default_save_dir(self):
        pictures = os.path.join(os.path.expanduser("~"), "Pictures")
        if os.path.isdir(pictures):
            return pictures
        return os.path.expanduser("~")

    def save_image_format(self, fmt="webp"):
        if not self.require_image():
            return
        fmt = str(fmt).lower()
        format_map = {
            "jpg": {"ext": ".jpg", "label": "JPEG", "filetypes": [("JPEG", "*.jpg;*.jpeg")]},
            "jpeg": {"ext": ".jpg", "label": "JPEG", "filetypes": [("JPEG", "*.jpg;*.jpeg")]},
            "png": {"ext": ".png", "label": "PNG", "filetypes": [("PNG", "*.png")]},
            "webp": {"ext": ".webp", "label": "WebP", "filetypes": [("WebP", "*.webp")]},
            "pdf": {"ext": ".pdf", "label": "PDF", "filetypes": [("PDF", "*.pdf")]},
        }
        info = format_map.get(fmt, format_map["webp"])
        path = filedialog.asksaveasfilename(
            title=f"{self.t('save_as')} {info['label']}",
            defaultextension=info["ext"],
            initialdir=self.get_default_save_dir(),
            initialfile=f"photo-retouchee{info['ext']}",
            filetypes=info["filetypes"] + [("All files", "*.*")]
        )
        if not path:
            return
        try:
            ext = os.path.splitext(path)[1].lower()
            if ext == "":
                path += info["ext"]
                ext = info["ext"]
            if fmt in ["jpg", "jpeg"]:
                if ext not in [".jpg", ".jpeg"]:
                    path += ".jpg"
                self.prepare_image_for_flat_export(self.image).save(
                    path, "JPEG", quality=100, subsampling=0, optimize=False, **self.get_save_kwargs()
                )
            elif fmt == "png":
                if ext != ".png":
                    path += ".png"
                self.image.save(path, "PNG", compress_level=0, **self.get_save_kwargs())
            elif fmt == "pdf":
                if ext != ".pdf":
                    path += ".pdf"
                self.prepare_image_for_flat_export(self.image).save(
                    path, "PDF", resolution=float(self.image_dpi[0]), **self.get_save_kwargs(include_icc=False)
                )
            else:
                if ext != ".webp":
                    path += ".webp"
                self.image.save(path, "WEBP", lossless=True, method=6, **self.get_save_kwargs())
            self.set_status(f"{self.t('saved')}: {os.path.basename(path)}")
        except Exception as exc:
            messagebox.showerror(APP_TITLE, str(exc))

    def save_image(self):
        # Compatibility wrapper. The interface now uses one button per format.
        self.save_image_format("webp")

    def quick_save_png(self):
        if not self.require_image():
            return
        try:
            folder = self.get_default_save_dir()
            path = os.path.join(folder, "photo-retouchee.png")
            n = 1
            while os.path.exists(path):
                path = os.path.join(folder, f"photo-retouchee-{n}.png")
                n += 1
            self.image.save(path, "PNG", compress_level=0, **self.get_save_kwargs())
            self.set_status(f"{self.t('saved')}: {os.path.basename(path)}")
        except Exception as exc:
            messagebox.showerror(APP_TITLE, str(exc))

    def reset_image(self):
        if self.original is None:
            return
        self.push_undo()
        self.image = self.original.copy()
        self.reset_advanced_slider_values_only()
        self.skin_selection_mask = None
        self.skin_auto_base_image = None
        self.skin_auto_kind = None
        self.clear_advanced_color_live_state()
        self.clear_background_placement_state()
        self.rotation_preview_angle = 0
        self.rotation_value.set(0)
        self.stop_other_modes()
        self.refresh_canvas()
        self.set_status(self.t("original_restored"))

    def update_zoom_label(self):
        try:
            self.zoom_percent_label.config(text=f"{int(round(self.zoom * 100))}%")
        except Exception:
            pass

    def can_pan_image(self):
        return self.image is not None and self.zoom > 1.0 and self.tool is None

    def update_canvas_cursor(self):
        try:
            if getattr(self, "busy_depth", 0) > 0:
                cursor = "watch"
            elif self.image is None:
                cursor = "hand2"
            elif self.crop_drag_mode == "move":
                cursor = "fleur"
            elif self.crop_drag_mode in ["nw", "ne", "sw", "se"]:
                cursor = "sizing"
            elif self.bg_placement_active or self.bg_placement_dragging:
                cursor = "fleur"
            elif self.panning_active:
                cursor = "fleur"
            elif self.tool in ["crop", "smooth", "heal", "text", "skin_mask_add", "skin_mask_erase", "cleanup_pick_color", "white_brush_beta", "white_bucket_beta", "white_rect_beta", "white_ellipse_beta", "white_lasso_beta", "transparent_lasso_beta", "recover_lasso_beta"]:
                cursor = "crosshair"
            elif self.can_pan_image():
                cursor = "hand2"
            else:
                cursor = "arrow"
            self.image_canvas.configure(cursor=cursor)
        except Exception:
            pass

    def refresh_canvas(self):
        self.update_zoom_label()
        self.update_canvas_cursor()
        self.image_canvas.delete("all")
        self.crop_rect_id = None
        self.brush_cursor_ids = []
        if self.image is None:
            w, h = max(1, self.image_canvas.winfo_width()), max(1, self.image_canvas.winfo_height())
            self.image_canvas.create_text(w // 2, h // 2, text=self.t("no_photo"), fill="#bbbbbb", font=("Arial", 18, "bold"))
            self.image_canvas.create_text(w // 2, (h // 2) + 34, text=self.t("open_photo"), fill="#888888", font=("Arial", 11, "bold"))
            self.image_canvas.configure(scrollregion=(0, 0, w, h))
            return

        source_image = self.image
        if getattr(self, "rotation_preview_angle", 0):
            try:
                source_image = self.image.rotate(
                    int(self.rotation_preview_angle),
                    expand=True,
                    resample=Image.Resampling.BICUBIC,
                    fillcolor=(0, 0, 0)
                )
            except Exception:
                source_image = self.image

        cw, ch = max(1, self.image_canvas.winfo_width()), max(1, self.image_canvas.winfo_height())
        iw, ih = source_image.size
        self.display_scale = min(cw / iw, ch / ih, 1.0) * self.zoom
        disp_w = max(1, int(iw * self.display_scale))
        disp_h = max(1, int(ih * self.display_scale))
        disp = source_image.resize((disp_w, disp_h), Image.LANCZOS)
        if self.filter_preview_name:
            disp = self.render_named_filter(disp, self.filter_preview_name, self.filter_intensity_value.get())
        if self.skin_selection_mask is not None and self.tool in ["skin_mask_add", "skin_mask_erase"]:
            try:
                mask_preview = self.skin_selection_mask.resize((disp_w, disp_h), Image.Resampling.NEAREST)
                alpha = mask_preview.point(lambda v: 90 if v > 0 else 0)
                overlay = Image.new("RGBA", (disp_w, disp_h), (255, 35, 85, 0))
                overlay.putalpha(alpha)
                disp = Image.alpha_composite(disp.convert("RGBA"), overlay)
            except Exception:
                pass
        self.tk_image = ImageTk.PhotoImage(disp)
        self.disp_w, self.disp_h = disp_w, disp_h
        self.image_canvas.create_image(0, 0, image=self.tk_image, anchor=tk.NW)
        self.image_canvas.configure(scrollregion=(0, 0, disp_w, disp_h))
        if self.tool == "text":
            self.draw_text_preview_items()
        if self.bg_placement_active:
            bbox = self.get_bg_placement_bbox()
            if bbox is not None:
                x1, y1 = self.image_to_canvas_coords(bbox[0], bbox[1])
                x2, y2 = self.image_to_canvas_coords(bbox[2], bbox[3])
                self.image_canvas.create_rectangle(x1, y1, x2, y2, outline="#66d9ff", width=2, dash=(7, 4))
        if self.logo_preview_active and getattr(self, "active_tab_key", None) == "logo_tab":
            self.draw_logo_preview()
        self.update_canvas_cursor()

    def event_to_canvas_coords(self, event):
        return self.image_canvas.canvasx(event.x), self.image_canvas.canvasy(event.y)

    def point_inside_image_display(self, canvas_x, canvas_y):
        return self.image is not None and 0 <= canvas_x <= self.disp_w and 0 <= canvas_y <= self.disp_h

    def canvas_to_image_coords(self, canvas_x, canvas_y):
        ix = int(canvas_x / self.display_scale)
        iy = int(canvas_y / self.display_scale)
        return max(0, min(self.image.width - 1, ix)), max(0, min(self.image.height - 1, iy))

    def image_to_canvas_coords(self, x, y):
        return int(x * self.display_scale), int(y * self.display_scale)

    def zoom_in(self):
        if self.require_image():
            self.zoom = min(6.0, self.zoom * 1.25)
            self.refresh_canvas()
            self.set_status("Zoom +")

    def zoom_out(self):
        if self.require_image():
            self.zoom = max(0.20, self.zoom / 1.25)
            self.refresh_canvas()
            self.set_status("Zoom -")

    def zoom_reset(self):
        if self.require_image():
            self.zoom = 1.0
            self.refresh_canvas()
            self.set_status("Zoom 100%")

    def on_mousewheel_zoom(self, event):
        if event.delta > 0:
            self.zoom_in()
        else:
            self.zoom_out()

    def on_image_wheel(self, event):
        if self.bg_placement_active:
            self.adjust_bg_subject_size_by_wheel(1 if event.delta > 0 else -1)
        elif self.is_logo_preview_allowed():
            self.adjust_logo_size_by_wheel(1 if event.delta > 0 else -1)
        else:
            self.image_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")

    def on_image_shift_wheel(self, event):
        self.image_canvas.xview_scroll(int(-1 * (event.delta / 120)), "units")

    def on_linux_wheel(self, direction):
        if self.bg_placement_active:
            self.adjust_bg_subject_size_by_wheel(direction)
        elif self.is_logo_preview_allowed():
            self.adjust_logo_size_by_wheel(direction)
        else:
            self.image_canvas.yview_scroll(-direction, "units")

    def set_tool(self, tool):
        if tool is not None and not self.require_image():
            return
        if tool is None:
            self.tool = None
        if tool is not None:
            self.skin_auto_base_image = None
            self.skin_auto_kind = None
        if tool is not None:
            self.filter_preview_name = None
            self.reset_logo_selection(refresh=False)
            self.tool = tool if self.tool != tool else None
        if self.tool != "text":
            self.clear_text_preview(refresh=False)
        if self.tool not in ["white_lasso_beta", "transparent_lasso_beta", "recover_lasso_beta"]:
            self.white_lasso_path = []
            self.white_lasso_active = False
            self.clear_white_lasso_preview()
        self.clear_crop_rect()
        self.hide_brush_cursor()
        self.update_canvas_cursor()
        self.refresh_canvas()
        status = {
            "crop": self.t("crop_active"),
            "smooth": self.t("smooth_active"),
            "skin_perfect": self.t("skin_perfect_active"),
            "skin_tone": self.t("skin_tone_active"),
            "bg_targeted": self.t("bg_targeted_active"),
            "white_brush_beta": self.t("white_cleanup_active"),
            "white_bucket_beta": self.t("white_bucket_active"),
            "white_rect_beta": self.t("white_shape_active"),
            "white_ellipse_beta": self.t("white_shape_active"),
            "white_lasso_beta": self.t("white_lasso_active"),
            "transparent_lasso_beta": self.t("transparent_lasso_active"),
            "recover_lasso_beta": self.t("recover_lasso_active"),
            "cleanup_pick_color": self.t("cleanup_pick_active"),
            "heal": self.t("heal_active"),
            "text": self.t("text_active"),
            "skin_mask_add": self.t("skin_makeup_area_active"),
            "skin_mask_erase": self.t("skin_makeup_area_erase_active"),
            None: self.t("tool_stopped")
        }
        self.set_status(status.get(self.tool, self.t("tool_stopped")))

    def on_mouse_down(self, event):
        if self.image is None:
            self.load_image()
            return

        cx, cy = self.event_to_canvas_coords(event)
        inside = self.point_inside_image_display(cx, cy)
        if not inside and not self.can_pan_image():
            return

        ix, iy = self.canvas_to_image_coords(cx, cy) if inside else (0, 0)

        if inside and self.bg_placement_active and self.tool not in ["white_lasso_beta", "transparent_lasso_beta", "recover_lasso_beta", "white_brush_beta", "white_bucket_beta", "white_rect_beta", "white_ellipse_beta", "cleanup_pick_color"]:
            if self.point_in_bg_placement_subject(ix, iy):
                self.bg_placement_dragging = True
                self.bg_placement_drag_offset = (ix - self.bg_placement_subject_x, iy - self.bg_placement_subject_y)
            else:
                subject = self.get_bg_placement_subject_resized()
                if subject is not None:
                    self.bg_placement_subject_x = ix - subject.width // 2
                    self.bg_placement_subject_y = iy - subject.height // 2
                    self.bg_placement_dragging = True
                    self.bg_placement_drag_offset = (subject.width // 2, subject.height // 2)
                    self.refresh_background_placement_preview()
            self.update_canvas_cursor()
            return

        if inside and self.is_logo_preview_allowed() and self.is_point_in_logo_preview(ix, iy):
            self.logo_dragging = True
            self.logo_drag_offset = (ix - self.logo_preview_x, iy - self.logo_preview_y)
            return

        if self.can_pan_image() and self.tool is None:
            self.panning_active = True
            self.image_canvas.scan_mark(event.x, event.y)
            self.update_canvas_cursor()
            return

        if not inside:
            return

        if self.tool in ["crop"]:
            hit = self.crop_hit_test(ix, iy)
            if hit in ["nw", "ne", "sw", "se"]:
                self.crop_drag_mode = hit
                self.update_crop_from_handle(hit, ix, iy)
            elif hit == "move":
                self.crop_drag_mode = "move"
                self.crop_drag_origin = (ix, iy)
                self.crop_drag_bounds = self.get_crop_bounds()
            else:
                self.crop_drag_mode = "new"
                self.crop_drag_origin = (ix, iy)
                self.crop_start = (ix, iy)
                self.crop_end = (ix, iy)
                self.clear_crop_rect()
        elif self.tool == "smooth":
            self.push_undo()
            self.smooth_at(ix, iy)
        elif self.tool == "skin_perfect":
            self.push_undo()
            self.perfect_skin_at(ix, iy)
        elif self.tool == "skin_tone":
            self.push_undo()
            self.skin_tone_at(ix, iy)
        elif self.tool == "bg_targeted":
            self.push_undo()
            self.background_targeted_at(ix, iy)
        elif self.tool == "cleanup_pick_color":
            rgb = self.average_color_in_circle(ix, iy)
            value = "#{:02x}{:02x}{:02x}".format(*rgb)
            self.cleanup_fill_color_value.set(value)
            self.update_bg_labels()
            self.set_status(f"{self.t('cleanup_color_set')}: {value}")
            self.set_tool(None)
        elif self.tool == "white_brush_beta":
            self.push_undo()
            self.paint_white_cleanup_at(ix, iy)
        elif self.tool == "white_bucket_beta":
            self.push_undo()
            self.bucket_fill_cleanup_at(ix, iy)
        elif self.tool in ["white_rect_beta", "white_ellipse_beta"]:
            self.crop_start = (ix, iy)
            self.crop_end = (ix, iy)
            self.draw_white_shape_preview()
        elif self.tool in ["white_lasso_beta", "transparent_lasso_beta", "recover_lasso_beta"]:
            self.white_lasso_path = [(ix, iy)]
            self.white_lasso_active = True
            self.draw_white_lasso_preview()
        elif self.tool in ["skin_mask_add", "skin_mask_erase"]:
            self.paint_skin_selection(ix, iy, erase=(self.tool == "skin_mask_erase"))
        elif self.tool == "heal":
            self.push_undo()
            self.heal_at(ix, iy)
        elif self.tool == "text":
            self.text_preview_x = ix
            self.text_preview_y = iy
            self.add_text_to_image(ix, iy)

    def on_mouse_drag(self, event):
        if self.image is None:
            return

        if self.panning_active:
            self.image_canvas.scan_dragto(event.x, event.y, gain=1)
            return

        cx, cy = self.event_to_canvas_coords(event)
        ix, iy = self.canvas_to_image_coords(cx, cy)

        if self.bg_placement_active and self.bg_placement_dragging:
            ox, oy = self.bg_placement_drag_offset
            self.bg_placement_subject_x = ix - ox
            self.bg_placement_subject_y = iy - oy
            self.schedule_background_placement_preview()
            return

        if self.overlay_preview_active and self.overlay_dragging:
            layer = self.get_active_overlay_layer()
            if layer is not None:
                ox, oy = self.overlay_drag_offset
                layer["x"] = ix - ox
                layer["y"] = iy - oy
                self.clamp_overlay_preview_position()
                self.refresh_canvas()
            return

        if self.is_logo_preview_allowed() and self.logo_dragging:
            ox, oy = self.logo_drag_offset
            self.logo_preview_x = ix - ox
            self.logo_preview_y = iy - oy
            self.clamp_logo_preview_position()
            if not self.move_logo_preview_canvas_items():
                self.refresh_canvas()
            return

        if self.tool in ["crop"] and self.crop_start:
            if self.crop_drag_mode == "move":
                self.move_crop_rect(ix, iy)
            elif self.crop_drag_mode in ["nw", "ne", "sw", "se"]:
                self.update_crop_from_handle(self.crop_drag_mode, ix, iy)
            else:
                self.crop_end = self.apply_crop_aspect_to_point(ix, iy)
                self.draw_crop_rect()
        elif self.tool == "smooth":
            self.smooth_at(ix, iy)
        elif self.tool == "skin_perfect":
            self.perfect_skin_at(ix, iy)
        elif self.tool == "skin_tone":
            self.skin_tone_at(ix, iy)
        elif self.tool == "bg_targeted":
            self.background_targeted_at(ix, iy)
        elif self.tool == "white_brush_beta":
            self.paint_white_cleanup_at(ix, iy)
        elif self.tool in ["white_rect_beta", "white_ellipse_beta"] and self.crop_start:
            self.crop_end = (ix, iy)
            self.draw_white_shape_preview()
        elif self.tool in ["white_lasso_beta", "transparent_lasso_beta", "recover_lasso_beta"] and self.white_lasso_active:
            self.extend_white_lasso(ix, iy)
        elif self.tool in ["skin_mask_add", "skin_mask_erase"]:
            self.paint_skin_selection(ix, iy, erase=(self.tool == "skin_mask_erase"))

    def on_mouse_up(self, event):
        if self.image is not None and self.tool in ["white_rect_beta", "white_ellipse_beta"] and self.crop_start and self.crop_end:
            self.apply_white_shape()
        elif self.image is not None and self.tool in ["white_lasso_beta", "transparent_lasso_beta", "recover_lasso_beta"] and self.white_lasso_active:
            cx, cy = self.event_to_canvas_coords(event)
            if self.point_inside_image_display(cx, cy):
                ix, iy = self.canvas_to_image_coords(cx, cy)
                self.extend_white_lasso(ix, iy)
            if self.tool == "transparent_lasso_beta":
                self.apply_transparent_lasso()
            elif self.tool == "recover_lasso_beta":
                self.apply_recover_lasso()
            else:
                self.apply_white_lasso()
        self.bg_placement_dragging = False
        self.logo_dragging = False
        self.panning_active = False
        self.crop_drag_mode = None
        self.crop_drag_origin = None
        self.crop_drag_bounds = None
        self.update_canvas_cursor()

    def on_mouse_move(self, event):
        if self.image is None:
            self.update_canvas_cursor()
            return
        cx, cy = self.event_to_canvas_coords(event)
        inside = self.point_inside_image_display(cx, cy)
        if self.tool == "text" and getattr(self, "active_tab_key", None) == "text_logo_tab":
            self.hide_brush_cursor()
            if inside:
                ix, iy = self.canvas_to_image_coords(cx, cy)
                changed = (ix != self.text_preview_x) or (iy != self.text_preview_y)
                self.text_preview_x = ix
                self.text_preview_y = iy
                if changed:
                    self.draw_text_preview_items()
            else:
                self.clear_text_preview(refresh=True)
        elif self.tool == "text":
            self.clear_text_preview(refresh=True)
        elif self.tool in ["smooth", "heal", "skin_mask_add", "skin_mask_erase", "skin_perfect", "skin_tone", "bg_targeted", "white_brush_beta"] and inside:
            self.show_brush_cursor(cx, cy)
        else:
            self.hide_brush_cursor()
        self.update_canvas_cursor()

    def show_brush_cursor(self, x, y):
        self.hide_brush_cursor()
        radius = max(3, int(self.brush_size.get() * self.display_scale))
        front = "#55ff88" if self.tool == "skin_mask_add" else ("#ffcc33" if self.tool == "skin_mask_erase" else ("#ffffff" if self.tool == "smooth" else (self.cleanup_fill_color_value.get() if self.tool == "white_brush_beta" else ("#ff77dd" if self.tool == "skin_perfect" else ("#b88cff" if self.tool == "skin_tone" else ("#66d9ff" if self.tool == "bg_targeted" else "#ff3355"))))))
        back = "#000000"
        self.brush_cursor_ids.append(self.image_canvas.create_oval(x-radius, y-radius, x+radius, y+radius, outline=back, width=5))
        self.brush_cursor_ids.append(self.image_canvas.create_oval(x-radius, y-radius, x+radius, y+radius, outline=front, width=2, dash=(4, 3)))

    def get_preview_contrast_colors(self, points):
        if self.image is None or not points:
            return "#000000", "#ffffff"
        samples = []
        try:
            for x, y in points[::max(1, len(points) // 40)]:
                ix = max(0, min(self.image.width - 1, int(x)))
                iy = max(0, min(self.image.height - 1, int(y)))
                r, g, b = self.image.convert("RGB").getpixel((ix, iy))
                samples.append(0.299 * r + 0.587 * g + 0.114 * b)
        except Exception:
            samples = []
        avg = sum(samples) / len(samples) if samples else 255
        return ("#000000", "#ffffff") if avg >= 140 else ("#ffffff", "#000000")

    def create_contrast_preview_shape(self, kind, coords, sample_points, dash=(6, 4)):
        front, back = self.get_preview_contrast_colors(sample_points)
        creator = self.image_canvas.create_oval if kind == "oval" else self.image_canvas.create_rectangle
        back_id = creator(*coords, outline=back, dash=dash, width=5)
        front_id = creator(*coords, outline=front, dash=dash, width=2)
        self.crop_preview_ids = [back_id, front_id]
        self.crop_rect_id = front_id

    def draw_crop_resize_handles(self, x1, y1, x2, y2):
        left, right = sorted([x1, x2])
        top, bottom = sorted([y1, y2])
        size = 11
        for hx, hy in [(left, top), (right, top), (left, bottom), (right, bottom)]:
            back_id = self.image_canvas.create_rectangle(
                hx - size, hy - size, hx + size, hy + size,
                fill="#000000", outline="#ffffff", width=3
            )
            front_id = self.image_canvas.create_rectangle(
                hx - size + 3, hy - size + 3, hx + size - 3, hy + size - 3,
                fill="#ff3355", outline="#ffffff", width=1
            )
            self.crop_preview_ids.extend([back_id, front_id])

    def get_crop_bounds(self):
        if not self.crop_start or not self.crop_end:
            return None
        x1, y1 = self.crop_start
        x2, y2 = self.crop_end
        return min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)

    def set_crop_bounds(self, left, top, right, bottom):
        if self.image is not None:
            left = max(0, min(self.image.width, left))
            right = max(0, min(self.image.width, right))
            top = max(0, min(self.image.height, top))
            bottom = max(0, min(self.image.height, bottom))
        self.crop_start = (left, top)
        self.crop_end = (right, bottom)

    def crop_hit_test(self, ix, iy):
        bounds = self.get_crop_bounds()
        if not bounds or self.image is None:
            return None
        left, top, right, bottom = bounds
        tolerance = max(10, int(18 / max(0.1, self.display_scale)))
        handles = {
            "nw": (left, top),
            "ne": (right, top),
            "sw": (left, bottom),
            "se": (right, bottom),
        }
        for mode, (hx, hy) in handles.items():
            if abs(ix - hx) <= tolerance and abs(iy - hy) <= tolerance:
                return mode
        if left <= ix <= right and top <= iy <= bottom:
            return "move"
        return None

    def update_crop_from_handle(self, mode, ix, iy):
        bounds = self.get_crop_bounds()
        if not bounds:
            return
        left, top, right, bottom = bounds
        anchors = {
            "nw": (right, bottom),
            "ne": (left, bottom),
            "sw": (right, top),
            "se": (left, top),
        }
        anchor = anchors.get(mode)
        if not anchor:
            return
        self.crop_start = anchor
        self.crop_end = self.apply_crop_aspect_to_point(ix, iy)
        self.draw_crop_rect()

    def move_crop_rect(self, ix, iy):
        if not self.crop_drag_origin or not self.crop_drag_bounds or self.image is None:
            return
        ox, oy = self.crop_drag_origin
        left, top, right, bottom = self.crop_drag_bounds
        dx = ix - ox
        dy = iy - oy
        width = right - left
        height = bottom - top
        new_left = max(0, min(self.image.width - width, left + dx))
        new_top = max(0, min(self.image.height - height, top + dy))
        self.set_crop_bounds(new_left, new_top, new_left + width, new_top + height)
        self.draw_crop_rect()

    def create_contrast_preview_line(self, coords, sample_points, width=2, dash=None, smooth=False):
        front, back = self.get_preview_contrast_colors(sample_points)
        back_id = self.image_canvas.create_line(*coords, fill=back, width=width + 4, dash=dash, smooth=smooth)
        front_id = self.image_canvas.create_line(*coords, fill=front, width=width, dash=dash, smooth=smooth)
        self.white_lasso_preview_ids.extend([back_id, front_id])

    def get_crop_aspect_ratio(self):
        value = self.crop_aspect_value.get() if hasattr(self, "crop_aspect_value") else "free"
        ratios = {
            "1:1": 1.0,
            "4:5": 4 / 5,
            "5:4": 5 / 4,
            "3:2": 3 / 2,
            "2:3": 2 / 3,
            "16:9": 16 / 9,
            "9:16": 9 / 16,
        }
        return ratios.get(value)

    def apply_crop_aspect_to_point(self, ix, iy):
        ratio = self.get_crop_aspect_ratio()
        if not ratio or not self.crop_start or self.image is None:
            return ix, iy

        sx, sy = self.crop_start
        sign_x = 1 if ix >= sx else -1
        sign_y = 1 if iy >= sy else -1
        width = abs(ix - sx)
        height = abs(iy - sy)
        if width < 1 and height < 1:
            return ix, iy
        if height < 1:
            height = width / ratio
        elif width < 1:
            width = height * ratio
        elif width / height > ratio:
            width = height * ratio
        else:
            height = width / ratio

        max_width = (self.image.width - sx) if sign_x > 0 else sx
        max_height = (self.image.height - sy) if sign_y > 0 else sy
        if width > max_width:
            width = max_width
            height = width / ratio
        if height > max_height:
            height = max_height
            width = height * ratio
        return sx + sign_x * width, sy + sign_y * height

    def on_crop_aspect_change(self):
        if self.tool == "crop" and self.crop_start and self.crop_end:
            self.crop_end = self.apply_crop_aspect_to_point(*self.crop_end)
            self.draw_crop_rect()

    def draw_crop_rect(self):
        self.clear_crop_rect()
        x1, y1 = self.image_to_canvas_coords(*self.crop_start)
        x2, y2 = self.image_to_canvas_coords(*self.crop_end)
        sample_points = [self.crop_start, self.crop_end]
        self.create_contrast_preview_shape("rectangle", (x1, y1, x2, y2), sample_points)
        self.draw_crop_resize_handles(x1, y1, x2, y2)

    def draw_white_shape_preview(self):
        self.clear_crop_rect()
        if not self.crop_start or not self.crop_end:
            return
        x1, y1 = self.image_to_canvas_coords(*self.crop_start)
        x2, y2 = self.image_to_canvas_coords(*self.crop_end)
        sx1, sy1 = self.crop_start
        sx2, sy2 = self.crop_end
        sample_points = [
            (sx1, sy1), (sx2, sy2),
            ((sx1 + sx2) / 2, sy1), ((sx1 + sx2) / 2, sy2),
            (sx1, (sy1 + sy2) / 2), (sx2, (sy1 + sy2) / 2),
            ((sx1 + sx2) / 2, (sy1 + sy2) / 2),
        ]
        if self.tool == "white_ellipse_beta":
            self.create_contrast_preview_shape("oval", (x1, y1, x2, y2), sample_points)
        else:
            self.create_contrast_preview_shape("rectangle", (x1, y1, x2, y2), sample_points)

    def draw_white_lasso_preview(self):
        self.clear_white_lasso_preview()
        if len(self.white_lasso_path) < 2:
            return
        pts = []
        for x, y in self.white_lasso_path:
            cx, cy = self.image_to_canvas_coords(x, y)
            pts.extend([cx, cy])
        self.create_contrast_preview_line(pts, self.white_lasso_path, width=2, smooth=True)
        if len(self.white_lasso_path) >= 3:
            sx, sy = self.image_to_canvas_coords(*self.white_lasso_path[0])
            ex, ey = self.image_to_canvas_coords(*self.white_lasso_path[-1])
            self.create_contrast_preview_line([ex, ey, sx, sy], [self.white_lasso_path[-1], self.white_lasso_path[0]], width=1, dash=(5, 4))

    def apply_crop(self):
        if not self.require_image() or not self.crop_start or not self.crop_end:
            return
        x1, y1 = self.crop_start
        x2, y2 = self.crop_end
        left, right = sorted([x1, x2])
        top, bottom = sorted([y1, y2])
        left, top, right, bottom = map(int, map(round, (left, top, right, bottom)))
        if right - left < 20 or bottom - top < 20:
            return
        self.push_undo()
        self.image = self.image.crop((left, top, right, bottom))
        self.skin_selection_mask = None
        self.crop_start = self.crop_end = None
        self.crop_drag_mode = None
        self.crop_drag_origin = None
        self.crop_drag_bounds = None
        self.tool = None
        self.refresh_canvas()
        self.set_status(self.t("apply_crop"))

    def pil_to_cv_rgb(self, img):
        """Convert PIL image to OpenCV RGB ndarray. Keeps alpha outside the processing path."""
        if not HAS_CV2:
            return None
        return np.array(img.convert("RGB"), dtype=np.uint8)

    def cv_rgb_to_pil(self, arr, alpha_source=None):
        """Convert OpenCV RGB ndarray back to PIL and restore alpha when needed."""
        result = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGB")
        if alpha_source is not None and "A" in alpha_source.getbands():
            result = result.convert("RGBA")
            result.putalpha(alpha_source.getchannel("A"))
        return result


    def build_subject_mask(self, img):
        """Approximate the visible human/model silhouette so auto skin tools do not
        work on the background. This is intentionally conservative: when it is
        unsure, it returns None and the normal skin mask is used. It is not a
        copied model/AI segmenter; it uses the border/background colour and keeps
        the central foreground shape."""
        if not HAS_CV2:
            return None
        try:
            base = img.convert("RGB")
            arr = self.pil_to_cv_rgb(base)
            h, w = arr.shape[:2]
            if h < 80 or w < 80:
                return None

            border = max(3, int(min(w, h) * 0.045))
            samples = np.concatenate([
                arr[:border, :, :].reshape(-1, 3),
                arr[-border:, :, :].reshape(-1, 3),
                arr[:, :border, :].reshape(-1, 3),
                arr[:, -border:, :].reshape(-1, 3),
            ], axis=0).astype(np.float32)
            bg_rgb = np.median(samples, axis=0)

            lab = cv2.cvtColor(arr, cv2.COLOR_RGB2LAB).astype(np.float32)
            bg_lab = cv2.cvtColor(np.uint8([[bg_rgb]]), cv2.COLOR_RGB2LAB).astype(np.float32)[0, 0]
            diff = np.sqrt(np.sum((lab - bg_lab) ** 2, axis=2))
            hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV)
            sat = hsv[:, :, 1].astype(np.float32)

            # Foreground: different enough from the border/background. Saturation
            # helps keep skin/clothing on white/grey studio backgrounds.
            foreground = ((diff > 15.0) | ((diff > 8.0) & (sat > 22))).astype(np.uint8) * 255
            foreground[:border, :] = 0
            foreground[-border:, :] = 0
            foreground[:, :border] = 0
            foreground[:, -border:] = 0

            kernel = np.ones((5, 5), np.uint8)
            foreground = cv2.morphologyEx(foreground, cv2.MORPH_CLOSE, kernel, iterations=2)
            foreground = cv2.morphologyEx(foreground, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8), iterations=1)

            count, labels, stats, centroids = cv2.connectedComponentsWithStats(foreground, connectivity=8)
            if count <= 1:
                return None

            keep = np.zeros((h, w), np.uint8)
            min_area = max(80, int(w * h * 0.004))
            cx1, cx2 = int(w * 0.12), int(w * 0.88)
            cy1, cy2 = int(h * 0.05), int(h * 0.98)
            kept_area = 0
            for label in range(1, count):
                area = int(stats[label, cv2.CC_STAT_AREA])
                if area < min_area:
                    continue
                cx, cy = centroids[label]
                # Keep central/large subject components. This keeps the model but
                # rejects isolated background patches.
                if (cx1 <= cx <= cx2 and cy1 <= cy <= cy2) or area > (w*h*0.05):
                    keep[labels == label] = 255
                    kept_area += area

            coverage = kept_area / float(w * h)
            if coverage < 0.015 or coverage > 0.92:
                return None

            keep = cv2.dilate(keep, np.ones((9, 9), np.uint8), iterations=1)
            keep = cv2.morphologyEx(keep, cv2.MORPH_CLOSE, np.ones((13, 13), np.uint8), iterations=2)

            # Fill holes inside the silhouette.
            flood = keep.copy()
            mask = np.zeros((h + 2, w + 2), np.uint8)
            cv2.floodFill(flood, mask, (0, 0), 255)
            holes = cv2.bitwise_not(flood)
            keep = cv2.bitwise_or(keep, holes)
            keep = cv2.GaussianBlur(keep, (0, 0), 2.2)
            return Image.fromarray(keep, "L")
        except Exception:
            return None

    def estimate_subject_protection_mask(self, img):
        """Soft protection mask for manual fill tools.
        It uses the same conservative subject estimate as the skin/background
        tools, then slightly dilates it so bucket fills do not repaint nails,
        clothes, hair, boots or skin when the clicked background is close in color."""
        subject = self.build_subject_mask(img)
        if subject is None:
            return None
        if HAS_CV2:
            try:
                arr = np.array(subject.convert("L"), dtype=np.uint8)
                arr = cv2.dilate(arr, np.ones((7, 7), np.uint8), iterations=1)
                arr = cv2.GaussianBlur(arr, (0, 0), 1.0)
                return Image.fromarray(arr, "L")
            except Exception:
                pass
        return subject.filter(ImageFilter.MaxFilter(size=7)).filter(ImageFilter.GaussianBlur(radius=1.0))

    def apply_subject_mask_to_skin_mask(self, skin_mask, img, external_mask=None):
        """Combine skin/tone masks with the model silhouette for automatic tools.
        For brush/local tools, external_mask is already the user-selected circle,
        so the silhouette step is skipped to avoid unreliable local segmentation."""
        if external_mask is not None:
            if external_mask.size != skin_mask.size:
                external_mask = external_mask.resize(skin_mask.size, Image.Resampling.NEAREST)
            return ImageChops.multiply(skin_mask, external_mask.convert("L"))
        subject_mask = self.build_subject_mask(img)
        if subject_mask is not None:
            if subject_mask.size != skin_mask.size:
                subject_mask = subject_mask.resize(skin_mask.size, Image.Resampling.BILINEAR)
            return ImageChops.multiply(skin_mask, subject_mask.convert("L"))
        return skin_mask

    def detect_primary_face_rect(self, img):
        """Return the most likely face rectangle using local OpenCV cascades.
        This is a lightweight offline detector; if the cascade is unavailable,
        the existing skin/geometry fallback still works."""
        if not HAS_CV2:
            return None
        try:
            base = img.convert("RGB")
            arr = self.pil_to_cv_rgb(base)
            h, w = arr.shape[:2]
            if h < 80 or w < 80:
                return None
            scale = min(1.0, 900.0 / float(max(w, h)))
            small = arr
            if scale < 1.0:
                small = cv2.resize(arr, (max(1, int(w * scale)), max(1, int(h * scale))), interpolation=cv2.INTER_AREA)
            gray = cv2.cvtColor(small, cv2.COLOR_RGB2GRAY)
            gray = cv2.equalizeHist(gray)
            cascade_names = [
                "haarcascade_frontalface_default.xml",
                "haarcascade_frontalface_alt2.xml",
                "haarcascade_profileface.xml",
            ]
            detections = []
            for name in cascade_names:
                path = os.path.join(getattr(cv2, "data", None).haarcascades, name) if getattr(cv2, "data", None) else ""
                if not path or not os.path.exists(path):
                    continue
                cascade = cv2.CascadeClassifier(path)
                if cascade.empty():
                    continue
                faces = cascade.detectMultiScale(
                    gray,
                    scaleFactor=1.08,
                    minNeighbors=4,
                    minSize=(max(24, int(small.shape[1] * 0.045)), max(24, int(small.shape[0] * 0.045))),
                )
                for (x, y, fw, fh) in faces:
                    detections.append((float(x) / scale, float(y) / scale, float(fw) / scale, float(fh) / scale))
            if not detections:
                return None
            best = None
            best_score = -1.0
            for x, y, fw, fh in detections:
                cx = x + fw / 2.0
                cy = y + fh / 2.0
                if cy > h * 0.72:
                    continue
                area = fw * fh
                center_bonus = 1.0 - min(1.0, abs(cx - w * 0.5) / (w * 0.5))
                upper_bonus = 1.0 - min(1.0, cy / max(1.0, h * 0.68))
                aspect = fw / max(1.0, fh)
                aspect_bonus = 1.0 - min(1.0, abs(aspect - 0.82) / 0.70)
                score = area * (0.72 + 0.22 * center_bonus + 0.18 * upper_bonus + 0.12 * aspect_bonus)
                if score > best_score:
                    best_score = score
                    best = (int(x), int(y), int(fw), int(fh))
            return best
        except Exception:
            return None

    def build_face_focus_mask(self, img):
        """Build a soft mask around the most likely face area.
        This keeps auto face retouching away from legs/arms/clothes while still
        preserving facial edges through the normal skin mask."""
        base = img.convert("RGB")
        w, h = base.size
        if w < 40 or h < 40:
            return Image.new("L", base.size, 0)
        if HAS_CV2:
            try:
                detected_face = self.detect_primary_face_rect(base)
                skin = np.array(self.build_skin_mask(base), dtype=np.uint8)
                upper_limit = max(8, int(h * 0.68))
                candidate = skin.copy()
                candidate[upper_limit:, :] = 0
                candidate[:max(1, int(h * 0.03)), :] = 0
                kernel = np.ones((5, 5), np.uint8)
                candidate = cv2.morphologyEx(candidate, cv2.MORPH_CLOSE, kernel, iterations=1)
                candidate = cv2.morphologyEx(candidate, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8), iterations=1)
                _, binary = cv2.threshold(candidate, 24, 255, cv2.THRESH_BINARY)
                count, labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8)
                best = None
                best_score = 0.0
                for label in range(1, count):
                    x = int(stats[label, cv2.CC_STAT_LEFT])
                    y = int(stats[label, cv2.CC_STAT_TOP])
                    bw = int(stats[label, cv2.CC_STAT_WIDTH])
                    bh = int(stats[label, cv2.CC_STAT_HEIGHT])
                    area = int(stats[label, cv2.CC_STAT_AREA])
                    if area < max(28, int(w * h * 0.00045)):
                        continue
                    cx, cy = centroids[label]
                    if cy > h * 0.68 or cx < w * 0.08 or cx > w * 0.92:
                        continue
                    aspect = bw / max(1.0, float(bh))
                    if aspect < 0.28 or aspect > 2.2:
                        continue
                    center_bonus = 1.0 - min(1.0, abs(cx - w * 0.5) / (w * 0.5))
                    upper_bonus = 1.0 - min(1.0, cy / max(1.0, h * 0.72))
                    score = area * (0.70 + 0.35 * center_bonus + 0.25 * upper_bonus)
                    if score > best_score:
                        best_score = score
                        best = (x, y, bw, bh)
                mask = np.zeros((h, w), np.uint8)
                if detected_face is not None:
                    x, y, bw, bh = detected_face
                    cx = x + bw / 2.0
                    cy = y + bh / 2.0
                    ax = max(18.0, bw * 0.86)
                    ay = max(22.0, bh * 1.06)
                    cy += bh * 0.09
                    cv2.ellipse(mask, (int(cx), int(cy)), (int(ax), int(ay)), 0, 0, 360, 255, -1)
                elif best is not None:
                    x, y, bw, bh = best
                    cx = x + bw / 2.0
                    cy = y + bh / 2.0
                    ax = max(18.0, bw * 0.82)
                    ay = max(22.0, bh * 1.02)
                    cy += bh * 0.04
                    cv2.ellipse(mask, (int(cx), int(cy)), (int(ax), int(ay)), 0, 0, 360, 255, -1)
                else:
                    cv2.ellipse(mask, (int(w * 0.5), int(h * 0.27)), (int(w * 0.20), int(h * 0.17)), 0, 0, 360, 255, -1)
                mask = cv2.GaussianBlur(mask, (0, 0), max(2.0, min(w, h) * 0.012))
                return Image.fromarray(mask, "L")
            except Exception:
                pass

        mask = Image.new("L", base.size, 0)
        draw = ImageDraw.Draw(mask)
        box = (
            int(w * 0.30),
            int(h * 0.10),
            int(w * 0.70),
            int(h * 0.46),
        )
        draw.ellipse(box, fill=255)
        return mask.filter(ImageFilter.GaussianBlur(radius=max(2, int(min(w, h) * 0.012))))

    def build_body_focus_mask(self, img):
        base = img.convert("RGB")
        w, h = base.size
        face = self.build_face_focus_mask(base)
        mask = Image.new("L", base.size, 0)
        draw = ImageDraw.Draw(mask)
        # Torso/arms zone. The lower part is excluded so nylon-covered legs keep
        # their existing smooth glossy texture unless the legs tool is chosen.
        draw.rectangle((int(w * 0.06), int(h * 0.30), int(w * 0.94), int(h * 0.68)), fill=255)
        if face is not None:
            inv_face = ImageOps.invert(face.convert("L").point(lambda v: 255 if v > 22 else 0))
            mask = ImageChops.multiply(mask, inv_face)
        subject = self.build_subject_mask(base)
        if subject is not None:
            mask = ImageChops.multiply(mask, subject.convert("L"))
        return mask.filter(ImageFilter.GaussianBlur(radius=max(2, int(min(w, h) * 0.010))))

    def build_legs_focus_mask(self, img):
        base = img.convert("RGB")
        w, h = base.size
        mask = Image.new("L", base.size, 0)
        draw = ImageDraw.Draw(mask)
        draw.rectangle((int(w * 0.04), int(h * 0.52), int(w * 0.96), h), fill=255)
        subject = self.build_subject_mask(base)
        if subject is not None:
            mask = ImageChops.multiply(mask, subject.convert("L"))
        return mask.filter(ImageFilter.GaussianBlur(radius=max(2, int(min(w, h) * 0.010))))

    def build_clean_white_background_mask(self, img):
        """Detect only a likely white studio background, not the model.
        The mask is built from white/neutral pixels connected to the image borders,
        then subtracts the detected subject silhouette. This is intentionally
        conservative: it cleans backdrop white, but avoids whitening the model,
        skin, clothes, latex, hair, and contour details."""
        if not HAS_CV2:
            return None
        try:
            base = img.convert("RGB")
            arr = self.pil_to_cv_rgb(base)
            h, w = arr.shape[:2]
            if h < 80 or w < 80:
                return None

            hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV)
            lab = cv2.cvtColor(arr, cv2.COLOR_RGB2LAB)
            L = lab[:, :, 0].astype(np.float32)
            sat = hsv[:, :, 1].astype(np.float32)
            maxc = np.max(arr, axis=2).astype(np.float32)
            minc = np.min(arr, axis=2).astype(np.float32)
            chroma = maxc - minc

            border = max(3, int(min(w, h) * 0.045))
            border_samples = np.concatenate([
                arr[:border, :, :].reshape(-1, 3),
                arr[-border:, :, :].reshape(-1, 3),
                arr[:, :border, :].reshape(-1, 3),
                arr[:, -border:, :].reshape(-1, 3),
            ], axis=0).astype(np.float32)
            bg_rgb = np.median(border_samples, axis=0)
            diff_bg = np.sqrt(np.sum((arr.astype(np.float32) - bg_rgb) ** 2, axis=2))

            # Strict gate: this cleanup is ONLY for a real white/neutral studio
            # background. If the border/background is not bright and low-saturation,
            # return None so auto-enhance will not whiten or blur/unify any background.
            border_max = np.max(border_samples, axis=1)
            border_min = np.min(border_samples, axis=1)
            border_chroma = border_max - border_min
            border_luma = border_samples @ np.array([0.299, 0.587, 0.114], dtype=np.float32)
            border_white = (border_luma > 184) & (border_min > 166) & (border_chroma < 58)
            white_ratio = float(np.mean(border_white)) if border_white.size else 0.0
            bg_chroma = float(np.max(bg_rgb) - np.min(bg_rgb))
            bg_luma = float(bg_rgb @ np.array([0.299, 0.587, 0.114], dtype=np.float32))
            bg_min = float(np.min(bg_rgb))
            if white_ratio < 0.18 or bg_luma < 164 or bg_min < 142 or bg_chroma > 82:
                return None

            # White/gray backdrop candidate: bright, low color, and close to the
            # border backdrop color. This avoids warm skin and colored clothes.
            white_candidate = (
                # real white backgrounds are often grayish or uneven, not pure white
                (L > 138) &
                (minc > 118) &
                (sat < 104) &
                (chroma < 105) &
                (diff_bg < 118)
            ) | (
                (minc > 176) &
                (sat < 110) &
                (chroma < 108) &
                (diff_bg < 135)
            )

            # Never treat likely skin as background, even if pale.
            skin = np.array(self.build_skin_mask(base), dtype=np.uint8) > 22
            tone_skin = np.array(self.build_skin_tone_mask(base), dtype=np.uint8) > 12
            white_candidate &= ~(skin | tone_skin)

            # Keep only candidate areas connected to the image borders. This is
            # the main safeguard against whitening white clothing inside the model.
            cand = (white_candidate.astype(np.uint8) * 255)
            connected = np.zeros((h, w), np.uint8)
            flood = cand.copy()
            mask = np.zeros((h + 2, w + 2), np.uint8)
            seeds = []
            step = max(1, int(min(w, h) / 90))
            for x in range(0, w, step):
                seeds.append((x, 0)); seeds.append((x, h - 1))
            for y in range(0, h, step):
                seeds.append((0, y)); seeds.append((w - 1, y))
            for x, y in seeds:
                if flood[y, x] == 255 and connected[y, x] == 0:
                    before = flood.copy()
                    cv2.floodFill(flood, mask, (x, y), 128)
                    region = (flood == 128) & (before == 255)
                    connected[region] = 255
                    flood[region] = 0
                    mask[:, :] = 0

            if int(np.count_nonzero(connected)) < max(50, int(w * h * 0.01)):
                return None

            # Subtract/dilate subject silhouette so the model and clothing are not touched.
            subject = self.build_subject_mask(base)
            if subject is not None:
                subj = np.array(subject.resize((w, h), Image.Resampling.BILINEAR), dtype=np.uint8)
                subj = cv2.dilate(subj, np.ones((23, 23), np.uint8), iterations=1)
                connected[subj > 12] = 0

            # Protect contour halos around the model.
            gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
            edges = cv2.Canny(gray, 38, 105)
            edges = cv2.dilate(edges, np.ones((5, 5), np.uint8), iterations=1)
            connected[edges > 0] = np.where(connected[edges > 0] > 0, 120, 0)

            # Smooth mask edges for natural transition, but keep the interior strong.
            connected = cv2.morphologyEx(connected, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8), iterations=1)
            connected = cv2.GaussianBlur(connected, (0, 0), 2.8)
            return Image.fromarray(connected, "L")
        except Exception:
            return None

    def clean_white_background_only(self, enhanced_arr, original_img):
        """Make a white/neutral studio background cleaner and more uniform without
        whitening the model. Works only on the white background mask connected to
        image borders."""
        if not HAS_CV2:
            return enhanced_arr
        try:
            mask_img = self.build_clean_white_background_mask(original_img)
            if mask_img is None:
                return enhanced_arr
            mask = np.array(mask_img, dtype=np.float32) / 255.0
            if float(np.mean(mask)) < 0.005:
                return enhanced_arr
            arr = enhanced_arr.astype(np.float32)
            # Uniform target: clean warm-neutral white, not pure clipped white.
            y = np.sum(arr * np.array([0.299, 0.587, 0.114], dtype=np.float32), axis=2)
            target_level = np.clip(np.maximum(248.0, y + 42.0), 248.0, 255.0)
            target = np.dstack([target_level, target_level, target_level]).astype(np.float32)

            # Stronger studio-white cleanup: lift gray/shadowed backdrop areas
            # toward clean white, but only inside the protected border-connected mask.
            bg_smooth = cv2.GaussianBlur(arr, (0, 0), 11.0)
            clean_target = target * 0.91 + bg_smooth * 0.09
            amount = (mask * 0.82)[:, :, None]
            arr = arr * (1.0 - amount) + clean_target * amount
            return np.clip(arr, 0, 255)
        except Exception:
            return enhanced_arr

    def build_clean_black_background_mask(self, img):
        """Detect a likely black/dark studio background connected to image borders,
        then subtract the subject so black clothing/hair/model contours are protected."""
        if not HAS_CV2:
            return None
        try:
            base = img.convert("RGB")
            arr = self.pil_to_cv_rgb(base)
            h, w = arr.shape[:2]
            if h < 80 or w < 80:
                return None
            hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV)
            gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
            sat = hsv[:, :, 1].astype(np.float32)
            maxc = np.max(arr, axis=2).astype(np.float32)
            minc = np.min(arr, axis=2).astype(np.float32)
            chroma = maxc - minc
            border = max(3, int(min(w, h) * 0.045))
            border_samples = np.concatenate([
                arr[:border, :, :].reshape(-1, 3),
                arr[-border:, :, :].reshape(-1, 3),
                arr[:, :border, :].reshape(-1, 3),
                arr[:, -border:, :].reshape(-1, 3),
            ], axis=0).astype(np.float32)
            border_luma = border_samples @ np.array([0.299, 0.587, 0.114], dtype=np.float32)
            border_max = np.max(border_samples, axis=1)
            border_min = np.min(border_samples, axis=1)
            border_chroma = border_max - border_min
            dark_ratio = float(np.mean((border_luma < 82) & (border_max < 118) & (border_chroma < 72))) if border_samples.size else 0.0
            bg_rgb = np.median(border_samples, axis=0)
            bg_luma = float(bg_rgb @ np.array([0.299, 0.587, 0.114], dtype=np.float32))
            bg_max = float(np.max(bg_rgb))
            bg_chroma = float(np.max(bg_rgb) - np.min(bg_rgb))
            if dark_ratio < 0.18 or bg_luma > 108 or bg_max > 150 or bg_chroma > 92:
                return None
            diff_bg = np.sqrt(np.sum((arr.astype(np.float32) - bg_rgb) ** 2, axis=2))
            dark_candidate = (
                # dark studio backgrounds may be grayish, faded or uneven
                (gray.astype(np.float32) < 128) &
                (maxc < 168) &
                (diff_bg < 115) &
                (sat < 170)
            ) | (
                (gray.astype(np.float32) < 86) &
                (maxc < 138) &
                (diff_bg < 138)
            )
            cand = (dark_candidate.astype(np.uint8) * 255)
            connected = np.zeros((h, w), np.uint8)
            flood = cand.copy()
            mask = np.zeros((h + 2, w + 2), np.uint8)
            seeds = []
            step = max(1, int(min(w, h) / 90))
            for x in range(0, w, step):
                seeds.append((x, 0)); seeds.append((x, h - 1))
            for y in range(0, h, step):
                seeds.append((0, y)); seeds.append((w - 1, y))
            for x, y in seeds:
                if flood[y, x] == 255 and connected[y, x] == 0:
                    before = flood.copy()
                    cv2.floodFill(flood, mask, (x, y), 128)
                    region = (flood == 128) & (before == 255)
                    connected[region] = 255
                    flood[region] = 0
                    mask[:, :] = 0
            if int(np.count_nonzero(connected)) < max(50, int(w * h * 0.01)):
                return None
            subject = self.build_subject_mask(base)
            if subject is not None:
                subj = np.array(subject.resize((w, h), Image.Resampling.BILINEAR), dtype=np.uint8)
                subj = cv2.dilate(subj, np.ones((23, 23), np.uint8), iterations=1)
                connected[subj > 10] = 0
            edges = cv2.Canny(gray, 35, 105)
            edges = cv2.dilate(edges, np.ones((5, 5), np.uint8), iterations=1)
            connected[edges > 0] = np.where(connected[edges > 0] > 0, 120, 0)
            connected = cv2.morphologyEx(connected, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8), iterations=1)
            connected = cv2.GaussianBlur(connected, (0, 0), 2.8)
            return Image.fromarray(connected, "L")
        except Exception:
            return None

    def improve_background_with_mask(self, img, mask_img, mode):
        if mask_img is None:
            return None
        try:
            if HAS_CV2:
                arr = self.pil_to_cv_rgb(img).astype(np.float32)
                mask = np.array(mask_img.resize(img.size, Image.Resampling.BILINEAR), dtype=np.float32) / 255.0
                if float(np.mean(mask)) < 0.005:
                    return None
                smooth_radius = 9.0 if mode == "white" else 10.0
                smooth = cv2.GaussianBlur(arr, (0, 0), smooth_radius)
                y = np.sum(arr * np.array([0.299, 0.587, 0.114], dtype=np.float32), axis=2)
                if mode == "white":
                    # Stronger result requested: push white/gray/dirty backdrop
                    # close to studio white while protecting subject/object edges.
                    target_level = np.clip(np.maximum(250.0, y + 48.0), 250.0, 255.0)
                    target = np.dstack([target_level, target_level, target_level]).astype(np.float32)
                    clean_target = target * 0.92 + smooth * 0.08
                    amount = (mask * 0.86)[:, :, None]
                else:
                    # Same philosophy for black backdrops: deeper, smoother black
                    # on detected background only.
                    target_level = np.clip(np.minimum(7.0, y - 42.0), 0.0, 12.0)
                    target = np.dstack([target_level, target_level, target_level]).astype(np.float32)
                    clean_target = target * 0.90 + smooth * 0.10
                    amount = (mask * 0.82)[:, :, None]
                out = arr * (1.0 - amount) + clean_target * amount
                return self.cv_rgb_to_pil(np.clip(out, 0, 255), img)
            return None
        except Exception:
            return None

    def apply_white_background_auto(self):
        if not self.require_image():
            return
        mask = self.build_clean_white_background_mask(self.image)
        result = self.improve_background_with_mask(self.image, mask, "white")
        if result is None:
            self.set_status(self.t("bg_no_white"))
            return
        self.push_undo()
        self.image = result
        self.refresh_canvas()
        self.set_status(self.t("bg_white_done"))

    def apply_black_background_auto(self):
        if not self.require_image():
            return
        mask = self.build_clean_black_background_mask(self.image)
        result = self.improve_background_with_mask(self.image, mask, "black")
        if result is None:
            self.set_status(self.t("bg_no_black"))
            return
        self.push_undo()
        self.image = result
        self.refresh_canvas()
        self.set_status(self.t("bg_black_done"))

    def composite_on_selected_background(self, cutout_rgba, size):
        if self.bg_replace_image_path and os.path.exists(self.bg_replace_image_path):
            with Image.open(self.bg_replace_image_path) as bg_file:
                bg = ImageOps.fit(bg_file.convert("RGB"), size, Image.LANCZOS).convert("RGBA")
        else:
            bg = Image.new("RGBA", size, hex_to_rgb(self.bg_replace_color_value.get()) + (255,))
        bg.alpha_composite(cutout_rgba.convert("RGBA"))
        return bg.convert("RGB")

    def refine_ai_cutout_alpha(self, source_rgba, cutout_rgba):
        source_rgba = source_rgba.convert("RGBA")
        cutout_rgba = cutout_rgba.convert("RGBA")
        if cutout_rgba.size != source_rgba.size:
            cutout_rgba = cutout_rgba.resize(source_rgba.size, Image.Resampling.LANCZOS)
        if not HAS_CV2:
            alpha = cutout_rgba.getchannel("A")
            clean = source_rgba.copy()
            clean.putalpha(alpha)
            return clean
        try:
            source = source_rgba
            src_rgb = np.array(source.convert("RGB"), dtype=np.uint8)
            rgba = np.array(cutout_rgba.convert("RGBA"))
            alpha = rgba[:, :, 3].astype(np.float32)
            rgb8 = src_rgb
            hsv = cv2.cvtColor(rgb8, cv2.COLOR_RGB2HSV)
            ycrcb = cv2.cvtColor(rgb8, cv2.COLOR_RGB2YCrCb)
            gray = cv2.cvtColor(rgb8, cv2.COLOR_RGB2GRAY).astype(np.float32)
            sat = hsv[:, :, 1].astype(np.float32)
            rgb = rgb8.astype(np.float32)
            maxc = np.max(rgb, axis=2)
            minc = np.min(rgb, axis=2)
            chroma = maxc - minc
            r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
            y = ycrcb[:, :, 0].astype(np.float32)
            cr = ycrcb[:, :, 1].astype(np.float32)
            cb = ycrcb[:, :, 2].astype(np.float32)
            skin = (
                (y > 52) & (r > 78) & (g > 42) & (b > 26) &
                (cr > 122) & (cr < 190) & (cb > 68) & (cb < 158) &
                (r > b * 1.03) & (r > g * 0.88) & (chroma > 8)
            )
            colorful = (sat > 70) | (chroma > 68)
            dark_hair_or_detail = (gray < 92) | ((gray < 145) & (sat > 20))
            jewelry_edges = cv2.Canny(rgb8, 45, 140) > 0

            neutral_backdrop = (
                (sat < 46) & (chroma < 58) & (gray > 86) & (gray < 248) &
                (~skin) & (~colorful) & (~dark_hair_or_detail)
            )
            colored_sheer_fabric = (
                (chroma > 16) &
                (r > g + 10) &
                (b > g + 8)
            )
            low_chroma_studio_shadow = (
                (sat < 44) & (chroma < 34) & (gray > 92) & (gray < 248) &
                (~colorful) & (~dark_hair_or_detail) & (~colored_sheer_fabric)
            )
            background_like_residue = neutral_backdrop | (
                (alpha < 190) & low_chroma_studio_shadow
            )

            # Follow only background-coloured pixels reachable from the outside.
            # This removes white/grey shadow islands around arms and body while
            # avoiding skin, clothing, hair and high-detail jewellery.
            h, w = alpha.shape
            walkable = ((alpha < 48) | background_like_residue).astype(np.uint8)
            flood = np.zeros((h + 2, w + 2), np.uint8)
            reachable = np.zeros((h, w), np.uint8)
            for x in range(w):
                if walkable[0, x]:
                    cv2.floodFill(walkable, flood, (x, 0), 2)
                if walkable[h - 1, x] == 1:
                    cv2.floodFill(walkable, flood, (x, h - 1), 2)
            for y0 in range(h):
                if walkable[y0, 0] == 1:
                    cv2.floodFill(walkable, flood, (0, y0), 2)
                if walkable[y0, w - 1] == 1:
                    cv2.floodFill(walkable, flood, (w - 1, y0), 2)
            reachable[walkable == 2] = 255

            protected_edges = cv2.dilate(jewelry_edges.astype(np.uint8) * 255, np.ones((5, 5), np.uint8), iterations=1) > 0
            fine_hair_or_edge_detail = (
                (alpha > 24) &
                protected_edges &
                ((gray < 220) | (sat > 10) | (chroma > 10))
            )
            protected_subject = (
                (alpha >= 190) |
                skin |
                colorful |
                dark_hair_or_detail |
                colored_sheer_fabric |
                protected_edges |
                fine_hair_or_edge_detail
            )
            shadow_walkable = (
                (alpha < 72) |
                (
                    low_chroma_studio_shadow &
                    (alpha < 178) &
                    (gray > 68) & (gray < 252) &
                    (sat < 66) & (chroma < 62) &
                    (~protected_subject)
                )
            ).astype(np.uint8)
            shadow_flood = shadow_walkable.copy()
            shadow_reachable = np.zeros((h, w), np.uint8)
            shadow_mask = np.zeros((h + 2, w + 2), np.uint8)
            for x in range(w):
                if shadow_flood[0, x] == 1:
                    cv2.floodFill(shadow_flood, shadow_mask, (x, 0), 2)
                if shadow_flood[h - 1, x] == 1:
                    cv2.floodFill(shadow_flood, shadow_mask, (x, h - 1), 2)
            for y0 in range(h):
                if shadow_flood[y0, 0] == 1:
                    cv2.floodFill(shadow_flood, shadow_mask, (0, y0), 2)
                if shadow_flood[y0, w - 1] == 1:
                    cv2.floodFill(shadow_flood, shadow_mask, (w - 1, y0), 2)
            shadow_reachable[shadow_flood == 2] = 255
            external_reachable = (reachable > 0) | (shadow_reachable > 0)
            isolated_soft_shadow = (alpha < 178) & low_chroma_studio_shadow
            fine_finger_gap_shadow = (
                (alpha > 42) & (alpha < 178) &
                low_chroma_studio_shadow &
                (sat < 40) & (chroma < 30) &
                (~protected_subject)
            )
            exterior_contact_shadow = (
                external_reachable &
                low_chroma_studio_shadow &
                (alpha < 178) &
                (gray > 68) & (gray < 252) &
                (sat < 66) & (chroma < 62) &
                (~protected_subject)
            )
            shadow_residue = (
                ((external_reachable & background_like_residue) | isolated_soft_shadow | fine_finger_gap_shadow | exterior_contact_shadow) &
                (alpha > 8) &
                (alpha < 190) &
                (~protected_subject)
            )
            shadow_residue = cv2.morphologyEx(shadow_residue.astype(np.uint8) * 255, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8), iterations=1)
            shadow_residue = cv2.GaussianBlur(shadow_residue, (0, 0), 1.4).astype(np.float32) / 255.0

            refined_alpha = alpha.copy()
            refined_alpha = np.where(shadow_residue > 0, refined_alpha * (1.0 - shadow_residue * 0.98), refined_alpha)

            # Extra pass for the faint grey/beige contact halos rembg often
            # keeps around fingers and bare feet on white studio rolls.
            fine_external_halo = (
                external_reachable &
                (alpha > 3) & (alpha < 178) &
                (sat < 62) & (chroma < 62) &
                (gray > 75) & (gray < 252) &
                low_chroma_studio_shadow &
                (~protected_subject)
            )
            fine_external_halo = cv2.morphologyEx(fine_external_halo.astype(np.uint8) * 255, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8), iterations=1)
            fine_external_halo = cv2.morphologyEx(fine_external_halo, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8), iterations=1)
            fine_external_halo = cv2.dilate(fine_external_halo, np.ones((2, 2), np.uint8), iterations=1)
            fine_external_soft = cv2.GaussianBlur(fine_external_halo, (0, 0), 1.0).astype(np.float32) / 255.0
            refined_alpha = np.where(fine_external_halo > 0, 0, refined_alpha)
            refined_alpha = np.where(
                (fine_external_halo == 0) & (fine_external_soft > 0),
                refined_alpha * (1.0 - fine_external_soft * 0.72),
                refined_alpha
            )

            # Final contour pass: rembg sometimes keeps a thin grey contact
            # shadow fully opaque at the outside edge of hands/feet. Remove
            # only low-chroma studio shadow pixels on the exterior contour.
            solid_subject = (refined_alpha > 8).astype(np.uint8) * 255
            interior_subject = cv2.erode(solid_subject, np.ones((5, 5), np.uint8), iterations=1) > 0
            outer_contour = (solid_subject > 0) & (~interior_subject)
            muted_contact_shadow = (
                (sat < 105) & (chroma < 86) &
                (gray > 54) & (gray < 236) &
                (~skin) & (~protected_edges) & (~colored_sheer_fabric)
            )
            contour_shadow = (
                outer_contour &
                (alpha < 178) &
                (low_chroma_studio_shadow | muted_contact_shadow) &
                (~protected_subject)
            )
            contour_shadow = cv2.morphologyEx(contour_shadow.astype(np.uint8) * 255, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8), iterations=1)
            contour_soft = cv2.GaussianBlur(contour_shadow, (0, 0), 0.7).astype(np.float32) / 255.0
            refined_alpha = np.where(contour_shadow > 0, 0, refined_alpha)
            refined_alpha = np.where(
                (contour_shadow == 0) & (contour_soft > 0),
                refined_alpha * (1.0 - contour_soft * 0.55),
                refined_alpha
            )

            # Rembg can make solid subject pixels slightly transparent. On a white
            # composite that washes out skin and clothes, so keep only the true
            # contour semi-transparent and restore the confident subject interior.
            confident_subject = (
                (alpha >= 190) &
                (~(shadow_residue > 0.02)) &
                (~((alpha < 190) & low_chroma_studio_shadow))
            )
            refined_alpha[confident_subject] = 255

            core = (refined_alpha > 145).astype(np.uint8) * 255
            core = cv2.morphologyEx(core, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8), iterations=1)
            inverse = cv2.bitwise_not(core)
            outside = inverse.copy()
            fill_mask = np.zeros((h + 2, w + 2), np.uint8)
            cv2.floodFill(outside, fill_mask, (0, 0), 0)

            border = max(3, int(min(w, h) * 0.045))
            border_samples = np.concatenate([
                rgb8[:border, :, :].reshape(-1, 3),
                rgb8[-border:, :, :].reshape(-1, 3),
                rgb8[:, :border, :].reshape(-1, 3),
                rgb8[:, -border:, :].reshape(-1, 3),
            ], axis=0).astype(np.float32)
            bg_rgb = np.median(border_samples, axis=0)
            bg_diff = np.sqrt(np.sum((rgb - bg_rgb) ** 2, axis=2))
            bg_grad = cv2.GaussianBlur(cv2.magnitude(
                cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3),
                cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
            ), (0, 0), 0.65)
            internal_bg_candidate = (
                (gray > 48) & (gray < 253) &
                (sat < 92) & (chroma < 92) &
                (bg_diff < 118) &
                (bg_grad < 30) &
                (~skin) & (~colorful) & (~dark_hair_or_detail) & (~colored_sheer_fabric) &
                (~protected_edges)
            )
            # With the current primary AI model, most real openings are already
            # transparent. Keep this disabled by default: aggressive internal
            # pocket removal can damage white clothing details such as zippers,
            # latex highlights and folds.
            internal_seed = np.zeros((h, w), np.uint8)
            internal_seed = cv2.morphologyEx(internal_seed, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8), iterations=1)
            count, labels, stats, centroids = cv2.connectedComponentsWithStats(internal_seed, connectivity=8)
            internal_gaps = np.zeros((h, w), np.uint8)
            max_gap_area = max(160, int(w * h * 0.035))
            for label in range(1, count):
                area = int(stats[label, cv2.CC_STAT_AREA])
                if area < 24 or area > max_gap_area:
                    continue
                x = int(stats[label, cv2.CC_STAT_LEFT])
                y0 = int(stats[label, cv2.CC_STAT_TOP])
                bw = int(stats[label, cv2.CC_STAT_WIDTH])
                bh = int(stats[label, cv2.CC_STAT_HEIGHT])
                if x <= 1 or y0 <= 1 or x + bw >= w - 2 or y0 + bh >= h - 2:
                    continue
                comp = labels == label
                if float(np.mean(bg_diff[comp])) > 92.0 or float(np.mean(bg_grad[comp])) > 20.0:
                    continue
                ring = cv2.dilate(comp.astype(np.uint8) * 255, np.ones((11, 11), np.uint8), iterations=1) > 0
                ring &= ~comp
                subject_ring_ratio = float(np.mean(refined_alpha[ring] > 112)) if np.any(ring) else 0.0
                if subject_ring_ratio < 0.18:
                    continue
                internal_gaps[comp] = 255
            internal_gaps = cv2.dilate(internal_gaps, np.ones((3, 3), np.uint8), iterations=1)
            internal_gaps_soft = cv2.GaussianBlur(internal_gaps, (0, 0), 1.1).astype(np.float32) / 255.0
            refined_alpha = np.where(internal_gaps_soft > 0, refined_alpha * (1.0 - internal_gaps_soft * 0.99), refined_alpha)

            enclosed_background_gaps = (
                (outside > 0) &
                (reachable == 0) &
                (gray > 66) & (gray < 252) &
                (sat < 76) & (chroma < 76) &
                (neutral_backdrop | low_chroma_studio_shadow | muted_contact_shadow) &
                (~skin) & (~colorful) & (~dark_hair_or_detail) & (~colored_sheer_fabric) &
                (~protected_edges)
            )
            enclosed_background_gaps = cv2.morphologyEx(enclosed_background_gaps.astype(np.uint8) * 255, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8), iterations=1)
            enclosed_background_gaps = cv2.GaussianBlur(enclosed_background_gaps, (0, 0), 0.8).astype(np.float32) / 255.0
            refined_alpha = np.where(enclosed_background_gaps > 0, refined_alpha * (1.0 - enclosed_background_gaps * 0.98), refined_alpha)

            enclosed_holes = (outside > 0) & (reachable == 0) & (~background_like_residue) & ~(enclosed_background_gaps > 0.05) & ~(internal_gaps_soft > 0.05)
            refined_alpha[enclosed_holes] = np.maximum(refined_alpha[enclosed_holes], 245)

            # Human recovery pass: when clothes, arms or legs are close to the
            # background colour, the AI can cut into the model. Restore only
            # pixels carrying human/garment evidence and close to the AI subject,
            # while still rejecting flat border-connected background.
            near_subject = cv2.dilate((refined_alpha > 26).astype(np.uint8) * 255, np.ones((17, 17), np.uint8), iterations=1) > 0
            strong_subject = cv2.dilate((refined_alpha > 150).astype(np.uint8) * 255, np.ones((29, 29), np.uint8), iterations=1) > 0
            textured_garment = (
                ((sat > 34) | (chroma > 28) | protected_edges) &
                (gray > 12) & (gray < 246) &
                (~background_like_residue)
            )
            recover_human = (
                (alpha < 190) &
                (near_subject | strong_subject) &
                (skin | colored_sheer_fabric | colorful | (dark_hair_or_detail & textured_garment) | (protected_edges & (gray < 235))) &
                (~fine_external_halo.astype(bool)) &
                (~contour_shadow.astype(bool))
            )
            recover_human = cv2.morphologyEx(recover_human.astype(np.uint8) * 255, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8), iterations=1)
            recover_soft = cv2.GaussianBlur(recover_human, (0, 0), 1.0).astype(np.float32) / 255.0
            refined_alpha = np.where(
                recover_soft > 0,
                np.maximum(refined_alpha, 205 + recover_soft * 45),
                refined_alpha
            )

            result = np.dstack([src_rgb, np.clip(refined_alpha, 0, 255).astype(np.uint8)])
            return Image.fromarray(result, "RGBA")
        except Exception:
            alpha = cutout_rgba.getchannel("A")
            if alpha.size != source_rgba.size:
                alpha = alpha.resize(source_rgba.size, Image.Resampling.LANCZOS)
            clean = source_rgba.copy()
            clean.putalpha(alpha)
            return clean

    def paint_white_cleanup_at(self, x, y):
        if not self.require_image():
            return
        radius = max(3, int(self.brush_size.get()))
        left, top = max(0, x - radius), max(0, y - radius)
        right, bottom = min(self.image.width, x + radius), min(self.image.height, y + radius)
        if right <= left or bottom <= top:
            return
        region = self.image.crop((left, top, right, bottom)).convert("RGB")
        mask = Image.new("L", region.size, 0)
        draw = ImageDraw.Draw(mask)
        cx, cy = x - left, y - top
        draw.ellipse((cx - radius, cy - radius, cx + radius, cy + radius), fill=255)
        mask = mask.filter(ImageFilter.GaussianBlur(radius=max(0.8, radius * 0.12)))
        mask = self.apply_cleanup_opacity_to_mask(mask)
        textured_fill = self.render_cleanup_fill_with_texture(region, mask)
        self.image.paste(Image.composite(textured_fill, region, mask), (left, top))
        self.refresh_canvas()

    def bucket_fill_cleanup_at(self, x, y):
        if not self.require_image():
            return
        base = self.image.convert("RGB")
        if not HAS_CV2:
            self.paint_white_cleanup_at(x, y)
            return
        try:
            arr = np.asarray(base, dtype=np.uint8)
            h, w = arr.shape[:2]
            x = max(0, min(w - 1, int(x)))
            y = max(0, min(h - 1, int(y)))

            radius = max(2, int(self.brush_size.get() * 0.35))
            x1, x2 = max(0, x - radius), min(w, x + radius + 1)
            y1, y2 = max(0, y - radius), min(h, y + radius + 1)
            target = np.median(arr[y1:y2, x1:x2].reshape(-1, 3), axis=0).astype(np.float32)

            arrf = arr.astype(np.float32)
            diff = np.sqrt(np.sum((arrf - target[None, None, :]) ** 2, axis=2))
            gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY).astype(np.float32)
            target_luma = float(target[0] * 0.299 + target[1] * 0.587 + target[2] * 0.114)
            luma_diff = np.abs(gray - target_luma)
            hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV)
            sat = hsv[:, :, 1].astype(np.float32)
            grad = cv2.GaussianBlur(cv2.magnitude(
                cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3),
                cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
            ), (0, 0), 0.55)

            tolerance = int(self.cleanup_bucket_tolerance_value.get())
            color_limit = 18.0 + tolerance * 1.55
            luma_limit = 12.0 + tolerance * 1.35
            gradient_limit = 8.0 + tolerance * 0.72
            target_sat = float(np.max(target) - np.min(target))
            if target_sat < 28:
                # White/grey studio background often has soft gradients. Let the
                # bucket follow those gradients, but stop at textured/coloured subject edges.
                color_limit *= 1.28
                luma_limit *= 1.34

            candidate = (
                (diff <= color_limit) &
                (luma_diff <= luma_limit) &
                (grad <= gradient_limit + np.clip(255.0 - sat, 0, 255) * 0.03)
            ).astype(np.uint8)

            flood = np.zeros((h + 2, w + 2), np.uint8)
            work = candidate.copy()
            cv2.floodFill(work, flood, (x, y), 2)
            mask_arr = (work == 2).astype(np.uint8) * 255

            if np.count_nonzero(mask_arr) < 4:
                self.set_status(self.t("white_cleanup_done"))
                return

            # Keep this as a local correction, not a whole-photo paint bomb. If
            # the clicked colour is connected to a large area, the user should
            # use the brush/lasso instead of one-click filling it.
            max_pixels = int(w * h * 0.075)
            if np.count_nonzero(mask_arr) > max_pixels:
                self.set_status(self.t("white_cleanup_done"))
                return
            protected = self.estimate_subject_protection_mask(base)
            if protected is not None:
                mask_arr[np.array(protected.resize((w, h), Image.Resampling.BILINEAR), dtype=np.uint8) > 24] = 0
                if np.count_nonzero(mask_arr) < 4:
                    self.set_status(self.t("white_cleanup_done"))
                    return

            mask_arr = cv2.morphologyEx(mask_arr, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8), iterations=1)
            mask_arr = cv2.GaussianBlur(mask_arr, (0, 0), 0.85)
            mask = Image.fromarray(mask_arr, "L")
            mask = self.apply_cleanup_opacity_to_mask(mask)
            textured_fill = self.render_cleanup_fill_with_texture(base, mask)
            self.image = Image.composite(textured_fill, base, mask)
            self.refresh_canvas()
            self.set_status(self.t("white_cleanup_done"))
        except Exception:
            self.paint_white_cleanup_at(x, y)

    def get_cleanup_fill_rgb(self):
        try:
            return hex_to_rgb(self.cleanup_fill_color_value.get())
        except Exception:
            return (255, 255, 255)

    def get_cleanup_opacity_factor(self):
        try:
            return max(0.10, min(1.0, int(self.cleanup_opacity_value.get()) / 100.0))
        except Exception:
            return 1.0

    def apply_cleanup_opacity_to_mask(self, mask):
        factor = self.get_cleanup_opacity_factor()
        if factor >= 0.995:
            return mask.convert("L")
        return mask.convert("L").point(lambda p: max(0, min(255, int(p * factor))))

    def render_cleanup_fill_with_texture(self, region, mask):
        """Apply the chosen cleanup colour while keeping the local background grain."""
        base = region.convert("RGB")
        target_rgb = self.get_cleanup_fill_rgb()
        if not HAS_CV2:
            gray = ImageEnhance.Contrast(base.convert("L")).enhance(0.55)
            dark = tuple(max(0, min(255, int(channel * 0.62))) for channel in target_rgb)
            light = tuple(max(0, min(255, int(channel + 42))) for channel in target_rgb)
            tinted = ImageOps.colorize(gray, black=dark, white=light)
            return tinted.convert("RGB")

        target = np.array(target_rgb, dtype=np.float32)
        mask_arr = np.asarray(mask, dtype=np.float32) / 255.0
        if np.max(mask_arr) <= 0:
            return Image.new("RGB", base.size, target_rgb)

        arr = np.asarray(base, dtype=np.float32)
        y = arr[:, :, 0] * 0.299 + arr[:, :, 1] * 0.587 + arr[:, :, 2] * 0.114
        active = mask_arr > 0.04
        if np.count_nonzero(active) < 4:
            mean_y = float(np.mean(y))
        else:
            mean_y = float(np.median(y[active]))

        sigma = max(0.9, min(8.0, min(base.size) * 0.035))
        soft_luma = cv2.GaussianBlur(y, (0, 0), sigma)
        broad_texture = (y - mean_y) * 0.42
        fine_texture = (y - soft_luma) * 0.50
        texture = np.clip(broad_texture + fine_texture, -48.0, 48.0)

        target_luma = float(target[0] * 0.299 + target[1] * 0.587 + target[2] * 0.114)
        luma_ratio = np.clip((target_luma + texture) / max(1.0, target_luma), 0.55, 1.45)
        fill = target[None, None, :] * luma_ratio[:, :, None]

        # Keep a small amount of original colour noise so repaired areas do not look airbrushed.
        local_color_noise = arr - y[:, :, None]
        fill = fill + local_color_noise * 0.10
        return Image.fromarray(np.clip(fill, 0, 255).astype(np.uint8), "RGB")

    def apply_white_shape(self):
        x1, y1 = self.crop_start
        x2, y2 = self.crop_end
        left, right = sorted((max(0, x1), min(self.image.width - 1, x2)))
        top, bottom = sorted((max(0, y1), min(self.image.height - 1, y2)))
        if right - left < 2 or bottom - top < 2:
            self.clear_crop_rect()
            return
        self.push_undo()
        region = self.image.crop((left, top, right, bottom)).convert("RGB")
        mask = Image.new("L", region.size, 0)
        draw = ImageDraw.Draw(mask)
        if self.tool == "white_ellipse_beta":
            draw.ellipse((0, 0, region.width - 1, region.height - 1), fill=255)
        else:
            draw.rectangle((0, 0, region.width - 1, region.height - 1), fill=255)
        mask = mask.filter(ImageFilter.GaussianBlur(radius=0.8))
        mask = self.apply_cleanup_opacity_to_mask(mask)
        textured_fill = self.render_cleanup_fill_with_texture(region, mask)
        self.image.paste(Image.composite(textured_fill, region, mask), (left, top))
        self.crop_start = self.crop_end = None
        self.clear_crop_rect()
        self.refresh_canvas()
        self.set_status(self.t("white_cleanup_done"))

    def extend_white_lasso(self, x, y):
        point = (int(x), int(y))
        if self.white_lasso_path:
            lx, ly = self.white_lasso_path[-1]
            if abs(point[0] - lx) + abs(point[1] - ly) < 3:
                return
        self.white_lasso_path.append(point)
        self.draw_white_lasso_preview()

    def expand_cleanup_mask(self, mask, radius=None):
        try:
            radius = int(self.cleanup_lasso_expand_value.get() if radius is None else radius)
        except Exception:
            radius = 0
        if radius <= 0:
            return mask
        radius = max(0, min(80, radius))
        if HAS_CV2:
            arr = np.array(mask.convert("L"), dtype=np.uint8)
            kernel_size = max(3, radius * 2 + 1)
            kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
            arr = cv2.dilate(arr, kernel, iterations=1)
            return Image.fromarray(arr, "L")
        return mask.filter(ImageFilter.MaxFilter(size=max(3, radius * 2 + 1)))

    def apply_white_lasso(self):
        self.white_lasso_active = False
        if len(self.white_lasso_path) < 3:
            self.white_lasso_path = []
            self.clear_white_lasso_preview()
            return
        self.push_undo()
        mask = Image.new("L", self.image.size, 0)
        draw = ImageDraw.Draw(mask)
        draw.polygon(self.white_lasso_path, fill=255)
        mask = self.expand_cleanup_mask(mask)
        mask = mask.filter(ImageFilter.GaussianBlur(radius=0.8))
        mask = self.apply_cleanup_opacity_to_mask(mask)
        base = self.image.convert("RGB")
        textured_fill = self.render_cleanup_fill_with_texture(base, mask)
        self.image = Image.composite(textured_fill, base, mask)
        self.white_lasso_path = []
        self.clear_white_lasso_preview()
        self.refresh_canvas()
        self.set_status(self.t("white_cleanup_done"))

    def apply_transparent_lasso(self):
        self.white_lasso_active = False
        if len(self.white_lasso_path) < 3:
            self.white_lasso_path = []
            self.clear_white_lasso_preview()
            return
        self.push_undo()
        mask = Image.new("L", self.image.size, 0)
        draw = ImageDraw.Draw(mask)
        draw.polygon(self.white_lasso_path, fill=255)
        mask = self.expand_cleanup_mask(mask)
        mask = mask.filter(ImageFilter.GaussianBlur(radius=0.9))
        mask = self.apply_cleanup_opacity_to_mask(mask)

        rgba = self.image.convert("RGBA")
        alpha = rgba.getchannel("A")
        alpha = ImageChops.subtract(alpha, mask)
        rgba.putalpha(alpha)
        self.image = rgba

        if self.bg_placement_active and self.bg_placement_subject is not None:
            self.apply_lasso_mask_to_background_subject(mask)

        self.white_lasso_path = []
        self.clear_white_lasso_preview()
        self.refresh_canvas()
        self.set_status(self.t("transparent_cleanup_done"))

    def apply_recover_lasso(self):
        self.white_lasso_active = False
        restore = getattr(self, "last_cutout_restore_layer", None)
        if restore is None or self.image is None or restore.size != self.image.size:
            self.white_lasso_path = []
            self.clear_white_lasso_preview()
            self.set_status(self.t("recover_source_missing"))
            return
        if len(self.white_lasso_path) < 3:
            self.white_lasso_path = []
            self.clear_white_lasso_preview()
            return
        self.push_undo()
        mask = Image.new("L", self.image.size, 0)
        draw = ImageDraw.Draw(mask)
        draw.polygon(self.white_lasso_path, fill=255)
        mask = self.expand_cleanup_mask(mask)
        mask = mask.filter(ImageFilter.GaussianBlur(radius=0.9))
        mask = self.apply_cleanup_opacity_to_mask(mask)

        restore_rgba = restore.convert("RGBA")
        restore_alpha = restore_rgba.getchannel("A")
        effective_mask = ImageChops.multiply(mask, restore_alpha)
        if max(effective_mask.getextrema()) <= 0:
            self.white_lasso_path = []
            self.clear_white_lasso_preview()
            self.set_status(self.t("recover_source_missing"))
            return
        base = self.image.convert("RGBA")
        recovered = Image.composite(restore_rgba, base, effective_mask)
        self.image = recovered
        self.white_lasso_path = []
        self.clear_white_lasso_preview()
        self.refresh_canvas()
        self.set_status(self.t("recover_cleanup_done"))

    def apply_lasso_mask_to_background_subject(self, mask):
        try:
            subject = self.get_bg_placement_subject_resized()
            if subject is None:
                return
            sx = int(round(self.bg_placement_subject_x))
            sy = int(round(self.bg_placement_subject_y))
            crop_box = (sx, sy, sx + subject.width, sy + subject.height)
            local = mask.crop(crop_box)
            if max(local.getextrema()) <= 0:
                return
            subject_rgba = subject.convert("RGBA")
            alpha = ImageChops.subtract(subject_rgba.getchannel("A"), local)
            subject_rgba.putalpha(alpha)
            self.bg_placement_subject = subject_rgba
            self.bg_placement_subject_base_scale = 1.0
            self.bg_subject_size_value.set(100)
            self.bg_subject_rotation_value.set(0)
            self.invalidate_bg_placement_subject_cache()
            self.image = self.render_background_placement_image()
        except Exception:
            pass

    def build_skin_mask(self, img):
        """Portrait-oriented skin mask inspired by professional retouching workflows.
        It scores likely skin pixels by colour, then softens/reduces the mask near
        strong edges instead of cutting them out completely. This keeps the tool
        visible on cheeks/arms/body while still reducing hits on eyes, hair, latex
        and clothing seams."""
        base = img.convert("RGB")
        if HAS_CV2:
            arr = self.pil_to_cv_rgb(base)
            ycrcb = cv2.cvtColor(arr, cv2.COLOR_RGB2YCrCb)
            hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV)
            r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
            y, cr, cb = ycrcb[:, :, 0], ycrcb[:, :, 1], ycrcb[:, :, 2]
            h, s, v = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]

            ycrcb_mask = (y >= 36) & (y <= 250) & (cr >= 124) & (cr <= 190) & (cb >= 66) & (cb <= 154)
            hsv_mask = (((h <= 42) | (h >= 166)) & (s >= 5) & (s <= 218) & (v >= 34) & (v <= 254))
            rgb_mask = (r > 32) & (g > 18) & (b > 12) & (r >= (b - 10)) & ((r - g) < 120) & ((r - b) < 155)
            not_neon_or_latex = (s < 225) & ((np.maximum.reduce([r, g, b]) - np.minimum.reduce([r, g, b])) < 185)
            color_mask = ((ycrcb_mask & rgb_mask) | (ycrcb_mask & hsv_mask)) & not_neon_or_latex

            gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
            edges = cv2.Laplacian(gray, cv2.CV_16S, ksize=3)
            edges = cv2.convertScaleAbs(edges).astype(np.float32)
            # Soft edge protection: strong detail gets less effect, but skin beside an edge is not lost.
            edge_weight = np.clip(1.0 - ((edges - 34.0) / 90.0), 0.0, 1.0)
            edge_weight = cv2.GaussianBlur(edge_weight, (0, 0), 0.8)
            mask_f = color_mask.astype(np.float32) * edge_weight
            mask_u = np.clip(mask_f * 255.0, 0, 255).astype(np.uint8)

            kernel = np.ones((3, 3), np.uint8)
            mask_u = cv2.morphologyEx(mask_u, cv2.MORPH_CLOSE, kernel, iterations=1)
            mask_u = cv2.GaussianBlur(mask_u, (0, 0), 1.2)
            return Image.fromarray(mask_u, "L")

        # Pillow fallback if OpenCV is unavailable.
        y_channel, cb_channel, cr_channel = base.convert("YCbCr").split()
        y_mask = y_channel.point(lambda v: 255 if 45 <= v <= 245 else 0)
        cb_mask = cb_channel.point(lambda v: 255 if 76 <= v <= 146 else 0)
        cr_mask = cr_channel.point(lambda v: 255 if 126 <= v <= 186 else 0)
        mask = ImageChops.multiply(ImageChops.multiply(y_mask, cb_mask), cr_mask)
        edge_mask = base.convert("L").filter(ImageFilter.FIND_EDGES).filter(ImageFilter.GaussianBlur(radius=0.7))
        edge_keep = edge_mask.point(lambda v: 255 if v < 60 else max(0, 255 - int((v-60)*3)))
        mask = ImageChops.multiply(mask, edge_keep)
        return mask.filter(ImageFilter.MedianFilter(size=3)).filter(ImageFilter.GaussianBlur(radius=1.0))

    def build_skin_tone_mask(self, img):
        """Stricter mask for Auto teint / skin tone changes.
        This deliberately rejects neutral white/grey backgrounds that can pass a
        broad skin detector. It still accepts pale skin when it has a warm/red
        skin signal, but it will not recolor flat white studio backdrops."""
        base = img.convert("RGB")
        if HAS_CV2:
            arr = self.pil_to_cv_rgb(base)
            ycrcb = cv2.cvtColor(arr, cv2.COLOR_RGB2YCrCb)
            hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV)
            r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
            y, cr, cb = ycrcb[:, :, 0], ycrcb[:, :, 1], ycrcb[:, :, 2]
            h, s, v = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]
            chroma = np.maximum.reduce([r, g, b]) - np.minimum.reduce([r, g, b])

            ycrcb_mask = (y >= 42) & (y <= 245) & (cr >= 132) & (cr <= 184) & (cb >= 76) & (cb <= 144)
            hue_skin = ((h <= 28) | (h >= 170)) & (s >= 10) & (s <= 205)
            warm_rgb = (r >= (g - 3)) & (r >= (b + 4)) & ((r - g) < 95) & ((r - b) < 135)
            warm_chroma = (cr >= (cb + 7)) & (chroma >= 13)

            # Critical protection: pure or near-pure white/grey backgrounds are neutral,
            # so they must never be treated as skin by the tone tool.
            neutral_highlight = (v >= 195) & (s <= 30) & (chroma <= 30)
            flat_neutral = (s <= 12) & (chroma <= 18)
            too_bright_flat = (y >= 222) & (chroma <= 36)

            color_mask = ycrcb_mask & warm_rgb & (hue_skin | warm_chroma) & ~(neutral_highlight | flat_neutral | too_bright_flat)

            gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
            edges = cv2.Laplacian(gray, cv2.CV_16S, ksize=3)
            edges = cv2.convertScaleAbs(edges).astype(np.float32)
            edge_weight = np.clip(1.0 - ((edges - 34.0) / 90.0), 0.0, 1.0)
            edge_weight = cv2.GaussianBlur(edge_weight, (0, 0), 0.8)
            mask_u = np.clip(color_mask.astype(np.float32) * edge_weight * 255.0, 0, 255).astype(np.uint8)
            mask_u = cv2.morphologyEx(mask_u, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8), iterations=1)
            mask_u = cv2.GaussianBlur(mask_u, (0, 0), 1.0)
            return Image.fromarray(mask_u, "L")

        # Pillow fallback: start from the broad mask, then remove neutral whites/greys.
        mask = self.build_skin_mask(base).convert("L")
        mp = mask.load()
        px = base.load()
        for yy in range(base.height):
            for xx in range(base.width):
                r, g, b = px[xx, yy]
                mx, mn = max(r, g, b), min(r, g, b)
                chroma = mx - mn
                lum = 0.299*r + 0.587*g + 0.114*b
                warm = (r >= g - 3) and (r >= b + 4) and ((r - b) < 135)
                if (mx >= 195 and chroma <= 30) or (lum >= 222 and chroma <= 36) or (chroma <= 18) or not warm:
                    mp[xx, yy] = 0
        return mask.filter(ImageFilter.GaussianBlur(radius=0.8))

    def mask_to_np_alpha(self, mask, strength_scale=1.0):
        if mask is None:
            return None
        arr = np.array(mask.convert("L"), dtype=np.float32) / 255.0
        return np.clip(arr * float(strength_scale), 0.0, 1.0)[:, :, None]

    def preserve_alpha(self, result_rgb, original):
        if "A" in original.getbands():
            result_rgb = result_rgb.convert("RGBA")
            result_rgb.putalpha(original.getchannel("A"))
        return result_rgb

    def ensure_skin_selection_mask(self):
        if self.image is None:
            return None
        if self.skin_selection_mask is None or self.skin_selection_mask.size != self.image.size:
            self.skin_selection_mask = Image.new("L", self.image.size, 0)
        return self.skin_selection_mask

    def paint_skin_selection(self, x, y, erase=False):
        if not self.require_image():
            return
        mask = self.ensure_skin_selection_mask()
        radius = max(4, int(self.brush_size.get()))
        from PIL import ImageDraw
        draw = ImageDraw.Draw(mask)
        box = (x - radius, y - radius, x + radius, y + radius)
        draw.ellipse(box, fill=0 if erase else 255)
        self.skin_selection_mask = mask
        self.refresh_canvas()
        self.set_status(self.t("skin_makeup_area_painted"))

    def average_color_in_circle(self, x, y, radius=None):
        if self.image is None:
            return (255, 255, 255)
        img = self.image.convert("RGB")
        if radius is None:
            radius = max(4, min(36, int(max(1, self.brush_size.get()) * 0.45)))
        radius = max(1, int(radius))
        left = max(0, int(x) - radius)
        top = max(0, int(y) - radius)
        right = min(img.width - 1, int(x) + radius)
        bottom = min(img.height - 1, int(y) + radius)
        pix = img.load()
        total_r = total_g = total_b = count = 0
        radius_sq = radius * radius
        for yy in range(top, bottom + 1):
            dy = yy - int(y)
            for xx in range(left, right + 1):
                dx = xx - int(x)
                if dx * dx + dy * dy > radius_sq:
                    continue
                r, g, b = pix[xx, yy]
                total_r += r
                total_g += g
                total_b += b
                count += 1
        if count <= 0:
            return pix[max(0, min(img.width - 1, int(x))), max(0, min(img.height - 1, int(y)))]
        return (int(round(total_r / count)), int(round(total_g / count)), int(round(total_b / count)))

    def clear_skin_selection(self):
        self.skin_selection_mask = None
        self.clear_crop_rect()
        self.refresh_canvas()
        self.set_status(self.t("skin_makeup_area_cleared"))

    def render_auto_skin_beauty(self, img, intensity):
        # Keep legacy button name, but use the newer structure-preserving skin engine.
        return self.render_perfect_skin_global(img, intensity)

    def auto_skin_beauty(self):
        if not self.require_image():
            return
        self.push_undo()
        self.stop_other_modes()
        self.image = self.render_auto_skin_beauty(self.image, self.skin_beauty_strength.get())
        self.refresh_canvas()
        self.set_status(self.t("skin_beauty_done"))

    def render_skin_makeup_finish(self, img, intensity, selection_mask=None):
        """Even out visible skin tones with a soft makeup-like finish.
        The effect uses a conservative skin mask and, optionally, a persistent
        user-painted mask so clothes/latex/hair/background are avoided."""
        base = img.convert("RGB")
        try:
            level = max(0, min(5, int(float(intensity))))
        except Exception:
            level = 1
        if level <= 0:
            return img.copy()

        strength = level / 5.0
        blur_radius = 0.45 + strength * 1.15
        color_radius = 3.0 + strength * 8.0
        blend_amount = 0.06 + strength * 0.22
        makeup_mix = 0.08 + strength * 0.18

        skin_mask = self.build_skin_mask(base)
        skin_mask = self.apply_subject_mask_to_skin_mask(skin_mask, base, external_mask=selection_mask)

        texture_soft = base.filter(ImageFilter.MedianFilter(size=3)).filter(ImageFilter.GaussianBlur(radius=blur_radius))
        color_wash = base.filter(ImageFilter.GaussianBlur(radius=color_radius))
        makeup = Image.blend(texture_soft, color_wash, makeup_mix)
        makeup = ImageEnhance.Brightness(makeup).enhance(1.001 + strength * 0.010)
        makeup = ImageEnhance.Color(makeup).enhance(0.998 + strength * 0.006)

        skin_mask = skin_mask.filter(ImageFilter.GaussianBlur(radius=0.8))
        skin_mask = skin_mask.point(lambda v: max(0, min(255, int(v * blend_amount))))

        result = Image.composite(makeup, base, skin_mask)
        if "A" in img.getbands():
            result = result.convert("RGBA")
            result.putalpha(img.getchannel("A"))
        return result

    def apply_skin_makeup_all(self):
        if not self.require_image():
            return
        self.push_undo()
        self.stop_other_modes()
        self.image = self.render_skin_makeup_finish(self.image, self.skin_makeup_strength.get())
        self.refresh_canvas()
        self.set_status(self.t("skin_makeup_done"))

    def apply_skin_makeup_selected(self):
        if not self.require_image():
            return
        if self.skin_selection_mask is None or self.skin_selection_mask.getbbox() is None:
            self.set_status(self.t("skin_makeup_area_empty"))
            return
        self.push_undo()
        self.stop_other_modes()
        self.image = self.render_skin_makeup_finish(
            self.image,
            self.skin_makeup_strength.get(),
            selection_mask=self.skin_selection_mask.copy()
        )
        self.refresh_canvas()
        self.set_status(self.t("skin_makeup_done"))

    def set_brush_size_preset(self, value):
        try:
            self.brush_size.set(int(value))
        except Exception:
            pass
        self.hide_brush_cursor()
        self.set_status(f"{self.t('brush_size')} : {int(value)}")

    def begin_skin_auto_session(self, kind):
        if self.image is None:
            return False
        # v86: each click is cumulative and builds on the previous correction.
        # We still keep the field reset so old preview-style base images do not interfere.
        self.push_undo()
        self.skin_auto_base_image = None
        self.skin_auto_kind = None
        return True

    def render_perfect_skin_global(self, img, intensity, external_mask=None, protect_fabric_texture=True):
        """Professional-looking skin enhancement using frequency-style retouching.
        The target is a mix of edge-aware smoothing and low-frequency colour
        unification, with a little texture preserved so the result does not look
        plastic or blurry."""
        base_pil = img.convert("RGB")
        try:
            level = max(0, min(5, int(float(intensity))))
        except Exception:
            level = 3
        if level <= 0:
            return img.copy()

        strength = level / 5.0
        skin_mask = self.build_skin_mask(base_pil)
        skin_mask = self.apply_subject_mask_to_skin_mask(skin_mask, base_pil, external_mask=external_mask)

        if HAS_CV2:
            arr = self.pil_to_cv_rgb(base_pil).astype(np.float32)
            arr8 = np.clip(arr, 0, 255).astype(np.uint8)

            # Structure-preserving retouch: keep most luminance/shape from the
            # original face. Only micro-texture and small colour unevenness are
            # softened. This prevents the nose, cheekbones and facial volumes
            # from disappearing on the first click.
            diameter = 9 + int(strength * 3) * 2
            bilateral_rgb = cv2.bilateralFilter(arr8, diameter, 38 + strength * 52, 14 + strength * 24)
            lab_original = cv2.cvtColor(arr8, cv2.COLOR_RGB2LAB).astype(np.float32)
            lab_smooth = cv2.cvtColor(bilateral_rgb, cv2.COLOR_RGB2LAB).astype(np.float32)
            lab_colour = cv2.GaussianBlur(lab_smooth, (0, 0), 1.6 + strength * 2.8)

            # Balises pro: keep facial volumes. Luminance/shape moves very little,
            # colour unevenness and micro-texture can move more visibly.
            lum_mix = 0.095 + strength * 0.145
            chroma_mix = 0.50 + strength * 0.40
            target_lab = lab_original.copy()
            target_lab[:, :, 0] = lab_original[:, :, 0] * (1.0 - lum_mix) + lab_smooth[:, :, 0] * lum_mix
            target_lab[:, :, 1] = lab_original[:, :, 1] * (1.0 - chroma_mix) + lab_colour[:, :, 1] * chroma_mix
            target_lab[:, :, 2] = lab_original[:, :, 2] * (1.0 - chroma_mix) + lab_colour[:, :, 2] * chroma_mix
            target = cv2.cvtColor(np.clip(target_lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB).astype(np.float32)

            # Re-inject fine detail so skin becomes cleaner without becoming
            # wax/plastic.
            micro_blur = cv2.GaussianBlur(arr, (0, 0), 0.78 + strength * 0.30)
            detail = arr - micro_blur
            target = np.clip(target + detail * (0.22 - strength * 0.035), 0, 255)
            target = np.clip(target * (1.003 + strength * 0.006) + (0.6 + strength * 1.1), 0, 255)

            # Visible on skin, but not destructive. The alpha can be strong on
            # flat cheeks/arms/body, then the guard masks reduce it on the nose,
            # cheekbones, lips, eyes and strong shadows.
            alpha = self.mask_to_np_alpha(skin_mask, 0.84 + strength * 0.46)

            # Balises / garde-fous for facial structure:
            # 1) fine edges: eyes, lashes, mouth, nostrils, hairline, latex seams;
            # 2) low-frequency face volumes: nose bridge, cheekbones, jaw shadows.
            gray = cv2.cvtColor(arr8, cv2.COLOR_RGB2GRAY).astype(np.float32)
            gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
            gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
            grad = cv2.magnitude(gx, gy)
            fine_guard = np.clip(1.0 - ((grad - 10.0) / 58.0), 0.08, 1.0)

            volume_gray = cv2.GaussianBlur(gray, (0, 0), 2.4)
            vgx = cv2.Sobel(volume_gray, cv2.CV_32F, 1, 0, ksize=3)
            vgy = cv2.Sobel(volume_gray, cv2.CV_32F, 0, 1, ksize=3)
            volume_grad = cv2.magnitude(vgx, vgy)
            volume_guard = np.clip(1.0 - ((volume_grad - 3.5) / 30.0), 0.46, 1.0)

            structure_keep = cv2.GaussianBlur(np.minimum(fine_guard, volume_guard), (0, 0), 0.8)[:, :, None]
            # Protect fine fabric/nylon texture on legs. Skin-coloured stockings
            # can pass the skin mask, so high-frequency texture must reduce the
            # smoothing instead of being erased.
            if protect_fabric_texture:
                texture_base = cv2.GaussianBlur(gray, (0, 0), 1.05)
                texture_detail = cv2.GaussianBlur(np.abs(gray - texture_base), (0, 0), 0.55)
                texture_guard = np.clip(1.0 - ((texture_detail - 2.8) / 18.0), 0.34, 1.0)[:, :, None]
            else:
                texture_guard = 1.0
            alpha = alpha * structure_keep * texture_guard

            result = arr * (1.0 - alpha) + target * alpha
            return self.cv_rgb_to_pil(result, img)

        # Pillow fallback: very light texture smoothing only, with lower opacity.
        texture_soft = base_pil.filter(ImageFilter.MedianFilter(size=3)).filter(ImageFilter.GaussianBlur(radius=0.28 + strength * 0.48))
        color_wash = base_pil.filter(ImageFilter.GaussianBlur(radius=1.15 + strength * 2.1))
        target = Image.blend(texture_soft, color_wash, 0.12 + strength * 0.16)
        target = ImageEnhance.Brightness(target).enhance(1.002 + strength * 0.005)
        target = ImageEnhance.Color(target).enhance(1.0 + strength * 0.004)
        skin_mask = skin_mask.point(lambda v: max(0, min(255, int(v * (0.46 + strength * 0.32)))))
        result = Image.composite(target, base_pil, skin_mask)
        return self.preserve_alpha(result, img)

    def render_skin_tone_global(self, img, shift, external_mask=None):
        """Visible but natural skin-tone tool.
        Negative = lighter/porcelain. Positive = warmer/tanned. The change is
        based on the model's detected skin, so pale, dark, warm and cool skin
        tones move gradually without becoming orange or sunburned."""
        base_pil = img.convert("RGB")
        try:
            shift = int(float(shift))
        except Exception:
            shift = 0
        if shift == 0:
            return img.copy()

        strength = min(1.0, abs(shift) / 5.0)
        skin_mask = self.build_skin_tone_mask(base_pil)
        skin_mask = self.apply_subject_mask_to_skin_mask(skin_mask, base_pil, external_mask=external_mask)

        if HAS_CV2:
            arr = self.pil_to_cv_rgb(base_pil).astype(np.float32)
            lab = cv2.cvtColor(np.clip(arr, 0, 255).astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32)
            mask_arr = np.array(skin_mask.convert("L"), dtype=np.float32) / 255.0
            active = mask_arr > 0.14
            if not np.any(active):
                return img.copy()

            l = lab[:, :, 0]
            a = lab[:, :, 1]
            b = lab[:, :, 2]
            ref_l = float(np.median(l[active]))
            ref_a = float(np.median(a[active]))
            ref_b = float(np.median(b[active]))
            target = lab.copy()
            if shift > 0:
                # Healthy foundation/bronze: slightly deeper and warmer, with a
                # ceiling tied to the detected skin so pale skin does not turn orange.
                target_l = np.clip(ref_l - (2.0 + 8.0 * strength), 35, 245)
                target_a = np.clip(ref_a + (0.6 + 2.6 * strength), 112, 151)
                target_b = np.clip(ref_b + (1.0 + 4.8 * strength), 112, 159)
                target[:, :, 0] = l * 0.72 + target_l * 0.28
                target[:, :, 1] = a * 0.70 + target_a * 0.30
                target[:, :, 2] = b * 0.66 + target_b * 0.34
            else:
                # Porcelain/cooler: lighten and neutralize redness, but keep the
                # person's own pigmentation instead of flattening everyone to white.
                target_l = np.clip(ref_l + (5.0 + 16.0 * strength), 45, 252)
                target_a = np.clip(ref_a - (0.9 + 4.8 * strength), 111, 148)
                target_b = np.clip(ref_b - (1.4 + 6.6 * strength), 108, 154)
                target[:, :, 0] = l * 0.72 + target_l * 0.28
                target[:, :, 1] = a * 0.70 + target_a * 0.30
                target[:, :, 2] = b * 0.68 + target_b * 0.32
            target_rgb = cv2.cvtColor(np.clip(target, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB).astype(np.float32)
            alpha = self.mask_to_np_alpha(skin_mask, 0.16 + strength * 0.22)
            result = arr * (1.0 - alpha) + target_rgb * alpha
            return self.cv_rgb_to_pil(result, img)

        # Pillow fallback.
        base = base_pil.copy()
        bp = base.load()
        mp = skin_mask.load()
        for yy in range(base.height):
            for xx in range(base.width):
                m = mp[xx, yy] / 255.0
                if m <= 0:
                    continue
                r, g, b = bp[xx, yy]
                lum = 0.299*r + 0.587*g + 0.114*b
                if lum < 38:
                    continue
                amt = m * (0.14 + strength * 0.24)
                if shift > 0:
                    tr = min(255, int(r * (1.0 + 0.004 * strength) + 5 * strength))
                    tg = min(255, int(g * (1.0 + 0.003 * strength) + 3 * strength))
                    tb = min(255, int(b * (1.0 + 0.001 * strength) + 1 * strength))
                else:
                    tr = min(255, int(r * (1.0 + 0.040 * strength) + 28 * strength))
                    tg = min(255, int(g * (1.0 + 0.040 * strength) + 28 * strength))
                    tb = min(255, int(b * (1.0 + 0.045 * strength) + 30 * strength))
                bp[xx, yy] = (int(r * (1-amt) + tr * amt), int(g * (1-amt) + tg * amt), int(b * (1-amt) + tb * amt))
        return self.preserve_alpha(base, img)

    def render_face_porcelain_balance(self, img, face_mask, intensity):
        """Unify face skin and lift beard-like lower-face shadows.
        This is intentionally face-only: it protects eyes, lips, nose edges and
        mouth detail while evening soft skin shadows for a light porcelain finish."""
        base_pil = img.convert("RGB")
        try:
            level = max(1, min(5, int(float(intensity))))
        except Exception:
            level = 4
        strength = level / 5.0

        skin_mask = self.build_skin_mask(base_pil)
        if face_mask is not None:
            if face_mask.size != skin_mask.size:
                face_mask = face_mask.resize(skin_mask.size, Image.Resampling.BILINEAR)
            skin_mask = ImageChops.multiply(skin_mask.convert("L"), face_mask.convert("L"))

        if HAS_CV2:
            arr = self.pil_to_cv_rgb(base_pil).astype(np.float32)
            arr8 = np.clip(arr, 0, 255).astype(np.uint8)
            mask = np.array(skin_mask.convert("L"), dtype=np.float32) / 255.0
            active = mask > 0.16
            if not np.any(active):
                return img.copy()

            lab = cv2.cvtColor(arr8, cv2.COLOR_RGB2LAB).astype(np.float32)
            l = lab[:, :, 0]
            a = lab[:, :, 1]
            b = lab[:, :, 2]
            skin_l = l[active]
            median_l = float(np.median(skin_l))
            target_l = float(np.percentile(skin_l, 62))

            ys, xs = np.where(active)
            y_min, y_max = int(np.min(ys)), int(np.max(ys))
            face_h = max(1, y_max - y_min)
            y_grid = np.arange(arr.shape[0], dtype=np.float32)[:, None]
            lower_face = np.clip((y_grid - (y_min + face_h * 0.42)) / max(1.0, face_h * 0.46), 0.0, 1.0)

            gray = cv2.cvtColor(arr8, cv2.COLOR_RGB2GRAY).astype(np.float32)
            gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
            gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
            grad = cv2.magnitude(gx, gy)
            edge_guard = np.clip(1.0 - ((grad - 8.0) / 46.0), 0.12, 1.0)
            edge_guard = cv2.GaussianBlur(edge_guard, (0, 0), 0.65)

            hsv = cv2.cvtColor(arr8, cv2.COLOR_RGB2HSV)
            sat = hsv[:, :, 1].astype(np.float32)
            lum_shadow = np.clip((median_l + 4.0 - l) / 42.0, 0.0, 1.0)
            neutral_shadow = np.clip(1.0 - sat / 115.0, 0.0, 1.0)
            beard_shadow = lum_shadow * (0.48 + lower_face * 0.72) * neutral_shadow

            lift = (target_l - l) * np.clip(beard_shadow, 0.0, 1.0) * (0.28 + strength * 0.26)
            soft_l = cv2.GaussianBlur(l, (0, 0), 1.2 + strength * 1.7)
            porcelain_lift = (soft_l - l) * (0.08 + strength * 0.12)
            lab[:, :, 0] = np.clip(l + (lift + porcelain_lift) * mask * edge_guard, 0, 255)

            # Gently pull face skin chroma toward its own average, not toward orange.
            a_med = float(np.median(a[active]))
            b_med = float(np.median(b[active]))
            chroma_unify = mask * edge_guard * (0.14 + strength * 0.18)
            lab[:, :, 1] = a * (1.0 - chroma_unify) + a_med * chroma_unify
            lab[:, :, 2] = b * (1.0 - chroma_unify) + b_med * chroma_unify

            result = cv2.cvtColor(np.clip(lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB).astype(np.float32)
            # Reinject fine detail over protected edges so eyes, lips, nostrils and nose bridge stay crisp.
            blur = cv2.GaussianBlur(arr, (0, 0), 0.7)
            detail = arr - blur
            result = np.clip(result + detail * (0.22 + (1.0 - edge_guard[:, :, None]) * 0.35), 0, 255)
            alpha = np.clip(mask[:, :, None] * edge_guard[:, :, None] * (0.55 + strength * 0.22), 0.0, 1.0)
            out = arr * (1.0 - alpha) + result * alpha
            return self.cv_rgb_to_pil(out, img)

        soft = base_pil.filter(ImageFilter.GaussianBlur(radius=1.4 + strength * 1.8))
        target = Image.blend(base_pil, soft, 0.18 + strength * 0.18)
        mask = skin_mask.filter(ImageFilter.GaussianBlur(radius=1.0))
        mask = mask.point(lambda v: max(0, min(255, int(v * (0.34 + strength * 0.18)))))
        return self.preserve_alpha(Image.composite(target, base_pil, mask), img)

    def apply_perfect_skin_auto_face(self):
        if not self.require_image():
            return
        if not self.begin_skin_auto_session("perfect_face"):
            return
        self.stop_other_modes()
        face_mask = self.build_face_focus_mask(self.image)
        level = self.percent_to_skin_level(self.skin_face_strength, 75)
        if level <= 0:
            self.set_status(self.t("skin_auto_face_done"))
            return
        face_base = self.render_perfect_skin_global(self.image, level, external_mask=face_mask)
        self.image = self.render_face_porcelain_balance(face_base, face_mask, level)
        self.refresh_canvas()
        self.set_status(self.t("skin_auto_face_done"))

    def apply_perfect_skin_auto_body(self):
        if not self.require_image():
            return
        if not self.begin_skin_auto_session("perfect"):
            return
        self.stop_other_modes()
        body_mask = self.build_body_focus_mask(self.image)
        level = self.percent_to_skin_level(self.skin_body_strength, 60)
        if level <= 0:
            self.set_status(self.t("skin_auto_done"))
            return
        self.image = self.render_perfect_skin_global(self.image, level, external_mask=body_mask)
        self.refresh_canvas()
        self.set_status(self.t("skin_auto_done"))

    def apply_perfect_skin_auto_legs(self):
        if not self.require_image():
            return
        if not self.begin_skin_auto_session("perfect_legs"):
            return
        self.stop_other_modes()
        legs_mask = self.build_legs_focus_mask(self.image)
        level = self.percent_to_skin_level(self.skin_legs_strength, 55)
        if level <= 0:
            self.set_status(self.t("skin_auto_legs_done"))
            return
        self.image = self.render_perfect_skin_global(self.image, level, external_mask=legs_mask, protect_fabric_texture=False)
        self.refresh_canvas()
        self.set_status(self.t("skin_auto_legs_done"))

    def adjust_perfect_skin_auto(self, delta):
        if not self.require_image():
            return
        value = max(0, min(100, int(self.skin_body_strength.get()) + int(delta) * 20))
        self.skin_body_strength.set(value)
        self.apply_perfect_skin_auto_body()

    def apply_skin_tone_auto_face(self):
        if not self.require_image():
            return
        if not self.begin_skin_auto_session("tone_face"):
            return
        self.stop_other_modes()
        level = self.percent_to_skin_level(self.skin_face_strength, 75)
        if level <= 0:
            self.set_status(self.t("skin_tone_auto_face_done"))
            return
        face_mask = self.build_face_focus_mask(self.image)
        shift = max(1, min(4, int(round(level / 1.55))))
        self.image = self.render_skin_tone_global(self.image, shift, external_mask=face_mask)
        self.refresh_canvas()
        self.set_status(self.t("skin_tone_auto_face_done"))

    def apply_skin_tone_auto_body(self):
        if not self.require_image():
            return
        if not self.begin_skin_auto_session("tone"):
            return
        self.stop_other_modes()
        level = self.percent_to_skin_level(self.skin_body_strength, 60)
        if level <= 0:
            self.set_status(self.t("skin_tone_auto_done"))
            return
        body_mask = self.build_body_focus_mask(self.image)
        shift = max(1, min(4, int(round(level / 1.55))))
        self.image = self.render_skin_tone_global(self.image, shift, external_mask=body_mask)
        self.refresh_canvas()
        self.set_status(self.t("skin_tone_auto_done"))

    def apply_skin_tone_auto_legs(self):
        if not self.require_image():
            return
        if not self.begin_skin_auto_session("tone_legs"):
            return
        self.stop_other_modes()
        level = self.percent_to_skin_level(self.skin_legs_strength, 55)
        if level <= 0:
            self.set_status(self.t("skin_tone_auto_legs_done"))
            return
        legs_mask = self.build_legs_focus_mask(self.image)
        shift = max(1, min(4, int(round(level / 1.55))))
        self.image = self.render_skin_tone_global(self.image, shift, external_mask=legs_mask)
        self.refresh_canvas()
        self.set_status(self.t("skin_tone_auto_legs_done"))

    def adjust_skin_tone_auto(self, delta):
        if not self.require_image():
            return
        value = max(0, min(100, int(self.skin_body_strength.get()) + int(delta) * 20))
        self.skin_body_strength.set(value)
        self.apply_skin_tone_auto_body()

    def build_radial_brush_mask(self, width, height, center_x, center_y, radius, softness=0.65):
        mask = Image.new("L", (width, height), 0)
        pix = mask.load()
        soft_start = max(0.05, min(0.98, softness))
        for yy in range(height):
            for xx in range(width):
                d = math.sqrt((xx-center_x)**2 + (yy-center_y)**2)
                if d > radius:
                    continue
                ratio = d / max(1, radius)
                if ratio <= soft_start:
                    value = 255
                else:
                    fall = 1.0 - ((ratio - soft_start) / max(0.001, 1.0 - soft_start))
                    value = int(255 * max(0.0, min(1.0, fall)))
                pix[xx, yy] = value
        return mask.filter(ImageFilter.GaussianBlur(radius=max(0.8, radius / 10)))

    def perfect_skin_at(self, x, y):
        radius = max(6, int(self.brush_size.get()))
        left, top = max(0, x-radius), max(0, y-radius)
        right, bottom = min(self.image.width, x+radius), min(self.image.height, y+radius)
        region_orig = self.image.crop((left, top, right, bottom))
        cx, cy = x - left, y - top
        radial = self.build_radial_brush_mask(region_orig.width, region_orig.height, cx, cy, radius, softness=0.72)
        result = self.render_perfect_skin_global(region_orig, self.skin_perfect_strength.get(), external_mask=radial)
        self.image.paste(result, (left, top))
        self.refresh_canvas()

    def skin_tone_at(self, x, y):
        """Uniformiser zone de peau.
        Corrects a bruise/mark/local discoloration by sampling nearby skin in a
        short ring around the brush circle. It mostly changes local colour (Lab a/b)
        and only slightly changes luminance so skin texture is preserved."""
        if not self.require_image():
            return
        radius = max(6, int(self.brush_size.get()))
        ring = max(5, min(22, int(radius * 0.22)))
        context = radius + ring + 3
        left, top = max(0, x-context), max(0, y-context)
        right, bottom = min(self.image.width, x+context), min(self.image.height, y+context)
        region_orig = self.image.crop((left, top, right, bottom))
        base = region_orig.convert("RGB")
        cx, cy = x - left, y - top
        try:
            level = max(1, min(5, int(float(self.skin_even_strength.get()))))
        except Exception:
            level = 3
        strength = level / 5.0

        if HAS_CV2:
            arr = self.pil_to_cv_rgb(base).astype(np.float32)
            arr8 = np.clip(arr, 0, 255).astype(np.uint8)
            h, w = arr8.shape[:2]
            yy, xx = np.mgrid[0:h, 0:w]
            dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
            inside = dist <= radius
            # Short sampling ring: close enough to represent nearby skin, not clothes/fond.
            ring_mask = (dist >= radius + 2) & (dist <= radius + ring + 2)
            skin_ring = np.array(self.build_skin_tone_mask(base), dtype=np.uint8) > 18
            ring_mask &= skin_ring

            # Remove obvious edges from sample ring, because seams/hair/latex edges distort the colour.
            gray = cv2.cvtColor(arr8, cv2.COLOR_RGB2GRAY)
            edge = cv2.Canny(gray, 45, 115)
            ring_mask &= (edge == 0)
            if int(np.count_nonzero(ring_mask)) < 12:
                skin_ring = np.array(self.build_skin_mask(base), dtype=np.uint8) > 30
                ring_mask = (dist >= radius + 1) & (dist <= radius + ring + 4) & skin_ring & (edge == 0)
            if int(np.count_nonzero(ring_mask)) < 8:
                return

            lab = cv2.cvtColor(arr8, cv2.COLOR_RGB2LAB).astype(np.float32)
            target_lab = np.median(lab[ring_mask], axis=0)

            # Radial brush with soft edge.
            radial = np.clip(1.0 - (dist / max(1, radius)), 0.0, 1.0)
            radial = np.power(radial, 0.70)
            radial[~inside] = 0.0

            # Apply mostly chroma correction; keep luminance detail/texture.
            L = lab[:, :, 0]
            A = lab[:, :, 1]
            B = lab[:, :, 2]
            hsv = cv2.cvtColor(arr8, cv2.COLOR_RGB2HSV)
            sat = hsv[:, :, 1].astype(np.float32)
            lum_rgb = cv2.cvtColor(arr8, cv2.COLOR_RGB2GRAY).astype(np.float32)
            protect_non_skin_like = ((sat > 205) | (lum_rgb < 22) | (lum_rgb > 248)).astype(np.float32)
            edge_guard = np.clip(1.0 - (cv2.dilate(edge, np.ones((3,3), np.uint8)).astype(np.float32) / 255.0) * 0.55, 0.35, 1.0)
            alpha = radial * edge_guard * (1.0 - protect_non_skin_like * 0.80) * (0.44 + strength * 0.26)

            # Bruises are often too blue/purple/dark: a/b move more than L.
            L_mix = alpha * (0.10 + strength * 0.08)
            C_mix = alpha
            lab[:, :, 0] = L * (1.0 - L_mix) + target_lab[0] * L_mix
            lab[:, :, 1] = A * (1.0 - C_mix) + target_lab[1] * C_mix
            lab[:, :, 2] = B * (1.0 - C_mix) + target_lab[2] * C_mix

            # Keep fine texture: mix back a little original high-frequency detail in luminance.
            out = cv2.cvtColor(np.clip(lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB).astype(np.float32)
            orig_blur = cv2.GaussianBlur(arr, (0, 0), 0.65)
            detail = arr - orig_blur
            out = np.clip(out + detail * (0.10 + 0.05 * (1.0 - strength)), 0, 255)
            result = self.cv_rgb_to_pil(out, region_orig)
            self.image.paste(result, (left, top))
            self.refresh_canvas()
            return

        # Pillow fallback: sample nearby skin and blend colour, preserving texture.
        skin = self.build_skin_tone_mask(base).convert("L")
        px = base.load(); mp = skin.load()
        samples = []
        for yy in range(base.height):
            for xx in range(base.width):
                d = math.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
                if radius + 1 <= d <= radius + ring + 3 and mp[xx, yy] > 24:
                    samples.append(px[xx, yy])
        if len(samples) < 8:
            return
        target = tuple(sorted([p[i] for p in samples])[len(samples)//2] for i in range(3))
        out = base.copy(); op = out.load()
        for yy in range(base.height):
            for xx in range(base.width):
                d = math.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
                if d > radius:
                    continue
                fade = max(0.0, 1.0 - d / max(1, radius)) ** 0.70
                amt = fade * (0.30 + strength * 0.24)
                r,g,b = op[xx, yy]
                op[xx, yy] = (
                    int(r*(1-amt) + target[0]*amt),
                    int(g*(1-amt) + target[1]*amt),
                    int(b*(1-amt) + target[2]*amt),
                )
        self.image.paste(self.preserve_alpha(out, region_orig), (left, top))
        self.refresh_canvas()

    def background_targeted_at(self, x, y):
        """Correction ciblée de fond.
        Samples a narrow ring just outside the brush and blends the painted area
        toward that nearby background colour. Works on white, black or any fairly
        uniform backdrop, without needing a white/black mode."""
        if not self.require_image():
            return
        radius = max(6, int(self.brush_size.get()))
        ring = max(5, min(24, int(radius * 0.25)))
        context = radius + ring + 3
        left, top = max(0, x-context), max(0, y-context)
        right, bottom = min(self.image.width, x+context), min(self.image.height, y+context)
        region_orig = self.image.crop((left, top, right, bottom))
        base = region_orig.convert("RGB")
        cx, cy = x - left, y - top
        try:
            level = max(1, min(5, int(float(self.bg_targeted_strength.get()))))
        except Exception:
            level = 3
        strength = level / 5.0
        if HAS_CV2:
            arr = self.pil_to_cv_rgb(base).astype(np.float32)
            arr8 = np.clip(arr, 0, 255).astype(np.uint8)
            h, w = arr8.shape[:2]
            yy, xx = np.mgrid[0:h, 0:w]
            dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
            inside = dist <= radius
            ring_mask = (dist >= radius + 2) & (dist <= radius + ring + 3)
            gray = cv2.cvtColor(arr8, cv2.COLOR_RGB2GRAY)
            edge = cv2.Canny(gray, 45, 120)
            ring_mask &= (edge == 0)
            # Reject skin-like samples from the ring so a nearby arm/leg does not become the reference.
            skin = np.array(self.build_skin_mask(base), dtype=np.uint8) > 35
            ring_mask &= ~skin
            if int(np.count_nonzero(ring_mask)) < 10:
                # Fallback: use only non-edge nearby pixels if skin filtering was too strict.
                ring_mask = (dist >= radius + 1) & (dist <= radius + ring + 4) & (edge == 0)
            if int(np.count_nonzero(ring_mask)) < 8:
                return
            sample = arr[ring_mask]
            # Robust median avoids a few pixels from shoes/clothes contaminating the target.
            target = np.median(sample, axis=0)
            smooth = cv2.GaussianBlur(arr, (0, 0), max(1.4, radius * 0.09))
            radial = np.clip(1.0 - dist / max(1, radius), 0.0, 1.0)
            radial = np.power(radial, 0.72)
            radial[~inside] = 0.0
            edge_guard = np.clip(1.0 - (cv2.dilate(edge, np.ones((3,3), np.uint8)).astype(np.float32) / 255.0) * 0.55, 0.35, 1.0)
            alpha = radial * edge_guard * (0.40 + strength * 0.25)
            # Correct colour toward nearby background while preserving some local texture.
            target_img = np.ones_like(arr) * target[None, None, :]
            clean = target_img * 0.68 + smooth * 0.32
            out = arr * (1.0 - alpha[:, :, None]) + clean * alpha[:, :, None]
            result = self.cv_rgb_to_pil(np.clip(out, 0, 255), region_orig)
            self.image.paste(result, (left, top))
            self.refresh_canvas()
            return
        # Pillow fallback.
        px = base.load()
        samples = []
        for yy in range(base.height):
            for xx in range(base.width):
                d = math.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
                if radius + 1 <= d <= radius + ring + 3:
                    samples.append(px[xx, yy])
        if len(samples) < 8:
            return
        target = tuple(sorted([p[i] for p in samples])[len(samples)//2] for i in range(3))
        out = base.copy(); op = out.load()
        for yy in range(base.height):
            for xx in range(base.width):
                d = math.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
                if d > radius:
                    continue
                fade = max(0.0, 1.0 - d / max(1, radius)) ** 0.72
                amt = fade * (0.32 + strength * 0.22)
                r,g,b = op[xx, yy]
                op[xx, yy] = (
                    int(r*(1-amt) + target[0]*amt),
                    int(g*(1-amt) + target[1]*amt),
                    int(b*(1-amt) + target[2]*amt),
                )
        self.image.paste(self.preserve_alpha(out, region_orig), (left, top))
        self.refresh_canvas()

    def smooth_at(self, x, y):
        radius = self.brush_size.get()
        strength = self.smooth_strength.get() / 100.0
        left, top = max(0, x-radius), max(0, y-radius)
        right, bottom = min(self.image.width, x+radius), min(self.image.height, y+radius)
        region = self.image.crop((left, top, right, bottom))
        blurred = region.filter(ImageFilter.GaussianBlur(radius=max(1, radius / 6)))
        mask = Image.new("L", region.size, 0)
        pix = mask.load()
        cx, cy = x - left, y - top
        for yy in range(region.height):
            for xx in range(region.width):
                d = math.sqrt((xx-cx)**2 + (yy-cy)**2)
                if d <= radius:
                    pix[xx, yy] = int(255 * strength * (1 - d / radius))
        self.image.paste(Image.composite(blurred, region, mask), (left, top))
        self.refresh_canvas()

    def heal_at(self, x, y):
        try:
            strength = max(10, min(100, int(float(self.heal_strength.get())))) / 100.0
        except Exception:
            strength = 0.90
        radius = max(8, int(self.brush_size.get() * (0.50 + strength * 0.35)))
        context = max(radius * 3, radius + 18)
        left, top = max(0, x-context), max(0, y-context)
        right, bottom = min(self.image.width, x+context), min(self.image.height, y+context)
        region_orig = self.image.crop((left, top, right, bottom))
        cx, cy = x-left, y-top

        if HAS_CV2:
            arr = self.pil_to_cv_rgb(region_orig)
            mask = np.zeros((arr.shape[0], arr.shape[1]), dtype=np.uint8)
            cv2.circle(mask, (int(cx), int(cy)), int(radius * (0.64 + strength * 0.24)), 255, -1)
            repaired = cv2.inpaint(arr, mask, max(2, int(radius * (0.24 + strength * 0.18))), cv2.INPAINT_TELEA)
            # Soft paste so the repair does not look stamped.
            soft = Image.new("L", region_orig.size, 0)
            draw = ImageDraw.Draw(soft)
            draw.ellipse((cx-radius, cy-radius, cx+radius, cy+radius), fill=255)
            soft = soft.filter(ImageFilter.GaussianBlur(radius=max(1.0, radius * (0.18 + (1.0 - strength) * 0.18))))
            if strength < 0.995:
                soft = soft.point(lambda v: max(0, min(255, int(v * (0.48 + strength * 0.52)))))
            repaired_pil = self.cv_rgb_to_pil(repaired, region_orig)
            blended = Image.composite(repaired_pil, region_orig, soft)
            self.image.paste(blended, (left, top))
            self.refresh_canvas()
            return

        # Pillow fallback.
        region = region_orig.convert("RGB")
        pixels = region.load()
        samples = []
        for yy in range(region.height):
            for xx in range(region.width):
                d = math.sqrt((xx-cx)**2 + (yy-cy)**2)
                if radius * 0.9 < d <= radius * 1.65:
                    samples.append(pixels[xx, yy])
        if not samples:
            return
        avg = tuple(sum(p[i] for p in samples) // len(samples) for i in range(3))
        patch = Image.new("RGB", region.size, avg).filter(ImageFilter.GaussianBlur(radius=max(1, radius / 5)))
        mask = Image.new("L", region.size, 0)
        mp = mask.load()
        opacity = 0.48 + strength * 0.52
        for yy in range(region.height):
            for xx in range(region.width):
                d = math.sqrt((xx-cx)**2 + (yy-cy)**2)
                if d <= radius:
                    mp[xx, yy] = int(255 * opacity * (1 - d / radius))
        self.image.paste(Image.composite(patch, region_orig, mask), (left, top))
        self.refresh_canvas()

    def on_filter_preview_change(self, *_):
        if self.filter_preview_name:
            self.refresh_canvas()

    def set_filter_preview(self, filter_name):
        if not self.require_image():
            return
        self.stop_other_modes()
        self.filter_preview_name = filter_name
        self.refresh_canvas()
        self.set_status(f"{self.t('filter_preview_active')}: {self.t('filter_' + filter_name)}")

    def clear_filter_preview(self):
        if self.filter_preview_name:
            self.filter_preview_name = None
            self.refresh_canvas()
        self.set_status(self.t("filter_preview_cleared"))

    def render_named_filter(self, img, filter_name, intensity):
        base = img.convert("RGB")
        try:
            intensity = max(0, min(100, int(float(intensity))))
        except Exception:
            intensity = 60
        if intensity <= 0 or not filter_name:
            return base

        alpha = intensity / 100.0
        filtered = base.copy()

        if filter_name == "cinematic_dark":
            lifted = ImageEnhance.Brightness(base).enhance(1.025)
            filtered = ImageEnhance.Contrast(lifted).enhance(1.11)
            filtered = ImageEnhance.Color(filtered).enhance(1.03)
            filtered = Image.blend(filtered, Image.new("RGB", base.size, (246, 232, 220)), 0.035)
            clarity = filtered.filter(ImageFilter.UnsharpMask(radius=0.8, percent=42, threshold=5))
            filtered = Image.blend(filtered, clarity, 0.28)

        elif filter_name == "gothic_portrait":
            cool = Image.blend(base, Image.new("RGB", base.size, (26, 22, 42)), 0.13)
            filtered = ImageEnhance.Contrast(cool).enhance(1.12)
            filtered = ImageEnhance.Color(filtered).enhance(0.94)
            filtered = Image.blend(filtered, Image.new("RGB", base.size, (86, 28, 70)), 0.035)
            filtered = ImageEnhance.Brightness(filtered).enhance(1.01)

        elif filter_name == "latex_shine":
            filtered = ImageEnhance.Contrast(base).enhance(1.15)
            filtered = ImageEnhance.Color(filtered).enhance(1.06)
            filtered = ImageEnhance.Brightness(filtered).enhance(1.015)
            shine = filtered.filter(ImageFilter.UnsharpMask(radius=1.1, percent=82, threshold=5))
            filtered = Image.blend(filtered, shine, 0.55)
            filtered = Image.blend(filtered, Image.new("RGB", base.size, (8, 8, 12)), 0.025)

        elif filter_name == "soft_beauty":
            blurred = base.filter(ImageFilter.GaussianBlur(radius=1.15))
            filtered = Image.blend(base, blurred, 0.24)
            filtered = ImageEnhance.Brightness(filtered).enhance(1.045)
            filtered = ImageEnhance.Contrast(filtered).enhance(0.985)
            filtered = ImageEnhance.Color(filtered).enhance(1.025)
            filtered = Image.blend(filtered, Image.new("RGB", base.size, (255, 242, 235)), 0.045)

        elif filter_name == "high_fashion":
            filtered = ImageEnhance.Brightness(base).enhance(1.035)
            filtered = ImageEnhance.Contrast(filtered).enhance(1.16)
            filtered = ImageEnhance.Color(filtered).enhance(1.035)
            filtered = filtered.filter(ImageFilter.UnsharpMask(radius=0.9, percent=64, threshold=4))
            filtered = Image.blend(filtered, Image.new("RGB", base.size, (246, 246, 242)), 0.018)

        elif filter_name == "black_red":
            gray = ImageOps.grayscale(base).convert("RGB")
            gray_channels = gray.split()
            r, _, _ = base.split()
            filtered = Image.merge("RGB", (
                r.point(lambda v: max(0, min(255, int(v * 1.00 + 8)))),
                gray_channels[1].point(lambda v: max(0, min(255, int(v * 0.34)))),
                gray_channels[2].point(lambda v: max(0, min(255, int(v * 0.36))))
            ))
            filtered = ImageEnhance.Contrast(filtered).enhance(1.12)
            filtered = Image.blend(filtered, Image.new("RGB", base.size, (24, 2, 4)), 0.075)

        elif filter_name == "vintage_gothic":
            gray = ImageOps.grayscale(base)
            silver_blue = ImageOps.colorize(gray, black="#10131b", white="#cbd2dc").convert("RGB")
            filtered = Image.blend(base, silver_blue, 0.46)
            filtered = ImageEnhance.Contrast(filtered).enhance(1.07)
            filtered = ImageEnhance.Color(filtered).enhance(0.91)
            filtered = Image.blend(filtered, Image.new("RGB", base.size, (238, 230, 218)), 0.025)

        elif filter_name == "black_white":
            filtered = ImageOps.grayscale(base).convert("RGB")
            filtered = ImageEnhance.Contrast(filtered).enhance(1.09)
            filtered = ImageEnhance.Brightness(filtered).enhance(1.01)

        elif filter_name == "noir_premium":
            filtered = ImageOps.grayscale(base).convert("RGB")
            filtered = ImageEnhance.Contrast(filtered).enhance(1.24)
            filtered = ImageEnhance.Brightness(filtered).enhance(0.995)
            filtered = filtered.filter(ImageFilter.UnsharpMask(radius=0.95, percent=62, threshold=4))

        return Image.blend(base, filtered, alpha)

    def apply_filter_preview(self):
        if not self.require_image():
            return
        if not self.filter_preview_name:
            self.set_status(self.t("choose_filter_first"))
            return
        self.push_undo()
        self.image = self.render_named_filter(self.image, self.filter_preview_name, self.filter_intensity_value.get())
        self.filter_preview_name = None
        self.refresh_canvas()
        self.set_status(self.t("filter_applied"))

    def get_percent_value(self, variable, default=100):
        try:
            return max(0.0, min(100.0, float(variable.get())))
        except Exception:
            return float(default)

    def percent_to_skin_level(self, variable, default=60):
        return self.get_percent_value(variable, default) / 20.0

    def apply_simple_brightness(self):
        if not self.require_image():
            return
        percent = max(-100.0, min(100.0, float(self.simple_brightness_value.get())))
        if abs(percent) < 0.01:
            return
        self.push_undo()
        factor = max(0.08, 1.0 + percent / 100.0 * 0.85)
        self.image = ImageEnhance.Brightness(self.image).enhance(factor)
        self.simple_brightness_value.set(0)
        self.refresh_canvas()

    def apply_simple_contrast(self):
        if not self.require_image():
            return
        percent = max(-100.0, min(100.0, float(self.simple_contrast_value.get())))
        if abs(percent) < 0.01:
            return
        self.push_undo()
        factor = max(0.08, 1.0 + percent / 100.0 * 0.95)
        self.image = ImageEnhance.Contrast(self.image).enhance(factor)
        self.simple_contrast_value.set(0)
        self.refresh_canvas()

    def apply_simple_sharpness(self):
        if not self.require_image():
            return
        percent = self.get_percent_value(self.simple_sharpness_value, 55)
        if percent <= 0:
            return
        self.push_undo()
        if HAS_CV2:
            arr = self.pil_to_cv_rgb(self.image).astype(np.float32)
            amount = percent / 100.0
            blur = cv2.GaussianBlur(arr, (0, 0), 0.55 + amount * 1.15)
            out = np.clip(arr + (arr - blur) * (0.35 + amount * 1.05), 0, 255)
            self.image = self.cv_rgb_to_pil(out, self.image)
        else:
            self.image = self.image.filter(ImageFilter.UnsharpMask(
                radius=0.65 + percent / 100.0 * 1.15,
                percent=50 + int(percent * 2.2),
                threshold=max(1, 6 - int(percent / 25))
            ))
        self.refresh_canvas()

    def render_skin_redness_neutralized(self, img, intensity):
        """Reduce excess red/orange/pink skin cast relative to the model's own skin.
        The reference is estimated from the face when possible, then from all skin,
        so the correction respects natural pigmentation instead of forcing one skin tone."""
        strength = max(0.0, min(1.0, float(intensity) / 100.0))
        if strength <= 0:
            return img.copy()
        base = img.convert("RGB")
        skin_mask = self.build_skin_tone_mask(base)
        subject = self.build_subject_mask(base)
        if subject is not None:
            skin_mask = ImageChops.multiply(skin_mask.convert("L"), subject.convert("L"))
        face_mask = self.build_face_focus_mask(base)
        ref_mask = ImageChops.multiply(skin_mask.convert("L"), face_mask.convert("L"))

        if HAS_CV2:
            arr = self.pil_to_cv_rgb(base).astype(np.float32)
            arr8 = np.clip(arr, 0, 255).astype(np.uint8)
            lab = cv2.cvtColor(arr8, cv2.COLOR_RGB2LAB).astype(np.float32)
            mask = np.array(skin_mask.convert("L"), dtype=np.float32) / 255.0
            ref = np.array(ref_mask.convert("L"), dtype=np.uint8) > 26
            active = mask > 0.13
            if int(np.count_nonzero(ref)) < max(60, int(arr.shape[0] * arr.shape[1] * 0.00035)):
                ref = active
            if int(np.count_nonzero(ref)) < 30:
                return img.copy()

            a_ref = lab[:, :, 1][ref]
            b_ref = lab[:, :, 2][ref]
            ref_a = float(np.percentile(a_ref, 42))
            ref_b = float(np.percentile(b_ref, 42))

            a = lab[:, :, 1]
            b = lab[:, :, 2]
            red_excess = np.clip((a - (ref_a + 3.0)) / 24.0, 0.0, 1.0)
            orange_excess = np.clip((b - (ref_b + 4.0)) / 30.0, 0.0, 1.0)
            excess = np.clip(red_excess * 0.72 + orange_excess * 0.55, 0.0, 1.0)

            gray = cv2.cvtColor(arr8, cv2.COLOR_RGB2GRAY).astype(np.float32)
            grad = cv2.GaussianBlur(cv2.magnitude(
                cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3),
                cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
            ), (0, 0), 0.65)
            edge_guard = np.clip(1.0 - ((grad - 10.0) / 58.0), 0.18, 1.0)
            amount = np.clip(mask * excess * edge_guard * (0.28 + strength * 0.62), 0.0, 0.78)

            target_a = ref_a + (a - ref_a) * 0.34
            target_b = ref_b + (b - ref_b) * 0.42
            lab[:, :, 1] = a * (1.0 - amount) + target_a * amount
            lab[:, :, 2] = b * (1.0 - amount) + target_b * amount
            out = cv2.cvtColor(np.clip(lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB)
            return self.preserve_alpha(Image.fromarray(out, "RGB"), img)

        # Pillow fallback: reduce red/orange only where the skin mask is strong.
        pixels = base.load()
        mp = skin_mask.load()
        ref_pixels = []
        for yy in range(0, base.height, max(1, base.height // 280)):
            for xx in range(0, base.width, max(1, base.width // 280)):
                if mp[xx, yy] > 28:
                    ref_pixels.append(pixels[xx, yy])
        if not ref_pixels:
            return img.copy()
        ref_r = sorted(p[0] for p in ref_pixels)[len(ref_pixels) // 2]
        ref_g = sorted(p[1] for p in ref_pixels)[len(ref_pixels) // 2]
        ref_b = sorted(p[2] for p in ref_pixels)[len(ref_pixels) // 2]
        out = base.copy()
        op = out.load()
        for yy in range(out.height):
            for xx in range(out.width):
                m = mp[xx, yy] / 255.0
                if m <= 0:
                    continue
                r, g, b = op[xx, yy]
                excess = max(0, r - max(g, b) - max(2, ref_r - max(ref_g, ref_b))) / 55.0
                amt = min(0.62, m * strength * excess)
                op[xx, yy] = (
                    int(r * (1.0 - amt) + ref_r * amt),
                    int(g * (1.0 - amt) + ref_g * amt),
                    int(b * (1.0 - amt) + ref_b * amt),
                )
        return self.preserve_alpha(out, img)

    def apply_skin_redness_neutralizer(self):
        if not self.require_image():
            return
        strength = self.get_percent_value(self.skin_redness_strength_value, 55)
        if strength <= 0:
            return
        self.push_undo()
        self.stop_other_modes()
        self.image = self.render_skin_redness_neutralized(self.image, strength)
        self.refresh_canvas()
        self.set_status(self.t("skin_redness_done"))

    def auto_enhance(self):
        if not self.require_image():
            return
        effect = self.get_percent_value(self.auto_enhance_strength_value, 75) / 100.0
        if effect <= 0:
            return
        self.push_undo()
        original_image_for_blend = self.image.convert("RGB")
        # Fast portrait-safe enhancement: visibly improves light, contrast, vibrance
        # and clarity while leaving flat white/grey/black backgrounds mostly alone.
        if HAS_CV2:
            original = self.pil_to_cv_rgb(self.image).astype(np.float32)
            sample = self.image.convert("RGB")
            sample.thumbnail((900, 900), Image.Resampling.BILINEAR)
            sample_arr = np.asarray(sample, dtype=np.float32)
            sample_y = (
                sample_arr[:, :, 0] * 0.299
                + sample_arr[:, :, 1] * 0.587
                + sample_arr[:, :, 2] * 0.114
            )
            p2, p98 = np.percentile(sample_y, [2, 98])
            span = max(42.0, float(p98 - p2))
            mean_l = float(np.mean(sample_y))

            y = (
                original[:, :, 0] * 0.299
                + original[:, :, 1] * 0.587
                + original[:, :, 2] * 0.114
            )
            chroma = np.max(original, axis=2) - np.min(original, axis=2)
            min_rgb = np.min(original, axis=2)
            max_rgb = np.max(original, axis=2)

            white_protect = (min_rgb > 212) & (chroma < 34)
            neutral_background = (chroma < 18) & ((y > 178) | (y < 72))
            background_protect = white_protect | neutral_background

            contrast = float(np.clip(1.10 + (145.0 - span) / 520.0, 1.10, 1.22))
            lift = float(np.clip(5.0 + (142.0 - mean_l) * 0.055, 2.5, 10.0))
            mid_weight = np.clip(1.0 - np.abs(y - 132.0) / 138.0, 0.0, 1.0)
            shadow_lift = np.clip((92.0 - y) / 92.0, 0.0, 1.0) * 5.0
            new_y = np.clip((y - 128.0) * contrast + 128.0 + lift * mid_weight + shadow_lift, 0, 255)

            ratio = np.clip((new_y + 1.0) / (y + 1.0), 0.72, 1.34)
            enhanced = np.clip(original * ratio[:, :, None], 0, 255)

            gray = np.sum(enhanced * np.array([0.299, 0.587, 0.114], dtype=np.float32), axis=2, keepdims=True)
            vibrance_weight = np.clip(1.0 - chroma / 155.0, 0.25, 1.0)
            sat_amount = (0.12 + 0.08 * vibrance_weight) * (1.0 - background_protect.astype(np.float32) * 0.94)
            enhanced = np.clip(gray + (enhanced - gray) * (1.0 + sat_amount[:, :, None]), 0, 255)

            warm_weight = np.clip((215.0 - y) / 170.0, 0.0, 1.0) * (1.0 - background_protect.astype(np.float32) * 0.96)
            warm = enhanced * np.array([1.018, 1.005, 0.982], dtype=np.float32)
            enhanced = np.clip(enhanced * (1.0 - warm_weight[:, :, None] * 0.42) + warm * (warm_weight[:, :, None] * 0.42), 0, 255)

            # The blur-based clarity pass is the slowest part on very large photos.
            # Keep it on normal sizes; large files still get the fast color/contrast/vibrance pass.
            if original.shape[0] * original.shape[1] <= 2600000:
                soft = cv2.GaussianBlur(enhanced, (0, 0), 0.68)
                sharpen_weight = (1.0 - background_protect.astype(np.float32) * 0.96)[:, :, None]
                enhanced = np.clip(enhanced + (enhanced - soft) * 0.20 * sharpen_weight, 0, 255)

            protect = background_protect.astype(np.float32)[:, :, None]
            original_weight = 0.10 + protect * 0.78
            final = np.clip(original * original_weight + enhanced * (1.0 - original_weight), 0, 255)
            final = original * (1.0 - effect) + final * effect
            self.image = self.cv_rgb_to_pil(final, self.image)
        else:
            img = ImageEnhance.Brightness(self.image).enhance(1.055)
            img = ImageEnhance.Contrast(img).enhance(1.12)
            img = ImageEnhance.Color(img).enhance(1.16)
            warm = Image.new("RGB", img.size, (255, 244, 232))
            img = Image.blend(img.convert("RGB"), warm, 0.012)
            filtered = img.filter(ImageFilter.UnsharpMask(radius=0.75, percent=70, threshold=5))
            self.image = Image.blend(original_image_for_blend, filtered, effect)
        self.refresh_canvas()
        self.set_status(self.t("auto_enhance"))

    def light_minus(self):
        if self.require_image():
            self.push_undo()
            self.image = ImageEnhance.Brightness(self.image).enhance(0.955)
            self.refresh_canvas()

    def light_plus(self):
        if self.require_image():
            self.push_undo()
            self.image = ImageEnhance.Brightness(self.image).enhance(1.052)
            self.refresh_canvas()

    def contrast_minus(self):
        if self.require_image():
            self.push_undo()
            self.image = ImageEnhance.Contrast(self.image).enhance(0.945)
            self.refresh_canvas()

    def contrast_plus(self):
        if self.require_image():
            self.push_undo()
            self.image = ImageEnhance.Contrast(self.image).enhance(1.060)
            self.refresh_canvas()

    def sharp_minus(self):
        if self.require_image():
            self.push_undo()
            self.image = ImageEnhance.Sharpness(self.image).enhance(0.88)
            self.refresh_canvas()

    def sharp_plus(self):
        if self.require_image():
            self.push_undo()
            self.image = self.image.filter(ImageFilter.UnsharpMask(radius=0.85, percent=58, threshold=5))
            self.refresh_canvas()

    def convert_to_black_and_white(self):
        if self.require_image():
            percent = self.get_percent_value(self.simple_bw_value, 100)
            if percent <= 0:
                return
            self.push_undo()
            base = self.image.convert("RGB")
            bw = base.convert("L").convert("RGB")
            self.image = Image.blend(base, bw, percent / 100.0)
            self.refresh_canvas()

    def convert_to_black_and_white_contrast(self):
        if self.require_image():
            percent = self.get_percent_value(self.simple_bw_value, 100)
            if percent <= 0:
                return
            self.push_undo()
            base = self.image.convert("RGB")
            bw = ImageEnhance.Contrast(base.convert("L")).enhance(1.22).convert("RGB")
            self.image = Image.blend(base, bw, percent / 100.0)
            self.refresh_canvas()

    def on_saturation_slider_change(self, *_):
        if self.image is None:
            return

        percent = int(self.simple_saturation_value.get())

        if self.saturation_live_base is None:
            self.push_undo()
            self.saturation_live_base = self.image.copy()

        self.image = ImageEnhance.Color(self.saturation_live_base).enhance(percent / 100.0)
        self.refresh_canvas()
        self.set_status(f"{self.t('saturation')}: {percent}%")

        if percent == 100:
            self.saturation_live_base = None

    def apply_quick_saturation(self, percent):
        # Kept for backward compatibility; saturation is now live from the slider.
        self.simple_saturation_value.set(percent)
        self.on_saturation_slider_change()

    def reset_simple_saturation(self):
        self.simple_saturation_value.set(100)
        self.on_saturation_slider_change()

    def apply_simple_saturation(self):
        if not self.require_image():
            return
        percent = int(self.simple_saturation_value.get())
        if percent == 100:
            return
        self.push_undo()
        self.image = ImageEnhance.Color(self.image).enhance(max(0.0, percent / 100.0))
        self.simple_saturation_value.set(100)
        self.saturation_live_base = None
        self.refresh_canvas()
        self.set_status(f"{self.t('saturation')}: {percent}%")

    def rotate_quick(self, degrees):
        if not self.require_image():
            return
        self.push_undo()
        self.rotation_preview_angle = 0
        self.rotation_value.set(0)
        self.image = self.image.transpose(Image.Transpose.ROTATE_270 if degrees == 90 else Image.Transpose.ROTATE_90)
        self.refresh_canvas()

    def apply_rotation_slider(self):
        if not self.require_image():
            return
        deg = int(self.rotation_value.get())
        if deg == 0:
            return
        self.push_undo()
        self.image = self.image.rotate(deg, expand=True, resample=Image.Resampling.BICUBIC, fillcolor=(0, 0, 0))
        self.rotation_preview_angle = 0
        self.rotation_value.set(0)
        self.refresh_canvas()

    def reset_rotation_slider(self):
        self.rotation_preview_angle = 0
        self.rotation_value.set(0)
        self.refresh_canvas()

    def apply_quick_resize(self, percent):
        self.resize_percent_value.set(percent)
        self.apply_resize_percentage()

    def reset_resize_slider(self):
        self.resize_percent_value.set(100)

    def apply_resize_percentage(self):
        if not self.require_image():
            return
        pct = int(self.resize_percent_value.get())
        if pct == 100:
            return
        self.push_undo()
        self.image = self.image.resize((max(1, int(self.image.width*pct/100)), max(1, int(self.image.height*pct/100))), Image.LANCZOS)
        self.resize_percent_value.set(100)
        self.refresh_canvas()

    def choose_text_color(self):
        chosen = colorchooser.askcolor(color=self.text_color_value.get(), title=self.t("text_color"))
        if chosen and chosen[1]:
            self.text_color_value.set(chosen[1])
            self.text_color_preview.config(text=chosen[1], fg=chosen[1])

    def clear_text_entry(self):
        self.text_input.delete("1.0", "end")

    def get_font_path(self, font_name):
        candidates = {
            "Lora": ["Lora-Regular.ttf", "Lora[wght].ttf"],
            "Playfair Display": ["PlayfairDisplay-Regular.ttf", "PlayfairDisplay[wght].ttf"],
            "Montserrat": ["Montserrat-Regular.ttf", "Montserrat[wght].ttf"],
            "Libre Baskerville": ["LibreBaskerville-Regular.ttf", "LibreBaskerville[wght].ttf"],
            "Great Vibes": ["GreatVibes-Regular.ttf"],
            "Cinzel Decorative": ["CinzelDecorative-Regular.ttf"],
            "Cormorant Garamond": ["CormorantGaramond-Regular.ttf"],
            "Spectral SC": ["SpectralSC-Regular.ttf"],
            "UnifrakturCook": ["UnifrakturCook-Bold.ttf"],
            "Cloister Black": ["CloisterBlack.ttf"],
            "Nosifer": ["Nosifer-Regular.ttf"],
            "Butler": ["Butler-Regular.ttf"],
            "Fette Fraktur": ["FetteFraktur.ttf"],
            "Gothic Ultra OT": ["GothicUltraOT.otf"],
            "VladTepes": ["VladTepesII.ttf"],
            "Old English": [
                "OLDENGL.TTF",
                "oldengl.ttf",
                "Old English Text MT.ttf",
                "OldEnglishTextMT.ttf",
                "OldEnglishTextMT-Regular.ttf",
                "Old English Text MT Regular.ttf"
            ],
            "Old English Text MT": [
                "OLDENGL.TTF",
                "oldengl.ttf",
                "Old English Text MT.ttf",
                "OldEnglishTextMT.ttf",
                "OldEnglishTextMT-Regular.ttf",
                "Old English Text MT Regular.ttf"
            ],
            "Copperplate Gothic": [
                "COPRGTB.TTF",
                "COPRGTL.TTF",
                "Copperplate Gothic Bold.ttf",
                "Copperplate Gothic Light.ttf",
                "CopperplateGothic.ttf",
                "CopperplateGothic-Regular.otf",
                "Copperplate Gothic Regular.otf"
            ],
            "Blackadder": [
                "ITCBLKAD.TTF",
                "ITCBLKAD.ttf",
                "Blackadder ITC.ttf",
                "Blackadder.ttf"
            ],
            "Garamond": [
                "GARA.TTF",
                "GARABD.TTF",
                "GARAIT.TTF",
                "Garamond.ttf"
            ],
            "Arial": ["arial.ttf", "ARIAL.TTF", "Arial.ttf"],
            "Georgia": ["georgia.ttf", "GEORGIA.TTF", "Georgia.ttf"],
            "Times New Roman": ["times.ttf", "TIMES.TTF", "Times New Roman.ttf"],
            "Serif": ["georgia.ttf", "times.ttf", "Georgia.ttf", "Times New Roman.ttf"],
        }

        search_dirs = [
            self.get_program_fonts_dir(),
            resource_path(os.path.join("assets", "fonts")),
            resource_path("fonts"),
            os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "fonts"),
            os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts"),
        ]

        if sys.platform.startswith("win"):
            windir = os.environ.get("WINDIR", r"C:\Windows")
            search_dirs.append(os.path.join(windir, "Fonts"))
        elif sys.platform == "darwin":
            search_dirs.extend([
                "/Library/Fonts",
                os.path.expanduser("~/Library/Fonts"),
                "/System/Library/Fonts"
            ])
        else:
            search_dirs.extend([
                "/usr/share/fonts",
                "/usr/local/share/fonts",
                os.path.expanduser("~/.fonts"),
                os.path.expanduser("~/.local/share/fonts")
            ])

        for directory in search_dirs:
            if not directory or not os.path.isdir(directory):
                continue
            for fn in candidates.get(font_name, []):
                path = os.path.join(directory, fn)
                if os.path.exists(path):
                    return path
        return None

    def get_pil_font_for_size(self, size):
        size = max(8, int(size))
        selected = self.text_font_value.get()

        # First: selected font from downloaded/program/system fonts.
        path = self.get_font_path(selected)
        if path:
            try:
                return ImageFont.truetype(path, size=size)
            except Exception:
                pass

        # Second: reliable scalable fonts that usually ship with Pillow.
        pil_fallbacks = [
            "DejaVuSerif.ttf",
            "DejaVuSans.ttf",
            "DejaVuSans-Bold.ttf",
            "LiberationSerif-Regular.ttf",
            "LiberationSans-Regular.ttf",
        ]
        for fallback in pil_fallbacks:
            try:
                return ImageFont.truetype(fallback, size=size)
            except Exception:
                pass

        # Third: common OS fonts by path/name.
        os_fallbacks = []
        if sys.platform.startswith("win"):
            windir = os.environ.get("WINDIR", r"C:\Windows")
            os_fallbacks.extend([
                os.path.join(windir, "Fonts", "arial.ttf"),
                os.path.join(windir, "Fonts", "georgia.ttf"),
                os.path.join(windir, "Fonts", "times.ttf"),
            ])
        elif sys.platform == "darwin":
            os_fallbacks.extend([
                "/System/Library/Fonts/Supplemental/Arial.ttf",
                "/System/Library/Fonts/Supplemental/Georgia.ttf",
                "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
                "/Library/Fonts/Arial.ttf",
            ])
        else:
            os_fallbacks.extend([
                "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
                "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
                "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
            ])

        for fallback in os_fallbacks:
            try:
                if os.path.exists(fallback):
                    return ImageFont.truetype(fallback, size=size)
            except Exception:
                pass

        # Last resort. This may not scale well, but should almost never be reached.
        return ImageFont.load_default()

    def get_pil_font(self):
        return self.get_pil_font_for_size(self.text_size_value.get())

    def color_hex_to_rgba(self, color, alpha=255):
        try:
            value = str(color).strip().lstrip("#")
            if len(value) == 3:
                value = "".join(ch * 2 for ch in value)
            if len(value) != 6:
                raise ValueError("invalid color")
            r = int(value[0:2], 16)
            g = int(value[2:4], 16)
            b = int(value[4:6], 16)
            return (r, g, b, max(0, min(255, int(alpha))))
        except Exception:
            return (255, 255, 255, max(0, min(255, int(alpha))))

    def render_text_on_pil_image(self, pil_img, x, y, scale=1.0, alpha=255):
        text = self.get_current_text_value()
        if not text:
            return pil_img

        base = pil_img.convert("RGBA")
        draw = ImageDraw.Draw(base)
        scaled_size = max(8, int(int(self.text_size_value.get()) * max(0.10, float(scale))))
        font = self.get_pil_font_for_size(scaled_size)
        shadow = max(1, scaled_size // 18)
        spacing = max(4, scaled_size // 10)
        color = self.color_hex_to_rgba(self.text_color_value.get(), alpha)
        shadow_color = (0, 0, 0, min(230, max(120, int(alpha))))
        try:
            draw.multiline_text((x + shadow, y + shadow), text, font=font, fill=shadow_color, anchor="mm", align="center", spacing=spacing)
            draw.multiline_text((x, y), text, font=font, fill=color, anchor="mm", align="center", spacing=spacing)
        except TypeError:
            draw.multiline_text((x + shadow, y + shadow), text, font=font, fill=shadow_color, align="center", spacing=spacing)
            draw.multiline_text((x, y), text, font=font, fill=color, align="center", spacing=spacing)
        return base

    def add_text_to_image(self, x, y):
        if not self.require_image():
            return
        text = self.get_current_text_value()
        if not text:
            messagebox.showwarning(APP_TITLE, self.t("write_text_first"))
            return
        self.push_undo(action="text")
        self.image = self.render_text_on_pil_image(self.image.convert("RGBA"), x, y, scale=1.0, alpha=255).convert("RGB")
        self.refresh_canvas()
        self.set_status(self.t("text_active"))
    def get_active_logo_path(self):
        choice = self.logo_choice_value.get()
        if choice == "custom":
            return self.logo_path
        filename = self.preset_logo_files.get(choice)
        return resource_path(os.path.join("logos", filename)) if filename else None

    def invalidate_logo_cache(self):
        if getattr(self, "logo_preview_after_id", None) is not None:
            try:
                self.root.after_cancel(self.logo_preview_after_id)
            except Exception:
                pass
            self.logo_preview_after_id = None
        self.logo_source_cache_key = None
        self.logo_source_cache_image = None
        self.logo_cache_key = None
        self.logo_cache_image = None
        self.logo_display_cache_key = None
        self.logo_display_cache_tk = None
        self.logo_preview_item_id = None
        self.logo_preview_rect_id = None
        self.logo_preview_rect_shadow_id = None

    def load_logo_image(self):
        path = filedialog.askopenfilename(title=self.t("custom_logo"), filetypes=self.get_open_image_filetypes())
        if path:
            self.logo_path = path
            self.logo_choice_value.set("custom")
            self.invalidate_logo_cache()
            self.refresh_logo_choice_buttons()
            self.update_logo_sample_preview()
            self.start_logo_preview()

    def make_logo_background_transparent(self, logo):
        """Preserve real alpha; if a logo has a solid square background, remove the border-connected background."""
        logo = logo.convert("RGBA")
        try:
            alpha = logo.getchannel("A")
            # If the file already has real transparency, do not modify it.
            if alpha.getextrema()[0] < 245:
                return logo

            w, h = logo.size
            if w < 3 or h < 3:
                return logo

            pix = logo.load()
            sample = []
            n = max(2, min(24, int(min(w, h) * 0.06)))
            corners = [
                (0, 0, n, n),
                (w - n, 0, w, n),
                (0, h - n, n, h),
                (w - n, h - n, w, h),
            ]
            for x1, y1, x2, y2 in corners:
                for yy in range(max(0, y1), min(h, y2)):
                    for xx in range(max(0, x1), min(w, x2)):
                        r, g, b, a = pix[xx, yy]
                        if a > 240:
                            sample.append((r, g, b))

            if not sample:
                return logo

            bg = tuple(int(sum(c[i] for c in sample) / len(sample)) for i in range(3))
            # Flexible enough for JPEG/WebP compression around a square background,
            # but limited by flood-fill so inner logo details are preserved.
            base_tol = 42

            def close_to_bg(x, y, tol=base_tol):
                r, g, b, a = pix[x, y]
                if a < 245:
                    return False
                return ((r - bg[0]) ** 2 + (g - bg[1]) ** 2 + (b - bg[2]) ** 2) ** 0.5 <= tol

            from collections import deque
            q = deque()
            seen = bytearray(w * h)

            def add_if_bg(x, y):
                if x < 0 or y < 0 or x >= w or y >= h:
                    return
                i = y * w + x
                if seen[i]:
                    return
                if close_to_bg(x, y):
                    seen[i] = 1
                    q.append((x, y))

            for x in range(w):
                add_if_bg(x, 0)
                add_if_bg(x, h - 1)
            for y in range(h):
                add_if_bg(0, y)
                add_if_bg(w - 1, y)

            while q:
                x, y = q.popleft()
                add_if_bg(x + 1, y)
                add_if_bg(x - 1, y)
                add_if_bg(x, y + 1)
                add_if_bg(x, y - 1)

            # If almost nothing was selected, leave as-is.
            if sum(seen) < max(8, (w * h) * 0.002):
                return logo

            mask = Image.new("L", (w, h), 0)
            mask.putdata([255 if v else 0 for v in seen])
            # Slight feather avoids a harsh cutout around antialiased edges.
            mask = mask.filter(ImageFilter.GaussianBlur(radius=0.9))
            r, g, b, a = logo.split()
            new_alpha = ImageChops.subtract(a, mask)
            logo.putalpha(new_alpha)
            return logo
        except Exception:
            return logo

    def invert_logo_black_to_white(self, logo):
        """Turn dark/black logo pixels white while preserving transparency and colored pixels."""
        logo = logo.convert("RGBA")
        try:
            pix = logo.load()
            for y in range(logo.height):
                for x in range(logo.width):
                    r, g, b, a = pix[x, y]
                    if a <= 0:
                        continue
                    lum = 0.299 * r + 0.587 * g + 0.114 * b
                    chroma = max(r, g, b) - min(r, g, b)
                    # Target black/dark neutral parts of a logo. Keep saturated colored details.
                    if lum < 95 and chroma < 80:
                        pix[x, y] = (255, 255, 255, a)
            return logo
        except Exception:
            return logo

    def get_logo_path_stamp(self, path):
        try:
            return (os.path.getmtime(path), os.path.getsize(path))
        except Exception:
            return (0, 0)

    def get_logo_source_image(self):
        path = self.get_active_logo_path()
        if not path or not os.path.exists(path):
            return None
        source_key = (path, self.get_logo_path_stamp(path), bool(self.logo_black_to_white_value.get()))
        if self.logo_source_cache_key == source_key and self.logo_source_cache_image is not None:
            return self.logo_source_cache_image
        logo = self.make_logo_background_transparent(Image.open(path).convert("RGBA"))
        if self.logo_black_to_white_value.get():
            logo = self.invert_logo_black_to_white(logo)
        self.logo_source_cache_key = source_key
        self.logo_source_cache_image = logo
        return logo

    def update_logo_sample_preview(self):
        if not hasattr(self, "logo_sample_label"):
            return
        try:
            source = self.get_logo_source_image()
            if source is None:
                self.logo_sample_tk = None
                self.logo_sample_label.config(image="", text=self.t("logo_preview_empty"), fg=COLORS["muted"])
                return
            preview = source.copy()
            preview.thumbnail((230, 130), Image.LANCZOS)
            bg = Image.new("RGBA", (250, 145), (43, 27, 34, 255))
            x = (bg.width - preview.width) // 2
            y = (bg.height - preview.height) // 2
            bg.alpha_composite(preview, (x, y))
            self.logo_sample_tk = ImageTk.PhotoImage(bg)
            self.logo_sample_label.config(image=self.logo_sample_tk, text="")
        except Exception:
            self.logo_sample_tk = None
            self.logo_sample_label.config(image="", text=self.t("logo_preview_empty"), fg=COLORS["muted"])

    def get_logo_image(self):
        path = self.get_active_logo_path()
        if not path or not os.path.exists(path):
            return None
        source = self.get_logo_source_image()
        if source is None:
            return None
        cache_key = (
            path,
            self.get_logo_path_stamp(path),
            int(self.logo_size_value.get()),
            int(self.logo_opacity_value.get()),
            bool(self.logo_black_to_white_value.get()),
            self.image.width if self.image is not None else 0,
        )
        if self.logo_cache_key == cache_key and self.logo_cache_image is not None:
            return self.logo_cache_image
        logo = source.copy()
        target_w = max(10, int(self.image.width * int(self.logo_size_value.get()) / 100))
        ratio = target_w / max(1, logo.width)
        logo = logo.resize((target_w, max(10, int(logo.height * ratio))), Image.LANCZOS)
        opacity = max(0, min(100, int(self.logo_opacity_value.get()))) / 100.0
        alpha = logo.getchannel("A").point(lambda p: int(p * opacity))
        logo.putalpha(alpha)
        self.logo_cache_key = cache_key
        self.logo_cache_image = logo
        self.logo_display_cache_key = None
        self.logo_display_cache_tk = None
        return logo

    def on_logo_slider_change(self, *_):
        if not self.logo_preview_active:
            self.logo_cache_key = None
            self.logo_cache_image = None
            self.logo_display_cache_key = None
            self.logo_display_cache_tk = None
            return
        if not self.is_logo_preview_allowed():
            self.clear_logo_preview_items()
            self.refresh_canvas()
            return
        self.schedule_logo_preview_refresh()

    def schedule_logo_preview_refresh(self, delay=8):
        if not self.is_logo_preview_allowed():
            self.clear_logo_preview_items()
            return
        if getattr(self, "logo_preview_after_id", None) is not None:
            try:
                self.root.after_cancel(self.logo_preview_after_id)
            except Exception:
                pass
        self.logo_preview_after_id = self.root.after(delay, self.update_logo_preview_canvas_image)

    def refresh_logo_preview(self, force_source=False):
        if getattr(self, "logo_preview_after_id", None) is not None:
            try:
                self.root.after_cancel(self.logo_preview_after_id)
            except Exception:
                pass
            self.logo_preview_after_id = None
        if force_source:
            self.logo_source_cache_key = None
            self.logo_source_cache_image = None
            self.update_logo_sample_preview()
        self.logo_cache_key = None
        self.logo_cache_image = None
        self.logo_display_cache_key = None
        self.logo_display_cache_tk = None
        if not self.is_logo_preview_allowed():
            self.clear_logo_preview_items()
            return
        if self.logo_preview_active:
            self.clamp_logo_preview_position()
            if not self.update_logo_preview_canvas_image():
                self.refresh_canvas()

    def start_logo_preview(self):
        if not self.require_image():
            return
        if getattr(self, "active_tab_key", None) != "logo_tab":
            self.clear_logo_preview_items()
            return
        self.tool = None
        if not self.logo_preview_active and int(self.logo_size_value.get()) < 35:
            self.logo_size_value.set(35)
        logo = self.get_logo_image()
        if logo is None:
            messagebox.showwarning(APP_TITLE, self.t("choose_logo_first"))
            return
        self.logo_preview_x = max(0, int((self.image.width - logo.width) / 2))
        self.logo_preview_y = max(0, int((self.image.height - logo.height) / 2))
        self.logo_preview_active = True
        self.logo_dragging = False
        self.refresh_canvas()
        self.set_status(self.t("logo_active"))

    def clear_logo_preview(self):
        self.reset_logo_selection(refresh=True)

    def is_logo_preview_allowed(self):
        return bool(self.logo_preview_active and getattr(self, "active_tab_key", None) == "logo_tab")

    def clear_logo_preview_items(self):
        self.logo_preview_active = False
        self.logo_dragging = False
        for item_id in (
            self.logo_preview_item_id,
            self.logo_preview_rect_id,
            self.logo_preview_rect_shadow_id,
        ):
            if item_id:
                try:
                    self.image_canvas.delete(item_id)
                except Exception:
                    pass
        self.logo_preview_item_id = None
        self.logo_preview_rect_id = None
        self.logo_preview_rect_shadow_id = None

    def capture_logo_state(self):
        return {
            "choice": self.logo_choice_value.get(),
            "path": self.logo_path,
            "size": int(self.logo_size_value.get()),
            "opacity": int(self.logo_opacity_value.get()),
            "black_to_white": bool(self.logo_black_to_white_value.get()),
            "active": bool(self.logo_preview_active),
            "x": int(self.logo_preview_x),
            "y": int(self.logo_preview_y),
        }

    def restore_logo_state(self, state):
        if not state:
            return
        self.logo_choice_value.set(state.get("choice", ""))
        self.logo_path = state.get("path")
        self.logo_size_value.set(int(state.get("size", self.logo_size_value.get())))
        self.logo_opacity_value.set(int(state.get("opacity", self.logo_opacity_value.get())))
        self.logo_black_to_white_value.set(bool(state.get("black_to_white", self.logo_black_to_white_value.get())))
        self.invalidate_logo_cache()
        self.refresh_logo_choice_buttons()
        self.update_logo_sample_preview()
        if self.get_active_logo_path() and self.image is not None:
            self.logo_preview_x = int(state.get("x", 0))
            self.logo_preview_y = int(state.get("y", 0))
            self.logo_preview_active = True
            self.logo_dragging = False
            self.clamp_logo_preview_position()
        else:
            self.clear_logo_preview_items()

    def clamp_logo_preview_position(self):
        logo = self.get_logo_image()
        if logo is None:
            return
        self.logo_preview_x = max(0, min(int(self.logo_preview_x), self.image.width - logo.width))
        self.logo_preview_y = max(0, min(int(self.logo_preview_y), self.image.height - logo.height))

    def is_point_in_logo_preview(self, ix, iy):
        logo = self.get_logo_image()
        if not self.is_logo_preview_allowed() or logo is None:
            return False
        return self.logo_preview_x <= ix <= self.logo_preview_x + logo.width and self.logo_preview_y <= iy <= self.logo_preview_y + logo.height

    def draw_logo_preview(self):
        if not self.is_logo_preview_allowed():
            self.clear_logo_preview_items()
            return
        logo = self.get_logo_image()
        if logo is None:
            return
        self.clamp_logo_preview_position()
        disp_w = max(1, int(logo.width * self.display_scale))
        disp_h = max(1, int(logo.height * self.display_scale))
        display_key = (self.logo_cache_key, disp_w, disp_h)
        if self.logo_display_cache_key == display_key and self.logo_display_cache_tk is not None:
            self.logo_preview_tk = self.logo_display_cache_tk
        else:
            disp_logo = logo.resize((disp_w, disp_h), Image.BILINEAR)
            self.logo_preview_tk = ImageTk.PhotoImage(disp_logo)
            self.logo_display_cache_key = display_key
            self.logo_display_cache_tk = self.logo_preview_tk
        x, y = self.image_to_canvas_coords(self.logo_preview_x, self.logo_preview_y)
        self.logo_preview_item_id = self.image_canvas.create_image(x, y, image=self.logo_preview_tk, anchor=tk.NW)
        shadow_color, front_color = self.get_logo_preview_frame_colors(logo)
        self.logo_preview_rect_shadow_id = self.image_canvas.create_rectangle(x, y, x+disp_w, y+disp_h, outline=shadow_color, width=7)
        self.logo_preview_rect_id = self.image_canvas.create_rectangle(x, y, x+disp_w, y+disp_h, outline=front_color, width=4, dash=(8, 4))
        self.image_canvas.tag_raise(self.logo_preview_rect_shadow_id)
        self.image_canvas.tag_raise(self.logo_preview_rect_id)

    def get_logo_preview_frame_colors(self, logo):
        try:
            if self.image is None or logo is None:
                return "#000000", "#ff2f6d"
            x1 = max(0, int(self.logo_preview_x))
            y1 = max(0, int(self.logo_preview_y))
            x2 = min(self.image.width, x1 + int(logo.width))
            y2 = min(self.image.height, y1 + int(logo.height))
            if x2 <= x1 or y2 <= y1:
                return "#000000", "#ff2f6d"
            region = self.image.convert("RGB").crop((x1, y1, x2, y2)).resize((1, 1), Image.BILINEAR)
            r, g, b = region.getpixel((0, 0))
            lum = (0.299 * r) + (0.587 * g) + (0.114 * b)
            if lum < 95:
                return "#ff2f6d", "#ffffff"
            return "#000000", "#ff2f6d"
        except Exception:
            return "#000000", "#ff2f6d"

    def update_logo_preview_canvas_image(self):
        self.logo_preview_after_id = None
        if not self.is_logo_preview_allowed():
            self.clear_logo_preview_items()
            return False
        logo = self.get_logo_image()
        if logo is None:
            return False
        self.clamp_logo_preview_position()
        disp_w = max(1, int(logo.width * self.display_scale))
        disp_h = max(1, int(logo.height * self.display_scale))
        display_key = (self.logo_cache_key, disp_w, disp_h)
        if self.logo_display_cache_key == display_key and self.logo_display_cache_tk is not None:
            self.logo_preview_tk = self.logo_display_cache_tk
        else:
            disp_logo = logo.resize((disp_w, disp_h), Image.BILINEAR)
            self.logo_preview_tk = ImageTk.PhotoImage(disp_logo)
            self.logo_display_cache_key = display_key
            self.logo_display_cache_tk = self.logo_preview_tk
        x, y = self.image_to_canvas_coords(self.logo_preview_x, self.logo_preview_y)
        try:
            if not self.logo_preview_item_id or not self.logo_preview_rect_id or not self.logo_preview_rect_shadow_id:
                self.draw_logo_preview()
            else:
                self.image_canvas.itemconfigure(self.logo_preview_item_id, image=self.logo_preview_tk)
                self.image_canvas.coords(self.logo_preview_item_id, x, y)
                self.image_canvas.coords(self.logo_preview_rect_shadow_id, x, y, x + disp_w, y + disp_h)
                self.image_canvas.coords(self.logo_preview_rect_id, x, y, x + disp_w, y + disp_h)
                shadow_color, front_color = self.get_logo_preview_frame_colors(logo)
                self.image_canvas.itemconfigure(self.logo_preview_rect_shadow_id, outline=shadow_color)
                self.image_canvas.itemconfigure(self.logo_preview_rect_id, outline=front_color)
                self.image_canvas.tag_raise(self.logo_preview_rect_shadow_id)
                self.image_canvas.tag_raise(self.logo_preview_rect_id)
            return True
        except Exception:
            return False

    def move_logo_preview_canvas_items(self):
        if not self.is_logo_preview_allowed():
            self.clear_logo_preview_items()
            return False
        logo = self.get_logo_image()
        if logo is None:
            return False
        disp_w = max(1, int(logo.width * self.display_scale))
        disp_h = max(1, int(logo.height * self.display_scale))
        x, y = self.image_to_canvas_coords(self.logo_preview_x, self.logo_preview_y)
        try:
            if self.logo_preview_item_id:
                self.image_canvas.coords(self.logo_preview_item_id, x, y)
            if self.logo_preview_rect_shadow_id:
                self.image_canvas.coords(self.logo_preview_rect_shadow_id, x, y, x + disp_w, y + disp_h)
            if self.logo_preview_rect_id:
                self.image_canvas.coords(self.logo_preview_rect_id, x, y, x + disp_w, y + disp_h)
            if self.logo_preview_rect_shadow_id and self.logo_preview_rect_id:
                shadow_color, front_color = self.get_logo_preview_frame_colors(logo)
                self.image_canvas.itemconfigure(self.logo_preview_rect_shadow_id, outline=shadow_color)
                self.image_canvas.itemconfigure(self.logo_preview_rect_id, outline=front_color)
            return bool(self.logo_preview_item_id and self.logo_preview_rect_id and self.logo_preview_rect_shadow_id)
        except Exception:
            return False

    def adjust_logo_size_by_wheel(self, direction):
        if not self.is_logo_preview_allowed():
            self.clear_logo_preview_items()
            return
        val = int(self.logo_size_value.get()) + (2 if direction > 0 else -2)
        self.logo_size_value.set(max(5, min(60, val)))
        self.logo_cache_key = None
        self.logo_cache_image = None
        self.logo_display_cache_key = None
        self.logo_display_cache_tk = None
        self.refresh_logo_preview()

    def apply_logo_watermark(self):
        if not self.require_image():
            return
        logo = self.get_logo_image()
        if logo is None:
            messagebox.showwarning(APP_TITLE, self.t("choose_logo_first"))
            return
        logo_state = self.capture_logo_state()
        base = self.image.convert("RGBA")
        if self.logo_preview_active:
            x, y = int(self.logo_preview_x), int(self.logo_preview_y)
        else:
            x = self.image.width - logo.width - max(12, int(min(self.image.size) * 0.03))
            y = self.image.height - logo.height - max(12, int(min(self.image.size) * 0.03))
        logo_state["x"] = int(x)
        logo_state["y"] = int(y)
        self.push_undo(action="logo", extra={"logo_state": logo_state})
        shadow = Image.new("RGBA", logo.size, (0, 0, 0, 0))
        shadow.putalpha(logo.getchannel("A").filter(ImageFilter.GaussianBlur(radius=2)).point(lambda p: int(p * 0.38)))
        base.alpha_composite(shadow, (x+3, y+3))
        base.alpha_composite(logo, (x, y))
        self.image = base.convert("RGB")
        self.reset_logo_selection(refresh=False)
        self.refresh_canvas()
        self.set_status(self.t("logo_applied"))

    def choose_bg_replace_color(self):
        chosen = colorchooser.askcolor(color=self.bg_replace_color_value.get(), title=self.t("bg_color"))
        if chosen and chosen[1]:
            self.bg_replace_color_value.set(chosen[1])
            self.update_bg_labels()
            if self.bg_placement_active and not self.bg_replace_image_path:
                self.update_background_placement_canvas()

    def choose_cleanup_fill_color(self):
        chosen = colorchooser.askcolor(color=self.cleanup_fill_color_value.get(), title=self.t("cleanup_color"))
        if chosen and chosen[1]:
            self.cleanup_fill_color_value.set(chosen[1])
            self.update_bg_labels()
            self.set_status(f"{self.t('cleanup_color_set')}: {chosen[1]}")

    def load_bg_replace_image(self):
        path = filedialog.askopenfilename(title=self.t("bg_photo"), filetypes=self.get_open_image_filetypes())
        if path:
            self.bg_replace_image_path = path
            self.update_bg_labels()
            if self.bg_placement_active:
                self.update_background_placement_canvas()

    def clear_bg_replace_image(self):
        self.bg_replace_image_path = None
        self.update_bg_labels()
        if self.bg_placement_active:
            self.update_background_placement_canvas()

    def get_bg_background_rotation_angle(self):
        try:
            return float(self.bg_background_rotation_value.get())
        except Exception:
            return 0.0

    def load_background_canvas_image(self, fallback_size):
        if self.bg_replace_image_path and os.path.exists(self.bg_replace_image_path):
            with Image.open(self.bg_replace_image_path) as bg_file:
                bg = bg_file.convert("RGB")
            max_side = 2400
            if max(bg.size) > max_side:
                bg.thumbnail((max_side, max_side), Image.LANCZOS)
            angle = self.get_bg_background_rotation_angle()
            if abs(angle) > 0.001:
                target_size = bg.size
                bg = bg.rotate(-angle, expand=True, resample=Image.Resampling.BICUBIC)
                bg = ImageOps.fit(bg, target_size, Image.LANCZOS)
            return bg
        return Image.new("RGB", fallback_size, hex_to_rgb(self.bg_replace_color_value.get()))

    def on_bg_background_rotation_change(self):
        if not self.bg_placement_active:
            return
        if self.bg_background_update_after_id is not None:
            try:
                self.root.after_cancel(self.bg_background_update_after_id)
            except Exception:
                pass
        self.bg_background_update_after_id = self.root.after(160, self.apply_bg_background_rotation_change)

    def apply_bg_background_rotation_change(self):
        self.bg_background_update_after_id = None
        if not self.bg_placement_active:
            return
        self.set_busy(True)
        try:
            self.update_background_placement_canvas()
        finally:
            self.set_busy(False)

    def update_background_placement_canvas(self):
        if not self.bg_placement_active:
            return
        old_bg = self.bg_placement_bg
        old_subject = self.get_bg_placement_subject_resized()
        if old_bg is not None and old_subject is not None and old_bg.width > 0 and old_bg.height > 0:
            cx_ratio = (self.bg_placement_subject_x + old_subject.width / 2) / old_bg.width
            cy_ratio = (self.bg_placement_subject_y + old_subject.height / 2) / old_bg.height
        else:
            cx_ratio = cy_ratio = 0.5
        fallback = old_bg.size if old_bg is not None else (self.bg_placement_original.size if self.bg_placement_original is not None else self.image.size)
        self.bg_placement_bg = self.load_background_canvas_image(fallback)
        new_subject = self.get_bg_placement_subject_resized()
        if new_subject is not None:
            self.bg_placement_subject_x = int(round(self.bg_placement_bg.width * cx_ratio - new_subject.width / 2))
            self.bg_placement_subject_y = int(round(self.bg_placement_bg.height * cy_ratio - new_subject.height / 2))
        self.refresh_background_placement_preview()

    def get_bg_placement_subject_scale(self):
        try:
            factor = max(10, min(220, int(float(self.bg_subject_size_value.get())))) / 100.0
        except Exception:
            factor = 1.0
        return max(0.05, self.bg_placement_subject_base_scale * factor)

    def invalidate_bg_placement_subject_cache(self):
        self.bg_placement_subject_cache_key = None
        self.bg_placement_subject_cache = None

    def get_bg_placement_subject_transformed(self):
        if self.bg_placement_subject is None:
            return None
        scale = self.get_bg_placement_subject_scale()
        try:
            angle = float(self.bg_subject_rotation_value.get())
        except Exception:
            angle = 0.0
        cache_key = (
            id(self.bg_placement_subject),
            self.bg_placement_subject.size,
            round(scale, 5),
            round(angle, 3)
        )
        if self.bg_placement_subject_cache_key == cache_key and self.bg_placement_subject_cache is not None:
            return self.bg_placement_subject_cache
        w = max(1, int(self.bg_placement_subject.width * scale))
        h = max(1, int(self.bg_placement_subject.height * scale))
        subject = self.bg_placement_subject.resize((w, h), Image.LANCZOS)
        if abs(angle) > 0.001:
            subject = subject.rotate(-angle, expand=True, resample=Image.Resampling.BICUBIC)
        self.bg_placement_subject_cache_key = cache_key
        self.bg_placement_subject_cache = subject
        return subject

    def get_bg_placement_subject_resized(self):
        return self.get_bg_placement_subject_transformed()

    def render_background_placement_image(self):
        if self.bg_placement_bg is None or self.bg_placement_subject is None:
            return self.image
        bg = self.bg_placement_bg.copy().convert("RGBA")
        subject = self.get_bg_placement_subject_resized()
        if subject is None:
            return bg.convert("RGB")
        x = int(round(self.bg_placement_subject_x))
        y = int(round(self.bg_placement_subject_y))
        bg.alpha_composite(subject, (x, y))
        return bg.convert("RGB")

    def render_background_restore_layer(self):
        if self.bg_placement_bg is None or self.bg_placement_original is None:
            return None
        try:
            bg_size = self.bg_placement_bg.size
            scale = self.get_bg_placement_subject_scale()
            angle = float(self.bg_subject_rotation_value.get())
            original = self.bg_placement_original.convert("RGBA")
            w = max(1, int(original.width * scale))
            h = max(1, int(original.height * scale))
            original = original.resize((w, h), Image.LANCZOS)
            if abs(angle) > 0.001:
                original = original.rotate(-angle, expand=True, resample=Image.Resampling.BICUBIC)
            layer = Image.new("RGBA", bg_size, (0, 0, 0, 0))
            layer.alpha_composite(original, (int(round(self.bg_placement_subject_x)), int(round(self.bg_placement_subject_y))))
            return layer
        except Exception:
            return None

    def get_bg_placement_bbox(self):
        subject = self.get_bg_placement_subject_resized()
        if subject is None:
            return None
        x = int(round(self.bg_placement_subject_x))
        y = int(round(self.bg_placement_subject_y))
        return (x, y, x + subject.width, y + subject.height)

    def clamp_bg_placement_position(self):
        if self.bg_placement_bg is None:
            return
        subject = self.get_bg_placement_subject_resized()
        if subject is None:
            return
        self.bg_placement_last_subject_size = subject.size
        margin = max(20, int(min(self.bg_placement_bg.size) * 0.04))
        self.bg_placement_subject_x = max(-subject.width + margin, min(self.bg_placement_bg.width - margin, int(self.bg_placement_subject_x)))
        self.bg_placement_subject_y = max(-subject.height + margin, min(self.bg_placement_bg.height - margin, int(self.bg_placement_subject_y)))

    def point_in_bg_placement_subject(self, x, y):
        bbox = self.get_bg_placement_bbox()
        if bbox is None:
            return False
        left, top, right, bottom = bbox
        return left <= x <= right and top <= y <= bottom

    def start_background_placement(self, subject_rgba):
        if not self.require_image():
            return
        original = self.image.copy()
        bg = self.load_background_canvas_image(original.size)
        self.bg_placement_original = original
        self.bg_placement_bg = bg
        self.bg_placement_subject = subject_rgba.convert("RGBA")
        self.invalidate_bg_placement_subject_cache()
        base_scale = min(bg.width / max(1, subject_rgba.width), bg.height / max(1, subject_rgba.height), 1.0) * 0.88
        self.bg_placement_subject_base_scale = max(0.05, base_scale)
        self.bg_background_rotation_value.set(0)
        self.bg_subject_size_value.set(100)
        self.bg_subject_rotation_value.set(0)
        subject = self.get_bg_placement_subject_resized()
        if subject is not None:
            self.bg_placement_subject_x = int((bg.width - subject.width) / 2)
            self.bg_placement_subject_y = int((bg.height - subject.height) / 2)
            self.bg_placement_last_subject_size = subject.size
        self.bg_placement_active = True
        self.bg_placement_dragging = False
        self.tool = None
        self.reset_logo_selection(refresh=False)
        self.image = self.render_background_placement_image()
        self.zoom = 1.0
        self.refresh_canvas()
        self.set_status(self.t("bg_placement_active"))

    def refresh_background_placement_preview(self):
        self.bg_placement_preview_after_id = None
        if not self.bg_placement_active:
            return
        self.clamp_bg_placement_position()
        self.image = self.render_background_placement_image()
        subject = self.get_bg_placement_subject_resized()
        self.bg_placement_last_subject_size = subject.size if subject is not None else None
        self.refresh_canvas()

    def schedule_background_placement_preview(self, delay=18):
        if not self.bg_placement_active:
            return
        if self.bg_placement_preview_after_id is not None:
            try:
                self.root.after_cancel(self.bg_placement_preview_after_id)
            except Exception:
                pass
        self.bg_placement_preview_after_id = self.root.after(delay, self.refresh_background_placement_preview)

    def preserve_bg_placement_center(self, callback):
        if not self.bg_placement_active:
            callback()
            return
        if self.bg_placement_last_subject_size:
            old_w, old_h = self.bg_placement_last_subject_size
        else:
            old_subject = self.get_bg_placement_subject_resized()
            old_w = old_subject.width if old_subject is not None else 0
            old_h = old_subject.height if old_subject is not None else 0
        old_cx = self.bg_placement_subject_x + old_w / 2
        old_cy = self.bg_placement_subject_y + old_h / 2
        callback()
        new_subject = self.get_bg_placement_subject_resized()
        if new_subject is not None:
            self.bg_placement_subject_x = int(round(old_cx - new_subject.width / 2))
            self.bg_placement_subject_y = int(round(old_cy - new_subject.height / 2))
        self.refresh_background_placement_preview()

    def preserve_bg_placement_center_scheduled(self):
        if not self.bg_placement_active:
            return
        if self.bg_placement_last_subject_size:
            old_w, old_h = self.bg_placement_last_subject_size
        else:
            old_subject = self.get_bg_placement_subject_resized()
            old_w = old_subject.width if old_subject is not None else 0
            old_h = old_subject.height if old_subject is not None else 0
        old_cx = self.bg_placement_subject_x + old_w / 2
        old_cy = self.bg_placement_subject_y + old_h / 2
        self.invalidate_bg_placement_subject_cache()
        new_subject = self.get_bg_placement_subject_resized()
        if new_subject is not None:
            self.bg_placement_subject_x = int(round(old_cx - new_subject.width / 2))
            self.bg_placement_subject_y = int(round(old_cy - new_subject.height / 2))
        self.schedule_background_placement_preview(delay=22)

    def on_bg_subject_size_change(self):
        if self.bg_placement_active:
            self.preserve_bg_placement_center_scheduled()

    def on_bg_subject_rotation_change(self):
        if self.bg_placement_active:
            self.preserve_bg_placement_center_scheduled()

    def adjust_bg_subject_size_by_wheel(self, direction):
        def change_size():
            value = int(self.bg_subject_size_value.get()) + (5 if direction > 0 else -5)
            self.bg_subject_size_value.set(max(10, min(220, value)))
            self.invalidate_bg_placement_subject_cache()
        self.preserve_bg_placement_center(change_size)

    def apply_background_placement(self):
        if not self.bg_placement_active:
            self.set_status(self.t("bg_no_placement"))
            return
        original = self.bg_placement_original.copy() if self.bg_placement_original is not None else None
        restore_layer = self.render_background_restore_layer()
        final = self.render_background_placement_image()
        self.clear_background_placement_state()
        if original is not None:
            self.push_specific_undo_image(original)
        self.last_cutout_restore_layer = restore_layer
        self.image = final
        self.refresh_canvas()
        self.set_status(self.t("bg_placement_done"))

    def cancel_background_placement(self, show_status=True):
        if not self.bg_placement_active:
            if show_status:
                self.set_status(self.t("bg_no_placement"))
            return
        original = self.bg_placement_original.copy() if self.bg_placement_original is not None else self.image
        self.clear_background_placement_state()
        self.image = original
        self.refresh_canvas()
        if show_status:
            self.set_status(self.t("bg_placement_cancelled"))

    def estimate_corner_color(self, img):
        w, h = img.size
        patch_w, patch_h = max(4, w//12), max(4, h//12)
        patches = [img.crop((0,0,patch_w,patch_h)), img.crop((w-patch_w,0,w,patch_h)), img.crop((0,h-patch_h,patch_w,h)), img.crop((w-patch_w,h-patch_h,w,h))]
        pixels = []
        for p in patches:
            pixels += list(p.getdata())
        return tuple(sum(px[i] for px in pixels)//len(pixels) for i in range(3))

    def build_background_mask(self, img, mode, tolerance):
        target = self.estimate_corner_color(img)
        if mode == "white":
            target = target if sum(target)/3 > 110 else (255,255,255)
        elif mode == "black":
            target = target if sum(target)/3 < 145 else (0,0,0)
        elif mode == "green":
            target = target if target[1] >= max(target[0], target[2]) else (0,255,0)

        mask = Image.new("L", img.size, 255)
        pix, mp = img.load(), mask.load()
        inner, outer = max(1, int(tolerance*0.8)), max(2, int(tolerance*2.4))
        tr, tg, tb = target
        for y in range(img.height):
            for x in range(img.width):
                r, g, b = pix[x, y]
                if mode == "green":
                    green_dom = g - max(r, b)
                    dist = ((r-tr)**2 + (g-tg)**2 + (b-tb)**2) ** 0.5
                    strength = max(green_dom * 1.25, outer - dist)
                    alpha = 0 if strength >= outer else 255 if strength <= inner else int(255*(outer-strength)/(outer-inner))
                else:
                    dist = ((r-tr)**2 + (g-tg)**2 + (b-tb)**2) ** 0.5
                    alpha = 0 if dist <= inner else 255 if dist >= outer else int(255*(dist-inner)/(outer-inner))
                mp[x, y] = max(0, min(255, alpha))
        return mask.filter(ImageFilter.GaussianBlur(radius=1.4))

    def replace_background(self, mode):
        if not self.require_image():
            return
        if mode in ("white", "auto") and HAS_REMBG:
            self.replace_background_ai_matte()
            return
        self.push_undo()
        img = self.image.convert("RGB")
        mask = self.build_background_mask(img, mode, int(self.bg_tolerance_value.get()))
        fg = img.convert("RGBA")
        fg.putalpha(mask)
        if self.bg_replace_image_path and os.path.exists(self.bg_replace_image_path):
            with Image.open(self.bg_replace_image_path) as bg_file:
                bg = ImageOps.fit(bg_file.convert("RGB"), img.size, Image.LANCZOS).convert("RGBA")
        else:
            bg = Image.new("RGB", img.size, hex_to_rgb(self.bg_replace_color_value.get())).convert("RGBA")
        self.image = Image.alpha_composite(bg, fg).convert("RGB")
        self.refresh_canvas()

    def preload_background_ai(self):
        if self.rembg_session is not None or not HAS_REMBG:
            return

        def worker():
            try:
                if ensure_rembg_loaded() and self.rembg_session is None and self.is_rembg_model_cached("birefnet-general-lite"):
                    self.rembg_session = self.create_best_rembg_session()
            except Exception:
                pass

        threading.Thread(target=worker, daemon=True).start()

    def get_rembg_model_home_candidates(self):
        base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
        candidates = []
        env_home = os.environ.get("U2NET_HOME")
        if env_home:
            candidates.append(env_home)
        candidates.append(os.path.join(base, "models"))
        candidates.append(os.path.join(os.path.expanduser("~"), ".u2net"))
        return candidates

    def is_rembg_model_cached(self, model_name):
        for folder in self.get_rembg_model_home_candidates():
            try:
                if folder and os.path.exists(os.path.join(folder, f"{model_name}.onnx")):
                    return True
            except Exception:
                pass
        return False

    def create_best_rembg_session(self):
        """Use the strongest free local cutout model available.
        BiRefNet general-lite is now the default because it separates full
        human subjects from white studio backgrounds more cleanly than the
        older U2Net/ISNet masks, while staying local and license-safe."""
        last_error = None
        for model_name in ("birefnet-general-lite", "u2net", "isnet-general-use", "u2net_human_seg"):
            try:
                session = rembg_new_session(model_name)
                if hasattr(self, "rembg_sessions") and self.rembg_sessions is not None:
                    self.rembg_sessions[model_name] = session
                self.rembg_session_model_name = model_name
                return session
            except Exception as exc:
                last_error = exc
        raise last_error if last_error is not None else RuntimeError("rembg session failed")

    def get_rembg_session_for_model(self, model_name):
        if not hasattr(self, "rembg_sessions") or self.rembg_sessions is None:
            self.rembg_sessions = {}
        if model_name in self.rembg_sessions:
            self.rembg_session_model_name = model_name
            return self.rembg_sessions[model_name]
        session = rembg_new_session(model_name)
        self.rembg_sessions[model_name] = session
        if model_name == "u2net":
            self.rembg_session = session
        self.rembg_session_model_name = model_name
        return session

    def clear_conservative_enclosed_background(self, source_rgb, alpha):
        if not HAS_CV2:
            return alpha
        try:
            rgb8 = source_rgb.astype(np.uint8)
            rgb = rgb8.astype(np.float32)
            h, w = alpha.shape
            hsv = cv2.cvtColor(rgb8, cv2.COLOR_RGB2HSV)
            gray = cv2.cvtColor(rgb8, cv2.COLOR_RGB2GRAY).astype(np.float32)
            sat = hsv[:, :, 1].astype(np.float32)
            maxc = np.max(rgb, axis=2)
            minc = np.min(rgb, axis=2)
            chroma = maxc - minc
            border = max(4, int(min(w, h) * 0.035))
            border_samples = np.concatenate([
                rgb[:border, :, :].reshape(-1, 3),
                rgb[-border:, :, :].reshape(-1, 3),
                rgb[:, :border, :].reshape(-1, 3),
                rgb[:, -border:, :].reshape(-1, 3),
            ], axis=0)
            bg_rgb = np.median(border_samples, axis=0)
            bg_diff = np.sqrt(np.sum((rgb - bg_rgb) ** 2, axis=2))
            grad = cv2.GaussianBlur(cv2.magnitude(
                cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3),
                cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
            ), (0, 0), 0.65)

            backdrop_candidate = (
                (alpha > 118) &
                (gray > 118) &
                (sat < 42) &
                (chroma < 48) &
                (bg_diff < 72) &
                (grad < 20)
            ).astype(np.uint8)
            backdrop_candidate = cv2.morphologyEx(backdrop_candidate * 255, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8), iterations=1)
            count, labels, stats, _centroids = cv2.connectedComponentsWithStats(backdrop_candidate, connectivity=8)
            remove = np.zeros((h, w), np.uint8)
            max_area = max(90, int(w * h * 0.006))
            vivid_or_dark = (sat > 54) | (chroma > 52) | (gray < 92)
            alpha_core = alpha > 156
            for label in range(1, count):
                area = int(stats[label, cv2.CC_STAT_AREA])
                if area < 18 or area > max_area:
                    continue
                x = int(stats[label, cv2.CC_STAT_LEFT])
                y0 = int(stats[label, cv2.CC_STAT_TOP])
                bw = int(stats[label, cv2.CC_STAT_WIDTH])
                bh = int(stats[label, cv2.CC_STAT_HEIGHT])
                if x <= 1 or y0 <= 1 or x + bw >= w - 2 or y0 + bh >= h - 2:
                    continue
                comp = labels == label
                ring = cv2.dilate(comp.astype(np.uint8) * 255, np.ones((15, 15), np.uint8), iterations=1) > 0
                ring &= ~comp
                if not np.any(ring):
                    continue
                subject_ring = float(np.mean(alpha_core[ring]))
                vivid_ring = float(np.mean(vivid_or_dark[ring]))
                if subject_ring < 0.36 or (vivid_ring < 0.12 and area > 2200):
                    continue
                remove[comp] = 255
            if not np.any(remove):
                return alpha
            remove = cv2.dilate(remove, np.ones((3, 3), np.uint8), iterations=1)
            soft = cv2.GaussianBlur(remove, (0, 0), 0.85).astype(np.float32) / 255.0
            return np.where(soft > 0, alpha * (1.0 - soft * 0.96), alpha)
        except Exception:
            return alpha

    def polish_ai_cutout_alpha(self, source_rgba, alpha_img):
        alpha_img = alpha_img.convert("L")
        if not HAS_CV2:
            return alpha_img.filter(ImageFilter.MedianFilter(size=3)).filter(ImageFilter.GaussianBlur(radius=0.35))
        try:
            source_rgb = np.array(source_rgba.convert("RGB"), dtype=np.uint8)
            alpha = np.array(alpha_img, dtype=np.float32)
            h, w = alpha.shape

            # Smooth jagged AI edges without erasing fur, hair, fingers or shoes.
            edge_band = (alpha > 2) & (alpha < 252)
            alpha_med = cv2.medianBlur(alpha.astype(np.uint8), 3).astype(np.float32)
            alpha_blur = cv2.GaussianBlur(alpha_med, (0, 0), 0.48)
            alpha = np.where(edge_band, alpha * 0.38 + alpha_blur * 0.62, alpha)

            rgb = source_rgb.astype(np.float32)
            hsv = cv2.cvtColor(source_rgb, cv2.COLOR_RGB2HSV)
            gray = cv2.cvtColor(source_rgb, cv2.COLOR_RGB2GRAY).astype(np.float32)
            sat = hsv[:, :, 1].astype(np.float32)
            maxc = np.max(rgb, axis=2)
            minc = np.min(rgb, axis=2)
            chroma = maxc - minc
            r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
            grad = cv2.GaussianBlur(cv2.magnitude(
                cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3),
                cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
            ), (0, 0), 0.65)

            border = max(4, int(min(w, h) * 0.035))
            border_samples = np.concatenate([
                rgb[:border, :, :].reshape(-1, 3),
                rgb[-border:, :, :].reshape(-1, 3),
                rgb[:, :border, :].reshape(-1, 3),
                rgb[:, -border:, :].reshape(-1, 3),
            ], axis=0)
            bg_rgb = np.median(border_samples, axis=0)
            bg_diff = np.sqrt(np.sum((rgb - bg_rgb) ** 2, axis=2))

            skin = (
                (r > 78) & (g > 42) & (b > 25) &
                (r > b * 1.03) & (r > g * 0.88) &
                (chroma > 8) & (sat > 12) & (sat < 190)
            )
            vivid_or_dark_subject = (sat > 62) | (chroma > 58) | (gray < 78)
            edge_detail = cv2.Canny(source_rgb, 42, 135) > 0
            protected = skin | vivid_or_dark_subject | edge_detail

            bg_like = (
                (bg_diff < 58) &
                (sat < 64) &
                (chroma < 62) &
                (gray > 58) &
                (~protected)
            )

            # Reduce outside white/grey fringing only where the pixel still looks
            # like the original studio background. This avoids tinting nails and
            # keeps textured fur/latex edges alive.
            fringe = (
                (alpha > 8) & (alpha < 210) &
                bg_like &
                (grad < 24)
            )
            fringe_soft = cv2.GaussianBlur(fringe.astype(np.uint8) * 255, (0, 0), 0.9).astype(np.float32) / 255.0
            alpha = np.where(fringe_soft > 0, alpha * (1.0 - fringe_soft * 0.72), alpha)

            # Remove small enclosed pockets of old background, such as a spot left
            # inside a hair/accessory loop. Large flat white clothing pieces are
            # ignored by area limits and by the surrounding-ring test.
            solid = (alpha > 128).astype(np.uint8) * 255
            candidate = (
                (alpha > 118) &
                bg_like &
                (grad < 18)
            ).astype(np.uint8)
            count, labels, stats, _centroids = cv2.connectedComponentsWithStats(candidate, connectivity=8)
            pocket = np.zeros((h, w), np.uint8)
            max_area = max(120, int(w * h * 0.008))
            for label in range(1, count):
                area = int(stats[label, cv2.CC_STAT_AREA])
                if area < 18 or area > max_area:
                    continue
                x = int(stats[label, cv2.CC_STAT_LEFT])
                y0 = int(stats[label, cv2.CC_STAT_TOP])
                bw = int(stats[label, cv2.CC_STAT_WIDTH])
                bh = int(stats[label, cv2.CC_STAT_HEIGHT])
                if x <= 1 or y0 <= 1 or x + bw >= w - 2 or y0 + bh >= h - 2:
                    continue
                comp = labels == label
                ring = cv2.dilate(comp.astype(np.uint8) * 255, np.ones((13, 13), np.uint8), iterations=1) > 0
                ring &= ~comp
                if not np.any(ring):
                    continue
                subject_ring = float(np.mean(solid[ring] > 0))
                protected_ring = float(np.mean(protected[ring]))
                if subject_ring < 0.34 and protected_ring < 0.12:
                    continue
                pocket[comp] = 255
            if np.any(pocket):
                pocket = cv2.dilate(pocket, np.ones((3, 3), np.uint8), iterations=1)
                pocket_soft = cv2.GaussianBlur(pocket, (0, 0), 0.85).astype(np.float32) / 255.0
                alpha = np.where(pocket_soft > 0, alpha * (1.0 - pocket_soft * 0.98), alpha)

            alpha = np.clip(alpha, 0, 255).astype(np.uint8)
            return Image.fromarray(alpha, "L")
        except Exception:
            return alpha_img.filter(ImageFilter.MedianFilter(size=3)).filter(ImageFilter.GaussianBlur(radius=0.35))

    def decontaminate_cutout_edges(self, source_rgba, alpha_img):
        if not HAS_CV2:
            return source_rgba.convert("RGB")
        try:
            rgb = np.array(source_rgba.convert("RGB"), dtype=np.float32)
            alpha = np.array(alpha_img.convert("L"), dtype=np.float32)
            h, w = alpha.shape
            if h < 8 or w < 8:
                return source_rgba.convert("RGB")

            solid_fg = (alpha > 222).astype(np.float32)
            if float(np.mean(solid_fg)) < 0.002:
                return source_rgba.convert("RGB")

            # Pull semi-transparent edge pixels toward nearby confident subject
            # colors. This removes white/grey studio contamination that becomes
            # visible as a halo on dark replacement backgrounds.
            radius = max(2.2, min(7.0, min(w, h) / 260.0))
            fg_weight = cv2.GaussianBlur(solid_fg, (0, 0), radius)
            fg_rgb = cv2.GaussianBlur(rgb * solid_fg[:, :, None], (0, 0), radius)
            fg_color = fg_rgb / np.maximum(fg_weight[:, :, None], 0.001)

            hsv = cv2.cvtColor(np.clip(rgb, 0, 255).astype(np.uint8), cv2.COLOR_RGB2HSV)
            gray = cv2.cvtColor(np.clip(rgb, 0, 255).astype(np.uint8), cv2.COLOR_RGB2GRAY).astype(np.float32)
            sat = hsv[:, :, 1].astype(np.float32)
            maxc = np.max(rgb, axis=2)
            minc = np.min(rgb, axis=2)
            chroma = maxc - minc

            border = max(4, int(min(w, h) * 0.035))
            border_samples = np.concatenate([
                rgb[:border, :, :].reshape(-1, 3),
                rgb[-border:, :, :].reshape(-1, 3),
                rgb[:, :border, :].reshape(-1, 3),
                rgb[:, -border:, :].reshape(-1, 3),
            ], axis=0)
            bg_rgb = np.median(border_samples, axis=0)
            bg_diff = np.sqrt(np.sum((rgb - bg_rgb) ** 2, axis=2))
            bg_like = (
                (bg_diff < 68) &
                (sat < 82) &
                (chroma < 82) &
                (gray > 42)
            )

            semi_edge = (alpha > 1) & (alpha < 252) & (fg_weight > 0.025)
            fringe_edge = semi_edge & (bg_like | (alpha < 216))
            if not np.any(fringe_edge):
                return source_rgba.convert("RGB")

            strength = np.clip((252.0 - alpha) / 170.0, 0.18, 0.86)
            strength = np.where(bg_like, np.maximum(strength, 0.52), strength)
            strength = np.where(fringe_edge, strength, 0.0)
            cleaned = rgb * (1.0 - strength[:, :, None]) + fg_color * strength[:, :, None]
            return Image.fromarray(np.clip(cleaned, 0, 255).astype(np.uint8), "RGB")
        except Exception:
            return source_rgba.convert("RGB")

    def render_ai_cutout_fast(self, source, session=None, use_alpha_matting=False, model_name=None):
        original_size = source.size
        work = source.copy()
        model_hint = (getattr(self, "rembg_session_model_name", "") if model_name == "best" else model_name) or getattr(self, "rembg_session_model_name", "") or ""
        max_side = 2400 if model_hint.startswith("birefnet") else 2600
        if max(work.size) > max_side:
            work.thumbnail((max_side, max_side), Image.LANCZOS)

        try:
            active_session = session or self.rembg_session
            if use_alpha_matting:
                cutout = rembg_remove(
                    work,
                    session=active_session,
                    alpha_matting=True,
                    alpha_matting_foreground_threshold=240,
                    alpha_matting_background_threshold=10,
                    alpha_matting_erode_size=8
                )
            else:
                cutout = rembg_remove(work, session=active_session)
        except Exception:
            # If PyMatting metadata is missing or alpha matting fails, still keep
            # the local AI cutout alive instead of crashing the tool.
            cutout = rembg_remove(work, session=session or self.rembg_session)
        if not isinstance(cutout, Image.Image):
            cutout = Image.open(cutout).convert("RGBA")
        else:
            cutout = cutout.convert("RGBA")

        if use_alpha_matting:
            cutout = self.refine_ai_cutout_alpha(work, cutout)
        alpha = cutout.getchannel("A")
        if alpha.size != original_size:
            alpha = alpha.resize(original_size, Image.Resampling.LANCZOS)
        alpha = self.polish_ai_cutout_alpha(source, alpha)

        result = self.decontaminate_cutout_edges(source, alpha).convert("RGBA")
        result.putalpha(alpha)
        return result

    def finish_background_ai_matte(self, cutout=None, error=None, status_key="bg_ai_done"):
        self.bg_ai_processing = False
        self.set_busy(False)
        if error is not None:
            messagebox.showerror(APP_TITLE, str(error))
            self.set_status(self.t("bg_ai_missing"))
            return
        if cutout is None:
            self.set_status(self.t("bg_ai_missing"))
            return
        self.start_background_placement(cutout)
        self.set_status(self.t(status_key))

    def start_background_ai_matte(self, model_name="birefnet-general-lite", status_key="bg_ai_done"):
        if not self.require_image():
            return
        if self.bg_ai_processing:
            self.set_status(self.t("bg_ai_start"))
            return
        if not ensure_rembg_loaded():
            self.set_status(self.t("bg_ai_missing"))
            detail = f"\n\nDetail technique: {REMBG_IMPORT_ERROR}" if REMBG_IMPORT_ERROR else ""
            messagebox.showwarning(APP_TITLE, self.t("bg_ai_missing") + detail)
            return
        self.bg_ai_processing = True
        self.set_busy(True, self.t("bg_ai_start"))
        self.set_status(self.t("bg_ai_start"))
        try:
            source = self.image.convert("RGBA")
        except Exception as exc:
            self.bg_ai_processing = False
            self.set_busy(False)
            messagebox.showerror(APP_TITLE, str(exc))
            self.set_status(self.t("bg_ai_missing"))
            return

        def worker():
            try:
                if model_name == "best":
                    self.rembg_session = self.create_best_rembg_session()
                    session = self.rembg_session
                else:
                    session = self.get_rembg_session_for_model(model_name)
                cutout = self.render_ai_cutout_fast(source, session=session, use_alpha_matting=False, model_name=model_name)
            except Exception as exc:
                self.root.after(0, lambda err=exc: self.finish_background_ai_matte(error=err, status_key=status_key))
                return
            self.root.after(0, lambda result=cutout: self.finish_background_ai_matte(cutout=result, status_key=status_key))

        threading.Thread(target=worker, daemon=True).start()

    def replace_background_ai_matte(self):
        self.start_background_ai_matte("best", "bg_ai_done")

    def replace_background_ai_matte_alternative(self):
        self.start_background_ai_matte("u2net", "bg_ai_alt_done")

    def reset_advanced_slider_values_only(self):
        if getattr(self, "advanced_preview_after_id", None) is not None:
            try:
                self.root.after_cancel(self.advanced_preview_after_id)
            except Exception:
                pass
            self.advanced_preview_after_id = None
        for v in [self.saturation_value, self.advanced_brightness_value, self.advanced_contrast_value, self.advanced_vibrance_value, self.advanced_shadows_value, self.advanced_highlights_value, self.advanced_sharpness_value, self.advanced_clarity_value, self.advanced_denoise_value, self.temperature_value, self.tint_value, self.red_value, self.green_value, self.blue_value]:
            v.set(0)

    def get_advanced_slider_state(self):
        return {
            "saturation": int(self.saturation_value.get()),
            "brightness": int(self.advanced_brightness_value.get()),
            "contrast": int(self.advanced_contrast_value.get()),
            "vibrance": int(self.advanced_vibrance_value.get()),
            "shadows": int(self.advanced_shadows_value.get()),
            "highlights": int(self.advanced_highlights_value.get()),
            "sharpness": int(self.advanced_sharpness_value.get()),
            "clarity": int(self.advanced_clarity_value.get()),
            "denoise": int(self.advanced_denoise_value.get()),
            "temperature": int(self.temperature_value.get()),
            "tint": int(self.tint_value.get()),
            "red": int(self.red_value.get()),
            "green": int(self.green_value.get()),
            "blue": int(self.blue_value.get()),
        }

    def restore_advanced_slider_state(self, state):
        if getattr(self, "advanced_preview_after_id", None) is not None:
            try:
                self.root.after_cancel(self.advanced_preview_after_id)
            except Exception:
                pass
            self.advanced_preview_after_id = None
        if not isinstance(state, dict):
            return
        mapping = [
            ("saturation", self.saturation_value),
            ("brightness", self.advanced_brightness_value),
            ("contrast", self.advanced_contrast_value),
            ("vibrance", self.advanced_vibrance_value),
            ("shadows", self.advanced_shadows_value),
            ("highlights", self.advanced_highlights_value),
            ("sharpness", self.advanced_sharpness_value),
            ("clarity", self.advanced_clarity_value),
            ("denoise", self.advanced_denoise_value),
            ("temperature", self.temperature_value),
            ("tint", self.tint_value),
            ("red", self.red_value),
            ("green", self.green_value),
            ("blue", self.blue_value),
        ]
        for key, variable in mapping:
            try:
                variable.set(int(state.get(key, 0)))
            except Exception:
                variable.set(0)

    def reset_color_sliders(self):
        self.reset_advanced_slider_values_only()
        if self.advanced_live_base is not None:
            self.image = self.advanced_live_base.copy()
            self.advanced_live_base = None
            self.advanced_live_base_array = None
            self.refresh_canvas()


    def on_advanced_color_slider_change(self, _value=None):
        """Keep advanced sliders responsive by delaying the heavy photo render slightly."""
        if self.image is None:
            return
        if getattr(self, "advanced_preview_after_id", None) is not None:
            try:
                self.root.after_cancel(self.advanced_preview_after_id)
            except Exception:
                pass
        self.advanced_preview_after_id = self.root.after(70, self.apply_advanced_color_slider_change)

    def apply_advanced_color_slider_change(self):
        self.advanced_preview_after_id = None
        if self.image is None:
            return

        sat, brightness, contrast, vibrance, shadows, highlights, sharpness, clarity, denoise, temp, tint, red, green, blue = [v.get() for v in [
            self.saturation_value,
            self.advanced_brightness_value,
            self.advanced_contrast_value,
            self.advanced_vibrance_value,
            self.advanced_shadows_value,
            self.advanced_highlights_value,
            self.advanced_sharpness_value,
            self.advanced_clarity_value,
            self.advanced_denoise_value,
            self.temperature_value,
            self.tint_value,
            self.red_value,
            self.green_value,
            self.blue_value
        ]]

        if sat == brightness == contrast == vibrance == shadows == highlights == sharpness == clarity == denoise == temp == tint == red == green == blue == 0:
            if self.advanced_live_base is not None:
                self.image = self.advanced_live_base.copy()
                self.advanced_live_base = None
                self.advanced_live_base_array = None
                self.refresh_canvas()
            return

        if self.advanced_live_base is None:
            self.push_undo()
            self.advanced_live_base = self.image.copy()
            self.advanced_live_base_array = None

        base = self.advanced_live_base.convert("RGB")
        r_gain = 1 + (temp / 100) * 0.45 + (red / 100) * 0.45
        g_gain = 1 + (green / 100) * 0.45 - abs(tint / 100) * 0.05
        b_gain = 1 - (temp / 100) * 0.45 + (blue / 100) * 0.45

        if tint > 0:
            r_gain += (tint / 100) * 0.22
            b_gain += (tint / 100) * 0.22
            g_gain -= (tint / 100) * 0.16
        elif tint < 0:
            g_gain += (-tint / 100) * 0.25
            r_gain -= (-tint / 100) * 0.12
            b_gain -= (-tint / 100) * 0.12

        if HAS_CV2:
            if (
                getattr(self, "advanced_live_base_array", None) is None or
                self.advanced_live_base_array.shape[0] != base.height or
                self.advanced_live_base_array.shape[1] != base.width
            ):
                self.advanced_live_base_array = self.pil_to_cv_rgb(base).astype(np.float32)
            arr = self.advanced_live_base_array.copy()
            gray = arr[:, :, 0] * 0.299 + arr[:, :, 1] * 0.587 + arr[:, :, 2] * 0.114
            sat_amount = (sat / 100.0) * 2.2
            arr = gray[:, :, None] + (arr - gray[:, :, None]) * (1.0 + sat_amount)
            gains = np.array([r_gain, g_gain, b_gain], dtype=np.float32)
            arr *= gains
            contrast_factor = 1.0 + (contrast / 100.0) * 1.18
            arr = (arr - 128.0) * max(0.05, contrast_factor) + 128.0 + brightness * 1.95
            if shadows or highlights:
                lum = arr[:, :, 0] * 0.299 + arr[:, :, 1] * 0.587 + arr[:, :, 2] * 0.114
                if shadows:
                    weight = np.clip((128.0 - lum) / 128.0, 0.0, 1.0) ** 1.15
                    arr += (shadows / 100.0) * 45.0 * weight[:, :, None]
                if highlights:
                    weight = np.clip((lum - 128.0) / 127.0, 0.0, 1.0) ** 1.10
                    arr += (highlights / 100.0) * 42.0 * weight[:, :, None]
            if vibrance:
                lum = arr[:, :, 0] * 0.299 + arr[:, :, 1] * 0.587 + arr[:, :, 2] * 0.114
                gray2 = lum[:, :, None]
                chroma = np.max(arr, axis=2) - np.min(arr, axis=2)
                weight = np.clip(1.0 - chroma / 155.0, 0.18, 1.0)
                amount = (vibrance / 100.0) * 0.75 * weight[:, :, None]
                arr = gray2 + (arr - gray2) * (1.0 + amount)
            arr = np.clip(arr, 0, 255)
            if denoise > 0:
                amount = max(0, min(100, int(denoise)))
                # Edge-aware local cleanup. Below 3MP it uses bilateral filtering;
                # above that it switches to a lighter blur blend so sliders stay responsive.
                if arr.shape[0] * arr.shape[1] <= 1600000:
                    filtered = cv2.bilateralFilter(arr.astype(np.uint8), 5 + 2 * int(amount > 45), 18 + amount * 0.62, 9 + amount * 0.22).astype(np.float32)
                else:
                    filtered = cv2.GaussianBlur(arr, (0, 0), 0.35 + amount / 100.0 * 0.85)
                blend = min(0.55, 0.10 + amount / 100.0 * 0.42)
                arr = np.clip(arr * (1.0 - blend) + filtered * blend, 0, 255)
            if clarity:
                amount = max(-50, min(100, int(clarity))) / 100.0
                lum = arr[:, :, 0] * 0.299 + arr[:, :, 1] * 0.587 + arr[:, :, 2] * 0.114
                radius = 2.1 if arr.shape[0] * arr.shape[1] <= 3000000 else 1.35
                local = cv2.GaussianBlur(lum, (0, 0), radius)
                detail = (lum - local)[:, :, None]
                arr = np.clip(arr + detail * (0.72 * amount), 0, 255)
            if sharpness > 0 and arr.shape[0] * arr.shape[1] <= 2600000:
                amount = max(0, min(100, int(sharpness)))
                blur = cv2.GaussianBlur(arr, (0, 0), 0.55 + amount / 100.0 * 1.35)
                arr = np.clip(arr + (arr - blur) * (0.75 + amount / 100.0 * 1.35), 0, 255)
            elif sharpness > 0:
                amount = max(0, min(100, int(sharpness)))
                blur = cv2.GaussianBlur(arr, (0, 0), 0.48 + amount / 100.0 * 0.65)
                arr = np.clip(arr + (arr - blur) * (0.22 + amount / 100.0 * 0.72), 0, 255)
            elif sharpness < 0:
                amount = abs(int(sharpness))
                arr = cv2.GaussianBlur(arr, (0, 0), amount / 50.0 * 1.5)
            self.image = self.cv_rgb_to_pil(arr, self.advanced_live_base)
            self.refresh_canvas()
            return

        saturation_factor = 1 + (sat / 100) * 2.6
        img = ImageEnhance.Color(base).enhance(max(0.0, saturation_factor))
        brightness_factor = 1 + (brightness / 100) * 1.35
        contrast_factor = 1 + (contrast / 100) * 1.25
        img = ImageEnhance.Brightness(img).enhance(max(0.05, brightness_factor))
        img = ImageEnhance.Contrast(img).enhance(max(0.05, contrast_factor))
        result = self.apply_rgb_gains(img, r_gain, g_gain, b_gain)
        result = self.apply_advanced_tone_controls(result, vibrance, shadows, highlights)
        if denoise > 0:
            amount = max(0, min(100, int(denoise)))
            result = result.filter(ImageFilter.GaussianBlur(radius=amount / 100.0 * 0.75))
        if clarity:
            amount = max(-50, min(100, int(clarity)))
            if amount > 0:
                result = result.filter(ImageFilter.UnsharpMask(radius=1.8, percent=35 + int(amount * 1.45), threshold=8))
            elif amount < 0:
                result = result.filter(ImageFilter.GaussianBlur(radius=abs(amount) / 50.0 * 0.65))
        if sharpness > 0:
            amount = max(0, min(100, int(sharpness)))
            result = result.filter(ImageFilter.UnsharpMask(
                radius=0.55 + amount / 100 * 1.45,
                percent=70 + int(amount * 4.4),
                threshold=max(0, 5 - int(amount / 25))
            ))
        elif sharpness < 0:
            amount = abs(int(sharpness))
            result = result.filter(ImageFilter.GaussianBlur(radius=amount / 50 * 1.6))
        self.image = result
        self.refresh_canvas()

    def auto_color(self):
        if not self.require_image():
            return
        self.push_undo()
        img = self.image.convert("RGB")
        sample = img.resize((max(1, img.width//12), max(1, img.height//12)))
        pixels = list(sample.getdata())
        avg_r = sum(p[0] for p in pixels)/len(pixels)
        avg_g = sum(p[1] for p in pixels)/len(pixels)
        avg_b = sum(p[2] for p in pixels)/len(pixels)
        avg = (avg_r+avg_g+avg_b)/3
        self.image = self.apply_rgb_gains(img, 1+((avg-avg_r)/255)*0.28, 1+((avg-avg_g)/255)*0.18, 1+((avg-avg_b)/255)*0.28)
        self.image = ImageEnhance.Color(self.image).enhance(1.04)
        self.refresh_canvas()

    def apply_manual_colors(self):
        self.apply_advanced_color_slider_change()


    def apply_rgb_gains(self, img, r_gain, g_gain, b_gain):
        img = img.convert("RGB")
        if HAS_CV2:
            arr = np.asarray(img, dtype=np.float32)
            gains = np.array([r_gain, g_gain, b_gain], dtype=np.float32)
            arr = np.clip(arr * gains, 0, 255).astype(np.uint8)
            return Image.fromarray(arr, "RGB")
        r, g, b = img.split()
        r = r.point(lambda value: max(0, min(255, int(value * r_gain))))
        g = g.point(lambda value: max(0, min(255, int(value * g_gain))))
        b = b.point(lambda value: max(0, min(255, int(value * b_gain))))
        return Image.merge("RGB", (r, g, b))

    def apply_advanced_tone_controls(self, img, vibrance, shadows, highlights):
        img = img.convert("RGB")
        if vibrance == shadows == highlights == 0:
            return img
        if HAS_CV2:
            arr = np.asarray(img, dtype=np.float32)
            lum = arr[:, :, 0] * 0.299 + arr[:, :, 1] * 0.587 + arr[:, :, 2] * 0.114
            if shadows:
                weight = np.clip((128.0 - lum) / 128.0, 0.0, 1.0) ** 1.15
                arr += (shadows / 100.0) * 45.0 * weight[:, :, None]
            if highlights:
                weight = np.clip((lum - 128.0) / 127.0, 0.0, 1.0) ** 1.10
                arr += (highlights / 100.0) * 42.0 * weight[:, :, None]
            if vibrance:
                lum2 = arr[:, :, 0] * 0.299 + arr[:, :, 1] * 0.587 + arr[:, :, 2] * 0.114
                gray = lum2[:, :, None]
                chroma = np.max(arr, axis=2) - np.min(arr, axis=2)
                weight = np.clip(1.0 - chroma / 155.0, 0.18, 1.0)
                amount = (vibrance / 100.0) * 0.75 * weight[:, :, None]
                arr = gray + (arr - gray) * (1.0 + amount)
            return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGB")

        if vibrance:
            img = ImageEnhance.Color(img).enhance(max(0.0, 1.0 + (vibrance / 100.0) * 0.65))
        if shadows or highlights:
            lut = []
            for value in range(256):
                lum = float(value)
                shadow_weight = max(0.0, min(1.0, (128.0 - lum) / 128.0)) ** 1.15
                highlight_weight = max(0.0, min(1.0, (lum - 128.0) / 127.0)) ** 1.10
                adjusted = lum + (shadows / 100.0) * 45.0 * shadow_weight + (highlights / 100.0) * 42.0 * highlight_weight
                lut.append(max(0, min(255, int(adjusted))))
            img = img.point(lut * 3)
        return img


def run_rembg_selftest(log_path=None):
    lines = []
    lines.append(f"HAS_REMBG={HAS_REMBG}")
    lines.append(f"REMBG_IMPORT_ERROR={REMBG_IMPORT_ERROR}")
    try:
        lines.append(f"HAS_CV2={HAS_CV2}")
        lines.append(f"HAS_DND={HAS_DND}")
        if not ensure_rembg_loaded():
            raise RuntimeError(REMBG_IMPORT_ERROR or "rembg import failed")
        test = Image.new("RGBA", (64, 64), (255, 255, 255, 255))
        draw = ImageDraw.Draw(test)
        draw.ellipse((18, 12, 46, 52), fill=(20, 20, 20, 255))
        model_used = None
        last_error = None
        session = None
        for model_name in ("birefnet-general-lite", "u2net", "isnet-general-use", "u2net_human_seg"):
            try:
                session = rembg_new_session(model_name)
                model_used = model_name
                break
            except Exception as exc:
                last_error = exc
        if session is None:
            raise last_error if last_error is not None else RuntimeError("rembg session failed")
        cutout = rembg_remove(test, session=session)
        if not isinstance(cutout, Image.Image):
            cutout = Image.open(cutout)
        lines.append(f"REMBG_MODEL={model_used}")
        lines.append(f"SELFTEST_RESULT=OK size={cutout.size} mode={cutout.mode}")
        ok = True
    except BaseException as exc:
        lines.append(f"SELFTEST_RESULT=ERROR {type(exc).__name__}: {exc}")
        ok = False
    text = "\n".join(lines)
    if log_path:
        try:
            with open(log_path, "w", encoding="utf-8") as f:
                f.write(text)
        except Exception:
            pass
    return 0 if ok else 1


def run_ui_selftest(log_path=None):
    lines = []

    def record(key, value):
        lines.append(f"{key}={value}")

    root = None
    ok = True
    try:
        root = TkinterDnD.Tk() if HAS_DND else tk.Tk()
        root.withdraw()
        app = LatexVampPhotoTool(root)
        root.update_idletasks()
        texts = []

        def collect_texts(widget):
            try:
                text = widget.cget("text")
                if text:
                    texts.append(str(text))
            except Exception:
                pass
            for child in widget.winfo_children():
                collect_texts(child)

        collect_texts(root)
        logo_button_texts = []
        for _choice_id, pair in getattr(app, "logo_choice_buttons", {}).items():
            btn, _label_key = pair
            logo_button_texts.append(btn.cget("text"))

        forbidden = [
            "Mon " + "logo perso",
            "Choix " + "1 - Simple",
            "Choix " + "2 - Minimal",
            "Choix " + "3 - Glossy",
            "Choix " + "4 - Logo",
            "Placer " + "le logo",
            "Retirer " + "mon logo",
            "Ajouter " + "une image par-dessus",
        ]
        record("NAV_LOGO", app.nav_buttons["logo_tab"][0].cget("text"))
        record("LOGO_TITLE", app.t("logo"))
        record("LOGO_BUTTON_COUNT", len(getattr(app, "logo_choice_buttons", {})))
        record("LOGO_BUTTONS", " | ".join(logo_button_texts))
        record("HAS_OLD_OPTIONMENU_ATTR", hasattr(app, "logo_choice_menu"))
        record("SUBTITLE_MAPPED", app.subtitle_label.winfo_ismapped())
        record("OLD_TEXT_FOUND", any(bad in text for bad in forbidden for text in texts))

        app.image = Image.new("RGB", (320, 240), (120, 90, 100))
        app.show_tab("logo_tab")
        app.toggle_logo_choice("simple_round")
        root.update_idletasks()
        app.refresh_canvas()
        root.update_idletasks()
        logo_item_before_switch = app.logo_preview_item_id
        logo_preview_started = bool(app.logo_preview_active and app.logo_choice_value.get() and logo_item_before_switch)
        app.show_tab("basic_tab")
        root.update_idletasks()
        canvas_items_after_logo_switch = set(app.image_canvas.find_all())
        logo_reset_on_leave = (
            not bool(app.logo_preview_active)
            and app.logo_choice_value.get() == ""
            and app.logo_path is None
            and not any((
                app.logo_preview_item_id,
                app.logo_preview_rect_id,
                app.logo_preview_rect_shadow_id,
            ))
            and logo_item_before_switch not in canvas_items_after_logo_switch
        )
        app.show_tab("text_logo_tab")
        app.text_input.delete("1.0", tk.END)
        app.text_input.insert("1.0", "Test")
        app.set_tool("text")
        app.text_preview_x = 160
        app.text_preview_y = 120
        app.refresh_canvas()
        root.update_idletasks()
        text_items_before_switch = tuple(app.text_preview_item_ids)
        text_preview_started = bool(text_items_before_switch)
        app.show_tab("advanced_tab")
        root.update_idletasks()
        canvas_items_after_text_switch = set(app.image_canvas.find_all())
        text_reset_on_leave = (
            app.tool != "text"
            and not bool(app.text_preview_item_ids)
            and app.text_preview_x is None
            and app.text_preview_y is None
            and app.get_current_text_value() == ""
            and not any(item in canvas_items_after_text_switch for item in text_items_before_switch)
        )
        app.show_tab("filters_tab")
        app.set_filter_preview("cinematicDark")
        app.crop_start = (10, 10)
        app.crop_end = (110, 110)
        app.rotation_preview_angle = 15
        root.update_idletasks()
        app.show_tab("size_tab")
        root.update_idletasks()
        global_temp_reset_on_tab_switch = (
            app.tool is None
            and app.filter_preview_name is None
            and app.rotation_preview_angle == 0
            and app.crop_start is None
            and app.crop_end is None
            and not bool(app.crop_rect_id)
            and not bool(getattr(app, "crop_preview_ids", []))
            and not bool(app.logo_preview_active)
            and app.logo_choice_value.get() == ""
            and app.text_preview_x is None
            and app.text_preview_y is None
            and app.get_current_text_value() == ""
        )
        record("LOGO_RESET_ON_LEAVE", logo_reset_on_leave)
        record("TEXT_RESET_ON_LEAVE", text_reset_on_leave)
        record("GLOBAL_TEMP_RESET_ON_TAB_SWITCH", global_temp_reset_on_tab_switch)
        record("HAS_ADVANCED_CLARITY", hasattr(app, "advanced_clarity_value"))
        record("HAS_ADVANCED_DENOISE", hasattr(app, "advanced_denoise_value"))
        record("HAS_CLEANUP_OPACITY", hasattr(app, "cleanup_opacity_value"))
        record("HAS_CHECKPOINT_BUTTONS", hasattr(app, "checkpoint_save_button") and hasattr(app, "checkpoint_restore_button"))
        record("HAS_EDGE_DECONTAMINATION", hasattr(app, "decontaminate_cutout_edges"))
        record("HAS_SIMPLE_FOCUSED_CONTROLS", all(hasattr(app, attr) for attr in (
            "auto_enhance_strength_value",
            "skin_redness_strength_value",
            "skin_face_strength",
            "skin_body_strength",
            "skin_legs_strength",
        )))
        record("HAS_FILTER_BLACK_WHITE", "filter_black_white" in app.widgets)
        record("APP_BUILD", APP_BUILD)

        checks = [
            app.nav_buttons["logo_tab"][0].cget("text") == "Logo / Objet",
            app.t("logo") == "Ajout: Logo / Copyright / Objet",
            len(getattr(app, "logo_choice_buttons", {})) == 4,
            logo_button_texts == [
                "Logo Simple rond",
                "Logo Minimal bouche",
                "Logo Glossy gothique",
                "Logo large / bannière",
            ],
            not hasattr(app, "logo_choice_menu"),
            not any(bad in text for bad in forbidden for text in texts),
            not bool(app.subtitle_label.winfo_ismapped()),
            logo_preview_started,
            logo_reset_on_leave,
            text_preview_started,
            text_reset_on_leave,
            global_temp_reset_on_tab_switch,
            hasattr(app, "advanced_clarity_value"),
            hasattr(app, "advanced_denoise_value"),
            hasattr(app, "cleanup_opacity_value"),
            hasattr(app, "checkpoint_save_button") and hasattr(app, "checkpoint_restore_button"),
            hasattr(app, "decontaminate_cutout_edges"),
            all(hasattr(app, attr) for attr in (
                "auto_enhance_strength_value",
                "skin_redness_strength_value",
                "skin_face_strength",
                "skin_body_strength",
                "skin_legs_strength",
            )),
            "filter_black_white" in app.widgets,
        ]
        ok = all(checks)
    except Exception as exc:
        ok = False
        record("UI_SELFTEST_ERROR", repr(exc))
    finally:
        try:
            if root is not None:
                root.destroy()
        except Exception:
            pass

    record("UI_SELFTEST_RESULT", "OK" if ok else "FAIL")
    output = "\n".join(lines)
    if log_path:
        try:
            with open(log_path, "w", encoding="utf-8") as handle:
                handle.write(output)
        except Exception:
            pass
    else:
        print(output)
    return 0 if ok else 1


if __name__ == "__main__":
    if "--lvpt-ui-selftest" in sys.argv:
        idx = sys.argv.index("--lvpt-ui-selftest")
        log_path = sys.argv[idx + 1] if len(sys.argv) > idx + 1 else None
        raise SystemExit(run_ui_selftest(log_path))
    if "--lvpt-rembg-selftest" in sys.argv:
        idx = sys.argv.index("--lvpt-rembg-selftest")
        log_path = sys.argv[idx + 1] if len(sys.argv) > idx + 1 else None
        raise SystemExit(run_rembg_selftest(log_path))
    root = TkinterDnD.Tk() if HAS_DND else tk.Tk()
    app = LatexVampPhotoTool(root)
    root.mainloop()
