{"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":91498,"databundleVersionId":11655853,"isSourceIdPinned":false,"sourceType":"competition"},{"sourceId":7884485,"sourceType":"datasetVersion","datasetId":4628051},{"sourceId":8026384,"sourceType":"datasetVersion","datasetId":4726252},{"sourceId":11575619,"sourceType":"datasetVersion","datasetId":7257638},{"sourceId":11754775,"sourceType":"datasetVersion","datasetId":7379527},{"sourceId":11924468,"sourceType":"datasetVersion","datasetId":6988459},{"sourceId":13961320,"sourceType":"datasetVersion","datasetId":8899689},{"sourceId":4533,"sourceType":"modelInstanceVersion","isSourceIdPinned":false,"modelInstanceId":3325,"modelId":986},{"sourceId":17191,"sourceType":"modelInstanceVersion","isSourceIdPinned":true,"modelInstanceId":14317,"modelId":21716},{"sourceId":17555,"sourceType":"modelInstanceVersion","isSourceIdPinned":false,"modelInstanceId":14611,"modelId":22086}],"dockerImageVersionId":31192,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# This Python 3 environment comes with many helpful analytics libraries installed\n# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python\n# For example, here's several helpful packages to load\n\nimport numpy as np # linear algebra\nimport pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\n\n# Input data files are available in the read-only \"../input/\" directory\n# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory\n\nimport os\nfor dirname, _, filenames in os.walk('/kaggle/input'):\n    for filename in filenames:\n        print(os.path.join(dirname, filename))","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:04:22.554311Z","iopub.execute_input":"2025-12-10T14:04:22.555114Z","iopub.status.idle":"2025-12-10T14:04:27.196388Z","shell.execute_reply.started":"2025-12-10T14:04:22.555066Z","shell.execute_reply":"2025-12-10T14:04:27.195608Z"},"collapsed":true,"jupyter":{"outputs_hidden":true}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Setup","metadata":{}},{"cell_type":"code","source":"!pip install --no-index /kaggle/input/imc2025-packages-pycolmap-lightglue-rerun-kornia/* --no-deps\n!mkdir -p /root/.cache/torch/hub/checkpoints\n!cp /kaggle/input/aliked/pytorch/aliked-n16/1/aliked-n16.pth /root/.cache/torch/hub/checkpoints/\n!cp /kaggle/input/lightglue/pytorch/aliked/1/aliked_lightglue.pth /root/.cache/torch/hub/checkpoints/\n!cp /kaggle/input/lightglue/pytorch/aliked/1/aliked_lightglue.pth /root/.cache/torch/hub/checkpoints/aliked_lightglue_v0-1_arxiv.pth","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:04:29.903517Z","iopub.execute_input":"2025-12-10T14:04:29.904300Z","iopub.status.idle":"2025-12-10T14:04:37.014189Z","shell.execute_reply.started":"2025-12-10T14:04:29.904274Z","shell.execute_reply":"2025-12-10T14:04:37.013181Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## *Imports*","metadata":{}},{"cell_type":"code","source":"import sys, os, gc, h5py, pycolmap\nimport numpy as np\nimport pandas as pd\nimport torch\nimport kornia as K\nimport torch.nn.functional as F\nfrom tqdm import tqdm\nfrom PIL import Image\nfrom pathlib import Path\nfrom transformers import AutoImageProcessor, AutoModel\nfrom lightglue import ALIKED, LightGlue\nfrom sklearn.cluster import DBSCAN\nfrom scipy.spatial.distance import cosine\nimport matplotlib.pyplot as plt\nfrom mpl_toolkits.mplot3d import Axes3D\nimport gc\nimport warnings\nwarnings.filterwarnings('ignore')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:05:07.551754Z","iopub.execute_input":"2025-12-10T14:05:07.552731Z","iopub.status.idle":"2025-12-10T14:05:07.557604Z","shell.execute_reply.started":"2025-12-10T14:05:07.552702Z","shell.execute_reply":"2025-12-10T14:05:07.556932Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def optimize_memory():\n    \"\"\"Free up GPU memory\"\"\"\n    gc.collect()\n    if torch.cuda.is_available():\n        torch.cuda.empty_cache()\n        print(f\"🧹 Cleared GPU cache\")\n        print(f\"   Allocated: {torch.cuda.memory_allocated() / 1e9:.2f} GB\")\n        print(f\"   Reserved: {torch.cuda.memory_reserved() / 1e9:.2f} GB\")\n\noptimize_memory()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:05:11.619527Z","iopub.execute_input":"2025-12-10T14:05:11.620363Z","iopub.status.idle":"2025-12-10T14:05:11.959413Z","shell.execute_reply.started":"2025-12-10T14:05:11.620324Z","shell.execute_reply":"2025-12-10T14:05:11.958645Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## *Testing Model Loading*","metadata":{}},{"cell_type":"code","source":"print(\"\\n🧪 Testing model loading...\")\n\n# Test ALIKED\ntry:\n    from lightglue import ALIKED\n    extractor = ALIKED(model_name='aliked-n16', max_num_keypoints=1024).eval()\n    print(\"✅ ALIKED loaded successfully\")\n    del extractor\n    optimize_memory()\nexcept Exception as e:\n    print(f\"❌ ALIKED error: {e}\")\n\n# Test LightGlue\ntry:\n    from lightglue import LightGlue\n    matcher = LightGlue(features='aliked').eval()\n    print(\"✅ LightGlue loaded successfully\")\n    del matcher\n    optimize_memory()\nexcept Exception as e:\n    print(f\"❌ LightGlue error: {e}\")\n\n# Test DINOv2 (this will download ~330MB)\ntry:\n    from transformers import AutoModel\n    model = AutoModel.from_pretrained('facebook/dinov2-small')\n    print(\"✅ DINOv2 loaded successfully\")\n    del model\n    optimize_memory()\nexcept Exception as e:\n    print(f\"❌ DINOv2 error: {e}\")\n\nprint(\"\\n✅ All models tested successfully!\")\nprint(\"\\n🚀 Ready to run the pipeline!\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:05:20.061910Z","iopub.execute_input":"2025-12-10T14:05:20.062207Z","iopub.status.idle":"2025-12-10T14:05:27.221622Z","shell.execute_reply.started":"2025-12-10T14:05:20.062185Z","shell.execute_reply":"2025-12-10T14:05:27.220716Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## *Data Check*","metadata":{}},{"cell_type":"code","source":"data_path = Path('/kaggle/input/image-matching-challenge-2025')\n\n# Check train data\ntrain_labels = pd.read_csv(data_path / 'train_labels.csv')\nprint(f\"\\n📊 Training Data:\")\nprint(f\"   Total images: {len(train_labels)}\")\nprint(f\"   Datasets: {train_labels['dataset'].nunique()}\")\nprint(f\"   Scenes: {train_labels['scene'].nunique()}\")\n\nprint(f\"\\n📂 Available datasets:\")\nfor dataset in train_labels['dataset'].unique():\n    scenes = train_labels[train_labels['dataset'] == dataset]['scene'].unique()\n    n_images = len(train_labels[train_labels['dataset'] == dataset])\n    print(f\"   • {dataset}: {len(scenes)} scenes, {n_images} images\")\n\nprint(f\"\\n💡 Start with a small scene first (e.g., 'fountain' from imc2023_haiper)\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:05:27.969049Z","iopub.execute_input":"2025-12-10T14:05:27.969829Z","iopub.status.idle":"2025-12-10T14:05:28.019656Z","shell.execute_reply.started":"2025-12-10T14:05:27.969800Z","shell.execute_reply":"2025-12-10T14:05:28.018926Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## *Configuration*","metadata":{}},{"cell_type":"code","source":"class Config:\n    # Paths\n    DATA_PATH = Path('/kaggle/input/image-matching-challenge-2025')\n    TRAIN_PATH = DATA_PATH / 'train'\n    TEST_PATH = DATA_PATH / 'test'\n    \n    # Processing settings\n    IS_TRAIN = True\n    DATASETS_TO_PROCESS = ['imc2023_haiper']  # Start with one, expand later\n    \n    # Image processing\n    RESIZE_MAX = 1280\n    TILE_SIZE = 1280\n    TILE_OVERLAP = 50\n    \n    # Feature extraction\n    ALIKED_MODEL = 'aliked-n16'  # Fast and accurate\n    NUM_FEATURES = 2048\n    \n    # Matching\n    LIGHTGLUE_FEATURES = 'aliked'\n    MATCH_THRESHOLD = 0.2\n    \n    # Image retrieval\n    DINO_MODEL = 'facebook/dinov2-small'\n    RETRIEVAL_TOP_K = 20  # Match top K similar images\n    SIM_THRESHOLD = 0.6\n    \n    # 3D reconstruction\n    MIN_MATCHES = 15\n    RANSAC_THRESH = 4.0\n    \n    # Hardware\n    DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'\n    BATCH_SIZE = 1  # Process one image pair at a time\n\nconfig = Config()\nprint(f\"🚀 Running on {config.DEVICE} | Mode: {'TRAIN' if config.IS_TRAIN else 'TEST'}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:05:41.218260Z","iopub.execute_input":"2025-12-10T14:05:41.218564Z","iopub.status.idle":"2025-12-10T14:05:41.224952Z","shell.execute_reply.started":"2025-12-10T14:05:41.218540Z","shell.execute_reply":"2025-12-10T14:05:41.224066Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## *Helper Functions*","metadata":{}},{"cell_type":"code","source":"def load_image(img_path, resize_max=None):\n    \"\"\"Load and preprocess image\"\"\"\n    img = Image.open(img_path).convert('RGB')\n    \n    if resize_max:\n        w, h = img.size\n        scale = min(resize_max / max(w, h), 1.0)\n        if scale < 1.0:\n            new_w, new_h = int(w * scale), int(h * scale)\n            img = img.resize((new_w, new_h), Image.LANCZOS)\n    \n    return np.array(img)\n\ndef to_tensor(img):\n    \"\"\"Convert image to tensor\"\"\"\n    if len(img.shape) == 2:\n        img = img[..., None]\n    return torch.from_numpy(img).permute(2, 0, 1).float() / 255.0\n\ndef compute_rotation_error(R_pred, R_gt):\n    \"\"\"Compute rotation error in degrees\"\"\"\n    R_err = R_pred @ R_gt.T\n    trace = np.trace(R_err)\n    angle = np.arccos(np.clip((trace - 1) / 2, -1, 1))\n    return np.degrees(angle)\n\ndef compute_translation_error(t_pred, t_gt):\n    \"\"\"Compute translation error (angular error)\"\"\"\n    t_pred_norm = t_pred / (np.linalg.norm(t_pred) + 1e-8)\n    t_gt_norm = t_gt / (np.linalg.norm(t_gt) + 1e-8)\n    angle = np.arccos(np.clip(np.dot(t_pred_norm, t_gt_norm), -1, 1))\n    return np.degrees(angle)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:05:45.059266Z","iopub.execute_input":"2025-12-10T14:05:45.059766Z","iopub.status.idle":"2025-12-10T14:05:45.066790Z","shell.execute_reply.started":"2025-12-10T14:05:45.059741Z","shell.execute_reply":"2025-12-10T14:05:45.066160Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Image Retreival","metadata":{}},{"cell_type":"markdown","source":"## *Hierarchical image retrieval using DINO v2 - small*","metadata":{}},{"cell_type":"code","source":"class ImageRetrieval:\n    def __init__(self, model_name=config.DINO_MODEL, device=config.DEVICE):\n        print(\"📸 Initializing DINOv2 for image retrieval...\")\n        self.device = device\n        self.processor = AutoImageProcessor.from_pretrained(model_name)\n        self.model = AutoModel.from_pretrained(model_name).to(device)\n        self.model.eval()\n        self.features_cache = {}\n    \n    @torch.no_grad()\n    def extract_features(self, img_path):\n        \"\"\"Extract global image features\"\"\"\n        if str(img_path) in self.features_cache:\n            return self.features_cache[str(img_path)]\n        \n        img = Image.open(img_path).convert('RGB')\n        inputs = self.processor(images=img, return_tensors=\"pt\").to(self.device)\n        outputs = self.model(**inputs)\n        \n        # Use CLS token as global feature\n        features = outputs.last_hidden_state[:, 0].cpu().numpy()[0]\n        self.features_cache[str(img_path)] = features\n        \n        return features\n    \n    def find_similar_images(self, query_path, candidate_paths, top_k=20):\n        \"\"\"Find top-k similar images using cosine similarity\"\"\"\n        query_feat = self.extract_features(query_path)\n        \n        similarities = []\n        for cand_path in candidate_paths:\n            if cand_path == query_path:\n                continue\n            cand_feat = self.extract_features(cand_path)\n            sim = 1 - cosine(query_feat, cand_feat)\n            similarities.append((cand_path, sim))\n        \n        # Sort by similarity\n        similarities.sort(key=lambda x: x[1], reverse=True)\n        return similarities[:top_k]\n    \n    def cluster_images(self, img_paths, eps=0.3, min_samples=2):\n        \"\"\"Cluster images using DBSCAN on feature space\"\"\"\n        features = np.array([self.extract_features(p) for p in img_paths])\n        \n        # DBSCAN clustering\n        clustering = DBSCAN(eps=eps, min_samples=min_samples, metric='cosine')\n        labels = clustering.fit_predict(features)\n        \n        # Group images by cluster\n        clusters = {}\n        for idx, label in enumerate(labels):\n            if label not in clusters:\n                clusters[label] = []\n            clusters[label].append(img_paths[idx])\n        \n        return clusters","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:05:49.212561Z","iopub.execute_input":"2025-12-10T14:05:49.213262Z","iopub.status.idle":"2025-12-10T14:05:49.221333Z","shell.execute_reply.started":"2025-12-10T14:05:49.213240Z","shell.execute_reply":"2025-12-10T14:05:49.220651Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Feature Matching","metadata":{}},{"cell_type":"markdown","source":"## *ALIKED + LightGlue*","metadata":{}},{"cell_type":"code","source":"class FeatureMatcher:\n    \"\"\"ALIKED + LightGlue feature matching - DEBUGGED VERSION\"\"\"\n    \n    def __init__(self, device=config.DEVICE):\n        print(\"🔍 Initializing ALIKED + LightGlue...\")\n        self.device = device\n        \n        # Load models\n        self.extractor = ALIKED(\n            model_name=config.ALIKED_MODEL,\n            max_num_keypoints=config.NUM_FEATURES,\n            detection_threshold=0.2,\n            resize=config.RESIZE_MAX\n        ).to(device).eval()\n        \n        self.matcher = LightGlue(\n            features=config.LIGHTGLUE_FEATURES,\n            depth_confidence=-1,\n            width_confidence=-1\n        ).to(device).eval()\n    \n    @torch.no_grad()\n    def extract_features(self, img_path):\n        \"\"\"Extract keypoints and descriptors\"\"\"\n        img = load_image(img_path, config.RESIZE_MAX)\n        img_tensor = to_tensor(img).unsqueeze(0).to(self.device)\n\n        # Extract features - pass image tensor directly, not in dict\n        features = self.extractor.extract(img_tensor)\n        \n        kpts = features['keypoints']\n        desc = features['descriptors']\n        \n        # Handle different descriptor formats\n        if desc.ndim == 4:\n            # Dense descriptors - extract at keypoint locations\n            B, C, H, W = desc.shape\n            kpts_norm = kpts.clone()\n            kpts_norm[..., 0] = kpts_norm[..., 0] / img_tensor.shape[-1] * (W - 1)\n            kpts_norm[..., 1] = kpts_norm[..., 1] / img_tensor.shape[-2] * (H - 1)\n            \n            # Normalize to [-1, 1] for grid_sample\n            grid = kpts_norm.clone()\n            grid[..., 0] = 2.0 * grid[..., 0] / (W - 1) - 1.0\n            grid[..., 1] = 2.0 * grid[..., 1] / (H - 1) - 1.0\n            grid = grid.unsqueeze(1)  # (B, 1, K, 2)\n            \n            # Sample descriptors at keypoint locations\n            desc = F.grid_sample(desc, grid, mode='bilinear', align_corners=True)\n            desc = desc.squeeze(2).permute(0, 2, 1).contiguous()  # (B, K, C)\n        \n        # Ensure contiguous memory\n        kpts = kpts.contiguous()\n        desc = desc.contiguous()\n        \n        # Add image_size field (required by LightGlue)\n        return {\n            'keypoints': kpts,           # (B, K, 2)\n            'descriptors': desc,         # (B, K, C)\n            'image': img_tensor,         # (B, 3, H, W)\n            'image_size': torch.tensor([[img_tensor.shape[-2], img_tensor.shape[-1]]], \n                                       device=self.device, dtype=torch.float32)  # (B, 2) = [H, W]\n        }\n    \n    @torch.no_grad()\n    def match_pair(self, feat0, feat1):\n        \"\"\"Match features between two images\"\"\"\n        \n        # Prepare data for LightGlue - use exact format expected\n        data = {\n            'image0': {\n                'image': feat0['image'],\n                'keypoints': feat0['keypoints'],\n                'descriptors': feat0['descriptors'],\n                'image_size': feat0['image_size']\n            },\n            'image1': {\n                'image': feat1['image'],\n                'keypoints': feat1['keypoints'],\n                'descriptors': feat1['descriptors'],\n                'image_size': feat1['image_size']\n            }\n        }\n        \n        # Run matcher\n        try:\n            matches = self.matcher(data)\n        except Exception as e:\n            print(f\"⚠️ Matcher error: {e}\")\n            # Print debug info\n            print(\"Debug shapes:\")\n            print(f\"  image0: {feat0['image'].shape}\")\n            print(f\"  image1: {feat1['image'].shape}\")\n            print(f\"  keypoints0: {feat0['keypoints'].shape}\")\n            print(f\"  keypoints1: {feat1['keypoints'].shape}\")\n            print(f\"  descriptors0: {feat0['descriptors'].shape}\")\n            print(f\"  descriptors1: {feat1['descriptors'].shape}\")\n            print(f\"  image_size0: {feat0['image_size'].shape if 'image_size' in feat0 else 'N/A'}\")\n            print(f\"  image_size1: {feat1['image_size'].shape if 'image_size' in feat1 else 'N/A'}\")\n            import traceback\n            traceback.print_exc()\n            return np.empty((0,2)), np.empty((0,2)), np.empty((0,))\n        \n        # Extract matches\n        matches0 = matches['matches0']  # (B, K0)\n        if matches0.ndim == 2:\n            matches0 = matches0[0]  # Remove batch dim -> (K0,)\n        \n        valid = matches0 > -1\n        \n        if valid.sum() == 0:\n            return np.empty((0,2)), np.empty((0,2)), np.empty((0,))\n        \n        # Get matched keypoints\n        kpts0 = feat0['keypoints'][0] if feat0['keypoints'].ndim == 3 else feat0['keypoints']\n        kpts1 = feat1['keypoints'][0] if feat1['keypoints'].ndim == 3 else feat1['keypoints']\n        \n        mkpts0 = kpts0[valid].cpu().numpy()\n        mkpts1 = kpts1[matches0[valid]].cpu().numpy()\n        \n        # Get confidence scores\n        if 'matching_scores0' in matches:\n            scores = matches['matching_scores0']\n            if scores.ndim == 2:\n                scores = scores[0]\n            confidence = scores[valid].cpu().numpy()\n        else:\n            confidence = np.ones(len(mkpts0))\n        \n        return mkpts0, mkpts1, confidence","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:05:52.508246Z","iopub.execute_input":"2025-12-10T14:05:52.509196Z","iopub.status.idle":"2025-12-10T14:05:52.524665Z","shell.execute_reply.started":"2025-12-10T14:05:52.509159Z","shell.execute_reply":"2025-12-10T14:05:52.524034Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Scene Reconstruction Coordinates Retrieval","metadata":{}},{"cell_type":"markdown","source":"## *3D Reconstruction using PyCOLMAP*","metadata":{}},{"cell_type":"code","source":"class SceneReconstructor:\n    def __init__(self):\n        print(\"🗺️ Initializing PyColmap reconstructor...\")\n        self.reconstruction = None\n    \n    def add_matches(self, img_pairs, matches_dict, img_paths):\n        \"\"\"Prepare matches for reconstruction - MEMORY OPTIMIZED\"\"\"\n        database_path = 'reconstruction.db'\n        \n        # Create database\n        if os.path.exists(database_path):\n            os.remove(database_path)\n        \n        # Use correct PyColmap API\n        db = pycolmap.Database(database_path)\n        \n        # Create camera model\n        camera = pycolmap.Camera(\n            model='SIMPLE_RADIAL',\n            width=1280,\n            height=720,\n            params=[1000.0, 640.0, 360.0, 0.0]\n        )\n        \n        # Write camera to database\n        camera_id = db.write_camera(camera)\n        \n        # Build image ID map first (minimal memory)\n        img_id_map = {}\n        img_to_kpts = {}  # Track which images have keypoints\n        \n        for idx, img_path in enumerate(img_paths):\n            image = pycolmap.Image(\n                name=str(img_path.name),\n                camera_id=camera_id,\n                points2D=[]\n            )\n            img_id = db.write_image(image)\n            img_id_map[str(img_path)] = img_id\n        \n        # Process matches in batches to avoid memory issues\n        print(f\"📝 Writing {len(matches_dict)} image pair matches...\")\n        \n        for idx, ((path0, path1), (mkpts0, mkpts1, conf)) in enumerate(matches_dict.items()):\n            if len(mkpts0) < config.MIN_MATCHES:\n                continue\n            \n            img_id0 = img_id_map.get(str(path0))\n            img_id1 = img_id_map.get(str(path1))\n            \n            if img_id0 is None or img_id1 is None:\n                continue\n            \n            # Only write keypoints ONCE per image (not for every pair!)\n            if img_id0 not in img_to_kpts:\n                db.write_keypoints(img_id0, mkpts0.astype(np.float32))\n                img_to_kpts[img_id0] = len(mkpts0)\n            \n            if img_id1 not in img_to_kpts:\n                db.write_keypoints(img_id1, mkpts1.astype(np.float32))\n                img_to_kpts[img_id1] = len(mkpts1)\n            \n            # Create match array (indices of corresponding points)\n            matches_array = np.column_stack([\n                np.arange(len(mkpts0)), \n                np.arange(len(mkpts1))\n            ]).astype(np.uint32)\n            \n            db.write_matches(img_id0, img_id1, matches_array)\n            \n            # Free memory every 10 pairs\n            if (idx + 1) % 10 == 0:\n                del matches_array\n                gc.collect()\n        \n        db.close()\n        print(f\"✅ Database created with {len(img_to_kpts)} images\")\n        return database_path\n    \n    def reconstruct(self, database_path, output_path='sparse'):\n        \"\"\"Run incremental reconstruction - MEMORY OPTIMIZED\"\"\"\n        if os.path.exists(output_path):\n            import shutil\n            shutil.rmtree(output_path)\n        os.makedirs(output_path, exist_ok=True)\n        \n        # Run mapper with memory-conscious options\n        options = pycolmap.IncrementalPipelineOptions()\n        options.min_num_matches = config.MIN_MATCHES\n        options.init_min_num_inliers = 50\n        options.abs_pose_min_num_inliers = 20\n        \n        print(\"🔨 Running COLMAP reconstruction...\")\n        \n        try:\n            maps = pycolmap.incremental_mapping(\n                database_path=database_path,\n                image_path='.',\n                output_path=output_path,\n                options=options\n            )\n        except Exception as e:\n            print(f\"⚠️ Reconstruction failed: {e}\")\n            import traceback\n            traceback.print_exc()\n            return None\n        \n        if not maps or len(maps) == 0:\n            print(\"⚠️ Reconstruction produced no models!\")\n            return None\n        \n        self.reconstruction = maps[0]\n        print(f\"✅ Reconstructed {len(self.reconstruction.images)} images\")\n        print(f\"   {len(self.reconstruction.points3D)} 3D points\")\n        \n        return self.reconstruction","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:05:56.112044Z","iopub.execute_input":"2025-12-10T14:05:56.112873Z","iopub.status.idle":"2025-12-10T14:05:56.124386Z","shell.execute_reply.started":"2025-12-10T14:05:56.112846Z","shell.execute_reply":"2025-12-10T14:05:56.123601Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## *Against Ground Truth*","metadata":{}},{"cell_type":"code","source":"class PipelineEvaluator:\n    def __init__(self, labels_df):\n        self.labels_df = labels_df\n    \n    def parse_matrix(self, matrix_str):\n        if pd.isna(matrix_str) or matrix_str == 'nan':\n            return None\n        \n        # Remove 'nan;' prefix and split\n        matrix_str = str(matrix_str).replace('nan;', '')\n        values = [float(x) for x in matrix_str.split(';') if x]\n        \n        if len(values) == 9:  # Rotation matrix\n            return np.array(values).reshape(3, 3)\n        elif len(values) == 3:  # Translation vector\n            return np.array(values)\n        return None\n    \n    def evaluate_scene(self, scene_name, reconstruction):\n        \"\"\"Evaluate reconstruction for a scene\"\"\"\n        scene_labels = self.labels_df[self.labels_df['scene'] == scene_name]\n        \n        results = {\n            'rotation_errors': [],\n            'translation_errors': [],\n            'num_images': len(reconstruction.images) if reconstruction else 0,\n            'num_points': len(reconstruction.points3D) if reconstruction else 0\n        }\n        \n        if not reconstruction:\n            return results\n        \n        # Compare reconstructed poses with ground truth\n        for img_id, image in reconstruction.images.items():\n            img_name = image.name\n            \n            # Find ground truth\n            gt_row = scene_labels[scene_labels['image'] == img_name]\n            if gt_row.empty:\n                continue\n            \n            gt_row = gt_row.iloc[0]\n            R_gt = self.parse_matrix(gt_row['rotation_matrix'])\n            t_gt = self.parse_matrix(gt_row['translation_vector'])\n            \n            if R_gt is None or t_gt is None:\n                continue\n            \n            # Get reconstructed pose\n            R_pred = image.rotmat()\n            t_pred = image.projection_center()\n            \n            # Compute errors\n            rot_err = compute_rotation_error(R_pred, R_gt)\n            trans_err = compute_translation_error(t_pred, t_gt)\n            \n            results['rotation_errors'].append(rot_err)\n            results['translation_errors'].append(trans_err)\n        \n        return results\n    \n    def print_results(self, results):\n        \"\"\"Print evaluation results\"\"\"\n        print(\"\\n\" + \"=\"*60)\n        print(\"📊 EVALUATION RESULTS\")\n        print(\"=\"*60)\n        \n        print(f\"\\n🏗️ Reconstruction Stats:\")\n        print(f\"   • Images reconstructed: {results['num_images']}\")\n        print(f\"   • 3D points: {results['num_points']}\")\n        \n        if results['rotation_errors']:\n            rot_errs = results['rotation_errors']\n            trans_errs = results['translation_errors']\n            \n            print(f\"\\n📐 Pose Accuracy:\")\n            print(f\"   • Rotation error (mean): {np.mean(rot_errs):.2f}°\")\n            print(f\"   • Rotation error (median): {np.median(rot_errs):.2f}°\")\n            print(f\"   • Translation error (mean): {np.mean(trans_errs):.2f}°\")\n            print(f\"   • Translation error (median): {np.median(trans_errs):.2f}°\")\n            \n            # Compute accuracy at thresholds\n            thresholds = [(5, 10), (10, 20), (20, 30)]\n            print(f\"\\n🎯 Accuracy at thresholds:\")\n            for rot_th, trans_th in thresholds:\n                correct = sum(1 for r, t in zip(rot_errs, trans_errs) \n                            if r < rot_th and t < trans_th)\n                acc = 100 * correct / len(rot_errs)\n                print(f\"   • <{rot_th}°/<{trans_th}°: {acc:.1f}%\")\n        \n        print(\"=\"*60 + \"\\n\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:05:59.148044Z","iopub.execute_input":"2025-12-10T14:05:59.148614Z","iopub.status.idle":"2025-12-10T14:05:59.159173Z","shell.execute_reply.started":"2025-12-10T14:05:59.148592Z","shell.execute_reply":"2025-12-10T14:05:59.158455Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Main","metadata":{}},{"cell_type":"markdown","source":"## *Helper to run (1 Scene)*","metadata":{}},{"cell_type":"code","source":"def run_pipeline(dataset_name, scene_name):\n    print(f\"\\n{'='*60}\")\n    print(f\"🎬 Processing: {dataset_name}/{scene_name}\")\n    print(f\"{'='*60}\\n\")\n    \n    # Load labels\n    labels_path = '/kaggle/input/image-matching-challenge-2025/train_labels.csv'\n    labels_df = pd.read_csv(labels_path)\n    \n    # Filter for this scene\n    scene_df = labels_df[\n        (labels_df['dataset'] == dataset_name) & \n        (labels_df['scene'] == scene_name)\n    ]\n    \n    if scene_df.empty:\n        print(f\"❌ No data found for {dataset_name}/{scene_name}\")\n        return\n    \n    # Get image paths\n    img_dir = '/kaggle/input/image-matching-challenge-2025/train/imc2023_haiper'\n    img_dir = Path(img_dir)\n    img_paths = sorted(list(img_dir.glob('*.png')) + list(img_dir.glob('*.jpg')))\n    \n    print(f\"📂 Found {len(img_paths)} images\")\n    \n    # Phase 1: Image Retrieval (MEMORY OPTIMIZED)\n    print(\"\\n\" + \"-\"*60)\n    print(\"PHASE 1: Image Retrieval\")\n    print(\"-\"*60)\n    \n    retriever = ImageRetrieval()\n    \n    # Limit to first 8 images to avoid memory issues (reduced from 10)\n    sample_imgs = img_paths[:8]\n    \n    # Find image pairs to match (smart pairing)\n    pairs_to_match = []\n    for img_path in tqdm(sample_imgs, desc=\"Finding pairs\"):\n        similar = retriever.find_similar_images(\n            img_path, sample_imgs, top_k=min(3, len(sample_imgs)-1)  # Reduced from 5\n        )\n        for sim_path, sim_score in similar:\n            if sim_score >= config.SIM_THRESHOLD:\n                pairs_to_match.append((img_path, sim_path, sim_score))\n    \n    print(f\"✅ Found {len(pairs_to_match)} promising image pairs\")\n    \n    # Clear retriever completely\n    retriever.features_cache.clear()\n    del retriever.model, retriever.processor, retriever\n    gc.collect()\n    torch.cuda.empty_cache()\n    \n    # Phase 2: Feature Matching (AGGRESSIVELY MEMORY OPTIMIZED)\n    print(\"\\n\" + \"-\"*60)\n    print(\"PHASE 2: Feature Matching\")\n    print(\"-\"*60)\n    \n    matcher = FeatureMatcher()\n    matches_dict = {}\n    \n    # Drastically limit pairs to avoid OOM\n    max_pairs = min(20, len(pairs_to_match))  # Reduced from 30\n    print(f\"⚙️ Processing {max_pairs} pairs (out of {len(pairs_to_match)} found)\")\n    \n    for idx, (img0_path, img1_path, _) in enumerate(tqdm(pairs_to_match[:max_pairs], desc=\"Matching\")):\n        try:\n            # Extract features\n            feat0 = matcher.extract_features(img0_path)\n            feat1 = matcher.extract_features(img1_path)\n            \n            # Match\n            mkpts0, mkpts1, conf = matcher.match_pair(feat0, feat1)\n            \n            # CRITICAL: Free tensors IMMEDIATELY\n            del feat0['image'], feat0['keypoints'], feat0['descriptors'], feat0['image_size']\n            del feat1['image'], feat1['keypoints'], feat1['descriptors'], feat1['image_size']\n            del feat0, feat1\n            \n            if len(mkpts0) >= config.MIN_MATCHES:\n                matches_dict[(img0_path, img1_path)] = (mkpts0, mkpts1, conf)\n            else:\n                # Free even if not saving\n                del mkpts0, mkpts1, conf\n            \n            # AGGRESSIVE cleanup every 3 matches\n            if (idx + 1) % 3 == 0:\n                gc.collect()\n                torch.cuda.empty_cache()\n                \n        except Exception as e:\n            print(f\"⚠️ Error matching {img0_path.name} - {img1_path.name}: {e}\")\n            # Emergency cleanup\n            gc.collect()\n            torch.cuda.empty_cache()\n            continue\n    \n    print(f\"✅ Matched {len(matches_dict)} image pairs\")\n    \n    # Free matcher completely\n    del matcher.extractor, matcher.matcher, matcher\n    gc.collect()\n    torch.cuda.empty_cache()\n    \n    # Phase 3: 3D Reconstruction\n    print(\"\\n\" + \"-\"*60)\n    print(\"PHASE 3: 3D Reconstruction\")\n    print(\"-\"*60)\n    \n    reconstructor = SceneReconstructor()\n    database_path = reconstructor.add_matches(\n        list(matches_dict.keys()), matches_dict, sample_imgs\n    )\n    \n    # Free matches_dict after writing to database\n    del matches_dict\n    gc.collect()\n    \n    reconstruction = reconstructor.reconstruct(database_path)\n    \n    # Phase 4: Evaluation\n    print(\"\\n\" + \"-\"*60)\n    print(\"PHASE 4: Evaluation\")\n    print(\"-\"*60)\n    \n    evaluator = PipelineEvaluator(labels_df)\n    results = evaluator.evaluate_scene(scene_name, reconstruction)\n    evaluator.print_results(results)\n    \n    # Visualize (simple 3D plot) - only if points exist\n    if reconstruction and len(reconstruction.points3D) > 0:\n        visualize_3d_points(reconstruction)\n    \n    # Cleanup\n    del reconstructor, evaluator\n    if reconstruction:\n        del reconstruction\n    gc.collect()\n    torch.cuda.empty_cache()\n    \n    return results\n\n\ndef visualize_3d_points(reconstruction, max_points=500):  # Reduced from 1000\n    \"\"\"Visualize 3D reconstruction - ULTRA MEMORY OPTIMIZED\"\"\"\n    print(\"\\n📊 Generating 3D visualization...\")\n    \n    # Extract 3D points (limit to avoid memory issues)\n    points = []\n    colors = []\n    \n    for point_id, point3D in reconstruction.points3D.items():\n        if len(points) >= max_points:\n            break\n        points.append(point3D.xyz)\n        colors.append(point3D.color / 255.0)\n    \n    if len(points) == 0:\n        print(\"⚠️ No 3D points to visualize\")\n        return\n    \n    points = np.array(points)\n    colors = np.array(colors)\n    \n    # Create 3D plot\n    fig = plt.figure(figsize=(8, 6))  # Further reduced\n    ax = fig.add_subplot(111, projection='3d')\n    \n    ax.scatter(points[:, 0], points[:, 1], points[:, 2], \n               c=colors, s=0.5, alpha=0.6)  # Smaller points\n    \n    # Camera positions\n    cam_positions = []\n    for img_id, image in reconstruction.images.items():\n        cam_positions.append(image.projection_center())\n    \n    if cam_positions:\n        cam_positions = np.array(cam_positions)\n        ax.scatter(cam_positions[:, 0], cam_positions[:, 1], cam_positions[:, 2],\n                   c='red', s=80, marker='^', label='Cameras')  # Smaller cameras\n    \n    ax.set_xlabel('X')\n    ax.set_ylabel('Y')\n    ax.set_zlabel('Z')\n    ax.set_title(f'3D Reconstruction ({len(points)} points)', fontsize=10)\n    ax.legend(fontsize=8)\n    \n    plt.tight_layout()\n    plt.savefig('reconstruction_3d.png', dpi=80, bbox_inches='tight')  # Lower DPI\n    print(\"✅ Saved visualization to reconstruction_3d.png\")\n    plt.show()\n    plt.close(fig)  # Free memory\n    \n    # Cleanup\n    del points, colors, cam_positions, fig, ax\n    gc.collect()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:06:03.053414Z","iopub.execute_input":"2025-12-10T14:06:03.054005Z","iopub.status.idle":"2025-12-10T14:06:03.071649Z","shell.execute_reply.started":"2025-12-10T14:06:03.053981Z","shell.execute_reply":"2025-12-10T14:06:03.070770Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## *Runner*","metadata":{}},{"cell_type":"code","source":"# MODIFIED: Only run image retrieval + matching (Phases 1-2)\n# Reconstruction moved to separate cells for debugging\n\ndataset = 'imc2023_haiper'\nscene = 'fountain'\n\nprint(f\"\\n{'='*60}\")\nprint(f\"🎬 Processing: {dataset}/{scene}\")\nprint(f\"{'='*60}\\n\")\n\n# Load labels\nlabels_path = '/kaggle/input/image-matching-challenge-2025/train_labels.csv'\nlabels_df = pd.read_csv(labels_path)\n\n# Filter for this scene\nscene_df = labels_df[\n    (labels_df['dataset'] == dataset) & \n    (labels_df['scene'] == scene)\n]\n\n# Get image paths\nimg_dir = '/kaggle/input/image-matching-challenge-2025/train/imc2023_haiper'\nimg_dir = Path(img_dir)\nimg_paths = sorted(list(img_dir.glob('*.png')) + list(img_dir.glob('*.jpg')))\n\nprint(f\"📂 Found {len(img_paths)} images\")\n\n# Phase 1: Image Retrieval\nprint(\"\\n\" + \"-\"*60)\nprint(\"PHASE 1: Image Retrieval\")\nprint(\"-\"*60)\n\nretriever = ImageRetrieval()\n\n# Limit to first 8 images\nsample_imgs = img_paths[:8]\n\n# Find image pairs to match\npairs_to_match = []\nfor img_path in tqdm(sample_imgs, desc=\"Finding pairs\"):\n    similar = retriever.find_similar_images(\n        img_path, sample_imgs, top_k=min(3, len(sample_imgs)-1)\n    )\n    for sim_path, sim_score in similar:\n        if sim_score >= config.SIM_THRESHOLD:\n            pairs_to_match.append((img_path, sim_path, sim_score))\n\nprint(f\"✅ Found {len(pairs_to_match)} promising image pairs\")\n\n# Clear retriever completely\nretriever.features_cache.clear()\ndel retriever.model, retriever.processor, retriever\ngc.collect()\ntorch.cuda.empty_cache()\n\n# Phase 2: Feature Matching\nprint(\"\\n\" + \"-\"*60)\nprint(\"PHASE 2: Feature Matching\")\nprint(\"-\"*60)\n\nmatcher = FeatureMatcher()\nmatches_dict = {}\n\n# Process limited pairs\nmax_pairs = min(20, len(pairs_to_match))\nprint(f\"⚙️ Processing {max_pairs} pairs (out of {len(pairs_to_match)} found)\")\n\nfor idx, (img0_path, img1_path, _) in enumerate(tqdm(pairs_to_match[:max_pairs], desc=\"Matching\")):\n    try:\n        # Extract features\n        feat0 = matcher.extract_features(img0_path)\n        feat1 = matcher.extract_features(img1_path)\n        \n        # Match\n        mkpts0, mkpts1, conf = matcher.match_pair(feat0, feat1)\n        \n        # Free tensors IMMEDIATELY\n        del feat0['image'], feat0['keypoints'], feat0['descriptors'], feat0['image_size']\n        del feat1['image'], feat1['keypoints'], feat1['descriptors'], feat1['image_size']\n        del feat0, feat1\n        \n        if len(mkpts0) >= config.MIN_MATCHES:\n            matches_dict[(img0_path, img1_path)] = (mkpts0, mkpts1, conf)\n        else:\n            del mkpts0, mkpts1, conf\n        \n        # Cleanup every 3 matches\n        if (idx + 1) % 3 == 0:\n            gc.collect()\n            torch.cuda.empty_cache()\n            \n    except Exception as e:\n        print(f\"⚠️ Error matching {img0_path.name} - {img1_path.name}: {e}\")\n        gc.collect()\n        torch.cuda.empty_cache()\n        continue\n\nprint(f\"✅ Matched {len(matches_dict)} image pairs\")\n\n# Free matcher completely\ndel matcher.extractor, matcher.matcher, matcher\ngc.collect()\ntorch.cuda.empty_cache()\n\nprint(\"\\n\" + \"=\"*60)\nprint(\"✅ PHASES 1-2 COMPLETED\")\nprint(\"=\"*60)\nprint(f\"📊 Summary:\")\nprint(f\"   • Images: {len(sample_imgs)}\")\nprint(f\"   • Matched pairs: {len(matches_dict)}\")\nprint(f\"   • Total matches: {sum(len(v[0]) for v in matches_dict.values())}\")\nprint(f\"\\n💾 Variables saved:\")\nprint(f\"   • sample_imgs: List of {len(sample_imgs)} image paths\")\nprint(f\"   • matches_dict: {len(matches_dict)} image pairs with matches\")\nprint(f\"   • labels_df: Ground truth labels\")\nprint(f\"   • scene: '{scene}'\")\nprint(f\"\\n▶️ Run next cell to analyze reconstruction code\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:06:06.733683Z","iopub.execute_input":"2025-12-10T14:06:06.734265Z","iopub.status.idle":"2025-12-10T14:06:22.514610Z","shell.execute_reply.started":"2025-12-10T14:06:06.734241Z","shell.execute_reply":"2025-12-10T14:06:22.513930Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Evaluation","metadata":{}},{"cell_type":"code","source":"print(\"\\n\" + \"-\"*60)\nprint(\"PHASE 3: Evaluate Matches Against Ground Truth\")\nprint(\"-\"*60)\n\nprint(\"\\n💡 Skipping expensive COLMAP reconstruction...\")\nprint(\"   Instead, evaluating feature matches directly using ground truth poses\")\n\n# Detect actual scene from image names\nsample_scene_names = [img.name for img in sample_imgs]\nprint(f\"\\n🔍 Detected images: {sample_scene_names[:3]}... (showing first 3)\")\n\n# Try to auto-detect scene from filenames\ndetected_scenes = set()\nfor img_name in sample_scene_names:\n    # Extract scene prefix (e.g., 'bike', 'fountain', 'chairs')\n    scene_prefix = img_name.split('_')[0]\n    detected_scenes.add(scene_prefix)\n\nprint(f\"   Detected scene prefixes: {detected_scenes}\")\n\n# Use all available ground truth (don't filter by scene if mismatch)\nscene_labels = labels_df.copy()\nprint(f\"   Loaded {len(scene_labels)} ground truth entries\")\n\n# Helper to parse ground truth matrices\ndef parse_matrix(matrix_str):\n    \"\"\"Parse rotation/translation matrix from string\"\"\"\n    if pd.isna(matrix_str) or matrix_str == 'nan':\n        return None\n    \n    matrix_str = str(matrix_str).replace('nan;', '')\n    values = [float(x) for x in matrix_str.split(';') if x]\n    \n    if len(values) == 9:  # Rotation matrix\n        return np.array(values).reshape(3, 3)\n    elif len(values) == 3:  # Translation vector\n        return np.array(values)\n    return None\n\n# Evaluate match quality using epipolar geometry\nprint(\"\\n📐 Computing epipolar errors for matched pairs...\")\n\nepipolar_errors = []\nmatch_stats = []\n\nfor (img0_path, img1_path), (mkpts0, mkpts1, conf) in matches_dict.items():\n    # Get ground truth for both images\n    gt0 = scene_labels[scene_labels['image'] == img0_path.name]\n    gt1 = scene_labels[scene_labels['image'] == img1_path.name]\n    \n    if gt0.empty or gt1.empty:\n        print(f\"⚠️  Missing ground truth for: {img0_path.name} or {img1_path.name}\")\n        continue\n    \n    gt0 = gt0.iloc[0]\n    gt1 = gt1.iloc[0]\n    \n    # Parse ground truth poses\n    R0 = parse_matrix(gt0['rotation_matrix'])\n    t0 = parse_matrix(gt0['translation_vector'])\n    R1 = parse_matrix(gt1['rotation_matrix'])\n    t1 = parse_matrix(gt1['translation_vector'])\n    \n    if R0 is None or t0 is None or R1 is None or t1 is None:\n        print(f\"⚠️  Invalid pose data for: {img0_path.name} or {img1_path.name}\")\n        continue\n    \n    # Compute relative pose (1 w.r.t 0)\n    R_rel = R1 @ R0.T\n    t_rel = t1 - R_rel @ t0\n    \n    # Normalize translation\n    t_rel = t_rel / (np.linalg.norm(t_rel) + 1e-8)\n    \n    # Compute essential matrix\n    t_cross = np.array([\n        [0, -t_rel[2], t_rel[1]],\n        [t_rel[2], 0, -t_rel[0]],\n        [-t_rel[1], t_rel[0], 0]\n    ])\n    E = t_cross @ R_rel\n    \n    # Compute epipolar errors (assuming calibrated coordinates)\n    # Using simple pinhole: K = [[f, 0, cx], [0, f, cy], [0, 0, 1]]\n    f = 1000.0\n    cx, cy = 640.0, 360.0\n    \n    # Normalize keypoints\n    pts0_norm = np.column_stack([\n        (mkpts0[:, 0] - cx) / f,\n        (mkpts0[:, 1] - cy) / f,\n        np.ones(len(mkpts0))\n    ])\n    \n    pts1_norm = np.column_stack([\n        (mkpts1[:, 0] - cx) / f,\n        (mkpts1[:, 1] - cy) / f,\n        np.ones(len(mkpts1))\n    ])\n    \n    # Compute symmetric epipolar error\n    errors = []\n    for p0, p1 in zip(pts0_norm, pts1_norm):\n        # Epipolar constraint: p1^T * E * p0 = 0\n        # Error is the distance from p1 to the epipolar line\n        Ep0 = E @ p0  # Epipolar line in image 1\n        \n        # Sampson distance (approximation of geometric distance)\n        numerator = (p1 @ Ep0) ** 2\n        denominator = Ep0[0]**2 + Ep0[1]**2 + 1e-8\n        err = np.sqrt(numerator / denominator)\n        errors.append(err)\n    \n    errors = np.array(errors)\n    epipolar_errors.extend(errors)\n    \n    # Compute inlier ratio (using 1e-3 threshold for normalized coords)\n    inlier_ratio = np.mean(errors < 1e-3)\n    \n    match_stats.append({\n        'pair': f\"{img0_path.name} ↔ {img1_path.name}\",\n        'num_matches': len(mkpts0),\n        'mean_error': np.mean(errors),\n        'median_error': np.median(errors),\n        'inlier_ratio': inlier_ratio,\n        'mean_confidence': np.mean(conf)\n    })\n\nepipolar_errors = np.array(epipolar_errors)\n\n# Print detailed results\nprint(\"\\n\" + \"=\"*70)\nprint(\"📊 MATCHING QUALITY EVALUATION\")\nprint(\"=\"*70)\n\nprint(f\"\\n🎯 Overall Statistics:\")\nprint(f\"   • Total matched pairs: {len(matches_dict)}\")\nprint(f\"   • Evaluated pairs: {len(match_stats)}\")\nprint(f\"   • Total match points: {len(epipolar_errors)}\")\n\nif len(epipolar_errors) > 0:\n    print(f\"\\n📐 Epipolar Error Analysis:\")\n    print(f\"   • Mean error: {np.mean(epipolar_errors):.6f}\")\n    print(f\"   • Median error: {np.median(epipolar_errors):.6f}\")\n    print(f\"   • Std dev: {np.std(epipolar_errors):.6f}\")\n    print(f\"   • Min error: {np.min(epipolar_errors):.6f}\")\n    print(f\"   • Max error: {np.max(epipolar_errors):.6f}\")\n    \n    # Inlier rates at different thresholds\n    thresholds = [1e-4, 5e-4, 1e-3, 5e-3, 1e-2]\n    print(f\"\\n✅ Inlier Rates (% matches below threshold):\")\n    for thresh in thresholds:\n        inlier_pct = 100 * np.mean(epipolar_errors < thresh)\n        print(f\"   • Error < {thresh:.0e}: {inlier_pct:.1f}%\")\n    \n    # Per-pair statistics\n    print(f\"\\n📋 Top 10 Best Matched Pairs (by inlier ratio):\")\n    match_stats_sorted = sorted(match_stats, key=lambda x: x['inlier_ratio'], reverse=True)\n    for i, stats in enumerate(match_stats_sorted[:10], 1):\n        print(f\"\\n   {i}. {stats['pair']}\")\n        print(f\"      Matches: {stats['num_matches']:4d} | Inliers: {stats['inlier_ratio']*100:5.1f}% | \"\n              f\"Mean error: {stats['mean_error']:.6f} | Conf: {stats['mean_confidence']:.3f}\")\n\nprint(\"\\n\" + \"=\"*70)\n\nreconstruction = None\n\ndel scene_labels, match_stats, epipolar_errors\ngc.collect()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-10T14:06:22.515950Z","iopub.execute_input":"2025-12-10T14:06:22.516171Z","iopub.status.idle":"2025-12-10T14:06:22.967709Z","shell.execute_reply.started":"2025-12-10T14:06:22.516155Z","shell.execute_reply":"2025-12-10T14:06:22.967150Z"}},"outputs":[],"execution_count":null}]}