COLMAP

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

Features:

  • COLMAP sparse reconstruction file parsing

  • Camera frustum visualization with viser.SceneApi.add_camera_frustum()

  • 3D point cloud display from structure-from-motion

  • Interactive camera and point visibility controls

Note

This example requires external assets. To download them, run:

git clone -b v1.0.15 https://github.com/nerfstudio-project/viser.git
cd viser/examples
./assets/download_assets.sh
python 04_demos/01_colmap_visualizer.py  # With viser installed.

Source: examples/04_demos/01_colmap_visualizer.py

COLMAP

Code

  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 vtf
 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    reorient_scene: bool = True,
 25) -> None:
 26    server = viser.ViserServer()
 27    server.gui.configure_theme(titlebar_content=None, control_layout="collapsible")
 28
 29    # Load the colmap info.
 30    cameras = read_cameras_binary(colmap_path / "cameras.bin")
 31    images = read_images_binary(colmap_path / "images.bin")
 32    points3d = read_points3d_binary(colmap_path / "points3D.bin")
 33
 34    points = np.array([points3d[p_id].xyz for p_id in points3d])
 35    colors = np.array([points3d[p_id].rgb for p_id in points3d])
 36
 37    gui_reset_up = server.gui.add_button(
 38        "Reset up direction",
 39        hint="Set the camera control 'up' direction to the current camera's 'up'.",
 40    )
 41
 42    # Let's rotate the scene so the average camera direction is pointing up.
 43    if reorient_scene:
 44        average_up = (
 45            # `qvec` corresponds to T_camera_world; we convert to T_world_camera.
 46            vtf.SO3(np.array([img.qvec for img in images.values()])).inverse()
 47            @ np.array([0.0, -1.0, 0.0])  # -y is up in the local frame!
 48        ).mean(axis=0)
 49        average_up /= np.linalg.norm(average_up)
 50        server.scene.set_up_direction((average_up[0], average_up[1], average_up[2]))
 51
 52    @gui_reset_up.on_click
 53    def _(event: viser.GuiEvent) -> None:
 54        client = event.client
 55        assert client is not None
 56        client.camera.up_direction = vtf.SO3(client.camera.wxyz) @ np.array(
 57            [0.0, -1.0, 0.0]
 58        )
 59
 60    gui_points = server.gui.add_slider(
 61        "Max points",
 62        min=1,
 63        max=len(points3d),
 64        step=1,
 65        initial_value=min(len(points3d), 50_000),
 66    )
 67    gui_frames = server.gui.add_slider(
 68        "Max frames",
 69        min=1,
 70        max=len(images),
 71        step=1,
 72        initial_value=min(len(images), 50),
 73    )
 74    gui_point_size = server.gui.add_slider(
 75        "Point size", min=0.01, max=0.1, step=0.001, initial_value=0.02
 76    )
 77
 78    point_mask = np.random.choice(points.shape[0], gui_points.value, replace=False)
 79    point_cloud = server.scene.add_point_cloud(
 80        name="/colmap/pcd",
 81        points=points[point_mask],
 82        colors=colors[point_mask],
 83        point_size=gui_point_size.value,
 84    )
 85    frames: List[viser.FrameHandle] = []
 86
 87    def visualize_frames() -> None:
 88
 89        # Remove existing image frames.
 90        for frame in frames:
 91            frame.remove()
 92        frames.clear()
 93
 94        # Interpret the images and cameras.
 95        img_ids = [im.id for im in images.values()]
 96        random.shuffle(img_ids)
 97        img_ids = sorted(img_ids[: gui_frames.value])
 98
 99        for img_id in tqdm(img_ids):
100            img = images[img_id]
101            cam = cameras[img.camera_id]
102
103            # Skip images that don't exist.
104            image_filename = images_path / img.name
105            if not image_filename.exists():
106                continue
107
108            T_world_camera = vtf.SE3.from_rotation_and_translation(
109                vtf.SO3(img.qvec), img.tvec
110            ).inverse()
111            frame = server.scene.add_frame(
112                f"/colmap/frame_{img_id}",
113                wxyz=T_world_camera.rotation().wxyz,
114                position=T_world_camera.translation(),
115                axes_length=0.1,
116                axes_radius=0.005,
117            )
118            frames.append(frame)
119
120            # For pinhole cameras, cam.params will be (fx, fy, cx, cy).
121            if cam.model != "PINHOLE":
122                print(f"Expected pinhole camera, but got {cam.model}")
123
124            H, W = cam.height, cam.width
125            fy = cam.params[1]
126            image = iio.imread(image_filename)
127            image = image[::downsample_factor, ::downsample_factor]
128            frustum = server.scene.add_camera_frustum(
129                f"/colmap/frame_{img_id}/frustum",
130                fov=2 * np.arctan2(H / 2, fy),
131                aspect=W / H,
132                scale=0.15,
133                image=image,
134            )
135
136            @frustum.on_click
137            def _(_, frame=frame) -> None:
138                for client in server.get_clients().values():
139                    client.camera.wxyz = frame.wxyz
140                    client.camera.position = frame.position
141
142    need_update = True
143
144    @gui_points.on_update
145    def _(_) -> None:
146        point_mask = np.random.choice(points.shape[0], gui_points.value, replace=False)
147        with server.atomic():
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)