From 1334366396ed73e4789322919a0b2812ae3b1d40 Mon Sep 17 00:00:00 2001 From: Zador Pataki Date: Tue, 22 Sep 2026 00:22:55 +0200 Subject: [PATCH] Use capped-radius global-positioning warm-up Run the sequential L2 warm-up in one continuous solve, avoiding repeated solver setup. --- .../colmap/src/bindings/global_positioning.cc | 3 + .../colmap/src/stages/global_positioning.cc | 41 ++++--- .../src/vidmap_native/global_positioning.h | 3 + .../tests/python/test_global_positioning.py | 100 +++++++++++++----- .../global_positioning/native_options.py | 2 + 5 files changed, 105 insertions(+), 44 deletions(-) diff --git a/extensions/colmap/src/bindings/global_positioning.cc b/extensions/colmap/src/bindings/global_positioning.cc index b43ba03..ccb98e0 100644 --- a/extensions/colmap/src/bindings/global_positioning.cc +++ b/extensions/colmap/src/bindings/global_positioning.cc @@ -81,6 +81,9 @@ void BindGlobalPositioning(py::module_& m) { &GlobalPositionerOptions::optimize_scales) .def_readwrite("sequential_support_warmup_rounds", &GlobalPositionerOptions::sequential_support_warmup_rounds) + .def_readwrite( + "sequential_support_max_trust_region_radius", + &GlobalPositionerOptions::sequential_support_max_trust_region_radius) .def_readwrite( "sequential_support_observations_per_track", &GlobalPositionerOptions::sequential_support_observations_per_track) diff --git a/extensions/colmap/src/stages/global_positioning.cc b/extensions/colmap/src/stages/global_positioning.cc index d98cf4a..f82c9bc 100644 --- a/extensions/colmap/src/stages/global_positioning.cc +++ b/extensions/colmap/src/stages/global_positioning.cc @@ -1029,29 +1029,35 @@ class GlobalPositioner { } void RunSequentialSupportWarmup() { - const int rounds = options_.sequential_support_warmup_rounds; - if (rounds == 0) return; + if (options_.sequential_support_warmup_rounds == 0) return; if (!has_sequential_support_candidate_) { throw std::runtime_error( "sequential support has no eligible regular observations"); } ceres::Solver::Options warmup_options = solver_options_; - warmup_options.max_num_iterations = 1; + warmup_options.max_num_iterations = + options_.sequential_support_warmup_rounds; + warmup_options.max_trust_region_radius = + options_.sequential_support_max_trust_region_radius; SetSequentialSupportWarmup(true); - for (int round = 0; round < rounds; ++round) { - ceres::Solver::Summary summary; - ceres::Solve(warmup_options, problem_.get(), &summary); - if (!summary.IsSolutionUsable()) { - SetSequentialSupportWarmup(false); - throw std::runtime_error("sequential support warm-up failed"); - } - if (options_.playback.IsEnabled() && - round % options_.playback.snapshot_every_n_iterations == 0) { - WritePlaybackCapture("iteration", round); - } + // Ceres iteration zero is the initial evaluation, not an update. + SolverPlaybackIterationCallback callback( + options_.playback.snapshot_every_n_iterations, + -1, + [this](const int iteration) { + if (iteration >= 0) WritePlaybackCapture("iteration", iteration); + }); + if (options_.playback.IsEnabled()) { + warmup_options.update_state_every_iteration = true; + warmup_options.callbacks.push_back(&callback); } + ceres::Solver::Summary summary; + ceres::Solve(warmup_options, problem_.get(), &summary); SetSequentialSupportWarmup(false); + if (!summary.IsSolutionUsable()) { + throw std::runtime_error("sequential support warm-up failed"); + } } void SetSequentialSupportWarmup(const bool enabled) { @@ -1209,7 +1215,6 @@ void GlobalPositionerOptions::Validate() const { } if (min_num_view_per_track <= 0 || random_seed < -1 || !std::isfinite(random_init_scale) || random_init_scale < 0.0 || - sequential_support_warmup_rounds < 0 || sequential_support_observations_per_track < 0 || !std::isfinite(uncalibrated_loss_downweight) || uncalibrated_loss_downweight < 0.0 || @@ -1231,6 +1236,12 @@ void GlobalPositionerOptions::Validate() const { parameter_tolerance < 0.0) { throw std::invalid_argument("invalid global positioning options"); } + if (sequential_support_warmup_rounds < 0 || + !std::isfinite(sequential_support_max_trust_region_radius) || + sequential_support_max_trust_region_radius < + ceres::Solver::Options().initial_trust_region_radius) { + throw std::invalid_argument("invalid sequential support solver options"); + } const bool sequential_support_enabled = sequential_support_warmup_rounds > 0; if (sequential_support_enabled != (sequential_support_observations_per_track > 0) || diff --git a/extensions/colmap/src/vidmap_native/global_positioning.h b/extensions/colmap/src/vidmap_native/global_positioning.h index 83c8d71..b054860 100644 --- a/extensions/colmap/src/vidmap_native/global_positioning.h +++ b/extensions/colmap/src/vidmap_native/global_positioning.h @@ -46,7 +46,10 @@ struct GlobalPositionerOptions { bool optimize_points = true; bool optimize_scales = true; // Temporarily apply a separate loss to early observations of each track. + // Zero disables warm-up. All other solver settings are inherited from GP. int sequential_support_warmup_rounds = 0; + double sequential_support_max_trust_region_radius = + ceres::Solver::Options().max_trust_region_radius; int sequential_support_observations_per_track = 0; LossConfig sequential_support_loss; std::vector sequential_support_image_timeline; diff --git a/extensions/colmap/tests/python/test_global_positioning.py b/extensions/colmap/tests/python/test_global_positioning.py index c4adfe5..55fd303 100644 --- a/extensions/colmap/tests/python/test_global_positioning.py +++ b/extensions/colmap/tests/python/test_global_positioning.py @@ -65,8 +65,7 @@ def _sequential_support_problem(storage_order): return problem -def test_sequential_support_warms_up_with_early_track_observations(): - problem = _sequential_support_problem([3, 1, 2]) +def _sequential_support_options(iterations): options = native.GlobalPositioningOptions() options.generate_random_positions = False options.generate_random_points = False @@ -74,32 +73,36 @@ def test_sequential_support_warms_up_with_early_track_observations(): options.min_num_views_per_track = 2 options.num_threads = 1 options.max_num_iterations = 1 - options.sequential_support_warmup_rounds = 2 + options.sequential_support_warmup_rounds = iterations + options.sequential_support_max_trust_region_radius = 1e4 options.sequential_support_observations_per_track = 2 - options.sequential_support_loss.type = native.LossFunctionType.TRIVIAL options.sequential_support_image_timeline = [1, 2, 3] + return options + + +def test_continuous_support_preserves_capped_reference_solution(): + problem = _sequential_support_problem([3, 1, 2]) + options = _sequential_support_options(2) result = native.run_global_positioning(options, problem) assert result.success np.testing.assert_allclose( problem.track(7).xyz, - [0.14776050274252941, 0.004862738467740601, 3.4976693525562697], + [0.14776050274253688, 0.004862738467740524, 3.497669352556072], rtol=0.0, atol=1e-12, ) np.testing.assert_allclose( [result.final_bata_scales[key] for key in ("7:1:0:0", "7:2:0:0", "7:3:0:0")], - [0.5868746726257932, 0.5785674026758529, 1.0], + [0.5868746726263283, 0.5785674026757762, 1.0], rtol=0.0, atol=1e-12, ) def test_sequential_support_requires_complete_unique_timeline(): - options = native.GlobalPositioningOptions() - options.sequential_support_warmup_rounds = 2 - options.sequential_support_observations_per_track = 2 + options = _sequential_support_options(2) options.sequential_support_image_timeline = [1, 2] with np.testing.assert_raises(ValueError): @@ -113,15 +116,7 @@ def solve(timeline): image = problem.image(image_id) image.is_inlier = np.ones(1, dtype=np.uint8) problem.update_image(image) - options = native.GlobalPositioningOptions() - options.generate_random_positions = False - options.generate_random_points = False - options.use_initial_positions = True - options.min_num_views_per_track = 2 - options.num_threads = 1 - options.max_num_iterations = 1 - options.sequential_support_warmup_rounds = 2 - options.sequential_support_observations_per_track = 2 + options = _sequential_support_options(2) options.sequential_support_image_timeline = timeline native.run_global_positioning(options, problem) return problem.track(7).xyz @@ -151,17 +146,7 @@ def test_sequential_support_is_disabled_by_default(): def test_sequential_support_playback_starts_before_warmup(): captures = [] - options = native.GlobalPositioningOptions() - options.generate_random_positions = False - options.generate_random_points = False - options.use_initial_positions = True - options.min_num_views_per_track = 2 - options.num_threads = 1 - options.max_num_iterations = 1 - options.sequential_support_warmup_rounds = 4 - options.sequential_support_observations_per_track = 2 - options.sequential_support_loss.type = native.LossFunctionType.TRIVIAL - options.sequential_support_image_timeline = [1, 2, 3] + options = _sequential_support_options(4) options.playback.snapshot_every_n_iterations = 3 options.playback.callback = captures.append @@ -405,3 +390,60 @@ def test_bundle_adjustment_preserves_camera_without_observations(): assert result.success assert result.diagnostics.num_intrinsics_prior_residuals == 1 np.testing.assert_array_equal(problem.camera(2).params, camera.params) + + +@pytest.mark.parametrize(("rounds", "function_tolerance", "expected_updates"), [(5, 0.0, 5), (16, 1.0, 0)]) +def test_continuous_warmup_respects_budget_and_early_convergence(rounds, function_tolerance, expected_updates): + captures = [] + options = _sequential_support_options(rounds) + options.function_tolerance = function_tolerance + options.gradient_tolerance = 0.0 + options.parameter_tolerance = 0.0 + options.playback.callback = captures.append + + result = native.run_global_positioning(options, _sequential_support_problem([3, 1, 2])) + + assert result.success + warmup = [c for c in captures if c["phase"] == "iteration" and c["iteration"] < rounds] + assert [c["iteration"] for c in warmup] == list(range(expected_updates)) + assert captures[0]["phase"] == "initial" + assert captures[-1]["phase"] == "final" + assert captures[-1]["iteration"] >= rounds + + +def test_python_radius_cap_preserves_initial_step_and_limits_later_steps(): + def warmup_updates(cap): + captures = [] + options = _sequential_support_options(2) + options.parameter_ordering = native.GlobalPositioningOrdering.SINGLETON + options.function_tolerance = options.gradient_tolerance = options.parameter_tolerance = 0.0 + options.sequential_support_max_trust_region_radius = cap + options.playback.callback = captures.append + result = native.run_global_positioning(options, _sequential_support_problem([3, 1, 2])) + assert result.success + updates = [c["points_xyz"] for c in captures if c["phase"] == "iteration" and c["iteration"] < 2] + assert len(updates) == 2 + return updates + + capped, uncapped = warmup_updates(1e4), warmup_updates(1e16) + np.testing.assert_array_equal(capped[0], uncapped[0]) + assert np.linalg.norm(capped[1] - uncapped[1]) > 1e-8 + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("warmup_rounds", -1), + ("max_trust_region_radius", 1.0), + ("max_trust_region_radius", 0.0), + ("max_trust_region_radius", float("nan")), + ], +) +def test_invalid_warmup_solver_options_are_rejected_before_mutation(field, value): + problem = _sequential_support_problem([3, 1, 2]) + before = problem.track(7).xyz.copy() + options = _sequential_support_options(16) + setattr(options, f"sequential_support_{field}", value) + with pytest.raises(ValueError, match="invalid sequential support solver options"): + native.run_global_positioning(options, problem) + np.testing.assert_array_equal(problem.track(7).xyz, before) diff --git a/vidmap/mapper/stages/global_positioning/native_options.py b/vidmap/mapper/stages/global_positioning/native_options.py index 5b97746..bf29de2 100644 --- a/vidmap/mapper/stages/global_positioning/native_options.py +++ b/vidmap/mapper/stages/global_positioning/native_options.py @@ -82,6 +82,8 @@ def build_first_global_positioning_options( if not image_timeline: raise ValueError("sequential support requires an image timeline") native_options.sequential_support_warmup_rounds = first.sequential_support_warmup_rounds + # Limit radius growth during warm-up; stock Ceres may still shrink it. + native_options.sequential_support_max_trust_region_radius = 1.0e4 native_options.sequential_support_observations_per_track = first.sequential_support_observations_per_track native_options.sequential_support_loss = loss_config_from_options(first.sequential_support_loss) native_options.sequential_support_image_timeline = [int(image_id) for image_id in image_timeline]