Spaces:
Running
Running
import argparse | |
import contextlib | |
import io | |
import sys | |
from pathlib import Path | |
from typing import Any, Dict, List, Optional | |
import numpy as np | |
import pycolmap | |
from tqdm import tqdm | |
from . import logger | |
from .utils.database import COLMAPDatabase | |
from .utils.geometry import compute_epipolar_errors | |
from .utils.io import get_keypoints, get_matches | |
from .utils.parsers import parse_retrieval | |
class OutputCapture: | |
def __init__(self, verbose: bool): | |
self.verbose = verbose | |
def __enter__(self): | |
if not self.verbose: | |
self.capture = contextlib.redirect_stdout(io.StringIO()) | |
self.out = self.capture.__enter__() | |
def __exit__(self, exc_type, *args): | |
if not self.verbose: | |
self.capture.__exit__(exc_type, *args) | |
if exc_type is not None: | |
logger.error("Failed with output:\n%s", self.out.getvalue()) | |
sys.stdout.flush() | |
def create_db_from_model( | |
reconstruction: pycolmap.Reconstruction, database_path: Path | |
) -> Dict[str, int]: | |
if database_path.exists(): | |
logger.warning("The database already exists, deleting it.") | |
database_path.unlink() | |
db = COLMAPDatabase.connect(database_path) | |
db.create_tables() | |
for i, camera in reconstruction.cameras.items(): | |
db.add_camera( | |
camera.model.value, | |
camera.width, | |
camera.height, | |
camera.params, | |
camera_id=i, | |
prior_focal_length=True, | |
) | |
for i, image in reconstruction.images.items(): | |
db.add_image(image.name, image.camera_id, image_id=i) | |
db.commit() | |
db.close() | |
return {image.name: i for i, image in reconstruction.images.items()} | |
def import_features( | |
image_ids: Dict[str, int], database_path: Path, features_path: Path | |
): | |
logger.info("Importing features into the database...") | |
db = COLMAPDatabase.connect(database_path) | |
for image_name, image_id in tqdm(image_ids.items()): | |
keypoints = get_keypoints(features_path, image_name) | |
keypoints += 0.5 # COLMAP origin | |
db.add_keypoints(image_id, keypoints) | |
db.commit() | |
db.close() | |
def import_matches( | |
image_ids: Dict[str, int], | |
database_path: Path, | |
pairs_path: Path, | |
matches_path: Path, | |
min_match_score: Optional[float] = None, | |
skip_geometric_verification: bool = False, | |
): | |
logger.info("Importing matches into the database...") | |
with open(str(pairs_path), "r") as f: | |
pairs = [p.split() for p in f.readlines()] | |
db = COLMAPDatabase.connect(database_path) | |
matched = set() | |
for name0, name1 in tqdm(pairs): | |
id0, id1 = image_ids[name0], image_ids[name1] | |
if len({(id0, id1), (id1, id0)} & matched) > 0: | |
continue | |
matches, scores = get_matches(matches_path, name0, name1) | |
if min_match_score: | |
matches = matches[scores > min_match_score] | |
db.add_matches(id0, id1, matches) | |
matched |= {(id0, id1), (id1, id0)} | |
if skip_geometric_verification: | |
db.add_two_view_geometry(id0, id1, matches) | |
db.commit() | |
db.close() | |
def estimation_and_geometric_verification( | |
database_path: Path, pairs_path: Path, verbose: bool = False | |
): | |
logger.info("Performing geometric verification of the matches...") | |
with OutputCapture(verbose): | |
with pycolmap.ostream(): | |
pycolmap.verify_matches( | |
database_path, | |
pairs_path, | |
options=dict( | |
ransac=dict(max_num_trials=20000, min_inlier_ratio=0.1) | |
), | |
) | |
def geometric_verification( | |
image_ids: Dict[str, int], | |
reference: pycolmap.Reconstruction, | |
database_path: Path, | |
features_path: Path, | |
pairs_path: Path, | |
matches_path: Path, | |
max_error: float = 4.0, | |
): | |
logger.info("Performing geometric verification of the matches...") | |
pairs = parse_retrieval(pairs_path) | |
db = COLMAPDatabase.connect(database_path) | |
inlier_ratios = [] | |
matched = set() | |
for name0 in tqdm(pairs): | |
id0 = image_ids[name0] | |
image0 = reference.images[id0] | |
cam0 = reference.cameras[image0.camera_id] | |
kps0, noise0 = get_keypoints( | |
features_path, name0, return_uncertainty=True | |
) | |
noise0 = 1.0 if noise0 is None else noise0 | |
if len(kps0) > 0: | |
kps0 = np.stack(cam0.cam_from_img(kps0)) | |
else: | |
kps0 = np.zeros((0, 2)) | |
for name1 in pairs[name0]: | |
id1 = image_ids[name1] | |
image1 = reference.images[id1] | |
cam1 = reference.cameras[image1.camera_id] | |
kps1, noise1 = get_keypoints( | |
features_path, name1, return_uncertainty=True | |
) | |
noise1 = 1.0 if noise1 is None else noise1 | |
if len(kps1) > 0: | |
kps1 = np.stack(cam1.cam_from_img(kps1)) | |
else: | |
kps1 = np.zeros((0, 2)) | |
matches = get_matches(matches_path, name0, name1)[0] | |
if len({(id0, id1), (id1, id0)} & matched) > 0: | |
continue | |
matched |= {(id0, id1), (id1, id0)} | |
if matches.shape[0] == 0: | |
db.add_two_view_geometry(id0, id1, matches) | |
continue | |
cam1_from_cam0 = ( | |
image1.cam_from_world * image0.cam_from_world.inverse() | |
) | |
errors0, errors1 = compute_epipolar_errors( | |
cam1_from_cam0, kps0[matches[:, 0]], kps1[matches[:, 1]] | |
) | |
valid_matches = np.logical_and( | |
errors0 <= cam0.cam_from_img_threshold(noise0 * max_error), | |
errors1 <= cam1.cam_from_img_threshold(noise1 * max_error), | |
) | |
# TODO: We could also add E to the database, but we need | |
# to reverse the transformations if id0 > id1 in utils/database.py. | |
db.add_two_view_geometry(id0, id1, matches[valid_matches, :]) | |
inlier_ratios.append(np.mean(valid_matches)) | |
logger.info( | |
"mean/med/min/max valid matches %.2f/%.2f/%.2f/%.2f%%.", | |
np.mean(inlier_ratios) * 100, | |
np.median(inlier_ratios) * 100, | |
np.min(inlier_ratios) * 100, | |
np.max(inlier_ratios) * 100, | |
) | |
db.commit() | |
db.close() | |
def run_triangulation( | |
model_path: Path, | |
database_path: Path, | |
image_dir: Path, | |
reference_model: pycolmap.Reconstruction, | |
verbose: bool = False, | |
options: Optional[Dict[str, Any]] = None, | |
) -> pycolmap.Reconstruction: | |
model_path.mkdir(parents=True, exist_ok=True) | |
logger.info("Running 3D triangulation...") | |
if options is None: | |
options = {} | |
with OutputCapture(verbose): | |
with pycolmap.ostream(): | |
reconstruction = pycolmap.triangulate_points( | |
reference_model, | |
database_path, | |
image_dir, | |
model_path, | |
options=options, | |
) | |
return reconstruction | |
def main( | |
sfm_dir: Path, | |
reference_model: Path, | |
image_dir: Path, | |
pairs: Path, | |
features: Path, | |
matches: Path, | |
skip_geometric_verification: bool = False, | |
estimate_two_view_geometries: bool = False, | |
min_match_score: Optional[float] = None, | |
verbose: bool = False, | |
mapper_options: Optional[Dict[str, Any]] = None, | |
) -> pycolmap.Reconstruction: | |
assert reference_model.exists(), reference_model | |
assert features.exists(), features | |
assert pairs.exists(), pairs | |
assert matches.exists(), matches | |
sfm_dir.mkdir(parents=True, exist_ok=True) | |
database = sfm_dir / "database.db" | |
reference = pycolmap.Reconstruction(reference_model) | |
image_ids = create_db_from_model(reference, database) | |
import_features(image_ids, database, features) | |
import_matches( | |
image_ids, | |
database, | |
pairs, | |
matches, | |
min_match_score, | |
skip_geometric_verification, | |
) | |
if not skip_geometric_verification: | |
if estimate_two_view_geometries: | |
estimation_and_geometric_verification(database, pairs, verbose) | |
else: | |
geometric_verification( | |
image_ids, reference, database, features, pairs, matches | |
) | |
reconstruction = run_triangulation( | |
sfm_dir, database, image_dir, reference, verbose, mapper_options | |
) | |
logger.info( | |
"Finished the triangulation with statistics:\n%s", | |
reconstruction.summary(), | |
) | |
return reconstruction | |
def parse_option_args(args: List[str], default_options) -> Dict[str, Any]: | |
options = {} | |
for arg in args: | |
idx = arg.find("=") | |
if idx == -1: | |
raise ValueError("Options format: key1=value1 key2=value2 etc.") | |
key, value = arg[:idx], arg[idx + 1 :] | |
if not hasattr(default_options, key): | |
raise ValueError( | |
f'Unknown option "{key}", allowed options and default values' | |
f" for {default_options.summary()}" | |
) | |
value = eval(value) | |
target_type = type(getattr(default_options, key)) | |
if not isinstance(value, target_type): | |
raise ValueError( | |
f'Incorrect type for option "{key}":' | |
f" {type(value)} vs {target_type}" | |
) | |
options[key] = value | |
return options | |
if __name__ == "__main__": | |
parser = argparse.ArgumentParser() | |
parser.add_argument("--sfm_dir", type=Path, required=True) | |
parser.add_argument("--reference_sfm_model", type=Path, required=True) | |
parser.add_argument("--image_dir", type=Path, required=True) | |
parser.add_argument("--pairs", type=Path, required=True) | |
parser.add_argument("--features", type=Path, required=True) | |
parser.add_argument("--matches", type=Path, required=True) | |
parser.add_argument("--skip_geometric_verification", action="store_true") | |
parser.add_argument("--min_match_score", type=float) | |
parser.add_argument("--verbose", action="store_true") | |
args = parser.parse_args().__dict__ | |
mapper_options = parse_option_args( | |
args.pop("mapper_options"), pycolmap.IncrementalMapperOptions() | |
) | |
main(**args, mapper_options=mapper_options) | |