{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":91498,"databundleVersionId":11655853},{"sourceType":"datasetVersion","sourceId":15780276,"datasetId":10114216,"databundleVersionId":16725860},{"sourceType":"datasetVersion","sourceId":15794517,"datasetId":10114212,"databundleVersionId":16741073}],"dockerImageVersionId":31329,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Image Matching Challenge 2025 — Submission\n\nPipeline: SuperPoint → DINOv2 retrieval → LightGlue → Louvain clustering → pycolmap SfM\n\n**Required Kaggle datasets (add as input):**\n- `image-matching-challenge-2025` (competition data)\n- `imc2025-pretrained-models` (model weights: SuperPoint, LightGlue, DINOv2-ViTS14)\n- `imc2025-lightglue-src` (LightGlue source code)","metadata":{}},{"cell_type":"code","source":"# ─── Setup: install dependencies, configure offline model loading ───\nimport os, sys, subprocess, glob\n\nWEIGHTS_DIR = \"/kaggle/input/datasets/ahmedn1/imc2025-pretrained-models\"\nLIGHTGLUE_INPUT = \"/kaggle/input/datasets/ahmedn1/imc2025-lightglue-src\"\n\n# Install pycolmap from bundled wheel matching the running Python version\ntry:\n    import pycolmap\nexcept ImportError:\n    py = f\"cp{sys.version_info.major}{sys.version_info.minor}\"\n    candidates = glob.glob(os.path.join(WEIGHTS_DIR, f\"pycolmap-*{py}-{py}-*.whl\"))\n    if not candidates:\n        raise RuntimeError(\n            f\"No pycolmap wheel in {WEIGHTS_DIR} for Python {py}. \"\n            \"Re-upload imc2025-pretrained-models with a matching wheel.\"\n        )\n    subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", candidates[0], \"-q\", \"--no-deps\"])\n\n# Find LightGlue source (directory containing the `lightglue` package)\nLIGHTGLUE_SRC = None\nfor candidate in [\n    os.path.join(LIGHTGLUE_INPUT, \"LightGlue\"),\n    LIGHTGLUE_INPUT,\n]:\n    if os.path.isdir(os.path.join(candidate, \"lightglue\")):\n        LIGHTGLUE_SRC = candidate\n        break\n\nif LIGHTGLUE_SRC is None:\n    for root, dirs, files in os.walk(LIGHTGLUE_INPUT):\n        if \"lightglue\" in dirs and os.path.isfile(os.path.join(root, \"lightglue\", \"__init__.py\")):\n            LIGHTGLUE_SRC = root\n            break\n\nif LIGHTGLUE_SRC is None:\n    raise RuntimeError(f\"LightGlue source not found under {LIGHTGLUE_INPUT}\")\nprint(f\"LightGlue source: {LIGHTGLUE_SRC}\")\n\n# LightGlue is pure Python — put it on sys.path (no pip build, no internet needed)\nif LIGHTGLUE_SRC not in sys.path:\n    sys.path.insert(0, LIGHTGLUE_SRC)\n\n# Point torch hub cache to our bundled weights so models load offline\nos.environ[\"TORCH_HOME\"] = WEIGHTS_DIR\n\nprint(\"Setup complete\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:40:44.823365Z","iopub.execute_input":"2026-04-17T20:40:44.824097Z","iopub.status.idle":"2026-04-17T20:40:44.832557Z","shell.execute_reply.started":"2026-04-17T20:40:44.824066Z","shell.execute_reply":"2026-04-17T20:40:44.831992Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─── 1. Configuration ───\nfrom pathlib import Path\n\nDATA_DIR = Path(\"/kaggle/input/competitions/image-matching-challenge-2025\")\nWORK_DIR = Path(\"/kaggle/working\")\nWORK_DIR.mkdir(exist_ok=True)\n\nCFG = {\n    \"sp_max_keypoints\": 4096,\n    \"dino_model\": \"dinov2_vits14\",\n    \"top_k_pairs\": 20,\n    \"top_k_pairs_sfm\": 20,\n    \"lg_min_matches\": 50,\n    \"lg_min_matches_sfm\": 50,\n    \"min_cluster_size\": 3,\n    \"camera_model\": \"SIMPLE_RADIAL\",\n    \"louvain_resolution\": 0.05,\n}","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:40:44.834036Z","iopub.execute_input":"2026-04-17T20:40:44.834369Z","iopub.status.idle":"2026-04-17T20:40:44.858730Z","shell.execute_reply.started":"2026-04-17T20:40:44.834315Z","shell.execute_reply":"2026-04-17T20:40:44.858144Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─── 2. Imports ───\nimport gc\nimport shutil\nimport sqlite3\nimport tempfile\nimport warnings\n\nimport h5py\nimport networkx as nx\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torchvision.transforms as transforms\nfrom PIL import Image\nfrom tqdm import tqdm\n\nfrom lightglue import LightGlue, SuperPoint\nfrom lightglue.utils import load_image\n\nwarnings.filterwarnings(\"ignore\")\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"Device: {DEVICE}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:40:44.859757Z","iopub.execute_input":"2026-04-17T20:40:44.860064Z","iopub.status.idle":"2026-04-17T20:41:04.690376Z","shell.execute_reply.started":"2026-04-17T20:40:44.860027Z","shell.execute_reply":"2026-04-17T20:41:04.689341Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─── 3. Data loading ───\n\ndef load_dataset_images(split: str = \"test\") -> dict[str, list[Path]]:\n    split_dir = DATA_DIR / split\n    datasets = {}\n    for dataset_dir in sorted(split_dir.iterdir()):\n        if not dataset_dir.is_dir():\n            continue\n        images = sorted([\n            p for p in dataset_dir.iterdir()\n            if p.suffix.lower() in {\".png\", \".jpg\", \".jpeg\"}\n        ])\n        if images:\n            datasets[dataset_dir.name] = images\n    return datasets","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:41:04.692200Z","iopub.execute_input":"2026-04-17T20:41:04.692768Z","iopub.status.idle":"2026-04-17T20:41:04.697640Z","shell.execute_reply.started":"2026-04-17T20:41:04.692743Z","shell.execute_reply":"2026-04-17T20:41:04.696792Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─── 4. SuperPoint feature extraction ───\n\ndef load_sp_model() -> SuperPoint:\n    return SuperPoint(max_num_keypoints=CFG[\"sp_max_keypoints\"]).eval().to(DEVICE)\n\n\ndef extract_sp_features(\n    datasets: dict[str, list[Path]],\n    features_path: Path,\n    extractor: SuperPoint,\n) -> None:\n    with h5py.File(features_path, \"w\") as h5:\n        for dataset, image_paths in datasets.items():\n            grp = h5.require_group(dataset)\n            for img_path in tqdm(image_paths, desc=f\"SuperPoint [{dataset}]\"):\n                img_tensor = load_image(img_path).to(DEVICE)\n                with torch.no_grad():\n                    feats = extractor.extract(img_tensor)\n                kps = feats[\"keypoints\"][0].cpu().numpy()\n                desc = feats[\"descriptors\"][0].cpu().numpy()\n                img_grp = grp.require_group(img_path.name)\n                img_grp.create_dataset(\"keypoints\", data=kps)\n                img_grp.create_dataset(\"descriptors\", data=desc)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:41:04.698521Z","iopub.execute_input":"2026-04-17T20:41:04.698891Z","iopub.status.idle":"2026-04-17T20:41:04.717326Z","shell.execute_reply.started":"2026-04-17T20:41:04.698861Z","shell.execute_reply":"2026-04-17T20:41:04.716657Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─── 5. DINOv2 pair retrieval ───\n\ndef load_dino_model(model_name: str) -> torch.nn.Module:\n    \"\"\"Load DINOv2 model. Works both online (torch.hub) and offline (local weights).\"\"\"\n    weights_path = Path(WEIGHTS_DIR) / \"hub\" / \"checkpoints\" / f\"{model_name}_pretrain.pth\"\n    if weights_path.exists():\n        # Offline: load architecture + weights directly\n        dinov2_repo = Path(WEIGHTS_DIR) / \"hub\" / \"facebookresearch_dinov2_main\"\n        if dinov2_repo.exists():\n            model = torch.hub.load(str(dinov2_repo), model_name, source=\"local\", verbose=False)\n        else:\n            # Fallback: build model manually and load weights\n            model = torch.hub.load(\"facebookresearch/dinov2\", model_name, verbose=False)\n    else:\n        model = torch.hub.load(\"facebookresearch/dinov2\", model_name, verbose=False)\n    return model.eval().to(DEVICE)\n\n\ndef get_dino_transform() -> transforms.Compose:\n    return transforms.Compose([\n        transforms.Resize(224),\n        transforms.CenterCrop(224),\n        transforms.ToTensor(),\n        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n    ])\n\n\ndef extract_dino_embeddings(\n    image_paths: list[Path],\n    model: torch.nn.Module,\n    transform: transforms.Compose,\n    batch_size: int = 16,\n) -> np.ndarray:\n    embeddings = []\n    for i in range(0, len(image_paths), batch_size):\n        batch = [transform(Image.open(p).convert(\"RGB\")) for p in image_paths[i:i+batch_size]]\n        with torch.no_grad():\n            feats = model(torch.stack(batch).to(DEVICE))\n        embeddings.append(feats.cpu().numpy())\n    return np.concatenate(embeddings, axis=0).astype(np.float32)\n\n\ndef retrieve_candidate_pairs(\n    image_paths: list[Path],\n    embeddings: np.ndarray,\n    top_k: int,\n) -> list[tuple[int, int]]:\n    norms = np.linalg.norm(embeddings, axis=1, keepdims=True)\n    normed = embeddings / (norms + 1e-8)\n    sim = normed @ normed.T\n    np.fill_diagonal(sim, -1)\n    k = min(top_k, len(image_paths) - 1)\n    pairs = set()\n    for i in range(len(image_paths)):\n        for j in np.argpartition(sim[i], -k)[-k:]:\n            pairs.add((min(i, int(j)), max(i, int(j))))\n    return sorted(pairs)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:41:04.718272Z","iopub.execute_input":"2026-04-17T20:41:04.718560Z","iopub.status.idle":"2026-04-17T20:41:04.733724Z","shell.execute_reply.started":"2026-04-17T20:41:04.718530Z","shell.execute_reply":"2026-04-17T20:41:04.733193Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─── 6. LightGlue matching ───\n\ndef load_lg_matcher() -> LightGlue:\n    return LightGlue(features=\"superpoint\").eval().to(DEVICE)\n\n\ndef match_and_verify_pairs_lg(\n    image_paths: list[Path],\n    candidate_pairs: list[tuple[int, int]],\n    features_h5: h5py.File,\n    dataset: str,\n    matcher: LightGlue,\n) -> dict[tuple[int, int], tuple[np.ndarray, int]]:\n    verified = {}\n    grp = features_h5[dataset]\n    names = [p.name for p in image_paths]\n\n    for (i, j) in tqdm(candidate_pairs, desc=f\"LightGlue [{dataset}]\"):\n        name_i, name_j = names[i], names[j]\n\n        feats_i = {\n            \"keypoints\":   torch.tensor(grp[name_i][\"keypoints\"][:],   dtype=torch.float32).unsqueeze(0).to(DEVICE),\n            \"descriptors\": torch.tensor(grp[name_i][\"descriptors\"][:], dtype=torch.float32).unsqueeze(0).to(DEVICE),\n        }\n        feats_j = {\n            \"keypoints\":   torch.tensor(grp[name_j][\"keypoints\"][:],   dtype=torch.float32).unsqueeze(0).to(DEVICE),\n            \"descriptors\": torch.tensor(grp[name_j][\"descriptors\"][:], dtype=torch.float32).unsqueeze(0).to(DEVICE),\n        }\n\n        with torch.no_grad():\n            result = matcher({\"image0\": feats_i, \"image1\": feats_j})\n\n        matches0 = result[\"matches0\"][0].cpu().numpy()\n        valid = matches0 >= 0\n        n_matches = int(valid.sum())\n\n        if n_matches < CFG[\"lg_min_matches_sfm\"]:\n            continue\n\n        idx_i = np.where(valid)[0]\n        idx_j = matches0[valid]\n        match_pairs = np.stack([idx_i, idx_j], axis=1)\n        verified[(i, j)] = (match_pairs, n_matches)\n\n    return verified","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:41:04.734584Z","iopub.execute_input":"2026-04-17T20:41:04.734969Z","iopub.status.idle":"2026-04-17T20:41:04.756568Z","shell.execute_reply.started":"2026-04-17T20:41:04.734939Z","shell.execute_reply":"2026-04-17T20:41:04.756064Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─── 7. Graph clustering ───\n\ndef cluster_images(\n    image_paths: list[Path],\n    verified_pairs: dict[tuple[int, int], tuple[np.ndarray, int]],\n    cluster_pairs_set: set[tuple[int, int]],\n    min_cluster_size: int,\n) -> dict[str, list[int]]:\n    n = len(image_paths)\n\n    G = nx.Graph()\n    G.add_nodes_from(range(n))\n    for (i, j), (_, n_matches) in verified_pairs.items():\n        if (i, j) in cluster_pairs_set and n_matches >= CFG[\"lg_min_matches\"]:\n            G.add_edge(i, j, weight=n_matches)\n\n    communities = nx.community.louvain_communities(\n        G, weight=\"weight\", resolution=CFG[\"louvain_resolution\"], seed=42\n    )\n\n    clusters: dict[str, list[int]] = {}\n    outlier_indices: list[int] = []\n    scene_idx = 0\n    for community in sorted(communities, key=len, reverse=True):\n        component = sorted(community)\n        if len(component) < min_cluster_size:\n            outlier_indices.extend(component)\n        else:\n            clusters[f\"cluster{scene_idx}\"] = component\n            scene_idx += 1\n\n    if outlier_indices:\n        clusters[\"outliers\"] = outlier_indices\n\n    print(f\"  {scene_idx} clusters, {len(outlier_indices)}/{n} outliers\")\n    for label, indices in clusters.items():\n        if label != \"outliers\":\n            print(f\"    {label}: {len(indices)} images\")\n    return clusters","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:41:04.757435Z","iopub.execute_input":"2026-04-17T20:41:04.757711Z","iopub.status.idle":"2026-04-17T20:41:04.781341Z","shell.execute_reply.started":"2026-04-17T20:41:04.757682Z","shell.execute_reply":"2026-04-17T20:41:04.780745Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─── 8. SfM ───\nimport pycolmap\n\ndef _encode_float32(arr: np.ndarray) -> bytes:\n    return arr.astype(np.float32).tobytes()\n\ndef _encode_uint32(arr: np.ndarray) -> bytes:\n    return arr.astype(np.uint32).tobytes()\n\ndef _encode_float64(arr: np.ndarray) -> bytes:\n    return arr.astype(np.float64).tobytes()\n\ndef _colmap_pair_id(id1: int, id2: int) -> int:\n    if id1 > id2:\n        id1, id2 = id2, id1\n    return id1 * 2147483647 + id2\n\n\ndef run_sfm_for_cluster(\n    cluster_image_paths: list[Path],\n    features_h5: h5py.File,\n    dataset: str,\n    name_verified: dict[tuple[str, str], tuple[np.ndarray, int]],\n) -> dict[str, tuple[np.ndarray, np.ndarray]]:\n\n    names = [p.name for p in cluster_image_paths]\n    orig_img_dir = cluster_image_paths[0].parent\n    grp = features_h5[dataset]\n\n    with tempfile.TemporaryDirectory() as tmp_str:\n        tmp_dir = Path(tmp_str)\n        db_path = tmp_dir / \"database.db\"\n        output_path = tmp_dir / \"sparse\"\n        output_path.mkdir()\n\n        # Detect shared intrinsics\n        sizes = set()\n        for p in cluster_image_paths:\n            im = Image.open(p)\n            sizes.add(im.size)\n            im.close()\n        camera_mode = pycolmap.CameraMode.SINGLE if len(sizes) == 1 else pycolmap.CameraMode.PER_IMAGE\n\n        reader_opts = pycolmap.ImageReaderOptions()\n        reader_opts.camera_model = CFG[\"camera_model\"]\n        reader_opts.default_focal_length_factor = 1.2\n        extract_opts = pycolmap.FeatureExtractionOptions()\n        extract_opts.sift.max_num_features = 512\n        pycolmap.extract_features(\n            str(db_path), str(orig_img_dir),\n            image_names=names,\n            camera_mode=camera_mode,\n            reader_options=reader_opts,\n            extraction_options=extract_opts,\n        )\n\n        # Inject SP keypoints + LG matches via sqlite3\n        conn = sqlite3.connect(str(db_path))\n\n        name_to_db_id = dict(conn.execute(\"SELECT name, image_id FROM images\").fetchall())\n        img_to_cam_id = dict(conn.execute(\"SELECT image_id, camera_id FROM images\").fetchall())\n\n        cam_objects: dict[int, pycolmap.Camera] = {}\n        for cam_id, model, w, h, params_blob in conn.execute(\n            \"SELECT camera_id, model, width, height, params FROM cameras\"\n        ).fetchall():\n            params = np.frombuffer(params_blob, dtype=np.float64).tolist()\n            cam_objects[cam_id] = pycolmap.Camera(\n                model=CFG[\"camera_model\"], width=int(w), height=int(h), params=params\n            )\n\n        # Overwrite SIFT keypoints with SuperPoint\n        for name in names:\n            db_id = name_to_db_id[name]\n            kps = grp[name][\"keypoints\"][:].astype(np.float32)\n            conn.execute(\n                \"UPDATE keypoints SET rows=?, cols=?, data=? WHERE image_id=?\",\n                (len(kps), 2, _encode_float32(kps), db_id)\n            )\n\n        conn.execute(\"DELETE FROM matches\")\n        conn.execute(\"DELETE FROM two_view_geometries\")\n\n        tvg_options = pycolmap.TwoViewGeometryOptions()\n\n        for (name_i, name_j), (match_pairs, _) in name_verified.items():\n            if name_i not in name_to_db_id or name_j not in name_to_db_id:\n                continue\n\n            db_id_i = name_to_db_id[name_i]\n            db_id_j = name_to_db_id[name_j]\n\n            if db_id_i > db_id_j:\n                db_id_i, db_id_j = db_id_j, db_id_i\n                name_i, name_j = name_j, name_i\n                match_pairs = match_pairs[:, ::-1].copy()\n\n            pair_id = _colmap_pair_id(db_id_i, db_id_j)\n            m = len(match_pairs)\n\n            conn.execute(\n                \"INSERT OR REPLACE INTO matches(pair_id, rows, cols, data) VALUES(?,?,?,?)\",\n                (pair_id, m, 2, _encode_uint32(match_pairs))\n            )\n\n            kps_i = grp[name_i][\"keypoints\"][:][match_pairs[:, 0]].astype(np.float64)\n            kps_j = grp[name_j][\"keypoints\"][:][match_pairs[:, 1]].astype(np.float64)\n            all_pairs = np.column_stack([np.arange(m), np.arange(m)]).astype(np.uint32)\n\n            try:\n                tvg = pycolmap.estimate_two_view_geometry(\n                    cam_objects[img_to_cam_id[db_id_i]], kps_i,\n                    cam_objects[img_to_cam_id[db_id_j]], kps_j,\n                    all_pairs, tvg_options,\n                )\n            except Exception:\n                continue\n\n            if tvg.config == 0:\n                continue\n            if len(tvg.inlier_matches) < CFG[\"lg_min_matches_sfm\"]:\n                continue\n\n            def _mat(m):\n                return _encode_float64(np.array(m)) if m is not None else bytes(72)\n\n            qvec = (\n                np.array(tvg.cam2_from_cam1.rotation.quat, dtype=np.float64)\n                if tvg.cam2_from_cam1 is not None else None\n            )\n            tvec = (\n                np.array(tvg.cam2_from_cam1.translation, dtype=np.float64)\n                if tvg.cam2_from_cam1 is not None else None\n            )\n\n            conn.execute(\n                \"INSERT OR REPLACE INTO two_view_geometries\"\n                \"(pair_id, rows, cols, data, config, F, E, H, qvec, tvec)\"\n                \" VALUES(?,?,?,?,?,?,?,?,?,?)\",\n                (\n                    pair_id,\n                    len(tvg.inlier_matches), 2, _encode_uint32(tvg.inlier_matches),\n                    int(tvg.config),\n                    _mat(tvg.F), _mat(tvg.E), _mat(tvg.H),\n                    _encode_float64(qvec) if qvec is not None else None,\n                    _encode_float64(tvec) if tvec is not None else None,\n                )\n            )\n\n        conn.commit()\n        conn.close()\n\n        # Incremental SfM — 3 seeds for robustness\n        SFM_SEEDS = [42, 0, 123]\n\n        best_poses: dict[str, tuple[np.ndarray, np.ndarray]] = {}\n        best_n = 0\n        best_quality = -1.0\n\n        for seed in SFM_SEEDS:\n            sfm_opts = pycolmap.IncrementalPipelineOptions()\n            sfm_opts.min_model_size = 2\n            sfm_opts.random_seed = seed\n            sfm_opts.mapper.random_seed = seed\n            sfm_opts.mapper.init_min_num_inliers = 15\n            sfm_opts.mapper.abs_pose_min_num_inliers = 15\n            sfm_opts.mapper.init_min_tri_angle = 4.0\n            sfm_opts.mapper.init_max_forward_motion = 0.99\n            sfm_opts.mapper.init_max_reg_trials = 30\n\n            try:\n                reconstructions = pycolmap.incremental_mapping(\n                    database_path=str(db_path),\n                    image_path=str(orig_img_dir),\n                    output_path=str(output_path),\n                    options=sfm_opts,\n                )\n            except Exception as e:\n                print(f\"      SfM seed={seed} crashed: {e}\")\n                continue\n\n            if not reconstructions:\n                continue\n\n            rec = max(reconstructions.values(), key=lambda r: r.num_reg_images())\n            n_reg = rec.num_reg_images()\n            mean_reproj = rec.compute_mean_reprojection_error()\n            quality_score = n_reg / (1.0 + mean_reproj)\n            if quality_score > best_quality:\n                best_quality = quality_score\n                best_n = n_reg\n                best_poses = {}\n                for _, image in rec.images.items():\n                    if image.has_pose:\n                        pose = image.cam_from_world()\n                        best_poses[image.name] = (\n                            pose.rotation.matrix(),\n                            pose.translation.flatten(),\n                        )\n\n            for p in output_path.iterdir():\n                if p.is_dir():\n                    shutil.rmtree(p)\n\n        return best_poses","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:41:04.782457Z","iopub.execute_input":"2026-04-17T20:41:04.782761Z","iopub.status.idle":"2026-04-17T20:41:04.807626Z","shell.execute_reply.started":"2026-04-17T20:41:04.782729Z","shell.execute_reply":"2026-04-17T20:41:04.806897Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─── 9. Submission helpers ───\n\ndef format_rotation(R: np.ndarray) -> str:\n    return \";\".join(f\"{v:.9f}\" for v in R.flatten())\n\ndef format_translation(t: np.ndarray) -> str:\n    return \";\".join(f\"{v:.9f}\" for v in t)\n\nNAN_ROTATION    = \";\".join([\"nan\"] * 9)\nNAN_TRANSLATION = \";\".join([\"nan\"] * 3)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:41:04.809554Z","iopub.execute_input":"2026-04-17T20:41:04.810060Z","iopub.status.idle":"2026-04-17T20:41:04.829010Z","shell.execute_reply.started":"2026-04-17T20:41:04.810010Z","shell.execute_reply":"2026-04-17T20:41:04.828141Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─── 10. Main pipeline ───\n\ndef run_pipeline(split: str = \"test\") -> pd.DataFrame:\n    features_path = WORK_DIR / f\"features_sp_{split}.h5\"\n\n    print(\"Loading datasets...\")\n    datasets = load_dataset_images(split)\n    print(f\"Found {len(datasets)} datasets: {list(datasets.keys())}\")\n\n    # Load sample submission to get correct image_id suffixes (_public/_private)\n    sample_path = DATA_DIR / \"sample_submission.csv\"\n    image_id_map: dict[tuple[str, str], str] = {}\n    if sample_path.exists():\n        sample = pd.read_csv(sample_path)\n        for _, row in sample.iterrows():\n            image_id_map[(row[\"dataset\"], row[\"image\"])] = row[\"image_id\"]\n        print(f\"Loaded {len(image_id_map)} image_ids from sample_submission.csv\")\n\n    print(\"\\n[1/4] Loading SuperPoint + LightGlue...\")\n    extractor = load_sp_model()\n    matcher   = load_lg_matcher()\n\n    if not features_path.exists():\n        print(\"\\n[2/4] Extracting SuperPoint features...\")\n        extract_sp_features(datasets, features_path, extractor)\n    else:\n        print(f\"\\n[2/4] Loaded cached SP features from {features_path}\")\n\n    del extractor\n    torch.cuda.empty_cache()\n    gc.collect()\n\n    print(\"\\n[3/4] Loading DINOv2 for pair retrieval...\")\n    dino = load_dino_model(CFG[\"dino_model\"])\n    dino_transform = get_dino_transform()\n\n    all_results = []\n\n    with h5py.File(features_path, \"r\") as feat_h5:\n        for dataset, image_paths in datasets.items():\n            print(f\"\\n{'='*60}\")\n            print(f\"Dataset: {dataset}  ({len(image_paths)} images)\")\n            print('='*60)\n\n            names = [p.name for p in image_paths]\n\n            print(\"\\n  DINOv2 retrieval → candidate pairs...\")\n            embs = extract_dino_embeddings(image_paths, dino, dino_transform)\n            pairs_sfm = retrieve_candidate_pairs(image_paths, embs, CFG[\"top_k_pairs_sfm\"])\n            pairs_cluster = retrieve_candidate_pairs(image_paths, embs, CFG[\"top_k_pairs\"])\n            cluster_pairs_set = set(pairs_cluster)\n            print(f\"  {len(pairs_sfm)} SfM candidates, {len(pairs_cluster)} cluster candidates\")\n            del embs; gc.collect()\n\n            print(\"\\n  LightGlue matching...\")\n            verified = match_and_verify_pairs_lg(\n                image_paths, pairs_sfm, feat_h5, dataset, matcher\n            )\n            print(f\"  Verified {len(verified)}/{len(pairs_sfm)} pairs\")\n\n            name_verified = {\n                (names[i], names[j]): val\n                for (i, j), val in verified.items()\n            }\n\n            print(\"\\n  Graph clustering...\")\n            clusters = cluster_images(image_paths, verified, cluster_pairs_set, CFG[\"min_cluster_size\"])\n\n            print(\"\\n  Running SfM...\")\n            all_poses: dict[str, tuple[np.ndarray, np.ndarray]] = {}\n            for scene_label, cluster_indices in clusters.items():\n                if scene_label == \"outliers\":\n                    continue\n                cluster_paths = [image_paths[i] for i in cluster_indices]\n                print(f\"    {scene_label}: {len(cluster_paths)} images → SfM...\")\n                poses = run_sfm_for_cluster(\n                    cluster_paths, feat_h5, dataset, name_verified\n                )\n                print(f\"      Registered {len(poses)}/{len(cluster_paths)} images\")\n                all_poses.update(poses)\n\n            # Build submission rows — use image_id from sample_submission.csv (has _public/_private)\n            for scene_label, cluster_indices in clusters.items():\n                for idx in cluster_indices:\n                    img_name = image_paths[idx].name\n                    # Look up the exact image_id Kaggle expects\n                    image_id = image_id_map.get(\n                        (dataset, img_name),\n                        f\"{dataset}_{img_name}_public\",  # fallback\n                    )\n                    if scene_label == \"outliers\" or img_name not in all_poses:\n                        R_str, t_str = NAN_ROTATION, NAN_TRANSLATION\n                        actual_scene = \"outliers\" if scene_label == \"outliers\" else scene_label\n                    else:\n                        R, t = all_poses[img_name]\n                        R_str = format_rotation(R)\n                        t_str = format_translation(t)\n                        actual_scene = scene_label\n                    all_results.append({\n                        \"image_id\": image_id,\n                        \"dataset\": dataset,\n                        \"scene\": actual_scene,\n                        \"image\": img_name,\n                        \"rotation_matrix\": R_str,\n                        \"translation_vector\": t_str,\n                    })\n\n    del dino, matcher\n    torch.cuda.empty_cache()\n    gc.collect()\n\n    submission = pd.DataFrame(all_results)\n    submission[\"rotation_matrix\"]    = submission[\"rotation_matrix\"].fillna(NAN_ROTATION)\n    submission[\"translation_vector\"] = submission[\"translation_vector\"].fillna(NAN_TRANSLATION)\n    return submission","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:41:04.829996Z","iopub.execute_input":"2026-04-17T20:41:04.830351Z","iopub.status.idle":"2026-04-17T20:41:04.848079Z","shell.execute_reply.started":"2026-04-17T20:41:04.830306Z","shell.execute_reply":"2026-04-17T20:41:04.847482Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ─── Run, validate, and save ───\nsubmission = run_pipeline(split=\"test\")\n\n# Validate against sample_submission if available\nsample_path = DATA_DIR / \"sample_submission.csv\"\nif sample_path.exists():\n    sample = pd.read_csv(sample_path)\n    expected_ids = set(sample[\"image_id\"])\n    actual_ids = set(submission[\"image_id\"])\n    missing = expected_ids - actual_ids\n    extra = actual_ids - expected_ids\n    if missing:\n        print(f\"WARNING: {len(missing)} image_ids missing from submission!\")\n        # Add missing rows with NaN poses\n        for img_id in missing:\n            row = sample[sample[\"image_id\"] == img_id].iloc[0]\n            submission = pd.concat([submission, pd.DataFrame([{\n                \"image_id\": img_id,\n                \"dataset\": row[\"dataset\"],\n                \"scene\": \"outliers\",\n                \"image\": row[\"image\"],\n                \"rotation_matrix\": NAN_ROTATION,\n                \"translation_vector\": NAN_TRANSLATION,\n            }])], ignore_index=True)\n        print(f\"  Added {len(missing)} missing rows with NaN poses\")\n    if extra:\n        print(f\"NOTE: {len(extra)} extra image_ids in submission (not in sample)\")\n    print(f\"Validation: {len(expected_ids)} expected, {len(actual_ids)} produced\")\nelse:\n    print(\"No sample_submission.csv found — skipping validation\")\n\nsubmission_path = WORK_DIR / \"submission.csv\"\nsubmission.to_csv(submission_path, index=False)\nprint(f\"\\nSubmission saved to {submission_path}\")\nprint(f\"Shape: {submission.shape}\")\nprint(submission.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-17T20:41:04.848887Z","iopub.execute_input":"2026-04-17T20:41:04.849158Z","iopub.status.idle":"2026-04-17T20:43:04.353463Z","shell.execute_reply.started":"2026-04-17T20:41:04.849130Z","shell.execute_reply":"2026-04-17T20:43:04.352587Z"}},"outputs":[],"execution_count":null}]}