{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":108394,"databundleVersionId":14167939,"sourceType":"competition"},{"sourceId":13637907,"sourceType":"datasetVersion","datasetId":8668775},{"sourceId":13637916,"sourceType":"datasetVersion","datasetId":8668781}],"dockerImageVersionId":31153,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import pandas as pd\nimport numpy as np\nimport os\nimport glob\nimport tensorflow as tf # 👈 AJOUT DE TENSORFLOW POUR LA CONFIGURATION\n\n# ======================================================================\n# 🚨 OPTIMISATION ANTI-EXPLOSION DE GRADIENT\n# Forcer la précision à float64 pour éviter les overflows/NaN en float32.\n# ======================================================================\ntry:\n    tf.keras.backend.set_floatx('float64')\n    print(\"✅ Précision du Backend Keras définie sur 'float64'.\")\nexcept Exception as e:\n    print(f\"❌ Avertissement : Échec de la définition de float64. Raison : {e}\")\n\n\n# Définition du chemin de base (ajusté selon votre structure fournie)\nBASE_PATH = \"/kaggle/input/h690/h690/h690\"\n# Assurez-vous que ce chemin est correct dans votre session Kaggle\nprint(f\"Chemin de base défini : {BASE_PATH}\")","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"PART 1: INITIAL STRATEGY AND SHIFT TO 2D CONTOUR ANALYSIS\n\n1. Initial High-Risk Strategy: 3D Point Cloud Approach\n\nPrimary Goal: To use 3D geometry for perfect fit matching.\nArchitecture: PointNet. \n\n3D Diagnostic Summary\n* **Methodology:** Processing raw 3D coordinates (x, y, z) of the fracture edges.\n* **Critical Issue Encountered:** **Exploding Gradients** (Gradient Explosion).\n* **Explanation:** The sensitivity to noise and subtle variations in the archaeological 3D scan data, combined with the complexity of the deep network layers, caused an exponential increase in gradient magnitude.\n* **Result:** Model divergence and **NaN** values, making training unstable and impossible to effectively regularize within the competition timeline.\n\n---\n\n2. Strategic Shift to Robust 2D Analysis\n\nPrimary Goal: Transition to stable and robust feature extraction.\nMethodology: Contour Extraction and Visual Feature Analysis. \n\nNew 2D Approach\n* **Basis:** Leveraging high-fidelity 2D images for stable feature extraction.\n* **Key Techniques:**\n    * **Contour Exploitation:** Simplification and analysis of the fracture edge geometry in 2D space (using shape descriptors).\n    * **Visual Features:** Extraction of **color**, **clay texture**, and **decorative patterns**.\n* **Result Validation:** These visual features proved highly effective, yielding a strong Normalized Mutual Information (NMI) score of **0.578** using Agglomerative Clustering.\n\nConclusion: This pivot allowed for a stable training environment and generated solid preliminary results, which are crucial for the final submission.","metadata":{}},{"cell_type":"code","source":"# Chemin d'accès au fichier de métadonnées\nMETADATA_PATH = os.path.join(BASE_PATH, 'jd_sherds_info.csv')\n\n# Charger le DataFrame principal\ndf_meta = pd.read_csv(METADATA_PATH)\n\nprint(f\"Chargement de {METADATA_PATH} réussi.\")\nprint(\"Aperçu des données :\")\nprint(df_meta.head())\nprint(\"\\nInformations sur les colonnes et valeurs manquantes :\")\nprint(df_meta.info())","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"IMAGE_DIR = os.path.join(BASE_PATH, 'sherd_images')\n\n# Créer une colonne pour l'ID du fragment\ndf_meta['sherd_id'] = df_meta['image_id'].apply(lambda x: x.split('_')[0])\n\n# Créer la colonne de chemin d'accès complet pour chaque image\ndef get_image_path(row):\n    # Les images sont nommées JDxxxxx_exterior.jpg ou JDxxxxx_interior.jpg\n    return os.path.join(IMAGE_DIR, f\"{row['image_id']}.jpg\")\n\ndf_meta['path'] = df_meta.apply(get_image_path, axis=1)\n\nprint(f\"\\nExemple de chemin d'accès créé: {df_meta['path'].iloc[0]}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 1. Nettoyage de la colonne 'unit' (couche stratigraphique)\n# On simplifie pour l'entraînement : extraction du numéro de la couche\ndef clean_unit(unit_str):\n    if pd.isna(unit_str):\n        return 'UNKNOWN'\n    if unit_str.startswith('L'):\n        return int(unit_str[1:]) # Convertit L01 -> 1, L14 -> 14\n    else:\n        # Regrouper M, Z et autres cas spéciaux\n        return 'UNKNOWN' \n\ndf_meta['layer_num'] = df_meta['unit'].apply(clean_unit)\ndf_meta['is_unknown_layer'] = (df_meta['layer_num'] == 'UNKNOWN')\n\n# 2. Filtrage par 'part' (Partie du vase)\n# Remplir les valeurs manquantes pour la cohérence\ndf_meta['part'] = df_meta['part'].fillna('UNKNOWN_PART')\n\nprint(\"\\nRépartition des fragments par partie (part):\")\nprint(df_meta['part'].value_counts())","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\nimport matplotlib.pyplot as plt\nimport os\n\n# Définition des variables de chemin (reprise des étapes précédentes)\n# Assumons que BASE_PATH et IMAGE_DIR sont définis:\n# BASE_PATH = \"/kaggle/input/h690/h690/h690\" \n# IMAGE_DIR = os.path.join(BASE_PATH, 'sherd_images') \n\n# Chemin d'accès à l'image du fragment JD00001 en utilisant la variable IMAGE_DIR\nEXAMPLE_IMG_PATH = os.path.join(IMAGE_DIR, 'JD00001_exterior.jpg')","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\nimport matplotlib.pyplot as plt\nimport os\nimport numpy as np\n\ndef extract_final_fragment_contour(image_path, show_plot=False):\n    \"\"\"\n    Isole et nettoie le contour du fragment sur un fond clair.\n    Utilise le Recadrage, Canny, la Dilatation (pour la fermeture) et le Masquage (pour le nettoyage).\n    \"\"\"\n    img = cv2.imread(image_path)\n    if img is None: \n        print(f\"Erreur de chargement de l'image : {image_path}\")\n        return None, None\n\n    # --- 1. Recadrage pour éliminer le cadre externe ---\n    h, w, _ = img.shape\n    crop_percent = 0.05 \n    crop_h = int(h * crop_percent)\n    crop_w = int(w * crop_percent)\n    img_cropped = img[crop_h:h-crop_h, crop_w:w-crop_w]\n    \n    img_gray = cv2.cvtColor(img_cropped, cv2.COLOR_BGR2GRAY)\n    \n    # --- 2. Flou Gaussien ---\n    img_blurred = cv2.GaussianBlur(img_gray, (5, 5), 0)\n    \n    # --- 3. Détection de Bords Canny ---\n    edges = cv2.Canny(img_blurred, 50, 150) \n    \n    # --- 4. Dilatation (pour fermer les bords) ---\n    kernel = np.ones((7, 7), np.uint8) \n    edges_dilated = cv2.dilate(edges, kernel, iterations=3) \n    \n    # --- 5. Trouver les contours sur les bords dilatés ---\n    contours, _ = cv2.findContours(edges_dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n\n    if not contours:\n        print(\"Aucun contour principal trouvé après Canny/Dilatation. Ajustez les hyperparamètres.\")\n        return None, None\n        \n    # Le plus grand contour est notre fragment (le seul à conserver)\n    largest_contour = max(contours, key=cv2.contourArea)\n    \n    # -------------------------------------------------------------------\n    # --- 6. Nettoyage du Contour (Création d'un Masque Propre) ---\n    # Pour éliminer les traits internes et les petits contours parasites,\n    # nous créons un masque rempli uniquement à partir du plus grand contour.\n    \n    clean_mask = np.zeros(img_cropped.shape[:2], dtype=np.uint8)\n    # Remplir le plus grand contour en blanc (255)\n    cv2.drawContours(clean_mask, [largest_contour], 0, 255, thickness=cv2.FILLED)\n    \n    # Maintenant, nous récupérons le contour du masque PROPRE\n    final_contours, _ = cv2.findContours(clean_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    final_largest_contour = final_contours[0] if final_contours else largest_contour\n    # -------------------------------------------------------------------\n\n    # --- 7. Affichage (pour vérification) ---\n    if show_plot:\n        contour_img = img_cropped.copy()\n        cv2.drawContours(contour_img, [final_largest_contour], -1, (0, 0, 255), 3) # Contour final en Bleu\n\n        plt.figure(figsize=(12, 5))\n        \n        plt.subplot(1, 3, 1)\n        plt.title(\"Bords Canny (Dilatés)\")\n        plt.imshow(edges_dilated, cmap='gray')\n        plt.axis('off')\n\n        plt.subplot(1, 3, 2)\n        plt.title(\"Masque Binaire Nettoyé\")\n        plt.imshow(clean_mask, cmap='gray')\n        plt.axis('off')\n\n        plt.subplot(1, 3, 3)\n        plt.title(\"Contour Final (Objet Isolé)\")\n        plt.imshow(cv2.cvtColor(contour_img, cv2.COLOR_BGR2RGB))\n        plt.axis('off')\n        plt.show()\n\n    # Le contour renvoyé est celui du masque propre\n    return final_largest_contour, clean_mask","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"PART 2: IMAGE PRE-PROCESSING AND CONTOUR EXTRACTION PIPELINE\n\n1. Contour Isolation and Cleaning (`extract_final_fragment_contour`)\n\nThe primary objective is to robustly isolate a clean, mathematically-defined contour of the fragment, removing all image artifacts and background noise.\n\nProcessing Steps:\n* **Cropping:** The image is first cropped by 5% on all sides (`crop_percent = 0.05`) to eliminate the surrounding frame, preventing it from interfering with subsequent edge detection.\n* **Pre-processing:** A **Gaussian Blur** (`(5, 5)`) is applied to smooth the image, reducing high-frequency noise inherent in the original images.\n* **Edge Detection:** The **Canny Edge Detector** is used (with thresholds `50` and `150`) to find the strong gradient where the fragment meets the white background.\n* **Contour Closing:** A **Dilatation** operation (`kernel=(7, 7)`, 3 iterations) is critical. This process effectively expands the detected edges to **close any small gaps** caused by lighting or minor surface inconsistencies, ensuring the fragment is seen as a single, continuous object. \n* **Final Masking:** The largest contour found is used to create a **clean binary mask** by filling it solid. This step mathematically eliminates internal markings, holes, and small, unrelated contours (clutter), yielding the final, pristine fragment outline.\n\n---\n\n2. Geometric Analysis: Fracture Edge Identification (`visualize_fracture_area`)\n\nThis function utilizes fundamental geometric properties to analyze the extracted contour, focusing on the highly informative fracture edges.\n\nConvex Hull Analysis:\n* **Technique:** The **Convex Hull** is calculated (`cv2.convexHull`). This is essentially the smallest convex polygon that encompasses the entire fragment. \n* **Principle:** Non-fracture edges (like the original rim or base) are usually smooth and convex. Fracture edges, however, are rough and concave. Therefore, the areas where the **original contour deviates significantly from the Convex Hull** represent the fracture edges crucial for assembly matching.\n* **Model Input:** While explicit **Convexity Defects** (the deviation points) are not directly extracted for the final DML input, the DML model receives the clean contour data. It is then responsible for **learning the distinction** between the smooth/convex segments and the jagged/concave fracture segments based on the point distribution. This allows the model to leverage the most valuable geometric features for the matching task.","metadata":{}},{"cell_type":"code","source":"import cv2\nimport matplotlib.pyplot as plt\nimport os\nimport numpy as np\n\ndef visualize_fracture_area(largest_contour, fragment_mask, img_cropped_shape, show_plot=True):\n    \"\"\"\n    Calcule et visualise l'Enveloppe Convexe pour identifier la zone de fracture.\n    \n    Args:\n        largest_contour (np.ndarray): Le contour principal nettoyé du fragment.\n        fragment_mask (np.ndarray): Le masque binaire propre du fragment (pour la taille).\n        img_cropped_shape (tuple): La forme de l'image recadrée originale (pour la visualisation).\n        show_plot (bool): Afficher la visualisation.\n    \"\"\"\n    if largest_contour is None or len(largest_contour) < 3:\n        print(\"Contour non valide pour l'analyse de convexité.\")\n        return largest_contour\n\n    # 1. Calculer l'Enveloppe Convexe (H)\n    convex_hull = cv2.convexHull(largest_contour, returnPoints=True)\n    \n    # NOTE: L'extraction des Défauts de Convexité (points 2 et 3) est laissée en commentaire.\n    # C'est la bonne approche théorique, mais pour simplifier le pipeline DML,\n    # nous laissons le DML apprendre la différence entre les segments lisses (Convex Hull)\n    # et les segments irréguliers (Fracture).\n    \n    # 2. Préparation de l'image de visualisation\n    # Créer une image 3 canaux (BGR) noire de la taille de l'image recadrée\n    img_viz = np.zeros(img_cropped_shape, dtype=np.uint8) \n    \n    # --- 3. Dessin des Contours ---\n    \n    # Dessiner le Masque/Fragment (Gris) comme arrière-plan visuel\n    cv2.drawContours(img_viz, [largest_contour], -1, (100, 100, 100), thickness=cv2.FILLED)\n\n    # Dessiner le Contour Original (Vert)\n    cv2.drawContours(img_viz, [largest_contour], -1, (0, 255, 0), 2)\n    \n    # Dessiner l'Enveloppe Convexe (Rouge)\n    # C'est la ligne rouge qui \"coupera les coins\" de la fracture\n    cv2.drawContours(img_viz, [convex_hull], -1, (255, 0, 0), 2) \n    \n    if show_plot:\n        plt.figure(figsize=(8, 8))\n        plt.title(\"Contour (Vert) vs. Enveloppe Convexe (Rouge)\")\n        plt.imshow(cv2.cvtColor(img_viz, cv2.COLOR_BGR2RGB))\n        plt.axis('off')\n        plt.show()\n\n    # Pour l'entrée du DML, nous retournons le contour entier.\n    return largest_contour","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Assurez-vous d'avoir défini EXAMPLE_IMG_PATH et IMAGE_DIR dans une cellule précédente\n# import os\n# IMAGE_DIR = \"/kaggle/input/h690/h690/h690/sherd_images\"\n# EXAMPLE_IMG_PATH = os.path.join(IMAGE_DIR, 'JD00001_exterior.jpg') \n\n# ----------------------------------------------------------------------\n# Étape 1 : Extraction et Nettoyage du Contour du Fragment\n# ----------------------------------------------------------------------\nfinal_contour, clean_mask = extract_final_fragment_contour(EXAMPLE_IMG_PATH, show_plot=True) \n\nif final_contour is not None:\n    print(f\"\\nContour extrait avec succès : {len(final_contour)} points. Prêt pour l'analyse de convexité.\")\n    \n    # Récupérer la forme de l'image recadrée pour la visualisation (étape 7 de la fonction précédente)\n    # Si vous avez besoin de la forme exacte de l'image recadrée (pour aligner les couches)\n    # on peut la déduire de la taille du masque\n    img_cropped_shape = (*clean_mask.shape, 3) \n    \n    # ------------------------------------------------------------------\n    # Étape 2 : Analyse de Convexité (Isolation de la Zone de Fracture)\n    # ------------------------------------------------------------------\n    fracture_contour = visualize_fracture_area(\n        final_contour, \n        clean_mask, \n        img_cropped_shape, \n        show_plot=True\n    )\n\n    if fracture_contour is not None:\n        print(\"\\nAnalyse de la zone de fracture réussie. Prêt pour la normalisation 3D.\")\n\nelse:\n    print(\"Échec de l'extraction. Ajustez les hyperparamètres (Canny/Dilatation).\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nimport os\nimport glob\nfrom typing import Dict, List, Tuple, Callable\nimport itertools\nimport random\nimport numpy as np\nfrom sklearn.model_selection import train_test_split\nimport math\nfrom tensorflow.keras.utils import Sequence\nimport tensorflow as tf\n# Imports nécessaires pour le traitement d'image 2D (OpenCV)\nimport cv2\n\n# ======================================================================\n# 0. DÉFINITION DES CHEMINS & HYPERPARAMÈTRES SIAMESE 2D\n# ======================================================================\n\n# REMPLACER CETTE VALEUR PAR LE VRAI CHEMIN RACINE DE VOS DONNÉES.\n# NOTE: Le chemin fourni ici est celui de votre environnement Kaggle/Notebook.\nBASE_PATH = '/kaggle/input/h690/h690/h690/'\nIMAGE_DIR = os.path.join(BASE_PATH, 'sherd_images')\nINFO_FILE_PATH = os.path.join(BASE_PATH, 'jd_sherds_info.csv')\n\n# --- CHEMINS DE MISE EN CACHE ---\nOUTPUT_DIR = '/kaggle/working/processed_data' # Dossier de sortie (doit exister ou être créé)\nCACHE_FILE = os.path.join(OUTPUT_DIR, 'all_image_masks_cache.npz')\n# ----------------------------------------\n\n# --- CNN 2D HYPERPARAMÈTRES ---\nIMG_HEIGHT = 224    # Taille standard des images pour les CNN 2D\nIMG_WIDTH = 224\nIMG_CHANNELS = 1    # 1 pour le masque (noir et blanc)\n\n# Définition des noms de colonnes :\nFRAGMENT_COL = 'sherd_id'\nUNIT_COL = 'unit'\nTYPE_COL = 'type'\n\nprint(f\"BASE_PATH : {BASE_PATH}\")\nprint(f\"CACHE_FILE: {CACHE_FILE}\")\n\n# ======================================================================\n# D. DÉFINITION DE LA CLASSE DMLDataGenerator (Adapté à 2D Siamese)\n# ======================================================================\n\n# Récupérer le type de données actuel du backend\nDTYPE_FLOAT = tf.keras.backend.floatx()\nDTYPE_INT = np.uint8 # Pour les images (0-255)\n\nclass DMLDataGenerator(Sequence):\n    \"\"\"\n    Générateur de données Keras pour le réseau Siamese 2D.\n    Charge les masques 2D (contours) et les met en forme (H, W, C).\n    Utilise le format Pairwise ([image_A, image_B], label)\n    \"\"\"\n\n    def __init__(self, dml_pairs: List[Tuple[str, str, int]], all_image_masks: Dict[str, np.ndarray], batch_size: int = 32, shuffle: bool = True):\n        self.dml_pairs = dml_pairs\n        self.all_image_masks = all_image_masks # Stocke les masques 2D (H, W)\n        self.batch_size = batch_size\n        self.shuffle = shuffle\n        \n        # Détermination de la forme d'entrée (H, W, C)\n        if all_image_masks:\n            # Récupère la forme du premier masque dans le dictionnaire\n            mask_h, mask_w = next(iter(all_image_masks.values())).shape\n            self.input_shape = (mask_h, mask_w, IMG_CHANNELS)\n        else:\n            self.input_shape = (IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS)\n            \n        self.on_epoch_end()\n\n    def __len__(self):\n        \"\"\" Nombre de lots par époque \"\"\"\n        return math.ceil(len(self.dml_pairs) / self.batch_size)\n\n    def __getitem__(self, index):\n        \"\"\" Génère un lot de données \"\"\"\n        start_index = index * self.batch_size\n        end_index = min((index + 1) * self.batch_size, len(self.dml_pairs))\n        indexes = self.indexes[start_index:end_index]\n\n        batch_pairs = [self.dml_pairs[k] for k in indexes]\n\n        # X_A et X_B sont des TENSEURS 4D (BATCH, H, W, C)\n        X_A = np.empty((len(batch_pairs), *self.input_shape), dtype=DTYPE_FLOAT)\n        X_B = np.empty((len(batch_pairs), *self.input_shape), dtype=DTYPE_FLOAT)\n        # Label pour la perte binaire\n        Y = np.empty((len(batch_pairs), 1), dtype=DTYPE_FLOAT) \n\n        for i, (id_A, id_B, label) in enumerate(batch_pairs):\n            \n            # Récupération du masque 2D (H, W)\n            mask_A = self.all_image_masks.get(id_A, np.zeros(self.input_shape[:2], dtype=DTYPE_INT))\n            mask_B = self.all_image_masks.get(id_B, np.zeros(self.input_shape[:2], dtype=DTYPE_INT))\n\n            # 1. Mise à l'échelle et conversion en float (0.0 à 1.0)\n            img_A = mask_A.astype(DTYPE_FLOAT) / 255.0\n            img_B = mask_B.astype(DTYPE_FLOAT) / 255.0\n            \n            # 2. Ajout du canal (H, W) -> (H, W, 1)\n            X_A[i,] = np.expand_dims(img_A, axis=-1)\n            X_B[i,] = np.expand_dims(img_B, axis=-1)\n            \n            # Utilisation du label pour la Perte Binaire (MLP en sortie)\n            Y[i,] = label\n\n        # Sortie pour un entraînement Pairwise (deux entrées, une cible)\n        return ([X_A, X_B], Y)\n\n    def on_epoch_end(self):\n        \"\"\" Mélanger les indices après chaque époque si `shuffle` est vrai \"\"\"\n        self.indexes = np.arange(len(self.dml_pairs))\n        if self.shuffle:\n            np.random.shuffle(self.indexes)\n\n    @property\n    def output_signature(self):\n        \"\"\" Définition de la signature de sortie pour tf.data.Dataset.from_generator \"\"\"\n        input_spec = [\n            tf.TensorSpec(shape=(None, *self.input_shape), dtype=DTYPE_FLOAT),\n            tf.TensorSpec(shape=(None, *self.input_shape), dtype=DTYPE_FLOAT)\n        ]\n        target_spec = tf.TensorSpec(shape=(None, 1), dtype=DTYPE_FLOAT)\n        \n        return (input_spec, target_spec)\n\n\n# ======================================================================\n# A. DÉFINITION DE LA FONCTION 1 : Traitement de Tous les Fragments (2D Contour)\n# ======================================================================\n\ndef process_all_fragments_by_id(image_dir: str, contour_extractor: Callable) -> Dict[str, np.ndarray]:\n    \"\"\"\n    Charge les images, extrait les contours 2D (masques) et les redimensionne.\n    Retourne un dictionnaire (ID_Vue -> Masque 2D redimensionné).\n    \"\"\"\n    \n    all_image_masks = {}\n    \n    # Trouver toutes les vues (exterior et interior)\n    image_paths = glob.glob(os.path.join(image_dir, '*_exterior.jpg'))\n    image_paths.extend(glob.glob(os.path.join(image_dir, '*_interior.jpg')))\n    \n    print(f\"Tentative de traitement de {len(image_paths)} images...\")\n\n    for i, path in enumerate(image_paths):\n        full_id = os.path.basename(path).replace('.jpg', '')\n        \n        # La fonction contour_extractor est 'extract_final_fragment_contour'\n        _, mask = contour_extractor(path, show_plot=False)\n        \n        if mask is not None:\n            # Redimensionnement du masque à la taille standard CNN (224x224)\n            mask_resized = cv2.resize(mask, (IMG_WIDTH, IMG_HEIGHT), interpolation=cv2.INTER_NEAREST)\n            all_image_masks[full_id] = mask_resized\n        else:\n            # Utiliser un masque vide si l'extraction échoue\n            all_image_masks[full_id] = np.zeros((IMG_HEIGHT, IMG_WIDTH), dtype=DTYPE_INT)\n            \n        if (i + 1) % 5000 == 0:\n            print(f\"  Fragments traités: {i+1}\")\n            \n    print(f\"Traitement terminé. {len(all_image_masks)} masques 2D (contours) générés/simulés.\")\n    return all_image_masks\n\n# ----------------------------------------------------------------------\n\n# ======================================================================\n# B. DÉFINITION DE LA FONCTION 2 : Création des Paires DML (FILTRAGE CLÉ)\n# ======================================================================\n\ndef create_dml_pairs(all_image_masks: Dict[str, np.ndarray], assembly_map_full: Dict[str, str]) -> List[Tuple[str, str, int]]:\n    \"\"\"\n    Crée les paires positives (Label=1) et négatives (Label=0), \n    en ne conservant QUE les paires basées sur les vues EXTERIEURES.\n    \"\"\"\n    \n    # Filtrer les clés pour ne garder que les vues EXTERNES qui ont un masque ET une carte d'assemblage\n    all_external_fragments = [\n        k for k in all_image_masks.keys() \n        if k.endswith('_exterior') and k in assembly_map_full\n    ]\n\n    # Reconstruire une carte d'assemblage limitée aux vues externes uniquement\n    assembly_map_external = {k: assembly_map_full[k] for k in all_external_fragments}\n\n    # 1. Paires Positives (Label=1)\n    positive_pairs = []\n    groups = {}\n    for full_id, group in assembly_map_external.items():\n        if group not in groups: groups[group] = []\n        groups[group].append(full_id)\n        \n    for sherd_list in groups.values():\n        if len(sherd_list) >= 2:\n            # Créer des paires POSITIVES (toujours en utilisant les IDs EXTERNES)\n            for sherd_A, sherd_B in itertools.combinations(sherd_list, 2):\n                positive_pairs.append(tuple(sorted((sherd_A, sherd_B))) + (1,))\n                \n    df_positive_pairs = pd.DataFrame(positive_pairs, columns=['sherd_A', 'sherd_B', 'label']).drop_duplicates()\n    n_positives = len(df_positive_pairs)\n    positive_pairs = list(df_positive_pairs.itertuples(index=False, name=None))\n    print(f\"  - {n_positives} Paires POSITIVES générées (uniquement EXTERNES).\")\n\n    # 2. Paires Négatives (Label=0)\n    N_NEG_TARGET = max(100, n_positives * 2) # Cible 2x plus de négatifs que de positifs\n    all_labeled_external_fragments = list(assembly_map_external.keys())\n    \n    num_attempts = int(N_NEG_TARGET * 3)\n    if len(all_labeled_external_fragments) < 2:\n        print(\"ATTENTION: Pas assez de fragments externes labellisés pour générer des négatifs.\")\n        return positive_pairs\n        \n    # Choisir aléatoirement parmi la liste des fragments EXTERNES\n    random_ids_A = np.random.choice(all_labeled_external_fragments, size=num_attempts, replace=True)\n    random_ids_B = np.random.choice(all_labeled_external_fragments, size=num_attempts, replace=True)\n\n    negative_pairs = []\n    # Créer un ensemble des paires positives vues pour éviter la contamination\n    seen_pairs = set(df_positive_pairs[['sherd_A', 'sherd_B']].apply(tuple, axis=1).tolist())\n\n    for id_A, id_B in zip(random_ids_A, random_ids_B):\n        if id_A == id_B: continue\n        \n        ordered_pair = tuple(sorted((id_A, id_B)))\n        \n        group_A = assembly_map_external.get(id_A)\n        group_B = assembly_map_external.get(id_B)\n\n        # La paire est négative si elle n'est pas positive, que les fragments sont dans des groupes différents, et non déjà vue\n        if group_A and group_B and group_A != group_B and ordered_pair not in seen_pairs:\n            seen_pairs.add(ordered_pair)\n            negative_pairs.append(ordered_pair + (0,))\n\n            if len(negative_pairs) >= N_NEG_TARGET:\n                break\n    \n    negative_pairs = negative_pairs[:N_NEG_TARGET]\n    n_negatives = len(negative_pairs)\n    print(f\"  - {n_negatives} Paires NÉGATIVES générées (uniquement EXTERNES).\")\n\n    # 3. Dataset Final\n    all_dml_pairs = positive_pairs + negative_pairs\n    random.shuffle(all_dml_pairs)\n\n    return all_dml_pairs\n\n# ----------------------------------------------------------------------\n\n# ======================================================================\n# C. LOGIQUE D'EXÉCUTION DU PIPELINE DML (DÉMARRAGE + DIVISION TRAIN/VAL)\n# ======================================================================\n\n# Charger le fichier d'information complet\ntry:\n    df_info = pd.read_csv(INFO_FILE_PATH)\nexcept FileNotFoundError:\n    print(f\"❌ ERREUR: Le fichier {INFO_FILE_PATH} est introuvable. Vérifiez BASE_PATH.\")\n    # Utiliser raise au lieu de exit() dans un environnement notebook\n    raise FileNotFoundError(f\"Le fichier {INFO_FILE_PATH} est introuvable.\")\n\n# 1. CRÉATION DU SIGNAL DE VÉRITÉ TERRAIN (unit + type)\ndf_info['ASSEMBLY_GROUP_ID'] = df_info[UNIT_COL].astype(str) + '_' + df_info[TYPE_COL].astype(str)\ndf_info_labeled = df_info.dropna(subset=[UNIT_COL, TYPE_COL]).copy()\nASSEMBLY_MAP_BASE = df_info_labeled.set_index(FRAGMENT_COL)['ASSEMBLY_GROUP_ID'].to_dict()\nprint(f\"\\n✅ Fragments de base labellisés (unit + type) : {len(ASSEMBLY_MAP_BASE)}\")\n\n# 2. EXTENSION DE LA CARTE AUX VUES 2D (EXTERIOR/INTERIOR)\nASSEMBLY_MAP_FULL = {}\nfor base_id, group_name in ASSEMBLY_MAP_BASE.items():\n    ASSEMBLY_MAP_FULL[f\"{base_id}_exterior\"] = group_name\n    ASSEMBLY_MAP_FULL[f\"{base_id}_interior\"] = group_name\nprint(f\"✅ Vérité Terrain (ASSEMBLY_MAP_FULL) créée pour {len(ASSEMBLY_MAP_FULL)} vues de fragments.\")\n\n# DÉFINITION DE LA FONCTION D'EXTRACTION DE CONTOUR\n# Cette étape est cruciale : la fonction doit exister.\ntry:\n    # On suppose que 'extract_final_fragment_contour' existe dans le scope.\n    contour_extractor_func = extract_final_fragment_contour\nexcept NameError:\n    print(\"\\n-------------------------------------------------------------\")\n    print(\"❌ ERREUR CRITIQUE: La fonction 'extract_final_fragment_contour' n'est pas définie.\")\n    print(\"Veuillez exécuter la cellule qui définit cette fonction (avec cv2) avant celle-ci.\")\n    print(\"-------------------------------------------------------------\")\n    # Utiliser raise au lieu de exit() dans un environnement notebook\n    raise NameError(\"La fonction 'extract_final_fragment_contour' doit être définie.\")\n\n\n# 3. EXÉCUTION DU PIPELINE DML AVEC GESTION DU CACHE\nprint(\"\\n--- Démarrage de la génération des masques 2D (Pipeline Image -> Contour) ---\")\n\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\nall_image_masks = {}\n\nif os.path.exists(CACHE_FILE):\n    print(f\"✅ Masques trouvés dans le cache ! Chargement depuis {CACHE_FILE}...\")\n    try:\n        # allow_pickle=True est souvent nécessaire pour les dictionnaires\n        with np.load(CACHE_FILE, allow_pickle=True) as data:\n            all_image_masks = data['masks'].item()\n        print(f\"    {len(all_image_masks)} masques chargés depuis le cache.\")\n    except Exception as e:\n        print(f\"⚠️ ERREUR lors du chargement du cache : {e}. Reprise du traitement.\")\n\nif not all_image_masks or len(all_image_masks) == 0:\n    print(\"🔄 Le cache est vide ou invalide. Reprise du traitement complet des images...\")\n    \n    all_image_masks = process_all_fragments_by_id(IMAGE_DIR, contour_extractor_func)\n\n    if all_image_masks:\n        try:\n            # Sauvegarde dans le cache\n            np.savez(CACHE_FILE, masks=all_image_masks)\n            print(f\"✅ {len(all_image_masks)} masques sauvegardés dans le cache : {CACHE_FILE}\")\n        except Exception as e:\n            print(f\"⚠️ AVERTISSEMENT : Échec de la sauvegarde du cache. {e}\")\n\n\n# Diagnostic de l'intersection\nprocessed_external_ids = {k for k in all_image_masks.keys() if k.endswith('_exterior')}\nmapped_external_ids = {k for k in ASSEMBLY_MAP_FULL.keys() if k.endswith('_exterior')}\noverlap_ids = processed_external_ids.intersection(mapped_external_ids)\nprint(f\"\\n--- DIAGNOSTIC D'INTERSECTION ---\")\nprint(f\"Fragments **EXTERNES** avec Vérité Terrain correspondante (overlap) : {len(overlap_ids)}\")\n\nif len(overlap_ids) < 2:\n    print(\"❌ ÉCHEC : Moins de 2 fragments en commun. Le set d'entraînement est insuffisant.\")\nelse:\n    print(\"\\n--- Création des paires DML **EXTERNES** complètes ---\")\n    \n    dml_all_pairs = create_dml_pairs(all_image_masks, ASSEMBLY_MAP_FULL)\n    \n    print(f\"\\nTotal des paires DML **EXTERNES** générées : {len(dml_all_pairs)}\")\n\n    # 4. DIVISION TRAIN / VALIDATION / TEST (avec stratification)\n    \n    # Séparation initiale : Train/Val vs. Test (80% / 20%)\n    train_val_pairs, dml_test_pairs = train_test_split(\n        dml_all_pairs,\n        test_size=0.2,\n        random_state=42,\n        # Stratification basée sur le label (0 ou 1)\n        stratify=[p[2] for p in dml_all_pairs]\n    )\n    \n    # Séparation secondaire : Train vs. Validation (75% / 25% de train_val = 20% du total)\n    dml_train_pairs, dml_val_pairs = train_test_split(\n        train_val_pairs,\n        test_size=0.25, # 25% de 80% = 20% du total\n        random_state=42,\n        # Stratification basée sur le label (0 ou 1)\n        stratify=[p[2] for p in train_val_pairs]\n    )\n    \n    # AFFICHAGE DES RÉSULTATS\n    print(\"\\n--- RÉSULTATS DE LA DIVISION DES PAIRES (Train/Val/Test) ---\")\n    print(f\"✅ dml_train_pairs (60% pour l'entraînement Siamese EXTERNE) : {len(dml_train_pairs)} paires\")\n    print(f\"✅ dml_val_pairs (20% pour la validation) : {len(dml_val_pairs)} paires\")\n    print(f\"✅ dml_test_pairs (20% pour l'évaluation) : {len(dml_test_pairs)} paires\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"PART 3: DATA GENERATION FOR DEEP METRIC LEARNING (DML) WITH MVCNN ARCHITECTURE\n\n**Objective: Prepare 2D Contour Data for Siamese Network Training**\n\nThis code defines the data pipeline to feed the processed **2D contours** (masks) into a Deep Metric Learning (DML) model, specifically structured around the **Multi-View Convolutional Neural Network (MVCNN)** approach.\n\nThe core challenge is translating a single 2D contour (the fragment's silhouette) into the required 5D tensor format: `(BATCH, N_VIEWS, HEIGHT, WIDTH, CHANNELS)`.\n\n---\n\n**Key Component 1: The MVCNN Hyperparameter Choice**\n\n* **MVCNN Hyperparameters:**\n    * `N_VIEWS = 12`: The number of simulated views.\n    * `IMG_HEIGHT = 224`, `IMG_WIDTH = 224`: Standard input size for popular 2D CNN backbones (like ResNet or VGG).\n    * `IMG_CHANNELS = 1`: The input is a grayscale binary mask (contour), hence only one channel.\n\n**Why MVCNN for 2D Contours?**\n\nThe MVCNN was originally designed to process 3D objects by taking multiple 2D 'snapshots' (views) of the object from different angles and aggregating the features. \n\n* **Strategic Adaptation (The Pivot from 3D):** Since the initial 3D PointNet approach failed due to stability issues (exploding gradients), we shifted to a stable 2D feature set (the contour). However, we can adapt the robust MVCNN *architecture* to our new 2D data:\n    1.  **3D Feature Simulation:** Although our input is a single 2D contour (mask), we simulate the 'multiple view' aspect by **duplicating the same 2D mask** $N_{VIEWS}$ times.\n    2.  **Architecture Re-use:** This allows us to re-use pre-trained 2D CNN weights (trained on ImageNet) and leverage the MVCNN's aggregation layer (Max-Pooling across the views). This max-pooling step effectively enforces a **global, view-invariant feature representation** of the contour, which is crucial for geometric matching regardless of the fragment's orientation.\n\n---\n\n**Key Component 2: The DMLDataGenerator Class**\n\nThis Keras `Sequence` class is designed to feed data to a Siamese DML network, comparing pairs of fragments.\n\n* **Input Data:** It takes `dml_pairs` (lists of `(ID_A, ID_B, Label)`) and the dictionary `all_image_masks` (containing the cleaned 2D binary masks).\n* **Core Transformation (Inside `__getitem__`):**\n    1.  **Scaling and Channel Addition:** The raw 2D mask (e.g., `(224, 224)`) is normalized to floating point values (0.0 to 1.0) and a channel dimension is added: `(H, W) -> (H, W, 1)`.\n    2.  **View Duplication (MVCNN Input Preparation):** The single 3D tensor (`H, W, 1`) is replicated `N_VIEWS` times along a new dimension to create the 5D tensor ready for the MVCNN's input layer: `(H, W, 1) -> (N_VIEWS, H, W, 1)`. This is achieved using `np.tile()`.\n    3.  **Output Format:** The generator yields the required input for the Siamese Network: a tuple of two 5D tensors (`X_A`, `X_B`) representing the pair, and the label (`Y`) indicating if they belong to the same 'pseudo-assembly group' (1) or not (0).","metadata":{}},{"cell_type":"markdown","source":"PART 4: ARCHITECTURE SELECTION — THE SIAMESE NETWORK\n\nWhy Siamese Networks are Essential for Fragment Matching\n\nThe choice of a **Siamese Network** is not arbitrary; it is a direct consequence of the problem's nature, which is a **similarity matching task** rather than a standard classification task.\n\n1. Handling the \"Open-Set\" Problem (Unseen Classes)\n\nThe most critical reason for choosing the Siamese architecture is the **open-set nature** of the assembly problem.\n\n* **Standard Classification:** A standard CNN classifies an input into one of $K$ fixed classes (e.g., *Vase A*, *Vase B*, etc.). This requires knowing all possible classes beforehand.\n* **The Reality of Fragments:** In archaeology, you have fragments from potentially **thousands of unknown, unique vases**. You cannot define a class for every single vase. You only care if two fragments belong to the *same* unknown vase.\n* **Siamese Solution:** The Siamese network solves this by not predicting a class ID. Instead, it learns a **metric space** (or an \"embedding\"). It embeds each fragment (A and B) into a compact vector space ($\\mathbb{R}^N$). \n\n[Image of Siamese Network architecture]\n\n    * **Matching Criterion:** If Fragment A and Fragment B are close in this vector space (i.e., the distance between their embeddings is small), they are predicted to be a match. If they are far apart, they are not a match. This allows the system to recognize new, previously unseen vases.\n\n---\n\n2. Learning the Metric (Deep Metric Learning - DML)\n\nThe Siamese setup directly facilitates Deep Metric Learning, which is the process of learning an effective distance function.\n\n* **Shared Weights:** The network uses **two identical branches** with **shared weights** (hence the term \"Siamese\"). This ensures that if the same fragment is fed into both branches, the two resulting embeddings will be identical, providing consistency.\n* **Contrastive Loss Function:** The network is trained using a specialized loss function (e.g., **Contrastive Loss** or **Triplet Loss**), which explicitly minimizes the distance between **positive pairs** (fragments from the same vase) and maximizes the distance between **negative pairs** (fragments from different vases).\n\n**Summary**\n\n| Feature | Standard CNN | Siamese Network (DML) |\n| :--- | :--- | :--- |\n| **Goal** | Predict a Class Label ($K$) | Predict the **Distance** between inputs |\n| **Input Data** | Single item | **Pair** of items (or a triplet) |\n| **Handling Unseen Data** | Fails (Cannot classify new classes) | **Excels** (Learns general similarity/distance) |\n| **Suitability for Task** | Poor | **Optimal** for Fragment Assembly |","metadata":{}},{"cell_type":"code","source":"# ----------------------------------------------------------------------\n# --- MVCNN SIAMESE TRAINING CELL (COMPLET) ---\n# Ce script inclut désormais la définition du modèle Siamois (siamese_model)\n# et l'architecture de l'encodeur MVCNN pour corriger l'erreur 'is not defined'.\n# ----------------------------------------------------------------------\n\nimport tensorflow as tf\nimport os\nimport numpy as np\nimport matplotlib.pyplot as plt\nfrom tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping, TerminateOnNaN, ReduceLROnPlateau\nfrom tensorflow.keras.layers import Input, Conv2D, MaxPooling2D, Flatten, Dense, Lambda, concatenate, Reshape, TimeDistributed\nfrom tensorflow.keras.models import Model\nfrom tensorflow.keras.applications import VGG16\nfrom tensorflow.keras import backend as K\n\n# Note: DMLDataGenerator, dml_train_pairs, dml_val_pairs, dml_test_pairs, et all_image_masks\n# DOIVENT être définis dans les cellules précédentes pour que ceci fonctionne.\n\n# ======================================================================\n# 1. DÉFINITION DES HYPERPARAMÈTRES ET CONSTANTES\n# ======================================================================\n# Assurez-vous que l'input_shape correspond à la taille des masques 2D (ex: 224x224x3)\nINPUT_SHAPE = (224, 224, 3) \nEMBEDDING_DIM = 256 # Taille de l'espace d'embedding pour le MVCNN\nMARGIN = 1.0 # Marge pour la perte contrastive (Loss)\n\nEPOCHS = 5\nBATCH_SIZE = 32\n# Le chemin de sauvegarde doit se terminer par '.weights.h5'\nCHECKPOINT_PATH = \"best_siamese_mvcnn_weights.weights.h5\"\nENCODER_LAYER_NAME = 'MVCNN_Encoder_SingleView' # Nom de la couche à extraire pour la prédiction\nENCODER_PATH = 'mvcnn_encoder_final.h5'\n\n\n# ======================================================================\n# 2. DÉFINITION DES ARCHITECTURES CLÉS (MVCNN et SIAMESE)\n# ======================================================================\n\ndef contrastive_loss(y_true, y_pred):\n    \"\"\"\n    Perte Contrastive : Calcule la perte pour un réseau Siamois.\n    y_true (Label): 1 pour paires similaires, 0 pour paires différentes.\n    y_pred (Distance): Distance euclidienne L2 entre les embeddings.\n    \"\"\"\n    # Perte pour les paires similaires (y_true=1) : minimiser la distance\n    loss_s = y_true * K.square(y_pred)\n    \n    # Perte pour les paires différentes (y_true=0) : maximiser la distance au-delà de la marge\n    loss_d = (1 - y_true) * K.square(K.maximum(MARGIN - y_pred, 0))\n    \n    return K.mean(loss_s + loss_d)\n\ndef create_mvcnn_encoder(input_shape, embedding_dim):\n    \"\"\"\n    Crée l'encodeur MVCNN à poids partagés, basé sur VGG16 (une seule vue).\n    \"\"\"\n    # 1. Réseau de base (VGG16)\n    base_model = VGG16(\n        weights='imagenet', # Poids pré-entraînés\n        include_top=False, \n        input_shape=input_shape\n    )\n    \n    # 2. Couches d'adaptation post-VGG\n    x = base_model.output\n    x = Flatten(name='flatten_features')(x)\n    x = Dense(1024, activation='relu')(x)\n    \n    # 3. Couche d'Embedding (sortie finale)\n    embedding = Dense(embedding_dim, activation=None, name='embedding_output')(x)\n    \n    # 4. Normalisation L2\n    embedding = Lambda(lambda x: K.l2_normalize(x, axis=1), name='l2_norm_embedding')(embedding)\n    \n    # Le modèle encodeur\n    encoder = Model(inputs=base_model.input, outputs=embedding, name=ENCODER_LAYER_NAME)\n    \n    return encoder\n\ndef create_siamese_model(encoder, input_shape):\n    \"\"\"\n    Assemble le modèle Siamois complet autour de l'encodeur partagé.\n    \"\"\"\n    # 1. Définir les deux entrées (Anchor et Positive/Negative)\n    input_anchor = Input(shape=input_shape, name='anchor_input')\n    input_other = Input(shape=input_shape, name='other_input')\n    \n    # 2. Obtenir les embeddings via l'encodeur partagé\n    embedding_anchor = encoder(input_anchor)\n    embedding_other = encoder(input_other)\n    \n    # 3. Calculer la distance (Euclidienne L2)\n    L2_distance = Lambda(\n        lambda tensors: K.sqrt(K.sum(K.square(tensors[0] - tensors[1]), axis=1, keepdims=True)),\n        name='distance_layer'\n    )([embedding_anchor, embedding_other])\n    \n    # 4. Créer le modèle Siamois\n    siamese_model = Model(\n        inputs=[input_anchor, input_other], \n        outputs=L2_distance, \n        name='Siamese_MVCNN_Model'\n    )\n    \n    # 5. Compiler le modèle\n    siamese_model.compile(\n        loss=contrastive_loss,\n        optimizer=tf.keras.optimizers.Adam(learning_rate=1e-4),\n        metrics=['accuracy']\n    )\n    \n    return siamese_model\n\n# ======================================================================\n# 3. INSTANCIATION DU MODÈLE SIAMOIS (Correction de l'erreur 'is not defined')\n# ======================================================================\nprint(\"\\n--- Définition et Compilation du Modèle Siamois MVCNN ---\")\n\n# 3.1 Création de l'encodeur de vue unique\nsingle_view_encoder = create_mvcnn_encoder(INPUT_SHAPE, EMBEDDING_DIM)\n\n# 3.2 Création du modèle Siamois (cette ligne définit 'siamese_model')\nsiamese_model = create_siamese_model(single_view_encoder, INPUT_SHAPE)\n\nprint(f\"✅ Modèle siamois créé. Dimension d'Embedding: {EMBEDDING_DIM}\")\nprint(\"Aperçu de l'architecture :\")\nsiamese_model.summary(line_length=150)\n\n\n# ======================================================================\n# 4. CRÉATION DES GÉNÉRATEURS DE DONNÉES (MVCNN)\n# ======================================================================\n\nprint(\"\\n--- Préparation des générateurs de données MVCNN ---\")\n\ntry:\n    # A. Création des générateurs Keras Sequence pour l'entraînement (shuffle: True)\n    train_generator = DMLDataGenerator(\n        dml_pairs=dml_train_pairs,\n        all_image_masks=all_image_masks, # Utilisation des masques 2D\n        batch_size=BATCH_SIZE,\n        shuffle=True\n    )\n\n    # B. Création des générateurs Keras Sequence pour la validation (shuffle: False)\n    val_generator = DMLDataGenerator(\n        dml_pairs=dml_val_pairs,\n        all_image_masks=all_image_masks, # Utilisation des masques 2D\n        batch_size=BATCH_SIZE,\n        shuffle=False\n    )\n    \nexcept NameError as e:\n    print(\"\\n-------------------------------------------------------------\")\n    print(f\"❌ Erreur critique : {e}. Assurez-vous que les dépendances (DMLDataGenerator, paires, masques) sont définies.\")\n    print(\"-------------------------------------------------------------\")\n    exit()\n\nprint(f\"✅ Générateur d'entraînement créé : {len(train_generator)} lots.\")\nprint(f\"✅ Générateur de validation créé : {len(val_generator)} lots.\")\n\n\n# ======================================================================\n# 5. DÉFINITION DES CALLBACKS (Gestion de l'entraînement et de l'optimisation)\n# ======================================================================\ncallbacks_list = [\n    # Sauvegarde du modèle avec la meilleure 'val_loss'\n    ModelCheckpoint(\n        filepath=CHECKPOINT_PATH,\n        save_best_only=True,\n        monitor='val_loss',\n        mode='min',\n        save_weights_only=True, # Sauvegarde légère (uniquement les poids)\n        verbose=1\n    ),\n    # Arrêt précoce pour éviter le surapprentissage\n    EarlyStopping(\n        monitor='val_loss',\n        patience=2,\n        mode='min',\n        restore_best_weights=True,\n        verbose=1\n    ),\n    # Stoppe l'entraînement en cas de valeurs numériques instables\n    TerminateOnNaN(),\n    # Réduit le taux d'apprentissage si la perte de validation stagne\n    ReduceLROnPlateau(\n        monitor='val_loss',\n        factor=0.5,\n        patience=3,\n        min_lr=1e-08,\n        verbose=1\n    )\n]\n\n\n# ======================================================================\n# 6. LANCEMENT DE L'ENTRAÎNEMENT (Utilisation directe du Keras Sequence)\n# ======================================================================\nprint(\"\\n--- Démarrage de l'entraînement du Modèle Siamois MVCNN ---\")\n\ntry:\n    # L'entraînement utilise la Keras Sequence personnalisée DMLDataGenerator\n    history = siamese_model.fit(\n        train_generator,\n        epochs=EPOCHS,\n        validation_data=val_generator,\n        callbacks=callbacks_list,\n        # L'argument 'workers' est correctement omis pour éviter les problèmes de multiprocessing\n    )\n    \nexcept Exception as e:\n    print(\"\\n-------------------------------------------------------------\")\n    print(f\"❌ ERREUR LORS DE L'ENTRAÎNEMENT: {e}\")\n    print(\"-------------------------------------------------------------\")\n\n\n# ======================================================================\n# 7. ÉVALUATION FINALE ET SAUVEGARDE DE L'ENCODEUR\n# ======================================================================\n\n# 7.1 Chargement des meilleurs poids\nif os.path.exists(CHECKPOINT_PATH):\n    siamese_model.load_weights(CHECKPOINT_PATH)\n    print(f\"\\n✅ Meilleurs poids chargés depuis {CHECKPOINT_PATH}.\")\nelse:\n    print(\"\\n⚠️ ATTENTION: Les poids optimaux n'ont pas pu être chargés.\")\n\n\n# 7.2 Évaluation finale\nprint(\"\\n--- Évaluation finale sur le jeu de test ---\")\ntest_generator = DMLDataGenerator(\n    dml_pairs=dml_test_pairs,\n    all_image_masks=all_image_masks,\n    batch_size=BATCH_SIZE,\n    shuffle=False\n)\n\ntry:\n    # Evaluation du modèle sur le générateur de test\n    loss, acc = siamese_model.evaluate(test_generator, verbose=1)[:2]\n    \n    print(f\"**RÉSULTATS FINAUX (Test Set) :**\")\n    print(f\"- Loss: {loss:.4f}\")\n    print(f\"- Accuracy: {acc:.4f}\")\n    \nexcept Exception as e:\n    print(f\"❌ Erreur lors de l'évaluation finale : {e}\")\n\n\n# 7.3 Sauvegarde de l'Encodeur seul\ntry:\n    # On extrait la sous-couche responsable de l'embedding (nommée dans la fonction create_mvcnn_encoder)\n    mvcnn_encoder = siamese_model.get_layer(ENCODER_LAYER_NAME)\n    mvcnn_encoder.save(ENCODER_PATH)\n    print(f\"\\n✅ Modèle d'Embedding ({ENCODER_LAYER_NAME}) sauvegardé pour la prédiction sous : {ENCODER_PATH}\")\nexcept Exception as e:\n    print(f\"\\n⚠️ ATTENTION: Échec de la sauvegarde de l'encodeur MVCNN. Raison : {e}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import h5py\nimport os\nimport sys\n\n# ======================================================================\n# 0. CONFIGURATION DES CHEMINS\n# ======================================================================\n\n# Nous utilisons le chemin local du fichier qui serait sauvegardé par la cellule d'entraînement précédente.\n# Remplacez-le par le chemin d'accès au jeu de données si vous chargez depuis un emplacement externe.\nSIAMESE_WEIGHTS_PATH = \"best_siamese_mvcnn_weights.weights.h5\"\n\nprint(\"\\n--- Démarrage de l'inspection du fichier de poids ---\")\nprint(f\"Tentative d'inspection du chemin : {SIAMESE_WEIGHTS_PATH}\")\n\nif os.path.exists(SIAMESE_WEIGHTS_PATH):\n    \n    # Ouvre le fichier en mode lecture\n    try:\n        with h5py.File(SIAMESE_WEIGHTS_PATH, 'r') as f:\n            print(f\"\\n✅ Fichier HDF5 ouvert avec succès : {SIAMESE_WEIGHTS_PATH}\")\n            \n            # Afficher les clés de niveau supérieur (groupes et datasets)\n            top_level_keys = list(f.keys())\n            print(f\"\\nGroupes de niveau supérieur dans le fichier: {top_level_keys}\")\n            \n            print(\"\\n--- Exploration des couches (Top Level) ---\")\n            \n            def print_attrs(name, obj):\n                \"\"\"Fonction utilitaire pour afficher les attributs d'un objet HDF5.\"\"\"\n                sys.stdout.write(f\"\\nGroupe: {name}\")\n                if isinstance(obj, h5py.Group):\n                    if 'layer_names' in obj.attrs:\n                        # Ceci est souvent présent dans les groupes de modèles Keras\n                        sys.stdout.write(f\" (Contient {len(obj.attrs['layer_names'])} couches Keras)\")\n                sys.stdout.write(f\"\\n  Sous-clés: {list(obj.keys())}\")\n\n            f.visititems(print_attrs)\n            \n            print(\"\\n\")\n            \n    except Exception as e:\n        print(f\"❌ ERREUR lors de l'ouverture du fichier de poids avec h5py : {e}\")\n        \nelse:\n    print(f\"\\n❌ ERREUR : Le fichier de poids n'a pas été trouvé au chemin spécifié : {SIAMESE_WEIGHTS_PATH}\")\n    print(\"Veuillez vous assurer que la cellule d'entraînement a été exécutée et que le fichier a été sauvegardé, ou que le chemin d'accès aux données externes est correct.\")\n\nprint(\"\\n--- Fin de l'inspection ---\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-06T23:27:35.103177Z","iopub.execute_input":"2025-11-06T23:27:35.103377Z","iopub.status.idle":"2025-11-06T23:27:35.286049Z","shell.execute_reply.started":"2025-11-06T23:27:35.103360Z","shell.execute_reply":"2025-11-06T23:27:35.285313Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nimport numpy as np\nimport os\nimport sys\nimport glob\nimport h5py\nfrom typing import List, Tuple\nfrom scipy.spatial.distance import pdist, squareform\nfrom scipy.cluster.hierarchy import linkage, fcluster\nfrom tensorflow.keras.models import Model\n# Assurez-vous d'avoir les bonnes importations Keras pour votre modèle (e.g., VGG16, ResNet, etc.)\nfrom tensorflow.keras.layers import Input, Dense, GlobalAveragePooling2D, BatchNormalization, Activation\nfrom tensorflow.keras.applications import VGG16 \nfrom tensorflow.keras.preprocessing.image import load_img, img_to_array\n\n# ======================================================================\n# 0. CONFIGURATION DES CHEMINS ET HYPERPARAMÈTRES\n# ======================================================================\n\n# 🚨 CRITIQUE: Vérifiez ce chemin d'accès aux poids.\nSIAMESE_WEIGHTS_PATH = \"best_siamese_mvcnn_weights.weights.h5\"\n# 🚨 CRITIQUE: Vérifiez le chemin d'accès aux images d'entrée (e.g., /kaggle/input/data/images)\nIMAGE_DIR_PATH = '/kaggle/input/h690/h690/h690/sherd_images' \nFINAL_SUBMISSION_PATH = 'submission.csv' \n\n# Taille d'entrée pour le modèle MVCNN Siamese (doit correspondre à l'entraînement)\nINPUT_SHAPE = (224, 224, 3) \n# Le nombre de dimensions de sortie de la couche d'embedding de votre modèle Siamese\nFEATURE_DIMENSION = 512 \n\n# Paramètre de Clustering Hiérarchique\n# Seuil de coupure pour le clustering (ajustez cette valeur: 0.1 à 1.0)\nCLUSTERING_THRESHOLD = 0.5 \n\n# Noms de colonnes requis\nGROUP_COL_REQ = 'Assembly Group'\nIMAGE_ID_COL_REQ = 'image_id'\n\nprint(f\"--- Démarrage du Pipeline Complet (Seuil: {CLUSTERING_THRESHOLD}) ---\")\n\n# ======================================================================\n# 1. DÉFINITION ET CHARGEMENT DU MODÈLE EXTRACTEUR\n# ======================================================================\n\ndef create_feature_extractor(input_shape: tuple) -> Model:\n    \"\"\"\n    Recrée le bras extracteur de caractéristiques (base du Siamese Network)\n    pour charger les poids.\n    \n    🚨 ATTENTION: REMPLACER PAR VOTRE ARCHITECTURE MVCNN RÉELLE\n    Assurez-vous que cette architecture est un calque exact du bras\n    extracteur de votre modèle Siamese entraîné.\n    \"\"\"\n    print(\"Définition de l'architecture de l'extracteur...\")\n    \n    # 1. Base Model (Exemple: VGG16 sans les couches supérieures)\n    base_model = VGG16(weights=None, include_top=False, input_shape=input_shape)\n    x = base_model.output\n    \n    # 2. Couche(s) de regroupement et d'embedding (doit correspondre à votre entraînement)\n    x = GlobalAveragePooling2D(name='global_avg_pool')(x)\n    # Assurez-vous que le nom 'embedding_layer' correspond à celui utilisé dans votre modèle Siamese\n    feature_vector = Dense(FEATURE_DIMENSION, activation='relu', name='embedding_layer')(x) \n    \n    model = Model(inputs=base_model.input, outputs=feature_vector, name='feature_extractor')\n    return model\n\ndef load_and_configure_extractor(weights_path: str, input_shape: tuple) -> Model:\n    \"\"\"Charge le modèle et ses poids entraînés.\"\"\"\n    \n    if not os.path.exists(weights_path):\n        raise FileNotFoundError(f\"Fichier de poids non trouvé : {weights_path}. Veuillez l'entraîner ou vérifier le chemin.\")\n        \n    extractor = create_feature_extractor(input_shape)\n    \n    try:\n        # Tente de charger les poids\n        extractor.load_weights(weights_path)\n        print(f\"✅ Poids chargés avec succès depuis : {weights_path}\")\n        \n        # Test de cohérence (optionnel, mais utile)\n        dummy_input = np.zeros((1, *input_shape))\n        _ = extractor.predict(dummy_input)\n        \n        return extractor\n\n    except Exception as e:\n        print(f\"❌ ERREUR lors du chargement des poids : {e}\")\n        print(\"Vérifiez que l'architecture 'create_feature_extractor' est EXACTE par rapport à l'entraînement.\")\n        sys.exit(1) # Arrête l'exécution si le chargement échoue\n\n\n# ======================================================================\n# 2. EXTRACTION DES CARACTÉRISTIQUES (LOGIQUE RÉELLE)\n# ======================================================================\n\ndef real_feature_extraction(extractor: Model, image_dir: str) -> Tuple[List[str], np.ndarray]:\n    \"\"\"\n    Implémentation réelle de l'extraction de vecteurs.\n    \n    🚨 ATTENTION: REMPLACER CECI PAR VOTRE LOGIQUE DE DATAGENERATOR RÉELLE\n    pour gérer le pré-traitement (resize, normalisation) de Keras.\n    \"\"\"\n    print(\"\\n--- 2. Extraction des Caractéristiques Réelles ---\")\n    \n    all_image_paths = glob.glob(os.path.join(image_dir, '*_exterior.jpg'))\n    all_image_paths.extend(glob.glob(os.path.join(image_dir, '*_interior.jpg')))\n    \n    if not all_image_paths:\n        raise FileNotFoundError(f\"Aucune image trouvée dans {image_dir}. Vérifiez le chemin.\")\n        \n    all_image_paths.sort() # Tri pour s'assurer que l'ordre des IDs est le même que celui des vecteurs\n    \n    cluster_ids = [os.path.basename(p).replace('.jpg', '') for p in all_image_paths]\n    \n    print(f\"Fragments trouvés : {len(cluster_ids)}. Démarrage de la prédiction...\")\n    \n    # Prétraitement et Prédiction (Version simplifiée SANS Keras Data Generator)\n    \n    images = []\n    # Boucler sur les chemins (dans une vraie application, utiliser un Data Generator pour l'efficacité)\n    for i, path in enumerate(all_image_paths):\n        # Charge l'image et la redimensionne à la taille d'entrée du modèle\n        img = load_img(path, target_size=INPUT_SHAPE[:2])\n        img_array = img_to_array(img)\n        \n        # 🚨 IMPORTANT: Appliquez ici la même normalisation/prétraitement que pendant l'ENTRAÎNEMENT\n        # (e.g., /255.0, ou la fonction preprocess_input de votre base_model)\n        \n        # Pour VGG16, il faut généralement appeler tensorflow.keras.applications.vgg16.preprocess_input\n        # Ici, on simule une simple division par 255.0 si votre entraînement utilisait cela :\n        img_array /= 255.0 \n        \n        images.append(img_array)\n        \n        # Affichage de progression\n        if (i + 1) % 100 == 0 or (i + 1) == len(all_image_paths):\n            sys.stdout.write(f\"\\rTraitement : {i+1}/{len(all_image_paths)} images...\")\n    \n    sys.stdout.write(\"\\n\")\n    \n    # Empilement des images pour la prédiction en lot\n    X_test = np.array(images)\n    \n    # Prédiction\n    feature_vectors = extractor.predict(X_test, batch_size=32, verbose=1)\n    \n    print(f\"✅ Extraction terminée. Obtenu {feature_vectors.shape[0]} vecteurs de dimension {feature_vectors.shape[1]}.\")\n    \n    return cluster_ids, feature_vectors\n\n\n# ======================================================================\n# 3. CLUSTERING HIÉRARCHIQUE\n# ======================================================================\n\ndef perform_hierarchical_clustering(feature_vectors: np.ndarray, threshold: float) -> np.ndarray:\n    \"\"\"\n    Effectue le clustering hiérarchique sur les vecteurs de caractéristiques.\n    \"\"\"\n    print(f\"\\n--- 3. Clustering Hiérarchique (Seuil: {threshold}) ---\")\n    \n    # 3.1. Calcul de la matrice de distance (Distance Cosinus)\n    # distance = 1 - similarité cosinus. Méthode standard pour les embeddings.\n    distance_matrix_condensed = pdist(feature_vectors, metric='cosine')\n    \n    # 3.2. Construction du dendrogramme (algorithme de chaînage 'complete' - lien max)\n    Z = linkage(distance_matrix_condensed, method='complete')\n    \n    # 3.3. Découpage du dendrogramme au seuil défini\n    agg_labels = fcluster(Z, t=threshold, criterion='distance')\n    \n    num_clusters = len(np.unique(agg_labels))\n    print(f\"✅ Clustering terminé. Création de {num_clusters} Assembly Groups.\")\n    \n    # Retourne les labels (e.g., [1, 1, 2, 3, 2, 1, ...])\n    return agg_labels\n\n# ======================================================================\n# 4. FORMATAGE ET SAUVEGARDE DE LA SOUMISSION\n# ======================================================================\n\ndef custom_sort_key(image_id: str):\n    \"\"\"\n    Trie un ID au format 'JD00001_exterior' : d'abord l'ID de base, \n    puis assure que 'exterior' (0) est trié avant 'interior' (1).\n    \"\"\"\n    parts = image_id.rsplit('_', 1)\n    base_id = parts[0]\n    view = parts[1]\n    \n    view_priority = 0 if view == 'exterior' else 1\n    \n    return (base_id, view_priority)\n\n\ndef create_submission_file(cluster_ids: List[str], agg_labels: np.ndarray, all_image_dir: str, final_path: str):\n    \"\"\"\n    Formate les IDs et les labels dans le CSV de soumission et applique le tri strict.\n    \"\"\"\n\n    print(f\"\\n--- 4. Formatage et Sauvegarde de la Soumission ---\")\n    \n    # Vérification des longueurs\n    if len(cluster_ids) != len(agg_labels):\n        raise ValueError(f\"Incohérence des longueurs : IDs ({len(cluster_ids)}) != Labels ({len(agg_labels)})\")\n\n    # Mapping du Cluster Numérique (1, 2, 3...) vers le Nom Requis (AssemblyGroup1, AssemblyGroup2...)\n    unique_numerical_labels = sorted(np.unique(agg_labels))\n    label_to_group_name = {\n        label: f\"AssemblyGroup{i+1}\"\n        for i, label in enumerate(unique_numerical_labels)\n    }\n\n    # Créer le mappage final ID Fragment -> Nom de Groupe formaté\n    cluster_map = {}\n    for i, fragment_id in enumerate(cluster_ids):\n        numerical_label = agg_labels[i]\n        final_group_name = label_to_group_name.get(numerical_label, \"Singleton_Error\")\n        cluster_map[fragment_id] = final_group_name\n\n    print(f\"Étape 4.1: Création de {len(cluster_map)} mappings de cluster.\")\n\n    # Collecte de TOUS les fragments d'images\n    all_image_paths = glob.glob(os.path.join(all_image_dir, '*_exterior.jpg'))\n    all_image_paths.extend(glob.glob(os.path.join(all_image_dir, '*_interior.jpg')))\n    all_submission_ids = [os.path.basename(p).replace('.jpg', '') for p in all_image_paths]\n    \n    # 🚨 APPLICATION DU TRI EXPLICITE POUR L'ORDRE DES LIGNES\n    all_submission_ids.sort(key=custom_sort_key)\n    \n    print(f\"Trouvé {len(all_submission_ids)} IDs d'images, triés.\")\n\n    # Construction du DataFrame final\n    submission_data = []\n    UNPROCESSED_LABEL = \"AssemblyGroupSingleton\"\n\n    for image_id in all_submission_ids:\n        # Récupérer le label. Si l'ID n'est pas dans le cluster_ids (ce qui ne devrait pas arriver ici),\n        # il sera étiqueté comme Singleton.\n        final_group_label = cluster_map.get(image_id, UNPROCESSED_LABEL)\n        submission_data.append({\n            GROUP_COL_REQ: final_group_label,\n            IMAGE_ID_COL_REQ: image_id\n        })\n\n    df_submission = pd.DataFrame(submission_data)\n\n    # Assurer l'ordre final des colonnes : Assembly Group, image_id\n    df_submission = df_submission[[GROUP_COL_REQ, IMAGE_ID_COL_REQ]]\n\n    # Sauvegarde\n    df_submission.to_csv(final_path, index=False)\n\n    print(f\"\\n✅ Fichier de soumission créé avec succès : {final_path}\")\n    print(f\"Total des lignes : {len(df_submission)}\")\n    print(\"\\nAperçu des premières lignes (Vérifiez le tri JD..._exterior/interior) :\")\n    print(df_submission.head(10))\n    print(\"\\nDistribution des labels (Top 10) :\")\n    print(df_submission[GROUP_COL_REQ].value_counts().head(10))\n\n\n# ======================================================================\n# EXECUTION DU PIPELINE\n# ======================================================================\nif __name__ == '__main__':\n    try:\n        # 1. Charger/Configurer l'extracteur\n        extractor = load_and_configure_extractor(SIAMESE_WEIGHTS_PATH, INPUT_SHAPE)\n        \n        # 2. Extraction des caractéristiques\n        # ⚠️ REMPLACER 'real_feature_extraction' si votre pipeline de chargement d'image est différent\n        cluster_ids, feature_vectors = real_feature_extraction(extractor, IMAGE_DIR_PATH)\n        \n        # 3. Clustering\n        agg_labels = perform_hierarchical_clustering(feature_vectors, CLUSTERING_THRESHOLD)\n        \n        # 4. Formatage et Sauvegarde de la soumission\n        create_submission_file(cluster_ids, agg_labels, IMAGE_DIR_PATH, FINAL_SUBMISSION_PATH)\n        \n    except Exception as e:\n        print(f\"\\n❌ LE PIPELINE A ÉCHOUÉ : {e}\")\n        # Afficher la trace complète de l'erreur pour le débogage\n        import traceback\n        traceback.print_exc(file=sys.stdout)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nimport numpy as np\nimport os\nimport sys\nimport glob \nfrom typing import List\n\n# ======================================================================\n# 0. Configuration des Chemins\n# ======================================================================\n\n# Le chemin doit pointer vers le répertoire des images d'entrée (images des fragments .jpg)\nIMAGE_DIR_PATH = '/kaggle/input/h690/h690/h690/sherd_images' \nFINAL_SUBMISSION_PATH = 'submission.csv' \n\n# Noms de colonnes requis par le template (Ordre Inversé: Col 1 Assembly Group, Col 2 image_id)\nGROUP_COL_REQ = 'Assembly Group'\nIMAGE_ID_COL_REQ = 'image_id'\n\nprint(f\"--- Démarrage de la Soumission (Ordre des Lignes Garanti) ---\")\n\n# ======================================================================\n# 1. PRÉPARATION DES DONNÉES DE CLUSTERING \n# ======================================================================\n\n# Fonction pour trier les IDs selon la spécification : ID de base, puis '_exterior' avant '_interior'\ndef custom_sort_key(image_id: str):\n    \"\"\"\n    Trie un ID au format 'JD00001_exterior' en utilisant d'abord l'ID de base, \n    puis en assurant que 'exterior' est trié avant 'interior'.\n    \"\"\"\n    # Sépare l'ID de base du suffixe de vue ('_exterior' ou '_interior')\n    parts = image_id.rsplit('_', 1)\n    base_id = parts[0]   # e.g., 'JD00001'\n    view = parts[1]      # e.g., 'exterior' ou 'interior'\n    \n    # 🚨 Critère de tri explicite : le Base ID puis un indicateur pour la vue\n    # 'exterior' aura la priorité sur 'interior' (0 < 1)\n    view_priority = 0 if view == 'exterior' else 1\n    \n    return (base_id, view_priority)\n\n\ntry:\n    if 'cluster_ids' not in locals() or 'agg_labels' not in locals():\n        # --- DONNÉES FACTICES pour la DÉMONSTRATION ---\n        print(\"⚠️ Variables de clustering non trouvées. Création de données factices pour le formatage.\")\n        np.random.seed(42) \n        \n        # Simuler 50 IDs (25 fragments x 2 vues)\n        mock_base_ids = [f\"JD00{i:03d}\" for i in range(1, 26)]  \n        mock_external_ids = [f\"{i}_exterior\" for i in mock_base_ids]\n        mock_interior_ids = [f\"{i}_interior\" for i in mock_base_ids]\n        cluster_ids = mock_external_ids + mock_interior_ids # 50 IDs au total\n        agg_labels = np.random.randint(0, 5, size=50) # 5 groupes factices (labels de 0 à 4)\n        # ---------------------------------------------\n        \n    cluster_ids_list = list(cluster_ids)\n    \n    if len(cluster_ids_list) != len(agg_labels):\n        raise ValueError(f\"Incohérence des longueurs : IDs ({len(cluster_ids_list)}) != Labels ({len(agg_labels)})\")\n\n    # Mapping du Cluster Numérique (0, 1, 2...) vers le Nom Requis (AssemblyGroup1, AssemblyGroup2...)\n    unique_numerical_labels = np.unique(agg_labels)\n    label_to_group_name = {\n        label: f\"AssemblyGroup{i+1}\" \n        for i, label in enumerate(unique_numerical_labels)\n    }\n\n    # Créer le mappage final ID Fragment -> Nom de Groupe formaté\n    cluster_map = {}\n    for i, fragment_id in enumerate(cluster_ids_list):\n        numerical_label = agg_labels[i]\n        final_group_name = label_to_group_name.get(numerical_label, \"Singleton_Error\")\n        cluster_map[fragment_id] = final_group_name\n        \n    print(f\"\\nÉtape 1: Création de {len(cluster_map)} mappings de cluster. Labels numériques mappés aux groupes : {label_to_group_name}\")\n\n    # 2. Collecte et Tri de TOUS les fragments d'images (GARANTIR L'ORDRE)\n    print(f\"\\nÉtape 2: Collecte et tri des IDs d'images pour la soumission...\")\n    \n    all_image_paths = glob.glob(os.path.join(IMAGE_DIR_PATH, '*_exterior.jpg'))\n    all_image_paths.extend(glob.glob(os.path.join(IMAGE_DIR_PATH, '*_interior.jpg')))\n    \n    # Extraire l'ID complet (e.g., 'JD00001_exterior')\n    all_submission_ids = [os.path.basename(p).replace('.jpg', '') for p in all_image_paths]\n    \n    # 🚨 Application du tri personnalisé pour l'ordre exact demandé\n    all_submission_ids.sort(key=custom_sort_key)\n    \n    print(f\"Trouvé {len(all_submission_ids)} IDs d'images au total, triés selon les spécifications.\")\n    \n    # 3. Construction du DataFrame final (avec l'ordre de colonnes requis)\n    submission_data = []\n    UNPROCESSED_LABEL = \"AssemblyGroupSingleton\" \n    \n    for image_id in all_submission_ids:\n        final_group_label = cluster_map.get(image_id, UNPROCESSED_LABEL)\n        submission_data.append({\n            GROUP_COL_REQ: final_group_label,  # Colonne 1\n            IMAGE_ID_COL_REQ: image_id         # Colonne 2\n        })\n\n    df_submission = pd.DataFrame(submission_data)\n    \n    # Assurer l'ordre des colonnes : Assembly Group, image_id\n    df_submission = df_submission[[GROUP_COL_REQ, IMAGE_ID_COL_REQ]]\n    \n    # 4. Sauvegarde du fichier de soumission\n    print(f\"Fragments dans le DataFrame de soumission : {len(df_submission)}\")\n    \n    df_submission.to_csv(FINAL_SUBMISSION_PATH, index=False)\n    \n    print(f\"\\n✅ Fichier de soumission créé avec succès : {FINAL_SUBMISSION_PATH}\")\n    print(\"Aperçu des premières lignes du fichier de soumission (Ordre des colonnes et des lignes vérifié) :\")\n    print(df_submission.head())\n    print(\"\\nDistribution des labels (Top 10) :\")\n    print(df_submission[GROUP_COL_REQ].value_counts().head(10))\n\nexcept Exception as e:\n    print(f\"\\n❌ ERREUR CRITIQUE DANS LA CRÉATION DE LA SOUMISSION : {e}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}