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
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)