Mattia D'Urso · Christian Sormann · Mattia Rossi · Friedrich Fraundorfer
ECCV 2026 🇸🇪
Visualization of three stages of EPO applied to the Graz Town Hall scene (TerraSky3D). Starting from the initial state (a) provided by VGGT output, we show an intermediate step (b) and the final refined poses (c). Ground truth poses are shown in green; optimized poses in red.
EPO (Edge-based Pose Optimization) is an optimization framework specifically designed to enhance Structure-from-Motion reconstructions generated by 3D foundation models, without the need for explicit feature tracks. We use Gradient Descent to learn a per-scene pose-MLP and a set of weights improving camera parameters and geometry. We demonstrate that EPO achieves geometric precision comparable to, or even exceeding, traditional Bundle Adjustment, while reducing runtime by up to 80% and operating efficiently on consumer-grade hardware.
The optimization loop was profiled and rebuilt around two findings: most of the per-step cost was not in the Triton kernels but in redundant data movement and in kernel-launch dispatch (~600 tiny launches per step). v1.2 removes both — edge points, DT fields, and pad masks are now read by image index directly inside the fused kernels, the geometry prologue (pose MLP → intrinsics → unprojection, forward and backward) is captured once as a CUDA graph and replayed, and the default batch size goes 128 → 1024.
EPO is now 2.4× faster than v1.1 (133 → 318 it/s mean over the 38-scene benchmark set, RTX 4090) and 8.4× faster than the torch backend wall-clock. On bicycle (MipNeRF360), the EPO optimization itself now takes ~5 s.
Accuracy is preserved by construction: every change is bit-exact against the previous loop (same kernels, same FP order), verified by identical AUC@5/@3/@1 on all 38 benchmark scenes. The larger batch size can shift individual scenes slightly (the optimizer sees different mini-batch compositions), but dataset means stay within ±0.5 AUC@5. Kernel-level optimizations that measured faster but perturbed AUC (e.g. reduction-order changes) were deliberately rejected.
EPO refines depth only at the edge pixels it samples, so the depths.pth it exports is sparse (~8% of pixels) and its point cloud is correspondingly thin. v1.1 adds a depth-completion stage built on Any2Full: it takes EPO's refined (sparse) depths as guidance and completes them into full-resolution dense maps, giving you a dense point cloud on top of the refined poses — poses, intrinsics, and the sparse model are left untouched.
python demo_epo.py \
--images_path bicycle/images \
--output_path out/bicycle \
--densify # New added to denisfy EPO output
This writes a dense_<model>_epo COLMAP model next to sparse_<model>_epo. See Densifying EPO's depths for the standalone API, the weights, and the point-count/DBSCAN caveats. Completion is a single feed-forward pass per image, so it adds only ~25 s for a 150-image scene — on bicycle (MipNeRF360, RTX 4090) the whole --densify run takes ~90 s end-to-end: ~65 s for VGGT + EPO and ~25 s for the densification on top.
python3-venv (any pip-only flow)git clone --recursive https://github.com/mattiadurso/epo.git
cd epo
conda env create -f environment.yml
conda activate epo
conda create -n epo python=3.10 -y
conda activate epo
pip install joblib \
kornia \
matplotlib \
numpy \
opencv-python \
pandas \
pycolmap \
rerun-sdk \
torch \
torchvision \
triton \
tqdm
# Only needed to run the demo notebooks (demo.ipynb); not required for the library or demo_epo.py
pip install git+https://github.com/mattiadurso/mylib.git
ℹ️
tritonis Linux-only and requires a CUDA build oftorch. On systems without CUDA, installtorchfrom the official selector first, then run the rest of thepip installline withouttriton— EPO will fall back to the PyTorch reference path (backend="torch").
wrapper/ provides swappable drivers for several 3D foundation models, each a thin driver over a pristine git submodule under third_party/ plus the shared pycolmap-4 conversion helper wrapper/np_to_colmap.py and the common base class wrapper/base_wrapper.py. Select one via --model on demo_epo.py (default vggt); the full list lives in the WRAPPERS registry in wrapper/__init__.py:
Each wrapper is also runnable on its own for a quick smoke test — handy to check a single model end-to-end without going through demo_epo.py or a dataset config. It writes a COLMAP model + depths.pth to --output_path using the model's registered weights (override with --model_path); for batch runs across benchmark datasets use wrapper/run_for_dataset.py instead:
python wrapper/vggt_wrapper.py --images_path scene/images/1 --output_path out/sparse
Only vggt's submodule is needed to run EPO's own demo/reconstructions; EPO refines any reconstruction in the expected layout without any of them. third_party/lightglue is a further submodule needed only for VGGT's optional Bundle-Adjustment path (use_ba=True); the default feed-forward path — including demo_epo.py — never imports it. If you cloned without --recursive:
wrapper/any2full_wrapper.py (over third_party/Any2Full) is a different kind of driver and is therefore not in the WRAPPERS registry: it takes an existing COLMAP reconstruction whose depths are sparse — EPO's own export, whose depths.pth only carries depth at the sampled edge pixels — and completes them into dense maps, passing the poses through untouched. See Densifying EPO's depths.
git submodule update --init --recursive
To actually run a model (e.g. via demo_epo.py), also install its dependencies — see the top of each wrapper module for the exact extras and any model-specific gotchas (e.g. vggt_omega's checkpoint is gated on Hugging Face: request access, then pass a local path via --model_path). For VGGT itself, install only these extras — do not run pip install -r third_party/vggt/requirements.txt, as its pinned torch/numpy versions would downgrade and break the EPO environment:
pip install huggingface_hub einops safetensors
We report models results averaged by dataset at mattiadurso.com/epo.
📦 A few demo scenes can be downloaded here. These are already pre-processed with VGGT, thus steps 1 and 2 can be skipped.
Organize your images using the following structure. Images can be grouped by camera if multiple cameras are used:
bicycle/
└── images/
├── 1/ # Images for Camera Group 1
│ ├── _DSC8679.jpg
│ ├── _DSC8680.jpg
│ └── ...
└── 2/ # Images for Camera Group 2 (if more)
├── _DSC9001.jpg
└── ...
Run a 3DFM (e.g., VGGT) on your images and export the reconstruction in COLMAP format and the dense depth maps with the following layout:
bicycle/
└── sparse/
├── cameras.bin # Camera intrinsic parameters
├── images.bin # Camera extrinsics and image registration
├── points3D.bin # Sparse 3D point cloud
└── depths.pth # Dense depth maps: torch.save'd dict
# {image_stem: {"depth": (H, W), "confidence": (H, W) (optional)}}
from epo import EPO
epo = EPO(
reconstruction_path="bicycle/sparse",
images_path="bicycle/images",
depths_path="bicycle/sparse/depths.pth",
backend="triton", # "torch" for the reference path
)
epo(early_stop="pose", gt_path="<path_to_gt>") # gt_path optional — enables evaluation at runtime
epo.to_colmap("out/sparse", save_points=True)
demo_epo.py runs steps 2–3 for you: the selected --model (vggt by default; see the Submodules table for the full list) on a folder of images, then EPO directly on its in-memory output (EPO.from_ff), writing sparse_<model> and sparse_<model>_epo under --output_path:
python demo_epo.py \
--images_path bicycle/images \
--output_path out/demo \
--model vggt \ # or vggt_omega, dvlt, da3, mapanything, pi3x
--gt_path <path_to_gt> # Optional — enables quantitative evaluation at runtime
Pass --model_output <dir> to reuse a previous run's reconstruction + depths.pth instead of re-running the model; both modes produce the same refinement. Pass --model_path <path/or/repo-id> to load the model from a local checkpoint instead of its default Hugging Face download.
depths.pth keys must match the relative image paths without extension (e.g., cam_<j>/image_<i>).cam_<j>/image_<i>.jpg).If you already have a 3DFM's output as tensors in memory, you can skip the COLMAP / depths.pth export and feed EPO directly:
from epo import EPO
# ff_data: dict keyed by "cam_id/image_name"
# Each value is a dict with the per-image tensors (already at images_size).
# Pose is world-to-camera (T_cw), matching COLMAP / PoseModule.
ff_data = {
"cam_<j>/image_<i>.jpg": {
"image": image_tensor, # (3, H, W) float in [0, 1]
"depth": depth_tensor, # (H, W)
"pose": pose_tensor, # (3, 4) or (4, 4), world-to-camera
"intrinsic": intrinsic_tensor, # (3, 3) pinhole
# "confidence": conf_tensor, # (H, W), optional
},
...
}
epo = EPO.from_ff(
ff_data,
backend="triton",
)
epo()
epo.to_colmap("out/sparse", save_points=True)
All other EPO(...) kwargs are forwarded. Images and depths must already be at images_size (no internal resize). With single_camera_per_folder=True (default) all images under the same "cam_id/" folder share one jointly-optimized camera; otherwise each image gets its own. Every wrapper in wrapper/ (see the Submodules table) returns an EPO-ready ff_data built this way from its forward() method (see demo_epo.py).
If you find this work useful, please consider citing:
@inproceedings{durso2026epo,
title = {Boosting 3D Foundation Models with Edge-Based Pose Optimization},
author = {Mattia D'Urso and Christian Sormann and Mattia Rossi and Friedrich Fraundorfer},
booktitle = {European Conference on Computer Vision (ECCV)},
year = {2026},
}
238 commits
Jupyter Notebook
57.6%
Python
42.4%
Mattia D'Urso · Christian Sormann · Mattia Rossi · Friedrich Fraundorfer
ECCV 2026 🇸🇪
Visualization of three stages of EPO applied to the Graz Town Hall scene (TerraSky3D). Starting from the initial state (a) provided by VGGT output, we show an intermediate step (b) and the final refined poses (c). Ground truth poses are shown in green; optimized poses in red.
EPO (Edge-based Pose Optimization) is an optimization framework specifically designed to enhance Structure-from-Motion reconstructions generated by 3D foundation models, without the need for explicit feature tracks. We use Gradient Descent to learn a per-scene pose-MLP and a set of weights improving camera parameters and geometry. We demonstrate that EPO achieves geometric precision comparable to, or even exceeding, traditional Bundle Adjustment, while reducing runtime by up to 80% and operating efficiently on consumer-grade hardware.
The optimization loop was profiled and rebuilt around two findings: most of the per-step cost was not in the Triton kernels but in redundant data movement and in kernel-launch dispatch (~600 tiny launches per step). v1.2 removes both — edge points, DT fields, and pad masks are now read by image index directly inside the fused kernels, the geometry prologue (pose MLP → intrinsics → unprojection, forward and backward) is captured once as a CUDA graph and replayed, and the default batch size goes 128 → 1024.
EPO is now 2.4× faster than v1.1 (133 → 318 it/s mean over the 38-scene benchmark set, RTX 4090) and 8.4× faster than the torch backend wall-clock. On bicycle (MipNeRF360), the EPO optimization itself now takes ~5 s.
Accuracy is preserved by construction: every change is bit-exact against the previous loop (same kernels, same FP order), verified by identical AUC@5/@3/@1 on all 38 benchmark scenes. The larger batch size can shift individual scenes slightly (the optimizer sees different mini-batch compositions), but dataset means stay within ±0.5 AUC@5. Kernel-level optimizations that measured faster but perturbed AUC (e.g. reduction-order changes) were deliberately rejected.
EPO refines depth only at the edge pixels it samples, so the depths.pth it exports is sparse (~8% of pixels) and its point cloud is correspondingly thin. v1.1 adds a depth-completion stage built on Any2Full: it takes EPO's refined (sparse) depths as guidance and completes them into full-resolution dense maps, giving you a dense point cloud on top of the refined poses — poses, intrinsics, and the sparse model are left untouched.
python demo_epo.py \
--images_path bicycle/images \
--output_path out/bicycle \
--densify # New added to denisfy EPO output
This writes a dense_<model>_epo COLMAP model next to sparse_<model>_epo. See Densifying EPO's depths for the standalone API, the weights, and the point-count/DBSCAN caveats. Completion is a single feed-forward pass per image, so it adds only ~25 s for a 150-image scene — on bicycle (MipNeRF360, RTX 4090) the whole --densify run takes ~90 s end-to-end: ~65 s for VGGT + EPO and ~25 s for the densification on top.
python3-venv (any pip-only flow)git clone --recursive https://github.com/mattiadurso/epo.git
cd epo
conda env create -f environment.yml
conda activate epo
conda create -n epo python=3.10 -y
conda activate epo
pip install joblib \
kornia \
matplotlib \
numpy \
opencv-python \
pandas \
pycolmap \
rerun-sdk \
torch \
torchvision \
triton \
tqdm
# Only needed to run the demo notebooks (demo.ipynb); not required for the library or demo_epo.py
pip install git+https://github.com/mattiadurso/mylib.git
ℹ️
tritonis Linux-only and requires a CUDA build oftorch. On systems without CUDA, installtorchfrom the official selector first, then run the rest of thepip installline withouttriton— EPO will fall back to the PyTorch reference path (backend="torch").
wrapper/ provides swappable drivers for several 3D foundation models, each a thin driver over a pristine git submodule under third_party/ plus the shared pycolmap-4 conversion helper wrapper/np_to_colmap.py and the common base class wrapper/base_wrapper.py. Select one via --model on demo_epo.py (default vggt); the full list lives in the WRAPPERS registry in wrapper/__init__.py:
Each wrapper is also runnable on its own for a quick smoke test — handy to check a single model end-to-end without going through demo_epo.py or a dataset config. It writes a COLMAP model + depths.pth to --output_path using the model's registered weights (override with --model_path); for batch runs across benchmark datasets use wrapper/run_for_dataset.py instead:
python wrapper/vggt_wrapper.py --images_path scene/images/1 --output_path out/sparse
Only vggt's submodule is needed to run EPO's own demo/reconstructions; EPO refines any reconstruction in the expected layout without any of them. third_party/lightglue is a further submodule needed only for VGGT's optional Bundle-Adjustment path (use_ba=True); the default feed-forward path — including demo_epo.py — never imports it. If you cloned without --recursive:
wrapper/any2full_wrapper.py (over third_party/Any2Full) is a different kind of driver and is therefore not in the WRAPPERS registry: it takes an existing COLMAP reconstruction whose depths are sparse — EPO's own export, whose depths.pth only carries depth at the sampled edge pixels — and completes them into dense maps, passing the poses through untouched. See Densifying EPO's depths.
git submodule update --init --recursive
To actually run a model (e.g. via demo_epo.py), also install its dependencies — see the top of each wrapper module for the exact extras and any model-specific gotchas (e.g. vggt_omega's checkpoint is gated on Hugging Face: request access, then pass a local path via --model_path). For VGGT itself, install only these extras — do not run pip install -r third_party/vggt/requirements.txt, as its pinned torch/numpy versions would downgrade and break the EPO environment:
pip install huggingface_hub einops safetensors
We report models results averaged by dataset at mattiadurso.com/epo.
📦 A few demo scenes can be downloaded here. These are already pre-processed with VGGT, thus steps 1 and 2 can be skipped.
Organize your images using the following structure. Images can be grouped by camera if multiple cameras are used:
bicycle/
└── images/
├── 1/ # Images for Camera Group 1
│ ├── _DSC8679.jpg
│ ├── _DSC8680.jpg
│ └── ...
└── 2/ # Images for Camera Group 2 (if more)
├── _DSC9001.jpg
└── ...
Run a 3DFM (e.g., VGGT) on your images and export the reconstruction in COLMAP format and the dense depth maps with the following layout:
bicycle/
└── sparse/
├── cameras.bin # Camera intrinsic parameters
├── images.bin # Camera extrinsics and image registration
├── points3D.bin # Sparse 3D point cloud
└── depths.pth # Dense depth maps: torch.save'd dict
# {image_stem: {"depth": (H, W), "confidence": (H, W) (optional)}}
from epo import EPO
epo = EPO(
reconstruction_path="bicycle/sparse",
images_path="bicycle/images",
depths_path="bicycle/sparse/depths.pth",
backend="triton", # "torch" for the reference path
)
epo(early_stop="pose", gt_path="<path_to_gt>") # gt_path optional — enables evaluation at runtime
epo.to_colmap("out/sparse", save_points=True)
demo_epo.py runs steps 2–3 for you: the selected --model (vggt by default; see the Submodules table for the full list) on a folder of images, then EPO directly on its in-memory output (EPO.from_ff), writing sparse_<model> and sparse_<model>_epo under --output_path:
python demo_epo.py \
--images_path bicycle/images \
--output_path out/demo \
--model vggt \ # or vggt_omega, dvlt, da3, mapanything, pi3x
--gt_path <path_to_gt> # Optional — enables quantitative evaluation at runtime
Pass --model_output <dir> to reuse a previous run's reconstruction + depths.pth instead of re-running the model; both modes produce the same refinement. Pass --model_path <path/or/repo-id> to load the model from a local checkpoint instead of its default Hugging Face download.
depths.pth keys must match the relative image paths without extension (e.g., cam_<j>/image_<i>).cam_<j>/image_<i>.jpg).If you already have a 3DFM's output as tensors in memory, you can skip the COLMAP / depths.pth export and feed EPO directly:
from epo import EPO
# ff_data: dict keyed by "cam_id/image_name"
# Each value is a dict with the per-image tensors (already at images_size).
# Pose is world-to-camera (T_cw), matching COLMAP / PoseModule.
ff_data = {
"cam_<j>/image_<i>.jpg": {
"image": image_tensor, # (3, H, W) float in [0, 1]
"depth": depth_tensor, # (H, W)
"pose": pose_tensor, # (3, 4) or (4, 4), world-to-camera
"intrinsic": intrinsic_tensor, # (3, 3) pinhole
# "confidence": conf_tensor, # (H, W), optional
},
...
}
epo = EPO.from_ff(
ff_data,
backend="triton",
)
epo()
epo.to_colmap("out/sparse", save_points=True)
All other EPO(...) kwargs are forwarded. Images and depths must already be at images_size (no internal resize). With single_camera_per_folder=True (default) all images under the same "cam_id/" folder share one jointly-optimized camera; otherwise each image gets its own. Every wrapper in wrapper/ (see the Submodules table) returns an EPO-ready ff_data built this way from its forward() method (see demo_epo.py).
If you find this work useful, please consider citing:
@inproceedings{durso2026epo,
title = {Boosting 3D Foundation Models with Edge-Based Pose Optimization},
author = {Mattia D'Urso and Christian Sormann and Mattia Rossi and Friedrich Fraundorfer},
booktitle = {European Conference on Computer Vision (ECCV)},
year = {2026},
}
238 commits
Jupyter Notebook
57.6%
Python
42.4%