├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── demo.py ├── pyproject.toml ├── src └── colmap_rerun │ ├── __init__.py │ ├── cli │ ├── __init__.py │ └── main.py │ ├── core │ ├── __init__.py │ ├── read_write_model.py │ └── reconstruction.py │ └── visualization │ ├── __init__.py │ └── visualizer.py └── tests ├── __init__.py └── test_visualizer.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Set up Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: '3.x' 17 | 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install build 22 | 23 | - name: Build package 24 | run: python -m build 25 | 26 | - name: Upload artifacts 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: dist 30 | path: dist/ 31 | 32 | - name: Upload to GitHub Release 33 | if: github.event_name == 'release' 34 | uses: softprops/action-gh-release@v2 35 | with: 36 | files: dist/*.whl 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | 41 | pypi-publish: 42 | name: Upload to PyPI 43 | needs: build 44 | runs-on: ubuntu-latest 45 | environment: pypi 46 | permissions: 47 | contents: read 48 | id-token: write 49 | steps: 50 | - name: Download artifacts 51 | uses: actions/download-artifact@v4 52 | with: 53 | name: dist 54 | path: dist 55 | 56 | - name: List dist directory 57 | run: ls -lh dist/ 58 | 59 | - name: Publish to PyPI 60 | uses: pypa/gh-action-pypi-publish@release/v1 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dataset/** 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | **__pycache__** 6 | *.egg-info/ 7 | *.egg 8 | *.whl 9 | build 10 | .DS_Store 11 | .idea/ 12 | dist 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # Standard hooks 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-case-conflict 8 | - id: check-merge-conflict 9 | - id: check-symlinks 10 | - id: check-yaml 11 | - id: debug-statements 12 | - id: end-of-file-fixer 13 | - id: mixed-line-ending 14 | - id: requirements-txt-fixer 15 | - id: trailing-whitespace 16 | 17 | # Python imports sorting 18 | - repo: https://github.com/pycqa/isort 19 | rev: 5.13.2 20 | hooks: 21 | - id: isort 22 | args: [--profile=black, --line-length=100] 23 | 24 | # Python formatting 25 | - repo: https://github.com/psf/black 26 | rev: 24.3.0 27 | hooks: 28 | - id: black 29 | args: [--line-length=100] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 RealcatTech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # COLMAP Rerun Visualizer 2 | 3 | [![PyPI Version](https://img.shields.io/pypi/v/colmap-rerun)](https://pypi.org/project/colmap-rerun/) 4 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 5 | [![PyPI Downloads](https://static.pepy.tech/badge/colmap-rerun)](https://pepy.tech/projects/colmap-rerun) 6 | [![DeepWiki](https://img.shields.io/badge/DeepWiki-colmap_rerun-blue.svg)](https://deepwiki.com/Vincentqyw/colmap-rerun) 7 | 8 | 10 | 11 | Interactive 3D visualization of COLMAP sparse/dense reconstruction output using Rerun's visualization capabilities. 12 | 13 | https://github.com/user-attachments/assets/590b9902-6213-4545-985a-af478ab6d576 14 | 15 | ## Features 16 | 17 | - Interactive 3D visualization of COLMAP reconstructions 18 | - Dataset-specific visualization presets 19 | - Resolution scaling for performance optimization 20 | - Python API and CLI interface 21 | 22 | ## Installation 23 | 24 | ### From PyPI 25 | 26 | ```bash 27 | pip install colmap-rerun 28 | ``` 29 | 30 | ### From Source 31 | 32 | ```bash 33 | git clone https://github.com/vincentqyw/colmap_rerun.git 34 | cd colmap_rerun 35 | pip install -e . 36 | ``` 37 | 38 | For development: 39 | 40 | ```bash 41 | pip install -e ".[dev]" 42 | ``` 43 | 44 | ## Getting Started 45 | 46 | ### Download Example Dataset 47 | 48 | Download sample reconstruction data: 49 | 50 | 1. Get sample data from [Google Drive](https://drive.google.com/drive/folders/1pqhjHtgIESKB_QL8NSaFQdwysFZluLSs?usp=drive_link) 51 | 2. Unzip to get directory structure: 52 | 53 | ```text 54 | sample_data/dense/ 55 | ├── images/ # Input images 56 | ├── sparse/ # COLMAP sparse reconstruction 57 | │ ├── cameras.bin # Camera intrinsics 58 | │ ├── images.bin # Camera poses 59 | │ └── points3D.bin # 3D point cloud 60 | └── stereo/ 61 | └── depth_maps/ # Depth maps (optional) 62 | ``` 63 | 64 | ## Usage 65 | 66 | ### Demo Script (Quick Start) 67 | 68 | The demo script accepts the same arguments as the CLI interface (see below for full options): 69 | 70 | Basic usage: 71 | ```bash 72 | python demo.py --dense_model sample_data/dense 73 | ``` 74 | 75 | Advanced options: 76 | ```bash 77 | python demo.py --dense_model sample_data/dense --resize 640 480 --unfiltered 78 | ``` 79 | 80 | ### Python API 81 | 82 | ```python 83 | from pathlib import Path 84 | from colmap_rerun import visualize_reconstruction 85 | from colmap_rerun.core.read_write_model import read_model 86 | 87 | data_root = Path("sample_data/dense") 88 | cameras, images, points3D = read_model(data_root / "sparse") 89 | 90 | visualize_reconstruction( 91 | cameras=cameras, 92 | images=images, 93 | points3D=points3D, 94 | images_root=Path(data_root / "images"), 95 | depths_root=Path(data_root / "stereo/depth_maps"), # optional 96 | ) 97 | ``` 98 | 99 | ### Command Line Interface 100 | 101 | After installing with `pip install -e .`: 102 | 103 | ```bash 104 | viz-colmap --dense_model sample_data/dense 105 | ``` 106 | 107 | #### CLI Options 108 | 109 | | Argument | Description | Required | 110 | |---------------------|----------------------------------------------|----------| 111 | | `--dense_model` | Path to dense reconstruction folder | No | 112 | | `--sparse_model` | Path to sparse reconstruction folder | Yes* | 113 | | `--images_path` | Path to input images folder | Yes* | 114 | | `--resize W H` | Resize images to width W and height H | No | 115 | | `--unfiltered` | Show unfiltered point cloud (with noise) | No | 116 | 117 | *Required if not using dense_model 118 | 119 | ## Contributing 120 | 121 | We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. 122 | 123 | ## Acknowledgements 124 | 125 | - [Rerun](https://github.com/rerun-io/rerun) team for visualization framework 126 | - Based on [structure_from_motion example](https://github.com/rerun-io/rerun/tree/main/examples/python/structure_from_motion) 127 | - COLMAP team for 3D reconstruction work 128 | 129 | ## License 130 | 131 | MIT - See [LICENSE](LICENSE) for details. 132 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | """Command line interface for visualizing COLMAP reconstructions.""" 2 | 3 | from argparse import ArgumentParser 4 | from pathlib import Path 5 | 6 | from src.colmap_rerun.core.reconstruction import load_sparse_model 7 | from src.colmap_rerun.visualization.visualizer import visualize_reconstruction 8 | 9 | import rerun as rr 10 | 11 | 12 | def main() -> None: 13 | """Main entry point for visualizing COLMAP sparse reconstruction.""" 14 | parser = ArgumentParser(description="Visualize the output of COLMAP's sparse reconstruction on a video.") 15 | parser.add_argument( 16 | "--sparse_model", 17 | help="Spare reconstruction dataset path, e.g., /path/to/dataset/sparse", 18 | type=Path, 19 | required=False, 20 | ) 21 | parser.add_argument( 22 | "--images_path", 23 | help="Path to the folder containing images, e.g., /path/to/dataset/images", 24 | type=Path, 25 | required=False, 26 | ) 27 | parser.add_argument( 28 | "--dense_model", 29 | help="Dense reconstruction dataset path, e.g., /path/to/dataset/dense", 30 | type=Path, 31 | required=False, 32 | ) 33 | parser.add_argument( 34 | "--resize", 35 | nargs=2, 36 | type=int, 37 | help="Target resolution to resize images as width height, e.g., 640 480", 38 | ) 39 | parser.add_argument( 40 | "--unfiltered", 41 | action="store_true", 42 | help="If set, we don't filter away any noisy data.", 43 | ) 44 | rr.script_add_args(parser) 45 | args = parser.parse_args() 46 | rr.script_setup(args, application_id="colmap_rerun") 47 | if args.resize: 48 | args.resize = tuple(args.resize) 49 | 50 | # If a dense model is provided, we use the sparse model from the dense model path. 51 | # This is useful for visualizing the sparse model from a dense reconstruction. 52 | # The spare model is expected to be in the format /path/to/dataset/dense/sparse. 53 | # The images path is expected to be in the format /path/to/dataset/dense/images. 54 | # The depth maps path is expected to be in the format /path/to/dataset/dense/stereo/depth_maps. 55 | if args.dense_model is not None: 56 | args.sparse_model = args.dense_model / "sparse" 57 | args.images_path = args.dense_model / "images" 58 | else: 59 | if args.sparse_model is None: 60 | raise ValueError("Sparse model path is required.") 61 | if args.images_path is None: 62 | raise ValueError("Images path is required.") 63 | if not args.sparse_model.exists(): 64 | raise ValueError(f"Sparse model path {args.sparse_model} does not exist.") 65 | if not args.images_path.exists(): 66 | raise ValueError(f"Images path {args.images_path} does not exist.") 67 | 68 | recon = load_sparse_model( 69 | model_path=args.sparse_model, 70 | images_root=args.images_path, 71 | depths_root=args.dense_model / "stereo" / "depth_maps", 72 | ) 73 | visualize_reconstruction( 74 | recon.cameras, 75 | recon.images, 76 | recon.points3D, 77 | recon.images_root, 78 | recon.depths_root, 79 | filter_output=not args.unfiltered, 80 | resize=args.resize, 81 | ) 82 | rr.script_teardown(args) 83 | 84 | 85 | if __name__ == "__main__": 86 | main() 87 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "colmap-rerun" 3 | version = "1.0.2" 4 | description = "Visualize COLMAP sparse reconstruction output using Rerun" 5 | readme = "README.md" 6 | authors = [ 7 | {name = "Vincentqyw", email = "alpha.vincentqin@gmail.com"}, 8 | ] 9 | license = {text = "MIT"} 10 | requires-python = ">=3.8" 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | ] 16 | dependencies = [ 17 | "opencv-python>=4.6", 18 | "numpy>=1.21", 19 | "rerun-sdk>=0.9", 20 | "tqdm>=4.0", 21 | ] 22 | 23 | [project.scripts] 24 | viz-colmap = "colmap_rerun.cli.main:main" 25 | 26 | [build-system] 27 | requires = ["setuptools", "wheel"] 28 | build-backend = "setuptools.build_meta" 29 | 30 | 31 | [project.optional-dependencies] 32 | test = [ 33 | "pytest>=7.0", 34 | "pytest-cov>=4.0", 35 | ] 36 | 37 | [tool.hatch.build] 38 | include = [ 39 | "src/colmap_rerun", 40 | "README.md", 41 | ] 42 | -------------------------------------------------------------------------------- /src/colmap_rerun/__init__.py: -------------------------------------------------------------------------------- 1 | """Structure from Motion package for visualizing COLMAP reconstructions using Rerun.""" 2 | 3 | __version__ = "0.1.0" 4 | -------------------------------------------------------------------------------- /src/colmap_rerun/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vincentqyw/colmap-rerun/f7e648869e6543994af29571a5564637a1a4e763/src/colmap_rerun/cli/__init__.py -------------------------------------------------------------------------------- /src/colmap_rerun/cli/main.py: -------------------------------------------------------------------------------- 1 | """Command line interface for visualizing COLMAP reconstructions.""" 2 | 3 | from argparse import ArgumentParser 4 | from pathlib import Path 5 | 6 | from ..core.reconstruction import load_sparse_model 7 | from ..visualization.visualizer import visualize_reconstruction 8 | 9 | import rerun as rr 10 | 11 | 12 | def main() -> None: 13 | """Main entry point for visualizing COLMAP sparse reconstruction.""" 14 | parser = ArgumentParser(description="Visualize the output of COLMAP's sparse reconstruction on a video.") 15 | parser.add_argument( 16 | "--sparse_model", 17 | help="Spare reconstruction dataset path, e.g., /path/to/dataset/sparse", 18 | type=Path, 19 | required=False, 20 | ) 21 | parser.add_argument( 22 | "--images_path", 23 | help="Path to the folder containing images, e.g., /path/to/dataset/images", 24 | type=Path, 25 | required=False, 26 | ) 27 | parser.add_argument( 28 | "--dense_model", 29 | help="Dense reconstruction dataset path, e.g., /path/to/dataset/dense", 30 | type=Path, 31 | required=False, 32 | ) 33 | parser.add_argument( 34 | "--resize", 35 | nargs=2, 36 | type=int, 37 | help="Target resolution to resize images as width height, e.g., 640 480", 38 | ) 39 | parser.add_argument( 40 | "--unfiltered", 41 | action="store_true", 42 | help="If set, we don't filter away any noisy data.", 43 | ) 44 | rr.script_add_args(parser) 45 | args = parser.parse_args() 46 | if args.resize: 47 | args.resize = tuple(args.resize) 48 | 49 | # If a dense model is provided, we use the sparse model from the dense model path. 50 | # This is useful for visualizing the sparse model from a dense reconstruction. 51 | # The spare model is expected to be in the format /path/to/dataset/dense/sparse. 52 | # The images path is expected to be in the format /path/to/dataset/dense/images. 53 | # The depth maps path is expected to be in the format /path/to/dataset/dense/stereo/depth_maps. 54 | if args.dense_model is not None: 55 | args.sparse_model = args.dense_model / "sparse" 56 | args.images_path = args.dense_model / "images" 57 | else: 58 | if args.sparse_model is None: 59 | raise ValueError("Sparse model path is required.") 60 | if args.images_path is None: 61 | raise ValueError("Images path is required.") 62 | if not args.sparse_model.exists(): 63 | raise ValueError(f"Sparse model path {args.sparse_model} does not exist.") 64 | if not args.images_path.exists(): 65 | raise ValueError(f"Images path {args.images_path} does not exist.") 66 | 67 | recon = load_sparse_model( 68 | model_path=args.sparse_model, 69 | images_root=args.images_path, 70 | depths_root=args.dense_model / "stereo" / "depth_maps", 71 | ) 72 | visualize_reconstruction( 73 | recon.cameras, 74 | recon.images, 75 | recon.points3D, 76 | recon.images_root, 77 | recon.depths_root, 78 | filter_output=not args.unfiltered, 79 | resize=args.resize, 80 | ) 81 | rr.script_teardown(args) 82 | 83 | 84 | if __name__ == "__main__": 85 | main() 86 | -------------------------------------------------------------------------------- /src/colmap_rerun/core/__init__.py: -------------------------------------------------------------------------------- 1 | """Core data structures and model I/O for COLMAP reconstructions.""" 2 | -------------------------------------------------------------------------------- /src/colmap_rerun/core/read_write_model.py: -------------------------------------------------------------------------------- 1 | # Copyright (c), ETH Zurich and UNC Chapel Hill. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # * Redistributions of source code must retain the above copyright 8 | # notice, this list of conditions and the following disclaimer. 9 | # 10 | # * Redistributions in binary form must reproduce the above copyright 11 | # notice, this list of conditions and the following disclaimer in the 12 | # documentation and/or other materials provided with the distribution. 13 | # 14 | # * Neither the name of ETH Zurich and UNC Chapel Hill nor the names of 15 | # its contributors may be used to endorse or promote products derived 16 | # from this software without specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE 22 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 24 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 26 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 27 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 28 | # POSSIBILITY OF SUCH DAMAGE. 29 | 30 | 31 | import argparse 32 | import collections 33 | import os 34 | import struct 35 | 36 | import numpy as np 37 | 38 | CameraModel = collections.namedtuple("CameraModel", ["model_id", "model_name", "num_params"]) 39 | Camera = collections.namedtuple("Camera", ["id", "model", "width", "height", "params"]) 40 | BaseImage = collections.namedtuple( 41 | "Image", ["id", "qvec", "tvec", "camera_id", "name", "xys", "point3D_ids"] 42 | ) 43 | Point3D = collections.namedtuple( 44 | "Point3D", ["id", "xyz", "rgb", "error", "image_ids", "point2D_idxs"] 45 | ) 46 | 47 | 48 | class Image(BaseImage): 49 | def qvec2rotmat(self): 50 | return qvec2rotmat(self.qvec) 51 | 52 | 53 | CAMERA_MODELS = { 54 | CameraModel(model_id=0, model_name="SIMPLE_PINHOLE", num_params=3), 55 | CameraModel(model_id=1, model_name="PINHOLE", num_params=4), 56 | CameraModel(model_id=2, model_name="SIMPLE_RADIAL", num_params=4), 57 | CameraModel(model_id=3, model_name="RADIAL", num_params=5), 58 | CameraModel(model_id=4, model_name="OPENCV", num_params=8), 59 | CameraModel(model_id=5, model_name="OPENCV_FISHEYE", num_params=8), 60 | CameraModel(model_id=6, model_name="FULL_OPENCV", num_params=12), 61 | CameraModel(model_id=7, model_name="FOV", num_params=5), 62 | CameraModel(model_id=8, model_name="SIMPLE_RADIAL_FISHEYE", num_params=4), 63 | CameraModel(model_id=9, model_name="RADIAL_FISHEYE", num_params=5), 64 | CameraModel(model_id=10, model_name="THIN_PRISM_FISHEYE", num_params=12), 65 | } 66 | CAMERA_MODEL_IDS = dict([(camera_model.model_id, camera_model) for camera_model in CAMERA_MODELS]) 67 | CAMERA_MODEL_NAMES = dict( 68 | [(camera_model.model_name, camera_model) for camera_model in CAMERA_MODELS] 69 | ) 70 | 71 | 72 | def read_next_bytes(fid, num_bytes, format_char_sequence, endian_character="<"): 73 | """Read and unpack the next bytes from a binary file. 74 | :param fid: 75 | :param num_bytes: Sum of combination of {2, 4, 8}, e.g. 2, 6, 16, 30, etc. 76 | :param format_char_sequence: List of {c, e, f, d, h, H, i, I, l, L, q, Q}. 77 | :param endian_character: Any of {@, =, <, >, !} 78 | :return: Tuple of read and unpacked values. 79 | """ 80 | data = fid.read(num_bytes) 81 | return struct.unpack(endian_character + format_char_sequence, data) 82 | 83 | 84 | def write_next_bytes(fid, data, format_char_sequence, endian_character="<"): 85 | """pack and write to a binary file. 86 | :param fid: 87 | :param data: data to send, if multiple elements are sent at the same time, 88 | they should be encapsuled either in a list or a tuple 89 | :param format_char_sequence: List of {c, e, f, d, h, H, i, I, l, L, q, Q}. 90 | should be the same length as the data list or tuple 91 | :param endian_character: Any of {@, =, <, >, !} 92 | """ 93 | if isinstance(data, (list, tuple)): 94 | bytes = struct.pack(endian_character + format_char_sequence, *data) 95 | else: 96 | bytes = struct.pack(endian_character + format_char_sequence, data) 97 | fid.write(bytes) 98 | 99 | 100 | def read_cameras_text(path): 101 | """ 102 | see: src/colmap/scene/reconstruction.cc 103 | void Reconstruction::WriteCamerasText(const std::string& path) 104 | void Reconstruction::ReadCamerasText(const std::string& path) 105 | """ 106 | cameras = {} 107 | with open(path, "r") as fid: 108 | while True: 109 | line = fid.readline() 110 | if not line: 111 | break 112 | line = line.strip() 113 | if len(line) > 0 and line[0] != "#": 114 | elems = line.split() 115 | camera_id = int(elems[0]) 116 | model = elems[1] 117 | width = int(elems[2]) 118 | height = int(elems[3]) 119 | params = np.array(tuple(map(float, elems[4:]))) 120 | cameras[camera_id] = Camera( 121 | id=camera_id, 122 | model=model, 123 | width=width, 124 | height=height, 125 | params=params, 126 | ) 127 | return cameras 128 | 129 | 130 | def read_cameras_binary(path_to_model_file): 131 | """ 132 | see: src/colmap/scene/reconstruction.cc 133 | void Reconstruction::WriteCamerasBinary(const std::string& path) 134 | void Reconstruction::ReadCamerasBinary(const std::string& path) 135 | """ 136 | cameras = {} 137 | with open(path_to_model_file, "rb") as fid: 138 | num_cameras = read_next_bytes(fid, 8, "Q")[0] 139 | for _ in range(num_cameras): 140 | camera_properties = read_next_bytes(fid, num_bytes=24, format_char_sequence="iiQQ") 141 | camera_id = camera_properties[0] 142 | model_id = camera_properties[1] 143 | model_name = CAMERA_MODEL_IDS[camera_properties[1]].model_name 144 | width = camera_properties[2] 145 | height = camera_properties[3] 146 | num_params = CAMERA_MODEL_IDS[model_id].num_params 147 | params = read_next_bytes( 148 | fid, 149 | num_bytes=8 * num_params, 150 | format_char_sequence="d" * num_params, 151 | ) 152 | cameras[camera_id] = Camera( 153 | id=camera_id, 154 | model=model_name, 155 | width=width, 156 | height=height, 157 | params=np.array(params), 158 | ) 159 | assert len(cameras) == num_cameras 160 | return cameras 161 | 162 | 163 | def write_cameras_text(cameras, path): 164 | """ 165 | see: src/colmap/scene/reconstruction.cc 166 | void Reconstruction::WriteCamerasText(const std::string& path) 167 | void Reconstruction::ReadCamerasText(const std::string& path) 168 | """ 169 | HEADER = ( 170 | "# Camera list with one line of data per camera:\n" 171 | + "# CAMERA_ID, MODEL, WIDTH, HEIGHT, PARAMS[]\n" 172 | + "# Number of cameras: {}\n".format(len(cameras)) 173 | ) 174 | with open(path, "w") as fid: 175 | fid.write(HEADER) 176 | for _, cam in cameras.items(): 177 | to_write = [cam.id, cam.model, cam.width, cam.height, *cam.params] 178 | line = " ".join([str(elem) for elem in to_write]) 179 | fid.write(line + "\n") 180 | 181 | 182 | def write_cameras_binary(cameras, path_to_model_file): 183 | """ 184 | see: src/colmap/scene/reconstruction.cc 185 | void Reconstruction::WriteCamerasBinary(const std::string& path) 186 | void Reconstruction::ReadCamerasBinary(const std::string& path) 187 | """ 188 | with open(path_to_model_file, "wb") as fid: 189 | write_next_bytes(fid, len(cameras), "Q") 190 | for _, cam in cameras.items(): 191 | model_id = CAMERA_MODEL_NAMES[cam.model].model_id 192 | camera_properties = [cam.id, model_id, cam.width, cam.height] 193 | write_next_bytes(fid, camera_properties, "iiQQ") 194 | for p in cam.params: 195 | write_next_bytes(fid, float(p), "d") 196 | return cameras 197 | 198 | 199 | def read_images_text(path): 200 | """ 201 | see: src/colmap/scene/reconstruction.cc 202 | void Reconstruction::ReadImagesText(const std::string& path) 203 | void Reconstruction::WriteImagesText(const std::string& path) 204 | """ 205 | images = {} 206 | with open(path, "r") as fid: 207 | while True: 208 | line = fid.readline() 209 | if not line: 210 | break 211 | line = line.strip() 212 | if len(line) > 0 and line[0] != "#": 213 | elems = line.split() 214 | image_id = int(elems[0]) 215 | qvec = np.array(tuple(map(float, elems[1:5]))) 216 | tvec = np.array(tuple(map(float, elems[5:8]))) 217 | camera_id = int(elems[8]) 218 | image_name = elems[9] 219 | elems = fid.readline().split() 220 | xys = np.column_stack( 221 | [ 222 | tuple(map(float, elems[0::3])), 223 | tuple(map(float, elems[1::3])), 224 | ] 225 | ) 226 | point3D_ids = np.array(tuple(map(int, elems[2::3]))) 227 | images[image_id] = Image( 228 | id=image_id, 229 | qvec=qvec, 230 | tvec=tvec, 231 | camera_id=camera_id, 232 | name=image_name, 233 | xys=xys, 234 | point3D_ids=point3D_ids, 235 | ) 236 | return images 237 | 238 | 239 | def read_images_binary(path_to_model_file): 240 | """ 241 | see: src/colmap/scene/reconstruction.cc 242 | void Reconstruction::ReadImagesBinary(const std::string& path) 243 | void Reconstruction::WriteImagesBinary(const std::string& path) 244 | """ 245 | images = {} 246 | with open(path_to_model_file, "rb") as fid: 247 | num_reg_images = read_next_bytes(fid, 8, "Q")[0] 248 | for _ in range(num_reg_images): 249 | binary_image_properties = read_next_bytes( 250 | fid, num_bytes=64, format_char_sequence="idddddddi" 251 | ) 252 | image_id = binary_image_properties[0] 253 | qvec = np.array(binary_image_properties[1:5]) 254 | tvec = np.array(binary_image_properties[5:8]) 255 | camera_id = binary_image_properties[8] 256 | binary_image_name = b"" 257 | current_char = read_next_bytes(fid, 1, "c")[0] 258 | while current_char != b"\x00": # look for the ASCII 0 entry 259 | binary_image_name += current_char 260 | current_char = read_next_bytes(fid, 1, "c")[0] 261 | image_name = binary_image_name.decode("utf-8") 262 | num_points2D = read_next_bytes(fid, num_bytes=8, format_char_sequence="Q")[0] 263 | x_y_id_s = read_next_bytes( 264 | fid, 265 | num_bytes=24 * num_points2D, 266 | format_char_sequence="ddq" * num_points2D, 267 | ) 268 | xys = np.column_stack( 269 | [ 270 | tuple(map(float, x_y_id_s[0::3])), 271 | tuple(map(float, x_y_id_s[1::3])), 272 | ] 273 | ) 274 | point3D_ids = np.array(tuple(map(int, x_y_id_s[2::3]))) 275 | images[image_id] = Image( 276 | id=image_id, 277 | qvec=qvec, 278 | tvec=tvec, 279 | camera_id=camera_id, 280 | name=image_name, 281 | xys=xys, 282 | point3D_ids=point3D_ids, 283 | ) 284 | return images 285 | 286 | 287 | def write_images_text(images, path): 288 | """ 289 | see: src/colmap/scene/reconstruction.cc 290 | void Reconstruction::ReadImagesText(const std::string& path) 291 | void Reconstruction::WriteImagesText(const std::string& path) 292 | """ 293 | if len(images) == 0: 294 | mean_observations = 0 295 | else: 296 | mean_observations = sum((len(img.point3D_ids) for _, img in images.items())) / len(images) 297 | HEADER = ( 298 | "# Image list with two lines of data per image:\n" 299 | + "# IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME\n" 300 | + "# POINTS2D[] as (X, Y, POINT3D_ID)\n" 301 | + "# Number of images: {}, mean observations per image: {}\n".format( 302 | len(images), mean_observations 303 | ) 304 | ) 305 | 306 | with open(path, "w") as fid: 307 | fid.write(HEADER) 308 | for _, img in images.items(): 309 | image_header = [ 310 | img.id, 311 | *img.qvec, 312 | *img.tvec, 313 | img.camera_id, 314 | img.name, 315 | ] 316 | first_line = " ".join(map(str, image_header)) 317 | fid.write(first_line + "\n") 318 | 319 | points_strings = [] 320 | for xy, point3D_id in zip(img.xys, img.point3D_ids): 321 | points_strings.append(" ".join(map(str, [*xy, point3D_id]))) 322 | fid.write(" ".join(points_strings) + "\n") 323 | 324 | 325 | def write_images_binary(images, path_to_model_file): 326 | """ 327 | see: src/colmap/scene/reconstruction.cc 328 | void Reconstruction::ReadImagesBinary(const std::string& path) 329 | void Reconstruction::WriteImagesBinary(const std::string& path) 330 | """ 331 | with open(path_to_model_file, "wb") as fid: 332 | write_next_bytes(fid, len(images), "Q") 333 | for _, img in images.items(): 334 | write_next_bytes(fid, img.id, "i") 335 | write_next_bytes(fid, img.qvec.tolist(), "dddd") 336 | write_next_bytes(fid, img.tvec.tolist(), "ddd") 337 | write_next_bytes(fid, img.camera_id, "i") 338 | for char in img.name: 339 | write_next_bytes(fid, char.encode("utf-8"), "c") 340 | write_next_bytes(fid, b"\x00", "c") 341 | write_next_bytes(fid, len(img.point3D_ids), "Q") 342 | for xy, p3d_id in zip(img.xys, img.point3D_ids): 343 | write_next_bytes(fid, [*xy, p3d_id], "ddq") 344 | 345 | 346 | def read_points3D_text(path): 347 | """ 348 | see: src/colmap/scene/reconstruction.cc 349 | void Reconstruction::ReadPoints3DText(const std::string& path) 350 | void Reconstruction::WritePoints3DText(const std::string& path) 351 | """ 352 | points3D = {} 353 | with open(path, "r") as fid: 354 | while True: 355 | line = fid.readline() 356 | if not line: 357 | break 358 | line = line.strip() 359 | if len(line) > 0 and line[0] != "#": 360 | elems = line.split() 361 | point3D_id = int(elems[0]) 362 | xyz = np.array(tuple(map(float, elems[1:4]))) 363 | rgb = np.array(tuple(map(int, elems[4:7]))) 364 | error = float(elems[7]) 365 | image_ids = np.array(tuple(map(int, elems[8::2]))) 366 | point2D_idxs = np.array(tuple(map(int, elems[9::2]))) 367 | points3D[point3D_id] = Point3D( 368 | id=point3D_id, 369 | xyz=xyz, 370 | rgb=rgb, 371 | error=error, 372 | image_ids=image_ids, 373 | point2D_idxs=point2D_idxs, 374 | ) 375 | return points3D 376 | 377 | 378 | def read_points3D_binary(path_to_model_file): 379 | """ 380 | see: src/colmap/scene/reconstruction.cc 381 | void Reconstruction::ReadPoints3DBinary(const std::string& path) 382 | void Reconstruction::WritePoints3DBinary(const std::string& path) 383 | """ 384 | points3D = {} 385 | with open(path_to_model_file, "rb") as fid: 386 | num_points = read_next_bytes(fid, 8, "Q")[0] 387 | for _ in range(num_points): 388 | binary_point_line_properties = read_next_bytes( 389 | fid, num_bytes=43, format_char_sequence="QdddBBBd" 390 | ) 391 | point3D_id = binary_point_line_properties[0] 392 | xyz = np.array(binary_point_line_properties[1:4]) 393 | rgb = np.array(binary_point_line_properties[4:7]) 394 | error = np.array(binary_point_line_properties[7]) 395 | track_length = read_next_bytes(fid, num_bytes=8, format_char_sequence="Q")[0] 396 | track_elems = read_next_bytes( 397 | fid, 398 | num_bytes=8 * track_length, 399 | format_char_sequence="ii" * track_length, 400 | ) 401 | image_ids = np.array(tuple(map(int, track_elems[0::2]))) 402 | point2D_idxs = np.array(tuple(map(int, track_elems[1::2]))) 403 | points3D[point3D_id] = Point3D( 404 | id=point3D_id, 405 | xyz=xyz, 406 | rgb=rgb, 407 | error=error, 408 | image_ids=image_ids, 409 | point2D_idxs=point2D_idxs, 410 | ) 411 | return points3D 412 | 413 | 414 | def write_points3D_text(points3D, path): 415 | """ 416 | see: src/colmap/scene/reconstruction.cc 417 | void Reconstruction::ReadPoints3DText(const std::string& path) 418 | void Reconstruction::WritePoints3DText(const std::string& path) 419 | """ 420 | if len(points3D) == 0: 421 | mean_track_length = 0 422 | else: 423 | mean_track_length = sum((len(pt.image_ids) for _, pt in points3D.items())) / len(points3D) 424 | HEADER = ( 425 | "# 3D point list with one line of data per point:\n" 426 | + "# POINT3D_ID, X, Y, Z, R, G, B, ERROR, TRACK[] as (IMAGE_ID, POINT2D_IDX)\n" 427 | + "# Number of points: {}, mean track length: {}\n".format(len(points3D), mean_track_length) 428 | ) 429 | 430 | with open(path, "w") as fid: 431 | fid.write(HEADER) 432 | for _, pt in points3D.items(): 433 | point_header = [pt.id, *pt.xyz, *pt.rgb, pt.error] 434 | fid.write(" ".join(map(str, point_header)) + " ") 435 | track_strings = [] 436 | for image_id, point2D in zip(pt.image_ids, pt.point2D_idxs): 437 | track_strings.append(" ".join(map(str, [image_id, point2D]))) 438 | fid.write(" ".join(track_strings) + "\n") 439 | 440 | 441 | def write_points3D_binary(points3D, path_to_model_file): 442 | """ 443 | see: src/colmap/scene/reconstruction.cc 444 | void Reconstruction::ReadPoints3DBinary(const std::string& path) 445 | void Reconstruction::WritePoints3DBinary(const std::string& path) 446 | """ 447 | with open(path_to_model_file, "wb") as fid: 448 | write_next_bytes(fid, len(points3D), "Q") 449 | for _, pt in points3D.items(): 450 | write_next_bytes(fid, pt.id, "Q") 451 | write_next_bytes(fid, pt.xyz.tolist(), "ddd") 452 | write_next_bytes(fid, pt.rgb.tolist(), "BBB") 453 | write_next_bytes(fid, pt.error, "d") 454 | track_length = pt.image_ids.shape[0] 455 | write_next_bytes(fid, track_length, "Q") 456 | for image_id, point2D_id in zip(pt.image_ids, pt.point2D_idxs): 457 | write_next_bytes(fid, [image_id, point2D_id], "ii") 458 | 459 | 460 | def detect_model_format(path, ext): 461 | if ( 462 | os.path.isfile(os.path.join(path, "cameras" + ext)) 463 | and os.path.isfile(os.path.join(path, "images" + ext)) 464 | and os.path.isfile(os.path.join(path, "points3D" + ext)) 465 | ): 466 | print("Detected model format: '" + ext + "'") 467 | return True 468 | 469 | return False 470 | 471 | 472 | def read_model(path, ext=""): 473 | # try to detect the extension automatically 474 | if ext == "": 475 | if detect_model_format(path, ".bin"): 476 | ext = ".bin" 477 | elif detect_model_format(path, ".txt"): 478 | ext = ".txt" 479 | else: 480 | print("Provide model format: '.bin' or '.txt'") 481 | return 482 | 483 | if ext == ".txt": 484 | cameras = read_cameras_text(os.path.join(path, "cameras" + ext)) 485 | images = read_images_text(os.path.join(path, "images" + ext)) 486 | points3D = read_points3D_text(os.path.join(path, "points3D") + ext) 487 | else: 488 | cameras = read_cameras_binary(os.path.join(path, "cameras" + ext)) 489 | images = read_images_binary(os.path.join(path, "images" + ext)) 490 | points3D = read_points3D_binary(os.path.join(path, "points3D") + ext) 491 | return cameras, images, points3D 492 | 493 | 494 | def write_model(cameras, images, points3D, path, ext=".bin"): 495 | if ext == ".txt": 496 | write_cameras_text(cameras, os.path.join(path, "cameras" + ext)) 497 | write_images_text(images, os.path.join(path, "images" + ext)) 498 | write_points3D_text(points3D, os.path.join(path, "points3D") + ext) 499 | else: 500 | write_cameras_binary(cameras, os.path.join(path, "cameras" + ext)) 501 | write_images_binary(images, os.path.join(path, "images" + ext)) 502 | write_points3D_binary(points3D, os.path.join(path, "points3D") + ext) 503 | return cameras, images, points3D 504 | 505 | 506 | def qvec2rotmat(qvec): 507 | return np.array( 508 | [ 509 | [ 510 | 1 - 2 * qvec[2] ** 2 - 2 * qvec[3] ** 2, 511 | 2 * qvec[1] * qvec[2] - 2 * qvec[0] * qvec[3], 512 | 2 * qvec[3] * qvec[1] + 2 * qvec[0] * qvec[2], 513 | ], 514 | [ 515 | 2 * qvec[1] * qvec[2] + 2 * qvec[0] * qvec[3], 516 | 1 - 2 * qvec[1] ** 2 - 2 * qvec[3] ** 2, 517 | 2 * qvec[2] * qvec[3] - 2 * qvec[0] * qvec[1], 518 | ], 519 | [ 520 | 2 * qvec[3] * qvec[1] - 2 * qvec[0] * qvec[2], 521 | 2 * qvec[2] * qvec[3] + 2 * qvec[0] * qvec[1], 522 | 1 - 2 * qvec[1] ** 2 - 2 * qvec[2] ** 2, 523 | ], 524 | ] 525 | ) 526 | 527 | 528 | def rotmat2qvec(R): 529 | Rxx, Ryx, Rzx, Rxy, Ryy, Rzy, Rxz, Ryz, Rzz = R.flat 530 | K = ( 531 | np.array( 532 | [ 533 | [Rxx - Ryy - Rzz, 0, 0, 0], 534 | [Ryx + Rxy, Ryy - Rxx - Rzz, 0, 0], 535 | [Rzx + Rxz, Rzy + Ryz, Rzz - Rxx - Ryy, 0], 536 | [Ryz - Rzy, Rzx - Rxz, Rxy - Ryx, Rxx + Ryy + Rzz], 537 | ] 538 | ) 539 | / 3.0 540 | ) 541 | eigvals, eigvecs = np.linalg.eigh(K) 542 | qvec = eigvecs[[3, 0, 1, 2], np.argmax(eigvals)] 543 | if qvec[0] < 0: 544 | qvec *= -1 545 | return qvec 546 | 547 | 548 | def main(): 549 | parser = argparse.ArgumentParser(description="Read and write COLMAP binary and text models") 550 | parser.add_argument("--input_model", help="path to input model folder") 551 | parser.add_argument( 552 | "--input_format", 553 | choices=[".bin", ".txt"], 554 | help="input model format", 555 | default="", 556 | ) 557 | parser.add_argument("--output_model", help="path to output model folder") 558 | parser.add_argument( 559 | "--output_format", 560 | choices=[".bin", ".txt"], 561 | help="output model format", 562 | default=".txt", 563 | ) 564 | args = parser.parse_args() 565 | 566 | cameras, images, points3D = read_model(path=args.input_model, ext=args.input_format) 567 | 568 | print("num_cameras:", len(cameras)) 569 | print("num_images:", len(images)) 570 | print("num_points3D:", len(points3D)) 571 | 572 | if args.output_model is not None: 573 | write_model( 574 | cameras, 575 | images, 576 | points3D, 577 | path=args.output_model, 578 | ext=args.output_format, 579 | ) 580 | 581 | 582 | def read_array(path): 583 | with open(path, "rb") as fid: 584 | width, height, channels = np.genfromtxt( 585 | fid, delimiter="&", max_rows=1, usecols=(0, 1, 2), dtype=int 586 | ) 587 | fid.seek(0) 588 | num_delimiter = 0 589 | byte = fid.read(1) 590 | while True: 591 | if byte == b"&": 592 | num_delimiter += 1 593 | if num_delimiter >= 3: 594 | break 595 | byte = fid.read(1) 596 | array = np.fromfile(fid, np.float32) 597 | array = array.reshape((width, height, channels), order="F") 598 | return np.transpose(array, (1, 0, 2)).squeeze() 599 | 600 | 601 | if __name__ == "__main__": 602 | main() 603 | -------------------------------------------------------------------------------- /src/colmap_rerun/core/reconstruction.py: -------------------------------------------------------------------------------- 1 | """Data structures for COLMAP reconstructions.""" 2 | 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | from typing import Dict, Optional 6 | 7 | from .read_write_model import Camera, Image, Point3D, read_model 8 | 9 | 10 | @dataclass 11 | class ReconstructionData: 12 | """Container for COLMAP reconstruction data.""" 13 | 14 | cameras: Dict[int, Camera] 15 | images: Dict[int, Image] 16 | points3D: Dict[int, Point3D] 17 | images_root: Optional[Path] = None 18 | depths_root: Optional[Path] = None 19 | 20 | 21 | def load_sparse_model( 22 | model_path: Path, images_root: Path, depths_root: Optional[Path] = None 23 | ) -> ReconstructionData: 24 | """Load COLMAP sparse reconstruction from disk.""" 25 | cameras, images, points3D = read_model(model_path) 26 | return ReconstructionData( 27 | cameras=cameras, 28 | images=images, 29 | points3D=points3D, 30 | images_root=images_root, 31 | depths_root=depths_root, 32 | ) 33 | -------------------------------------------------------------------------------- /src/colmap_rerun/visualization/__init__.py: -------------------------------------------------------------------------------- 1 | """Visualization of COLMAP reconstructions using Rerun.""" 2 | -------------------------------------------------------------------------------- /src/colmap_rerun/visualization/visualizer.py: -------------------------------------------------------------------------------- 1 | """Visualization of COLMAP reconstructions using Rerun.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import re 7 | from pathlib import Path 8 | from typing import Any, Dict, Optional, Tuple 9 | 10 | import cv2 11 | import numpy as np 12 | import numpy.typing as npt 13 | import rerun as rr 14 | import rerun.blueprint as rrb 15 | from tqdm import tqdm 16 | 17 | from ..core.read_write_model import Camera, Image, Point3D, read_array 18 | 19 | FILTER_MIN_VISIBLE: int = 100 # Minimum number of visible points to keep an image 20 | 21 | 22 | def scale_camera(camera: Camera, resize: Tuple[int, int]) -> Tuple[Camera, npt.NDArray[np.float64]]: 23 | """Scale camera intrinsics to match resized image dimensions.""" 24 | assert camera.model == "PINHOLE" 25 | new_width, new_height = resize 26 | scale_factor = np.array([new_width / camera.width, new_height / camera.height]) 27 | 28 | # For PINHOLE camera model, params are: [focal_length_x, focal_length_y, principal_point_x, principal_point_y] 29 | new_params = np.append(camera.params[:2] * scale_factor, camera.params[2:] * scale_factor) 30 | 31 | return ( 32 | Camera(camera.id, camera.model, new_width, new_height, new_params), 33 | scale_factor, 34 | ) 35 | 36 | 37 | def convert_simple_radial_to_pinhole(camera: Camera) -> Camera: 38 | """Convert COLMAP SIMPLE_RADIAL camera model to PINHOLE model.""" 39 | if camera.model != "SIMPLE_RADIAL": 40 | return camera 41 | 42 | assert len(camera.params) == 4 43 | assert camera.width > 0 and camera.height > 0 44 | 45 | # For SIMPLE_RADIAL camera model, params are: [focal_length, principal_point_x, principal_point_y, k1] 46 | new_params = np.array([camera.params[0], camera.params[0], camera.params[1], camera.params[2]]) 47 | 48 | return Camera(camera.id, "PINHOLE", camera.width, camera.height, new_params) 49 | 50 | 51 | def visualize_reconstruction( 52 | cameras: Dict[int, Camera], 53 | images: Dict[int, Image], 54 | points3D: Dict[int, Point3D], 55 | images_root: Path, 56 | depths_root: Optional[Path] = None, 57 | filter_output: bool = True, 58 | resize: Optional[Tuple[int, int]] = None, 59 | depth_range: Optional[Tuple[float, float]] = [0.0, 50.0], 60 | ) -> None: 61 | """Log COLMAP reconstruction to Rerun for visualization.""" 62 | print("Building visualization by logging to Rerun") 63 | 64 | rr.init("colmap_sparse_model", spawn=True) 65 | blueprint = rrb.Blueprint( 66 | rrb.Horizontal( 67 | rrb.Spatial3DView(name="3D", origin="/"), 68 | rrb.Vertical( 69 | rrb.Spatial2DView(name="Camera", origin="/camera/image"), 70 | rrb.Spatial2DView(name="Depth", origin="/camera/depth"), 71 | # rrb.Spatial2DView(name="Depth-photo", origin="/camera/depth-photo"), 72 | rrb.TimeSeriesView(origin="/plot"), 73 | ), 74 | ) 75 | ) 76 | rr.send_blueprint(blueprint) 77 | rr.log("/", rr.ViewCoordinates.RIGHT_HAND_Y_DOWN, static=True) 78 | rr.log("plot/avg_reproj_err", rr.SeriesLines(colors=[240, 45, 58]), static=True) 79 | 80 | if filter_output: 81 | # Filter out noisy points 82 | points3D = { 83 | id: point 84 | for id, point in points3D.items() 85 | if point.rgb.any() and len(point.image_ids) > 4 86 | } 87 | 88 | # Log all 3D points (static, visible at all times) 89 | all_points = [point.xyz for point in points3D.values()] 90 | all_colors = [point.rgb for point in points3D.values()] 91 | rr.log( 92 | "points/all", 93 | rr.Points3D(all_points, colors=all_colors), 94 | static=True, 95 | ) 96 | # Iterate through images (video frames) logging data related to each frame. 97 | for image in tqdm(sorted(images.values(), key=lambda im: im.name)): 98 | image_file = images_root / image.name 99 | 100 | if not os.path.exists(image_file): 101 | continue 102 | 103 | # COLMAP sets image ids that don't match the original video frame 104 | idx_match = re.search(r"\d+", image.name) 105 | assert idx_match is not None 106 | frame_idx = int(idx_match.group(0)) 107 | 108 | quat_xyzw = image.qvec[[1, 2, 3, 0]].astype(np.float32) # COLMAP uses wxyz quaternions 109 | camera = cameras[image.camera_id] 110 | camera = convert_simple_radial_to_pinhole(camera) 111 | if resize: 112 | camera, scale_factor = scale_camera(camera, resize) 113 | else: 114 | scale_factor = np.array([1.0, 1.0]) 115 | 116 | visible = [id != -1 and points3D.get(id) is not None for id in image.point3D_ids] 117 | visible_ids = image.point3D_ids[visible] 118 | 119 | if filter_output and len(visible_ids) < FILTER_MIN_VISIBLE: 120 | continue 121 | 122 | visible_xyzs = [points3D[id] for id in visible_ids] 123 | visible_xys = image.xys[visible] 124 | if resize: 125 | visible_xys *= scale_factor 126 | 127 | rr.set_time("frame", sequence=frame_idx) 128 | 129 | points = [point.xyz for point in visible_xyzs] 130 | point_colors = [[255, 0, 0] for point in visible_xyzs] 131 | point_errors = [point.error for point in visible_xyzs] 132 | 133 | rr.log("plot/avg_reproj_err", rr.Scalars(np.mean(point_errors))) 134 | 135 | rr.log( 136 | "points", 137 | rr.Points3D(points, colors=point_colors, radii=[0.05] * len(points)), 138 | rr.AnyValues(error=point_errors), 139 | ) 140 | 141 | # COLMAP's camera transform is "camera from world" 142 | rr.log( 143 | "camera", 144 | rr.Transform3D( 145 | translation=image.tvec, 146 | quaternion=quat_xyzw, 147 | relation=rr.TransformRelation.ChildFromParent, 148 | ), 149 | ) 150 | rr.log("camera", rr.ViewCoordinates.RDF, static=True) # X=Right, Y=Down, Z=Forward 151 | 152 | # Log camera intrinsics 153 | assert camera.model == "PINHOLE" 154 | rr.log( 155 | "camera/image", 156 | rr.Pinhole( 157 | resolution=[camera.width, camera.height], 158 | focal_length=camera.params[0:2], 159 | principal_point=camera.params[2:4], 160 | ), 161 | ) 162 | 163 | bgr = cv2.imread(str(image_file)) 164 | if depths_root: 165 | depth_path = depths_root / f"{image.name}.geometric.bin" 166 | depth_photo_path = depths_root / f"{image.name}.photometric.bin" 167 | if depth_path.exists(): 168 | depth = read_array(depth_path) 169 | # depth_photo = read_array(depth_photo_path) 170 | if resize: 171 | depth = cv2.resize(depth, resize) 172 | # depth_photo = cv2.resize(depth_photo, resize) 173 | 174 | rr.log( 175 | "camera/depth", 176 | rr.Pinhole( 177 | resolution=[camera.width, camera.height], 178 | focal_length=camera.params[0:2], 179 | principal_point=camera.params[2:4], 180 | ), 181 | ) 182 | rr.log( 183 | "camera/depth", rr.DepthImage(depth, colormap="Turbo", depth_range=depth_range) 184 | ) 185 | # rr.log("camera/depth-photo", rr.DepthImage(depth_photo)) 186 | 187 | if resize: 188 | bgr = cv2.resize(bgr, resize) 189 | 190 | rr.log("camera/image", rr.Image(bgr, color_model="BGR").compress(jpeg_quality=75)) 191 | rr.log("camera/image/keypoints", rr.Points2D(visible_xys, colors=[34, 138, 167])) 192 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for colmap_rerun package.""" 2 | -------------------------------------------------------------------------------- /tests/test_visualizer.py: -------------------------------------------------------------------------------- 1 | """Tests for visualization module.""" 2 | 3 | from colmap_rerun.visualization.visualizer import convert_simple_radial_to_pinhole, scale_camera 4 | 5 | 6 | def test_convert_simple_radial_to_pinhole(): 7 | """Test camera model conversion.""" 8 | from colmap_rerun.core.read_write_model import Camera 9 | 10 | camera = Camera(id=1, model="SIMPLE_RADIAL", width=640, height=480, params=[500, 320, 240, 0.1]) 11 | converted = convert_simple_radial_to_pinhole(camera) 12 | assert converted.model == "PINHOLE" 13 | assert len(converted.params) == 4 14 | assert converted.params[0] == converted.params[1] # fx == fy 15 | 16 | 17 | def test_scale_camera(): 18 | """Test camera scaling.""" 19 | from colmap_rerun.core.read_write_model import Camera 20 | 21 | camera = Camera(id=1, model="PINHOLE", width=640, height=480, params=[500, 500, 320, 240]) 22 | scaled, scale_factor = scale_camera(camera, (320, 240)) 23 | assert scaled.width == 320 24 | assert scaled.height == 240 25 | assert scale_factor[0] == 0.5 26 | assert scale_factor[1] == 0.5 27 | assert scaled.params[0] == 250 # fx scaled 28 | assert scaled.params[2] == 160 # cx scaled 29 | --------------------------------------------------------------------------------