diff --git a/README.md b/README.md index bbaa535..010af8c 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,7 @@ python -m vidmap.datasets.prepare --dataset "$DATASET" ``` This downloads and validates the supported evaluation inventory, then converts -it into the canonical `images/`, `rec/`, and testset layout. LaMAR intentionally +it into the dataset layouts below. LaMAR intentionally prepares only the images referenced by its benchmark testset: sessions and images absent from that testset are not retained. Downloads resume after interruption and completed sequences are reused. Validated source archives are @@ -384,7 +384,7 @@ Download sizes are the checksum-pinned source archives; prepared sizes cover the canonical dataset after conversion. LaMAR archives are session-level, so the download includes unused frames inside each required session ZIP; only the 94,489 testset images are retained. Retaining downloads requires roughly the sum -of both columns. +of both columns, plus conversion staging. After preparation, the selected dataset is available under `./local/datasets//`, with its benchmark definitions under @@ -392,40 +392,57 @@ After preparation, the selected dataset is available under ```text local/ -├── datasets/ -│ ├── lamar/ -│ │ └── scene: / -│ │ ├── images/ -│ │ │ └── cam0//.jpg # e.g. ios_2022-06-30_15.55.53/130156492755.jpg -│ │ ├── rec/ -│ │ └── preparation.json -│ ├── euroc/ -│ │ └── scene: / -│ │ ├── images/ -│ │ │ └── cam0/.png # e.g. 1403636580863555584.png -│ │ ├── rec/ -│ │ └── preparation.json -│ ├── eth3d_slam/ -│ │ └── scene: / -│ │ ├── images/ -│ │ │ └── cam0/. # e.g. 5865.976533.png -│ │ ├── rec/ -│ │ ├── unposed_images.json -│ │ └── preparation.json -│ └── crocodl/ # Unlike LaMAR, CroCoDL has GT per session, not per scene. -│ └── / # e.g. ARCHE_D2 -│ └── / # e.g. ios_2023-07-12_20.46.14_000 -│ ├── images/ -│ │ └── cam0/.jpg # e.g. 4971459477.jpg -│ ├── rec/ -│ └── metadata.json -└── testsets/ - ├── lamar//.yaml # e.g. HGE/sample.yaml - ├── euroc//all.yaml # e.g. machine_hall-MH_01_easy/all.yaml - ├── eth3d_slam//all.yaml # e.g. plant_1/all.yaml - └── crocodl//.yaml # e.g. ios-ARCHE_D2/sample-100.yaml +├── datasets//// +│ ├── preparation.json +│ └── / +│ ├── images/cam0/... # cam1/, cam2/, ... when available +│ └── rec/ # Matching calibration and reference poses +└── testsets///.yaml ``` +| Dataset | Variant(s) | +| --- | --- | +| LaMAR, ETH3D-SLAM, CroCoDL | `native` | +| EuRoC | `rectified` | + +
+[Examples for each dataset - click to expand] + +```text +local/datasets/ +├── lamar/CAB/ios_2021-06-02_14.21.49_000/ +│ ├── preparation.json +│ └── native/ +│ ├── images/cam0/... +│ └── rec/ +├── euroc/machine_hall/MH_01_easy/ +│ ├── preparation.json +│ └── rectified/ +│ ├── images/cam0/... +│ └── rec/ +├── eth3d_slam/plant_1/0/ +│ ├── preparation.json +│ └── native/ +│ ├── images/cam0/... +│ └── rec/ +└── crocodl/ios-ARCHE_D2/ios_2023-07-12_20.46.14_000/ + ├── preparation.json + └── native/ + ├── images/cam0/... + └── rec/ + +local/testsets/ +├── lamar/CAB/sample.yaml +├── euroc/machine_hall/all.yaml +├── eth3d_slam/plant_1/all.yaml +└── crocodl/ios-ARCHE_D2/complete.yaml # Also sample-*.yaml +``` + +
+ +Each `rec/` contains calibration and camera poses, without 3D points. +Select a benchmark with `--mode`; its testset specifies the recording, image variant and image IDs. + ### Run and evaluate diff --git a/docs/cameras.md b/docs/cameras.md index 55e41d1..4d315ec 100644 --- a/docs/cameras.md +++ b/docs/cameras.md @@ -89,14 +89,15 @@ In this example: ## Panoramas -Put your 360° equirectangular images in `/data/images/cam0/`. +Put your 360° equirectangular images in `/data/images/cam0/`, or pass a stitched +MP4/MOV panorama as `--input_data`. Save `vidmap/configs/frontend/my_panorama.yaml`: ```yaml defaults: [calib/rig] rig: projection: - image_size: [840, 840] # width, height + aspect_ratio: [1, 1] # width : height horizontal_fov_degrees: 120.0 views: 0: {yaw_degrees: 0.0, pitch_degrees: 0.0} @@ -104,7 +105,9 @@ rig: 2: {yaw_degrees: 240.0, pitch_degrees: 0.0} ``` -**Full cube:** keep square images and replace the FOV and views inside `projection`: +View resolution follows the tracking settings (`tracks.images`). + +**Full cube:** keep `[1, 1]` and replace the FOV and views inside `projection`: ```yaml horizontal_fov_degrees: 90.0 diff --git a/docs/files.md b/docs/files.md index 0c502c0..29c2a45 100644 --- a/docs/files.md +++ b/docs/files.md @@ -10,7 +10,7 @@ e.g. `cam0/1.png`. | Uncalibrated monocular | A directory containing `cam0/`, or an MP4/MOV video | | Calibrated monocular | A directory containing `cam0/` | | Physical rig | A directory containing `cam0/`, `cam1/`, … | -| Panorama | A directory containing `cam0/` with 360° equirectangular images | +| Panorama | A directory containing `cam0/` with 360° equirectangular images, or a stitched MP4/MOV panorama | Uncalibrated monocular images use natural filename order (`frame2.png` before `frame10.png`). Rigs, calibrated monocular and panoramas resolve numeric filenames diff --git a/vidmap/benchmark/cli.py b/vidmap/benchmark/cli.py index fd3b298..9d666ca 100644 --- a/vidmap/benchmark/cli.py +++ b/vidmap/benchmark/cli.py @@ -330,7 +330,7 @@ def main(argv=None): validate_profile_selection(selection, enabled=run_options.profile) except ValueError as error: parser.error(str(error)) - selection.dataset_spec.prepare(conf.selection.scene) + selection.dataset_spec.prepare(conf.selection.scene, mode=conf.selection.mode) import time diff --git a/vidmap/benchmark/report.py b/vidmap/benchmark/report.py index e49cdf6..5ead036 100644 --- a/vidmap/benchmark/report.py +++ b/vidmap/benchmark/report.py @@ -197,7 +197,6 @@ def collect( if evaluation_options is not None: conf = dataclasses.replace(conf, evaluation=evaluation_options) wate_auc_full_thresholds = evaluation_options.windowed_ate_auc_full_thresholds - dataset.prepare() result_evaluator = BenchmarkResultEvaluator( conf, dataset, @@ -249,7 +248,9 @@ def collect( wate_colls[mode][scene][wkey + "_trajs"] = len(all_vals) else: wate_colls[mode].setdefault(scene, {})[wkey] = float(np.mean(all_vals)) - if getattr(args, "scene", None) is None: + if dataset.name == "euroc": + scenes = list(out) + elif getattr(args, "scene", None) is None: scenes = result_evaluator.selection.dataset_layout.scenes else: scenes = args.scene diff --git a/vidmap/benchmark/result_evaluation.py b/vidmap/benchmark/result_evaluation.py index 7caaa38..116cf7f 100644 --- a/vidmap/benchmark/result_evaluation.py +++ b/vidmap/benchmark/result_evaluation.py @@ -5,10 +5,13 @@ import matplotlib import pycolmap +import yaml from tqdm import tqdm -from vidmap.benchmark.trajectory import AbsoluteTrajectoryEvaluator, AbsoluteTrajectoryOptions +from vidmap.benchmark.trajectory import AbsoluteTrajectoryEvaluator +from vidmap.configuration.dump import frontend_config_path from vidmap.datasets.selection import PreparedTargetSelection +from vidmap.frontend.options.rig import RigOptions from vidmap.run_options import RunOptions from vidmap.utils.logging import log_context, progress_bars_enabled @@ -40,39 +43,18 @@ def __init__( self.output_name = output_name self.run_options = RunOptions() if run_options is None else run_options self.selection = PreparedTargetSelection(conf.selection, dataset) - self.evaluator = AbsoluteTrajectoryEvaluator(options=AbsoluteTrajectoryOptions.from_config(conf.evaluation)) + rig = None if self.frontend_conf is None else self.frontend_conf.pipeline.rig + self.evaluator = AbsoluteTrajectoryEvaluator.from_config(conf, rig) - @staticmethod - def remap_image_ids(reconstruction, ground_truth): - """Return a reconstruction whose image IDs match ground truth names.""" - ground_truth_ids = {image.name: image_id for image_id, image in ground_truth.images.items()} - id_mapping = { - image_id: ground_truth_ids[image.name] - for image_id, image in reconstruction.images.items() - if image.name in ground_truth_ids and image_id != ground_truth_ids[image.name] - } - if not id_mapping: - return reconstruction - - remapped = pycolmap.Reconstruction() - for camera in reconstruction.cameras.values(): - remapped.add_camera_with_trivial_rig(camera) - for image_id, image in reconstruction.images.items(): - remapped_image = pycolmap.Image( - image_id=id_mapping.get(image_id, image_id), - camera_id=image.camera_id, - name=image.name, - ) - if image.has_pose: - remapped.add_image_with_trivial_frame(remapped_image, image.cam_from_world()) - else: - remapped.add_image_with_trivial_frame(remapped_image) - for point3d in reconstruction.points3D.values(): - remapped.add_point3D(point3d) - return remapped + def _report_group(self, case): + # EuRoC reports individual recordings, independently of their storage scene. + if self.selection.dataset_spec.name == "euroc": + return f"{case.scene}-{case.recording}" + return case.scene def evaluate_case(self, case, scene_parser, outputs, recalls, wates): """Load, evaluate, and aggregate one persisted benchmark reconstruction.""" + group = self._report_group(case) try: results = BenchmarkResultStore( self.conf, @@ -84,8 +66,15 @@ def evaluate_case(self, case, scene_parser, outputs, recalls, wates): output_name=self.output_name, ) reconstruction = pycolmap.Reconstruction(results.output_dir / "rec") - reconstruction = self.remap_image_ids(reconstruction, scene_parser.rec) - evaluation = self.evaluator.evaluate( + evaluator = self.evaluator + frontend_path = frontend_config_path(results.output_dir) + if frontend_path.is_file(): + frontend = yaml.safe_load(frontend_path.read_text()) + rig = frontend["rig"] if "rig" in frontend else None + evaluator = AbsoluteTrajectoryEvaluator.from_config( + self.conf, None if rig is None else RigOptions(**rig) + ) + evaluation = evaluator.evaluate( estimated_reconstruction=reconstruction, ground_truth_reconstruction=scene_parser.rec, ) @@ -97,29 +86,33 @@ def evaluate_case(self, case, scene_parser, outputs, recalls, wates): ) summary = evaluation.summarize(verbose=logger.isEnabledFor(logging.INFO)) - outputs[case.scene][case.testset_type].append(summary["all"]) + outputs[group][case.testset_type].append(summary["all"]) if "recall" in summary: - recalls[case.scene][case.testset_type].append(summary["recall"]) + recalls[group][case.testset_type].append(summary["recall"]) for key in (key for key in summary if key.startswith("wate_") or key.startswith("wrre_")): - target = wates[case.scene][key][case.testset_type] + target = wates[group][key][case.testset_type] if key.startswith("wate_auc_errors_"): target.extend(summary[key]) else: target.append(summary[key]) - return True, case.scene + return True, group except Exception as error: if self.run_options.terminate_on_error: raise error logger.exception("Error processing %s: %s", case.scene, error) - return False, case.scene + return False, group def evaluate_saved(self): """Evaluate every selected reconstruction saved on disk.""" - scenes = self.selection.dataset_layout.scenes - outputs = {scene: defaultdict(list) for scene in scenes} - recalls = {scene: defaultdict(list) for scene in scenes} - wates = {scene: defaultdict(lambda: defaultdict(list)) for scene in scenes} - counts = {scene: {"completed": 0, "failed": 0} for scene in scenes} + groups = self.selection.dataset_layout.scenes + if self.selection.dataset_spec.name == "euroc": + groups = tuple( + self._report_group(case) for _, cases in self.selection.iter_scene_targets() for case in cases + ) + outputs = {group: defaultdict(list) for group in groups} + recalls = {group: defaultdict(list) for group in groups} + wates = {group: defaultdict(lambda: defaultdict(list)) for group in groups} + counts = {group: {"completed": 0, "failed": 0} for group in groups} for case, scene_parser, _ in tqdm( self.selection.prepared_targets(), disable=not progress_bars_enabled(), @@ -131,6 +124,6 @@ def evaluate_saved(self): testset=case.testset_desc, config=self.conf.name, ): - ok, scene = self.evaluate_case(case, scene_parser, outputs, recalls, wates) - counts[scene]["completed" if ok else "failed"] += 1 + ok, group = self.evaluate_case(case, scene_parser, outputs, recalls, wates) + counts[group]["completed" if ok else "failed"] += 1 return outputs, counts, recalls, wates diff --git a/vidmap/benchmark/trajectory.py b/vidmap/benchmark/trajectory.py index d7f2477..33e0088 100644 --- a/vidmap/benchmark/trajectory.py +++ b/vidmap/benchmark/trajectory.py @@ -6,11 +6,14 @@ import numpy as np import pycolmap +from evo.core.trajectory import PoseTrajectory3D from pydantic import ConfigDict from vidmap.configuration.validators import dataclass as pydantic_dataclass from vidmap.utils.trajectory import ( align_reconstruction_to_reference_sequence, + compute_ate_statistics, + reconstruction_to_evo_trajectory, remap_poses_to_timeline, trajectory_images, ) @@ -86,13 +89,52 @@ def windowed_ate_auc_fields(window_size, errors, thresholds): return {wate_auc_result_key(window_size, threshold): auc for threshold, auc in zip(thresholds, aucs, strict=True)} -def align_reconstruction_for_evaluation(reconstruction, ground_truth_reconstruction) -> None: - """Apply the frozen paper-default two-pass Sim(3) alignment.""" - # Paper-reproduction outputs align once for visualization and refine that - # result before evaluation. Keep this numerical contract explicit. +def _prepare_evaluation_pair(estimated, ground_truth, reference_from_gt=None): + """Select cam0 for evaluation and match estimated image IDs to GT by name. + + Keep unposed GT images and GT images with missing estimates so the evaluator + can distinguish unavailable ground truth from reconstruction failures. + """ + reference_ids = { + image.name: image.image_id for image in ground_truth.images.values() if image.name.startswith("cam0/") + } + if not reference_ids: + raise ValueError("Benchmark ground truth has no cam0 images") + selected = [] + for reconstruction, rotate_gt in ((estimated, False), (ground_truth, reference_from_gt is not None)): + images = [image for image in reconstruction.images.values() if image.name.startswith("cam0/")] + if ( + not rotate_gt + and len(images) == reconstruction.num_images() + and all(image.image_id == reference_ids[image.name] for image in images) + ): + selected.append(reconstruction) + continue + output = pycolmap.Reconstruction() + for image in images: + if image.camera_id not in output.cameras: + output.add_camera_with_trivial_rig(image.camera) + target = pycolmap.Image(image_id=reference_ids[image.name], camera_id=image.camera_id, name=image.name) + if image.has_pose: + pose = image.cam_from_world() + if rotate_gt: + pose = reference_from_gt * pose + output.add_image_with_trivial_frame(target, pose) + else: + output.add_image_with_trivial_frame(target) + selected.append(output) + return tuple(selected) + + +def _align_reconstruction_for_evaluation( + reconstruction, ground_truth_reconstruction, *, full_reconstruction=None +) -> None: + """Reproduce the paper's two-pass Sim(3) alignment: visualization, then evaluation.""" for _ in range(2): _, similarity = align_reconstruction_to_reference_sequence(reconstruction, ground_truth_reconstruction) reconstruction.transform(similarity) + if full_reconstruction is not None and full_reconstruction is not reconstruction: + full_reconstruction.transform(similarity) def logmap_so3(R): @@ -203,6 +245,9 @@ def convert_numpy_scalars_to_builtin(obj): class AbsoluteTrajectoryResult: options: AbsoluteTrajectoryOptions data: dict + ate: dict | None = None + estimated_trajectory: PoseTrajectory3D | None = None + ground_truth_trajectory: PoseTrajectory3D | None = None def summarize(self, *, verbose=False): text, summary = self._build_summary(verbose=verbose) @@ -316,12 +361,24 @@ def _build_summary(self, *, verbose): class AbsoluteTrajectoryEvaluator: - def __init__(self, *, options: AbsoluteTrajectoryOptions | None = None): + def __init__(self, *, options: AbsoluteTrajectoryOptions | None = None, reference_view=None): if options is None: options = AbsoluteTrajectoryOptions() elif not isinstance(options, AbsoluteTrajectoryOptions): raise TypeError(f"Expected AbsoluteTrajectoryOptions, got {type(options).__name__}") self.options = options + self.reference_from_gt = None + if reference_view is not None: + from vidmap.datasets.prepare.helpers.panorama_capture import camera_from_panorama + + self.reference_from_gt = pycolmap.Rigid3d( + pycolmap.Rotation3d(camera_from_panorama(asdict(reference_view))), np.zeros(3) + ) + + @classmethod + def from_config(cls, conf, rig=None): + view = None if rig is None or rig.projection is None else rig.projection.views[0] + return cls(options=AbsoluteTrajectoryOptions.from_config(conf.evaluation), reference_view=view) def summarize_pose_errors(self, pose_errors): pose_error_summary = {} @@ -395,10 +452,29 @@ def evaluate( *, estimated_reconstruction: pycolmap.Reconstruction, ground_truth_reconstruction: pycolmap.Reconstruction, + align_in_place: bool = False, ) -> AbsoluteTrajectoryResult: - for image_id in estimated_reconstruction.images: - if image_id not in ground_truth_reconstruction.images: - raise ValueError(f"Image {image_id} not in ground truth reconstruction") + """Evaluate cam0, matching estimates to GT by image name. + + With align_in_place, also align the full reconstruction for saving and + return its cam0 trajectories and ATE using the paper's two-pass alignment. + Otherwise, leave both input reconstructions unchanged. + """ + full_reconstruction = estimated_reconstruction + estimated_reconstruction, ground_truth_reconstruction = _prepare_evaluation_pair( + estimated_reconstruction, ground_truth_reconstruction, self.reference_from_gt + ) + ate = estimated_trajectory = ground_truth_trajectory = None + if align_in_place: + _align_reconstruction_for_evaluation( + estimated_reconstruction, ground_truth_reconstruction, full_reconstruction=full_reconstruction + ) + estimated_trajectory = reconstruction_to_evo_trajectory(estimated_reconstruction) + supported_ground_truth = remap_poses_to_timeline( + estimated_reconstruction, ground_truth_reconstruction, interpolate_missing_poses=False + ).reconstruction + ground_truth_trajectory = reconstruction_to_evo_trajectory(supported_ground_truth) + ate = compute_ate_statistics(ground_truth_trajectory, estimated_trajectory) num_images = estimated_reconstruction.num_images() num_registered_images = estimated_reconstruction.num_reg_images() estimated_reconstruction, _ = align_reconstruction_to_reference_sequence( @@ -430,7 +506,13 @@ def evaluate( data["results"]["scale"] = self.options.gt_scale data["success"] = True - return AbsoluteTrajectoryResult(options=self.options, data=data) + return AbsoluteTrajectoryResult( + options=self.options, + data=data, + ate=ate, + estimated_trajectory=estimated_trajectory, + ground_truth_trajectory=ground_truth_trajectory, + ) def absolute_pose_errors(self, estim_rec, gt_rec): poses_es_world_full = { diff --git a/vidmap/benchmarking.py b/vidmap/benchmarking.py index 6f36096..b07f8f7 100644 --- a/vidmap/benchmarking.py +++ b/vidmap/benchmarking.py @@ -4,17 +4,12 @@ from dataclasses import replace from vidmap.benchmark.results import BenchmarkResultStore -from vidmap.benchmark.trajectory import ( - AbsoluteTrajectoryEvaluator, - AbsoluteTrajectoryOptions, - align_reconstruction_for_evaluation, -) +from vidmap.benchmark.trajectory import AbsoluteTrajectoryEvaluator from vidmap.frontend.identity import FrontendIdentity from vidmap.mapper.inputs import MapperInputs from vidmap.reconstruction import run_mapping from vidmap.run_options import RunOptions from vidmap.utils.logging import log_context -from vidmap.utils.trajectory import compute_ate_statistics, reconstruction_to_evo_trajectory, remap_poses_to_timeline logger = logging.getLogger(__name__) @@ -36,7 +31,7 @@ def __init__( self.selection = selection self.run_options = RunOptions() if run_options is None else run_options self.output_name = output_name - self.evaluator = AbsoluteTrajectoryEvaluator(options=AbsoluteTrajectoryOptions.from_config(conf.evaluation)) + self.evaluator = AbsoluteTrajectoryEvaluator.from_config(conf, frontend_conf.pipeline.rig) def run( self, @@ -196,21 +191,17 @@ def run( if self.evaluator is None: raise RuntimeError("Benchmark evaluation requires an evaluator") - align_reconstruction_for_evaluation(estimated_reconstruction, scene_parser.rec) - estimated_trajectory = reconstruction_to_evo_trajectory(estimated_reconstruction) - aligned_ground_truth_reconstruction = remap_poses_to_timeline( - estimated_reconstruction, scene_parser.rec, interpolate_missing_poses=False - ).reconstruction - ground_truth_trajectory = reconstruction_to_evo_trajectory(aligned_ground_truth_reconstruction) - absolute_trajectory_error = compute_ate_statistics(ground_truth_trajectory, estimated_trajectory) - self.results.write_ate(absolute_trajectory_error) trajectory_evaluation = self.evaluator.evaluate( estimated_reconstruction=estimated_reconstruction, ground_truth_reconstruction=scene_parser.rec, + align_in_place=True, ) + self.results.write_ate(trajectory_evaluation.ate) trajectory_metrics = trajectory_evaluation.summarize(verbose=logger.isEnabledFor(logging.INFO)) - self.results.write_metrics(absolute_trajectory_error, trajectory_metrics) + self.results.write_metrics(trajectory_evaluation.ate, trajectory_metrics) self.results.write_reconstruction(estimated_reconstruction) - self.results.write_trajectory(estimated_trajectory, ground_truth_trajectory) + self.results.write_trajectory( + trajectory_evaluation.estimated_trajectory, trajectory_evaluation.ground_truth_trajectory + ) return estimated_reconstruction diff --git a/vidmap/configs/frontend/calib/panorama.yaml b/vidmap/configs/frontend/calib/panorama.yaml new file mode 100644 index 0000000..21d64c6 --- /dev/null +++ b/vidmap/configs/frontend/calib/panorama.yaml @@ -0,0 +1,11 @@ +defaults: + - rig + +rig: + projection: + aspect_ratio: [1, 1] + horizontal_fov_degrees: 120.0 + views: + 0: {yaw_degrees: 0.0, pitch_degrees: 0.0} + 1: {yaw_degrees: 120.0, pitch_degrees: 0.0} + 2: {yaw_degrees: 240.0, pitch_degrees: 0.0} diff --git a/vidmap/datasets/assets/euroc/source_archives.json b/vidmap/datasets/assets/euroc/source_archives.json index 6459693..4d91ee5 100644 --- a/vidmap/datasets/assets/euroc/source_archives.json +++ b/vidmap/datasets/assets/euroc/source_archives.json @@ -23,7 +23,7 @@ } }, "sequences": { - "machine_hall-MH_01_easy": { + "MH_01_easy": { "archive": "machine_hall", "member": "machine_hall/MH_01_easy/MH_01_easy.zip", "size": 1571292346, @@ -33,7 +33,7 @@ "first_image": "1403636580863555584.png", "last_image": "1403636762713555456.png" }, - "machine_hall-MH_02_easy": { + "MH_02_easy": { "archive": "machine_hall", "member": "machine_hall/MH_02_easy/MH_02_easy.zip", "size": 1292413952, @@ -43,7 +43,7 @@ "first_image": "1403636859551666432.png", "last_image": "1403637009451666432.png" }, - "machine_hall-MH_03_medium": { + "MH_03_medium": { "archive": "machine_hall", "member": "machine_hall/MH_03_medium/MH_03_medium.zip", "size": 1105301873, @@ -53,7 +53,7 @@ "first_image": "1403637132938319104.png", "last_image": "1403637264388318976.png" }, - "machine_hall-MH_04_difficult": { + "MH_04_difficult": { "archive": "machine_hall", "member": "machine_hall/MH_04_difficult/MH_04_difficult.zip", "size": 726518489, @@ -63,7 +63,7 @@ "first_image": "1403638128945096960.png", "last_image": "1403638227695097088.png" }, - "machine_hall-MH_05_difficult": { + "MH_05_difficult": { "archive": "machine_hall", "member": "machine_hall/MH_05_difficult/MH_05_difficult.zip", "size": 828426215, @@ -73,7 +73,7 @@ "first_image": "1403638519527829504.png", "last_image": "1403638630527829504.png" }, - "vicon_room1-V1_01_easy": { + "V1_01_easy": { "archive": "vicon_room1", "member": "vicon_room1/V1_01_easy/V1_01_easy.zip", "size": 1149702102, @@ -83,7 +83,7 @@ "first_image": "1403715274312143104.png", "last_image": "1403715417812143104.png" }, - "vicon_room1-V1_02_medium": { + "V1_02_medium": { "archive": "vicon_room1", "member": "vicon_room1/V1_02_medium/V1_02_medium.zip", "size": 668079368, @@ -93,7 +93,7 @@ "first_image": "1403715524912143104.png", "last_image": "1403715608412143104.png" }, - "vicon_room1-V1_03_difficult": { + "V1_03_difficult": { "archive": "vicon_room1", "member": "vicon_room1/V1_03_difficult/V1_03_difficult.zip", "size": 801343542, @@ -103,7 +103,7 @@ "first_image": "1403715888384058112.png", "last_image": "1403715993034057984.png" }, - "vicon_room2-V2_01_easy": { + "V2_01_easy": { "archive": "vicon_room2", "member": "vicon_room2/V2_01_easy/V2_01_easy.zip", "size": 883081407, @@ -113,7 +113,7 @@ "first_image": "1413393213505760512.png", "last_image": "1413393325455760384.png" }, - "vicon_room2-V2_02_medium": { + "V2_02_medium": { "archive": "vicon_room2", "member": "vicon_room2/V2_02_medium/V2_02_medium.zip", "size": 898452319, @@ -123,7 +123,7 @@ "first_image": "1413393887255760384.png", "last_image": "1413394002655760384.png" }, - "vicon_room2-V2_03_difficult": { + "V2_03_difficult": { "archive": "vicon_room2", "member": "vicon_room2/V2_03_difficult/V2_03_difficult.zip", "size": 820796031, diff --git a/vidmap/datasets/base.py b/vidmap/datasets/base.py index 8b0040a..687b313 100644 --- a/vidmap/datasets/base.py +++ b/vidmap/datasets/base.py @@ -3,7 +3,7 @@ import importlib import json from collections.abc import Callable, Iterable, Iterator, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, Protocol @@ -13,6 +13,7 @@ from PIL import Image if TYPE_CHECKING: + from vidmap.datasets.capture import PreparedCapture from vidmap.datasets.video import VideoDatasetManifest from vidmap.datasets.names import EvaluationMetric @@ -50,6 +51,29 @@ def validate_path_component(value: object, label: str) -> str: return value +@dataclass(frozen=True) +class PreparedTestset: + """A benchmark selection with an explicit prepared input, within its scene.""" + + recording: str + variant: str + image_ids: tuple[int, ...] + + def __post_init__(self) -> None: + validate_path_component(self.recording, "Recording") + validate_path_component(self.variant, "Image variant") + if any( + type(image_id) is not int # noqa: E721 - bool is not a valid image ID. + or not 0 <= image_id < pycolmap.INVALID_IMAGE_ID + for image_id in self.image_ids + ): + raise ValueError("Testsets must contain valid integer image IDs") + object.__setattr__(self, "image_ids", tuple(self.image_ids)) + + def as_dict(self) -> dict: + return dict(recording=self.recording, variant=self.variant, image_ids=list(self.image_ids)) + + @dataclass(frozen=True) class DatasetLayout: name: str @@ -61,13 +85,19 @@ class DatasetLayout: manifest: VideoDatasetManifest | None = None def scene_dir(self, scene: str) -> Path: - return self.data_dir / scene + return self.data_dir / validate_path_component(scene, "Dataset scene") + + def recording_dir(self, scene: str, recording: str) -> Path: + return self.scene_dir(scene) / validate_path_component(recording, "Recording") + + def input_dir(self, scene: str, recording: str, variant: str) -> Path: + return self.recording_dir(scene, recording) / validate_path_component(variant, "Image variant") - def images_dir(self, scene: str) -> Path: - return self.scene_dir(scene) / "images" + def images_dir(self, scene: str, recording: str, variant: str) -> Path: + return self.input_dir(scene, recording, variant) / "images" - def reconstruction_dir(self, scene: str) -> Path: - return self.scene_dir(scene) / "rec" + def reconstruction_dir(self, scene: str, recording: str, variant: str) -> Path: + return self.input_dir(scene, recording, variant) / "rec" def testset_path(self, scene: str, mode: str) -> Path: scene = validate_path_component(scene, "Dataset scene") @@ -90,7 +120,7 @@ def select_scenes(self, scenes: Iterable[str] | None) -> tuple[str, ...]: raise ValueError(f"Dataset {self.name!r} does not define scenes: {unknown}") return tuple(scene for scene in self.scenes if scene in selected_set) - def read_testsets(self, scene: str, mode: str) -> dict[str, list[int]] | None: + def read_testsets(self, scene: str, mode: str) -> dict[str, PreparedTestset] | None: path = self.testset_path(scene, mode) if not path.exists(): return None @@ -102,8 +132,8 @@ def read_testsets(self, scene: str, mode: str) -> dict[str, list[int]] | None: if not isinstance(testsets, Mapping): raise ValueError(f"Invalid testset file {path}: expected a YAML mapping") - normalized: dict[str, list[int]] = {} - for key, image_ids in testsets.items(): + normalized = {} + for key, selection in testsets.items(): if isinstance(key, bool) or not isinstance(key, (str, int)): raise ValueError(f"Invalid testset file {path}: testset IDs must be strings or integers, got {key!r}") testset_id = str(key) @@ -113,16 +143,13 @@ def read_testsets(self, scene: str, mode: str) -> dict[str, list[int]] | None: raise ValueError(f"Invalid testset file {path}: unsafe testset ID {key!r}") from error if testset_id in normalized: raise ValueError(f"Invalid testset file {path}: duplicate testset ID {testset_id!r}") - if not isinstance(image_ids, list) or any( - isinstance(image_id, bool) - or not isinstance(image_id, int) - or not 0 <= image_id < pycolmap.INVALID_IMAGE_ID - for image_id in image_ids - ): + if not isinstance(selection, Mapping) or set(selection) != {"recording", "variant", "image_ids"}: raise ValueError( - f"Invalid testset file {path}: {testset_id!r} must contain a sequence of valid integer image IDs" + f"Invalid testset file {path}: {testset_id!r} must identify its recording and variant" ) - normalized[testset_id] = list(image_ids) + if not isinstance(selection["image_ids"], list): + raise ValueError(f"Invalid testset file {path}: {testset_id!r} image_ids must be a list") + normalized[testset_id] = PreparedTestset(**selection) return normalized @@ -132,9 +159,7 @@ class DatasetParser(Protocol): reconstruction_dir: Path | None rec: pycolmap.Reconstruction - -SceneParserFactory = Callable[[str], DatasetParser] -BatchCaseParser = Callable[[str, Iterable[str]], Iterable[DatasetParser]] + def frontend_capture(self) -> PreparedCapture: ... @dataclass(frozen=True) @@ -146,39 +171,53 @@ class DatasetCapabilities: @dataclass(frozen=True) class DatasetPreparation: module: str - - def __call__(self, scenes: tuple[str, ...] | None = None) -> None: - importlib.import_module(self.module).main(scenes=scenes) + profiles: tuple[tuple[str, str], ...] = () + + def profile(self, mode: str) -> str: + profiles = dict(self.profiles) + if mode not in profiles: + raise ValueError(f"Unsupported preparation mode {mode!r}; expected one of {tuple(profiles)}") + return profiles[mode] + + def __call__(self, scenes: tuple[str, ...] | None = None, *, mode: str) -> None: + module = importlib.import_module(self.module) + if self.profiles: + module.main(scenes=scenes, profile=self.profile(mode)) + else: + module.main(scenes=scenes) -def _missing_standard_scene_artifacts(layout: DatasetLayout, scenes: tuple[str, ...]) -> tuple[Path, ...]: +def _missing_standard_scene_artifacts(layout: DatasetLayout, scenes: tuple[str, ...], mode: str) -> tuple[Path, ...]: missing = [] for scene in scenes: - images = layout.images_dir(scene) - reconstruction = layout.reconstruction_dir(scene) - if not (images / "cam0").is_dir() or not any(path.is_file() for path in (images / "cam0").rglob("*")): - missing.append(images) - if not reconstruction.is_dir() or not any(reconstruction.iterdir()): - missing.append(reconstruction) - if layout.manifest is not None: - sidecar = layout.scene_dir(scene) / CANONICAL_UNPOSED_IMAGES_FILENAME - testset = layout.testset_path(scene, "all") - if not sidecar.is_file(): - missing.append(sidecar) - if not testset.is_file(): - missing.append(testset) + testsets = layout.read_testsets(scene, mode) + if testsets is None: + missing.append(layout.testset_path(scene, mode)) + continue + for recording, variant in dict.fromkeys((case.recording, case.variant) for case in testsets.values()): + images = layout.images_dir(scene, recording, variant) + reconstruction = layout.reconstruction_dir(scene, recording, variant) + receipt = layout.recording_dir(scene, recording) / "preparation.json" + if not receipt.is_file(): + missing.append(receipt) + if not (images / "cam0").is_dir() or not any(path.is_file() for path in (images / "cam0").rglob("*")): + missing.append(images) + if not reconstruction.is_dir() or not any(reconstruction.iterdir()): + missing.append(reconstruction) + if layout.manifest is not None: + sidecar = layout.input_dir(scene, recording, variant) / CANONICAL_UNPOSED_IMAGES_FILENAME + if not sidecar.is_file(): + missing.append(sidecar) return tuple(missing) @dataclass(frozen=True) class DatasetSpec: name: str - parser: SceneParserFactory layout: DatasetLayout preparer: DatasetPreparation | Callable[[], None] | None = None capabilities: DatasetCapabilities = DatasetCapabilities() - batch_case_parser: BatchCaseParser | None = None - missing_artifacts: Callable[[DatasetLayout, tuple[str, ...]], tuple[Path, ...]] | None = ( + missing_artifacts: Callable[[DatasetLayout, tuple[str, ...], str], tuple[Path, ...]] | None = ( _missing_standard_scene_artifacts ) default_mode: str = "minimal" @@ -191,13 +230,24 @@ def __post_init__(self) -> None: if self.default_evaluation_metric not in {"pose_auc", "wate_auc"}: raise ValueError(f"Unsupported default evaluation metric {self.default_evaluation_metric!r}") - def prepare(self, scenes: Iterable[str] | None = None) -> None: + def _missing_preparation_artifacts(self, scenes: tuple[str, ...], mode: str) -> tuple[Path, ...]: + missing = self.missing_artifacts(self.layout, scenes, mode) + if not self.capabilities.allows_missing_testsets: + missing += tuple( + self.layout.testset_path(scene, mode) + for scene in scenes + if not self.layout.testset_path(scene, mode).is_file() + ) + return missing + + def prepare(self, scenes: Iterable[str] | None = None, *, mode: str | None = None) -> None: if self.missing_artifacts is None: return selected_scenes = self.layout.select_scenes(scenes) if not selected_scenes: return - missing = self.missing_artifacts(self.layout, selected_scenes) + mode = self.default_mode if mode is None else mode + missing = self._missing_preparation_artifacts(selected_scenes, mode) if not missing: return if self.preparer is None: @@ -207,10 +257,10 @@ def prepare(self, scenes: Iterable[str] | None = None) -> None: f"provide non-empty prepared artifacts at: {expected}" ) if isinstance(self.preparer, DatasetPreparation): - self.preparer(selected_scenes) + self.preparer(selected_scenes, mode=mode) else: self.preparer() - missing = self.missing_artifacts(self.layout, selected_scenes) + missing = self._missing_preparation_artifacts(selected_scenes, mode) if missing: expected = ", ".join(str(path) for path in missing) raise RuntimeError( @@ -218,7 +268,9 @@ def prepare(self, scenes: Iterable[str] | None = None) -> None: f"missing non-empty prepared artifacts: {expected}" ) - def select_testsets(self, scene: str, mode: str, testset_ids: Iterable[str] | None = None) -> dict[str, list[int]]: + def select_testsets( + self, scene: str, mode: str, testset_ids: Iterable[str] | None = None + ) -> dict[str, PreparedTestset]: testsets = self.layout.read_testsets(scene, mode) if testsets is None: if self.capabilities.allows_missing_testsets: @@ -230,30 +282,40 @@ def select_testsets(self, scene: str, mode: str, testset_ids: Iterable[str] | No return {testset_id: testsets[testset_id] for testset_id in testset_ids if testset_id in testsets} return {testset_id: testsets[testset_id] for testset_id in testset_ids} - def parsers_for_cases(self, scene: str, testset_descs: Iterable[str]) -> Iterator[DatasetParser]: - if self.batch_case_parser is not None: - yield from self.batch_case_parser(scene, testset_descs) - return - - parser = self.parser(scene) - for _ in testset_descs: + def parsers_for_cases(self, scene: str, selections: Iterable[PreparedTestset]) -> Iterator[DatasetParser]: + parser = None + current = None + for selection in selections: + key = (selection.recording, selection.variant) + if key != current: + parser = PreparedSceneParser(self.layout, scene, selection) + current = key yield parser class PreparedSceneParser: - def __init__(self, layout: DatasetLayout, scene: str) -> None: + def __init__(self, layout: DatasetLayout, scene: str, selection: PreparedTestset) -> None: self.layout = layout self.scene = scene - self.rgb_dir = layout.images_dir(scene) - self.reconstruction_dir = layout.reconstruction_dir(scene) + self.recording = selection.recording + self.variant = selection.variant + self.input_dir = layout.input_dir(scene, self.recording, self.variant) + self.rgb_dir = self.input_dir / "images" + self.reconstruction_dir = self.input_dir / "rec" + preparation = json.loads((layout.recording_dir(scene, self.recording) / "preparation.json").read_text()) + self.input_metadata = preparation["variants"][self.variant] self.rec = pycolmap.Reconstruction(self.reconstruction_dir) + if self.rec.num_points3D() or any(image.num_points2D() for image in self.rec.images.values()): + raise ValueError( + f"Prepared reference models must contain only camera poses and calibration: {self.reconstruction_dir}" + ) images_by_name = {image.name: image for image in self.rec.images.values()} unposed_names = { name for name, image in images_by_name.items() if image.has_pose and not image_has_public_pose(image) } if layout.manifest is not None: layout.manifest.get(scene) - sidecar = layout.scene_dir(scene) / CANONICAL_UNPOSED_IMAGES_FILENAME + sidecar = self.input_dir / CANONICAL_UNPOSED_IMAGES_FILENAME if not sidecar.is_file(): raise FileNotFoundError(f"Canonical scene is missing unposed-image sidecar: {sidecar}") try: @@ -276,6 +338,41 @@ def __init__(self, layout: DatasetLayout, scene: str) -> None: ): self.rec.deregister_frame(frame_id) + def frontend_capture(self) -> PreparedCapture: + """Copy input geometry without exposing ground-truth poses to the frontend.""" + from vidmap.datasets.capture import PreparedCapture, copy_unposed_reconstruction + + if self.input_metadata["rig"] is not None: + from vidmap.datasets.prepare.helpers.rig_capture import load_capture + from vidmap.frontend.options.rig import RigOptions + + options = RigOptions(**self.input_metadata["rig"]) + streams = {Path(image.name).parts[0] for image in self.rec.images.values()} + if streams == {"cam0"}: + options = replace(options, cameras=options.cameras[:1], frames=None) + elif streams != {f"cam{i}" for i in range(len(options.cameras))}: + raise ValueError("Prepared rig selection must contain cam0 or all declared streams") + capture = load_capture(self.rgb_dir, options) + selected = copy_unposed_reconstruction(capture.rec, [image.name for image in self.rec.images.values()]) + return replace( + capture, + rec=selected, + frame_timestamps_ns={fid: capture.frame_timestamps_ns[fid] for fid in selected.frames}, + ) + + return PreparedCapture( + rec=copy_unposed_reconstruction(self.rec), + rgb_dir=self.rgb_dir, + frame_timestamps_ns=None, + provenance={}, + preserve_calibration=False, + forced_keyframe_names=( + frozenset(image.name for image in self.rec.images.values() if image.has_pose) + if self.input_metadata["force_gt_keyframes"] + else frozenset() + ), + ) + def camera(self, imid: int) -> pycolmap.Camera: return self.rec.cameras[self.rec.images[imid].camera_id] diff --git a/vidmap/datasets/capture.py b/vidmap/datasets/capture.py index 6357a17..a918cc7 100644 --- a/vidmap/datasets/capture.py +++ b/vidmap/datasets/capture.py @@ -326,15 +326,7 @@ def prepare_scene_capture(scene_parser) -> PreparedCapture: """Adapt a dataset's calibration and image inventory to the frontend capture.""" if isinstance(scene_parser, PreparedCapture): return scene_parser - - return PreparedCapture( - rec=copy_unposed_reconstruction(scene_parser.rec), - rgb_dir=scene_parser.rgb_dir, - frame_timestamps_ns=None, - provenance={}, - preserve_calibration=False, - forced_keyframe_names=frozenset(image.name for image in scene_parser.rec.images.values() if image.has_pose), - ) + return scene_parser.frontend_capture() def copy_unposed_reconstruction(source, reference_image_names=None) -> pycolmap.Reconstruction: diff --git a/vidmap/datasets/crocodl.py b/vidmap/datasets/crocodl.py index 0d6d764..21e9c41 100644 --- a/vidmap/datasets/crocodl.py +++ b/vidmap/datasets/crocodl.py @@ -2,11 +2,7 @@ from __future__ import annotations -from collections.abc import Iterable, Iterator - -import pycolmap - -from vidmap.datasets.base import PreparedSceneParser, validate_path_component +from vidmap.datasets.base import validate_path_component from vidmap.datasets.layouts import get_dataset_layout CROCODL_LAYOUT = get_dataset_layout("crocodl") @@ -34,55 +30,3 @@ def parse_benchmark_scene(scene: str) -> str: raise ValueError(f"CroCoDL scene {scene!r} is not an iOS scene") location = validate_path_component(location, "CroCoDL location") return location - - -def _session_from_testset(testset_desc: str) -> str: - testset_desc = validate_path_component(testset_desc, "CroCoDL testset ID") - session_id, separator, index = testset_desc.rpartition("-") - if not separator or not session_id or not index.isdecimal(): - raise ValueError(f"CroCoDL testset ID {testset_desc!r} must end in a numeric subsequence index") - return validate_path_component(session_id, "CroCoDL session ID") - - -class CroCoDLParser(PreparedSceneParser): - """Read a CroCoDL session selected by a location-scoped testset key.""" - - @classmethod - def for_testsets(cls, scene: str, testset_descs: Iterable[str]) -> Iterator["CroCoDLParser"]: - """Construct independent case parsers while loading each source session once.""" - reconstructions: dict[str, pycolmap.Reconstruction] = {} - for testset_desc in testset_descs: - session_id = _session_from_testset(testset_desc) - if session_id in reconstructions: - yield cls(scene, testset_id=session_id, _reconstruction=reconstructions[session_id]) - continue - parser = cls(scene, testset_id=session_id) - reconstructions[session_id] = parser.rec - yield parser - - def __init__( - self, - scene: str, - testset_id: str | None = None, - *, - _reconstruction: pycolmap.Reconstruction | None = None, - ) -> None: - self.layout = CROCODL_LAYOUT - self.scene = scene - location = parse_benchmark_scene(scene) - self.session_id = testset_id - - if testset_id is None: - self.scene_dir = self.layout.data_dir / scene - else: - testset_id = validate_ios_session(testset_id) - self.scene_dir = self.layout.data_dir / location / testset_id - if not self.scene_dir.is_dir() or not (self.scene_dir / "rec").is_dir(): - raise ValueError( - f"CroCoDL session {testset_id!r} not found in registered scene {scene!r}: {self.scene_dir}" - ) - - self.reconstruction_dir = self.scene_dir / "rec" - self.images_dir = self.scene_dir / "images" - self.rgb_dir = self.images_dir - self.rec = _reconstruction if _reconstruction is not None else pycolmap.Reconstruction(self.reconstruction_dir) diff --git a/vidmap/datasets/eth3d_slam.py b/vidmap/datasets/eth3d_slam.py index cb0f52e..b001718 100644 --- a/vidmap/datasets/eth3d_slam.py +++ b/vidmap/datasets/eth3d_slam.py @@ -2,7 +2,7 @@ from vidmap.datasets.video import VideoDatasetManifest, VideoSequence -ETH3D_SLAM_PREPARATION_VERSION = 3 +ETH3D_SLAM_PREPARATION_VERSION = 4 ETH3D_SLAM_SCENES = ( "cables_1", diff --git a/vidmap/datasets/lamar.py b/vidmap/datasets/lamar.py deleted file mode 100644 index 48b9912..0000000 --- a/vidmap/datasets/lamar.py +++ /dev/null @@ -1,13 +0,0 @@ -"""LaMAR dataset parser.""" - -from vidmap.datasets.base import PreparedSceneParser -from vidmap.datasets.layouts import get_dataset_layout - -LAMAR_LAYOUT = get_dataset_layout("lamar") - - -class LaMARParser(PreparedSceneParser): - """Read a prepared LaMAR ground-truth reconstruction.""" - - def __init__(self, scene: str) -> None: - super().__init__(LAMAR_LAYOUT, scene) diff --git a/vidmap/datasets/names.py b/vidmap/datasets/names.py index fab8133..ca977fc 100644 --- a/vidmap/datasets/names.py +++ b/vidmap/datasets/names.py @@ -45,19 +45,7 @@ def __post_init__(self) -> None: ), DatasetDefinition( name="euroc", - scenes=( - "machine_hall-MH_01_easy", - "machine_hall-MH_02_easy", - "machine_hall-MH_03_medium", - "machine_hall-MH_04_difficult", - "machine_hall-MH_05_difficult", - "vicon_room1-V1_01_easy", - "vicon_room1-V1_02_medium", - "vicon_room1-V1_03_difficult", - "vicon_room2-V2_01_easy", - "vicon_room2-V2_02_medium", - "vicon_room2-V2_03_difficult", - ), + scenes=("machine_hall", "vicon_room1", "vicon_room2"), default_mode="all", default_evaluation_metric="pose_auc", preparer_module="vidmap.datasets.prepare.euroc", diff --git a/vidmap/datasets/prepare/crocodl.py b/vidmap/datasets/prepare/crocodl.py index ee4179e..8d048be 100644 --- a/vidmap/datasets/prepare/crocodl.py +++ b/vidmap/datasets/prepare/crocodl.py @@ -18,10 +18,10 @@ import yaml from tqdm import tqdm -from vidmap.datasets.base import validate_path_component +from vidmap.datasets.base import PreparedTestset, validate_path_component from vidmap.datasets.crocodl import benchmark_scene_name, parse_benchmark_scene, validate_ios_session from vidmap.datasets.layouts import get_dataset_layout -from vidmap.datasets.prepare.video_common import resumable_http_download, safe_extract_zip +from vidmap.datasets.prepare.helpers.video_common import resumable_http_download, safe_extract_zip from vidmap.datasets.resources import dataset_asset PoseKey: TypeAlias = tuple[int, str] @@ -41,7 +41,7 @@ _TESTSET_LENGTHS = (100, 250, 500, 1500) _SAMPLE_TESTSET = re.compile(r"sample-(\d+)\.yaml") _HUGGING_FACE_REPOSITORY = "https://huggingface.co/datasets/CroCoDL" -CROCODL_PREPARATION_VERSION = 2 +CROCODL_PREPARATION_VERSION = 3 CROCODL_LICENSE = "No dataset license declared by the pinned official Hugging Face repositories" @@ -374,14 +374,14 @@ def get_testset_data( return result -def _atomic_yaml(path: Path, data: Mapping[str, list[int]]) -> None: +def _atomic_yaml(path: Path, data: Mapping[str, PreparedTestset]) -> None: temporary = path.with_name(f".{path.name}.crocodl-tmp") if temporary.exists(): temporary.unlink() try: with temporary.open("w", encoding="utf-8", newline="\n") as file: yaml.safe_dump( - dict(sorted(data.items())), + {name: testset.as_dict() for name, testset in sorted(data.items())}, file, allow_unicode=False, default_flow_style=False, @@ -394,7 +394,7 @@ def _atomic_yaml(path: Path, data: Mapping[str, list[int]]) -> None: def write_combined_testsets( - testsets: Mapping[TestsetKey, Mapping[str, list[int]]], + testsets: Mapping[TestsetKey, Mapping[str, PreparedTestset]], output_dir: Path, location: str, *, @@ -409,10 +409,10 @@ def write_combined_testsets( raise CroCoDLFormatError(f"CroCoDL location {location!r} is not registered") output_dir.mkdir(parents=True, exist_ok=True) complete = testsets.get("complete") - if not complete or any(not image_ids for image_ids in complete.values()): + if not complete or any(not testset.image_ids for testset in complete.values()): raise CroCoDLFormatError(f"refusing to publish empty complete testsets for scene {scene!r}") if any( - not subsequences or any(not image_ids for image_ids in subsequences.values()) + not subsequences or any(not testset.image_ids for testset in subsequences.values()) for subsequences in testsets.values() ): raise CroCoDLFormatError(f"refusing to publish empty testsets for scene {scene!r}") @@ -554,12 +554,13 @@ def convert_single_camera_session( "num_images": len(images), "num_cameras": len(reconstruction.cameras), "preparation_version": CROCODL_PREPARATION_VERSION, + "variants": {"native": {"rig": None, "force_gt_keyframes": True}}, } if provenance is not None: metadata["source"] = dict(provenance) - session_dir = Path(output_dir) / location / session_id + session_dir = Path(output_dir) / f"ios-{location}" / session_id with _staged_session(session_dir) as staging: - images_dir, rec_dir = staging / "images" / "cam0", staging / "rec" + images_dir, rec_dir = staging / "native" / "images" / "cam0", staging / "native" / "rec" images_dir.mkdir(parents=True) rec_dir.mkdir() _materialize_images( @@ -571,7 +572,7 @@ def convert_single_camera_session( ) print(f" Writing {session_id} ({len(reconstruction.images)} images)") reconstruction.write_binary(rec_dir) - with (staging / "metadata.json").open("w", encoding="utf-8", newline="\n") as file: + with (staging / "preparation.json").open("w", encoding="utf-8", newline="\n") as file: json.dump(metadata, file, indent=2, sort_keys=True) file.write("\n") return ConversionResult(session_id, testsets) @@ -595,14 +596,19 @@ def _select_session_ids( def _combine_testsets( results: Sequence[ConversionResult], -) -> TestsetData: - combined: TestsetData = {} +) -> dict[TestsetKey, dict[str, PreparedTestset]]: + combined: dict[TestsetKey, dict[str, PreparedTestset]] = {} for result in sorted(results, key=lambda item: item.session_id): for length, subsequences in result.testsets.items(): destination = combined.setdefault(length, {}) if duplicates := destination.keys() & subsequences.keys(): raise CroCoDLFormatError(f"duplicate testset subsequences: {sorted(duplicates)}") - destination.update(subsequences) + destination.update( + { + name: PreparedTestset(result.session_id, "native", tuple(image_ids)) + for name, image_ids in subsequences.items() + } + ) return combined @@ -612,15 +618,14 @@ def _prepared_conversion_results( allowed_session_ids: Sequence[str] | None = None, ) -> list[ConversionResult]: """Recompute testsets from every prepared session in one converted location.""" - location_dir = Path(output_dir) / location allowed = None if allowed_session_ids is None else set(allowed_session_ids) results: list[ConversionResult] = [] - for session_dir in sorted(location_dir.iterdir()): + for session_dir in sorted((Path(output_dir) / f"ios-{location}").iterdir()): if not session_dir.name.startswith("ios_"): continue if allowed is not None and session_dir.name not in allowed: continue - metadata_path = session_dir / "metadata.json" + metadata_path = session_dir / "preparation.json" if not session_dir.is_dir() or not metadata_path.is_file(): continue try: @@ -634,10 +639,14 @@ def _prepared_conversion_results( session_id = validate_ios_session(metadata.get("session")) except (TypeError, ValueError) as error: raise CroCoDLFormatError(f"invalid prepared CroCoDL metadata {metadata_path}: {error}") from error - if metadata.get("location") != location or session_id != session_dir.name: - raise CroCoDLFormatError(f"prepared CroCoDL metadata does not match its location: {metadata_path}") - reconstruction_dir = session_dir / "rec" - images_dir = session_dir / "images" + if ( + metadata["location"] != location + or session_id != session_dir.name + or metadata["preparation_version"] != CROCODL_PREPARATION_VERSION + ): + raise CroCoDLFormatError(f"prepared CroCoDL metadata does not match its recording: {metadata_path}") + reconstruction_dir = session_dir / "native" / "rec" + images_dir = session_dir / "native" / "images" if not reconstruction_dir.is_dir() or not images_dir.is_dir(): raise CroCoDLFormatError(f"prepared CroCoDL session is incomplete: {session_dir}") try: @@ -711,10 +720,10 @@ def convert_location( def _official_session_is_prepared(archive: OfficialIosArchive) -> bool: - session_dir = CROCODL_DATA_DIR / archive.location / archive.session_id - metadata_path = session_dir / "metadata.json" - images_dir = session_dir / "images" - rec_dir = session_dir / "rec" + session_dir = CROCODL_DATA_DIR / f"ios-{archive.location}" / archive.session_id + metadata_path = session_dir / "preparation.json" + images_dir = session_dir / "native" / "images" + rec_dir = session_dir / "native" / "rec" if not metadata_path.is_file() or not images_dir.is_dir() or not rec_dir.is_dir(): return False try: @@ -733,6 +742,7 @@ def _official_session_is_prepared(archive: OfficialIosArchive) -> bool: return ( metadata.get("location") == archive.location and metadata.get("preparation_version") == CROCODL_PREPARATION_VERSION + and metadata["variants"] == {"native": {"rig": None, "force_gt_keyframes": True}} and metadata.get("session") == archive.session_id and source == { @@ -752,13 +762,21 @@ def official_scene_is_prepared(scene: str) -> bool: archives = _official_archives_for_scene(scene) if not archives or not all(_official_session_is_prepared(archive) for archive in archives): return False - testset = CROCODL_LAYOUT.testset_path(scene, "complete") try: - payload = yaml.safe_load(testset.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, yaml.YAMLError): + testsets = CROCODL_LAYOUT.read_testsets(scene, "complete") + except (OSError, UnicodeDecodeError, ValueError, yaml.YAMLError): return False - expected = {f"{archive.session_id}-0" for archive in archives} - return isinstance(payload, Mapping) and set(payload) == expected and all(payload[key] for key in expected) + expected = {f"{archive.session_id}-0": archive.session_id for archive in archives} + return ( + testsets is not None + and set(testsets) == set(expected) + and all( + testsets[name].recording == recording + and testsets[name].variant == "native" + and bool(testsets[name].image_ids) + for name, recording in expected.items() + ) + ) def _extract_official_archive(archive: OfficialIosArchive, downloaded: Path) -> Path: diff --git a/vidmap/datasets/prepare/eth3d_slam.py b/vidmap/datasets/prepare/eth3d_slam.py index 119d61e..dbbe1c3 100644 --- a/vidmap/datasets/prepare/eth3d_slam.py +++ b/vidmap/datasets/prepare/eth3d_slam.py @@ -16,11 +16,11 @@ import yaml from PIL import Image -from vidmap.datasets.base import CANONICAL_UNPOSED_IMAGES_FILENAME, DatasetLayout +from vidmap.datasets.base import CANONICAL_UNPOSED_IMAGES_FILENAME, DatasetLayout, PreparedTestset from vidmap.datasets.eth3d_slam import ETH3D_SLAM_MANIFEST, ETH3D_SLAM_PREPARATION_VERSION from vidmap.datasets.layouts import get_dataset_layout -from vidmap.datasets.prepare import filesystem as prep_fs -from vidmap.datasets.prepare.video_common import resumable_http_download, safe_extract_zip +from vidmap.datasets.prepare.helpers import filesystem as prep_fs +from vidmap.datasets.prepare.helpers.video_common import resumable_http_download, safe_extract_zip from vidmap.datasets.resources import dataset_asset _URL_TEMPLATE = "https://www.eth3d.net/data/slam/datasets/{scene}_mono.zip" @@ -115,19 +115,19 @@ def _read_unposed_names(scene_dir: Path) -> tuple[str, ...] | None: def _scene_is_complete(layout: DatasetLayout, scene: str) -> bool: - images_dir = layout.images_dir(scene) - rec_dir = layout.reconstruction_dir(scene) + images_dir = layout.images_dir(scene, "0", "native") + rec_dir = layout.reconstruction_dir(scene, "0", "native") if not images_dir.is_dir() or not prep_fs.complete_colmap_model(rec_dir): return False try: reconstruction = pycolmap.Reconstruction(rec_dir) testsets = layout.read_testsets(scene, "all") - preparation = json.loads((layout.scene_dir(scene) / "preparation.json").read_text(encoding="utf-8")) + preparation = json.loads((layout.recording_dir(scene, "0") / "preparation.json").read_text(encoding="utf-8")) except (OSError, RuntimeError, ValueError, json.JSONDecodeError): return False image_ids = sorted(reconstruction.images) names = {image.name for image in reconstruction.images.values()} - unposed = _read_unposed_names(layout.scene_dir(scene)) + unposed = _read_unposed_names(layout.input_dir(scene, "0", "native")) try: archive = _source_archive(scene) except ValueError: @@ -135,11 +135,12 @@ def _scene_is_complete(layout: DatasetLayout, scene: str) -> bool: return ( bool(reconstruction.cameras) and bool(image_ids) - and testsets == {"0": image_ids} + and testsets == {"0": PreparedTestset("0", "native", tuple(image_ids))} and unposed is not None and set(unposed) <= names and isinstance(preparation, Mapping) and preparation.get("preparation_version") == ETH3D_SLAM_PREPARATION_VERSION + and preparation["variants"] == {"native": {"rig": None, "force_gt_keyframes": True}} and (archive is None or preparation.get("source") == archive.record()) and (archive is None or preparation.get("gt_available") is archive.gt_available) and all( @@ -152,9 +153,9 @@ def _scene_is_complete(layout: DatasetLayout, scene: str) -> bool: def _pending_scenes(layout: DatasetLayout, scenes: tuple[str, ...] | None = None) -> tuple[str, ...]: pending = [] for scene in layout.scenes if scenes is None else scenes: - destination = layout.scene_dir(scene) + destination = layout.recording_dir(scene, "0") testset = layout.testset_path(scene, "all") - shutil.rmtree(layout.data_dir / f".{scene}.preparing", ignore_errors=True) + shutil.rmtree(destination.with_name(f".{destination.name}.preparing"), ignore_errors=True) testset.with_name(f".{testset.name}.preparing").unlink(missing_ok=True) if destination.exists() or testset.exists(): if not _scene_is_complete(layout, scene): @@ -325,19 +326,20 @@ def _prepare_scene( archive: SourceArchive | None = None, ) -> None: gt_available = True if archive is None else archive.gt_available - destination = layout.scene_dir(scene) + destination = layout.recording_dir(scene, "0") testset = layout.testset_path(scene, "all") temporary_testset = testset.with_name(f".{testset.name}.preparing") testset.parent.mkdir(parents=True, exist_ok=True) temporary_testset.unlink(missing_ok=True) try: with prep_fs.scene_staging(destination) as staging: - image_ids = _convert_scene(source, staging, gt_available=gt_available) + image_ids = _convert_scene(source, staging / "native", gt_available=gt_available) with (staging / "preparation.json").open("w", encoding="utf-8") as file: json.dump( { "gt_available": gt_available, "preparation_version": ETH3D_SLAM_PREPARATION_VERSION, + "variants": {"native": {"rig": None, "force_gt_keyframes": True}}, "source": None if archive is None else archive.record(), }, file, @@ -346,8 +348,9 @@ def _prepare_scene( ) file.write("\n") with temporary_testset.open("x", encoding="utf-8") as file: - yaml.safe_dump({0: image_ids}, file, sort_keys=False, default_flow_style=True) - if not prep_fs.complete_colmap_model(staging / "rec") or not image_ids: + testset_data = PreparedTestset("0", "native", tuple(image_ids)).as_dict() + yaml.safe_dump({0: testset_data}, file, sort_keys=False, default_flow_style=True) + if not prep_fs.complete_colmap_model(staging / "native" / "rec") or not image_ids: raise RuntimeError(f"ETH3D-SLAM conversion did not produce a complete scene: {staging}") if testset.exists() or testset.is_symlink(): raise RuntimeError(f"ETH3D-SLAM testset destination appeared during preparation: {testset}") diff --git a/vidmap/datasets/prepare/euroc.py b/vidmap/datasets/prepare/euroc.py index 3abd5fc..3087281 100644 --- a/vidmap/datasets/prepare/euroc.py +++ b/vidmap/datasets/prepare/euroc.py @@ -17,11 +17,12 @@ import yaml from scipy.spatial.transform import Rotation +from vidmap.datasets.base import PreparedTestset from vidmap.datasets.layouts import get_dataset_layout -from vidmap.datasets.prepare.video_common import checksum, resumable_http_download +from vidmap.datasets.prepare.helpers.video_common import checksum, resumable_http_download from vidmap.datasets.resources import dataset_asset -EUROC_PREPARATION_VERSION = 2 +EUROC_PREPARATION_VERSION = 3 EUROC_LICENSE = "In Copyright - Non-Commercial Use Permitted" EUROC_SOURCE = "https://www.research-collection.ethz.ch/items/bcaf173e-5dac-484b-bc37-faf97a594f1f" EUROC_LAYOUT = get_dataset_layout("euroc") @@ -41,7 +42,7 @@ class SourceArchive: @dataclass(frozen=True) class SequenceSource: scene: str - archive: str + recording: str member: str size: int sha256: str @@ -81,9 +82,9 @@ def source_inventory() -> tuple[dict[str, SourceArchive], dict[str, SequenceSour for name, raw in payload["archives"].items() } sequences = { - scene: SequenceSource( - scene=scene, - archive=raw["archive"], + recording: SequenceSource( + scene=raw["archive"], + recording=recording, member=raw["member"], size=int(raw["size"]), sha256=raw["sha256"], @@ -92,12 +93,10 @@ def source_inventory() -> tuple[dict[str, SourceArchive], dict[str, SequenceSour first_image=raw["first_image"], last_image=raw["last_image"], ) - for scene, raw in payload["sequences"].items() + for recording, raw in payload["sequences"].items() } - if tuple(sequences) != EUROC_LAYOUT.scenes: - raise ValueError("EuRoC source inventory does not exactly match the registered sequence manifest") - if any(sequence.archive not in archives for sequence in sequences.values()): - raise ValueError("EuRoC sequence inventory references an unknown source archive") + if tuple(archives) != EUROC_LAYOUT.scenes or {sequence.scene for sequence in sequences.values()} != set(archives): + raise ValueError("EuRoC source inventory does not exactly match the registered scenes") return archives, sequences @@ -273,9 +272,19 @@ def _images_bytes(images: tuple[ImagePose, ...]) -> bytes: return output.getvalue() -def canonical_testset_bytes(image_count: int) -> bytes: - """Return the canonical full-trajectory EuRoC testset bytes.""" - return yaml.safe_dump({0: list(range(image_count))}, default_flow_style=True).encode("utf-8") +def canonical_testset_bytes(scene: str) -> bytes: + """Select each recording in one scene as an independent full trajectory.""" + _, sequences = source_inventory() + testsets = { + sequence.recording: PreparedTestset( + sequence.recording, "rectified", tuple(range(sequence.canonical_images)) + ).as_dict() + for sequence in sequences.values() + if sequence.scene == scene + } + if not testsets: + raise ValueError(f"Unknown EuRoC scene {scene!r}") + return yaml.safe_dump(testsets, sort_keys=False, default_flow_style=True).encode("utf-8") def _write_exact(destination: Path, contents: bytes) -> None: @@ -295,10 +304,10 @@ def _write_exact(destination: Path, contents: bytes) -> None: partial.replace(destination) -def _ensure_testset(sequence: SequenceSource) -> None: +def _ensure_testset(scene: str) -> None: _write_exact( - EUROC_LAYOUT.testsets / sequence.scene / "all.yaml", - canonical_testset_bytes(sequence.canonical_images), + EUROC_LAYOUT.testset_path(scene, "all"), + canonical_testset_bytes(scene), ) @@ -312,7 +321,7 @@ def _validate_archive(path: Path, *, size: int, sha256: str) -> None: def _sequence_archive(sequence: SequenceSource, aggregate: SourceArchive, download_dir: Path) -> Path: - destination = download_dir / "sequences" / f"{sequence.scene}.zip" + destination = download_dir / "sequences" / f"{sequence.recording}.zip" if destination.exists(): _validate_archive(destination, size=sequence.size, sha256=sequence.sha256) return destination @@ -342,24 +351,27 @@ def _read_member(bundle: zipfile.ZipFile, member: str) -> bytes: return bundle.read(member) -def euroc_scene_is_prepared(scene_dir: Path) -> bool: - """Check the exact established image/pose/testset boundary for one sequence.""" - _, sequences = source_inventory() - if scene_dir.name not in sequences: +def _recording_is_prepared(recording_dir: Path, sequence: SequenceSource) -> bool: + """Check the established image and reference-model contents for one recording.""" + images_dir = recording_dir / "rectified" / "images" / "cam0" + rec_dir = recording_dir / "rectified" / "rec" + receipt = recording_dir / "preparation.json" + if ( + not receipt.is_file() + or not images_dir.is_dir() + or not all((rec_dir / name).is_file() for name in _MODEL_FILES) + ): return False - sequence = sequences[scene_dir.name] - images_dir = scene_dir / "images" / "cam0" - rec_dir = scene_dir / "rec" - testset = EUROC_LAYOUT.testsets / sequence.scene / "all.yaml" - if not images_dir.is_dir() or not all((rec_dir / name).is_file() for name in _MODEL_FILES): + preparation = json.loads(receipt.read_text(encoding="utf-8")) + if preparation["preparation_version"] != EUROC_PREPARATION_VERSION or preparation["variants"] != { + "rectified": {"rig": None, "force_gt_keyframes": True} + }: return False image_paths = tuple(sorted(path for path in images_dir.iterdir() if path.is_file())) if ( len(image_paths) != sequence.canonical_images or image_paths[0].name != sequence.first_image or image_paths[-1].name != sequence.last_image - or not testset.is_file() - or testset.read_bytes() != canonical_testset_bytes(sequence.canonical_images) ): return False reconstruction = pycolmap.Reconstruction(rec_dir) @@ -375,11 +387,29 @@ def euroc_scene_is_prepared(scene_dir: Path) -> bool: ) -def _write_preparation_record(scene_dir: Path, sequence: SequenceSource, aggregate: SourceArchive) -> None: +def euroc_scene_is_prepared(scene_dir: Path) -> bool: + """Check that every recording and the shared scene testset are prepared.""" + scene = scene_dir.name + if scene not in EUROC_LAYOUT.scenes: + return False + testset = EUROC_LAYOUT.testset_path(scene, "all") + if not testset.is_file() or testset.read_bytes() != canonical_testset_bytes(scene): + return False + _, sequences = source_inventory() + return all( + _recording_is_prepared(scene_dir / sequence.recording, sequence) + for sequence in sequences.values() + if sequence.scene == scene + ) + + +def _write_preparation_record(recording_dir: Path, sequence: SequenceSource, aggregate: SourceArchive) -> None: payload = { "dataset": "EuRoC MAV", - "sequence": sequence.scene, + "scene": sequence.scene, + "recording": sequence.recording, "preparation_version": EUROC_PREPARATION_VERSION, + "variants": {"rectified": {"rig": None, "force_gt_keyframes": True}}, "gt_evaluable": True, "camera": "cam0", "image_rectification": "opencv-radtan-alpha-0.0", @@ -391,34 +421,28 @@ def _write_preparation_record(scene_dir: Path, sequence: SequenceSource, aggrega "source": EUROC_SOURCE, } _write_exact( - scene_dir / "preparation.json", + recording_dir / "preparation.json", (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8"), ) -def prepare_scene(scene: str, download_dir: Path) -> None: +def prepare_recording(sequence: SequenceSource, aggregate: SourceArchive, download_dir: Path) -> None: """Prepare one official EuRoC sequence without replacing existing output.""" - archives, sequences = source_inventory() - if scene not in sequences: - raise ValueError(f"Unknown EuRoC sequence {scene!r}") - sequence = sequences[scene] - final_scene = EUROC_LAYOUT.data_dir / scene - if final_scene.exists() or final_scene.is_symlink(): - _ensure_testset(sequence) - if euroc_scene_is_prepared(final_scene): + destination = EUROC_LAYOUT.recording_dir(sequence.scene, sequence.recording) + if destination.exists() or destination.is_symlink(): + if _recording_is_prepared(destination, sequence): return - raise RuntimeError(f"Existing EuRoC scene is not the canonical prepared output: {final_scene}") + raise RuntimeError(f"Existing EuRoC recording is not the canonical prepared output: {destination}") - aggregate = archives[sequence.archive] sequence_path = _sequence_archive(sequence, aggregate, download_dir) - staging_scene = EUROC_LAYOUT.data_dir / ".euroc-preparing" / scene - staging_scene.mkdir(parents=True, exist_ok=True) + staging = EUROC_LAYOUT.data_dir / ".euroc-preparing" / sequence.scene / sequence.recording + staging.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(sequence_path) as bundle: calibration = parse_sensor(_read_member(bundle, "mav0/cam0/sensor.yaml").decode("utf-8")) image_index = parse_image_index(_read_member(bundle, "mav0/cam0/data.csv").decode("utf-8")) if len(image_index) != sequence.raw_cam0_images: raise ValueError( - f"EuRoC {scene} raw cam0 count mismatch: {len(image_index)} != {sequence.raw_cam0_images}" + f"EuRoC {sequence.recording} raw cam0 count mismatch: {len(image_index)} != {sequence.raw_cam0_images}" ) ground_truth = parse_ground_truth( _read_member(bundle, "mav0/state_groundtruth_estimate0/data.csv").decode("utf-8") @@ -429,24 +453,23 @@ def prepare_scene(scene: str, download_dir: Path) -> None: or poses[0].name != sequence.first_image or poses[-1].name != sequence.last_image ): - raise ValueError(f"EuRoC {scene} canonical GT-range inventory mismatch") + raise ValueError(f"EuRoC {sequence.recording} canonical GT-range inventory mismatch") rectified, map1, map2 = _rectification(calibration) for image in poses: contents = _read_member(bundle, f"mav0/cam0/data/{image.name}") _write_exact( - staging_scene / "images" / "cam0" / image.name, + staging / "rectified" / "images" / "cam0" / image.name, _rectify_png(contents, map1, map2, (calibration.width, calibration.height)), ) - _write_exact(staging_scene / "rec" / "cameras.bin", _camera_bytes(calibration, rectified)) - _write_exact(staging_scene / "rec" / "images.bin", _images_bytes(poses)) - _write_exact(staging_scene / "rec" / "points3D.bin", struct.pack(" None: @@ -461,23 +484,26 @@ def main( download: bool = True, delete_files: bool = False, ) -> None: - """Download official bundles and prepare selected or all EuRoC sequences.""" + """Download official bundles and prepare every recording in the selected scenes.""" selected = EUROC_LAYOUT.select_scenes(scenes) archives, sequences = source_inventory() pending = [] for scene in selected: - final_scene = EUROC_LAYOUT.data_dir / scene - if final_scene.exists() or final_scene.is_symlink(): - _ensure_testset(sequences[scene]) - if not euroc_scene_is_prepared(final_scene): - raise RuntimeError(f"Existing EuRoC scene is not the canonical prepared output: {final_scene}") + _ensure_testset(scene) + for sequence in sequences.values(): + if sequence.scene not in selected: + continue + destination = EUROC_LAYOUT.recording_dir(sequence.scene, sequence.recording) + if destination.exists() or destination.is_symlink(): + if not _recording_is_prepared(destination, sequence): + raise RuntimeError(f"Existing EuRoC recording is not the canonical prepared output: {destination}") else: - pending.append(scene) + pending.append(sequence) if not pending: return download_dir = EUROC_LAYOUT.data_dir / "downloads" - needed_archives = tuple(dict.fromkeys(sequences[scene].archive for scene in pending)) + needed_archives = tuple(dict.fromkeys(sequence.scene for sequence in pending)) if download: for archive_name in needed_archives: archive = archives[archive_name] @@ -491,11 +517,11 @@ def main( for archive_name in needed_archives: archive = archives[archive_name] _validate_archive(download_dir / archive.filename, size=archive.size, sha256=archive.sha256) - for scene in pending: - prepare_scene(scene, download_dir) + for sequence in pending: + prepare_recording(sequence, archives[sequence.scene], download_dir) if delete_files: - for scene in pending: - (download_dir / "sequences" / f"{scene}.zip").unlink(missing_ok=True) + for sequence in pending: + (download_dir / "sequences" / f"{sequence.recording}.zip").unlink(missing_ok=True) for archive_name in needed_archives: (download_dir / archives[archive_name].filename).unlink(missing_ok=True) diff --git a/vidmap/datasets/prepare/helpers/__init__.py b/vidmap/datasets/prepare/helpers/__init__.py new file mode 100644 index 0000000..3088c87 --- /dev/null +++ b/vidmap/datasets/prepare/helpers/__init__.py @@ -0,0 +1 @@ +"""Supporting modules for dataset and capture preparation.""" diff --git a/vidmap/datasets/prepare/filesystem.py b/vidmap/datasets/prepare/helpers/filesystem.py similarity index 100% rename from vidmap/datasets/prepare/filesystem.py rename to vidmap/datasets/prepare/helpers/filesystem.py diff --git a/vidmap/datasets/prepare/panorama_capture.py b/vidmap/datasets/prepare/helpers/panorama_capture.py similarity index 67% rename from vidmap/datasets/prepare/panorama_capture.py rename to vidmap/datasets/prepare/helpers/panorama_capture.py index 4fc6e6f..88607ca 100644 --- a/vidmap/datasets/prepare/panorama_capture.py +++ b/vidmap/datasets/prepare/helpers/panorama_capture.py @@ -15,11 +15,27 @@ build_capture_reconstruction, resolve_capture_frames, ) -from vidmap.datasets.prepare.filesystem import prepare_image_directory +from vidmap.datasets.prepare.helpers.filesystem import prepare_image_directory from vidmap.frontend.cache.metadata import fingerprint +from vidmap.frontend.options.matching import RoMaImageOptions +from vidmap.frontend.options.rig import PanoramaOptions from vidmap.repro.hashing import sha256_file +def camera_from_panorama(view): + """Return the declared view rotation in COLMAP's +Z-forward, +Y-down frame.""" + import pycolmap + + return ( + pycolmap.Rotation3d(np.array([-math.radians(view["pitch_degrees"]), 0, 0])) + * pycolmap.Rotation3d(np.array([0, -math.radians(view["yaw_degrees"]), 0])) + ).matrix() + + +def _image_name(camera_id, frame): + return f"cam{camera_id}/{Path(frame['image_name']).stem}.png" + + def _geometry(identity): import pycolmap from pycolmap.panorama import create_pano_rig_config @@ -30,13 +46,7 @@ def _geometry(identity): if not math.isfinite(focal) or focal <= 0: raise ValueError("Projection focal length must be finite and positive") ids = sorted(projection["views"]) - rotations = [ - ( - pycolmap.Rotation3d(np.array([-math.radians(projection["views"][cid]["pitch_degrees"]), 0, 0])) - * pycolmap.Rotation3d(np.array([0, -math.radians(projection["views"][cid]["yaw_degrees"]), 0])) - ).matrix() - for cid in ids - ] + rotations = [camera_from_panorama(projection["views"][cid]) for cid in ids] config = create_pano_rig_config(rotations, ref_idx=0) cameras = [ pycolmap.Camera( @@ -57,7 +67,24 @@ def _geometry(identity): return cameras, rig, rotations -def _inputs(image_dir, options): +def _image_size( + projection: PanoramaOptions, panorama_size: tuple[int, int], image_options: RoMaImageOptions +) -> tuple[int, int]: + """Resolve view dimensions using the tracking resize policy.""" + width = panorama_size[0] * projection.horizontal_fov_degrees / 360 + aspect_width, aspect_height = projection.aspect_ratio + size = (width, width * aspect_height / aspect_width) + resize_max = image_options.resize_max + if resize_max and (image_options.resize_force or max(size) > resize_max): + scale = resize_max / max(size) + size = tuple(value * scale for value in size) + size = tuple(round(value) for value in size) + if min(size) < 1: + raise ValueError("Panorama aspect ratio and tracking resolution must produce positive image dimensions") + return size + + +def _inputs(image_dir, options, image_names, image_options): projection = asdict(options.projection) root = Path(image_dir).expanduser().resolve(strict=True) if not root.is_dir(): @@ -65,6 +92,13 @@ def _inputs(image_dir, options): frames = resolve_capture_frames(root, ("cam0",), options.frames) if any(row["camera_id"] != 0 or not row["image_name"].startswith("cam0/") for row in frames): raise ValueError("Every panorama frame must contain exactly one cam0/ image") + if image_names is not None: + selected = set(image_names) + frames = tuple(frame for frame in frames if frame["image_name"] in selected) + if not frames or {frame["image_name"] for frame in frames} != selected: + raise ValueError("Panorama selection must contain known source images") + if len({Path(frame["image_name"]).stem for frame in frames}) != len(frames): + raise ValueError("Panorama filenames must have unique stems") records, panorama_size = [], None for row in frames: source = (root / row["image_name"]).resolve(strict=True) @@ -79,8 +113,9 @@ def _inputs(image_dir, options): records.append( {key: value for key, value in row.items() if key != "camera_id"} | {"sha256": sha256_file(source)} ) + projection["image_size"] = _image_size(options.projection, panorama_size, image_options) identity = dict( - schema_version=2, + schema_version=3, renderer="colmap-opencv-linear-wrap-v2", projection=projection, panorama_size=panorama_size, @@ -100,7 +135,7 @@ def _capture(output, identity, hashes, preview_image_dir, cameras, rig, rotation frame_id=frame["frame_id"], timestamp_ns=frame["timestamp_ns"], camera_id=camera.camera_id, - image_name=f"camera-{camera.camera_id}/{frame['frame_id']}.png", + image_name=_image_name(camera.camera_id, frame), ) for frame in identity["frames"] for camera in cameras @@ -133,7 +168,7 @@ def _render(identity, source_root, target, cameras, rotations): xy = spherical_img_from_cam(tuple(identity["panorama_size"]), rays @ rotation) xy = (xy.reshape(width, height, 2).transpose(1, 0, 2) - 0.5).astype(np.float32) xy[..., 1] = np.clip(xy[..., 1], 0, identity["panorama_size"][1] - 1) - directory = target / f"camera-{camera.camera_id}" + directory = target / f"cam{camera.camera_id}" directory.mkdir(parents=True) maps.append((directory, xy)) @@ -142,36 +177,30 @@ def _render(identity, source_root, target, cameras, rotations): pixels = np.asarray(image.convert("RGB")) for directory, xy in maps: rendered = cv2.remap(pixels, xy, None, cv2.INTER_LINEAR, borderMode=cv2.BORDER_WRAP) - Image.fromarray(rendered).save(directory / f"{frame['frame_id']}.png") + Image.fromarray(rendered).save(directory / f"{Path(frame['image_name']).stem}.png") def prepare_panorama_capture( - image_dir: str | Path, options, output_dir: str | Path, *, frontend_options + image_dir: str | Path, options, output_dir: str | Path, *, frontend_options, image_names=None ) -> CaptureSnapshot: - """Render a declared 2:1 panorama capture as RGB pinhole images; never overwrite existing output. + """Render pinhole views at tracking resolution. - ``projection.image_size`` is [width, height]. Angles define camera-from-panorama - ``Rx(-pitch) Ry(-yaw)`` in COLMAP's +Z-forward, +Y-down convention. - Source timestamps and frame IDs remain - explicit; no image pose or GT is read. Cache identity binds source file contents, - source associations and the full declared projection, independently of location. + Reuse output only when source files, frame associations and projection match. """ output = Path(output_dir).expanduser().absolute() - identity, preview_image_dir = _inputs(image_dir, options) + identity, preview_image_dir = _inputs(image_dir, options, image_names, frontend_options.tracks.images) cameras, rig, rotations = _geometry(identity) from vidmap.frontend.preparation.cameras import resolve_camera_preparation capture = _capture(output, identity, {}, preview_image_dir, cameras, rig, rotations).capture resolve_camera_preparation(capture, frontend_options) names = tuple( - f"camera-{cid}/{frame['frame_id']}.png" - for frame in identity["frames"] - for cid in sorted(identity["projection"]["views"]) + _image_name(cid, frame) for frame in identity["frames"] for cid in sorted(identity["projection"]["views"]) ) def write_images(image_dir): _render(identity, preview_image_dir, image_dir, cameras, rotations) - if _inputs(preview_image_dir, options)[0] != identity: + if _inputs(preview_image_dir, options, image_names, frontend_options.tracks.images)[0] != identity: raise ValueError("Source capture changed while rendering") hashes = prepare_image_directory(output, identity, names, write_images) diff --git a/vidmap/datasets/prepare/rig_capture.py b/vidmap/datasets/prepare/helpers/rig_capture.py similarity index 98% rename from vidmap/datasets/prepare/rig_capture.py rename to vidmap/datasets/prepare/helpers/rig_capture.py index a4a1e74..1a4451e 100644 --- a/vidmap/datasets/prepare/rig_capture.py +++ b/vidmap/datasets/prepare/helpers/rig_capture.py @@ -13,7 +13,7 @@ resolve_capture_frames, ) from vidmap.datasets.local import select_images -from vidmap.datasets.prepare.filesystem import prepare_image_directory +from vidmap.datasets.prepare.helpers.filesystem import prepare_image_directory def load_capture(image_dir: str | Path, options=None, *, imnames=None) -> PreparedCapture: diff --git a/vidmap/datasets/prepare/timestamp_matching.py b/vidmap/datasets/prepare/helpers/timestamp_matching.py similarity index 100% rename from vidmap/datasets/prepare/timestamp_matching.py rename to vidmap/datasets/prepare/helpers/timestamp_matching.py diff --git a/vidmap/datasets/prepare/video_common.py b/vidmap/datasets/prepare/helpers/video_common.py similarity index 100% rename from vidmap/datasets/prepare/video_common.py rename to vidmap/datasets/prepare/helpers/video_common.py diff --git a/vidmap/datasets/prepare/lamar.py b/vidmap/datasets/prepare/lamar.py index fc394d4..6f36920 100644 --- a/vidmap/datasets/prepare/lamar.py +++ b/vidmap/datasets/prepare/lamar.py @@ -9,11 +9,14 @@ import logging import re import shutil +import struct import tarfile import zipfile from collections.abc import Iterable from dataclasses import dataclass +from functools import cache from pathlib import Path +from tempfile import TemporaryDirectory import numpy as np import pycolmap @@ -21,8 +24,8 @@ from PIL import Image from vidmap.datasets.layouts import get_dataset_layout -from vidmap.datasets.prepare import filesystem as prep_fs -from vidmap.datasets.prepare.video_common import resumable_http_download +from vidmap.datasets.prepare.helpers import filesystem as prep_fs +from vidmap.datasets.prepare.helpers.video_common import resumable_http_download from vidmap.datasets.resources import dataset_asset logger = logging.getLogger(__name__) @@ -34,12 +37,7 @@ _GROUND_TRUTH_MODELS_URL = ( f"https://github.com/cvg/vidmap/releases/download/dataset-assets-v2/{_GROUND_TRUTH_MODELS_FILENAME}" ) -_PREPARATION_PROFILE = "sample-300" -_SAMPLE_SHA256 = { - "CAB": "71f3741af6f372a3db7fd2ab10c89b7111c0ff537c16a13baa68fff29462a08a", - "HGE": "e496c0b26920e02139d2d183d6466747074e17f679c4b20a4e4721247dc1a890", - "LIN": "273ea2532e036097179d5b96e6e486e4913b44472f4af81d01cba7f1b1821114", -} +_PREPARATION_VERSION = 1 _EXPECTED_GT_IMAGE_COUNTS = {"CAB": 206_161, "HGE": 130_953, "LIN": 117_970} _EXPECTED_SAMPLE_IMAGE_COUNTS = {"CAB": 36_334, "HGE": 27_289, "LIN": 30_866} _MODEL_FILES = ("cameras.bin", "images.bin", "points3D.bin") @@ -82,13 +80,13 @@ def _ground_truth_models_archive(downloads: Path, *, download: bool) -> Path: ) -def source_inventory() -> dict[str, tuple[SourceArchive, ...]]: - """Load the checksum-pinned official archives needed by sample-300.""" +def source_inventory(profile: str = "sample-300") -> dict[str, tuple[SourceArchive, ...]]: + """Load checksum-pinned official session archives for the requested profile.""" payload = json.loads(dataset_asset("lamar", "source_archives.json").read_text(encoding="utf-8")) base_url = payload["base_url"] inventory = {} for scene in LAMAR_LAYOUT.scenes: - required = _sample_sessions(scene) + required = _profile_sessions(scene, profile) entries = [] for raw in payload["scenes"][scene]["archives"]: if Path(raw["filename"]).stem in required: @@ -103,9 +101,7 @@ def source_inventory() -> dict[str, tuple[SourceArchive, ...]]: ) selected = {Path(entry.filename).stem for entry in entries} if selected != required: - raise ValueError( - f"LaMAR {scene} source inventory does not cover sample-300: {sorted(required - selected)}" - ) + raise ValueError(f"LaMAR {scene} source inventory does not cover {profile}: {sorted(required - selected)}") inventory[scene] = tuple(entries) return inventory @@ -183,10 +179,9 @@ def infer_rotation(camera: RawCamera, target: pycolmap.Camera) -> int: matches = [] for rotations in range(4): candidate = rotate_camera(camera, rotations) - if (candidate.width, candidate.height) == ( - target.width, - target.height, - ) and np.allclose(candidate.params, target.params, rtol=0.0, atol=1e-9): + if (candidate.width, candidate.height) == (target.width, target.height) and np.allclose( + candidate.params, target.params, rtol=0.0, atol=1e-9 + ): matches.append(rotations) if len(matches) != 1: raise ValueError(f"Could not uniquely match LaMAR camera {camera} to {target}; rotations={matches}") @@ -210,13 +205,20 @@ def generate_testsets(scene: str, image_ids: set[int]) -> dict[str, dict[str, li return result -def canonical_testsets(scene: str) -> dict[str, bytes]: - """Read the exact canonical sample-300 YAML bytes for one scene.""" +def canonical_testsets(scene: str, profile: str = "sample-300") -> dict[str, bytes]: + """Read the exact canonical testset YAML bytes for one scene.""" + if profile != "sample-300": + raise ValueError(f"Unknown LaMAR profile: {profile}") return {"sample": dataset_asset("lamar", "testsets", scene, "sample-300.yaml").read_bytes()} +@cache +def _parse_selection(contents: bytes) -> dict[str, list[int]]: + return yaml.safe_load(contents) + + def _sample_mapping(scene: str) -> dict[str, list[int]]: - mapping = yaml.safe_load(canonical_testsets(scene)["sample"]) + mapping = _parse_selection(canonical_testsets(scene)["sample"]) if not isinstance(mapping, dict): raise ValueError(f"Bundled LaMAR sample-300 testset is malformed for {scene}") return mapping @@ -232,37 +234,53 @@ def _sample_sessions(scene: str) -> set[str]: return sessions -def _sample_image_names(scene: str, ground_truth: pycolmap.Reconstruction) -> set[str]: - image_ids = {image_id for values in _sample_mapping(scene).values() for image_id in values} +def _selected_image_names(scene: str, ground_truth: pycolmap.Reconstruction, profile: str = "sample-300") -> set[str]: + mapping = _parse_selection(next(iter(canonical_testsets(scene, profile).values()))) + image_ids = {image_id for values in mapping.values() for image_id in values} by_id = {int(image.image_id): str(image.name) for image in ground_truth.images.values()} missing = image_ids - by_id.keys() if missing: - raise ValueError(f"LaMAR {scene} sample-300 references missing GT image IDs: {sorted(missing)[:3]}") + raise ValueError(f"LaMAR {scene} selection references missing image IDs: {sorted(missing)[:3]}") names = {by_id[image_id] for image_id in image_ids} expected = _EXPECTED_SAMPLE_IMAGE_COUNTS[scene] if len(names) != expected: - raise ValueError(f"LaMAR {scene} sample-300 image count mismatch: expected {expected}, got {len(names)}") + raise ValueError(f"LaMAR {scene} {profile} image count mismatch: expected {expected}, got {len(names)}") sessions = {Path(name).parent.as_posix() for name in names} - if sessions != _sample_sessions(scene): - raise ValueError(f"LaMAR {scene} sample-300 session mapping is inconsistent") + if sessions != _profile_sessions(scene, profile): + raise ValueError(f"LaMAR {scene} {profile} session mapping is inconsistent") return names -def _ensure_testsets(scenes: Iterable[str]) -> None: - """Install exact canonical YAML bytes, refusing to replace any mismatch.""" +def _ensure_testsets(scenes: Iterable[str], profile: str = "sample-300") -> None: + """Install benchmark cases with explicit recording and variant references.""" for scene in scenes: - for mode, contents in canonical_testsets(scene).items(): - destination = LAMAR_LAYOUT.testsets / scene / f"{mode}.yaml" + selections = {mode: {} for mode in canonical_testsets(scene, profile)} + for recording in sorted(_profile_sessions(scene, profile)): + manifest = json.loads((LAMAR_LAYOUT.recording_dir(scene, recording) / "preparation.json").read_text()) + for mode, cases in manifest["testsets"].items(): + for case, image_ids in cases.items(): + if case in selections[mode]: + raise ValueError(f"Duplicate LaMAR benchmark case: {scene}/{case}") + selections[mode][case] = { + "recording": recording, + "variant": "native", + "image_ids": image_ids, + } + for mode, canonical in canonical_testsets(scene, profile).items(): + expected = _parse_selection(canonical) + cases = selections[mode] + if {case: entry["image_ids"] for case, entry in cases.items()} != expected: + raise ValueError(f"LaMAR {scene}/{mode} selection differs from the canonical asset") + contents = yaml.safe_dump({case: cases[case] for case in expected}, sort_keys=False).encode() + destination = LAMAR_LAYOUT.testset_path(scene, mode) if destination.exists() or destination.is_symlink(): if not destination.is_file() or destination.read_bytes() != contents: - raise RuntimeError(f"Existing LaMAR testset differs from the canonical asset: {destination}") + raise RuntimeError(f"Existing LaMAR testset differs from the prepared selection: {destination}") continue destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_suffix(destination.suffix + ".part") - if temporary.exists(): - raise RuntimeError(f"Unexpected LaMAR testset staging artifact: {temporary}") temporary.write_bytes(contents) - temporary.rename(destination) + temporary.replace(destination) def _extract_ground_truth(scene: str, destination: Path, ground_truth_models: Path) -> None: @@ -297,149 +315,227 @@ def _copy_image(bundle: zipfile.ZipFile, member: str, destination: Path, rotatio temporary.rename(destination) -def _convert_scene( - scene: str, - archives: tuple[Path, ...], - destination: Path, - ground_truth_models: Path, -) -> None: - rec_dir = destination / "rec" - _extract_ground_truth(scene, rec_dir, ground_truth_models) - ground_truth = pycolmap.Reconstruction(rec_dir) - if ground_truth.num_images() != _EXPECTED_GT_IMAGE_COUNTS[scene]: - raise ValueError(f"LaMAR {scene} GT image count mismatch") - by_name = {image.name: image for image in ground_truth.images.values()} - required_names = _sample_image_names(scene, ground_truth) - archive_sessions = {path.stem for path in archives} - if archive_sessions != _sample_sessions(scene): - raise ValueError(f"LaMAR {scene} archives do not exactly match the sample-300 sessions") +def _recording_testsets(scene, profile, ground_truth): + result = {recording: {} for recording in _profile_sessions(scene, profile)} + for mode, contents in canonical_testsets(scene, profile).items(): + cases = _parse_selection(contents) + if profile == "sample-300" and generate_testsets(scene, set(ground_truth.images))[mode] != cases: + raise ValueError(f"Canonical LaMAR {scene}/{mode} testset content mismatch") + for case, image_ids in cases.items(): + recordings = {Path(ground_truth.images[image_id].name).parent.as_posix() for image_id in image_ids} + if len(recordings) != 1: + raise ValueError(f"LaMAR case must belong to one recording: {scene}/{case}") + recording = recordings.pop() + result[recording].setdefault(mode, {})[case] = image_ids + return result + +def _write_recording_model(destination, records, cameras): + """Preserve source IDs and pose sentinels while flattening recording-local names.""" + destination.mkdir(parents=True) + selected_cameras = sorted({values[-1] for values in records.values()}) + with (destination / "cameras.bin").open("xb") as stream: + stream.write(struct.pack(" bool: - """Return whether an existing scene has every canonical LaMAR artifact.""" - scene = scene_dir.name - if scene not in _EXPECTED_GT_IMAGE_COUNTS: - return False - if not prep_fs.complete_colmap_model(scene_dir / "rec"): - return False - manifest_path = scene_dir / "preparation.json" - if not manifest_path.is_file(): - return False - ground_truth = pycolmap.Reconstruction(scene_dir / "rec") - if ground_truth.num_images() != _EXPECTED_GT_IMAGE_COUNTS[scene]: - return False - if any(not image.name.startswith("cam0/") for image in ground_truth.images.values()): - return False - for image in ground_truth.images.values(): - image.name = image.name.removeprefix("cam0/") - expected_names = {f"cam0/{name}" for name in _sample_image_names(scene, ground_truth)} - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - if not isinstance(manifest, dict): +def _convert_scene( + scene: str, + archives: tuple[Path, ...], + destination: Path, + ground_truth_models: Path, + *, + profile: str = "sample-300", +) -> None: + destination.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory(prefix=f".{scene}-", dir=destination) as temporary: + source_model = Path(temporary) / "rec" + _extract_ground_truth(scene, source_model, ground_truth_models) + ground_truth = pycolmap.Reconstruction(source_model) + if ground_truth.num_images() != _EXPECTED_GT_IMAGE_COUNTS[scene] or ground_truth.num_points3D(): + raise ValueError(f"LaMAR {scene} GT inventory mismatch") + if any(camera.model != pycolmap.CameraModelId.PINHOLE for camera in ground_truth.cameras.values()): + raise ValueError("Expected PINHOLE cameras in the LaMAR source model") + required_names = _selected_image_names(scene, ground_truth, profile) + testsets = _recording_testsets(scene, profile, ground_truth) + records = {recording: {} for recording in testsets} + with (source_model / "images.bin").open("rb") as stream: + for name, values in _image_records(stream): + if name in required_names: + records[Path(name).parent.as_posix()][name] = values + for archive in archives: + recording = archive.stem + if recording not in testsets: + raise ValueError(f"Unexpected LaMAR source recording: {recording}") + target = destination / recording + logger.info(f"Preparing LaMAR {scene}/{recording}") + with prep_fs.scene_staging(target) as staging: + _convert_recording( + scene, + archive, + staging, + ground_truth, + records[recording], + testsets[recording], + profile, + ) + if not lamar_recording_is_prepared(staging, profile=profile, recording=recording): + raise RuntimeError(f"LaMAR preparation did not produce a complete recording: {recording}") + if target.exists(): + raise RuntimeError(f"LaMAR recording destination appeared during preparation: {target}") + staging.rename(target) + + +def lamar_recording_is_prepared( + recording_dir: Path, profile: str | None = None, *, recording: str | None = None +) -> bool: + """Validate one recording independently of external benchmark selection files.""" + recording = recording_dir.name if recording is None else recording + manifest_path = recording_dir / "preparation.json" + rec_dir = recording_dir / "native/rec" + if not manifest_path.is_file() or not prep_fs.complete_colmap_model(rec_dir): return False - manifest_keys = { - "dataset", - "data_version", - "device", - "ground_truth_asset_sha256", - "images", - "profile", - "rotated_images", - "scene", - "source_archives", - } - if set(manifest) != manifest_keys: + manifest = json.loads(manifest_path.read_text()) + if "preparation_version" not in manifest or manifest["preparation_version"] != _PREPARATION_VERSION: return False - expected_archives = [source.filename for source in source_inventory()[scene]] + scene = manifest["scene"] if ( - manifest["dataset"] != "LaMAR" + scene not in LAMAR_LAYOUT.scenes + or manifest["recording"] != recording + or manifest["dataset"] != "LaMAR" or manifest["data_version"] != "2.2" or manifest["device"] != "iOS" or manifest["ground_truth_asset_sha256"] != _GROUND_TRUTH_MODELS_SHA256 - or manifest["images"] != len(expected_names) - or manifest["profile"] != _PREPARATION_PROFILE - or manifest["scene"] != scene - or manifest["source_archives"] != expected_archives + or (profile is not None and manifest["profile"] != profile) + or manifest["source_archives"] != [f"{recording}.zip"] + or manifest["variants"] != {"native": {"rig": None, "force_gt_keyframes": True}} + or recording not in _profile_sessions(scene, manifest["profile"]) ): return False - images = scene_dir / "images" - if not images.is_dir(): + if manifest["model_sha256"] != {name: _sha256(rec_dir / name) for name in _MODEL_FILES}: return False - actual_names = {path.relative_to(images).as_posix() for path in images.rglob("*.jpg") if path.is_file()} - if actual_names != expected_names: + canonical = { + mode: _parse_selection(contents) for mode, contents in canonical_testsets(scene, manifest["profile"]).items() + } + if set(manifest["testsets"]) != set(canonical): + return False + image_ids = set() + for mode, cases in manifest["testsets"].items(): + if not cases or any( + case not in canonical[mode] or ids != canonical[mode][case] for case, ids in cases.items() + ): + return False + image_ids.update(image_id for ids in cases.values() for image_id in ids) + with (rec_dir / "images.bin").open("rb") as stream: + records = dict(_image_records(stream)) + if {values[0] for values in records.values()} != image_ids or manifest["images"] != len(records): return False - sample = LAMAR_LAYOUT.testset_path(scene, "sample") - return sample.is_file() and _sha256(sample) == _SAMPLE_SHA256[scene] + images_dir = recording_dir / "native/images" + names = {path.relative_to(images_dir).as_posix() for path in images_dir.rglob("*.jpg") if path.is_file()} + return names == records.keys() and all(Path(name).parent.as_posix() == "cam0" for name in records) + + +def lamar_scene_is_prepared(dataset_root: Path, scene: str, profile: str = "sample-300") -> bool: + """Return whether all recordings selected by one benchmark group are ready.""" + return all( + lamar_recording_is_prepared(dataset_root / scene / recording, profile=profile) + for recording in _profile_sessions(scene, profile) + ) -def _pending_scenes(scenes: tuple[str, ...]) -> tuple[str, ...]: - pending = [] +def _pending_recordings(scenes, profile): + pending = {} for scene in scenes: - destination = LAMAR_LAYOUT.scene_dir(scene) - if destination.exists(): - if not lamar_scene_is_prepared(destination): - raise RuntimeError(f"LaMAR scene destination is partial or incompatible: {destination}") - continue - pending.append(scene) - return tuple(pending) + missing = [] + for recording in sorted(_profile_sessions(scene, profile)): + destination = LAMAR_LAYOUT.recording_dir(scene, recording) + if destination.exists() or destination.is_symlink(): + if not lamar_recording_is_prepared(destination, profile=profile): + raise RuntimeError(f"LaMAR recording destination is partial or incompatible: {destination}") + else: + missing.append(recording) + if missing: + pending[scene] = set(missing) + return pending + + +def _profile_sessions(scene: str, profile: str) -> set[str]: + if profile != "sample-300": + raise ValueError(f"Unknown LaMAR profile: {profile}") + return _sample_sessions(scene) + + +def _image_records(stream): + """Read COLMAP image headers without retaining 2D observations.""" + for _ in range(struct.unpack(" None: """Download official iOS sessions and prepare selected or all LaMAR scenes.""" selected = LAMAR_LAYOUT.select_scenes(scenes) - _ensure_testsets(selected) - pending = _pending_scenes(selected) + pending = _pending_recordings(selected, profile) if not pending: + _ensure_testsets(selected, profile) return - inventory = source_inventory() + inventory = source_inventory(profile) downloads = LAMAR_LAYOUT.data_dir / "downloads" downloads.mkdir(parents=True, exist_ok=True) ground_truth_models = _ground_truth_models_archive(downloads, download=download) - for scene in pending: + for scene, recordings in pending.items(): archive_paths = [] for source in inventory[scene]: + if Path(source.filename).stem not in recordings: + continue path = downloads / scene / source.filename if download: resumable_http_download( @@ -472,20 +571,21 @@ def main( elif not path.is_file() or path.stat().st_size != source.size or _sha256(path) != source.sha256: raise FileNotFoundError(f"Missing checksum-valid LaMAR archive: {path}") archive_paths.append(path) - destination = LAMAR_LAYOUT.scene_dir(scene) - with prep_fs.scene_staging(destination) as staging: - _convert_scene(scene, tuple(archive_paths), staging, ground_truth_models) - if destination.exists(): - raise RuntimeError(f"LaMAR scene destination appeared during preparation: {destination}") - staging.rename(destination) - if not lamar_scene_is_prepared(destination): - raise RuntimeError(f"LaMAR preparation did not produce a complete scene: {scene}") + _convert_scene( + scene, + tuple(archive_paths), + LAMAR_LAYOUT.scene_dir(scene), + ground_truth_models, + profile=profile, + ) + _ensure_testsets((scene,), profile) if delete_files: for path in archive_paths: path.unlink() scene_downloads = downloads / scene if not any(scene_downloads.iterdir()): scene_downloads.rmdir() + _ensure_testsets(selected, profile) if delete_files: ground_truth_models.unlink() @@ -494,6 +594,7 @@ def main( logging.basicConfig(level=logging.INFO, format="%(message)s") parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--scenes", nargs="+", choices=LAMAR_LAYOUT.scenes) + parser.add_argument("--profile", choices=["sample-300"], default="sample-300") parser.add_argument( "--no-download", action="store_true", @@ -509,4 +610,5 @@ def main( arguments.scenes, download=not arguments.no_download, delete_files=arguments.delete_downloads, + profile=arguments.profile, ) diff --git a/vidmap/datasets/registry.py b/vidmap/datasets/registry.py index bc9caab..a7a5b4f 100644 --- a/vidmap/datasets/registry.py +++ b/vidmap/datasets/registry.py @@ -2,7 +2,7 @@ from __future__ import annotations -from functools import cache, partial +from functools import cache from typing import TYPE_CHECKING from vidmap.datasets.names import get_dataset_definition @@ -16,47 +16,46 @@ def get_dataset_spec(name: str) -> DatasetSpec: """Construct and cache one requested dataset specification.""" definition = get_dataset_definition(name) - from vidmap.datasets.base import DatasetCapabilities, DatasetPreparation, DatasetSpec, PreparedSceneParser + from vidmap.datasets.base import DatasetCapabilities, DatasetPreparation, DatasetSpec from vidmap.datasets.layouts import get_dataset_layout layout = get_dataset_layout(name) preparer = None if definition.preparer_module is None else DatasetPreparation(definition.preparer_module) if name == "crocodl": - from vidmap.datasets.crocodl import CroCoDLParser - def missing_artifacts(layout, scenes): + def missing_artifacts(layout, scenes, mode): from vidmap.datasets.prepare.crocodl import official_scene_is_prepared return tuple(layout.scene_dir(scene) for scene in scenes if not official_scene_is_prepared(scene)) return DatasetSpec( name=name, - parser=CroCoDLParser, layout=layout, capabilities=DatasetCapabilities( allows_missing_testsets=True, ignores_unknown_testsets=True, ), - batch_case_parser=CroCoDLParser.for_testsets, preparer=preparer, missing_artifacts=missing_artifacts, default_mode=definition.default_mode, default_evaluation_metric=definition.default_evaluation_metric, ) if name == "lamar": - from vidmap.datasets.lamar import LaMARParser + preparer = DatasetPreparation(definition.preparer_module, (("sample", "sample-300"),)) - def missing_artifacts(layout, scenes): + def missing_artifacts(layout, scenes, mode): from vidmap.datasets.prepare.lamar import lamar_scene_is_prepared + profile = preparer.profile(mode) return tuple( - layout.scene_dir(scene) for scene in scenes if not lamar_scene_is_prepared(layout.scene_dir(scene)) + layout.scene_dir(scene) + for scene in scenes + if not lamar_scene_is_prepared(layout.data_dir, scene, profile=profile) ) return DatasetSpec( name=name, - parser=LaMARParser, layout=layout, preparer=preparer, missing_artifacts=missing_artifacts, @@ -66,7 +65,7 @@ def missing_artifacts(layout, scenes): if name == "euroc": - def missing_artifacts(layout, scenes): + def missing_artifacts(layout, scenes, mode): from vidmap.datasets.prepare.euroc import euroc_scene_is_prepared return tuple( @@ -75,7 +74,6 @@ def missing_artifacts(layout, scenes): return DatasetSpec( name=name, - parser=partial(PreparedSceneParser, layout), layout=layout, preparer=preparer, missing_artifacts=missing_artifacts, @@ -84,7 +82,6 @@ def missing_artifacts(layout, scenes): ) return DatasetSpec( name=name, - parser=partial(PreparedSceneParser, layout), layout=layout, preparer=preparer, default_mode=definition.default_mode, diff --git a/vidmap/datasets/selection.py b/vidmap/datasets/selection.py index 500be41..5d081c4 100644 --- a/vidmap/datasets/selection.py +++ b/vidmap/datasets/selection.py @@ -33,6 +33,8 @@ class PreparedTarget: testset_type: str testset_desc: str ref_imids: tuple[int, ...] + recording: str + variant: str def __post_init__(self) -> None: object.__setattr__(self, "ref_imids", tuple(self.ref_imids)) @@ -60,9 +62,11 @@ def iter_scene_targets(self) -> Iterator[tuple[str, tuple[PreparedTarget, ...]]] scene=scene, testset_type=self.options.mode, testset_desc=str(testset_id), - ref_imids=tuple(ref_imids), + ref_imids=selection.image_ids, + recording=selection.recording, + variant=selection.variant, ) - for testset_id, ref_imids in testsets.items() + for testset_id, selection in testsets.items() ) yield scene, targets @@ -102,8 +106,12 @@ def scene_has_public_ground_truth(self, scene: str) -> bool: def prepared_targets(self) -> Iterator[tuple[PreparedTarget, DatasetParser, list[str]]]: """Yield selected targets with their target-specific parser and image names.""" for scene, scene_targets in self.iter_scene_targets(): - descriptions = (target.testset_desc for target in scene_targets) - parsers = self.dataset_spec.parsers_for_cases(scene, descriptions) + from vidmap.datasets.base import PreparedTestset + + selections = ( + PreparedTestset(target.recording, target.variant, target.ref_imids) for target in scene_targets + ) + parsers = self.dataset_spec.parsers_for_cases(scene, selections) source_parser = None for target, scene_parser in zip(scene_targets, parsers, strict=True): if scene_parser is not source_parser: diff --git a/vidmap/frontend/identity.py b/vidmap/frontend/identity.py index 2a0c8b8..336523c 100644 --- a/vidmap/frontend/identity.py +++ b/vidmap/frontend/identity.py @@ -9,7 +9,7 @@ from vidmap.configuration.names import config_name_to_output_slug from vidmap.frontend.cache import fingerprint -FRONTEND_IDENTITY_SCHEMA_VERSION = 6 +FRONTEND_IDENTITY_SCHEMA_VERSION = 7 def semantic_frontend_config(conf) -> dict[str, object]: diff --git a/vidmap/frontend/options/rig.py b/vidmap/frontend/options/rig.py index 3b1fe2a..ddb1619 100644 --- a/vidmap/frontend/options/rig.py +++ b/vidmap/frontend/options/rig.py @@ -34,7 +34,10 @@ class PanoramaView: @options_dataclass(frozen=True, config=ConfigDict(extra="forbid")) class PanoramaOptions: - image_size: tuple[Annotated[int, Field(strict=True, gt=0)], Annotated[int, Field(strict=True, gt=0)]] + aspect_ratio: tuple[ + Annotated[float, Field(strict=True, gt=0, allow_inf_nan=False)], + Annotated[float, Field(strict=True, gt=0, allow_inf_nan=False)], + ] horizontal_fov_degrees: Annotated[float, Field(strict=True, gt=0, lt=180)] views: Annotated[dict[CameraId, PanoramaView], Field(min_length=1)] diff --git a/vidmap/frontend/preparation/cameras.py b/vidmap/frontend/preparation/cameras.py index fba2b78..c6f37da 100644 --- a/vidmap/frontend/preparation/cameras.py +++ b/vidmap/frontend/preparation/cameras.py @@ -42,9 +42,8 @@ def resolve_camera_preparation(capture: PreparedCapture, options) -> CameraPrepa if priors.camera_model not in pycolmap.CameraModelId.__members__: raise ValueError(f"Unknown COLMAP camera model: {priors.camera_model}") has_multi_sensor_rig = any(rig.num_sensors() != 1 for rig in capture.rec.rigs.values()) - if capture.preserve_calibration: - if priors.initialization != "supplied": - raise ValueError("Preserved capture calibration requires supplied initialization") + use_supplied = priors.initialization == "supplied" + if capture.preserve_calibration and use_supplied: if options.keyframes.selection.intrinsics_source != "ground_truth": raise ValueError("Preserved capture calibration requires supplied keyframing intrinsics") if priors.camera_model != "PINHOLE": @@ -80,16 +79,16 @@ def resolve_camera_preparation(capture: PreparedCapture, options) -> CameraPrepa distorted = camera.model_name not in ("PINHOLE", "SIMPLE_PINHOLE") image_mode = settings[cid].image_mode if image_mode == "auto": - image_mode = "undistort" if capture.preserve_calibration and distorted else "native" + image_mode = "undistort" if use_supplied and distorted else "native" if image_mode == "undistort" and (distorted or priors.camera_model in DISTORTED_CAMERA_MODELS): - if not capture.preserve_calibration: + if not use_supplied: raise ValueError("Undistortion requires supplied capture calibration") undistort.append(cid) elif distorted: - if capture.preserve_calibration: + if use_supplied: raise ValueError("Native calibrated distorted cameras are unsupported; select undistort") - if camera.model_name not in DISTORTED_CAMERA_MODELS or camera.model_name != priors.camera_model: - raise ValueError("Native distorted source cameras require a supported matching preparation model") + if priors.camera_model not in DISTORTED_CAMERA_MODELS: + raise ValueError("Native distorted source cameras require a supported distortion prediction model") return CameraPreparationPlan(allocation, priors, tuple(undistort)) @@ -124,7 +123,7 @@ def prepare_capture(capture: PreparedCapture, plan: CameraPreparationPlan) -> Pr frame.add_data_id(prepared.data_id) rec.add_frame(frame) rec.add_image(prepared) - return replace(capture, rec=rec) + return replace(capture, rec=rec, preserve_calibration=False) def initialize_selected_camera_geometry(working_rec, source_rec, image_names, plan: CameraPreparationPlan): diff --git a/vidmap/frontend/rig.py b/vidmap/frontend/rig.py index 0f4aa64..04f5da9 100644 --- a/vidmap/frontend/rig.py +++ b/vidmap/frontend/rig.py @@ -1,5 +1,7 @@ """Frontend selection and pair policy over authoritative COLMAP membership.""" +import numpy as np + from vidmap.datasets.capture import PreparedCapture from vidmap.frontend.options.rig import RigOptions @@ -14,11 +16,18 @@ def resolve_rig_options(capture: PreparedCapture, options: RigOptions): if capture.panorama_metadata() is not None and options.match_same_timestamp: raise ValueError("Same-timestamp matching between views of one panorama is not allowed") for first, second in options.match_same_timestamp: - if not any( - rig.has_sensor(capture.rec.camera(first).sensor_id) - and rig.has_sensor(capture.rec.camera(second).sensor_id) - for rig in capture.rec.rigs.values() - ): + sensors = [capture.rec.camera(cid).sensor_id for cid in (first, second)] + for rig in capture.rec.rigs.values(): + if not all(rig.has_sensor(sensor) for sensor in sensors): + continue + centers = [ + np.zeros(3) if rig.is_ref_sensor(sensor) else rig.sensor_from_rig(sensor).inverse().translation + for sensor in sensors + ] + if np.array_equal(*centers): + raise ValueError("Same-timestamp matching requires a nonzero camera baseline") + break + else: raise ValueError("Same-timestamp camera pairs must belong to the same rig") sequential = {frozenset(pair) for pair in options.flag_same_timestamp_as_sequential} return reference, tuple( diff --git a/vidmap/frontend/runner.py b/vidmap/frontend/runner.py index 504d224..0d37157 100644 --- a/vidmap/frontend/runner.py +++ b/vidmap/frontend/runner.py @@ -71,16 +71,18 @@ def run_local_frontend( raise ValueError("Capture inputs already supply calibration and complete frames") scene_parser = input_path elif conf.pipeline.rig is not None and conf.pipeline.rig.projection is not None: - from vidmap.datasets.prepare.panorama_capture import prepare_panorama_capture + from vidmap.datasets.prepare.helpers.panorama_capture import prepare_panorama_capture + from vidmap.reconstruction import prepare_reconstruction_images if imnames is not None: raise ValueError("Panorama inputs require complete frames") + image_dir = prepare_reconstruction_images(input_path, workspace) input_snapshot = prepare_panorama_capture( - input_path, conf.pipeline.rig, workspace / "prepared_capture", frontend_options=conf.pipeline + image_dir, conf.pipeline.rig, workspace / "prepared_capture", frontend_options=conf.pipeline ) scene_parser = input_snapshot.capture else: - from vidmap.datasets.prepare.rig_capture import load_capture + from vidmap.datasets.prepare.helpers.rig_capture import load_capture from vidmap.reconstruction import prepare_reconstruction_images image_dir = prepare_reconstruction_images(input_path, workspace) @@ -91,7 +93,7 @@ def run_local_frontend( preparation_plan = resolve_camera_preparation(scene_parser, conf.pipeline) if preparation_plan.undistort_camera_ids: - from vidmap.datasets.prepare.rig_capture import prepare_rig_capture + from vidmap.datasets.prepare.helpers.rig_capture import prepare_rig_capture input_snapshot = prepare_rig_capture( scene_parser, @@ -237,12 +239,25 @@ def run_with_results(self, *, force_frontend: bool = False) -> FrontendRunResult config=self.conf.name, ): try: - scene_parser = prepare_scene_capture(scene_parser) - input_snapshot = ( - scene_parser.snapshot() - if scene_parser.preserve_calibration or scene_parser.frame_timestamps_ns is not None - else None - ) + if self.conf.pipeline.rig is not None and self.conf.pipeline.rig.projection is not None: + from vidmap.datasets.prepare.helpers.panorama_capture import prepare_panorama_capture + + input_snapshot = prepare_panorama_capture( + scene_parser.rgb_dir, + self.conf.pipeline.rig, + results.resolved_frontend_work_dir() / "prepared_capture", + frontend_options=self.conf.pipeline, + image_names=reference_image_names, + ) + scene_parser = input_snapshot.capture + reference_image_names = scene_parser.imnames + else: + scene_parser = prepare_scene_capture(scene_parser) + input_snapshot = ( + scene_parser.snapshot() + if scene_parser.preserve_calibration or scene_parser.frame_timestamps_ns is not None + else None + ) identity = FrontendIdentity.from_config( self.conf, dataset=self.selection.dataset_spec.name, diff --git a/vidmap/frontend/video_images.py b/vidmap/frontend/video_images.py index 24f2268..1b81f3d 100644 --- a/vidmap/frontend/video_images.py +++ b/vidmap/frontend/video_images.py @@ -29,7 +29,8 @@ def prepare_image(self, image, idx): if self.conf.resize_to_shape is not None: target_size = self.conf.resize_to_shape - image = resize_image(image, target_size, self.conf.interpolation) + if tuple(target_size) != size: + image = resize_image(image, target_size, self.conf.interpolation) else: should_resize = False if self.conf.resize_max and (self.conf.resize_force or max_dim > self.conf.resize_max): @@ -41,7 +42,8 @@ def prepare_image(self, image, idx): if should_resize: size_new = tuple(int(round(x * scale)) for x in size) - image = resize_image(image, size_new, self.conf.interpolation) + if size_new != size: + image = resize_image(image, size_new, self.conf.interpolation) if self.conf.grayscale: image = image[None] # (1, H, W) diff --git a/vidmap/mapper/inputs/snapshot.py b/vidmap/mapper/inputs/snapshot.py index b4cfe95..5f46579 100644 --- a/vidmap/mapper/inputs/snapshot.py +++ b/vidmap/mapper/inputs/snapshot.py @@ -23,7 +23,7 @@ VGC_FILTERED_PAIRS_NAME = "vgc_filtered_pairs.json" MANIFEST_NAME = "manifest.json" MANIFEST_SCHEMA_VERSION = 14 -_FRONTEND_IDENTITY_SCHEMA_VERSION = 6 +_FRONTEND_IDENTITY_SCHEMA_VERSION = 7 SQLITE_SIDECAR_SUFFIXES = ("-journal", "-shm", "-wal") REQUIRED_PAYLOAD_NAMES = frozenset({DATABASE_NAME, DEPTH_MAPS_NAME, LC_MASKS_NAME}) diff --git a/vidmap/reconstruction.py b/vidmap/reconstruction.py index f2005e4..c72eb07 100644 --- a/vidmap/reconstruction.py +++ b/vidmap/reconstruction.py @@ -6,6 +6,7 @@ import json import logging import math +import os import shutil import subprocess import tempfile @@ -442,6 +443,13 @@ def run_mapping( def initialize_output() -> None: output_dir.mkdir(parents=True, exist_ok=True) + input_link = output_dir / "mapper_inputs" + if input_link.resolve() != mapper_inputs.directory: + if input_link.is_symlink() and overwrite_outputs: + input_link.unlink() + input_link.symlink_to( + os.path.relpath(mapper_inputs.directory, output_dir.resolve()), target_is_directory=True + ) dump_mapping_configs( frontend_conf, mapping_conf, @@ -463,10 +471,11 @@ def initialize_output() -> None: overwrite_outputs=overwrite_outputs, on_inputs_validated=initialize_output, ) + projection = frontend_conf.pipeline.rig is not None and frontend_conf.pipeline.rig.projection is not None extract_point_colors( reconstruction, mapper_inputs, - image_dir=None if scene_parser is None else scene_parser.rgb_dir, + image_dir=None if scene_parser is None or projection else scene_parser.rgb_dir, ) if mapper_inputs.full_depth_maps_path is not None: from vidmap.depth_artifacts import write_reference diff --git a/vidmap/visualization/playback/alignment.py b/vidmap/visualization/playback/alignment.py index f9d7c02..2fd2424 100644 --- a/vidmap/visualization/playback/alignment.py +++ b/vidmap/visualization/playback/alignment.py @@ -5,12 +5,11 @@ from typing import Any import numpy as np -import yaml from natsort import natsorted from vidmap.datasets.base import image_has_public_pose -from .dataset_roots import select_dataset_root +from .dataset_roots import resolve_prepared_input @dataclass(frozen=True) @@ -131,23 +130,9 @@ def similarity_from_pycolmap(transform: Any) -> Similarity: def resolve_ground_truth_model_path(run: Path) -> Path | None: - """Resolve the scene's saved ground-truth model.""" - from vidmap.configuration.dump import mapping_config_path - - config_path = mapping_config_path(run) - if not config_path.is_file(): - return None - config = yaml.safe_load(config_path.read_text()) or {} - scene = _single_config_value(config.get("scene")) - if scene is None: - return None - root, _configured = select_dataset_root( - config, - run, - lambda candidate: (candidate / scene / "rec").is_dir(), - purpose="Solver playback GT", - ) - return None if root is None else root / scene / "rec" + """Resolve the selected recording's ground-truth model.""" + prepared = resolve_prepared_input(run) + return None if prepared is None else prepared[0] / "rec" def estimate_model_to_ground_truth_alignment( @@ -163,6 +148,11 @@ def estimate_model_to_ground_truth_alignment( import pycolmap gt = pycolmap.Reconstruction(gt_path) + selected = None + prepared = resolve_prepared_input(run) + if prepared is not None and prepared[0] / "rec" == gt_path: + _, selection = prepared + selected = {gt.images[image_id].name for image_id in selection.image_ids if image_id in gt.images} parents = {Path(image.name).parent.as_posix() for image in model.images.values()} gt_images = { str(image.name): image @@ -180,7 +170,6 @@ def estimate_model_to_ground_truth_alignment( transform = pycolmap.estimate_sim3d(np.asarray(source), np.asarray(target)) if transform is None: raise ValueError("Could not align the selected saved model to GT") - selected = _testset_image_names(run, gt_path, gt) path_names = gt_images if selected is None else set(gt_images) & selected path = np.asarray( [_camera_center(gt_images[name]) for name in natsorted(path_names)], @@ -189,44 +178,5 @@ def estimate_model_to_ground_truth_alignment( return transform, path.reshape((-1, 3)) -def _testset_image_names(run: Path | None, gt_path: Path, gt: Any) -> set[str] | None: - from vidmap.configuration.dump import mapping_config_path - - if run is None or not (config_path := mapping_config_path(run)).is_file(): - return None - config = yaml.safe_load(config_path.read_text()) or {} - mode = config.get("mode") - scene = _single_config_value(config.get("scene")) - testset = _single_config_value(config.get("testset_id")) - if not isinstance(mode, str) or scene is None or testset is None: - return None - - from vidmap.datasets.names import SUPPORTED_DATASETS - from vidmap.paths import dataset_paths - - candidates = ( - dataset_paths(part).testsets / scene / f"{mode}.yaml" for part in gt_path.parts if part in SUPPORTED_DATASETS - ) - testset_path = next( - (candidate for candidate in candidates if candidate.is_file()), - None, - ) - if testset_path is None: - return None - payload = yaml.safe_load(testset_path.read_text()) or {} - image_ids = payload.get(testset) - if not isinstance(image_ids, list): - return None - return {str(gt.images[int(image_id)].name) for image_id in image_ids if int(image_id) in gt.images} - - -def _single_config_value(value: Any) -> str | None: - if isinstance(value, str) and value: - return value - if isinstance(value, (list, tuple)) and len(value) == 1: - return str(value[0]) - return None - - def _camera_center(image: Any) -> np.ndarray: return np.asarray(image.cam_from_world().inverse().translation, dtype=np.float64).reshape(3) diff --git a/vidmap/visualization/playback/dataset_roots.py b/vidmap/visualization/playback/dataset_roots.py index d49dae1..aab4a52 100644 --- a/vidmap/visualization/playback/dataset_roots.py +++ b/vidmap/visualization/playback/dataset_roots.py @@ -1,64 +1,28 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Callable +from typing import TYPE_CHECKING from vidmap.datasets.layouts import get_dataset_layout -from vidmap.datasets.names import DATASET_DEFINITIONS - - -def _canonical_layouts(): - return {definition.name: get_dataset_layout(definition.name) for definition in DATASET_DEFINITIONS} - - -def select_dataset_root( - config: dict[str, Any], - run: Path | None, - valid: Callable[[Path], bool], - *, - purpose: str, -) -> tuple[Path | None, tuple[str, ...]]: - """Select one configured local dataset root, rejecting ambiguous fallbacks.""" - layouts = _canonical_layouts() - roots = {name: layout.data_dir for name, layout in layouts.items()} - configured = configured_dataset_names(config, run, layouts) - candidates = tuple((name, roots[name]) for name in configured) if configured else tuple(roots.items()) - matches = tuple((name, root) for name, root in candidates if valid(root)) - if len(matches) > 1: - choices = ", ".join(f"{name}={root}" for name, root in matches) - raise ValueError(f"{purpose} dataset root is ambiguous: {choices}") - return (matches[0][1] if matches else None), configured - - -def configured_dataset_names( - config: dict[str, Any], - run: Path | None, - layouts: dict[str, Any], -) -> tuple[str, ...]: - obsolete = sorted({"benchmark_dataset", "dataset_name", "dataset"} & config.keys()) - if obsolete: - raise ValueError( - "Playback configs do not accept dataset identity fields " - f"{obsolete}; locate the run under the dataset experiment root" - ) - requested = [] - locations = [] - if "output_root" in config and config["output_root"]: - locations.append(Path(config["output_root"]).expanduser()) - if run is not None: - locations.append(Path(run).expanduser()) - for name, layout in layouts.items(): - if any(_is_relative_to(location, layout.default_exp_dir) for location in locations): - requested.append(name) - unique = tuple(dict.fromkeys(requested)) - if len(unique) > 1: - raise ValueError("Run identifies multiple datasets: " + ", ".join(unique)) - return unique - - -def _is_relative_to(path: Path, root: Path) -> bool: - try: - path.resolve().relative_to(root.resolve()) - except ValueError: - return False - return True +from vidmap.mapper.inputs import MANIFEST_NAME, MapperInputs + +if TYPE_CHECKING: + from vidmap.datasets.base import PreparedTestset + + +def resolve_prepared_input(run: Path | None) -> tuple[Path, PreparedTestset] | None: + """Resolve benchmark inputs from saved identity; local runs have no benchmark GT.""" + if run is None or not (run / "mapper_inputs" / MANIFEST_NAME).is_file(): + return None + identity = MapperInputs.from_directory(run / "mapper_inputs").frontend_identity() + if identity["dataset"] == "local": + return None + layout = get_dataset_layout(identity["dataset"]) + testsets = layout.read_testsets(identity["scene"], identity["mode"]) + if testsets is None or identity["testset_id"] not in testsets: + return None + selection = testsets[identity["testset_id"]] + if selection.image_ids != tuple(identity["reference_image_ids"]): + raise ValueError(f"Prepared testset differs from the saved run selection: {run}") + root = layout.input_dir(identity["scene"], selection.recording, selection.variant) + return (root, selection) if (root / "rec").is_dir() else None diff --git a/vidmap/visualization/playback/flythrough.py b/vidmap/visualization/playback/flythrough.py index d3932eb..5017b17 100644 --- a/vidmap/visualization/playback/flythrough.py +++ b/vidmap/visualization/playback/flythrough.py @@ -6,7 +6,6 @@ from typing import Any, Iterator import numpy as np -import yaml from natsort import natsorted from scipy.spatial.transform import Rotation, RotationSpline, Slerp @@ -16,7 +15,7 @@ from .alignment import estimate_model_to_ground_truth_alignment, resolve_ground_truth_model_path from .api import Playback, Resolution -from .dataset_roots import select_dataset_root +from .dataset_roots import resolve_prepared_input from .depth_lift import DepthLiftReader from .scene import ( DEPTH_LIFT_RADIUS_UI_POINTS, @@ -49,7 +48,7 @@ def build_model_flythrough_sequence(playback: Playback, resolution: Resolution) alignment, gt_centers = estimate_model_to_ground_truth_alignment( model, gt_path if playback.align_to_ground_truth else None, - run=run_root, + run=run_root if playback.ground_truth is None else None, ) poses, point_ids, points, observations = _load_sparse_model_scene( model, @@ -185,8 +184,7 @@ def _resolve_flythrough_inputs( config_path = mapping_config_path(run) if not config_path.is_file(): - raise FileNotFoundError(f"Flythrough needs the run config to infer RGB and GT data: {config_path}") - config = yaml.safe_load(config_path.read_text()) or {} + raise FileNotFoundError(f"Flythrough run config is missing: {config_path}") model_path = _model_path(run, playback.reconstruction) import pycolmap @@ -197,7 +195,7 @@ def _resolve_flythrough_inputs( local_images = local_run_image_dir(run) if local_images is None: - rgb_dir, gt_path = _resolve_dataset_paths(config, image_names, run=run) + rgb_dir, gt_path = _resolve_dataset_paths(run, image_names) else: rgb_dir, gt_path = _validate_explicit_image_dir(local_images, image_names), None else: @@ -261,27 +259,12 @@ def _model_path(run: Path, selection: str | Path | None) -> Path: return path -def _resolve_dataset_paths( - config: dict[str, Any], names: tuple[str, ...], *, run: Path | None = None -) -> tuple[Path, Path | None]: - scene = _require_single_config_value(config.get("scene"), "scene") - parents = {Path(name).parent for name in names} - root, configured = select_dataset_root( - config, - run, - lambda candidate: _contains_sequences(candidate, scene, parents), - purpose="Flythrough", - ) - if root is not None: - dataset = root / scene - gt = dataset / "rec" - return dataset / "images", gt if gt.is_dir() else None - sequences = ", ".join(sorted(str(parent) for parent in parents)) - requested = ", ".join(configured) if configured else "" - raise FileNotFoundError( - f"Could not resolve RGB data for configured dataset {requested}, " - f"scene {scene!r}, and model sequences: {sequences}" - ) +def _resolve_dataset_paths(run: Path, names: tuple[str, ...]) -> tuple[Path, Path | None]: + prepared = resolve_prepared_input(run) + if prepared is None: + raise FileNotFoundError("No saved benchmark input identity; provide image_dir for this reconstruction") + root, _ = prepared + return _validate_explicit_image_dir(root / "images", names), root / "rec" def _validate_explicit_image_dir(image_dir: Path, names: tuple[str, ...]) -> Path: @@ -295,19 +278,6 @@ def _validate_explicit_image_dir(image_dir: Path, names: tuple[str, ...]) -> Pat return root -def _contains_sequences(root: Path, scene: str, parents: set[Path]) -> bool: - images = root / scene / "images" - return images.is_dir() and all((images / parent).is_dir() for parent in parents) - - -def _require_single_config_value(value: Any, label: str) -> str: - if isinstance(value, (list, tuple)) and value: - return str(value[0]) - if isinstance(value, str) and value: - return value - raise ValueError(f"Run config must contain {label} to infer flythrough inputs") - - def _load_sparse_model_scene( model: Any, rgb_dir: Path,