{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","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,"sourceType":"competition"},{"sourceId":7884485,"sourceType":"datasetVersion","datasetId":4628051},{"sourceId":8469545,"sourceType":"datasetVersion","datasetId":4911029},{"sourceId":11843537,"sourceType":"datasetVersion","datasetId":7441260},{"sourceId":11972445,"sourceType":"datasetVersion","datasetId":7344616},{"sourceId":242190041,"sourceType":"kernelVersion"}],"dockerImageVersionId":31192,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true},"papermill":{"default_parameters":{},"duration":165.319084,"end_time":"2025-12-12T08:40:30.176079","environment_variables":{},"exception":null,"input_path":"__notebook__.ipynb","output_path":"__notebook__.ipynb","parameters":{},"start_time":"2025-12-12T08:37:44.856995","version":"2.6.0"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"5878b13d","cell_type":"markdown","source":"## Group Members\n\n#### 1. Mirza Abdul Wasay (22K-4087)\n#### 2. Mudasir (22K-8732)\n\n## BAI-7A (Computer Vision Project Submission)","metadata":{"papermill":{"duration":0.003304,"end_time":"2025-12-12T08:37:49.266563","exception":false,"start_time":"2025-12-12T08:37:49.263259","status":"completed"},"tags":[]}},{"id":"3ec39034","cell_type":"markdown","source":"## Installing required dependencies\nBelow commands install the required dependencies","metadata":{"papermill":{"duration":0.002824,"end_time":"2025-12-12T08:37:49.272380","exception":false,"start_time":"2025-12-12T08:37:49.269556","status":"completed"},"tags":[]}},{"id":"60dcf142","cell_type":"code","source":"# Installing LightGlue and other dependencies (offline installation)\n!pip install --no-index /kaggle/input/imc2024-packages-lightglue-rerun-kornia/lightglue* --no-deps\n\n# Installing IMC 2025 related wheels (also offline)\n!pip install --no-index /kaggle/input/icm2025-packages/*.whl --no-deps","metadata":{"execution":{"iopub.status.busy":"2025-12-13T17:38:34.214197Z","iopub.execute_input":"2025-12-13T17:38:34.214426Z","iopub.status.idle":"2025-12-13T17:38:41.482686Z","shell.execute_reply.started":"2025-12-13T17:38:34.214398Z","shell.execute_reply":"2025-12-13T17:38:41.481772Z"},"papermill":{"duration":6.975223,"end_time":"2025-12-12T08:37:56.250216","exception":false,"start_time":"2025-12-12T08:37:49.274993","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"ebf1156f","cell_type":"code","source":"# Creating directory for torch checkpoints\n!mkdir -p /root/.cache/torch/hub/checkpoints\n\n# Copying official weights into the torch checkpoint folder\n!cp /kaggle/input/imc2024-official-weights/* /root/.cache/torch/hub/checkpoints","metadata":{"execution":{"iopub.status.busy":"2025-12-13T17:38:41.483684Z","iopub.execute_input":"2025-12-13T17:38:41.483912Z","iopub.status.idle":"2025-12-13T17:38:42.480692Z","shell.execute_reply.started":"2025-12-13T17:38:41.483888Z","shell.execute_reply":"2025-12-13T17:38:42.479808Z"},"papermill":{"duration":5.413053,"end_time":"2025-12-12T08:38:01.667102","exception":false,"start_time":"2025-12-12T08:37:56.254049","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"fc59529e","cell_type":"markdown","source":"## Step 2 - Scan all test images and group them by scene\nWe ignore LICENSE files and build a dictionary of scene → image paths.\n","metadata":{"papermill":{"duration":0.004873,"end_time":"2025-12-12T08:38:01.675957","exception":false,"start_time":"2025-12-12T08:38:01.671084","status":"completed"},"tags":[]}},{"id":"771aa615","cell_type":"code","source":"import os\nimport json\nfrom glob import glob\n\n# Define paths for input dataset and output JSON directory\nINPUT_ROOT = \"/kaggle/input/image-matching-challenge-2025\"\nOUTPUT_ROOT = \"/kaggle/working/fnames\"\n\n# Create output directory if it doesn't exist\nos.makedirs(OUTPUT_ROOT, exist_ok=True)","metadata":{"execution":{"iopub.status.busy":"2025-12-13T17:38:52.873226Z","iopub.execute_input":"2025-12-13T17:38:52.873516Z","iopub.status.idle":"2025-12-13T17:38:52.878740Z","shell.execute_reply.started":"2025-12-13T17:38:52.873468Z","shell.execute_reply":"2025-12-13T17:38:52.878135Z"},"papermill":{"duration":0.009692,"end_time":"2025-12-12T08:38:01.688904","exception":false,"start_time":"2025-12-12T08:38:01.679212","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"61bcd538","cell_type":"code","source":"# collecting scene wise images path\n\n# Dictionary to store scenes with their image paths\nscenes = {}\n\n# Iterate through all files inside test scenes\nfor x in glob(os.path.join(INPUT_ROOT, \"test/*/*.*\")):\n    if \"LICENSE\" not in x:  # Skip LICENSE files\n        folder = os.path.basename(os.path.dirname(x))  # Extract folder/scene name\n        if folder not in scenes:\n            scenes[folder] = []  # Create list for new scene\n        scenes[folder].append(x)  # Add image path","metadata":{"execution":{"iopub.status.busy":"2025-12-13T17:38:59.756248Z","iopub.execute_input":"2025-12-13T17:38:59.756739Z","iopub.status.idle":"2025-12-13T17:38:59.776293Z","shell.execute_reply.started":"2025-12-13T17:38:59.756711Z","shell.execute_reply":"2025-12-13T17:38:59.775813Z"},"papermill":{"duration":0.025084,"end_time":"2025-12-12T08:38:01.717262","exception":false,"start_time":"2025-12-12T08:38:01.692178","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"bc7383bf","cell_type":"code","source":"# Sort keys based on number of images (small → large)\nsorted_keys = sorted(scenes.keys(), key=lambda k: len(scenes[k]))\n\n# Prepare tasks for 2 workers\ntasks = [[] for _ in range(2)]\nfor idx, key in enumerate(sorted_keys):\n    worker_id = idx % 2  # Alternate assignment\n    tasks[worker_id].append(key)\n\n# Remove empty lists\ntasks = [t for t in tasks if len(t) > 0]","metadata":{"execution":{"iopub.status.busy":"2025-12-13T17:39:01.270250Z","iopub.execute_input":"2025-12-13T17:39:01.270890Z","iopub.status.idle":"2025-12-13T17:39:01.276107Z","shell.execute_reply.started":"2025-12-13T17:39:01.270865Z","shell.execute_reply":"2025-12-13T17:39:01.275189Z"},"papermill":{"duration":0.009853,"end_time":"2025-12-12T08:38:01.730720","exception":false,"start_time":"2025-12-12T08:38:01.720867","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"752d30fb","cell_type":"code","source":"# Save scenes list for each worker into JSON files\nfor i, task in enumerate(tasks):\n    output_path = os.path.join(OUTPUT_ROOT, f\"worker_{i}_scenes.json\")\n    with open(output_path, \"w\") as f:\n        json.dump({s: scenes[s] for s in task}, f, indent=2)\n\n# Final confirmation\nprint(f\"Saved scene splits to {OUTPUT_ROOT}\")","metadata":{"execution":{"iopub.status.busy":"2025-12-13T17:39:07.202788Z","iopub.execute_input":"2025-12-13T17:39:07.203466Z","iopub.status.idle":"2025-12-13T17:39:07.208859Z","shell.execute_reply.started":"2025-12-13T17:39:07.203440Z","shell.execute_reply":"2025-12-13T17:39:07.208239Z"},"papermill":{"duration":0.009868,"end_time":"2025-12-12T08:38:01.744121","exception":false,"start_time":"2025-12-12T08:38:01.734253","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"4012d3ed","cell_type":"markdown","source":"## Creating `inference.py` file\nWe are writing the full pipeline script into a Python file so it can be executed later.","metadata":{"papermill":{"duration":0.003258,"end_time":"2025-12-12T08:38:01.750840","exception":false,"start_time":"2025-12-12T08:38:01.747582","status":"completed"},"tags":[]}},{"id":"a3272ef1","cell_type":"code","source":"%%writefile inference.py\nimport os\nimport json\nimport time\nimport sys\nimport networkx as nx\nimport pandas as pd\nimport numpy as np\nimport gc\nimport hdbscan\nimport argparse\nfrom math import ceil\nsys.path.append(\"/kaggle/input/image-matching-2025-code/image-matching-3D-main\")\n\nfrom images_to_3d.tasks.get_pair.get_image_pair_exhaustive import task_get_image_pair_exhaustive\nfrom images_to_3d.tasks.get_pair.get_image_pair_DINO import task_get_image_pair_DINO\nfrom images_to_3d.tasks.get_pair.get_image_pair_kNN import task_get_image_pair_kNN\nfrom images_to_3d.tasks.get_pair.get_transparent_pair import task_get_transparent_pair\nfrom images_to_3d.tasks.matching.matching import task_matching\nfrom images_to_3d.tasks.matching.matching_find_best import task_matching_find_best\nfrom images_to_3d.tasks.matching.rotate_matching_find_best import task_rotate_matching_find_best\nfrom images_to_3d.tasks.crop.sfm_mkpc import task_sfm_mkpc\nfrom images_to_3d.tasks.crop.pair_mkpc import task_pair_mkpc\nfrom images_to_3d.tasks.crop.transparent_crop import task_transparent_crop\nfrom images_to_3d.tasks.utils.ransac import task_ransac\nfrom images_to_3d.tasks.utils.concat import task_concat\nfrom images_to_3d.tasks.utils.rem_less_match_pair import task_rem_less_match_pair\nfrom images_to_3d.tasks.utils.count_matching_num import task_count_matching_num\nfrom images_to_3d.tasks.utils.extract_inliner_matching_points import task_extract_inliner_matching_points\nfrom images_to_3d.tasks.utils.extract_csv_pair import task_extract_csv_pair\nfrom images_to_3d.tasks.utils.estimate_rot import task_estimate_rot\nfrom images_to_3d.tasks.utils.get_exif import task_get_exif\nfrom images_to_3d.tasks.global_feat.get_image_feats import task_generate_image_feats\nfrom images_to_3d.models.utils import read_image, numpy_image_to_torch\nfrom images_to_3d.models import ALIKED\nfrom glob import glob\nfrom tqdm  import tqdm\nfrom pathlib import Path\nimport kornia as K\n\nINPUT_ROOT = \"/kaggle/input/image-matching-challenge-2025\"\nOUTPUT_ROOT = \"/kaggle/working/outputs\"\ntask_map = {\n    \"get_image_pair_exhaustive\": task_get_image_pair_exhaustive,\n    \"get_image_pair_DINO\": task_get_image_pair_DINO,\n    \"get_image_pair_kNN\": task_get_image_pair_kNN,\n    \"get_transparent_pair\": task_get_transparent_pair,\n\n    \"matching\": task_matching,\n    \"matching_find_best\": task_matching_find_best,\n    \"rotate_matching_find_best\": task_rotate_matching_find_best,\n\n    \"sfm_mkpc\": task_sfm_mkpc,\n    \"pair_mkpc\": task_pair_mkpc,\n    \"transparent_crop\": task_transparent_crop,\n    \n    \"ransac\": task_ransac,\n    \"concat\": task_concat,\n    \"rem_less_match_pair\": task_rem_less_match_pair,\n    \"count_matching_num\": task_count_matching_num,\n    \"extract_inliner_matching_points\": task_extract_inliner_matching_points,\n    \"extract_csv_pair\": task_extract_csv_pair,\n    \"estimate_rot\": task_estimate_rot,\n    \"get_exif\": task_get_exif,\n    \"generate_image_feats\": task_generate_image_feats,\n}\n\nimport json\npipleline = json.load(open(\"/kaggle/input/image-matching-2025-code/image-matching-3D-main/notebooks/pipeline.json\", \"r\"))\n\nfilter_threshold1 = 0.531049\npipleline[2][\"params\"][\"keypoint_matching_args\"][\"matcher_params\"][\"filter_threshold\"] = filter_threshold1\nfilter_threshold2 = 0.485205\npipleline[3][\"params\"][\"keypoint_matching_args\"][\"matcher_params\"][\"filter_threshold\"] = filter_threshold2\nfilter_threshold3 = 0.758147\npipleline[5][\"params\"][\"keypoint_matching_args\"][\"matcher_params\"][\"filter_threshold\"] = filter_threshold3\nfilter_threshold4 = 0.944721\npipleline[10][\"params\"][\"keypoint_matching_args\"][\"matcher_params\"][\"filter_threshold\"] = filter_threshold4\n\nth_matching_num = 33\npipleline[7]['params'][\"th_matching_num\"] = th_matching_num * 10\n\nransac_min_matches = 7\npipleline[-2]['params']['min_matches'] = ransac_min_matches * 10\n\npipleline[-2]['params']['ransac_params'][\"param1\"] = 2/2\npipleline[-2]['params']['ransac_params'][\"param2\"] = 0.767528\n\nclass Pipeline():\n    def __init__(self, data_dict, work_dir, input_dir_root, pipeline_config, device_id, pdb = False):\n        self.device = K.utils.get_cuda_device_if_available(device_id)\n        print(f\"device: {self.device}\")\n        self.data_dict = data_dict\n        self.work_dir = work_dir\n        self.input_dir_root = input_dir_root\n        os.makedirs(self.work_dir, exist_ok=True)\n        self.pipeline_config = pipeline_config\n        self.processing_times = {\n            \"task\": [],\n            \"comment\": [],\n            \"processing_time\": []\n        }\n        self.pdb = pdb\n\n\n    def exec(self):\n        all_processing_time = 0\n        for p in self.pipeline_config:\n            task = p[\"task\"]\n            comment = p[\"comment\"]\n            p[\"params\"][\"device\"] = self.device\n            p[\"params\"][\"data_dict\"] = self.data_dict\n            p[\"params\"][\"work_dir\"] = self.work_dir\n            p[\"params\"][\"input_dir_root\"] = self.input_dir_root\n            p[\"params\"][\"pdb\"] = self.pdb\n            \n            start = time.time()\n            print(f\"===== [{task}] {comment} =====\")\n            task_map[task](p[\"params\"])\n            gc.collect()\n            print(\"====================\")\n            end = time.time()\n\n            self.processing_times[\"task\"].append(task)\n            self.processing_times[\"comment\"].append(comment)\n            self.processing_times[\"processing_time\"].append(end-start)\n            all_processing_time += end-start\n        \n        self.processing_times[\"task\"].append(\"All\")\n        self.processing_times[\"comment\"].append(\"\")\n        self.processing_times[\"processing_time\"].append(all_processing_time)\n        processing_times_df = pd.DataFrame.from_dict(self.processing_times)\n        processing_times_df.to_csv(os.path.join(self.work_dir, \"processing_time.csv\"), index=False)\n\nfrom collections import defaultdict\nimport shutil\nfrom images_to_3d.tasks.reconstruction.reconstruct import reconstruction\ndef find_connected_components(edges, minimum_size=9):\n    # Build the adjacency list\n    graph = defaultdict(set)\n    nodes = set()\n    for u, v in edges:\n        graph[u].add(v)\n        graph[v].add(u)\n        nodes.update([u, v])\n    \n    visited = set()\n    components = []\n\n    def dfs(node, component):\n        visited.add(node)\n        component.append(node)\n        for neighbor in graph[node]:\n            if neighbor not in visited:\n                dfs(neighbor, component)\n\n    for node in nodes:\n        if node not in visited:\n            component = []\n            dfs(node, component)\n            components.append(component)\n    #post processing\n    components = [c for c in components if len(c)>=minimum_size]\n    return len(components), components\n\ndef get_components(edges,minimum_size=5):\n    G = nx.Graph()\n    G.add_edges_from(edges)\n    num_degrees = list(dict(G.degree()).values())\n    \n    pos = nx.spring_layout(G, k=0.5, iterations=100, seed=42)\n    model = hdbscan.HDBSCAN(min_cluster_size=5,)\n    labels = model.fit_predict(np.stack(list(pos.values())))   \n\n    degree_counts = {x: [] for x in np.unique(labels)}\n    for d,l in zip(num_degrees,labels):\n        degree_counts[l].append(d)\n    \n    connectedness = np.mean([np.mean(v)/len(v) for k,v in degree_counts.items() if k!=-1])\n    connectedness = np.nan_to_num(connectedness,posinf=0,neginf=0,nan=0)\n    if model.probabilities_.mean()>0.8 and connectedness>0.5:\n        print(\"using hdbscan\")\n        c = np.unique(labels[labels!=-1])\n        connected_components = {idx:[] for idx in c}\n        for node_label, position in zip(labels, pos.keys()):\n            if node_label in connected_components:\n                connected_components[node_label].append(position)\n        connected_components = [v for v in connected_components.values() if len(v)>0]\n        return len(connected_components),connected_components\n    else :\n        print(\"using spanning tree\")\n        c,connected_components = find_connected_components(edges, minimum_size=minimum_size)\n    \n    return c,connected_components\n        \nclass Config: \n    min_size=5\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser()\n    parser.add_argument('--world_size', type=int, required=True)\n    parser.add_argument('--rank', type=int, required=True)\n    args = parser.parse_args()\n\n    scenes = json.load(open(os.path.join(\"/kaggle/working/fnames\", f\"worker_{args.rank}_scenes.json\")))\n    scenes = {k:[Path(p) for p in v] for k,v in scenes.items()}\n    gpu_id = os.environ[\"CUDA_VISIBLE_DEVICES\"]\n    print(f\"running {args.rank}/ {args.world_size} with gpu {gpu_id} : {scenes.keys()}\")\n    for dset_name in scenes:\n        try:\n            work_dir = os.path.join(OUTPUT_ROOT, dset_name)\n            pipeline = Pipeline(scenes[dset_name], Path(work_dir), Path(INPUT_ROOT), pipleline, 0, pdb = False)\n            pipeline.exec()\n            print(f\"Processing {work_dir}\")\n            print(\"=\"*100)\n            image_pairs = pd.read_csv(os.path.join(work_dir, \"image_pair.csv\"))\n            counts, connected_components = get_components([(a,b) for a,b in zip(image_pairs.key1, image_pairs.key2)], minimum_size=Config.min_size)\n            print(f\"Found {counts} connected components of length {[len(c) for c in connected_components]}\")\n            \n            colmap_mapper_options = {\n                \"min_model_size\": 3, # By default colmap does not generate a reconstruction if less than 10 images are registered. Lower it to 3.\n                \"max_num_models\": 25,\n                #\"num_threads\": 1,\n            }\n            \n            for i,image_paths in enumerate(connected_components):\n                output_dir = os.path.join(work_dir, f\"minsize_{Config.min_size}_scene_{i}\")\n                os.makedirs(output_dir, exist_ok=True)\n                image_root = str(scenes[os.path.basename(work_dir)][0].parents[0])\n                reconstruction(image_root, image_paths, os.path.basename(work_dir), f\"minsize_{Config.min_size}_scene_{i}\", work_dir, output_dir,colmap_mapper_options,image_model=\"radial\")\n                print(f\"Scene {i} done\")    \n                print(\"=\"*100)\n        except:\n            pass","metadata":{"execution":{"iopub.status.busy":"2025-12-13T17:39:08.242256Z","iopub.execute_input":"2025-12-13T17:39:08.242869Z","iopub.status.idle":"2025-12-13T17:39:08.252155Z","shell.execute_reply.started":"2025-12-13T17:39:08.242844Z","shell.execute_reply":"2025-12-13T17:39:08.251390Z"},"papermill":{"duration":0.014581,"end_time":"2025-12-12T08:38:01.768832","exception":false,"start_time":"2025-12-12T08:38:01.754251","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"3fc0324f","cell_type":"code","source":"import subprocess\nimport os\n\n# Launch process for rank 0 on GPU 0\np0 = subprocess.Popen(\n    ['python3', 'inference.py', '--world_size=2', '--rank=0'],\n    env={**os.environ, 'CUDA_VISIBLE_DEVICES': '0'}\n)\n\n# Launch process for rank 1 on GPU 1\np1 = subprocess.Popen(\n    ['python3', 'inference.py', '--world_size=2', '--rank=1'],\n    env={**os.environ, 'CUDA_VISIBLE_DEVICES': '1'}\n)\n\n# (Optional) Wait for both processes to complete\np0.wait()\np1.wait()\n\nprint(\"Both inference processes have completed.\")","metadata":{"execution":{"iopub.status.busy":"2025-12-13T17:39:34.997525Z","iopub.execute_input":"2025-12-13T17:39:34.998104Z","iopub.status.idle":"2025-12-13T17:42:00.294405Z","shell.execute_reply.started":"2025-12-13T17:39:34.998079Z","shell.execute_reply":"2025-12-13T17:42:00.293495Z"},"papermill":{"duration":147.654168,"end_time":"2025-12-12T08:40:29.426509","exception":false,"start_time":"2025-12-12T08:38:01.772341","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null},{"id":"e240aebc","cell_type":"markdown","source":"## Saving submission.csv\nGenerate Final Submission","metadata":{"papermill":{"duration":0.019873,"end_time":"2025-12-12T08:40:29.467854","exception":false,"start_time":"2025-12-12T08:40:29.447981","status":"completed"},"tags":[]}},{"id":"cc630939","cell_type":"code","source":"import os\nimport pandas as pd\nimport numpy as np\nfrom glob import glob\nfrom pathlib import Path\n\nINPUT_ROOT = \"/kaggle/input/image-matching-challenge-2025\"\nOUTPUT_ROOT = \"/kaggle/working/outputs\"\n\n# --- Load scenes --------------------------------------------------------\nscenes = {}\nfor x in glob(os.path.join(INPUT_ROOT, \"test/*/*.*\")):\n    if \"LICENSE\" in x:\n        continue\n    scene = os.path.basename(os.path.dirname(x))\n    scenes.setdefault(scene, []).append(Path(x))\n\nUSED_COLS = [\"image_id\", \"dataset\", \"scene\", \"image\", \"rotation_matrix\", \"translation_vector\"]\n\n# --- Load all worker submission csvs ------------------------------------\nsubmission_files = glob(os.path.join(OUTPUT_ROOT, \"*\", \"minsize_5_scene_*\", \"submission.csv\"))\n\nsubmission_images = pd.concat([pd.read_csv(fn) for fn in submission_files], ignore_index=True)\n\n# --- Build proper columns -----------------------------------------------\nsubmission_images[\"dataset\"] = submission_images[\"image_path\"].apply(lambda x: x.split(\"/\")[5])\nsubmission_images[\"image\"] = submission_images[\"image_path\"].apply(lambda x: os.path.basename(x))\nsubmission_images[\"image_id\"] = submission_images[\"dataset\"] + \"_\" + submission_images[\"image\"] + \"_public\"\n\nsubmission_images = submission_images[USED_COLS]\n\n# --- Determine processed image IDs --------------------------------------\nnon_outlier_ids = set(submission_images[\"image_id\"])\n\n# --- Find missing images and create outlier rows -------------------------\noutliers = []\nfor dset_name, images in scenes.items():\n    for img_path in images:\n        filename = img_path.name\n        img_id = f\"{dset_name}_{filename}_public\"\n\n        if img_id in non_outlier_ids:\n            continue\n\n        outliers.append({\n            \"image_id\": img_id,\n            \"dataset\": dset_name,\n            \"scene\": \"outliers\",\n            \"image\": filename,\n            \"rotation_matrix\": \"nan;nan;nan;nan;nan;nan;nan;nan;nan\",\n            \"translation_vector\": \"nan;nan;nan\"\n        })\n\n# --- Merge final submission ----------------------------------------------\nsubmission_images = pd.concat(\n    [submission_images, pd.DataFrame(outliers)],\n    ignore_index=True\n)\n\nsubmission_images.sort_values([\"dataset\", \"scene\"]).to_csv(\"submission.csv\", index=False)\n","metadata":{"papermill":{"duration":0.307485,"end_time":"2025-12-12T08:40:29.794799","exception":false,"start_time":"2025-12-12T08:40:29.487314","status":"completed"},"tags":[],"trusted":true,"execution":{"iopub.status.busy":"2025-12-13T17:42:36.021647Z","iopub.execute_input":"2025-12-13T17:42:36.022031Z","iopub.status.idle":"2025-12-13T17:42:36.332323Z","shell.execute_reply.started":"2025-12-13T17:42:36.022010Z","shell.execute_reply":"2025-12-13T17:42:36.331817Z"}},"outputs":[],"execution_count":null},{"id":"f74e87ce","cell_type":"code","source":"","metadata":{"papermill":{"duration":0.020066,"end_time":"2025-12-12T08:40:29.835179","exception":false,"start_time":"2025-12-12T08:40:29.815113","status":"completed"},"tags":[],"trusted":true},"outputs":[],"execution_count":null}]}