opensfm/align.py (327 lines of code) (raw):

"""Tools to align a reconstruction to GPS and GCP data.""" import logging import math from collections import defaultdict from typing import Dict, Any, List, Tuple, Optional import cv2 import numpy as np from opensfm import multiview, transformations as tf, types, pygeometry, pymap logger = logging.getLogger(__name__) def align_reconstruction( reconstruction: types.Reconstruction, gcp: List[pymap.GroundControlPoint], config: Dict[str, Any], use_gps: bool = True, bias_override: bool = False, ) -> Optional[Tuple[float, np.ndarray, np.ndarray]]: """Align a reconstruction with GPS and GCP data.""" has_scaled_rigs = any( [True for ri in reconstruction.rig_instances.values() if len(ri.shots) > 1] ) use_scale = not has_scaled_rigs if bias_override and config["bundle_compensate_gps_bias"]: return set_gps_bias(reconstruction, config, gcp, use_scale) else: res = compute_reconstruction_similarity( reconstruction, gcp, config, use_gps, use_scale ) if res: s, A, b = res apply_similarity(reconstruction, s, A, b) return res def apply_similarity_pose( pose: pygeometry.Pose, s: float, A: np.ndarray, b: np.ndarray ) -> None: """Apply a similarity (y = s A x + b) to an object having a 'pose' member.""" R = pose.get_rotation_matrix() t = np.array(pose.translation) Rp = R.dot(A.T) tp = -Rp.dot(b) + s * t pose.set_rotation_matrix(Rp) pose.translation = tp def apply_similarity( reconstruction: types.Reconstruction, s: float, A: np.ndarray, b: np.ndarray ) -> None: """Apply a similarity (y = s A x + b) to a reconstruction. :param reconstruction: The reconstruction to transform. :param s: The scale (a scalar) :param A: The rotation matrix (3x3) :param b: The translation vector (3) """ # Align points. for point in reconstruction.points.values(): Xp = s * A.dot(point.coordinates) + b point.coordinates = Xp.tolist() # Align rig instances for rig_instance in reconstruction.rig_instances.values(): apply_similarity_pose(rig_instance.pose, s, A, b) # Scale rig cameras for rig_camera in reconstruction.rig_cameras.values(): apply_similarity_pose(rig_camera.pose, s, np.eye(3), np.array([0, 0, 0])) def compute_reconstruction_similarity( reconstruction: types.Reconstruction, gcp: List[pymap.GroundControlPoint], config: Dict[str, Any], use_gps: bool, use_scale: bool, ) -> Optional[Tuple[float, np.ndarray, np.ndarray]]: """Compute similarity so that the reconstruction is aligned with GPS and GCP data. Config parameter `align_method` can be used to choose the alignment method. Accepted values are - navie: does a direct 3D-3D fit - orientation_prior: assumes a particular camera orientation """ align_method = config["align_method"] if align_method == "auto": align_method = detect_alignment_constraints( config, reconstruction, gcp, use_gps, ) res = None if align_method == "orientation_prior": res = compute_orientation_prior_similarity( reconstruction, config, gcp, use_gps, use_scale ) elif align_method == "naive": res = compute_naive_similarity(config, reconstruction, gcp, use_gps, use_scale) if not res: return None s, A, b = res if (s == 0) or np.isnan(A).any() or np.isnan(b).any(): logger.warning( "Computation of alignment similarity (%s) is degenerate." % align_method ) return None return res def alignment_constraints( config: Dict[str, Any], reconstruction: types.Reconstruction, gcp: List[pymap.GroundControlPoint], use_gps: bool, ) -> Tuple[List[np.ndarray], List[np.ndarray]]: """Gather alignment constraints to be used by checking bundle_use_gcp and bundle_use_gps.""" X, Xp = [], [] # Get Ground Control Point correspondences if gcp and config["bundle_use_gcp"]: triangulated, measured = triangulate_all_gcp(reconstruction, gcp) X.extend(triangulated) Xp.extend(measured) # Get camera center correspondences if use_gps and config["bundle_use_gps"]: for rig_instance in reconstruction.rig_instances.values(): gpses = [ shot.metadata.gps_position.value for shot in rig_instance.shots.values() if shot.metadata.gps_position.has_value ] if len(gpses) > 0: X.append(rig_instance.pose.get_origin()) Xp.append(np.average(gpses, axis=0)) return X, Xp def detect_alignment_constraints( config: Dict[str, Any], reconstruction: types.Reconstruction, gcp: List[pymap.GroundControlPoint], use_gps: bool, ) -> str: """Automatically pick the best alignment method, depending if alignment data such as GPS/GCP is aligned on a single-line or not. """ X, Xp = alignment_constraints(config, reconstruction, gcp, use_gps) if len(X) < 3: return "orientation_prior" X = np.array(X) X = X - np.average(X, axis=0) evalues, _ = np.linalg.eig(X.T.dot(X)) evalues = np.array(sorted(evalues)) ratio_1st_2nd = math.fabs(evalues[2] / evalues[1]) epsilon_abs = 1e-10 epsilon_ratio = 5e3 is_line = sum(evalues < epsilon_abs) > 1 or ratio_1st_2nd > epsilon_ratio if is_line: logger.warning( "Shots and/or GCPs are aligned on a single-line. Using %s prior", config["align_orientation_prior"], ) return "orientation_prior" else: logger.info( "Shots and/or GCPs are well-conditioned. Using naive 3D-3D alignment." ) return "naive" def compute_naive_similarity( config: Dict[str, Any], reconstruction: types.Reconstruction, gcp: List[pymap.GroundControlPoint], use_gps: bool, use_scale: bool, ) -> Optional[Tuple[float, np.ndarray, np.ndarray]]: """Compute similarity with GPS and GCP data using direct 3D-3D matches.""" X, Xp = alignment_constraints(config, reconstruction, gcp, use_gps) if len(X) == 0: return None # Translation-only case, either : # - a single value # - identical values same_values = np.linalg.norm(np.std(Xp, axis=0)) < 1e-10 single_value = len(X) == 1 if single_value: logger.warning("Only 1 constraints. Using translation-only alignment.") if same_values: logger.warning( "GPS/GCP data seems to have identical values. Using translation-only alignment." ) if same_values or single_value: t = np.array(Xp[0]) - np.array(X[0]) return 1.0, np.identity(3), t # Will be up to some unknown rotation if len(X) == 2: logger.warning("Only 2 constraints. Will be up to some unknown rotation.") X.append(X[1]) Xp.append(Xp[1]) # Compute similarity Xp = s A X + b X = np.array(X) Xp = np.array(Xp) T = tf.superimposition_matrix(X.T, Xp.T, scale=use_scale) A, b = T[:3, :3], T[:3, 3] s = np.linalg.det(A) ** (1.0 / 3) A /= s return s, A, b def compute_orientation_prior_similarity( reconstruction: types.Reconstruction, config: Dict[str, Any], gcp: List[pymap.GroundControlPoint], use_gps: bool, use_scale: bool, ) -> Optional[Tuple[float, np.ndarray, np.ndarray]]: """Compute similarity with GPS data assuming particular a camera orientation. In some cases, using 3D-3D matches directly fails to find proper orientation of the world. That happends mainly when all cameras lie close to a straigh line. In such cases, we can impose a particular orientation of the cameras to improve the orientation of the alignment. The config parameter `align_orientation_prior` can be used to specify such orientation. Accepted values are: - no_roll: assumes horizon is horizontal on the images - horizontal: assumes cameras are looking towards the horizon - vertical: assumes cameras are looking down towards the ground """ X, Xp = alignment_constraints(config, reconstruction, gcp, use_gps) X = np.array(X) Xp = np.array(Xp) if len(X) < 1: return None p = estimate_ground_plane(reconstruction, config) if p is None: return None Rplane = multiview.plane_horizontalling_rotation(p) if Rplane is None: return None X = Rplane.dot(X.T).T # Estimate 2d similarity to align to GPS two_shots = len(X) == 2 single_shot = len(X) < 2 same_shots = ( X.std(axis=0).max() < 1e-8 or Xp.std(axis=0).max() < 0.01 # All points are the same. ) # All GPS points are the same. if single_shot or same_shots: s = 1.0 A = Rplane b = Xp.mean(axis=0) - X.mean(axis=0) # Clamp shots pair scale to 1km, so the # optimizer can still catch-up acceptable error max_scale = 1000 current_scale = np.linalg.norm(b) if two_shots and current_scale > max_scale: b = max_scale * b / current_scale s = max_scale / current_scale else: try: T = tf.affine_matrix_from_points( X.T[:2], Xp.T[:2], shear=False, scale=use_scale ) except ValueError: return None s = np.linalg.det(T[:2, :2]) ** 0.5 A = np.eye(3) A[:2, :2] = T[:2, :2] / s A = A.dot(Rplane) b = np.array( [ T[0, 2], T[1, 2], Xp[:, 2].mean() - s * X[:, 2].mean(), # vertical alignment ] ) return s, A, b def set_gps_bias( reconstruction: types.Reconstruction, config: Dict[str, Any], gcp: List[pymap.GroundControlPoint], use_scale: bool, ) -> Optional[Tuple[float, np.ndarray, np.ndarray]]: """Compute and set the bias transform of the GPS coordinate system wrt. to the GCP one.""" # Compute similarity ('gps_bias') that brings the reconstruction on the GCPs ONLY gps_bias = compute_reconstruction_similarity( reconstruction, gcp, config, False, use_scale ) if not gps_bias: logger.warning("Cannot align on GCPs only, GPS bias won't be compensated.") return None # Align the reconstruction on GCPs ONLY s, A, b = gps_bias A_angle_axis = cv2.Rodrigues(A)[0].flatten() logger.info( f"Applying global bias with scale {s:.5f} / translation {b} / rotation {A_angle_axis}" ) apply_similarity(reconstruction, s, A, b) # Compute per camera similarity between the GCP and the shots positions per_camera_shots = defaultdict(list) for s in reconstruction.shots.values(): per_camera_shots[s.camera.id].append(s.id) per_camera_transform = {} for camera_id, shots_id in per_camera_shots.items(): # As we re-use 'compute_reconstruction_similarity', we need to construct a 'Reconstruction' subrec = types.Reconstruction() subrec.add_camera(reconstruction.cameras[camera_id]) for shot_id in shots_id: subrec.add_shot(reconstruction.shots[shot_id]) per_camera_transform[camera_id] = compute_reconstruction_similarity( subrec, [], config, True, use_scale ) if any([True for x in per_camera_transform.values() if not x]): logger.warning("Cannot compensate some shots, GPS bias won't be compensated.") else: for camera_id, transform in per_camera_transform.items(): s, A, b = transform A_angle_axis = cv2.Rodrigues(A)[0].flatten() s, A_angle_axis, b = 1.0 / s, -A_angle_axis, -A.T.dot(b) / s logger.info( f"Camera {camera_id} bias : scale {s:.5f} / translation {b} / rotation {A_angle_axis}" ) camera_bias = pygeometry.Similarity(A_angle_axis, b, s) reconstruction.set_bias(camera_id, camera_bias) return gps_bias def estimate_ground_plane( reconstruction: types.Reconstruction, config: Dict[str, Any] ) -> Optional[np.ndarray]: """Estimate ground plane orientation. It assumes cameras are all at a similar height and uses the align_orientation_prior option to enforce cameras to look horizontally or vertically. """ orientation_type = config["align_orientation_prior"] onplane, verticals = [], [] for shot in reconstruction.shots.values(): R = shot.pose.get_rotation_matrix() x, y, z = get_horizontal_and_vertical_directions( R, shot.metadata.orientation.value ) if orientation_type == "no_roll": onplane.append(x) verticals.append(-y) elif orientation_type == "horizontal": onplane.append(x) onplane.append(z) verticals.append(-y) elif orientation_type == "vertical": onplane.append(x) onplane.append(y) verticals.append(-z) ground_points = [] for shot in reconstruction.shots.values(): ground_points.append(shot.pose.get_origin()) ground_points = np.array(ground_points) ground_points -= ground_points.mean(axis=0) try: plane = multiview.fit_plane( ground_points, np.array(onplane), np.array(verticals) ) except ValueError: return None return plane def get_horizontal_and_vertical_directions( R: np.ndarray, orientation: int ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """Get orientation vectors from camera rotation matrix and orientation tag. Return a 3D vectors pointing to the positive XYZ directions of the image. X points to the right, Y to the bottom, Z to the front. """ # See http://sylvana.net/jpegcrop/exif_orientation.html if orientation == 1: return R[0, :], R[1, :], R[2, :] if orientation == 2: return -R[0, :], R[1, :], -R[2, :] if orientation == 3: return -R[0, :], -R[1, :], R[2, :] if orientation == 4: return R[0, :], -R[1, :], R[2, :] if orientation == 5: return R[1, :], R[0, :], -R[2, :] if orientation == 6: return -R[1, :], R[0, :], R[2, :] if orientation == 7: return -R[1, :], -R[0, :], -R[2, :] if orientation == 8: return R[1, :], -R[0, :], R[2, :] logger.error("unknown orientation {0}. Using 1 instead".format(orientation)) return R[0, :], R[1, :], R[2, :] def triangulate_all_gcp( reconstruction: types.Reconstruction, gcp: List[pymap.GroundControlPoint] ) -> Tuple[List[np.ndarray], List[np.ndarray]]: """Group and triangulate Ground Control Points seen in 2+ images.""" triangulated, measured = [], [] for point in gcp: x = multiview.triangulate_gcp( point, reconstruction.shots, reproj_threshold=0.004, min_ray_angle_degrees=2.0, ) if x is not None: triangulated.append(x) point_enu = reconstruction.reference.to_topocentric(*point.lla_vec) measured.append(point_enu) return triangulated, measured