{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# IMC 2025 — Pipeline εικονο-αντιστοίχισης βασισμένο στο MASt3R\n\nNotebook έτοιμο για **Kaggle submission** στο *Image Matching Challenge 2025*.\n\n## Τι κάνει\nΓια κάθε **dataset** (ομάδα εικόνων) του challenge εκτελεί τα εξής στάδια:\n\n1. **Retrieval** — διαλέγει ζεύγη εικόνων που πιθανότατα επικαλύπτονται\n   (global descriptors από τον encoder του MASt3R + cosine similarity).\n2. **Matching** — τρέχει MASt3R σε κάθε ζεύγος → πυκνές αντιστοιχίσεις pixel.\n3. **Geometric verification** — έλεγχος με F-matrix RANSAC (MAGSAC) → κρατάει inliers.\n4. **Keypoint aggregation** — συγχωνεύει τα σημεία και τα φορτώνει σε βάση COLMAP.\n5. **SfM** — incremental mapping με `pycolmap`. Κάθε ανεξάρτητο model = ένα scene/cluster.\n6. **Output** — κάθε registered εικόνα → `(scene, R, T)`. Οι υπόλοιπες → `outliers`.\n   Γράφεται το `submission.csv` στο σωστό format.\n\n## Γιατί έτσι\nΤο IMC2025 βαθμολογείται με **mAA** πάνω στα κέντρα καμερών `C = -Rᵀ·T`, αφού\nευθυγραμμιστούν ανά cluster. Άρα χρειαζόμαστε: (α) σωστό clustering σε scenes,\n(β) συνεπείς πόζες μέσα σε κάθε cluster, (γ) απόρριψη των outliers. Ο incremental\nmapper του COLMAP βγάζει **φυσικά** πολλά ανεξάρτητα models όταν οι εικόνες\nανήκουν σε διαφορετικές σκηνές → αυτό μας δίνει το clustering *δωρεάν*, και οι\nμη-registered εικόνες είναι οι outliers.\n\n## Πώς τρέχει στο Kaggle (Internet = OFF στο scoring!)\nΠρόσθεσε ως **Kaggle Datasets**: τα weights του MASt3R (`.pth`), το repo `mast3r`\n(+ `dust3r` submodule), και offline wheels για `pycolmap`. Μετά προσάρμοσε τα\npaths στο `CFG` (η έξοδος του «ΒΗΜΑ Α» παρακάτω σου τα δίνει έτοιμα) και τρέξε\nόλα τα κελιά με τη σειρά.\n\n> **Σημείωση:** τα κομμάτια που εξαρτώνται από `torch` / MASt3R / `pycolmap` δεν\n> τρέχουν χωρίς GPU + weights + δεδομένα. Είναι όμως απομονωμένα & σχολιασμένα.\n> Τα σημεία με τη μεγαλύτερη ευαισθησία σε εκδόσεις φέρουν την ένδειξη\n> **`[ΕΛΕΓΞΕ ΕΚΔΟΣΗ]`**.\n","metadata":{}},{"cell_type":"code","source":"!python --version\n!pip install --no-index --find-links=/kaggle/input/notebooks/harrypapkos/colmap/wheels pycolmap\n\n#Imports\nfrom __future__ import annotations\nimport glob\nimport os\nimport sys\nimport gc\nimport time\nimport sqlite3\nfrom dataclasses import dataclass\nfrom typing import Dict, List, Tuple, Optional\n\n#Χρειαζονται για Internet Off αλλιως βγαζει σφαλμα\nos.environ.setdefault(\"HF_HUB_OFFLINE\", \"1\")\nos.environ.setdefault(\"TRANSFORMERS_OFFLINE\", \"1\")\nos.environ.setdefault(\"HF_DATASETS_OFFLINE\", \"1\")\nos.environ.setdefault(\"TOKENIZERS_PARALLELISM\", \"false\")\n\n#Αγνοω τα warnings που βγαζει το cuda\nimport warnings\nfor _cat in (FutureWarning, DeprecationWarning, UserWarning):\n    warnings.filterwarnings(\"ignore\", category=_cat)\n\nimport numpy as np\n\ntry:\n    import cv2\nexcept Exception:\n    cv2 = None\n    \ntry:\n    import pandas as pd\nexcept Exception:\n    pd = None","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-01T08:16:30.090458Z","iopub.execute_input":"2026-07-01T08:16:30.091212Z","iopub.status.idle":"2026-07-01T08:16:33.949612Z","shell.execute_reply.started":"2026-07-01T08:16:30.091161Z","shell.execute_reply":"2026-07-01T08:16:33.948927Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n@dataclass\nclass CFG:\n    #Οριζω τα Paths απο τα αρχεια του Input και Output\n    data_root         = \"/kaggle/input/competitions/image-matching-challenge-2025\"\n    sample_submission = \"/kaggle/input/competitions/image-matching-challenge-2025/sample_submission.csv\"\n    mast3r_repo       = \"/kaggle/input/notebooks/harrypapkos/colmap/mast3r\"\n    mast3r_weights    = \"/kaggle/input/notebooks/harrypapkos/colmap/MASt3R_ViTLarge_BaseDecoder_512_catmlpdpt_metric.pth\"\n    out_csv           = \"/kaggle/working/submission.csv\"\n    work_dir          = \"/kaggle/working/imc_work\"\n\n    #Δουλευω με GPU και \n    device: str = \"cuda\"        # \"cuda\" στο Kaggle · \"cpu\" μόνο για τοπικό debug\n    seed: int = 42  \n    #Image Retrieval \n\n    #Για μικρα datasets κανω exhaustive. Για μεγαλα dataset κραταω τους top k \n    exhaustive_max: int = 30       # εύρος: 10–60. ↑ = πιο πολλά exhaustive (αργό)\n    retrieval_top_k: int = 25      # εύρος: 10–50. ↑ = πιο πολλά ζεύγη/κάλυψη, πιο αργό\n    retrieval_min_sim: float = 0.25  # εύρος: 0.05–0.4. ↑ = λιγότερα/καθαρότερα ζεύγη\n    retrieval_size: int = 200      # εύρος: 128–384. μικρό = γρήγορα global features\n\n    #MATCHING (MASt3R) \n    mast3r_size: int = 512         \n    fast_nn_subsample: int = 8     # βήμα δειγματοληψίας στο fast_reciprocal_NNs\n                                   #   εύρος: 2–16 · ↓ (2/4) = ΠΟΛΥ πιο πολλά matches (αργό)\n                                   #                ↑ (16)  = λιγότερα matches (γρήγορο)\n    min_conf: float = 1.001        # εύρος: 1.0–3.0 · κατώφλι confidence (1.0 = κράτα τα όλα)\n    max_pairs_per_dataset: int = 0  # 0 = χωρίς όριο · >0 κόβει ζεύγη για ασφάλεια χρόνου\n\n    #GEOMETRIC VERIFICATION (cv2 / MAGSAC) \n    ransac_thr_px: float = 0.5     # εύρος: 0.25–3.0 px · reprojection threshold στο F-RANSAC\n    ransac_conf: float = 0.9999    # εύρος: 0.999–0.99999 · επιθυμητή αξιοπιστία\n    ransac_max_iters: int = 80000  # εύρος: 10k–200k · άνω όριο επαναλήψεων\n    min_inliers: int = 15          # εύρος: 8–40 · ζεύγη με λιγότερους inliers -> απόρριψη\n\n    #KEYPOINT AGGREGATION (detector-free -> COLMAP) \n    quant_px: float = 2.0          # εύρος: 1.0–4.0 px · ακτίνα συγχώνευσης κοντινών σημείων\n                                   #   ↑ = λιγότερα keypoints (γρηγορότερο SfM, λιγότερη ακρίβεια)\n\n    # ---- 5) COLMAP / SfM -----------------------------------------------------\n    camera_model: str = \"SIMPLE_RADIAL\"   # \"SIMPLE_RADIAL\" (ανθεκτικό) ή \"PINHOLE\"\n    shared_camera: bool = False    # phototourism -> κάθε εικόνα δική της κάμερα (False)\n    focal_prior_factor: float = 1.2  # εύρος: 0.9–1.5 · αρχικό f = factor * max(W,H) χωρίς EXIF\n    refine_focal: bool = True      # βελτιστοποίηση focal μέσα στο bundle adjustment\n    mapper_min_model_size: int = 5    # εύρος: 3–10 · ελάχιστες εικόνες για να κρατηθεί model\n    mapper_min_num_matches: int = 15  # εύρος: 10–30\n    mapper_multiple_models: bool = True  # ΑΠΑΡΑΙΤΗΤΟ: επιτρέπει πολλά scenes/clusters\n    mapper_max_num_models: int = 50   # άνω όριο ανεξάρτητων models ανά dataset\n    min_cluster_size: int = 4      # εύρος: 3–8 · μικρότερα clusters -> γίνονται outliers\n\n    # ---- 6) OUTPUT -----------------------------------------------------------\n    outlier_label: str = \"outliers\"   # ετικέτα scene για μη-registered εικόνες\n    cluster_prefix: str = \"cluster\"   # scene = f\"{cluster_prefix}{k}\"","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n            \ndef set_seed(seed: int) -> None:\n    \"\"\"Κλειδώνει τους random generators (numpy + lazy torch) για επαναληψιμότητα.\"\"\"\n    np.random.seed(seed)\n    os.environ[\"PYTHONHASHSEED\"] = str(seed)\n    try:\n        import torch\n        torch.manual_seed(seed)\n        if torch.cuda.is_available():\n            torch.cuda.manual_seed_all(seed)\n        # benchmark=True -> λίγο πιο γρήγορα convs, αλλά ΜΗ-ντετερμινιστικά\n        torch.backends.cudnn.benchmark = True\n    except Exception:\n        pass\n\n\ndef read_sample_submission(path: str):\n    \"\"\"\n    Διαβάζει το sample_submission.csv. Επιστρέφουμε ΟΛΟΚΛΗΡΟ το DataFrame ώστε\n    αργότερα να γεμίσουμε τις στήλες-στόχους χωρίς να μαντεύουμε ονόματα — έτσι το\n    script προσαρμόζεται μόνο του στο πραγματικό schema του runtime.\n    \"\"\"\n    df = pd.read_csv(path)\n    df.columns = [c.strip() for c in df.columns]  # καθάρισμα κενών στα headers\n    return df\n\n\ndef detect_columns(df) -> Dict[str, str]:\n    \"\"\"\n    Εντοπίζει ανθεκτικά τα πραγματικά ονόματα στηλών (αλλάζουν ελαφρώς ανά έτος).\n    Επιστρέφει mapping: λογικό_όνομα -> πραγματική_στήλη.\n    \"\"\"\n    lut = {c.lower(): c for c in df.columns}\n\n    def pick(*candidates, required=True, default=None):\n        for cand in candidates:\n            if cand in lut:\n                return lut[cand]\n        if required:\n            raise KeyError(\n                f\"Δεν βρέθηκε καμία από τις στήλες {candidates}. \"\n                f\"Διαθέσιμες: {list(df.columns)}\"\n            )\n        return default\n\n    return {\n        \"dataset\": pick(\"dataset\"),\n        \"image\":   pick(\"image\", \"image_path\", \"image_name\", \"filename\"),\n        \"scene\":   pick(\"scene\", \"cluster\", required=False, default=\"scene\"),\n        \"rot\":     pick(\"rotation_matrix\"),\n        \"trans\":   pick(\"translation_vector\"),\n    }\n\n\ndef group_images_by_dataset(df, colmap: Dict[str, str]) -> Dict[str, List[Tuple[int, str]]]:\n    \"\"\"\n    dataset -> [(row_index, image_name), ...]\n    Κρατάμε το row_index ώστε στο τέλος να γράψουμε τα αποτελέσματα στη σωστή γραμμή.\n    \"\"\"\n    groups: Dict[str, List[Tuple[int, str]]] = {}\n    for idx, row in df.iterrows():\n        ds = str(row[colmap[\"dataset\"]])\n        img = str(row[colmap[\"image\"]])\n        groups.setdefault(ds, []).append((int(idx), img))\n    return groups\n\n\ndef resolve_image_path(cfg: CFG, dataset: str, image_name: str) -> Optional[str]:\n    \"\"\"\n    Βρίσκει το πραγματικό αρχείο εικόνας. Το ακριβές layout διαφέρει (π.χ.\n    <root>/test/<dataset>/images/<img>), οπότε δοκιμάζουμε αρκετές πιθανές θέσεις\n    και επιστρέφουμε την πρώτη που υπάρχει.\n    \"\"\"\n    candidates = [\n        os.path.join(cfg.data_root, \"test\", dataset, \"images\", image_name),\n        os.path.join(cfg.data_root, \"test\", dataset, image_name),\n        os.path.join(cfg.data_root, \"train\", dataset, \"images\", image_name),\n        os.path.join(cfg.data_root, dataset, \"images\", image_name),\n        os.path.join(cfg.data_root, image_name),\n        image_name,\n    ]\n    for p in candidates:\n        if os.path.isfile(p):\n            return p\n    return None","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Στάδιο 1 — Retrieval (επιλογή ζευγών)\n\nΑπό τον πίνακα cosine-ομοιότητας κρατάμε τα ζεύγη που αξίζει να ταιριάξουμε.\nΓια μικρά σύνολα δοκιμάζουμε **όλα** τα ζεύγη· για μεγάλα, μόνο τους top-k\nγείτονες που περνούν το κατώφλι.","metadata":{}},{"cell_type":"code","source":"# ===========================================================================\n#  1) RETRIEVAL — ποια ζεύγη εικόνων αξίζει να ταιριάξουμε;\n#     Καθαρή numpy λογική -> ελέγχεται στο self-test.\n# ===========================================================================\nfrom itertools import combinations\n\n\ndef pick_pairs_by_similarity(sim: np.ndarray, top_k: int, min_sim: float,\n                             exhaustive_max: int) -> List[Tuple[int, int]]:\n    \"\"\"\n    sim: NxN συμμετρικός πίνακας cosine ομοιότητας (η διαγώνιος αγνοείται).\n    Επιστρέφει λίστα ζευγών (i < j):\n      • Για μικρά N (<= exhaustive_max): ΟΛΑ τα ζεύγη (πιο σίγουρο).\n      • Αλλιώς: top-k γείτονες ανά εικόνα ΚΑΙ κατώφλι min_sim -> ένωση, χωρίς διπλά.\n    \"\"\"\n    n = sim.shape[0]\n\n    # --- μικρό σύνολο: εξαντλητικά όλα τα ζεύγη ---\n    if n <= exhaustive_max:\n        return list(combinations(range(n), 2))\n\n    # --- μεγάλο σύνολο: κράτα μόνο τους top-k γείτονες που περνούν το κατώφλι ---\n    s = sim.copy()\n    np.fill_diagonal(s, -np.inf)         # μη διαλέξεις την ίδια εικόνα ως γείτονα\n    k = min(top_k, n - 1)\n    # argpartition = φθηνό top-k (δεν χρειάζεται πλήρες sort)\n    nn = np.argpartition(-s, kth=k - 1, axis=1)[:, :k]\n\n    pairs = set()\n    for i in range(n):\n        for j in nn[i]:\n            j = int(j)\n            if i == j or s[i, j] < min_sim:\n                continue\n            pairs.add((i, j) if i < j else (j, i))\n    return sorted(pairs)\n\n\ndef extract_global_descriptors(cfg: CFG, image_paths: List[str], model=None) -> np.ndarray:\n    \"\"\"\n    Ένας global descriptor ανά εικόνα, για το retrieval.\n    Στρατηγική: mean-pool στα tokens του ENCODER του MASt3R -> L2-normalize.\n    Έτσι μένουμε \"MASt3R-only\" (χωρίς extra δίκτυο τύπου DINOv2/NetVLAD).\n    Επιστρέφει (N, D) float32, L2-normalized -> άρα cosine == dot product.\n    \"\"\"\n    import torch\n    from dust3r.utils.image import load_images  # type: ignore\n\n    device = cfg.device\n    feats = []\n    model.eval()\n    with torch.no_grad():\n        for p in image_paths:\n            imgs = load_images([p], size=cfg.retrieval_size, verbose=False)\n            x = imgs[0][\"img\"].to(device)\n        \n            # [ΕΛΕΓΞΕ ΕΚΔΟΣΗ] κάποιες εκδόσεις: model._encode_image(img, true_shape)\n            true_shape = torch.as_tensor(_to_np(imgs[0][\"true_shape\"])).to(device)\n            tok = model._encode_image(x, true_shape)[0]  # [0] = tokens\n            g = tok.float().mean(dim=1).squeeze(0)       # mean-pool -> (D,)\n            g = torch.nn.functional.normalize(g, dim=0)  # L2-normalize\n            feats.append(g.cpu().numpy().astype(np.float32))\n    return np.stack(feats, axis=0)","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Στάδιο 2 — Matching (MASt3R)\n\nΟ `MASt3RMatcher` φορτώνει το checkpoint μία φορά και για κάθε ζεύγος βγάζει\nπυκνές αντιστοιχίσεις pixel. Τα βοηθητικά χειρίζονται σωστά EXIF orientation και\nτην αντιστροφή του resize + center-crop (κρίσιμο για σωστές συντεταγμένες).","metadata":{}},{"cell_type":"code","source":"# ===========================================================================\n#  2) MATCHING — wrapper γύρω από το MASt3R (lazy import του repo)\n# ===========================================================================\n# --- μικρά βοηθητικά για γεωμετρία/coordinates -----------------------------\ndef _original_wh(path: str) -> Tuple[int, int]:\n    \"\"\"\n    Αρχικές διαστάσεις (W, H) της εικόνας.\n\n    ΓΙΑΤΙ ΜΕ PIL + exif_transpose: ο dust3r/load_images ΕΦΑΡΜΟΖΕΙ το EXIF\n    orientation. Αν εδώ αγνοήσουμε το EXIF (π.χ. σκέτο cv2.imread), τότε σε\n    φωτογραφίες κινητού μπερδεύεται W<->H -> ασύμβατες συντεταγμένες -> κακό SfM.\n    Διαβάζουμε λοιπόν ΜΕ το ίδιο orientation που \"βλέπει\" ο MASt3R.\n    \"\"\"\n    try:\n        from PIL import Image, ImageOps\n        with Image.open(path) as im:\n            im = ImageOps.exif_transpose(im)\n            return int(im.size[0]), int(im.size[1])   # (W, H)\n    except Exception:\n        img = cv2.imread(path)\n        h, w = img.shape[:2]\n        return w, h\n\n\ndef _to_np(x) -> np.ndarray:\n    \"\"\"torch.Tensor / list / numpy -> numpy (χωρίς το numpy-2.0 copy warning).\"\"\"\n    if hasattr(x, \"detach\"):\n        x = x.detach().cpu().numpy()\n    return np.asarray(x)\n\n\ndef _sample_conf(conf_map: np.ndarray, xy: np.ndarray) -> np.ndarray:\n    \"\"\"Δειγματοληψία τιμών confidence στις (επεξεργασμένες) pixel-θέσεις των matches.\"\"\"\n    h, w = conf_map.shape[:2]\n    xs = np.clip(xy[:, 0].astype(int), 0, w - 1)\n    ys = np.clip(xy[:, 1].astype(int), 0, h - 1)\n    return conf_map[ys, xs]\n\n\ndef _map_to_original(xy: np.ndarray, proc_wh: Tuple[int, int],\n                     orig_wh: Tuple[int, int], size: int) -> np.ndarray:\n    \"\"\"\n    Inverse map: επεξεργασμένο pixel (μετά από resize + center-crop) -> αρχικό pixel.\n\n    Ο dust3r/load_images κάνει (1) ΟΜΟΙΟΜΟΡΦΟ resize ώστε η ΜΕΓΑΛΗ πλευρά = size,\n    και (2) ΣΥΜΜΕΤΡΙΚΟ center-crop σε πολλαπλάσιο του 16. Αν αγνοήσουμε το\n    crop-offset, τα keypoints βγαίνουν συστηματικά μετατοπισμένα -> χαλάει το\n    F-matrix & οι πόζες. Εδώ υπολογίζουμε σωστά offset + κλίμακα ανά άξονα.\n    \"\"\"\n    if len(xy) == 0:\n        return xy\n    pw, ph = proc_wh                              # διαστάσεις μετά το crop (W, H)\n    ow, oh = orig_wh\n    S = max(ow, oh)\n    rw = int(round(ow * size / float(S)))         # διαστάσεις μετά το resize (ΠΡΙΝ το crop)\n    rh = int(round(oh * size / float(S)))\n    # offset ΑΚΡΙΒΩΣ όπως ο dust3r (floor division), ώστε να μηδενίζεται και η\n    # υπολειπόμενη μετατόπιση σε μονές resized διαστάσεις:\n    crop_left = rw // 2 - pw // 2\n    crop_top = rh // 2 - ph // 2\n    out = xy.astype(np.float64).copy()\n    out[:, 0] = (xy[:, 0] + crop_left) * (ow / float(rw))\n    out[:, 1] = (xy[:, 1] + crop_top) * (oh / float(rh))\n    return out\n\n\n# --- ο βασικός matcher ------------------------------------------------------\nclass MASt3RMatcher:\n    \"\"\"\n    Φορτώνει ΜΙΑ φορά το checkpoint του MASt3R και κάνει matching ανά ζεύγος.\n    Οι αντιστοιχίσεις επιστρέφονται σε ΑΡΧΙΚΕΣ pixel-συντεταγμένες.\n    \"\"\"\n\n    def __init__(self, cfg: CFG):\n        self.cfg = cfg\n        # Βάζουμε το repo στο sys.path με τη σωστή σειρά:\n        #   \"\"              -> για `import mast3r`\n        #   \"dust3r\"        -> για `import dust3r`\n        #   \"dust3r/croco\"  -> το dust3r κάνει `from croco...` εσωτερικά\n        if cfg.mast3r_repo:\n            for sub in (\"\", \"dust3r\", os.path.join(\"dust3r\", \"croco\")):\n                path = os.path.join(cfg.mast3r_repo, sub) if sub else cfg.mast3r_repo\n                if path not in sys.path:\n                    sys.path.insert(0, path)\n\n        import torch\n        from mast3r.model import AsymmetricMASt3R  # type: ignore\n        self.torch = torch\n        self.model = AsymmetricMASt3R.from_pretrained(cfg.mast3r_weights).to(cfg.device)\n        self.model.eval()\n\n\n    def get_model(self):\n        return self.model\n\n    def match_pair(self, path0: str, path1: str\n                   ) -> Tuple[np.ndarray, np.ndarray, Tuple[int, int], Tuple[int, int]]:\n        \"\"\"\n        Τρέχει MASt3R στο ζεύγος (path0, path1). Επιστρέφει:\n          mkpts0 (M,2), mkpts1 (M,2) σε ΑΡΧΙΚΕΣ συντεταγμένες,\n          orig_size0 (W,H), orig_size1 (W,H).\n        \"\"\"\n        cfg = self.cfg\n        torch = self.torch\n        from dust3r.inference import inference          # type: ignore\n        from dust3r.utils.image import load_images      # type: ignore\n        from mast3r.fast_nn import fast_reciprocal_NNs   # type: ignore\n\n        imgs = load_images([path0, path1], size=cfg.mast3r_size, verbose=False)\n        # αρχικές διαστάσεις (για το rescale αργότερα)\n        oW0, oH0 = _original_wh(path0)\n        oW1, oH1 = _original_wh(path1)\n\n        # --- forward pass -> descriptors & pointmaps ---\n        with torch.no_grad():\n            output = inference([tuple(imgs)], self.model, cfg.device,\n                               batch_size=1, verbose=False)\n        pred1, pred2 = output[\"pred1\"], output[\"pred2\"]\n        view1, view2 = output[\"view1\"], output[\"view2\"]\n\n        desc1 = pred1[\"desc\"].squeeze(0).detach()\n        desc2 = pred2[\"desc\"].squeeze(0).detach()\n\n        # --- fast reciprocal nearest-neighbour matching στον χώρο των descriptors ---\n        nn = fast_reciprocal_NNs(\n            desc1, desc2,\n            subsample_or_initxy1=cfg.fast_nn_subsample,\n            device=cfg.device, dist=\"dot\", block_size=2 ** 13,\n        )\n        m0 = np.asarray(nn[0], dtype=np.float64)   # (M,2) στον επεξεργασμένο χώρο view1\n        m1 = np.asarray(nn[1], dtype=np.float64)   # (M,2) στον επεξεργασμένο χώρο view2\n\n        # --- (προαιρετικό) φιλτράρισμα βάσει confidence στα pointmaps ---\n        if cfg.min_conf > 1.0:\n            c1 = pred1.get(\"conf\", None)\n            c2 = pred2.get(\"conf\", None)\n            if c1 is not None and c2 is not None:\n                c1 = c1.squeeze(0).detach().cpu().numpy()\n                c2 = c2.squeeze(0).detach().cpu().numpy()\n                keep = (_sample_conf(c1, m0) >= cfg.min_conf) & \\\n                       (_sample_conf(c2, m1) >= cfg.min_conf)\n                m0, m1 = m0[keep], m1[keep]\n\n        # --- rescale: επεξεργασμένος χώρος (true_shape) -> αρχικά pixel ---\n        ts0 = _to_np(view1[\"true_shape\"]).reshape(-1)\n        ts1 = _to_np(view2[\"true_shape\"]).reshape(-1)\n        pH0, pW0 = int(ts0[0]), int(ts0[1])\n        pH1, pW1 = int(ts1[0]), int(ts1[1])\n        m0 = _map_to_original(m0, (pW0, pH0), (oW0, oH0), cfg.mast3r_size)\n        m1 = _map_to_original(m1, (pW1, pH1), (oW1, oH1), cfg.mast3r_size)\n        return m0.astype(np.float32), m1.astype(np.float32), (oW0, oH0), (oW1, oH1)","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Στάδιο 3 — Geometric verification\n\nΦιλτράρουμε τις αντιστοιχίσεις με F-matrix RANSAC (MAGSAC++) και κρατάμε μόνο\nτους γεωμετρικά συνεπείς inliers.","metadata":{}},{"cell_type":"code","source":"# ===========================================================================\n#  3) GEOMETRIC VERIFICATION — F-matrix RANSAC (cv2 / MAGSAC)\n#     Καθαρή cv2 λογική.\n# ===========================================================================\ndef geometric_verify_pair(mkpts0: np.ndarray, mkpts1: np.ndarray, cfg: CFG\n                          ) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:\n    \"\"\"\n    Κρατάει μόνο τις γεωμετρικά συνεπείς αντιστοιχίσεις (inliers ως προς έναν\n    fundamental matrix). Επιστρέφει (inlier_mkpts0, inlier_mkpts1, F).\n    Αν ο έλεγχος αποτύχει (λίγα σημεία / λίγοι inliers), επιστρέφει άδειους πίνακες.\n    \"\"\"\n    # χρειαζόμαστε >=8 σημεία για F, και τουλάχιστον όσα ζητά το cfg.min_inliers\n    if len(mkpts0) < max(8, cfg.min_inliers):\n        return mkpts0[:0], mkpts1[:0], None\n\n    try:\n        # MAGSAC++ (πιο ανθεκτικό σε θόρυβο από το κλασικό RANSAC)\n        F, mask = cv2.findFundamentalMat(\n            mkpts0, mkpts1, method=cv2.USAC_MAGSAC,\n            ransacReprojThreshold=cfg.ransac_thr_px,\n            confidence=cfg.ransac_conf, maxIters=cfg.ransac_max_iters,\n        )\n    except Exception:\n        # fallback αν το build του OpenCV δεν έχει USAC_MAGSAC\n        F, mask = cv2.findFundamentalMat(\n            mkpts0, mkpts1, cv2.FM_RANSAC,\n            cfg.ransac_thr_px, cfg.ransac_conf,\n        )\n\n    if F is None or mask is None:\n        return mkpts0[:0], mkpts1[:0], None\n\n    mask = mask.ravel().astype(bool)\n    if mask.sum() < cfg.min_inliers:\n        return mkpts0[:0], mkpts1[:0], None\n    return mkpts0[mask], mkpts1[mask], F","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Στάδιο 4 — Keypoint aggregation\n\nΟ MASt3R είναι detector-free, οπότε το ίδιο σημείο εμφανίζεται ελαφρώς αλλιώς σε\nδιαφορετικά ζεύγη. Κβαντίζουμε σε πλέγμα ώστε τα κοντινά σημεία να θεωρούνται το\nίδιο keypoint — τη μορφή που θέλει η βάση COLMAP.","metadata":{}},{"cell_type":"code","source":"# ===========================================================================\n#  4) KEYPOINT AGGREGATION — γέφυρα detector-free -> COLMAP\n#     Καθαρή numpy λογική -> ελέγχεται στο self-test.\n# ===========================================================================\nclass KeypointRegistry:\n    \"\"\"\n    Μαζεύει για ΜΙΑ εικόνα ένα ενιαίο σύνολο keypoints. Επειδή ο MASt3R είναι\n    detector-free, το ίδιο φυσικό σημείο μπορεί να προκύψει ελαφρώς αλλιώς σε\n    διαφορετικά ζεύγη· γι' αυτό κβαντίζουμε σε πλέγμα ακτίνας quant_px ώστε τα\n    πολύ κοντινά σημεία να θεωρούνται ΤΟ ΙΔΙΟ keypoint (και να συγχωνεύονται).\n    \"\"\"\n\n    def __init__(self, quant_px: float):\n        self.quant = max(1e-6, float(quant_px))\n        self.coords: List[List[float]] = []          # [x, y] ανά keypoint\n        self.index: Dict[Tuple[int, int], int] = {}   # grid-cell -> keypoint id\n\n    def add(self, x: float, y: float) -> int:\n        \"\"\"Επιστρέφει το id του keypoint (δημιουργεί νέο μόνο αν το cell είναι άδειο).\"\"\"\n        cell = (int(round(x / self.quant)), int(round(y / self.quant)))\n        idx = self.index.get(cell)\n        if idx is None:\n            idx = len(self.coords)\n            self.index[cell] = idx\n            self.coords.append([float(x), float(y)])\n        return idx\n\n    def array(self) -> np.ndarray:\n        if not self.coords:\n            return np.zeros((0, 2), dtype=np.float32)\n        return np.asarray(self.coords, dtype=np.float32)\n\n\ndef aggregate_matches(image_names: List[str],\n                      pair_matches: Dict[Tuple[int, int], Tuple[np.ndarray, np.ndarray]],\n                      quant_px: float\n                      ) -> Tuple[Dict[int, np.ndarray], Dict[Tuple[int, int], np.ndarray]]:\n    \"\"\"\n    Μετατρέπει τα verified matches (σε pixel) στη μορφή που θέλει η βάση COLMAP:\n      keypoints[img_idx] -> (K, 2) πίνακας keypoints\n      matches[(i, j)]    -> (M, 2) πίνακας από ΖΕΥΓΗ keypoint-indices (με i < j)\n    \"\"\"\n    registries = {i: KeypointRegistry(quant_px) for i in range(len(image_names))}\n    out_matches: Dict[Tuple[int, int], np.ndarray] = {}\n\n    for (i, j), (m0, m1) in pair_matches.items():\n        if len(m0) == 0:\n            continue\n        idx_pairs = np.empty((len(m0), 2), dtype=np.int64)\n        for r in range(len(m0)):\n            idx_pairs[r, 0] = registries[i].add(float(m0[r, 0]), float(m0[r, 1]))\n            idx_pairs[r, 1] = registries[j].add(float(m1[r, 0]), float(m1[r, 1]))\n        # μετά την κβάντιση μπορεί να προέκυψαν ίδια ζεύγη -> αφαίρεσε διπλά\n        out_matches[(i, j)] = np.unique(idx_pairs, axis=0)\n\n    keypoints = {i: reg.array() for i, reg in registries.items()}\n    return keypoints, out_matches","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## COLMAP database helper\n\nΓράφουμε **απευθείας** στο SQLite schema του COLMAP (σταθερό σε όλες τις εκδόσεις),\nώστε να μην εξαρτιόμαστε από αλλαγές στο python API.","metadata":{}},{"cell_type":"code","source":"# ===========================================================================\n#  COLMAP DATABASE HELPER — γράφουμε ΑΠΕΥΘΕΙΑΣ στο SQLite schema του COLMAP\n#  (σταθερό σε όλες τις εκδόσεις, ανεξάρτητα από το pycolmap API).\n# ===========================================================================\nMAX_IMAGE_ID = 2 ** 31 - 1\n\nCREATE_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS cameras (camera_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,\n  model INTEGER NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL,\n  params BLOB, prior_focal_length INTEGER NOT NULL);\nCREATE TABLE IF NOT EXISTS images (image_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,\n  name TEXT NOT NULL UNIQUE, camera_id INTEGER NOT NULL,\n  prior_qw REAL, prior_qx REAL, prior_qy REAL, prior_qz REAL,\n  prior_tx REAL, prior_ty REAL, prior_tz REAL,\n  CONSTRAINT image_id_check CHECK(image_id >= 0 AND image_id < 2147483647),\n  FOREIGN KEY(camera_id) REFERENCES cameras(camera_id));\nCREATE TABLE IF NOT EXISTS keypoints (image_id INTEGER PRIMARY KEY NOT NULL,\n  rows INTEGER NOT NULL, cols INTEGER NOT NULL, data BLOB,\n  FOREIGN KEY(image_id) REFERENCES images(image_id) ON DELETE CASCADE);\nCREATE TABLE IF NOT EXISTS descriptors (image_id INTEGER PRIMARY KEY NOT NULL,\n  rows INTEGER NOT NULL, cols INTEGER NOT NULL, data BLOB,\n  FOREIGN KEY(image_id) REFERENCES images(image_id) ON DELETE CASCADE);\nCREATE TABLE IF NOT EXISTS matches (pair_id INTEGER PRIMARY KEY NOT NULL,\n  rows INTEGER NOT NULL, cols INTEGER NOT NULL, data BLOB);\nCREATE TABLE IF NOT EXISTS two_view_geometries (pair_id INTEGER PRIMARY KEY NOT NULL,\n  rows INTEGER NOT NULL, cols INTEGER NOT NULL, data BLOB, config INTEGER NOT NULL,\n  F BLOB, E BLOB, H BLOB, qvec BLOB, tvec BLOB);\n\"\"\"\n\n\ndef _pair_id(id1: int, id2: int) -> int:\n    \"\"\"Μοναδικό id ζεύγους, όπως το ορίζει το COLMAP (πάντα με μικρότερο πρώτο).\"\"\"\n    if id1 > id2:\n        id1, id2 = id2, id1\n    return id1 * MAX_IMAGE_ID + id2\n\n\ndef _blob(arr: np.ndarray) -> bytes:\n    \"\"\"numpy array -> raw bytes (contiguous) για αποθήκευση ως SQLite BLOB.\"\"\"\n    return np.ascontiguousarray(arr).tobytes()\n\n\nclass ColmapDB:\n    \"\"\"Μίνι wrapper γύρω από το SQLite schema του COLMAP (create + inserts).\"\"\"\n\n    def __init__(self, path: str):\n        if os.path.exists(path):\n            os.remove(path)                 # ξεκινάμε πάντα από καθαρή βάση\n        self.con = sqlite3.connect(path)\n        self.con.executescript(CREATE_SCHEMA)\n\n    def add_camera(self, model_id: int, w: int, h: int,\n                   params: np.ndarray, prior: int = 1) -> int:\n        cur = self.con.execute(\n            \"INSERT INTO cameras (model,width,height,params,prior_focal_length) \"\n            \"VALUES (?,?,?,?,?)\",\n            (model_id, w, h, _blob(np.asarray(params, np.float64)), prior))\n        return cur.lastrowid\n\n    def add_image(self, name: str, camera_id: int) -> int:\n        cur = self.con.execute(\n            \"INSERT INTO images (name,camera_id) VALUES (?,?)\", (name, camera_id))\n        return cur.lastrowid\n\n    def add_keypoints(self, image_id: int, kpts: np.ndarray) -> None:\n        kpts = np.asarray(kpts, np.float32)\n        if kpts.ndim != 2 or kpts.shape[1] != 2:\n            kpts = kpts.reshape(-1, 2)\n        self.con.execute(\"INSERT INTO keypoints VALUES (?,?,?,?)\",\n                         (image_id, kpts.shape[0], kpts.shape[1], _blob(kpts)))\n\n    def add_matches(self, id1: int, id2: int, matches: np.ndarray) -> None:\n        m = np.asarray(matches, np.uint32)\n        if id1 > id2:                       # να ταιριάζει η σειρά με το pair_id\n            m = m[:, ::-1]\n        self.con.execute(\"INSERT INTO matches VALUES (?,?,?,?)\",\n                         (_pair_id(id1, id2), m.shape[0], m.shape[1], _blob(m)))\n\n    def add_two_view_geometry(self, id1: int, id2: int, matches: np.ndarray,\n                              F: np.ndarray, config: int = 3) -> None:\n        # config=3 -> UNCALIBRATED (Fundamental). ΕΜΕΙΣ δίνουμε τους inliers, οπότε\n        # το COLMAP δεν χρειάζεται να ξανακάνει geometric verification.\n        m = np.asarray(matches, np.uint32)\n        if id1 > id2:\n            m = m[:, ::-1]\n        F = np.asarray(F if F is not None else np.eye(3), np.float64)\n        I = np.eye(3, dtype=np.float64)\n        self.con.execute(\n            \"INSERT INTO two_view_geometries VALUES (?,?,?,?,?,?,?,?,?,?)\",\n            (_pair_id(id1, id2), m.shape[0], m.shape[1], _blob(m), config,\n             _blob(F), _blob(I), _blob(I), _blob(np.array([1., 0, 0, 0])),\n             _blob(np.zeros(3))))\n\n    def commit(self):\n        self.con.commit()\n\n    def close(self):\n        self.con.close()\n\n\n# αντιστοίχιση ονόματος μοντέλου κάμερας -> id του COLMAP\nCOLMAP_MODEL_IDS = {\"SIMPLE_PINHOLE\": 0, \"PINHOLE\": 1, \"SIMPLE_RADIAL\": 2, \"RADIAL\": 3}\n\n\ndef camera_params(model: str, w: int, h: int, focal: float) -> np.ndarray:\n    \"\"\"Αρχικές παράμετροι κάμερας ανάλογα με το μοντέλο (principal point = κέντρο).\"\"\"\n    cx, cy = w / 2.0, h / 2.0\n    if model == \"PINHOLE\":\n        return np.array([focal, focal, cx, cy], np.float64)\n    if model == \"SIMPLE_PINHOLE\":\n        return np.array([focal, cx, cy], np.float64)\n    # SIMPLE_RADIAL: [f, cx, cy, k]  (k = radial distortion, ξεκινά από 0)\n    return np.array([focal, cx, cy, 0.0], np.float64)","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Στάδιο 5 — SfM (incremental mapping)\n\nΧτίζουμε τη βάση (κάμερες, εικόνες, keypoints, matches, two-view geometries) και\nτρέχουμε τον incremental mapper του `pycolmap`. Κάθε ανεξάρτητο model = ένα cluster.","metadata":{}},{"cell_type":"code","source":"# ===========================================================================\n#  5) SfM — χτίσιμο της βάσης COLMAP + incremental mapping με pycolmap\n# ===========================================================================\ndef run_incremental_sfm(cfg: CFG, dataset: str,\n                        image_names: List[str], image_paths: List[str],\n                        sizes: List[Tuple[int, int]],\n                        keypoints: Dict[int, np.ndarray],\n                        matches: Dict[Tuple[int, int], np.ndarray],\n                        fmats: Dict[Tuple[int, int], np.ndarray]):\n    \"\"\"\n    Επιστρέφει dict {model_idx: pycolmap.Reconstruction}. Κάθε ανεξάρτητο model\n    αντιστοιχεί σε ένα cluster/scene. Ο incremental mapper βγάζει ΦΥΣΙΚΑ πολλά\n    models όταν οι εικόνες ανήκουν σε διαφορετικές σκηνές -> έτσι παίρνουμε το\n    clustering \"δωρεάν\", και οι μη-registered εικόνες είναι οι outliers.\n    \"\"\"\n    import pycolmap\n\n    ds_dir = os.path.join(cfg.work_dir, dataset)\n    os.makedirs(ds_dir, exist_ok=True)\n    db_path = os.path.join(ds_dir, \"database.db\")\n\n    db = ColmapDB(db_path)\n    model_id = COLMAP_MODEL_IDS[cfg.camera_model]\n\n    # --- εισαγωγή καμερών + εικόνων + keypoints ---\n    img_ids: Dict[int, int] = {}\n    shared_cam_id = None\n    for i, name in enumerate(image_names):\n        w, h = sizes[i]\n        focal = cfg.focal_prior_factor * max(w, h)   # αρχικό focal όταν λείπει EXIF\n        if cfg.shared_camera:\n            if shared_cam_id is None:\n                shared_cam_id = db.add_camera(\n                    model_id, w, h, camera_params(cfg.camera_model, w, h, focal))\n            cam_id = shared_cam_id\n        else:\n            cam_id = db.add_camera(\n                model_id, w, h, camera_params(cfg.camera_model, w, h, focal))\n        iid = db.add_image(name, cam_id)\n        img_ids[i] = iid\n        db.add_keypoints(iid, keypoints.get(i, np.zeros((0, 2), np.float32)))\n\n    # --- εισαγωγή matches + verified two-view geometries ---\n    for (i, j), m in matches.items():\n        if len(m) == 0:\n            continue\n        db.add_matches(img_ids[i], img_ids[j], m)\n        db.add_two_view_geometry(img_ids[i], img_ids[j], m, fmats.get((i, j)))\n    db.commit()\n    db.close()\n\n    # --- incremental mapping ---\n    # [ΕΛΕΓΞΕ ΕΚΔΟΣΗ]: τα ονόματα των options αλλάζουν ανά pycolmap version.\n    opts = pycolmap.IncrementalPipelineOptions()\n    try:\n        opts.min_model_size = cfg.mapper_min_model_size\n        opts.min_num_matches = cfg.mapper_min_num_matches\n        opts.multiple_models = cfg.mapper_multiple_models   # ΑΠΑΡΑΙΤΗΤΟ για clusters\n        opts.max_num_models = cfg.mapper_max_num_models\n        opts.ba_refine_focal_length = cfg.refine_focal\n        opts.ba_refine_principal_point = False\n        opts.ba_refine_extra_params = cfg.refine_focal\n    except Exception:\n        # παλιό API -> απλώς αφήνουμε τα defaults\n        pass\n\n    out_dir = os.path.join(ds_dir, \"sparse\")\n    os.makedirs(out_dir, exist_ok=True)\n    # image_path = ρίζα ως προς την οποία τα image names είναι σχετικά.\n    # Χρησιμοποιούμε τον κοινό γονικό φάκελο όλων των εικόνων.\n    image_dir = (os.path.commonpath([os.path.dirname(p) for p in image_paths])\n                 if image_paths else cfg.data_root)\n\n    maps = pycolmap.incremental_mapping(\n        database_path=db_path, image_path=image_dir,\n        output_path=out_dir, options=opts)\n    return maps if maps is not None else {}","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Εξαγωγή πόζας & συλλογή αποτελεσμάτων\n\nΠαίρνουμε `R, t` από κάθε registered εικόνα (καλύπτοντας όλες τις εκδόσεις\n`pycolmap`) και τα οργανώνουμε σε clusters· ό,τι δεν registered γίνεται outlier.","metadata":{}},{"cell_type":"code","source":"# ===========================================================================\n#  ΕΞΑΓΩΓΗ ΠΟΖΑΣ (ανθεκτικό σε εκδόσεις pycolmap) + ΣΥΛΛΟΓΗ ΑΠΟΤΕΛΕΣΜΑΤΩΝ\n# ===========================================================================\ndef qvec2rotmat(q: np.ndarray) -> np.ndarray:\n    \"\"\"Quaternion (w, x, y, z) -> 3x3 rotation matrix (για πολύ παλιό pycolmap API).\"\"\"\n    w, x, y, z = q\n    return np.array([\n        [1 - 2 * (y * y + z * z), 2 * (x * y - z * w),     2 * (x * z + y * w)],\n        [2 * (x * y + z * w),     1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],\n        [2 * (x * z - y * w),     2 * (y * z + x * w),     1 - 2 * (x * x + y * y)],\n    ], dtype=np.float64)\n\n\ndef image_pose(image) -> Tuple[np.ndarray, np.ndarray]:\n    \"\"\"\n    Επιστρέφει (R 3x3, t 3) στη σύμβαση cam_from_world (X_cam = R·X_world + t),\n    ακριβώς ό,τι θέλει το submission (κέντρο κάμερας C = -R^T · t).\n\n    ΚΑΛΥΠΤΟΥΜΕ ΟΛΕΣ τις εκδόσεις pycolmap:\n      • νέο (3.10+/4.x): image.cam_from_world ΕΙΝΑΙ method -> πρέπει να ΚΛΗΘΕΙ\n      • 0.4–0.6: ήταν property (Rigid3d)\n      • πολύ παλιό: υπήρχε qvec/tvec\n    \"\"\"\n    cfw = getattr(image, \"cam_from_world\", None)\n    if cfw is not None:\n        T = cfw() if callable(cfw) else cfw            # method Ή property\n        if T is not None:\n            rot = T.rotation\n            mat = getattr(rot, \"matrix\", None)\n            R = mat() if callable(mat) else np.asarray(rot.matrix)\n            R = np.asarray(R, np.float64).reshape(3, 3)\n            t = np.asarray(T.translation, np.float64).reshape(3)\n            return R, t\n\n    # παλιό API — μόνο αν όντως υπάρχει (δεν σκάμε με AttributeError)\n    if hasattr(image, \"qvec\"):\n        R = qvec2rotmat(np.asarray(image.qvec, np.float64))\n        t = np.asarray(image.tvec, np.float64).reshape(3)\n        return R, t\n\n    raise AttributeError(\"Καμία γνωστή pose-API δεν βρέθηκε στο pycolmap.Image\")\n\n\ndef collect_dataset_results(maps, image_names: List[str], cfg: CFG) -> Dict[str, dict]:\n    \"\"\"\n    Μετατρέπει τα reconstructions σε: image_name -> {\"scene\", \"R\", \"t\"}.\n      • Κρατάμε μόνο models με >= min_cluster_size registered εικόνες.\n      • Το μεγαλύτερο model γίνεται cluster0, το επόμενο cluster1 κ.ο.κ.\n      • Ό,τι δεν έχει registered πόζα -> outliers (identity pose).\n    \"\"\"\n    results: Dict[str, dict] = {}\n\n    def _num_reg(rec) -> int:\n        \"\"\"Πλήθος registered εικόνων (επίσημο API, αλλιώς μέτρημα με has_pose).\"\"\"\n        try:\n            return int(rec.num_reg_images())\n        except Exception:\n            pass\n        try:\n            return sum(1 for im in rec.images.values() if getattr(im, \"has_pose\", True))\n        except Exception:\n            return 0\n\n    # ταξινόμηση models κατά #registered φθίνουσα -> μεγαλύτερο = cluster0\n    items = []\n    for k, rec in (maps.items() if hasattr(maps, \"items\") else enumerate(maps)):\n        items.append((_num_reg(rec), k, rec))\n    items.sort(key=lambda z: -z[0])\n\n    cluster_idx = 0\n    for n_reg, _, rec in items:\n        if n_reg < cfg.min_cluster_size:\n            continue\n        scene = f\"{cfg.cluster_prefix}{cluster_idx}\"\n        cluster_idx += 1\n        try:\n            imgs = list(rec.images.values())\n        except Exception:\n            imgs = []\n        for im in imgs:\n            if hasattr(im, \"has_pose\") and not im.has_pose:\n                continue                       # μόνο registered εικόνες έχουν πόζα\n            try:\n                R, t = image_pose(im)\n            except Exception:\n                continue                       # μία κακή εικόνα ΔΕΝ ρίχνει το dataset\n            results[os.path.basename(im.name)] = {\"scene\": scene, \"R\": R, \"t\": t}\n\n    # ό,τι δεν μπήκε σε cluster -> outlier με identity pose\n    for name in image_names:\n        base = os.path.basename(name)\n        if base not in results:\n            results[base] = {\"scene\": cfg.outlier_label,\n                             \"R\": np.eye(3), \"t\": np.zeros(3)}\n    return results","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Στάδιο 6 — Submission formatting\n\nΜετατρέπουμε `R` (3×3) και `t` (3) σε strings χωρισμένα με `;` και τα γράφουμε\nστις σωστές στήλες του DataFrame.","metadata":{}},{"cell_type":"code","source":"# ===========================================================================\n#  6) SUBMISSION FORMATTING — γράψιμο R/t στο σωστό string format του Kaggle\n# ===========================================================================\ndef mat_to_str(R: np.ndarray) -> str:\n    \"\"\"3x3 -> 9 τιμές (row-major) χωρισμένες με ';'.\"\"\"\n    return \";\".join(repr(float(v)) for v in np.asarray(R, np.float64).reshape(-1))\n\n\ndef vec_to_str(t: np.ndarray) -> str:\n    \"\"\"3-διάνυσμα -> 3 τιμές χωρισμένες με ';'.\"\"\"\n    return \";\".join(repr(float(v)) for v in np.asarray(t, np.float64).reshape(-1))\n\n\ndef write_submission(df, colmap: Dict[str, str],\n                     groups: Dict[str, List[Tuple[int, str]]],\n                     per_dataset_results: Dict[str, Dict[str, dict]]):\n    \"\"\"Γεμίζει scene / rotation_matrix / translation_vector στο df (in place).\"\"\"\n    for ds, rows in groups.items():\n        res = per_dataset_results.get(ds, {})\n        for idx, image_name in rows:\n            base = os.path.basename(str(image_name))\n            r = res.get(base, {\"scene\": \"outliers\", \"R\": np.eye(3), \"t\": np.zeros(3)})\n            df.at[idx, colmap[\"scene\"]] = r[\"scene\"]\n            df.at[idx, colmap[\"rot\"]] = mat_to_str(r[\"R\"])\n            df.at[idx, colmap[\"trans\"]] = vec_to_str(r[\"t\"])\n    return df","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Ορχήστρα (orchestration)\n\n`process_dataset` τρέχει όλη την αλυσίδα σε ένα dataset· `preflight` ελέγχει τα\noffline assets· `run_pipeline` δένει τα πάντα και γράφει το `submission.csv`.","metadata":{}},{"cell_type":"code","source":"# ===========================================================================\n#  ΟΡΧΗΣΤΡΑ — ένα dataset τη φορά, και το συνολικό pipeline\n# ===========================================================================\ndef process_dataset(cfg: CFG, dataset: str, rows: List[Tuple[int, str]],\n                    matcher: \"MASt3RMatcher\") -> Dict[str, dict]:\n    \"\"\"Τρέχει ολόκληρη την αλυσίδα (retrieval -> matching -> verify -> SfM) σε 1 dataset.\"\"\"\n    t0 = time.time()\n    image_names = [r[1] for r in rows]\n\n    # --- κράτα μόνο εικόνες που όντως υπάρχουν στον δίσκο ---\n    image_paths, valid_names = [], []\n    for name in image_names:\n        p = resolve_image_path(cfg, dataset, name)\n        if p:\n            image_paths.append(p)\n            valid_names.append(name)\n    if len(image_paths) < 2:\n        # με λιγότερες από 2 εικόνες δεν γίνεται matching -> όλα outliers\n        return {os.path.basename(n): {\"scene\": cfg.outlier_label,\n                                      \"R\": np.eye(3), \"t\": np.zeros(3)}\n                for n in image_names}\n\n    # --- 1) RETRIEVAL: πίνακας ομοιότητας -> λίστα ζευγών ---\n    desc = extract_global_descriptors(cfg, image_paths, model=matcher.get_model())\n    sim = desc @ desc.T                    # cosine (τα desc είναι L2-normalized)\n    pairs = pick_pairs_by_similarity(sim, cfg.retrieval_top_k,\n                                     cfg.retrieval_min_sim, cfg.exhaustive_max)\n    if cfg.max_pairs_per_dataset > 0:\n        pairs = pairs[:cfg.max_pairs_per_dataset]\n    print(f\"[{dataset}] images={len(image_paths)} pairs={len(pairs)}\")\n\n    # --- 2)+3) MATCHING + GEOMETRIC VERIFICATION ανά ζεύγος ---\n    pair_matches: Dict[Tuple[int, int], Tuple[np.ndarray, np.ndarray]] = {}\n    fmats: Dict[Tuple[int, int], np.ndarray] = {}\n    sizes: List[Tuple[int, int]] = [(0, 0)] * len(image_paths)\n    for (i, j) in pairs:\n        m0, m1, s0, s1 = matcher.match_pair(image_paths[i], image_paths[j])\n        sizes[i], sizes[j] = s0, s1\n        m0, m1, F = geometric_verify_pair(m0, m1, cfg)\n        if len(m0) >= cfg.min_inliers:\n            pair_matches[(i, j)] = (m0, m1)\n            fmats[(i, j)] = F\n    # εικόνες χωρίς έγκυρο ζεύγος: πάρε διαστάσεις κατευθείαν από το αρχείο\n    for i, p in enumerate(image_paths):\n        if sizes[i] == (0, 0):\n            sizes[i] = _original_wh(p)\n\n    # --- 4) AGGREGATION: matches -> keypoints + index-pairs ---\n    keypoints, idx_matches = aggregate_matches(valid_names, pair_matches, cfg.quant_px)\n\n    # --- 5) SfM ---\n    try:\n        maps = run_incremental_sfm(cfg, dataset, valid_names, image_paths, sizes,\n                                   keypoints, idx_matches, fmats)\n    except Exception as e:\n        print(f\"[{dataset}] SfM απέτυχε: {e}\")\n        maps = {}\n\n    res = collect_dataset_results(maps, image_names, cfg)\n    n_clusters = len(set(v[\"scene\"] for v in res.values()) - {cfg.outlier_label})\n    print(f\"[{dataset}] ολοκληρώθηκε σε {time.time() - t0:.1f}s, clusters={n_clusters}\")\n    gc.collect()\n    return res\n\n\ndef preflight(cfg: CFG) -> None:\n    \"\"\"\n    Ελέγχει ΠΡΙΝ ξεκινήσουμε ότι όλα τα offline assets υπάρχουν (Internet=OFF).\n    Αν λείπει κάτι, σταματάμε ΕΔΩ με σαφές μήνυμα αντί να σκάσει αργότερα.\n    \"\"\"\n    problems = []\n    if not os.path.isfile(cfg.sample_submission):\n        problems.append(f\"Λείπει το sample_submission: {cfg.sample_submission}\")\n    if not os.path.isfile(cfg.mast3r_weights):\n        problems.append(f\"Λείπουν τα MASt3R weights (.pth): {cfg.mast3r_weights}\")\n    if not os.path.isdir(cfg.mast3r_repo):\n        problems.append(f\"Λείπει ο φάκελος του mast3r repo: {cfg.mast3r_repo}\")\n    else:\n        if not os.path.isdir(os.path.join(cfg.mast3r_repo, \"mast3r\")):\n            problems.append(f\"Λείπει το package 'mast3r/' μέσα στο {cfg.mast3r_repo}\")\n        if not (os.path.isdir(os.path.join(cfg.mast3r_repo, \"dust3r\"))\n                or os.path.isdir(os.path.join(cfg.mast3r_repo, \"dust3r\", \"dust3r\"))):\n            problems.append(f\"Λείπει το submodule 'dust3r/' μέσα στο {cfg.mast3r_repo}\")\n\n    try:\n        import pycolmap  # noqa: F401\n    except Exception as e:\n        problems.append(f\"Το pycolmap δεν εισάγεται ({e}). Πρόσθεσε offline wheel dataset.\")\n\n    try:\n        import torch\n        if cfg.device == \"cuda\" and not torch.cuda.is_available():\n            print(\"[preflight] ΠΡΟΣΟΧΗ: ζητήθηκε cuda αλλά δεν υπάρχει GPU -> πολύ αργό.\")\n    except Exception as e:\n        problems.append(f\"Το torch δεν εισάγεται ({e}).\")\n\n    if problems:\n        raise RuntimeError(\"OFFLINE PREFLIGHT FAILED:\\n  - \" + \"\\n  - \".join(problems))\n    print(\"[preflight] OK — όλα τα τοπικά assets υπάρχουν, έτοιμο για Internet=OFF.\")\n\n\ndef run_pipeline(cfg: CFG):\n    \"\"\"Το πλήρες pipeline: διάβασμα submission -> ανά dataset -> γράψιμο csv.\"\"\"\n    set_seed(cfg.seed)\n    os.makedirs(cfg.work_dir, exist_ok=True)\n    preflight(cfg)   # σταμάτα νωρίς αν λείπει offline asset\n\n    df = read_sample_submission(cfg.sample_submission)\n    colmap = detect_columns(df)\n    groups = group_images_by_dataset(df, colmap)\n    print(f\"Σύνολο datasets: {len(groups)} | εικόνες: {len(df)}\")\n\n    matcher = MASt3RMatcher(cfg)   # φόρτωση μοντέλου ΜΙΑ φορά για όλα τα datasets\n\n    per_dataset_results: Dict[str, Dict[str, dict]] = {}\n    for ds, rows in groups.items():\n        try:\n            per_dataset_results[ds] = process_dataset(cfg, ds, rows, matcher)\n        except Exception as e:\n            print(f\"[{ds}] σφάλμα: {e} -> όλα outliers\")\n            per_dataset_results[ds] = {\n                os.path.basename(n): {\"scene\": cfg.outlier_label,\n                                      \"R\": np.eye(3), \"t\": np.zeros(3)}\n                for _, n in rows}\n\n    write_submission(df, colmap, groups, per_dataset_results)\n    df.to_csv(cfg.out_csv, index=False)\n    print(f\"Γράφτηκε: {cfg.out_csv}\")","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Self-test (χωρίς GPU/δεδομένα)\n\nΓρήγορος έλεγχος της «καθαρής» λογικής (retrieval, aggregation, verification,\nformatting). Αν σκάσει εδώ, το πρόβλημα είναι στη λογική — όχι στα βαριά\ndependencies.","metadata":{}},{"cell_type":"code","source":"# # ===========================================================================\n# #  SELF-TEST — τρέχει ΧΩΡΙΣ torch / pycolmap / mast3r / competition data.\n# #  Ελέγχει την \"καθαρή\" λογική: retrieval, aggregation, verification, formatting.\n# #  Χρήσιμο για γρήγορη επιβεβαίωση ότι η βασική αλυσίδα δουλεύει σωστά.\n# # ===========================================================================\n# def _selftest() -> int:\n#     print(\"== SELFTEST: pure-logic (numpy / cv2 / pandas) ==\")\n#     import pandas as _pd\n\n#     # 1) RETRIEVAL: μικρό N -> exhaustive· μεγάλο N -> top-k + threshold\n#     sim_small = np.eye(5) + 0.1\n#     p_small = pick_pairs_by_similarity(sim_small, top_k=3, min_sim=0.0, exhaustive_max=30)\n#     assert len(p_small) == 10, p_small                     # C(5,2) = 10\n#     rng = np.random.default_rng(0)\n#     X = rng.normal(size=(40, 16)); X /= np.linalg.norm(X, axis=1, keepdims=True)\n#     p_big = pick_pairs_by_similarity(X @ X.T, top_k=5, min_sim=0.0, exhaustive_max=30)\n#     assert all(a < b for a, b in p_big) and len(set(p_big)) == len(p_big)\n#     print(f\"  retrieval ok: small={len(p_small)} pairs, big={len(p_big)} pairs\")\n\n#     # 2) AGGREGATION: ίδιο σημείο σε 2 ζεύγη πρέπει να συγχωνευτεί\n#     names = [\"a.jpg\", \"b.jpg\", \"c.jpg\"]\n#     m01_0 = np.array([[10.0, 10.0], [50.0, 50.0]], np.float32)\n#     m01_1 = np.array([[11.0, 11.0], [80.0, 80.0]], np.float32)\n#     m02_0 = np.array([[10.4, 10.4]], np.float32)           # ~ίδιο με (10,10) -> merge\n#     m02_1 = np.array([[200.0, 200.0]], np.float32)\n#     pm = {(0, 1): (m01_0, m01_1), (0, 2): (m02_0, m02_1)}\n#     kpts, idxm = aggregate_matches(names, pm, quant_px=2.0)\n#     assert kpts[0].shape[0] == 2, kpts[0]                  # (10,10) & (50,50) — το (10.4,10.4) merged\n#     assert idxm[(0, 1)].shape == (2, 2)\n#     assert idxm[(0, 2)].shape == (1, 2)\n#     print(f\"  aggregation ok: img0 keypoints={kpts[0].shape[0]} (merged σωστά)\")\n\n#     # 3) GEOMETRIC VERIFICATION με συνθετικά in-plane σημεία (homography)\n#     if cv2 is not None:\n#         pts0 = rng.uniform(0, 500, size=(60, 2)).astype(np.float32)\n#         H = np.array([[1, 0.01, 5], [0.0, 1, 3], [0, 0, 1]], np.float64)\n#         pts1 = (np.c_[pts0, np.ones(len(pts0))] @ H.T)[:, :2].astype(np.float32)\n#         cfg = CFG(min_inliers=8, ransac_thr_px=2.0)\n#         inl0, _inl1, _F = geometric_verify_pair(pts0, pts1, cfg)\n#         assert len(inl0) >= 8, len(inl0)\n#         print(f\"  geom-verify ok: inliers={len(inl0)}/60\")\n\n#     # 4) SUBMISSION FORMATTING end-to-end (mock reconstruction)\n#     class _Rot:\n#         def __init__(self, M): self._M = M\n#         def matrix(self): return self._M\n\n#     class _T:\n#         def __init__(self, R, t): self.rotation = _Rot(R); self.translation = t\n\n#     class _Img:\n#         def __init__(self, name, R, t): self.name = name; self.cam_from_world = _T(R, t)\n\n#     class _Rec:\n#         def __init__(self, imgs): self.images = {i: im for i, im in enumerate(imgs)}\n\n#     R1 = np.eye(3)\n#     rec = _Rec([_Img(\"a.jpg\", R1, np.array([1.0, 2, 3])),\n#                 _Img(\"b.jpg\", R1, np.array([4.0, 5, 6])),\n#                 _Img(\"c.jpg\", R1, np.array([7.0, 8, 9])),\n#                 _Img(\"d.jpg\", R1, np.array([0.0, 0, 1]))])\n#     cfg = CFG(min_cluster_size=2)\n#     res = collect_dataset_results({0: rec}, [\"a.jpg\", \"b.jpg\", \"c.jpg\", \"d.jpg\", \"e.jpg\"], cfg)\n#     assert res[\"a.jpg\"][\"scene\"] == \"cluster0\"\n#     assert res[\"e.jpg\"][\"scene\"] == cfg.outlier_label      # e δεν registered\n\n#     df = _pd.DataFrame({\n#         \"dataset\": [\"d1\"] * 5,\n#         \"image\": [\"a.jpg\", \"b.jpg\", \"c.jpg\", \"d.jpg\", \"e.jpg\"],\n#         \"scene\": [\"\"] * 5, \"rotation_matrix\": [\"\"] * 5, \"translation_vector\": [\"\"] * 5,\n#     })\n#     colmap = detect_columns(df)\n#     groups = group_images_by_dataset(df, colmap)\n#     write_submission(df, colmap, groups, {\"d1\": res})\n#     assert len(df.loc[0, \"rotation_matrix\"].split(\";\")) == 9\n#     assert len(df.loc[0, \"translation_vector\"].split(\";\")) == 3\n#     assert df.loc[4, \"scene\"] == cfg.outlier_label\n#     print(\"  submission formatting ok: rot=9 vals, trans=3 vals, outlier σωστό\")\n\n#     print(\"== ΟΛΑ ΤΑ SELFTESTS ΠΕΡΑΣΑΝ ==\")\n#     return 0\n\n\n# # Τρέξε τους ελέγχους λογικής (δεν χρειάζεται GPU/δεδομένα).\n# # Αν κάτι σκάσει εδώ, το πρόβλημα είναι στη λογική, ΟΧΙ στα βαριά dependencies.\n# _selftest()","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Εκτέλεση\n\nΦτιάξε το config και τρέξε ολόκληρο το pipeline. Παράγεται το `submission.csv`\nστο `cfg.out_csv`. Ξεσχολίασε τις γραμμές παρακάτω για γρήγορες προσαρμογές.","metadata":{}},{"cell_type":"code","source":"# ===========================================================================\n#  ΕΚΤΕΛΕΣΗ ΤΟΥ PIPELINE\n# ===========================================================================\ncfg = CFG()\n\n# --- (προαιρετικό) γρήγορες προσαρμογές πριν το τρέξιμο ---\n# cfg.device            = \"cuda\"     # \"cpu\" μόνο για τοπικό debug\n# cfg.retrieval_top_k   = 25         # 10–50  (περισσότερα ζεύγη = πιο αργό)\n# cfg.fast_nn_subsample = 8          # 2–16   (μικρότερο = πιο πολλά matches)\n# cfg.min_inliers       = 15         # 8–40\n# cfg.min_cluster_size  = 4          # 3–8\n\nrun_pipeline(cfg)","metadata":{},"outputs":[],"execution_count":null}]}