COLMAP visualizerΒΆ

Visualize COLMAP sparse reconstruction outputs. To get demo data, see ./assets/download_colmap_garden.sh.

  1import random
  2import time
  3from pathlib import Path
  4from typing import List
  5
  6import imageio.v3 as iio
  7import numpy as np
  8import tyro
  9from tqdm.auto import tqdm
 10
 11import viser
 12import viser.transforms as tf
 13from viser.extras.colmap import (
 14    read_cameras_binary,
 15    read_images_binary,
 16    read_points3d_binary,
 17)
 18
 19
 20def main(
 21    colmap_path: Path = Path(__file__).parent / "assets/colmap_garden/sparse/0",
 22    images_path: Path = Path(__file__).parent / "assets/colmap_garden/images_8",
 23    downsample_factor: int = 2,
 24) -> None:
 25    """Visualize COLMAP sparse reconstruction outputs.
 26
 27    Args:
 28        colmap_path: Path to the COLMAP reconstruction directory.
 29        images_path: Path to the COLMAP images directory.
 30        downsample_factor: Downsample factor for the images.
 31    """
 32    server = viser.ViserServer()
 33    server.gui.configure_theme(titlebar_content=None, control_layout="collapsible")
 34
 35    # Load the colmap info.
 36    cameras = read_cameras_binary(colmap_path / "cameras.bin")
 37    images = read_images_binary(colmap_path / "images.bin")
 38    points3d = read_points3d_binary(colmap_path / "points3D.bin")
 39    gui_reset_up = server.gui.add_button(
 40        "Reset up direction",
 41        hint="Set the camera control 'up' direction to the current camera's 'up'.",
 42    )
 43
 44    @gui_reset_up.on_click
 45    def _(event: viser.GuiEvent) -> None:
 46        client = event.client
 47        assert client is not None
 48        client.camera.up_direction = tf.SO3(client.camera.wxyz) @ np.array(
 49            [0.0, -1.0, 0.0]
 50        )
 51
 52    gui_points = server.gui.add_slider(
 53        "Max points",
 54        min=1,
 55        max=len(points3d),
 56        step=1,
 57        initial_value=min(len(points3d), 50_000),
 58    )
 59    gui_frames = server.gui.add_slider(
 60        "Max frames",
 61        min=1,
 62        max=len(images),
 63        step=1,
 64        initial_value=min(len(images), 100),
 65    )
 66    gui_point_size = server.gui.add_slider(
 67        "Point size", min=0.01, max=0.1, step=0.001, initial_value=0.05
 68    )
 69
 70    points = np.array([points3d[p_id].xyz for p_id in points3d])
 71    colors = np.array([points3d[p_id].rgb for p_id in points3d])
 72
 73    point_mask = np.random.choice(points.shape[0], gui_points.value, replace=False)
 74    point_cloud = server.scene.add_point_cloud(
 75        name="/colmap/pcd",
 76        points=points[point_mask],
 77        colors=colors[point_mask],
 78        point_size=gui_point_size.value,
 79    )
 80    frames: List[viser.FrameHandle] = []
 81
 82    def visualize_frames() -> None:
 83        """Send all COLMAP elements to viser for visualization. This could be optimized
 84        a ton!"""
 85
 86        # Remove existing image frames.
 87        for frame in frames:
 88            frame.remove()
 89        frames.clear()
 90
 91        # Interpret the images and cameras.
 92        img_ids = [im.id for im in images.values()]
 93        random.shuffle(img_ids)
 94        img_ids = sorted(img_ids[: gui_frames.value])
 95
 96        def attach_callback(
 97            frustum: viser.CameraFrustumHandle, frame: viser.FrameHandle
 98        ) -> None:
 99            @frustum.on_click
100            def _(_) -> None:
101                for client in server.get_clients().values():
102                    client.camera.wxyz = frame.wxyz
103                    client.camera.position = frame.position
104
105        for img_id in tqdm(img_ids):
106            img = images[img_id]
107            cam = cameras[img.camera_id]
108
109            # Skip images that don't exist.
110            image_filename = images_path / img.name
111            if not image_filename.exists():
112                continue
113
114            T_world_camera = tf.SE3.from_rotation_and_translation(
115                tf.SO3(img.qvec), img.tvec
116            ).inverse()
117            frame = server.scene.add_frame(
118                f"/colmap/frame_{img_id}",
119                wxyz=T_world_camera.rotation().wxyz,
120                position=T_world_camera.translation(),
121                axes_length=0.1,
122                axes_radius=0.005,
123            )
124            frames.append(frame)
125
126            # For pinhole cameras, cam.params will be (fx, fy, cx, cy).
127            if cam.model != "PINHOLE":
128                print(f"Expected pinhole camera, but got {cam.model}")
129
130            H, W = cam.height, cam.width
131            fy = cam.params[1]
132            image = iio.imread(image_filename)
133            image = image[::downsample_factor, ::downsample_factor]
134            frustum = server.scene.add_camera_frustum(
135                f"/colmap/frame_{img_id}/frustum",
136                fov=2 * np.arctan2(H / 2, fy),
137                aspect=W / H,
138                scale=0.15,
139                image=image,
140            )
141            attach_callback(frustum, frame)
142
143    need_update = True
144
145    @gui_points.on_update
146    def _(_) -> None:
147        point_mask = np.random.choice(points.shape[0], gui_points.value, replace=False)
148        point_cloud.points = points[point_mask]
149        point_cloud.colors = colors[point_mask]
150
151    @gui_frames.on_update
152    def _(_) -> None:
153        nonlocal need_update
154        need_update = True
155
156    @gui_point_size.on_update
157    def _(_) -> None:
158        point_cloud.point_size = gui_point_size.value
159
160    while True:
161        if need_update:
162            need_update = False
163            visualize_frames()
164
165        time.sleep(1e-3)
166
167
168if __name__ == "__main__":
169    tyro.cli(main)