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)