Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions extensions/colmap/src/bindings/global_positioning.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 26 additions & 15 deletions extensions/colmap/src/stages/global_positioning.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ||
Expand All @@ -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) ||
Expand Down
3 changes: 3 additions & 0 deletions extensions/colmap/src/vidmap_native/global_positioning.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImageId> sequential_support_image_timeline;
Expand Down
100 changes: 71 additions & 29 deletions extensions/colmap/tests/python/test_global_positioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,41 +65,44 @@ 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
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_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):
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions vidmap/mapper/stages/global_positioning/native_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading