|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +# Snippets to read from the colmap database taken from: |
| 4 | +# https://github.com/colmap/colmap/blob/ad7bd93f1a27af7533121aa043a167fe1490688c / |
| 5 | +# scripts/python/export_to_bundler.py |
| 6 | +# scripts/python/read_write_model.py |
| 7 | +# License is that derived from those files. |
| 8 | + |
| 9 | +from __future__ import absolute_import |
| 10 | +from __future__ import division |
| 11 | +from __future__ import print_function |
| 12 | +from __future__ import unicode_literals |
| 13 | + |
| 14 | +import argparse |
| 15 | +import math |
| 16 | +import os |
| 17 | +import sqlite3 |
| 18 | +from pathlib import Path |
| 19 | + |
| 20 | +import networkx |
| 21 | +import numpy as np |
| 22 | + |
| 23 | +from opensfm import dataset |
| 24 | +from opensfm import features |
| 25 | +from opensfm import io |
| 26 | +from opensfm import types |
| 27 | + |
| 28 | +EXPORT_DIR_NAME = 'opensfm_export' |
| 29 | + |
| 30 | + |
| 31 | +def import_cameras_images(db, data): |
| 32 | + cursor = db.cursor() |
| 33 | + cursor.execute("SELECT camera_id, model, width, height, prior_focal_length, params FROM " |
| 34 | + "cameras;") |
| 35 | + cameras = {} |
| 36 | + for row in cursor: |
| 37 | + camera_id, camera_model_id, width, height, prior_focal, params = row |
| 38 | + params = np.fromstring(params, dtype=np.double) |
| 39 | + cam = cam_from_colmap_params(camera_model_id, width, height, params, prior_focal) |
| 40 | + cam.id = str(camera_id) |
| 41 | + cameras[camera_id] = cam |
| 42 | + |
| 43 | + data.save_camera_models(cameras) |
| 44 | + |
| 45 | + images_map = {} |
| 46 | + cursor.execute("SELECT image_id, camera_id, name FROM images;") |
| 47 | + for row in cursor: |
| 48 | + image_id, camera_id, filename = int(row[0]), int(row[1]), row[2] |
| 49 | + images_map[image_id] = (filename, camera_id) |
| 50 | + cam = cameras[camera_id] |
| 51 | + focal_ratio = cam.focal_x if isinstance(cam, types.BrownPerspectiveCamera) else cam.focal |
| 52 | + exif_data = { |
| 53 | + "make": "unknown", |
| 54 | + "model": "unknown", |
| 55 | + "width": cam.width, |
| 56 | + "height": cam.height, |
| 57 | + "projection_type": cam.projection_type, |
| 58 | + "focal_ratio": focal_ratio, |
| 59 | + "orientation": 1, |
| 60 | + "camera": "{}".format(camera_id), |
| 61 | + "skey": "TheSequence", |
| 62 | + "capture_time": 0.0, |
| 63 | + "gps": {}, |
| 64 | + } |
| 65 | + data.save_exif(filename, exif_data) |
| 66 | + |
| 67 | + cursor.close() |
| 68 | + return cameras, images_map |
| 69 | + |
| 70 | + |
| 71 | +def pair_id_to_image_ids(pair_id): |
| 72 | + image_id2 = pair_id % 2147483647 |
| 73 | + image_id1 = (pair_id - image_id2) // 2147483647 |
| 74 | + return image_id1, image_id2 |
| 75 | + |
| 76 | + |
| 77 | +def get_scale_orientation_from_affine(arr): |
| 78 | + # (x, y, a_11, a_12, a_21, a_22) |
| 79 | + a11 = arr[:, 2] |
| 80 | + a12 = arr[:, 3] |
| 81 | + a21 = arr[:, 4] |
| 82 | + a22 = arr[:, 5] |
| 83 | + scale_x = np.sqrt(a11 * a11 + a21 * a21) |
| 84 | + scale_y = np.sqrt(a12 * a12 + a22 * a22) |
| 85 | + orientation = np.arctan2(a21, a11) |
| 86 | + # shear = np.arctan2(-a12, a22) - orientation |
| 87 | + scale = (scale_x + scale_y) / 2 |
| 88 | + return scale, orientation |
| 89 | + |
| 90 | + |
| 91 | +def import_features(db, data, image_map, camera_map): |
| 92 | + cursor = db.cursor() |
| 93 | + cursor.execute("SELECT image_id, rows, cols, data FROM keypoints;") |
| 94 | + keypoints = {} |
| 95 | + colors = {} |
| 96 | + for row in cursor: |
| 97 | + image_id, n_rows, n_cols, arr = row |
| 98 | + filename, camera_id = image_map[image_id] |
| 99 | + cam = camera_map[camera_id] |
| 100 | + |
| 101 | + arr = np.fromstring(arr, dtype=np.float32).reshape((n_rows, n_cols)) |
| 102 | + |
| 103 | + rgb = data.load_image(filename).astype(np.float32) |
| 104 | + xc = np.clip(arr[:, 1].astype(int), 0, rgb.shape[0] - 1) |
| 105 | + yc = np.clip(arr[:, 0].astype(int), 0, rgb.shape[1] - 1) |
| 106 | + colors[image_id] = rgb[xc, yc, :] |
| 107 | + |
| 108 | + arr[:, :2] = features.normalized_image_coordinates(arr[:, :2], cam.width, cam.height) |
| 109 | + if n_cols == 4: |
| 110 | + x, y, s, o = arr[:, 0], arr[:, 1], arr[:, 2], arr[:, 3] |
| 111 | + elif n_cols == 6: |
| 112 | + x, y = arr[:, 0], arr[:, 1] |
| 113 | + s, o = get_scale_orientation_from_affine(arr) |
| 114 | + elif n_cols == 2: |
| 115 | + x, y = arr[:, 0], arr[:, 1] |
| 116 | + s = np.zeros_like(x) |
| 117 | + o = np.zeros_like(x) |
| 118 | + else: |
| 119 | + raise ValueError |
| 120 | + s = s / max(cam.width, cam.height) |
| 121 | + keypoints[image_id] = np.vstack((x, y, s, o)).T |
| 122 | + |
| 123 | + cursor.execute("SELECT image_id, rows, cols, data FROM descriptors;") |
| 124 | + for row in cursor: |
| 125 | + image_id, n_rows, n_cols, arr = row |
| 126 | + filename, _ = image_map[image_id] |
| 127 | + descriptors = np.fromstring(arr, dtype=np.uint8).reshape((n_rows, n_cols)) |
| 128 | + kp = keypoints[image_id] |
| 129 | + data.save_features(filename, kp, descriptors, colors[image_id]) |
| 130 | + |
| 131 | + cursor.close() |
| 132 | + return keypoints |
| 133 | + |
| 134 | + |
| 135 | +def import_matches(db, data, image_map): |
| 136 | + cursor = db.cursor() |
| 137 | + min_matches = 1 |
| 138 | + cursor.execute("SELECT pair_id, data FROM two_view_geometries WHERE rows>=?;", (min_matches,)) |
| 139 | + |
| 140 | + matches_per_im1 = {m[0]: {} for m in image_map.values()} |
| 141 | + |
| 142 | + for row in cursor: |
| 143 | + pair_id = row[0] |
| 144 | + inlier_matches = np.fromstring(row[1], dtype=np.uint32).reshape(-1, 2) |
| 145 | + image_id1, image_id2 = pair_id_to_image_ids(pair_id) |
| 146 | + image_name1 = image_map[image_id1][0] |
| 147 | + image_name2 = image_map[image_id2][0] |
| 148 | + matches_per_im1[image_name1][image_name2] = inlier_matches |
| 149 | + |
| 150 | + for image_name1, matches in matches_per_im1.items(): |
| 151 | + data.save_matches(image_name1, matches) |
| 152 | + |
| 153 | + cursor.close() |
| 154 | + |
| 155 | + |
| 156 | +def import_cameras_reconstruction(path_cameras): |
| 157 | + """ |
| 158 | + Imports cameras from a COLMAP reconstruction text file |
| 159 | + """ |
| 160 | + r_cams = {} |
| 161 | + mapping = {'FULL_OPENCV': 6, 'RADIAL': 3, 'RADIAL_FISHEYE': 9} |
| 162 | + with io.open_rt(path_cameras) as fin: |
| 163 | + for row in fin: |
| 164 | + if row[0] == '#': |
| 165 | + continue |
| 166 | + row = row[:-1].split(' ') |
| 167 | + camera_id = row[0] |
| 168 | + camera_model = row[1] |
| 169 | + width = int(row[2]) |
| 170 | + height = int(row[3]) |
| 171 | + params = [float(p) for p in row[4:]] |
| 172 | + camera_model_id = mapping[camera_model] |
| 173 | + cam = cam_from_colmap_params(camera_model_id, width, height, params) |
| 174 | + cam.id = camera_id |
| 175 | + r_cams[camera_id] = cam |
| 176 | + return r_cams |
| 177 | + |
| 178 | + |
| 179 | +def cam_from_colmap_params(camera_model_id, width, height, params, prior_focal=1): |
| 180 | + """ |
| 181 | + Helper function to map from colmap parameters to an OpenSfM camera |
| 182 | + """ |
| 183 | + mapping = {3: 'perspective', 6: 'brown', 9: 'fisheye'} |
| 184 | + projection_type = mapping[camera_model_id] |
| 185 | + normalizer = max(width, height) |
| 186 | + if projection_type == 'perspective': |
| 187 | + cam = types.PerspectiveCamera() |
| 188 | + cam.focal = params[0] / normalizer if prior_focal else 0.85 |
| 189 | + cam.k1 = params[3] |
| 190 | + cam.k2 = params[4] |
| 191 | + elif projection_type == 'brown': |
| 192 | + cam = types.BrownPerspectiveCamera() |
| 193 | + cam.focal_x = params[0] / normalizer if prior_focal else 0.85 |
| 194 | + cam.focal_y = params[1] / normalizer if prior_focal else 0.85 |
| 195 | + cam.c_x = (params[2] - (width - 1) * 0.5) / normalizer |
| 196 | + cam.c_y = (params[3] - (height - 1) * 0.5) / normalizer |
| 197 | + cam.k1, cam.k2, cam.p1, cam.p2, cam.k3 = params[4:9] |
| 198 | + else: # projection_type == 'fisheye' |
| 199 | + cam = types.FisheyeCamera() |
| 200 | + cam.focal = params[0] / normalizer if prior_focal else 0.85 |
| 201 | + cam.k1 = params[3] |
| 202 | + cam.width = width |
| 203 | + cam.height = height |
| 204 | + return cam |
| 205 | + |
| 206 | + |
| 207 | +def import_shots_reconstruction(path_shots, camera_map, keypoints, points3D): |
| 208 | + """ |
| 209 | + Reads the points.txt from colmap, which contains two lines of data per image: |
| 210 | + # IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME |
| 211 | + # POINTS2D[] as (X, Y, POINT3D_ID) |
| 212 | +
|
| 213 | + With this, we build the OpenSfM shots and a partial tracks graph. |
| 214 | + """ |
| 215 | + tracks_graph = networkx.Graph() |
| 216 | + shots = {} |
| 217 | + with io.open_rt(path_shots) as fin: |
| 218 | + while True: |
| 219 | + row = fin.readline().strip() |
| 220 | + if not row: # None or len==0 |
| 221 | + break |
| 222 | + if row[0] == '#': |
| 223 | + continue |
| 224 | + |
| 225 | + row = row.split(' ') |
| 226 | + colmap_shot_id = int(row[0]) |
| 227 | + q = np.array(tuple(map(float, row[1:5]))) |
| 228 | + t = np.array(tuple(map(float, row[5:8]))) |
| 229 | + colmap_camera_id = row[8] |
| 230 | + image = row[9] # filename / key |
| 231 | + |
| 232 | + shot = types.Shot() |
| 233 | + shot.pose = types.Pose(rotation=quaternion_to_angle_axis(q), translation=t) |
| 234 | + shot.camera = camera_map[colmap_camera_id] |
| 235 | + shot.id = image |
| 236 | + shots[shot.id] = shot |
| 237 | + |
| 238 | + row = fin.readline().strip().split(' ') |
| 239 | + xys = np.column_stack([tuple(map(float, row[0::3])), tuple(map(float, row[1::3]))]) |
| 240 | + xys = features.normalized_image_coordinates(xys, |
| 241 | + shot.camera.width, |
| 242 | + shot.camera.height) |
| 243 | + point3D_ids = np.array(tuple(map(int, row[2::3]))) |
| 244 | + |
| 245 | + point2d_idx = 0 |
| 246 | + for point3D_id, xy in zip(point3D_ids, xys): |
| 247 | + if point3D_id == -1: |
| 248 | + continue |
| 249 | + kp = keypoints[colmap_shot_id][point2d_idx] |
| 250 | + s = kp[2] |
| 251 | + tracks_graph.add_node(str(image), bipartite=0) |
| 252 | + tracks_graph.add_node(str(point3D_id), bipartite=1) |
| 253 | + tracks_graph.add_edge(str(image), |
| 254 | + str(point3D_id), |
| 255 | + feature=(float(xy[0]), float(xy[1])), |
| 256 | + feature_id=point2d_idx, |
| 257 | + feature_scale=float(s), |
| 258 | + feature_color=points3D[point3D_id].color) |
| 259 | + point2d_idx += 1 |
| 260 | + |
| 261 | + return shots, tracks_graph |
| 262 | + |
| 263 | + |
| 264 | +def import_points_reconstruction(path_points): |
| 265 | + points3d = {} |
| 266 | + |
| 267 | + with io.open_rt(path_points) as fin: |
| 268 | + for row in fin: |
| 269 | + if row[0] == '#': |
| 270 | + continue |
| 271 | + row = row[:-1].split(' ') |
| 272 | + p = types.Point() |
| 273 | + p.id = int(row[0]) |
| 274 | + p.coordinates = tuple(map(float, row[1:4])) |
| 275 | + p.color = tuple(map(int, row[4:7])) |
| 276 | + points3d[p.id] = p |
| 277 | + return points3d |
| 278 | + |
| 279 | + |
| 280 | +def quaternion_to_angle_axis(quaternion): |
| 281 | + if quaternion[0] > 1: |
| 282 | + quaternion = quaternion / np.linalg.norm(quaternion) |
| 283 | + qw, qx, qy, qz = quaternion |
| 284 | + s = max(0.001, math.sqrt(1 - qw * qw)) |
| 285 | + x = qx / s |
| 286 | + y = qy / s |
| 287 | + z = qz / s |
| 288 | + angle = 2 * math.acos(qw) |
| 289 | + return [angle * x, angle * y, angle * z] |
| 290 | + |
| 291 | + |
| 292 | +if __name__ == "__main__": |
| 293 | + parser = argparse.ArgumentParser( |
| 294 | + description='Convert COLMAP database to OpenSfM dataset') |
| 295 | + parser.add_argument('database', help='path to the database to be processed') |
| 296 | + parser.add_argument('images', help='path to the images') |
| 297 | + args = parser.parse_args() |
| 298 | + |
| 299 | + p_db = Path(args.database) |
| 300 | + export_folder = p_db.parent / EXPORT_DIR_NAME |
| 301 | + export_folder.mkdir(exist_ok=True) |
| 302 | + images_path = export_folder / 'images' |
| 303 | + if not images_path.exists(): |
| 304 | + os.symlink(args.images, images_path, target_is_directory=True) |
| 305 | + |
| 306 | + data = dataset.DataSet(export_folder) |
| 307 | + db = sqlite3.connect(p_db.as_posix()) |
| 308 | + camera_map, image_map = import_cameras_images(db, data) |
| 309 | + keypoints = import_features(db, data, image_map, camera_map) |
| 310 | + import_matches(db, data, image_map) |
| 311 | + |
| 312 | + rec_cameras = p_db.parent / 'cameras.txt' |
| 313 | + rec_points = p_db.parent / 'points3D.txt' |
| 314 | + rec_images = p_db.parent / 'images.txt' |
| 315 | + if rec_cameras.exists() and rec_images.exists() and rec_points.exists(): |
| 316 | + cameras = import_cameras_reconstruction(rec_cameras) |
| 317 | + points3D = import_points_reconstruction(rec_points) |
| 318 | + shots, tracks_graph = import_shots_reconstruction(rec_images, cameras, keypoints, points3D) |
| 319 | + data.save_tracks_graph(tracks_graph) |
| 320 | + |
| 321 | + reconstruction = types.Reconstruction() |
| 322 | + reconstruction.cameras = cameras |
| 323 | + reconstruction.shots = shots |
| 324 | + reconstruction.points = points3D |
| 325 | + data.save_reconstruction([reconstruction]) |
| 326 | + else: |
| 327 | + print("Didn't find reconstruction files in text format") |
| 328 | + |
| 329 | + db.close() |
0 commit comments