Skip to content

Retire glomap_ra GP+RA; route through colmap4-native - #5

Merged
Zador-Pataki merged 49 commits into
glomap-refactorfrom
glomap-refactor-2
Apr 27, 2026
Merged

Zador-Pataki merged 49 commits into
glomap-refactorfrom
glomap-refactor-2

Conversation

@Zador-Pataki

Copy link
Copy Markdown
Owner

Summary

Retires the parallel colmap::glomap_ra::GlobalPositioner /
RotationEstimator implementations in favor of the colmap4-native
colmap::GlobalPositioner / RotationEstimator extended with our
glomap-fork additions. Net diff: ~−1,500 LOC (added ~1,500 to native;
deleted ~3,000 of glomap_ra GP+RA).

What changed

Native GP (colmap/estimators/global_positioning.{h,cc}):

  • New PointConstraintType::SPLIT_METRIC_DEPTH enum + branch in
    AddPoint3DToProblem. Emits a 1-D MetricDepthError residual
    alongside BATA when image.depth_prior_validity[fid] is true.
  • 5-toggle metric-depth functor + per-image dmap_scales_ parameter
    blocks (lazy-inserted, std::map for pointer stability) +
    ScalePriorError / LogScalePriorError.
  • 10-bucket loss-routing cascade
    (4-way geometry × 5-way depth) + is_excluded skip + 2-loop
    iteration over track.Elements() + track.lc_elements.
  • filter_depth_outliers pre-Solve sweep populates depth_outliers_.
  • Conditional gauge-pin removal (gated on SPLIT_METRIC_DEPTH) so
    upstream colmap GP unit tests pass unchanged.
  • Pycolmap binding extended with the full fork option surface +
    LossFunctionConfig dict-typed properties + dict return shape.

Native RA (colmap/estimators/rotation_averaging{,_impl}.{h,cc}):

  • IRLS final-weight map plumbed via out-param (preserves bool return
    ABI for upstream tests).
  • skip_risky_LC_pairs filter in BuildPairConstraints via 4-layer
    CorrespondenceGraph& plumbing (problem ctor → estimator → runner
    → binding).
  • prioritize_tracking MST: 1e9 penalty on LC-dominated edges in
    ComputeMaximumPoseGraphSpanningTree.
  • New SolveCeres path activated by use_video_constraints && !use_gravity. Per-pair Huber (tracking) / Cauchy (LC) loss;
    RelativeRotationError AutoDiff functor; IsTrackingPair helper.
  • 3 dead RA option fields (use_precomputed_weights,
    fix_non_lc_weights, fixed_non_lc_weight) dropped from
    RotationEstimatorOptions.

Deletion (M13):

  • colmap/sfm/global_positioning_glomap.{h,cc}
  • colmap/sfm/rotation_averaging_glomap.{h,cc}
  • colmap/sfm/cost_function_glomap.h
  • colmap/sfm/l1_solver_glomap.h
  • colmap/sfm/optimization_base_glomap.h
  • colmap/sfm/tree_glomap.{h,cc}
  • Corresponding pycolmap bindings.
  • pycolmap.glomap_ra submodule kept alive — track-filter,
    track-establishment, image-pair-inliers, view-graph-manipulation
    bindings continue to use it.

Companion PR

Videosfm caller migration:
https://github.com/Zador-Pataki/videosfm-private/pulls (branch
colmap4-glomap-refactor-2).

Plan + audit

Full design in worktree .claude/notes/glomap_audit/:

  • port_plan.md (v3) — 16-milestone plan
  • gp_inventory.md / ra_inventory.md — pre-port audit of fork-vs-native deltas
  • round2_{cpp,python,verification}_review.md — agent reviews
  • execution_log.md — milestone-by-milestone notes

Test plan

  • Build clean (colmap/scripts/build.sh) + pycolmap test suite.
  • Pipeline runs end-to-end (sample-10 LaMAR HGE + canonical DB +
    MPSFM_REUSE_DB=1 + GP_SEED=1) through native RA + native GP
    after M13 deletion.
  • Cross-implementation ATE drift logged but not gated per
    Decision G2; reviewer should sanity-check that pre-port baseline
    ATE (~37.40/66.77/93.35/96.68/99.67) is achievable post-port via
    tuning of the new fork option fields if needed.

M1 (Hygiene):
- Drop dead glomap-fork option fields use_precomputed_weights,
  fix_non_lc_weights, fixed_non_lc_weight from
  colmap::RotationEstimatorOptions. Per audit (gp_inventory.md, ra_inventory.md)
  these are dead in fork .cc itself or hardcoded false in pipeline. Drop
  matching pycolmap def_readwrite + clean up referencing code paths in
  glomap_ra::RotationEstimator (kills the obsolete SolveWeightedLS branch
  + the no-op fix_non_lc_weights warning).
- Update comment block at rotation_averaging.h:70-71 — only skip_risky_LC_pairs
  + use_video_constraints + video_*_scale survive as fork-flag stubs.
- Gate native GP first-scale pin (global_positioning.cc:349-355) on
  point_constraint_type != SPLIT_METRIC_DEPTH. Preserves existing colmap GP
  unit tests under default BATA mode (Q1 / R10).

M2 (Foundation):
- New file colmap/util/loss_config.h: LossFunctionConfig {name, scale,
  weight} + CreateLossFromConfig factory. Names: trivial, huber, cauchy,
  arctan, softlone. weight!=1 wraps in ScaledLoss.
- Extend colmap::GlobalPositionerOptions with glomap-fork additions:
  PointConstraintType enum (BATA / SPLIT_METRIC_DEPTH), use_init,
  random_init_scale, depth toggles (use_log_scale_for_depth_map_scales,
  use_log_residual_for_depth, zero_residual_behind, smooth_log_linear_transition,
  log_linear_threshold), scale_prior_stddev, filter_depth_outliers,
  initial_dmap_scales (optional<map<image_t, double>>),
  10 LossFunctionConfig fields with names verbatim from existing glomap_ra
  port (loss_normal_geometry, loss_normal_depth, loss_lc_geometry,
  loss_lc_depth, loss_normal_geometry_inlier, loss_normal_depth_inlier,
  loss_normal_depth_outlier, loss_normal_geometry_trackstart,
  loss_normal_depth_trackstart, loss_scale_prior).
- Change random_seed default from -1 to 1 (Decision 9, preserves
  byte-identity for existing call sites that previously inherited GP_SEED=1).
- Extend GlobalPositioner state: dmap_scales_, dmap_scale_observation_counts_,
  depth_outliers_, 10 cached_loss_*_ shared_ptrs, soft_outlier_fallback_loss_.

M3 (Cost functors):
- Add WeightedBATADirectionalError to cost_functions/motion_averaging.h.
  AutoDiff <3,3,3,1> on (c_i, X_k, scale). Anisotropic per-axis weighting
  (1/sigma_x, 1/sigma_y, 1/sigma_z). Caller computes
  sigma_z = (sigma_x + sigma_y) / 2.
- New file cost_functions/metric_depth.h with 3 functors:
  MetricDepthError (5-toggle, 12 reachable combinations — smooth_transition
  only meaningful in use_log_residual=true branch),
  ScalePriorError (linear), LogScalePriorError (log-space, prior=log(1)=0).
  All ported verbatim from fork's cost_function.h.

Cluster cleanup (carried over from prior session):
- Strip 175 lines of dead Fetzer focal cost from sfm/cost_function_glomap.h.
- Strip 47 lines of FilterTracksByReprojection (unbound, unused) from
  sfm/track_filter_glomap.{h,cc}.

Verified: ATE 37.40 / 66.77 / 93.35 / 96.68 / 99.67 unchanged (M0a baseline)
on sample-10 LaMAR HGE / ios_2022-01-12_14.59.02_000-0 + canonical DB
+ GP_SEED=1.
Implement the trivial-rig case of the metric-depth residual structure.
Activated by point_constraint_type=SPLIT_METRIC_DEPTH (default still
BATA — no behavior change for native callers).

Per-observation in AddPoint3DToProblem (image.IsRefInFrame() branch only):
- When image.depth_prior_validity[fid] is true and depth_prior > 0,
  emit a 1-D MetricDepthError residual on
  (frame_centers_[image.FrameId()], point3D.xyz, dmap_scales_[image_id]).
- Lazy-insert dmap_scales_ on first valid observation per image. Initial
  value: from options_.initial_dmap_scales (caller-supplied GP1→GP2
  handoff) if provided, else 1.0 (linear) / 0.0 (log).
- Increment dmap_scale_observation_counts_ for the per-image
  ScalePriorError weight.

Per-image emission in AddPointToCameraConstraints (after track loop):
- Reset dmap_scales_ + dmap_scale_observation_counts_ at start (lazy
  filled by AddPoint3DToProblem).
- After all observations added: emit one ScalePriorError /
  LogScalePriorError per image with depth observations. Loss is wrapped
  in ceres::ScaledLoss(TrivialLoss, obs_count) so dense-depth images get
  proportionally stronger priors. M5 will replace TrivialLoss with the
  configurable cached_loss_scale_prior_.

Parameter ordering / bound (M4 step 5):
- dmap_scales_ ride alongside frame_centers_ in parameter group 2.
- Linear-space: SetParameterLowerBound(&dmap_scale, 0, 1e-5) prevents
  collapse. Log-space: no bound (parameter is log-space, positivity comes
  from exp() in MetricDepthError).

Init flag wiring (M4 steps 6-7):
- options_.use_init short-circuits InitializeRandomPositions for both
  camera centers AND track xyz (collapses fork's split flag).
- options_.random_init_scale replaces hardcoded 100.0 cube size in two
  call sites.

Verified: ATE 37.40 / 66.77 / 93.35 / 96.68 / 99.67 unchanged (default
BATA path) on sample-10 LaMAR HGE / ios_2022-01-12_14.59.02_000-0 +
canonical DB + GP_SEED=1.
Implement glomap-fork loss-routing cascade in colmap::GlobalPositioner.
Activated by point_constraint_type=SPLIT_METRIC_DEPTH; default BATA mode
unchanged.

Refactor: extract per-observation logic into AddObservationToProblem
helper (point3D_id, observation, is_lc_observation, random_initialization,
reconstruction). AddPoint3DToProblem becomes a dispatcher iterating
track.Elements() (is_lc=false) then track.lc_elements (is_lc=true) —
mirrors fork's two-parallel-collection structure.

Cache pre-warm in AddPointToCameraConstraints: materialize 10
cached_loss_*_ via CreateLossFromConfig. Default TrivialLoss/weight=1
config is equivalent to no override; non-default Huber/Cauchy/etc take
effect through cascade.

is_excluded skip: top of AddObservationToProblem.

4-way geometry cascade (SPLIT_METRIC_DEPTH only):
  is_lc -> loss_lc_geometry, is_track_anchor -> loss_normal_geometry_trackstart,
  is_inlier -> loss_normal_geometry_inlier, else -> loss_normal_geometry.
5-way depth cascade for MetricDepth residual:
  depth_outliers_ + LC -> SKIP depth residual entirely (deletes
    metric_depth_cost to avoid leak),
  depth_outliers_ + non-LC -> ScaledLoss(HuberLoss(1), 1) hardcoded
    soft fallback,
  is_lc -> loss_lc_depth, track_anchor -> loss_normal_depth_trackstart,
  is_inlier -> loss_normal_depth_inlier, MDRP-flagged outlier ->
  loss_normal_depth_outlier, else -> loss_normal_depth.

ScalePriorError wrapper updated: replaces M4 TrivialLoss(obs_count)
with cached_loss_scale_prior_ wrapped in ScaledLoss(obs_count). Default
config preserves obs_count weighting unchanged.

Verified ATE 37.40/66.77/93.35/96.68/99.67 unchanged (default BATA)
on sample-10 LaMAR HGE.
Implement glomap-fork pre-Solve depth-outlier filter in
colmap::GlobalPositioner. Activated by
options_.filter_depth_outliers + point_constraint_type=SPLIT_METRIC_DEPTH.

In AddPointToCameraConstraints, after cache pre-warm + dmap_scales_
reset:
- Seed dmap_scales_ from options_.initial_dmap_scales when caller
  supplied them (typical GP2 path: GP1 hands off solved scales). Linear
  scale converted to log if use_log_scale_for_depth_map_scales=true.
  Lazy-insert in AddObservationToProblem then preserves these values.
- Clear depth_outliers_; if filter_depth_outliers, call
  FilterDepthOutliers(reconstruction).

FilterDepthOutliers method (new):
- Per-point3D: walk track.Elements() and track.lc_elements separately.
- For each observation: skip when depth_prior_validity[fid]=false or
  depth_prior <= 1e-6 or stddev_rel <= 1e-9.
- Apply per-image scale (dmap_scales_[image_id]) if available; else
  raw prior. Threshold check:
    |log(z_est) - log(scale * depth_prior)| >= 3 * log(1 + stddev_rel)
- Insert (image_id, point2D_idx) into depth_outliers_ on flag.

Anonymous-namespace helper DepthOutlierFlag deduplicates the regular vs
LC sweep.

Native uses image.CamFromWorld() * X_world (the canonical cam_from_world
projection) — fork's pre-Solve branch had inadvertently relied on the
in-Solve flipped translation convention via the image.cam_from_world
member directly; native's CamFromWorld() resolves cleanly because we run
this BEFORE the Solve loop temporarily flips frame_centers_.

Verified ATE 37.40/66.77/93.35/96.68/99.67 unchanged (default BATA mode +
filter_depth_outliers=false) on sample-10 LaMAR HGE.
…ETRIC_DEPTH

Pycolmap binding for native colmap::GlobalPositioner now exposes the full
glomap-fork option surface (M2) + dict return shape (Decision 7).

Option struct extension (BindGlobalPositioner):
- New PointConstraintType enum binding (BATA / SPLIT_METRIC_DEPTH).
- Bind use_init, random_init_scale, depth toggles
  (use_log_scale_for_depth_map_scales, use_log_residual_for_depth,
  zero_residual_behind, smooth_log_linear_transition,
  log_linear_threshold), scale_prior_stddev, filter_depth_outliers.
- Bind initial_dmap_scales as Optional[Dict[image_id, float]] with None
  short-circuit; Python passes linear-space, C++ stores per
  use_log_scale_for_depth_map_scales convention.
- Bind 10 LossFunctionConfig fields as dict-typed properties via
  templated BindLossField helper accepting/emitting
  {'name', 'scale', 'weight'}. Names verbatim from glomap_ra port:
  loss_normal_geometry, loss_normal_depth, loss_lc_geometry,
  loss_lc_depth, loss_normal_geometry_inlier, loss_normal_depth_inlier,
  loss_normal_depth_outlier, loss_normal_geometry_trackstart,
  loss_normal_depth_trackstart, loss_scale_prior.

run_global_positioning return shape:
- Was: bool. Now: dict {success: bool, dmap_scale_map: Dict[image_id,
  float] (linear), dmap_scale_map_nested: Dict[image_id, {linear, log}]}.
- Reconstruction is mutated in place; Python reads images/tracks from
  rec.images / rec.points3D directly.
- Mirrors glomap_ra binding return shape so videosfm caller migration
  (Python side, deferred to next session) is a near-1:1 swap.

GetDmapScales accessor on GlobalPositioner exposes the per-image
dmap_scales_ map for the binding's dict-extraction step. Linear
conversion (exp() when use_log_scale_for_depth_map_scales=true) happens
in the lambda.

Verified: ATE 37.40/66.77/93.35/96.68/99.67 unchanged on sample-10 LaMAR
HGE — caller still on glomap_ra path; native binding extension is
additive and doesn't affect existing flow.
…ons bindings

Bug fixes uncovered during M7 caller migration:

1. SetupProblem scales_.reserve undercount: was only counting
   point3D.track.Length(); M5's two-loop iteration also walks
   track.lc_elements, each emplace_back-ing a new scale. Without
   reserved capacity, vector reallocation invalidates earlier &scale
   pointers stored in Ceres residual blocks → "ParameterBlock with
   size 1 has at least one invalid value". Fix: include lc_elements
   in the reservation count.

2. dmap_scales_ container: switched from unordered_map<image_t, double>
   to std::map<image_t, double>. Ceres residuals store
   &dmap_scales_[image_id] data pointers; a hash-table rehash during
   lazy-insert in AddObservationToProblem invalidates them. std::map
   (rb-tree) keeps pointers stable for the entry's lifetime.

3. Parameter group separation: dmap_scales_ now go in their own
   parameter group (group_id+1), separate from frame_centers_. Mixing
   1-D dmap_scales with 3-D frame_centers in the same Schur-ordering
   group breaks the Schur-complement preprocessor — Ceres downgrades
   to SPARSE_NORMAL_CHOLESKY then rejects the inhomogeneous block sizes.
   Separate group keeps each Schur block size-uniform.

4. Pycolmap binding: added solver_options.{num_threads,
   max_num_iterations, function_tolerance, gradient_tolerance,
   parameter_tolerance} as def_property pass-throughs on
   GlobalPositionerOptions. The solver_options nested struct is not
   directly bound in pycolmap, so caller migration would otherwise
   fail with "object has no attribute 'solver_options'".
   Mirrors the existing pyglomap binding's pattern.

Verified GP runs to CONVERGENCE (no more spurious FAILURE termination)
on sample-10 LaMAR HGE under both BATA and SPLIT_METRIC_DEPTH paths.
ATE differs from glomap_ra baseline (cross-implementation drift expected
per Decision 7); structural integrity restored.
Glomap-fork RA contributed a per-pair IRLS final-weight map that the
videosfm pipeline reads for its consecutive-pair-weight degeneracy
diagnostic (rotation_averaging.py:117-129). Native colmap RA discards
the IRLS weight vector after the final factorize. M8 plumbs it out.

Plumbing (4 layers):
1. RotationAveragingProblem gains final_weights_ member +
   FinalWeights() accessor + SetFinalWeightsFromIRLS(weights_irls)
   setter. The setter translates the per-row weight vector into a
   per-pair map keyed by image_pair_t. Each PairConstraint owns 1
   (gravity-aligned) or 3 (full-3DOF) consecutive rows starting at
   constraint.row_index — we read weight at row_index (all rows
   share the same weight per ComputeIRLSWeights). Gauge-fixing rows
   excluded.
2. SolveIRLS captures the last successful iteration's weight vector
   in last_weights and calls problem.SetFinalWeightsFromIRLS at the
   end (after the convergence loop). Empty if SolveIRLS doesn't run
   (e.g. L1-only path).
3. RotationEstimator::EstimateRotations + SolveRotationAveraging gain
   std::unordered_map<image_pair_t, double>* final_weights = nullptr
   out-param. When non-null, copy problem.FinalWeights() to *out
   after the solve.
4. RunRotationAveraging mirrors. Wired through both branches (with /
   without rig expansion).

Pycolmap binding:
- Adds extract_final_weights: bool = False kwarg. Default returns
  bool (preserves upstream colmap test ABI per Decision 8). When True,
  returns {success: bool, final_weights: Dict[image_pair_t, float]}
  dict. C5 fix: indexed by constraint.row_index (the row-major index
  into weights_irls), NOT by pair_info.index (the inventory's earlier
  draft).

Default behavior unchanged. Verified the C++ builds clean.
Glomap-fork RA's skip_risky_LC_pairs drops pairs whose loop-closure
inlier count strictly exceeds non-LC inliers. The filter reads
ImagePair.{inliers, are_lc} fork fields — these live on
CorrespondenceGraph::ImagePair, NOT on PoseGraph::Edge (which is a
strict subset). M9 plumbs an optional CorrespondenceGraph* through 4
layers so RA can apply the filter without bloating the upstream
PoseGraph interface.

Plumbing:
1. CorrespondenceGraph gains const ImagePairsMap() accessor (the
   existing MutableImagePairs() can't be called through a const
   pointer). Used only by RA M9.
2. RotationAveragingProblem ctor takes optional CorrespondenceGraph*
   (default nullptr). Stored as correspondence_graph_ member.
3. BuildPairConstraints reads cg.ImagePairsMap()[pair_id].{inliers,
   are_lc} when options_.skip_risky_LC_pairs && correspondence_graph_
   non-null. Counts LC inliers; if lc_count > non_lc_count, continue
   (pair excluded from constraint matrix entirely).
4. RotationEstimator::EstimateRotations + SolveRotationAveraging +
   RunRotationAveraging gain optional CorrespondenceGraph* parameter
   (default nullptr; passes through to RotationAveragingProblem ctor).
5. Pycolmap binding adds correspondence_graph: CorrespondenceGraph =
   None kwarg. Default behavior unchanged for callers that don't pass
   the CG.

Default behavior: with skip_risky_LC_pairs=False (default) or
correspondence_graph=None, no filtering — exactly matches pre-M9 behavior.

Verified C++ builds clean.
Glomap-fork RA's prioritize_tracking spanning-tree initialization
penalises LC-dominated edges so the MST picks tracking-dominated edges
as parents. Activated when use_video_constraints=true (M11 video-Ceres
path). Without the penalty, LC-contaminated initialization undermines
the Ceres video-aware solve.

ComputeMaximumPoseGraphSpanningTree (local helper in
rotation_averaging.cc) now accepts:
- prioritize_tracking (bool, default false): enable LC-penalty.
- correspondence_graph (CorrespondenceGraph*, default nullptr):
  source of ImagePair.{inliers, are_lc} fork fields.

When prioritize_tracking + correspondence_graph are both set, the MST
weight for each edge is reduced by 1e9 if the pair has strict LC
majority (lc_count > non_lc_count). Native MST maximises weights, so
fork's +1e9 to weights_boost becomes -1e9 to weight here
(convention inversion). Fallback to plain num_matches weight when
fork fields absent or empty.

InitializeFromMaximumSpanningTree forwards the CG; reads
options_.use_video_constraints to set prioritize_tracking. Wired
through SolveRotationAveraging from EstimateRotations + RunRotationAveraging.

Default behavior unchanged when use_video_constraints=false (no penalty
applied). Verified C++ builds clean.
Glomap-fork RA video-aware Ceres path. Activated when
options_.use_video_constraints && !options_.use_gravity. Replaces L1+IRLS
with a Ceres optimization over per-frame 3-DOF angle-axis blocks. Each
pair contributes a relative-rotation residual wrapped in either
HuberLoss (tracking-dominated pair, scale=video_tracking_huber_scale)
or CauchyLoss (LC-dominated pair, scale=video_lc_cauchy_scale).

Anonymous-namespace helpers:
- RelativeRotationError: AutoDiff<3, 3, 3> functor on (r1_aa, r2_aa).
  Residual = AngleAxis(R2^T * R_rel * R1) where R_rel is precomputed
  from PairConstraint::Full3DOF::R_cam2_from_cam1. Sign convention
  matches fork (no leading negative — the resulting AngleAxis is
  identical sign to the L1+IRLS tangent residual at convergence;
  Ceres doesn't care about sign anyway since LM minimizes
  ||residual||^2).
- IsTrackingPair: counts LC inliers from CorrespondenceGraph::ImagePair
  fork fields (inliers + are_lc); returns true when non_lc_count >=
  lc_count. Reads via problem.CorrespondenceGraphPtr() (M9 plumbing).

RotationAveragingProblem accessor additions (M11):
- MutableEstimatedRotations() — direct mutable access to per-frame
  3-DOF angle-axis storage so Ceres can optimize in place.
- FrameIdToParamIdx() — frame_id -> 3-DOF block offset mapping.
- ImageIdToFrameId() — for trivial-rig path PairConstraint
  image_id -> frame_id resolution.
- FixedFrameId() — gauge-fixing anchor.
- CorrespondenceGraphPtr() — M9 plumbing surface.

RotationAveragingSolver::Solve dispatches video-Ceres path before
L1+IRLS when use_video_constraints && !use_gravity. Mutually-exclusive
warning logged when both are set (use_video_constraints disabled).

Cost is Huber+Cauchy with TAKE_OWNERSHIP semantics; no leak. Solver:
SPARSE_NORMAL_CHOLESKY, max 100 iters. M0c-equivalent verification:
default behavior unchanged when use_video_constraints=false.
Final deletion phase. Pre-deletion grep verified that the deleted files
have no remaining external references after M7 + M12 caller migration.
Submodule pycolmap.glomap_ra stays alive — track-filter,
track-establishment, image-pair-inliers, view-graph-manipulation, and
relative-pose-estimation continue to bind under it.

Files deleted (~3000 LOC):
- src/colmap/sfm/global_positioning_glomap.{h,cc}
- src/colmap/sfm/rotation_averaging_glomap.{h,cc}
- src/colmap/sfm/cost_function_glomap.h
- src/colmap/sfm/l1_solver_glomap.h
- src/colmap/sfm/optimization_base_glomap.h
- src/colmap/sfm/tree_glomap.{h,cc} (only RA used it)
- src/pycolmap/sfm/global_positioning_glomap.cc
- src/pycolmap/sfm/rotation_averaging_glomap.cc

Files KEPT (still used by surviving glomap_ra modules):
- rigid3d_glomap.{h,cc} — track_filter_glomap consumer
- two_view_geometry_glomap.{h,cc} — image_pair_inliers consumer
- track_filter_glomap, track_establishment_glomap, image_pair_inliers,
  view_graph_manipulation, view_graph_calibration, relative_pose_estimation

CMakeLists updates:
- src/colmap/sfm/CMakeLists.txt: drop deleted entries from SRCS list.
- src/pycolmap/sfm/bindings.cc: drop BindGlobalPositionerOptions /
  BindGlobalPositioningGlomap / BindRotationAveragingGlomap forward
  decls + dispatch calls. Other binders (BindViewGraphManipulation,
  BindRelativePoseEstimation, BindTrackEstablishmentGlomap,
  BindImagePairInliersGlomap, BindTrackFilterGlomap) preserved — those
  bind to the pycolmap.glomap_ra submodule which stays alive.

Pycolmap CMake auto-globs sources, so the .cc deletions remove
themselves from the binding build.

Verified: end-to-end pipeline runs on sample-10 LaMAR HGE +
canonical DB + GP_SEED=1 with native pycolmap.run_global_positioning +
pycolmap.run_rotation_averaging only. ATE produced (cross-impl drift
vs glomap_ra-baseline expected per Decision G2).
…ailable

Bug uncovered by post-port audit (audit_algorithmic_semantics.md
suspect #1): the M3 cost functor WeightedBATADirectionalError was
defined and built, but AddObservationToProblem unconditionally created
the unweighted BATAPairwiseDirectionCostFunctor — the M5 cascade only
swapped the LOSS function, not the COST function. Result: anisotropic
per-keypoint weighting was dropped on every geometry residual,
causing the SPLIT_METRIC_DEPTH path to trade off geometry vs metric-
depth at the wrong relative scale.

Fix: dispatch WeightedBATADirectionalError when
image.angular_stddevs[fid] is populated (videosfm pipeline always
populates). sigma_x, sigma_y from the Vector2d entry; sigma_z =
0.5*(sigma_x+sigma_y). Falls back to BATAPairwiseDirectionCostFunctor
when sigmas absent. Mirrors fork's dispatch at
glomap/glomap/estimators/global_positioning.cc:592-601, 646-657.

Verified ATE recovery on sample-10 LaMAR HGE / ios_2022-01-12_14.59.02_000-0
+ canonical DB + GP_SEED=1:
- Pre-fix:  0.00 / 0.00 / 17.22 / 57.92 / 95.79
- Post-fix: 39.44 / 68.32 / 93.66 / 96.83 / 99.68
- Baseline: 37.40 / 66.77 / 93.35 / 96.68 / 99.67 (glomap_ra)

Native port now produces parity-or-slightly-better ATE than the
pre-port glomap_ra baseline. Cross-implementation drift is within
~5% (Decision G2 sanity threshold).
When random_seed == -1 (caller explicitly opts out of deterministic
seeding) fall back to the GP_SEED env var so the documented Tier-2
byte-identity recipe (CLAUDE.md "ATE byte-identity") works without
patching every call site for the transition cycle.

Plan-vs-execution audit flagged this: header docstring claimed
GP_SEED fallback existed but ctor only honored options_.random_seed >= 0.
Adding cstdlib for std::getenv/std::atoi.
…produce-fork TODOs

Per user audit ask: any place where M0–M13 changed colmap4 default
behavior to reproduce the pyglomap fork should be marked as a TODO
not silently hardcoded — the eventual goal is to converge back to
native defaults.

Two GP-side crutches:
- random_seed default 1 (upstream is -1 / random_device)
- GP_SEED env-var fallback in ctor when random_seed == -1

Both stay in place for Tier-2 byte-identity but are now marked
TODO(reproduce-fork). Companion notes:
.claude/notes/glomap_audit/audit_default_overrides.md
Six fork-imported headers in colmap/sfm/ now carry a per-file TODO
comment pointing at the dedup audit
(.claude/notes/glomap_audit/audit_glomap_files_vs_colmap4.md). Each
banner names which symbols in that TU duplicate native colmap4
helpers vs which are unique fork algorithms.

grep -rn 'TODO(dedup-glomap-vs-colmap4)' colmap/src now surfaces the
~250-350 LOC dedup follow-up scoped for after the M0-M13 PR merges.
…ault)

Per backward-compat study (audit_backward_compat.md): make vanilla
upstream colmap4 the default for both GP and RA so non-fork callers
get unmodified upstream behavior. Every fork-added feature now
requires an explicit opt-in.

GlobalPositionerOptions — 3 changes:
- New `use_observation_exclusions` (default false): gates the
  per-observation skip via `image.is_excluded[idx]` in
  AddObservationToProblem (Gate G-1).
- New `use_lc_observations` (default false): gates the
  `track.lc_elements` second loop in AddPoint3DToProblem and the
  paired sweep in FilterDepthOutliers. This IS the GP-side LC config —
  pairs with RA's `skip_risky_LC_pairs` to give two complementary LC
  controls (Gate G-2).
- `random_seed` default restored from 1 -> -1 (upstream non-deterministic
  random_device). The GP_SEED env-var fallback in the ctor stays as a
  documented escape for the Tier-2 byte-identity recipe (Gate G-3).

RotationEstimatorOptions — 1 change:
- New `prioritize_tracking_in_mst` (default false): decouples M10 MST
  LC-penalty from M11 `use_video_constraints`. Previously
  `ComputeMaximumPoseGraphSpanningTree(prioritize_tracking=...)` was
  hardcoded to `use_video_constraints`; now a separate knob (Gate R-1).

pycolmap binding: 3 new `def_readwrite` entries on GlobalPositionerOptions
(use_observation_exclusions, use_lc_observations) and RotationEstimatorOptions
(prioritize_tracking_in_mst).

Net effect:
- Vanilla pycolmap.run_global_positioning / run_rotation_averaging
  callers now get upstream colmap4 behavior unmodified.
- videosfm flips all four gates true via _to_native_gp_options /
  _to_pycolmap_ra_options so the existing pipeline is unchanged.

Smoke-tested: defaults are False/-1, post-flip True/1.
Three-step dedup pass (audit_glomap_files_vs_colmap4.md). Replace
fork-imported helpers that duplicated existing colmap4 native code.

#1 union_find_glomap.h (-42 LOC)
- DELETED. colmap::UnionFind<T> at colmap/math/union_find.h:40 is a
  strict superset (Reserve / FindIfExists / Compress / Parents) and
  the body of Find/Union is byte-equivalent.
- Added 1-line Clear() to native colmap::UnionFind so it covers the
  one method TrackEngine used that wasn't already in native.
- track_establishment_glomap.h now includes colmap/math/union_find.h
  and uses unqualified UnionFind (resolves to colmap::UnionFind via
  parent-namespace lookup).

#2 rigid3d_glomap.{h,cc} (-79 LOC)
- DELETED entirely. All 8 symbols turned out to be dead code:
  - DegToRad / RadToDeg duplicated colmap/math/math.h:59-64
  - RotationToAngleAxis / AngleAxisToRotation / Rigid3dToAngleAxis /
    CalcAngle*2 / CalcTrans / CalcTransAngle were declared and defined
    but never called from any TU after M0-M13.
- Includers (image_pair_inliers_glomap.h, track_filter_glomap.cc)
  switched to direct includes of colmap/math/math.h + colmap/geometry/rigid3.h.

#3 two_view_geometry_glomap.{h,cc} (-83 LOC)
- DROPPED EssentialFromMotion (= EssentialMatrixFromPose at
  colmap/geometry/essential_matrix.cc:97).
- DROPPED SampsonError(E, Vec2, Vec2) overload (= ComputeSquaredSampsonError
  at essential_matrix.cc:168, callers pass .homogeneous()).
- DROPPED HomographyError (= ComputeSquaredHomographyError at
  colmap/geometry/homography_matrix.cc:266, identical formula).
- DROPPED FundamentalFromMotionAndCameras (was 1-line wrapper, dead
  code — never called).
- KEPT CheckCheirality (depth-gated single-pair extension, no native
  equivalent), GetOrientationSignum (vendored from GC-RANSAC),
  SampsonError(E, Vec3, Vec3) (depth-aware z-divide variant).
- image_pair_inliers_glomap.cc updated to call native helpers; added
  geometry/essential_matrix.h + geometry/homography_matrix.h includes.

Net: -204 LOC across 4 files dropped + 6 files updated. Build clean,
pycolmap loads, defaults unchanged.
Final dedup step (audit_glomap_files_vs_colmap4.md). Replace the
vendored `glomap_ra::Track` struct with native `colmap::Point3D`
throughout TrackEngine, TrackFilter, and the 2 binding TUs.

The vendored type was almost a verbatim duplicate of Point3D:
- xyz / color / is_initialized -> already on Point3D (M0/glomap-fork)
- observations (vector<pair>) -> Point3D::track.Elements() (vector<TrackElement>)
- lc_observations              -> Point3D::track.lc_elements (already added at scene/track.h:93)
- track_id                     -> redundant with the map key, dropped

Changes:
- track_establishment_glomap.h: drop `Track` struct + `Observation`
  typedef. `track_t` now aliases `colmap::point3D_t`. API uses
  `unordered_map<track_t, Point3D>`.
- track_establishment_glomap.cc: 4 algorithm methods rewritten to
  use Point3D::track.AddElement / .Elements() / .lc_elements / .Length().
- track_filter_glomap.{h,cc}: same.
- pycolmap/sfm/track_establishment_glomap.cc: drop ToPoint3D
  conversion (35 LOC). Bindings now pass-through dict<int, Point3D>
  with no fork↔native marshalling.
- pycolmap/sfm/track_filter_glomap.cc: drop ToGlomapRaTrack +
  FromGlomapRaTrack (40 LOC). Same pass-through pattern.

Net: -115 LOC across 6 files. Build clean, pycolmap loads,
establish_full_tracks / find_tracks_for_problem /
glomap_ra.filter_tracks_by_angle / glomap_ra.filter_track_triangulation_angle
all expose the same dict<int, Point3D> shape they did before — videosfm
caller is unchanged.
Round of post-review polish on the backward-compat + dedup PRs.

Correctness / fail-loud:
- track_establishment_glomap.cc: THROW_CHECK on the two
  ProcessLoopClosurePairs tracks.emplace calls (track_id
  collision was previously silent).
- track_filter_glomap.cc + track_establishment_glomap.cc: switch
  track.Elements().clear() -> track.SetElements({}) so the
  call survives a future signature drift on Elements()
  (review #7).

Docstring / comment:
- pycolmap motion_averaging.cc: random_seed binding doc now
  matches the new -1 default and surfaces the GP_SEED env-var
  escape at the Python boundary (review #1, #3).
- global_positioning.h: header note on use_observation_exclusions
  clarifies that Image::is_excluded is itself fork-only;
  vanilla pycolmap users won't typically populate it (review #2).
- rotation_averaging.h: prioritize_tracking_in_mst comment
  rephrased to describe gate semantics rather than videosfm wiring
  (the very coupling we just decoupled).
- two_view_geometry_glomap.h: header explains FundamentalFromMotionAndCameras
  was a dead-code delete (no replacement migration), unlike the
  other 3 dropped helpers which got migrated to native (review #1
  generic).
- track_establishment_glomap.h: file-level note explaining why the
  glomap_ra namespace alias is kept post-collapse (binding-path
  stability — pycolmap submodule + videosfm import paths).
Two cleanups bundled.

D1 (loss_config dedup, audit_dedup.md):
- LossFunctionConfig{name, scale, weight} (string-based) replaced with
  LossConfig{type, scale, weight} where type is the native
  CeresBundleAdjustmentOptions::LossFunctionType enum.
- Free CreateLossFromConfig factory dropped; LossConfig::CreateLossFunction()
  now reuses native colmap::CreateLossFunction(type, scale) plus a
  3-line ScaledLoss(weight) wrap.
- Native colmap::CreateLossFunction was previously in an anonymous
  namespace inside bundle_adjustment_ceres.cc; promoted to a public
  forward declaration in bundle_adjustment_ceres.h so other estimators
  can route through it.
- pycolmap binding: dropped the templated string-based BindLossField
  helper. Each loss field is now a plain def_readwrite returning a
  pycolmap.LossConfig object. videosfm side does the
  string -> LossFunctionType conversion at the bridge boundary.
- arctan support deferred — only used by gravity_refinement (separate TU,
  uses ceres::ArctanLoss directly), never used by GP cascade.

Comment cleanup pass:
- Strip M0..M13 milestone tags, Gate G-X / Gate R-X labels, audit_*.md
  cross-refs, videosfm sets X via Y_options'' caller-knowledge phrases.
- Reduce verbose --- Glomap-fork ... (M5) ---'' banners to short
  purpose lines or drop entirely. Keep WHY / hidden-constraint /
  workaround comments verbatim.
- Touched: global_positioning.{h,cc}, rotation_averaging.{h,cc},
  rotation_averaging_impl.{h,cc}, motion_averaging.cc binding.

Build + smoke clean. videosfm bridge updated in companion commit.
…ning.h

Follow-up to D1 dedup. The loss_config.h after the rewrite was just a
~25-line struct definition with one consumer (GlobalPositionerOptions).
Inline it into global_positioning.h next to PointConstraintType so the
GP-specific helpers live together.

- LossConfig struct + CreateLossFunction() method moved to
  colmap/estimators/global_positioning.h.
- loss_config.h deleted; CMakeLists.txt entry removed.
- Includers (global_positioning.cc, motion_averaging.cc binding) drop
  the include — global_positioning.h covers it transitively.

Net: 1 fewer header in the tree.
Lift the loss-creation primitive out of bundle_adjustment_ceres.{h,cc}
into a dedicated colmap/estimators/ceres_loss.{h,cc} so it's a true
shared utility — not a private detail of CeresBundleAdjustmentOptions.

Changes:
- New colmap/estimators/ceres_loss.{h,cc}: top-level
  colmap::LossFunctionType enum + colmap::CreateLossFunction(type, scale)
  factory. Definition moved verbatim from bundle_adjustment_ceres.cc.
- bundle_adjustment_ceres.h: CeresBundleAdjustmentOptions::LossFunctionType
  is now a back-compat using alias to colmap::LossFunctionType;
  the now-redundant forward declaration of CreateLossFunction is dropped.
- bundle_adjustment_ceres.cc: definition deleted (moved to ceres_loss.cc).
- global_positioning.h: GlobalPositionerOptions gains configurable
  loss_function_type + loss_function_weight fields. Default
  HUBER preserves the previous hardcoded behavior. CreateLossFunction()
  body now mirrors BA — dispatch via the factory + ScaledLoss(weight) wrap.
  Drops include of bundle_adjustment_ceres.h in favor of ceres_loss.h.
- pycolmap motion_averaging.cc: expose loss_function_type +
  loss_function_weight on GlobalPositionerOptions. The existing
  pycolmap.LossFunctionType enum (bound via BA's binding through the
  alias) is now the canonical Python-side type.

Net effect: vanilla colmap GP becomes loss-configurable like BA. The
fork's 10-bucket cascade (LossConfig) still uses the same enum — now
referenced via the top-level type rather than nested inside BA options.
Audit catch (parallel agents auditing fork-modified native files for
non-fork drift). The pre-existing fork copy-ctor and operator= for
colmap::Image carried all the per-feature fork fields (depth_priors,
angular_stddevs, is_inlier, is_excluded, is_track_anchor,
is_depth_outlier, etc.) but missed the more recently added
pixel_cholesky_xy_ private member. Same class of bug as the documented
`cam_from_world` regression in
memory/glomap_ra_port_lto_fp_nondeterminism.md — any py::cast roundtrip
or value-copy of an Image silently zeroed the field, breaking pose-cov
covariance plumbing for downstream consumers.

Also reorders the mem-init list to match the header declaration order.

Cosmetic-only follow-ups in the same audit pass:
- estimators/global_positioning.cc: dedent the extracted
  AddObservationToProblem body by 2 spaces (was inheriting the
  original loop-body indentation; cosmetic-only, no semantic effect).
- scene/track.h: collapse stray double blank line.

Build clean; default-constructed Image still works.
…tive primitives

Per audit_dedup.md D2: replace the two fork cost functors in
cost_functions/metric_depth.h:182-244 with native
CovarianceWeightedCostFunctor<NormalPriorCostFunctor<1>> from
cost_functions/utils.h.

Mathematically equivalent:
- ScalePriorError(prior=1, sigma): r = (s - 1) / sigma
- LogScalePriorError(sigma_log):   r = log_s / sigma_log
- Native: CovarianceWeightedCostFunctor<NormalPriorCostFunctor<1>>::Create(cov, prior_vec)
  with cov = sigma², prior_vec = (1.0) for linear or (0.0) for log.

The linear/log selection in global_positioning.cc:226-232 collapses to
a single Create() call where the only branch is which constant the
prior_vec carries (0.0 vs 1.0).

Net: -73 LOC in metric_depth.h (the 2 functor bodies + their factories
+ docstrings), +9 LOC in global_positioning.cc (vec construction +
Create call). Build clean; ScalePriorError/LogScalePriorError no
longer exposed.
…WCF<BATA>

Per audit_dedup.md D4: replace the fork's anisotropic-weighted BATA
functor with native CovarianceWeightedCostFunctor<BATAPairwiseDirectionCostFunctor>.

The fork rotated the residual into camera frame inside the functor:
    r_fork = diag(1/sigma) * R * (t_obs - s*(c2-c1))

Native CWCF whitens with sqrt(cov^-1) of a passed-in covariance.
Encoding the rotation in the world-frame covariance:

    cov_world = R^T * diag(sigma^2) * R

makes ||r_native||^2 == ||r_fork||^2 (since both equal
r_world^T * cov_world^-1 * r_world). Cost contribution is identical;
residual vectors differ but Ceres loss functions act on the squared
norm so the optimization converges to the same minimum.

Why R can be baked at construction time: GP rotations are frozen
post-RA. Ceres parameter blocks are (position1, position2, scale)
only — rotation is never updated by the optimizer.

Made BATAPairwiseDirectionCostFunctor CWCF-compatible by adding the
2-line metadata (kNumResiduals + kParameterDims) the wrapper expects.
This parallels what NormalPriorCostFunctor already does and is an
upstream-friendly addition.

Net: -60 LOC functor body in motion_averaging.h, +14 LOC at the
dispatch site (cov_world construction + CWCF::Create call).
WeightedBATADirectionalError no longer exposed.

Risk: medium — WeightedBATADirectionalError was the M5 critical fix
that recovered ATE 0 -> 39. Math is provably equivalent in cost, but
autodiff Jacobian path differs vector-wise so FP precision may
shift slightly. Needs sample-10 LaMAR HGE ATE check before merge.
Per audit_glomap_files_vs_colmap4.md Phase 5 (Strategy A): both native
GlobalMapper::EstablishTracks and fork TrackEngine::EstablishFullTracks
implemented the same union-find + intra-image consistency + length
filter + greedy subsample algorithm over post-RANSAC corr-graph
inliers. Extract the core into a new shared helper so both consumers
route through the same code; fork's LC second-pass +
2-view-depth-prior gate stay as fork-specific extensions.

New: src/colmap/sfm/track_establishment.{h,cc} (~210 LOC)
- struct TrackEstablishmentOptions{intra_image_consistency_threshold,
  min_num_views_per_track, required_tracks_per_view}
- using MatchPredicate = std::function<bool(image_t, point2D_t,
  image_t, point2D_t)> — optional, returns true to skip a match in
  the UF phase. Used by fork to skip LC inliers in the regular pass.
- EstablishTracksFromCorrGraph(valid_pair_ids, corr_graph,
  image_id_to_keypoints, options, ignore_match) — returns
  unordered_map<point3D_t, Point3D>.

GlobalMapper::EstablishTracks shrinks from ~135 LOC to ~30 LOC of
orchestration: builds the keypoint map + collects valid pair ids
from pose_graph, calls the helper, commits the result via
AddPoint3D. No behavior change for native callers — predicate
defaults to nullptr (no LC skip), options carry the same defaults
as the previous inline impl.

TrackEngine::EstablishFullTracks now:
- Builds image_id_to_keypoints from images_ (fork's Image.features
  rather than reconstruction's points2D — pre-rec state).
- Pre-computes an LC observation set from view_graph.image_pairs[*]
  inliers + are_lc, encoded as pair<uint64_t, uint64_t> for std::set
  lookup.
- Calls EstablishTracksFromCorrGraph with an ignore_match predicate
  that skips matches in the LC set.
- Calls existing ProcessLoopClosurePairs as the post-pass to populate
  Track::lc_elements (and mint two new tracks per LC-only observation
  pair, with obs-key-derived ids that don't collide with native's
  dense [0, N) range since image_id<<32 >= 2^32).

Deleted from track_establishment_glomap.{h,cc}:
- TrackEngine::BlindConcatenation (~55 LOC)
- TrackEngine::TrackCollection (~110 LOC)
- UnionFind<image_pair_t> uf_ member
- Now-unused colmap/math/union_find.h include
Total: ~165 LOC removed from fork; ~210 LOC added in the new shared
helper (most of which is moved-not-new code).

Backward compat:
- Native API gains an optional ignore_match parameter defaulting
  to nullptr — vanilla callers see identical behavior.
- Fork API unchanged (TrackEngine::EstablishFullTracks signature +
  return shape preserved).
- pycolmap binding unchanged.

Risk: medium — core algorithm is byte-equivalent (verbatim move of
the loop bodies), but unordered_map iteration order may differ
between native's local instance and fork's instance, which can
produce a different greedy-subsample tiebreak. Default options
have required_tracks_per_view=INT_MAX (no-op subsample), so
iteration order doesn't matter for native. Fork's caller passes
require_tracks_per_view=INT_MAX as well at this stage; the actual
greedy subsample lives in FindTracksForProblem (untouched).

Needs sample-10 LaMAR HGE ATE check before merge.
Phase 5 first-run verify revealed that ExtractMatchesBetweenImages reads
from CorrespondenceGraph::flat_corrs which is only populated by
FinalizeAfterMatchingComplete (native pipeline's geom-verify step). The
fork-driven videosfm pipeline writes inliers directly into
ImagePair::matches + ImagePair::inliers without going through
flat_corrs, so the helper saw zero matches and produced zero tracks.

Fix: iterate ImagePair::matches indexed by ImagePair::inliers directly.
Both the native colmap4 pipeline (which populates these fields during
geom-verify) and downstream pipelines that populate them via custom
relative-pose flows (e.g. videosfm's MDRP) work through this path.

Drops ExtractMatchesBetweenImages dependency entirely; drops the
feature/types.h include that came with FeatureMatches.

Verified end-to-end: sample-10 LaMAR HGE produces 25/25 registered
images with AUC 73.69/86.85/97.37 @ {0.05, 0.1, 0.5}m. Tier-1
structural identity holds through GP1+GP2 (n_tracks, total_observations,
total_lc_observations all match m13 baseline byte-for-byte at every
checkpoint).
Spread across 4 files:

- src/colmap/estimators/cost_functions/motion_averaging_test.cc (+9 tests)
  CWCF<BATAPairwiseDirectionCostFunctor> math equivalence with random
  inputs (rotation, sigma, c1, c2, scale, t_obs); zero-residual edge
  case; identity-rotation isotropic-sigma reduction. Algebraically
  proves D4 dedup (cov_world = R^T diag(sigma^2) R) preserves the
  squared residual norm of the deleted WeightedBATADirectionalError.

- src/colmap/sfm/track_establishment_test.cc (+5 tests, NEW file)
  Phase 5 helper EstablishTracksFromCorrGraph: 3-pair triangle smoke
  test, length filter drops short tracks, intra-image-consistency
  drop, ignore_match predicate, empty-input no-crash.

- src/colmap/estimators/cost_functions/metric_depth_test.cc (+7 tests, NEW)
  MetricDepthError: zero residual when prior matches predicted depth;
  linear vs log residual mode formulas; zero_residual_behind_camera
  gate; log-scale parameterization; 5-toggle smoke; rejects
  non-positive sigma.

- src/colmap/estimators/global_positioning_test.cc (+4 tests)
  LossConfig::CreateLossFunction(): TRIVIAL/HUBER/CAUCHY/SOFT_L1
  dispatch through native colmap::CreateLossFunction; weight != 1
  triggers ScaledLoss wrap (verified by 2x scaling on output rho).

CMakeLists entries added for the two new test files; existing
TESTS_ENABLED=ON build picks them up. All 4 ctest groups pass.
Phase 5 left ProcessLoopClosurePairs minting new tracks via
static_cast<track_t>((image_id<<32)|feature_id). With image_id >= 1
this lands ids >= 2^32, well above native's dense [0, N) range, so
collisions are theoretical-only. Switch to sequential ids continuing
from native's max — eliminates the magic encoding and makes the
THROW_CHECK guard truly defensive rather than load-bearing.
Round 2 of unit-test additions (6 areas, 34 tests). Driven by the
'genuine coverage gaps' list — fork-unique algorithms previously
tested only via end-to-end pipeline.

ProcessLoopClosurePairs (track_establishment_test.cc, +5):
  4 branches of LC second-pass + sequential-id no-collision smoke.

FindTracksForProblem (track_establishment_test.cc, +6):
  Length filter, 2-view depth-prior gate (drop + keep + boundary),
  greedy quota, min_num_tracks_per_view default-bug enshrined.

TrackFilter (track_filter_test.cc, NEW, +7):
  FilterTracksByAngle aligned/misaligned/uncalib-double-thresh +
  point-behind-camera; FilterTrackTriangulationAngle parallel/perp +
  threshold sweep.

RelativeRotationError (rotation_averaging_test.cc, +3):
  Zero residual when consistent, non-zero on injected angular error
  matches the discrepancy norm, swap+invert symmetry.
  Required moving the struct from anonymous namespace in
  rotation_averaging_impl.cc into rotation_averaging_impl.h so tests
  can construct it directly.

ImagePairsInlierCount (image_pair_inliers_test.cc, NEW, +5):
  4-gate degeneracy (epipolar, cheirality, epipole-angle, depth-prior
  vs nodepth threshold). All 4 gates exercised on the essential-matrix
  path with controlled synthetic inputs.

Backward-compat gates G-1/G-2/R-1 (+8):
  G-1 (use_observation_exclusions) off/on/partial — global_positioning_test.cc
  G-2 (use_lc_observations) off/on — global_positioning_test.cc
  R-1 (prioritize_tracking_in_mst) off/on/no-corr-graph-fallback — rotation_averaging_test.cc.
  Required exposing ComputeMaximumPoseGraphSpanningTree as a public
  symbol in rotation_averaging.h (was anon-namespace).

Net: 65 total unit tests across 8 ctest groups, all passing.
The ``colmap::glomap_ra::`` namespace and pycolmap.glomap_ra submodule
were named after the original rotation-averaging port from glomap, but
their current contents are track establishment, track filtering, and
pair-inlier scoring — none of which are RA. Rename to ``sfm_ext``
(SFM extensions) so the label reflects the actual contents.

Mechanical sweep across 11 C++ files + 4 binding files:
- ``namespace glomap_ra { ... }`` -> ``namespace sfm_ext { ... }``
- ``glomap_ra::Foo`` -> ``sfm_ext::Foo`` everywhere
- ``GetOrCreateGlomapRaModule`` -> ``GetOrCreateSfmExtModule``
- ``m.def_submodule("glomap_ra")`` -> ``m.def_submodule("sfm_ext")``

Python alias kept for back-compat: pycolmap/__init__.py defines
``glomap_ra = sfm_ext`` so existing videosfm callers
(``pycolmap.glomap_ra.filter_tracks_by_angle`` etc.) keep working
unchanged. Filenames keep the ``_glomap`` suffix for now — file rename
is a separate, larger churn.

Verified:
- Full build clean (warm).
- 8/8 ctests pass: track_establishment_test, track_filter_test (7/7),
  image_pair_inliers_test, motion_averaging_test, metric_depth_test,
  global_positioning_test, rotation_averaging_test (controllers +
  estimators).
- pycolmap import + alias intact: pycolmap.glomap_ra is pycolmap.sfm_ext,
  filter_tracks_by_angle resolves on both.
- videosfm GlobalPositioningPhase imports OK with the alias.
Cleans up post-rename comment debt:
- image_pair_inliers_glomap.h: remove TODO that already shipped (Sampson/H errors now route through native helpers); drop 'vendored from glomap/types.h' framing on InlierThresholdOptions.
- track_filter_glomap.cc: replace 'glomap RA pipeline' / 'RA-shadow' wording with neutral 'dict-of-tracks state used by sfm_ext'.
- pycolmap/sfm/track_filter_glomap.cc: drop pyglomap.* Python-name mentions (module no longer exists).
- pycolmap/sfm/track_establishment_glomap.cc: drop 'Glomap-style' from establish_full_tracks docstring.
Second comment-cleanup pass beyond the sfm_ext-renamed files. Strips
'mirrors pyglomap.X', 'byte-for-byte parity with pyglomap', 'so the
videosfm pyglomap shim can be retired' framing now that pyglomap is
gone and the videosfm shim is removed.

Preserved: design rationale (e.g. issue colmap#40 reference for adopting
CalibrateViewGraph wrapper, pybind11 deep-copy gotcha), open
algorithmic TODOs unrelated to the rename.
…lishment

Removes the parallel colmap::sfm_ext::TrackEngine path. The fork-specific
behaviors that lived in track_establishment_glomap.{h,cc} now live as
native free functions in track_establishment.{h,cc} and as methods on
GlobalMapper. Net diff -300 LOC; one less namespace; one less file pair.

Native types already carried the data (Track::lc_elements,
ImagePair::are_lc, Image::depth_priors / depth_prior_validity were added
during the M0-M13 port). This commit fills in the missing code paths:

New free functions in colmap::namespace (track_establishment.{h,cc}):
- MakeLoopClosureMatchPredicate(valid_pair_ids, corr_graph) - returns a
  MatchPredicate that excludes ImagePair::are_lc[idx]==true inliers from
  the union-find pass, pre-computing the LC set in O(num inliers).
- AppendLoopClosureObservations(valid_pair_ids, corr_graph, tracks) -
  4-branch second pass that populates Track::lc_elements (no-track /
  both-tracks / one-track-each / same-id, mints new tracks at
  max(track_id)+1). Operates on a tracks dict so binding + GlobalMapper
  share the call site.
- struct TrackSubsampleOptions + SubsampleTracks(...) - depth-aware
  greedy length-sorted subsample. Reads depth_priors / depth_prior_validity
  / registered_image_ids passed as separate maps to keep the function
  Reconstruction-free. Asymmetric length filter preserved (lower bound
  counts Length+lc_elements, upper bound counts Length only).

GlobalMapper extensions (global_mapper.{h,cc}):
- GlobalMapperOptions gains 4 fields (default-off so vanilla colmap
  behavior is unchanged): track_lc_second_pass, track_two_view_depth_gate,
  track_max_num_views_per_track, track_max_num_tracks.
- EstablishTracks runs MakeLoopClosureMatchPredicate +
  AppendLoopClosureObservations when track_lc_second_pass=true; bypasses
  the helper-side greedy subsample in that mode.
- New SubsampleTracksForProblem method bridges Reconstruction <-> dict and
  calls SubsampleTracks.

Pycolmap binding (track_establishment_glomap.cc - file kept, contents
rewritten to use native API):
- pycolmap.establish_full_tracks(view_graph, images, options,
  lc_second_pass=False) - top-level binding (path preserved). Takes the
  native pycolmap.TrackEstablishmentOptions; lc_second_pass optional
  kwarg toggles the LC second pass.
- pycolmap.find_tracks_for_problem(view_graph, images, tracks_full,
  options) - takes pycolmap.TrackSubsampleOptions (new bound struct).
- The old pycolmap.TrackEstablishmentOptions (fork shape) is gone. The
  native TrackEstablishmentOptions has 3 fields
  (intra_image_consistency_threshold, min_num_views_per_track,
  required_tracks_per_view); the 5 subsample knobs (max_num_views_per_track,
  max_num_tracks, two_view_depth_gate, etc.) live on the new
  TrackSubsampleOptions.

Tests (track_establishment_test.cc):
- All 16 tests preserved (5 native + 6 FindTracksForProblem-now-SubsampleTracks
  + 5 ProcessLoopClosurePairs-now-AppendLoopClosureObservations).
- MinTracksPerViewBugDocumentation rewritten to describe the new int-typed
  INT_MAX default (signed comparison never trips) instead of the old
  signed/unsigned -1 -> UINT_MAX promotion. Same observable behavior.

Adjacent (track_filter_glomap.{h,cc} + binding + tests):
- Repointed track_t / feature_t typedef include from the deleted
  track_establishment_glomap.h to colmap/util/types.h (the canonical
  point3D_t / point2D_t aliases). track_t / feature_t aliases are gone.

Files DELETED:
- src/colmap/sfm/track_establishment_glomap.h (-82 LOC)
- src/colmap/sfm/track_establishment_glomap.cc (-311 LOC)

Verified: scripts/build.sh clean (cold + warm). 7 ctest groups pass
(scene/track, scene/image, sfm/track_establishment, sfm/track_filter,
sfm/image_pair_inliers, estimators/global_positioning, estimators/
rotation_averaging) - 100% in 0.3s.
Three-stage cleanup mirroring the TrackEngine fold (commit 77c0574).
Net result: ZERO _glomap files in colmap/sfm/, ZERO sfm_ext namespace
references. All fork-extension code now lives in plain colmap::
namespace alongside upstream code, with no naming tax.

Stage 1 - two_view_geometry_glomap deletion (-79 LOC):
- 3 helpers (CheckCheirality single-pair, GetOrientationSignum,
  SampsonError Vec3 depth-aware) had ONE caller each
  (image_pair_inliers). Folded as anonymous-namespace statics in the
  consumer file. Deleted two_view_geometry_glomap.{h,cc} +
  CMakeLists entry.

Stage 2 - track_filter_glomap rename (no LOC delta, +/- ~250):
- Renamed src/colmap/sfm/track_filter_glomap.{h,cc} -> track_filter.{h,cc}.
- Dropped sfm_ext:: namespace -> plain colmap::.
- Converted struct TrackFilter::FilterX static methods to free
  functions FilterTracksByAngle / FilterTrackTriangulationAngle.
- Pycolmap binding: pycolmap.sfm_ext.filter_tracks_by_angle ->
  pycolmap.filter_tracks_by_angle (top-level). Same for
  filter_track_triangulation_angle. Test file + downstream callers
  updated.

Stage 3 - image_pair_inliers_glomap rename (no LOC delta):
- Renamed src/colmap/sfm/image_pair_inliers_glomap.{h,cc} ->
  image_pair_inliers.{h,cc}.
- Dropped sfm_ext:: namespace -> plain colmap::.
- Behavior preserved verbatim (hard-coded thres_epipole_nodepth,
  disabled Gate-3 angle check, F-branch positive_count==negative_count
  silent drop). TODOs for later cleanup left in-place.
- Pycolmap binding: pycolmap.image_pairs_inlier_count was already
  top-level; renamed BindImagePairInliersGlomap -> BindImagePairInliers.
- Renamed src/pycolmap/sfm/track_establishment_glomap.cc ->
  track_establishment.cc; renamed BindTrackEstablishmentGlomap ->
  BindTrackEstablishment. Same fold treatment.

Pycolmap top-level surface (no sfm_ext submodule any more):
- pycolmap.establish_full_tracks / find_tracks_for_problem
- pycolmap.filter_tracks_by_angle / filter_track_triangulation_angle
- pycolmap.image_pairs_inlier_count
- pycolmap.TrackEstablishmentOptions / TrackSubsampleOptions / InlierThresholdOptions

Removed back-compat alias pycolmap.glomap_ra = pycolmap.sfm_ext (no
remaining callers; sfm_ext submodule itself is gone).

Verified: scripts/build.sh clean. 7 ctest groups pass (scene/track,
scene/image, sfm/track_establishment, sfm/track_filter,
sfm/image_pair_inliers, estimators/global_positioning, estimators/
rotation_averaging) - 100% in 0.29s.
…ield

Audit showed gravity_dir is exposed in the binding (motion_averaging.cc)
+ declared in the struct but has ZERO consumers in
colmap/src/colmap/estimators/. Setting it does nothing — pure footgun
for anyone who flips it expecting gravity-aware behavior. Pose-prior
data is supplied separately via the run_rotation_averaging
`pose_priors` argument; this field never participated in the up-vector
math.

The ten other dead-flag suspects from the prior audit
(use_precomputed_weights, fix_non_lc_weights, fixed_non_lc_weight,
consecutive_trivial, use_relative_pose_constraints, relative_pose_default_stddev,
loss_relative_pose, debug_only_relative_pose, scale_regularization_sigma,
image_ids_to_optimize) are already GHOSTS — not on the struct, not in
the binding, not in videosfm. No additional cleanup needed.
… BindImagePairInliers definition

Adds GlobalPositionerOptions::apply_uncalibrated_loss_downweight (bool,
default true). When true, AddPointToCameraConstraints wraps the BATA
loss in ScaledLoss(0.5) for cameras whose focal length lacks an EXIF
prior (Camera::has_prior_focal_length == false) - the mainline colmap4
heuristic for downweighting bearings that depend on a VGC-estimated
focal. When false, all cameras get full-weight residuals, matching the
glomap fork's behavior.

Default true preserves upstream colmap behavior; videosfm callers
calibrated against fork-flavored full-weight residuals can flip the
flag false at the bridge level (companion videosfm commit wires this
through GPOptions).

Bound in motion_averaging.cc.

Also fixes a leftover from the earlier _glomap rename (commit
db8a5a0): src/pycolmap/sfm/image_pair_inliers.cc still defined the
binder as BindImagePairInliersGlomap while bindings.cc was already
calling BindImagePairInliers, producing an undefined-symbol linker
error any time the binding got fully relinked. The earlier build
succeeded only because the .so was cached. Rename the definition to
match.
Three small hardening additions + comment-text cleanup across the
GP/RA C++ surface:

- rotation_averaging_impl.cc: SolveIRLS now mirrors SolveL1's NaN-guard
  on the step vector. Without this, a singular pose-graph silently
  corrupts cams_from_world via UpdateState and the IRLS loop returns
  true with garbage residuals on the next iteration. L1 already had
  this guard at line 755; IRLS didn't.

- rotation_averaging_impl.cc: RotationAveragingProblem ctor adds a
  THROW_CHECK that 'skip_risky_LC_pairs=true' requires a
  CorrespondenceGraph. Previously the LC-skip filter silently never
  triggered when CG was nullptr - the option appeared to work but
  did nothing.

- image.h: Image::operator== now also compares pixel_cholesky_xy_.
  This is the field that triggered the earlier copy-ctor field-drop
  bug fix (commit 985b2ec); including it in equality means a future
  unit test of the form 'EXPECT_EQ(copy, original)' would catch an
  analogous regression. Other fork-extension fields (depth_priors,
  is_inlier, cam_from_world, etc.) are intentionally NOT added -
  those are mutable pipeline state, not identity-defining.

- global_positioning.h: cleaned 'glomap fork' / 'videosfm callers'
  vocabulary leakage in the apply_uncalibrated_loss_downweight
  comment; the comment now describes what the bool does without
  referencing internal nomenclature. Also added a doc note that the
  10 cascade LossConfigs only run under SPLIT_METRIC_DEPTH (BATA mode
  silently ignores them - prevents future caller from misconfiguring
  these in BATA mode and wondering why nothing changes).
Match the C++ class name (CorrespondenceGraph). Aligns variable names,
parameter names, and dict keys with the type so callers see one term.
…g_in_mst

The MST initializer's LC-edge penalty branch was a separate flag from
the video-aware Ceres solver, but they were always enabled together
(the fork wired prioritize_tracking_in_mst directly off
use_video_constraints). Collapse: ComputeMaximumPoseGraphSpanningTree
now reads options_.use_video_constraints, deleting the redundant flag,
its pycolmap binding, and its option-struct field. The function-level
prioritize_tracking parameter stays, callable directly by unit tests.
…LcPenaltyMst

The flag was removed in 423c6e5; the tests now exercise the function-
level prioritize_tracking parameter directly. Rename the test cases
and the comment block to match the surviving concept (LC-penalty
branch in ComputeMaximumPoseGraphSpanningTree).
- CreateLossFunction: replace silent `return nullptr` on unhandled
  enum with LOG(FATAL). The previous fall-through would crash with a
  nullptr deref downstream; failing at the source surfaces an enum
  extension oversight immediately.

- run_global_positioning binding: drop the duplicate
  ``dmap_scale_map_nested`` result key. It was a literal alias of
  ``dmap_scale_map`` — same py::dict assigned twice. Caller now reads
  the single key.
Comment-only sweep across estimators / sfm / scene headers and
impls. Rewrites describe what the code does instead of where it
came from. No identifier or behavior changes.
ComputeResiduals is called from L1 and IRLS inner loops. The seed
call inside it reset the global PRNG to the same value every
iteration, replaying the identical jitter sequence in
ComputeGravityAligned1DOFResidual. Move the seed call to
RotationAveragingSolver::Solve (once per problem solve) so the
PRNG advances naturally across iterations — matches the fork's
behavior of seeding once at the GP/RA boundary.
Two fork-equivalent fixes to InitializeFromMaximumSpanningTree:

1. Seed cams_from_world[root] from the reconstruction's MDRP-prior
   pose before BFS-propagating relative rotations. The previous
   default-constructed identity seed rotated every descendant by
   R_root^-1 vs the prior gauge, which can shift IRLS weights through
   the max_rotation_error_deg filter.

2. Edge weight in ComputeMaximumPoseGraphSpanningTree now reads
   inliers.size() from the CorrespondenceGraph (post-RANSAC survivor
   count) when CG is plumbed through, falling back to edge.num_matches
   (raw match count) for vanilla colmap callers without a CG. Matches
   fork's MST weight semantic.

Test fixture updated: pair inliers vectors are now sized to match the
edge num_matches values so both weight sources agree on the MST shape.
…m fork

Restores the median-z_est/depth_prior seeding the fork ran at GP1
entry. Without this, GP1 lazy-inserts dmap_scales_ at 1.0 (linear) /
0.0 (log) for every image — the optimizer starts in a different basin
than the fork.

Source: glomap/glomap/estimators/global_positioning.cc:1006-1133.
Adapted to colmap4's Reconstruction model: iterates Points3D' track
elements (and lc_elements when use_lc_observations is set) to
accumulate per-image z_est/depth_prior samples, takes the median per
image, and seeds dmap_scales_ with log(median) or linear median per
use_log_scale_for_depth_map_scales.

Call site in Solve() is gated on SPLIT_METRIC_DEPTH + use_init +
initial_dmap_scales empty, so GP2 (which supplies initial_dmap_scales
from GP1 output) bypasses this path. Images without observations
consumed here still fall through to the lazy-insert path in
AddObservationToProblem.
Sweep upstream-bound C++ comments / docstrings / test-file
references for "pre-port", "post-port", "M1-M13" milestone tags,
"glomap-fork", "fork-only", "videosfm-side", and the
videosfm-private issue colmap#40 reference (incl. one runtime warning
string). Rewrites describe what the code does now. One identifier
rename: fork_sqnorm -> expected_sqnorm in motion_averaging_test.cc.
No behavior change.
…m_cam1 is null

The original port of glomap's UpdateImagePairsConfig added a
THROW_CHECK around the UNCALIBRATED -> CALIBRATED upgrade requiring
``tvg.cam2_from_cam1.has_value()``. Pairs whose decomposition never
produced a relative pose (e.g. F-only RANSAC path or decomposition
failure) crash the pipeline at this site.

The fork's ImagePair carried cam2_from_cam1 as a Rigid3d (always
default-constructible to identity) so it never tripped this check;
colmap's TwoViewGeometry::cam2_from_cam1 is std::optional<Rigid3d>
so absence is real and observable.

Replace the THROW_CHECK with `continue`. Pairs without a relative
pose stay UNCALIBRATED and are dropped by downstream filters
(inlier-num / inlier-ratio) instead of crashing. Verified end-to-end
on V2-adaptive sample-10 HGE.
@Zador-Pataki
Zador-Pataki merged commit d48d727 into glomap-refactor Apr 27, 2026
1 of 13 checks passed
@Zador-Pataki
Zador-Pataki deleted the glomap-refactor-2 branch April 27, 2026 22:49
Zador-Pataki added a commit that referenced this pull request Apr 28, 2026
* M1-M3: drop dead RA options + extend GP options + cost functors

M1 (Hygiene):
- Drop dead glomap-fork option fields use_precomputed_weights,
  fix_non_lc_weights, fixed_non_lc_weight from
  colmap::RotationEstimatorOptions. Per audit (gp_inventory.md, ra_inventory.md)
  these are dead in fork .cc itself or hardcoded false in pipeline. Drop
  matching pycolmap def_readwrite + clean up referencing code paths in
  glomap_ra::RotationEstimator (kills the obsolete SolveWeightedLS branch
  + the no-op fix_non_lc_weights warning).
- Update comment block at rotation_averaging.h:70-71 — only skip_risky_LC_pairs
  + use_video_constraints + video_*_scale survive as fork-flag stubs.
- Gate native GP first-scale pin (global_positioning.cc:349-355) on
  point_constraint_type != SPLIT_METRIC_DEPTH. Preserves existing colmap GP
  unit tests under default BATA mode (Q1 / R10).

M2 (Foundation):
- New file colmap/util/loss_config.h: LossFunctionConfig {name, scale,
  weight} + CreateLossFromConfig factory. Names: trivial, huber, cauchy,
  arctan, softlone. weight!=1 wraps in ScaledLoss.
- Extend colmap::GlobalPositionerOptions with glomap-fork additions:
  PointConstraintType enum (BATA / SPLIT_METRIC_DEPTH), use_init,
  random_init_scale, depth toggles (use_log_scale_for_depth_map_scales,
  use_log_residual_for_depth, zero_residual_behind, smooth_log_linear_transition,
  log_linear_threshold), scale_prior_stddev, filter_depth_outliers,
  initial_dmap_scales (optional<map<image_t, double>>),
  10 LossFunctionConfig fields with names verbatim from existing glomap_ra
  port (loss_normal_geometry, loss_normal_depth, loss_lc_geometry,
  loss_lc_depth, loss_normal_geometry_inlier, loss_normal_depth_inlier,
  loss_normal_depth_outlier, loss_normal_geometry_trackstart,
  loss_normal_depth_trackstart, loss_scale_prior).
- Change random_seed default from -1 to 1 (Decision 9, preserves
  byte-identity for existing call sites that previously inherited GP_SEED=1).
- Extend GlobalPositioner state: dmap_scales_, dmap_scale_observation_counts_,
  depth_outliers_, 10 cached_loss_*_ shared_ptrs, soft_outlier_fallback_loss_.

M3 (Cost functors):
- Add WeightedBATADirectionalError to cost_functions/motion_averaging.h.
  AutoDiff <3,3,3,1> on (c_i, X_k, scale). Anisotropic per-axis weighting
  (1/sigma_x, 1/sigma_y, 1/sigma_z). Caller computes
  sigma_z = (sigma_x + sigma_y) / 2.
- New file cost_functions/metric_depth.h with 3 functors:
  MetricDepthError (5-toggle, 12 reachable combinations — smooth_transition
  only meaningful in use_log_residual=true branch),
  ScalePriorError (linear), LogScalePriorError (log-space, prior=log(1)=0).
  All ported verbatim from fork's cost_function.h.

Cluster cleanup (carried over from prior session):
- Strip 175 lines of dead Fetzer focal cost from sfm/cost_function_glomap.h.
- Strip 47 lines of FilterTracksByReprojection (unbound, unused) from
  sfm/track_filter_glomap.{h,cc}.

Verified: ATE 37.40 / 66.77 / 93.35 / 96.68 / 99.67 unchanged (M0a baseline)
on sample-10 LaMAR HGE / ios_2022-01-12_14.59.02_000-0 + canonical DB
+ GP_SEED=1.

* M4: SPLIT_METRIC_DEPTH path body in colmap::GlobalPositioner

Implement the trivial-rig case of the metric-depth residual structure.
Activated by point_constraint_type=SPLIT_METRIC_DEPTH (default still
BATA — no behavior change for native callers).

Per-observation in AddPoint3DToProblem (image.IsRefInFrame() branch only):
- When image.depth_prior_validity[fid] is true and depth_prior > 0,
  emit a 1-D MetricDepthError residual on
  (frame_centers_[image.FrameId()], point3D.xyz, dmap_scales_[image_id]).
- Lazy-insert dmap_scales_ on first valid observation per image. Initial
  value: from options_.initial_dmap_scales (caller-supplied GP1→GP2
  handoff) if provided, else 1.0 (linear) / 0.0 (log).
- Increment dmap_scale_observation_counts_ for the per-image
  ScalePriorError weight.

Per-image emission in AddPointToCameraConstraints (after track loop):
- Reset dmap_scales_ + dmap_scale_observation_counts_ at start (lazy
  filled by AddPoint3DToProblem).
- After all observations added: emit one ScalePriorError /
  LogScalePriorError per image with depth observations. Loss is wrapped
  in ceres::ScaledLoss(TrivialLoss, obs_count) so dense-depth images get
  proportionally stronger priors. M5 will replace TrivialLoss with the
  configurable cached_loss_scale_prior_.

Parameter ordering / bound (M4 step 5):
- dmap_scales_ ride alongside frame_centers_ in parameter group 2.
- Linear-space: SetParameterLowerBound(&dmap_scale, 0, 1e-5) prevents
  collapse. Log-space: no bound (parameter is log-space, positivity comes
  from exp() in MetricDepthError).

Init flag wiring (M4 steps 6-7):
- options_.use_init short-circuits InitializeRandomPositions for both
  camera centers AND track xyz (collapses fork's split flag).
- options_.random_init_scale replaces hardcoded 100.0 cube size in two
  call sites.

Verified: ATE 37.40 / 66.77 / 93.35 / 96.68 / 99.67 unchanged (default
BATA path) on sample-10 LaMAR HGE / ios_2022-01-12_14.59.02_000-0 +
canonical DB + GP_SEED=1.

* M5: GP body Cluster B — loss routing fabric + two-loop iteration

Implement glomap-fork loss-routing cascade in colmap::GlobalPositioner.
Activated by point_constraint_type=SPLIT_METRIC_DEPTH; default BATA mode
unchanged.

Refactor: extract per-observation logic into AddObservationToProblem
helper (point3D_id, observation, is_lc_observation, random_initialization,
reconstruction). AddPoint3DToProblem becomes a dispatcher iterating
track.Elements() (is_lc=false) then track.lc_elements (is_lc=true) —
mirrors fork's two-parallel-collection structure.

Cache pre-warm in AddPointToCameraConstraints: materialize 10
cached_loss_*_ via CreateLossFromConfig. Default TrivialLoss/weight=1
config is equivalent to no override; non-default Huber/Cauchy/etc take
effect through cascade.

is_excluded skip: top of AddObservationToProblem.

4-way geometry cascade (SPLIT_METRIC_DEPTH only):
  is_lc -> loss_lc_geometry, is_track_anchor -> loss_normal_geometry_trackstart,
  is_inlier -> loss_normal_geometry_inlier, else -> loss_normal_geometry.
5-way depth cascade for MetricDepth residual:
  depth_outliers_ + LC -> SKIP depth residual entirely (deletes
    metric_depth_cost to avoid leak),
  depth_outliers_ + non-LC -> ScaledLoss(HuberLoss(1), 1) hardcoded
    soft fallback,
  is_lc -> loss_lc_depth, track_anchor -> loss_normal_depth_trackstart,
  is_inlier -> loss_normal_depth_inlier, MDRP-flagged outlier ->
  loss_normal_depth_outlier, else -> loss_normal_depth.

ScalePriorError wrapper updated: replaces M4 TrivialLoss(obs_count)
with cached_loss_scale_prior_ wrapped in ScaledLoss(obs_count). Default
config preserves obs_count weighting unchanged.

Verified ATE 37.40/66.77/93.35/96.68/99.67 unchanged (default BATA)
on sample-10 LaMAR HGE.

* M6: GP filter_depth_outliers pre-Solve pass

Implement glomap-fork pre-Solve depth-outlier filter in
colmap::GlobalPositioner. Activated by
options_.filter_depth_outliers + point_constraint_type=SPLIT_METRIC_DEPTH.

In AddPointToCameraConstraints, after cache pre-warm + dmap_scales_
reset:
- Seed dmap_scales_ from options_.initial_dmap_scales when caller
  supplied them (typical GP2 path: GP1 hands off solved scales). Linear
  scale converted to log if use_log_scale_for_depth_map_scales=true.
  Lazy-insert in AddObservationToProblem then preserves these values.
- Clear depth_outliers_; if filter_depth_outliers, call
  FilterDepthOutliers(reconstruction).

FilterDepthOutliers method (new):
- Per-point3D: walk track.Elements() and track.lc_elements separately.
- For each observation: skip when depth_prior_validity[fid]=false or
  depth_prior <= 1e-6 or stddev_rel <= 1e-9.
- Apply per-image scale (dmap_scales_[image_id]) if available; else
  raw prior. Threshold check:
    |log(z_est) - log(scale * depth_prior)| >= 3 * log(1 + stddev_rel)
- Insert (image_id, point2D_idx) into depth_outliers_ on flag.

Anonymous-namespace helper DepthOutlierFlag deduplicates the regular vs
LC sweep.

Native uses image.CamFromWorld() * X_world (the canonical cam_from_world
projection) — fork's pre-Solve branch had inadvertently relied on the
in-Solve flipped translation convention via the image.cam_from_world
member directly; native's CamFromWorld() resolves cleanly because we run
this BEFORE the Solve loop temporarily flips frame_centers_.

Verified ATE 37.40/66.77/93.35/96.68/99.67 unchanged (default BATA mode +
filter_depth_outliers=false) on sample-10 LaMAR HGE.

* M7 (binding side): extend pycolmap.run_global_positioning for SPLIT_METRIC_DEPTH

Pycolmap binding for native colmap::GlobalPositioner now exposes the full
glomap-fork option surface (M2) + dict return shape (Decision 7).

Option struct extension (BindGlobalPositioner):
- New PointConstraintType enum binding (BATA / SPLIT_METRIC_DEPTH).
- Bind use_init, random_init_scale, depth toggles
  (use_log_scale_for_depth_map_scales, use_log_residual_for_depth,
  zero_residual_behind, smooth_log_linear_transition,
  log_linear_threshold), scale_prior_stddev, filter_depth_outliers.
- Bind initial_dmap_scales as Optional[Dict[image_id, float]] with None
  short-circuit; Python passes linear-space, C++ stores per
  use_log_scale_for_depth_map_scales convention.
- Bind 10 LossFunctionConfig fields as dict-typed properties via
  templated BindLossField helper accepting/emitting
  {'name', 'scale', 'weight'}. Names verbatim from glomap_ra port:
  loss_normal_geometry, loss_normal_depth, loss_lc_geometry,
  loss_lc_depth, loss_normal_geometry_inlier, loss_normal_depth_inlier,
  loss_normal_depth_outlier, loss_normal_geometry_trackstart,
  loss_normal_depth_trackstart, loss_scale_prior.

run_global_positioning return shape:
- Was: bool. Now: dict {success: bool, dmap_scale_map: Dict[image_id,
  float] (linear), dmap_scale_map_nested: Dict[image_id, {linear, log}]}.
- Reconstruction is mutated in place; Python reads images/tracks from
  rec.images / rec.points3D directly.
- Mirrors glomap_ra binding return shape so videosfm caller migration
  (Python side, deferred to next session) is a near-1:1 swap.

GetDmapScales accessor on GlobalPositioner exposes the per-image
dmap_scales_ map for the binding's dict-extraction step. Linear
conversion (exp() when use_log_scale_for_depth_map_scales=true) happens
in the lambda.

Verified: ATE 37.40/66.77/93.35/96.68/99.67 unchanged on sample-10 LaMAR
HGE — caller still on glomap_ra path; native binding extension is
additive and doesn't affect existing flow.

* M7 (C++): scales_ reservation fix + dmap_scales std::map + Ceres options bindings

Bug fixes uncovered during M7 caller migration:

1. SetupProblem scales_.reserve undercount: was only counting
   point3D.track.Length(); M5's two-loop iteration also walks
   track.lc_elements, each emplace_back-ing a new scale. Without
   reserved capacity, vector reallocation invalidates earlier &scale
   pointers stored in Ceres residual blocks → "ParameterBlock with
   size 1 has at least one invalid value". Fix: include lc_elements
   in the reservation count.

2. dmap_scales_ container: switched from unordered_map<image_t, double>
   to std::map<image_t, double>. Ceres residuals store
   &dmap_scales_[image_id] data pointers; a hash-table rehash during
   lazy-insert in AddObservationToProblem invalidates them. std::map
   (rb-tree) keeps pointers stable for the entry's lifetime.

3. Parameter group separation: dmap_scales_ now go in their own
   parameter group (group_id+1), separate from frame_centers_. Mixing
   1-D dmap_scales with 3-D frame_centers in the same Schur-ordering
   group breaks the Schur-complement preprocessor — Ceres downgrades
   to SPARSE_NORMAL_CHOLESKY then rejects the inhomogeneous block sizes.
   Separate group keeps each Schur block size-uniform.

4. Pycolmap binding: added solver_options.{num_threads,
   max_num_iterations, function_tolerance, gradient_tolerance,
   parameter_tolerance} as def_property pass-throughs on
   GlobalPositionerOptions. The solver_options nested struct is not
   directly bound in pycolmap, so caller migration would otherwise
   fail with "object has no attribute 'solver_options'".
   Mirrors the existing pyglomap binding's pattern.

Verified GP runs to CONVERGENCE (no more spurious FAILURE termination)
on sample-10 LaMAR HGE under both BATA and SPLIT_METRIC_DEPTH paths.
ATE differs from glomap_ra baseline (cross-implementation drift expected
per Decision 7); structural integrity restored.

* M8: plumb IRLS final weights via out-param (preserves bool return ABI)

Glomap-fork RA contributed a per-pair IRLS final-weight map that the
videosfm pipeline reads for its consecutive-pair-weight degeneracy
diagnostic (rotation_averaging.py:117-129). Native colmap RA discards
the IRLS weight vector after the final factorize. M8 plumbs it out.

Plumbing (4 layers):
1. RotationAveragingProblem gains final_weights_ member +
   FinalWeights() accessor + SetFinalWeightsFromIRLS(weights_irls)
   setter. The setter translates the per-row weight vector into a
   per-pair map keyed by image_pair_t. Each PairConstraint owns 1
   (gravity-aligned) or 3 (full-3DOF) consecutive rows starting at
   constraint.row_index — we read weight at row_index (all rows
   share the same weight per ComputeIRLSWeights). Gauge-fixing rows
   excluded.
2. SolveIRLS captures the last successful iteration's weight vector
   in last_weights and calls problem.SetFinalWeightsFromIRLS at the
   end (after the convergence loop). Empty if SolveIRLS doesn't run
   (e.g. L1-only path).
3. RotationEstimator::EstimateRotations + SolveRotationAveraging gain
   std::unordered_map<image_pair_t, double>* final_weights = nullptr
   out-param. When non-null, copy problem.FinalWeights() to *out
   after the solve.
4. RunRotationAveraging mirrors. Wired through both branches (with /
   without rig expansion).

Pycolmap binding:
- Adds extract_final_weights: bool = False kwarg. Default returns
  bool (preserves upstream colmap test ABI per Decision 8). When True,
  returns {success: bool, final_weights: Dict[image_pair_t, float]}
  dict. C5 fix: indexed by constraint.row_index (the row-major index
  into weights_irls), NOT by pair_info.index (the inventory's earlier
  draft).

Default behavior unchanged. Verified the C++ builds clean.

* M9: skip_risky_LC_pairs filter via 4-layer CorrespondenceGraph plumbing

Glomap-fork RA's skip_risky_LC_pairs drops pairs whose loop-closure
inlier count strictly exceeds non-LC inliers. The filter reads
ImagePair.{inliers, are_lc} fork fields — these live on
CorrespondenceGraph::ImagePair, NOT on PoseGraph::Edge (which is a
strict subset). M9 plumbs an optional CorrespondenceGraph* through 4
layers so RA can apply the filter without bloating the upstream
PoseGraph interface.

Plumbing:
1. CorrespondenceGraph gains const ImagePairsMap() accessor (the
   existing MutableImagePairs() can't be called through a const
   pointer). Used only by RA M9.
2. RotationAveragingProblem ctor takes optional CorrespondenceGraph*
   (default nullptr). Stored as correspondence_graph_ member.
3. BuildPairConstraints reads cg.ImagePairsMap()[pair_id].{inliers,
   are_lc} when options_.skip_risky_LC_pairs && correspondence_graph_
   non-null. Counts LC inliers; if lc_count > non_lc_count, continue
   (pair excluded from constraint matrix entirely).
4. RotationEstimator::EstimateRotations + SolveRotationAveraging +
   RunRotationAveraging gain optional CorrespondenceGraph* parameter
   (default nullptr; passes through to RotationAveragingProblem ctor).
5. Pycolmap binding adds correspondence_graph: CorrespondenceGraph =
   None kwarg. Default behavior unchanged for callers that don't pass
   the CG.

Default behavior: with skip_risky_LC_pairs=False (default) or
correspondence_graph=None, no filtering — exactly matches pre-M9 behavior.

Verified C++ builds clean.

* M10: prioritize_tracking MST penalty for LC-dominated edges

Glomap-fork RA's prioritize_tracking spanning-tree initialization
penalises LC-dominated edges so the MST picks tracking-dominated edges
as parents. Activated when use_video_constraints=true (M11 video-Ceres
path). Without the penalty, LC-contaminated initialization undermines
the Ceres video-aware solve.

ComputeMaximumPoseGraphSpanningTree (local helper in
rotation_averaging.cc) now accepts:
- prioritize_tracking (bool, default false): enable LC-penalty.
- correspondence_graph (CorrespondenceGraph*, default nullptr):
  source of ImagePair.{inliers, are_lc} fork fields.

When prioritize_tracking + correspondence_graph are both set, the MST
weight for each edge is reduced by 1e9 if the pair has strict LC
majority (lc_count > non_lc_count). Native MST maximises weights, so
fork's +1e9 to weights_boost becomes -1e9 to weight here
(convention inversion). Fallback to plain num_matches weight when
fork fields absent or empty.

InitializeFromMaximumSpanningTree forwards the CG; reads
options_.use_video_constraints to set prioritize_tracking. Wired
through SolveRotationAveraging from EstimateRotations + RunRotationAveraging.

Default behavior unchanged when use_video_constraints=false (no penalty
applied). Verified C++ builds clean.

* M11: video-Ceres SolveCeres + IsTrackingPair + RelativeRotationError

Glomap-fork RA video-aware Ceres path. Activated when
options_.use_video_constraints && !options_.use_gravity. Replaces L1+IRLS
with a Ceres optimization over per-frame 3-DOF angle-axis blocks. Each
pair contributes a relative-rotation residual wrapped in either
HuberLoss (tracking-dominated pair, scale=video_tracking_huber_scale)
or CauchyLoss (LC-dominated pair, scale=video_lc_cauchy_scale).

Anonymous-namespace helpers:
- RelativeRotationError: AutoDiff<3, 3, 3> functor on (r1_aa, r2_aa).
  Residual = AngleAxis(R2^T * R_rel * R1) where R_rel is precomputed
  from PairConstraint::Full3DOF::R_cam2_from_cam1. Sign convention
  matches fork (no leading negative — the resulting AngleAxis is
  identical sign to the L1+IRLS tangent residual at convergence;
  Ceres doesn't care about sign anyway since LM minimizes
  ||residual||^2).
- IsTrackingPair: counts LC inliers from CorrespondenceGraph::ImagePair
  fork fields (inliers + are_lc); returns true when non_lc_count >=
  lc_count. Reads via problem.CorrespondenceGraphPtr() (M9 plumbing).

RotationAveragingProblem accessor additions (M11):
- MutableEstimatedRotations() — direct mutable access to per-frame
  3-DOF angle-axis storage so Ceres can optimize in place.
- FrameIdToParamIdx() — frame_id -> 3-DOF block offset mapping.
- ImageIdToFrameId() — for trivial-rig path PairConstraint
  image_id -> frame_id resolution.
- FixedFrameId() — gauge-fixing anchor.
- CorrespondenceGraphPtr() — M9 plumbing surface.

RotationAveragingSolver::Solve dispatches video-Ceres path before
L1+IRLS when use_video_constraints && !use_gravity. Mutually-exclusive
warning logged when both are set (use_video_constraints disabled).

Cost is Huber+Cauchy with TAKE_OWNERSHIP semantics; no leak. Solver:
SPARSE_NORMAL_CHOLESKY, max 100 iters. M0c-equivalent verification:
default behavior unchanged when use_video_constraints=false.

* M13: delete glomap_ra GP + RA (videosfm now drives both via native)

Final deletion phase. Pre-deletion grep verified that the deleted files
have no remaining external references after M7 + M12 caller migration.
Submodule pycolmap.glomap_ra stays alive — track-filter,
track-establishment, image-pair-inliers, view-graph-manipulation, and
relative-pose-estimation continue to bind under it.

Files deleted (~3000 LOC):
- src/colmap/sfm/global_positioning_glomap.{h,cc}
- src/colmap/sfm/rotation_averaging_glomap.{h,cc}
- src/colmap/sfm/cost_function_glomap.h
- src/colmap/sfm/l1_solver_glomap.h
- src/colmap/sfm/optimization_base_glomap.h
- src/colmap/sfm/tree_glomap.{h,cc} (only RA used it)
- src/pycolmap/sfm/global_positioning_glomap.cc
- src/pycolmap/sfm/rotation_averaging_glomap.cc

Files KEPT (still used by surviving glomap_ra modules):
- rigid3d_glomap.{h,cc} — track_filter_glomap consumer
- two_view_geometry_glomap.{h,cc} — image_pair_inliers consumer
- track_filter_glomap, track_establishment_glomap, image_pair_inliers,
  view_graph_manipulation, view_graph_calibration, relative_pose_estimation

CMakeLists updates:
- src/colmap/sfm/CMakeLists.txt: drop deleted entries from SRCS list.
- src/pycolmap/sfm/bindings.cc: drop BindGlobalPositionerOptions /
  BindGlobalPositioningGlomap / BindRotationAveragingGlomap forward
  decls + dispatch calls. Other binders (BindViewGraphManipulation,
  BindRelativePoseEstimation, BindTrackEstablishmentGlomap,
  BindImagePairInliersGlomap, BindTrackFilterGlomap) preserved — those
  bind to the pycolmap.glomap_ra submodule which stays alive.

Pycolmap CMake auto-globs sources, so the .cc deletions remove
themselves from the binding build.

Verified: end-to-end pipeline runs on sample-10 LaMAR HGE +
canonical DB + GP_SEED=1 with native pycolmap.run_global_positioning +
pycolmap.run_rotation_averaging only. ATE produced (cross-impl drift
vs glomap_ra-baseline expected per Decision G2).

* M5 fix: dispatch WeightedBATADirectionalError when angular_stddevs available

Bug uncovered by post-port audit (audit_algorithmic_semantics.md
suspect #1): the M3 cost functor WeightedBATADirectionalError was
defined and built, but AddObservationToProblem unconditionally created
the unweighted BATAPairwiseDirectionCostFunctor — the M5 cascade only
swapped the LOSS function, not the COST function. Result: anisotropic
per-keypoint weighting was dropped on every geometry residual,
causing the SPLIT_METRIC_DEPTH path to trade off geometry vs metric-
depth at the wrong relative scale.

Fix: dispatch WeightedBATADirectionalError when
image.angular_stddevs[fid] is populated (videosfm pipeline always
populates). sigma_x, sigma_y from the Vector2d entry; sigma_z =
0.5*(sigma_x+sigma_y). Falls back to BATAPairwiseDirectionCostFunctor
when sigmas absent. Mirrors fork's dispatch at
glomap/glomap/estimators/global_positioning.cc:592-601, 646-657.

Verified ATE recovery on sample-10 LaMAR HGE / ios_2022-01-12_14.59.02_000-0
+ canonical DB + GP_SEED=1:
- Pre-fix:  0.00 / 0.00 / 17.22 / 57.92 / 95.79
- Post-fix: 39.44 / 68.32 / 93.66 / 96.83 / 99.68
- Baseline: 37.40 / 66.77 / 93.35 / 96.68 / 99.67 (glomap_ra)

Native port now produces parity-or-slightly-better ATE than the
pre-port glomap_ra baseline. Cross-implementation drift is within
~5% (Decision G2 sanity threshold).

* global_positioning: add GP_SEED env-var fallback in ctor

When random_seed == -1 (caller explicitly opts out of deterministic
seeding) fall back to the GP_SEED env var so the documented Tier-2
byte-identity recipe (CLAUDE.md "ATE byte-identity") works without
patching every call site for the transition cycle.

Plan-vs-execution audit flagged this: header docstring claimed
GP_SEED fallback existed but ctor only honored options_.random_seed >= 0.
Adding cstdlib for std::getenv/std::atoi.

* global_positioning: mark random_seed default + GP_SEED fallback as reproduce-fork TODOs

Per user audit ask: any place where M0–M13 changed colmap4 default
behavior to reproduce the pyglomap fork should be marked as a TODO
not silently hardcoded — the eventual goal is to converge back to
native defaults.

Two GP-side crutches:
- random_seed default 1 (upstream is -1 / random_device)
- GP_SEED env-var fallback in ctor when random_seed == -1

Both stay in place for Tier-2 byte-identity but are now marked
TODO(reproduce-fork). Companion notes:
.claude/notes/glomap_audit/audit_default_overrides.md

* sfm/*_glomap*: add TODO(dedup-glomap-vs-colmap4) banners

Six fork-imported headers in colmap/sfm/ now carry a per-file TODO
comment pointing at the dedup audit
(.claude/notes/glomap_audit/audit_glomap_files_vs_colmap4.md). Each
banner names which symbols in that TU duplicate native colmap4
helpers vs which are unique fork algorithms.

grep -rn 'TODO(dedup-glomap-vs-colmap4)' colmap/src now surfaces the
~250-350 LOC dedup follow-up scoped for after the M0-M13 PR merges.

* estimators: gate fork additions behind opt-in options (vanilla-by-default)

Per backward-compat study (audit_backward_compat.md): make vanilla
upstream colmap4 the default for both GP and RA so non-fork callers
get unmodified upstream behavior. Every fork-added feature now
requires an explicit opt-in.

GlobalPositionerOptions — 3 changes:
- New `use_observation_exclusions` (default false): gates the
  per-observation skip via `image.is_excluded[idx]` in
  AddObservationToProblem (Gate G-1).
- New `use_lc_observations` (default false): gates the
  `track.lc_elements` second loop in AddPoint3DToProblem and the
  paired sweep in FilterDepthOutliers. This IS the GP-side LC config —
  pairs with RA's `skip_risky_LC_pairs` to give two complementary LC
  controls (Gate G-2).
- `random_seed` default restored from 1 -> -1 (upstream non-deterministic
  random_device). The GP_SEED env-var fallback in the ctor stays as a
  documented escape for the Tier-2 byte-identity recipe (Gate G-3).

RotationEstimatorOptions — 1 change:
- New `prioritize_tracking_in_mst` (default false): decouples M10 MST
  LC-penalty from M11 `use_video_constraints`. Previously
  `ComputeMaximumPoseGraphSpanningTree(prioritize_tracking=...)` was
  hardcoded to `use_video_constraints`; now a separate knob (Gate R-1).

pycolmap binding: 3 new `def_readwrite` entries on GlobalPositionerOptions
(use_observation_exclusions, use_lc_observations) and RotationEstimatorOptions
(prioritize_tracking_in_mst).

Net effect:
- Vanilla pycolmap.run_global_positioning / run_rotation_averaging
  callers now get upstream colmap4 behavior unmodified.
- videosfm flips all four gates true via _to_native_gp_options /
  _to_pycolmap_ra_options so the existing pipeline is unchanged.

Smoke-tested: defaults are False/-1, post-flip True/1.

* sfm: dedup *_glomap helpers against colmap4 native

Three-step dedup pass (audit_glomap_files_vs_colmap4.md). Replace
fork-imported helpers that duplicated existing colmap4 native code.

#1 union_find_glomap.h (-42 LOC)
- DELETED. colmap::UnionFind<T> at colmap/math/union_find.h:40 is a
  strict superset (Reserve / FindIfExists / Compress / Parents) and
  the body of Find/Union is byte-equivalent.
- Added 1-line Clear() to native colmap::UnionFind so it covers the
  one method TrackEngine used that wasn't already in native.
- track_establishment_glomap.h now includes colmap/math/union_find.h
  and uses unqualified UnionFind (resolves to colmap::UnionFind via
  parent-namespace lookup).

#2 rigid3d_glomap.{h,cc} (-79 LOC)
- DELETED entirely. All 8 symbols turned out to be dead code:
  - DegToRad / RadToDeg duplicated colmap/math/math.h:59-64
  - RotationToAngleAxis / AngleAxisToRotation / Rigid3dToAngleAxis /
    CalcAngle*2 / CalcTrans / CalcTransAngle were declared and defined
    but never called from any TU after M0-M13.
- Includers (image_pair_inliers_glomap.h, track_filter_glomap.cc)
  switched to direct includes of colmap/math/math.h + colmap/geometry/rigid3.h.

#3 two_view_geometry_glomap.{h,cc} (-83 LOC)
- DROPPED EssentialFromMotion (= EssentialMatrixFromPose at
  colmap/geometry/essential_matrix.cc:97).
- DROPPED SampsonError(E, Vec2, Vec2) overload (= ComputeSquaredSampsonError
  at essential_matrix.cc:168, callers pass .homogeneous()).
- DROPPED HomographyError (= ComputeSquaredHomographyError at
  colmap/geometry/homography_matrix.cc:266, identical formula).
- DROPPED FundamentalFromMotionAndCameras (was 1-line wrapper, dead
  code — never called).
- KEPT CheckCheirality (depth-gated single-pair extension, no native
  equivalent), GetOrientationSignum (vendored from GC-RANSAC),
  SampsonError(E, Vec3, Vec3) (depth-aware z-divide variant).
- image_pair_inliers_glomap.cc updated to call native helpers; added
  geometry/essential_matrix.h + geometry/homography_matrix.h includes.

Net: -204 LOC across 4 files dropped + 6 files updated. Build clean,
pycolmap loads, defaults unchanged.

* sfm: collapse glomap_ra::Track onto colmap::Point3D (#4)

Final dedup step (audit_glomap_files_vs_colmap4.md). Replace the
vendored `glomap_ra::Track` struct with native `colmap::Point3D`
throughout TrackEngine, TrackFilter, and the 2 binding TUs.

The vendored type was almost a verbatim duplicate of Point3D:
- xyz / color / is_initialized -> already on Point3D (M0/glomap-fork)
- observations (vector<pair>) -> Point3D::track.Elements() (vector<TrackElement>)
- lc_observations              -> Point3D::track.lc_elements (already added at scene/track.h:93)
- track_id                     -> redundant with the map key, dropped

Changes:
- track_establishment_glomap.h: drop `Track` struct + `Observation`
  typedef. `track_t` now aliases `colmap::point3D_t`. API uses
  `unordered_map<track_t, Point3D>`.
- track_establishment_glomap.cc: 4 algorithm methods rewritten to
  use Point3D::track.AddElement / .Elements() / .lc_elements / .Length().
- track_filter_glomap.{h,cc}: same.
- pycolmap/sfm/track_establishment_glomap.cc: drop ToPoint3D
  conversion (35 LOC). Bindings now pass-through dict<int, Point3D>
  with no fork↔native marshalling.
- pycolmap/sfm/track_filter_glomap.cc: drop ToGlomapRaTrack +
  FromGlomapRaTrack (40 LOC). Same pass-through pattern.

Net: -115 LOC across 6 files. Build clean, pycolmap loads,
establish_full_tracks / find_tracks_for_problem /
glomap_ra.filter_tracks_by_angle / glomap_ra.filter_track_triangulation_angle
all expose the same dict<int, Point3D> shape they did before — videosfm
caller is unchanged.

* review fixes: docstrings, fail-loud emplace, dead-code notes

Round of post-review polish on the backward-compat + dedup PRs.

Correctness / fail-loud:
- track_establishment_glomap.cc: THROW_CHECK on the two
  ProcessLoopClosurePairs tracks.emplace calls (track_id
  collision was previously silent).
- track_filter_glomap.cc + track_establishment_glomap.cc: switch
  track.Elements().clear() -> track.SetElements({}) so the
  call survives a future signature drift on Elements()
  (review #7).

Docstring / comment:
- pycolmap motion_averaging.cc: random_seed binding doc now
  matches the new -1 default and surfaces the GP_SEED env-var
  escape at the Python boundary (review #1, #3).
- global_positioning.h: header note on use_observation_exclusions
  clarifies that Image::is_excluded is itself fork-only;
  vanilla pycolmap users won't typically populate it (review #2).
- rotation_averaging.h: prioritize_tracking_in_mst comment
  rephrased to describe gate semantics rather than videosfm wiring
  (the very coupling we just decoupled).
- two_view_geometry_glomap.h: header explains FundamentalFromMotionAndCameras
  was a dead-code delete (no replacement migration), unlike the
  other 3 dropped helpers which got migrated to native (review #1
  generic).
- track_establishment_glomap.h: file-level note explaining why the
  glomap_ra namespace alias is kept post-collapse (binding-path
  stability — pycolmap submodule + videosfm import paths).

* estimators: D1 dedup loss_config + strip rot-prone comment tags

Two cleanups bundled.

D1 (loss_config dedup, audit_dedup.md):
- LossFunctionConfig{name, scale, weight} (string-based) replaced with
  LossConfig{type, scale, weight} where type is the native
  CeresBundleAdjustmentOptions::LossFunctionType enum.
- Free CreateLossFromConfig factory dropped; LossConfig::CreateLossFunction()
  now reuses native colmap::CreateLossFunction(type, scale) plus a
  3-line ScaledLoss(weight) wrap.
- Native colmap::CreateLossFunction was previously in an anonymous
  namespace inside bundle_adjustment_ceres.cc; promoted to a public
  forward declaration in bundle_adjustment_ceres.h so other estimators
  can route through it.
- pycolmap binding: dropped the templated string-based BindLossField
  helper. Each loss field is now a plain def_readwrite returning a
  pycolmap.LossConfig object. videosfm side does the
  string -> LossFunctionType conversion at the bridge boundary.
- arctan support deferred — only used by gravity_refinement (separate TU,
  uses ceres::ArctanLoss directly), never used by GP cascade.

Comment cleanup pass:
- Strip M0..M13 milestone tags, Gate G-X / Gate R-X labels, audit_*.md
  cross-refs, videosfm sets X via Y_options'' caller-knowledge phrases.
- Reduce verbose --- Glomap-fork ... (M5) ---'' banners to short
  purpose lines or drop entirely. Keep WHY / hidden-constraint /
  workaround comments verbatim.
- Touched: global_positioning.{h,cc}, rotation_averaging.{h,cc},
  rotation_averaging_impl.{h,cc}, motion_averaging.cc binding.

Build + smoke clean. videosfm bridge updated in companion commit.

* estimators: delete loss_config.h, fold LossConfig into global_positioning.h

Follow-up to D1 dedup. The loss_config.h after the rewrite was just a
~25-line struct definition with one consumer (GlobalPositionerOptions).
Inline it into global_positioning.h next to PointConstraintType so the
GP-specific helpers live together.

- LossConfig struct + CreateLossFunction() method moved to
  colmap/estimators/global_positioning.h.
- loss_config.h deleted; CMakeLists.txt entry removed.
- Includers (global_positioning.cc, motion_averaging.cc binding) drop
  the include — global_positioning.h covers it transitively.

Net: 1 fewer header in the tree.

* estimators: extract LossFunctionType + factory into ceres_loss.{h,cc}

Lift the loss-creation primitive out of bundle_adjustment_ceres.{h,cc}
into a dedicated colmap/estimators/ceres_loss.{h,cc} so it's a true
shared utility — not a private detail of CeresBundleAdjustmentOptions.

Changes:
- New colmap/estimators/ceres_loss.{h,cc}: top-level
  colmap::LossFunctionType enum + colmap::CreateLossFunction(type, scale)
  factory. Definition moved verbatim from bundle_adjustment_ceres.cc.
- bundle_adjustment_ceres.h: CeresBundleAdjustmentOptions::LossFunctionType
  is now a back-compat using alias to colmap::LossFunctionType;
  the now-redundant forward declaration of CreateLossFunction is dropped.
- bundle_adjustment_ceres.cc: definition deleted (moved to ceres_loss.cc).
- global_positioning.h: GlobalPositionerOptions gains configurable
  loss_function_type + loss_function_weight fields. Default
  HUBER preserves the previous hardcoded behavior. CreateLossFunction()
  body now mirrors BA — dispatch via the factory + ScaledLoss(weight) wrap.
  Drops include of bundle_adjustment_ceres.h in favor of ceres_loss.h.
- pycolmap motion_averaging.cc: expose loss_function_type +
  loss_function_weight on GlobalPositionerOptions. The existing
  pycolmap.LossFunctionType enum (bound via BA's binding through the
  alias) is now the canonical Python-side type.

Net effect: vanilla colmap GP becomes loss-configurable like BA. The
fork's 10-bucket cascade (LossConfig) still uses the same enum — now
referenced via the top-level type rather than nested inside BA options.

* scene/image: fix Image copy/operator= dropping pixel_cholesky_xy_

Audit catch (parallel agents auditing fork-modified native files for
non-fork drift). The pre-existing fork copy-ctor and operator= for
colmap::Image carried all the per-feature fork fields (depth_priors,
angular_stddevs, is_inlier, is_excluded, is_track_anchor,
is_depth_outlier, etc.) but missed the more recently added
pixel_cholesky_xy_ private member. Same class of bug as the documented
`cam_from_world` regression in
memory/glomap_ra_port_lto_fp_nondeterminism.md — any py::cast roundtrip
or value-copy of an Image silently zeroed the field, breaking pose-cov
covariance plumbing for downstream consumers.

Also reorders the mem-init list to match the header declaration order.

Cosmetic-only follow-ups in the same audit pass:
- estimators/global_positioning.cc: dedent the extracted
  AddObservationToProblem body by 2 spaces (was inheriting the
  original loop-body indentation; cosmetic-only, no semantic effect).
- scene/track.h: collapse stray double blank line.

Build clean; default-constructed Image still works.

* estimators: D2 dedup — drop ScalePriorError/LogScalePriorError for native primitives

Per audit_dedup.md D2: replace the two fork cost functors in
cost_functions/metric_depth.h:182-244 with native
CovarianceWeightedCostFunctor<NormalPriorCostFunctor<1>> from
cost_functions/utils.h.

Mathematically equivalent:
- ScalePriorError(prior=1, sigma): r = (s - 1) / sigma
- LogScalePriorError(sigma_log):   r = log_s / sigma_log
- Native: CovarianceWeightedCostFunctor<NormalPriorCostFunctor<1>>::Create(cov, prior_vec)
  with cov = sigma², prior_vec = (1.0) for linear or (0.0) for log.

The linear/log selection in global_positioning.cc:226-232 collapses to
a single Create() call where the only branch is which constant the
prior_vec carries (0.0 vs 1.0).

Net: -73 LOC in metric_depth.h (the 2 functor bodies + their factories
+ docstrings), +9 LOC in global_positioning.cc (vec construction +
Create call). Build clean; ScalePriorError/LogScalePriorError no
longer exposed.

* estimators: D4 dedup — drop WeightedBATADirectionalError for native CWCF<BATA>

Per audit_dedup.md D4: replace the fork's anisotropic-weighted BATA
functor with native CovarianceWeightedCostFunctor<BATAPairwiseDirectionCostFunctor>.

The fork rotated the residual into camera frame inside the functor:
    r_fork = diag(1/sigma) * R * (t_obs - s*(c2-c1))

Native CWCF whitens with sqrt(cov^-1) of a passed-in covariance.
Encoding the rotation in the world-frame covariance:

    cov_world = R^T * diag(sigma^2) * R

makes ||r_native||^2 == ||r_fork||^2 (since both equal
r_world^T * cov_world^-1 * r_world). Cost contribution is identical;
residual vectors differ but Ceres loss functions act on the squared
norm so the optimization converges to the same minimum.

Why R can be baked at construction time: GP rotations are frozen
post-RA. Ceres parameter blocks are (position1, position2, scale)
only — rotation is never updated by the optimizer.

Made BATAPairwiseDirectionCostFunctor CWCF-compatible by adding the
2-line metadata (kNumResiduals + kParameterDims) the wrapper expects.
This parallels what NormalPriorCostFunctor already does and is an
upstream-friendly addition.

Net: -60 LOC functor body in motion_averaging.h, +14 LOC at the
dispatch site (cov_world construction + CWCF::Create call).
WeightedBATADirectionalError no longer exposed.

Risk: medium — WeightedBATADirectionalError was the M5 critical fix
that recovered ATE 0 -> 39. Math is provably equivalent in cost, but
autodiff Jacobian path differs vector-wise so FP precision may
shift slightly. Needs sample-10 LaMAR HGE ATE check before merge.

* sfm: Phase 5 dedup — extract track-establishment core into shared helper

Per audit_glomap_files_vs_colmap4.md Phase 5 (Strategy A): both native
GlobalMapper::EstablishTracks and fork TrackEngine::EstablishFullTracks
implemented the same union-find + intra-image consistency + length
filter + greedy subsample algorithm over post-RANSAC corr-graph
inliers. Extract the core into a new shared helper so both consumers
route through the same code; fork's LC second-pass +
2-view-depth-prior gate stay as fork-specific extensions.

New: src/colmap/sfm/track_establishment.{h,cc} (~210 LOC)
- struct TrackEstablishmentOptions{intra_image_consistency_threshold,
  min_num_views_per_track, required_tracks_per_view}
- using MatchPredicate = std::function<bool(image_t, point2D_t,
  image_t, point2D_t)> — optional, returns true to skip a match in
  the UF phase. Used by fork to skip LC inliers in the regular pass.
- EstablishTracksFromCorrGraph(valid_pair_ids, corr_graph,
  image_id_to_keypoints, options, ignore_match) — returns
  unordered_map<point3D_t, Point3D>.

GlobalMapper::EstablishTracks shrinks from ~135 LOC to ~30 LOC of
orchestration: builds the keypoint map + collects valid pair ids
from pose_graph, calls the helper, commits the result via
AddPoint3D. No behavior change for native callers — predicate
defaults to nullptr (no LC skip), options carry the same defaults
as the previous inline impl.

TrackEngine::EstablishFullTracks now:
- Builds image_id_to_keypoints from images_ (fork's Image.features
  rather than reconstruction's points2D — pre-rec state).
- Pre-computes an LC observation set from view_graph.image_pairs[*]
  inliers + are_lc, encoded as pair<uint64_t, uint64_t> for std::set
  lookup.
- Calls EstablishTracksFromCorrGraph with an ignore_match predicate
  that skips matches in the LC set.
- Calls existing ProcessLoopClosurePairs as the post-pass to populate
  Track::lc_elements (and mint two new tracks per LC-only observation
  pair, with obs-key-derived ids that don't collide with native's
  dense [0, N) range since image_id<<32 >= 2^32).

Deleted from track_establishment_glomap.{h,cc}:
- TrackEngine::BlindConcatenation (~55 LOC)
- TrackEngine::TrackCollection (~110 LOC)
- UnionFind<image_pair_t> uf_ member
- Now-unused colmap/math/union_find.h include
Total: ~165 LOC removed from fork; ~210 LOC added in the new shared
helper (most of which is moved-not-new code).

Backward compat:
- Native API gains an optional ignore_match parameter defaulting
  to nullptr — vanilla callers see identical behavior.
- Fork API unchanged (TrackEngine::EstablishFullTracks signature +
  return shape preserved).
- pycolmap binding unchanged.

Risk: medium — core algorithm is byte-equivalent (verbatim move of
the loop bodies), but unordered_map iteration order may differ
between native's local instance and fork's instance, which can
produce a different greedy-subsample tiebreak. Default options
have required_tracks_per_view=INT_MAX (no-op subsample), so
iteration order doesn't matter for native. Fork's caller passes
require_tracks_per_view=INT_MAX as well at this stage; the actual
greedy subsample lives in FindTracksForProblem (untouched).

Needs sample-10 LaMAR HGE ATE check before merge.

* track_establishment: iterate image_pair.matches/inliers directly

Phase 5 first-run verify revealed that ExtractMatchesBetweenImages reads
from CorrespondenceGraph::flat_corrs which is only populated by
FinalizeAfterMatchingComplete (native pipeline's geom-verify step). The
fork-driven videosfm pipeline writes inliers directly into
ImagePair::matches + ImagePair::inliers without going through
flat_corrs, so the helper saw zero matches and produced zero tracks.

Fix: iterate ImagePair::matches indexed by ImagePair::inliers directly.
Both the native colmap4 pipeline (which populates these fields during
geom-verify) and downstream pipelines that populate them via custom
relative-pose flows (e.g. videosfm's MDRP) work through this path.

Drops ExtractMatchesBetweenImages dependency entirely; drops the
feature/types.h include that came with FeatureMatches.

Verified end-to-end: sample-10 LaMAR HGE produces 25/25 registered
images with AUC 73.69/86.85/97.37 @ {0.05, 0.1, 0.5}m. Tier-1
structural identity holds through GP1+GP2 (n_tracks, total_observations,
total_lc_observations all match m13 baseline byte-for-byte at every
checkpoint).

* tests: 31 new gtest cases for fork additions + dedup correctness

Spread across 4 files:

- src/colmap/estimators/cost_functions/motion_averaging_test.cc (+9 tests)
  CWCF<BATAPairwiseDirectionCostFunctor> math equivalence with random
  inputs (rotation, sigma, c1, c2, scale, t_obs); zero-residual edge
  case; identity-rotation isotropic-sigma reduction. Algebraically
  proves D4 dedup (cov_world = R^T diag(sigma^2) R) preserves the
  squared residual norm of the deleted WeightedBATADirectionalError.

- src/colmap/sfm/track_establishment_test.cc (+5 tests, NEW file)
  Phase 5 helper EstablishTracksFromCorrGraph: 3-pair triangle smoke
  test, length filter drops short tracks, intra-image-consistency
  drop, ignore_match predicate, empty-input no-crash.

- src/colmap/estimators/cost_functions/metric_depth_test.cc (+7 tests, NEW)
  MetricDepthError: zero residual when prior matches predicted depth;
  linear vs log residual mode formulas; zero_residual_behind_camera
  gate; log-scale parameterization; 5-toggle smoke; rejects
  non-positive sigma.

- src/colmap/estimators/global_positioning_test.cc (+4 tests)
  LossConfig::CreateLossFunction(): TRIVIAL/HUBER/CAUCHY/SOFT_L1
  dispatch through native colmap::CreateLossFunction; weight != 1
  triggers ScaledLoss wrap (verified by 2x scaling on output rho).

CMakeLists entries added for the two new test files; existing
TESTS_ENABLED=ON build picks them up. All 4 ctest groups pass.

* track_establishment_glomap: harden LC-track id minting

Phase 5 left ProcessLoopClosurePairs minting new tracks via
static_cast<track_t>((image_id<<32)|feature_id). With image_id >= 1
this lands ids >= 2^32, well above native's dense [0, N) range, so
collisions are theoretical-only. Switch to sequential ids continuing
from native's max — eliminates the magic encoding and makes the
THROW_CHECK guard truly defensive rather than load-bearing.

* tests: 34 more gtest cases for fork-unique paths + backward-compat gates

Round 2 of unit-test additions (6 areas, 34 tests). Driven by the
'genuine coverage gaps' list — fork-unique algorithms previously
tested only via end-to-end pipeline.

ProcessLoopClosurePairs (track_establishment_test.cc, +5):
  4 branches of LC second-pass + sequential-id no-collision smoke.

FindTracksForProblem (track_establishment_test.cc, +6):
  Length filter, 2-view depth-prior gate (drop + keep + boundary),
  greedy quota, min_num_tracks_per_view default-bug enshrined.

TrackFilter (track_filter_test.cc, NEW, +7):
  FilterTracksByAngle aligned/misaligned/uncalib-double-thresh +
  point-behind-camera; FilterTrackTriangulationAngle parallel/perp +
  threshold sweep.

RelativeRotationError (rotation_averaging_test.cc, +3):
  Zero residual when consistent, non-zero on injected angular error
  matches the discrepancy norm, swap+invert symmetry.
  Required moving the struct from anonymous namespace in
  rotation_averaging_impl.cc into rotation_averaging_impl.h so tests
  can construct it directly.

ImagePairsInlierCount (image_pair_inliers_test.cc, NEW, +5):
  4-gate degeneracy (epipolar, cheirality, epipole-angle, depth-prior
  vs nodepth threshold). All 4 gates exercised on the essential-matrix
  path with controlled synthetic inputs.

Backward-compat gates G-1/G-2/R-1 (+8):
  G-1 (use_observation_exclusions) off/on/partial — global_positioning_test.cc
  G-2 (use_lc_observations) off/on — global_positioning_test.cc
  R-1 (prioritize_tracking_in_mst) off/on/no-corr-graph-fallback — rotation_averaging_test.cc.
  Required exposing ComputeMaximumPoseGraphSpanningTree as a public
  symbol in rotation_averaging.h (was anon-namespace).

Net: 65 total unit tests across 8 ctest groups, all passing.

* sfm: rename glomap_ra namespace -> sfm_ext

The ``colmap::glomap_ra::`` namespace and pycolmap.glomap_ra submodule
were named after the original rotation-averaging port from glomap, but
their current contents are track establishment, track filtering, and
pair-inlier scoring — none of which are RA. Rename to ``sfm_ext``
(SFM extensions) so the label reflects the actual contents.

Mechanical sweep across 11 C++ files + 4 binding files:
- ``namespace glomap_ra { ... }`` -> ``namespace sfm_ext { ... }``
- ``glomap_ra::Foo`` -> ``sfm_ext::Foo`` everywhere
- ``GetOrCreateGlomapRaModule`` -> ``GetOrCreateSfmExtModule``
- ``m.def_submodule("glomap_ra")`` -> ``m.def_submodule("sfm_ext")``

Python alias kept for back-compat: pycolmap/__init__.py defines
``glomap_ra = sfm_ext`` so existing videosfm callers
(``pycolmap.glomap_ra.filter_tracks_by_angle`` etc.) keep working
unchanged. Filenames keep the ``_glomap`` suffix for now — file rename
is a separate, larger churn.

Verified:
- Full build clean (warm).
- 8/8 ctests pass: track_establishment_test, track_filter_test (7/7),
  image_pair_inliers_test, motion_averaging_test, metric_depth_test,
  global_positioning_test, rotation_averaging_test (controllers +
  estimators).
- pycolmap import + alias intact: pycolmap.glomap_ra is pycolmap.sfm_ext,
  filter_tracks_by_angle resolves on both.
- videosfm GlobalPositioningPhase imports OK with the alias.

* comments(sfm_ext): drop stale glomap_ra / pyglomap / vendored references

Cleans up post-rename comment debt:
- image_pair_inliers_glomap.h: remove TODO that already shipped (Sampson/H errors now route through native helpers); drop 'vendored from glomap/types.h' framing on InlierThresholdOptions.
- track_filter_glomap.cc: replace 'glomap RA pipeline' / 'RA-shadow' wording with neutral 'dict-of-tracks state used by sfm_ext'.
- pycolmap/sfm/track_filter_glomap.cc: drop pyglomap.* Python-name mentions (module no longer exists).
- pycolmap/sfm/track_establishment_glomap.cc: drop 'Glomap-style' from establish_full_tracks docstring.

* comments(pycolmap): drop residual pyglomap migration archaeology

Second comment-cleanup pass beyond the sfm_ext-renamed files. Strips
'mirrors pyglomap.X', 'byte-for-byte parity with pyglomap', 'so the
videosfm pyglomap shim can be retired' framing now that pyglomap is
gone and the videosfm shim is removed.

Preserved: design rationale (e.g. issue #40 reference for adopting
CalibrateViewGraph wrapper, pybind11 deep-copy gotcha), open
algorithmic TODOs unrelated to the rename.

* sfm: fold sfm_ext::TrackEngine into native GlobalMapper / track_establishment

Removes the parallel colmap::sfm_ext::TrackEngine path. The fork-specific
behaviors that lived in track_establishment_glomap.{h,cc} now live as
native free functions in track_establishment.{h,cc} and as methods on
GlobalMapper. Net diff -300 LOC; one less namespace; one less file pair.

Native types already carried the data (Track::lc_elements,
ImagePair::are_lc, Image::depth_priors / depth_prior_validity were added
during the M0-M13 port). This commit fills in the missing code paths:

New free functions in colmap::namespace (track_establishment.{h,cc}):
- MakeLoopClosureMatchPredicate(valid_pair_ids, corr_graph) - returns a
  MatchPredicate that excludes ImagePair::are_lc[idx]==true inliers from
  the union-find pass, pre-computing the LC set in O(num inliers).
- AppendLoopClosureObservations(valid_pair_ids, corr_graph, tracks) -
  4-branch second pass that populates Track::lc_elements (no-track /
  both-tracks / one-track-each / same-id, mints new tracks at
  max(track_id)+1). Operates on a tracks dict so binding + GlobalMapper
  share the call site.
- struct TrackSubsampleOptions + SubsampleTracks(...) - depth-aware
  greedy length-sorted subsample. Reads depth_priors / depth_prior_validity
  / registered_image_ids passed as separate maps to keep the function
  Reconstruction-free. Asymmetric length filter preserved (lower bound
  counts Length+lc_elements, upper bound counts Length only).

GlobalMapper extensions (global_mapper.{h,cc}):
- GlobalMapperOptions gains 4 fields (default-off so vanilla colmap
  behavior is unchanged): track_lc_second_pass, track_two_view_depth_gate,
  track_max_num_views_per_track, track_max_num_tracks.
- EstablishTracks runs MakeLoopClosureMatchPredicate +
  AppendLoopClosureObservations when track_lc_second_pass=true; bypasses
  the helper-side greedy subsample in that mode.
- New SubsampleTracksForProblem method bridges Reconstruction <-> dict and
  calls SubsampleTracks.

Pycolmap binding (track_establishment_glomap.cc - file kept, contents
rewritten to use native API):
- pycolmap.establish_full_tracks(view_graph, images, options,
  lc_second_pass=False) - top-level binding (path preserved). Takes the
  native pycolmap.TrackEstablishmentOptions; lc_second_pass optional
  kwarg toggles the LC second pass.
- pycolmap.find_tracks_for_problem(view_graph, images, tracks_full,
  options) - takes pycolmap.TrackSubsampleOptions (new bound struct).
- The old pycolmap.TrackEstablishmentOptions (fork shape) is gone. The
  native TrackEstablishmentOptions has 3 fields
  (intra_image_consistency_threshold, min_num_views_per_track,
  required_tracks_per_view); the 5 subsample knobs (max_num_views_per_track,
  max_num_tracks, two_view_depth_gate, etc.) live on the new
  TrackSubsampleOptions.

Tests (track_establishment_test.cc):
- All 16 tests preserved (5 native + 6 FindTracksForProblem-now-SubsampleTracks
  + 5 ProcessLoopClosurePairs-now-AppendLoopClosureObservations).
- MinTracksPerViewBugDocumentation rewritten to describe the new int-typed
  INT_MAX default (signed comparison never trips) instead of the old
  signed/unsigned -1 -> UINT_MAX promotion. Same observable behavior.

Adjacent (track_filter_glomap.{h,cc} + binding + tests):
- Repointed track_t / feature_t typedef include from the deleted
  track_establishment_glomap.h to colmap/util/types.h (the canonical
  point3D_t / point2D_t aliases). track_t / feature_t aliases are gone.

Files DELETED:
- src/colmap/sfm/track_establishment_glomap.h (-82 LOC)
- src/colmap/sfm/track_establishment_glomap.cc (-311 LOC)

Verified: scripts/build.sh clean (cold + warm). 7 ctest groups pass
(scene/track, scene/image, sfm/track_establishment, sfm/track_filter,
sfm/image_pair_inliers, estimators/global_positioning, estimators/
rotation_averaging) - 100% in 0.3s.

* sfm: drop _glomap suffix + sfm_ext namespace from remaining fork files

Three-stage cleanup mirroring the TrackEngine fold (commit 77c0574b).
Net result: ZERO _glomap files in colmap/sfm/, ZERO sfm_ext namespace
references. All fork-extension code now lives in plain colmap::
namespace alongside upstream code, with no naming tax.

Stage 1 - two_view_geometry_glomap deletion (-79 LOC):
- 3 helpers (CheckCheirality single-pair, GetOrientationSignum,
  SampsonError Vec3 depth-aware) had ONE caller each
  (image_pair_inliers). Folded as anonymous-namespace statics in the
  consumer file. Deleted two_view_geometry_glomap.{h,cc} +
  CMakeLists entry.

Stage 2 - track_filter_glomap rename (no LOC delta, +/- ~250):
- Renamed src/colmap/sfm/track_filter_glomap.{h,cc} -> track_filter.{h,cc}.
- Dropped sfm_ext:: namespace -> plain colmap::.
- Converted struct TrackFilter::FilterX static methods to free
  functions FilterTracksByAngle / FilterTrackTriangulationAngle.
- Pycolmap binding: pycolmap.sfm_ext.filter_tracks_by_angle ->
  pycolmap.filter_tracks_by_angle (top-level). Same for
  filter_track_triangulation_angle. Test file + downstream callers
  updated.

Stage 3 - image_pair_inliers_glomap rename (no LOC delta):
- Renamed src/colmap/sfm/image_pair_inliers_glomap.{h,cc} ->
  image_pair_inliers.{h,cc}.
- Dropped sfm_ext:: namespace -> plain colmap::.
- Behavior preserved verbatim (hard-coded thres_epipole_nodepth,
  disabled Gate-3 angle check, F-branch positive_count==negative_count
  silent drop). TODOs for later cleanup left in-place.
- Pycolmap binding: pycolmap.image_pairs_inlier_count was already
  top-level; renamed BindImagePairInliersGlomap -> BindImagePairInliers.
- Renamed src/pycolmap/sfm/track_establishment_glomap.cc ->
  track_establishment.cc; renamed BindTrackEstablishmentGlomap ->
  BindTrackEstablishment. Same fold treatment.

Pycolmap top-level surface (no sfm_ext submodule any more):
- pycolmap.establish_full_tracks / find_tracks_for_problem
- pycolmap.filter_tracks_by_angle / filter_track_triangulation_angle
- pycolmap.image_pairs_inlier_count
- pycolmap.TrackEstablishmentOptions / TrackSubsampleOptions / InlierThresholdOptions

Removed back-compat alias pycolmap.glomap_ra = pycolmap.sfm_ext (no
remaining callers; sfm_ext submodule itself is gone).

Verified: scripts/build.sh clean. 7 ctest groups pass (scene/track,
scene/image, sfm/track_establishment, sfm/track_filter,
sfm/image_pair_inliers, estimators/global_positioning, estimators/
rotation_averaging) - 100% in 0.29s.

* comments: drop stale sfm_ext / TrackEngine mentions in pycolmap bindings

* rotation_averaging: drop dead RotationEstimatorOptions::gravity_dir field

Audit showed gravity_dir is exposed in the binding (motion_averaging.cc)
+ declared in the struct but has ZERO consumers in
colmap/src/colmap/estimators/. Setting it does nothing — pure footgun
for anyone who flips it expecting gravity-aware behavior. Pose-prior
data is supplied separately via the run_rotation_averaging
`pose_priors` argument; this field never participated in the up-vector
math.

The ten other dead-flag suspects from the prior audit
(use_precomputed_weights, fix_non_lc_weights, fixed_non_lc_weight,
consecutive_trivial, use_relative_pose_constraints, relative_pose_default_stddev,
loss_relative_pose, debug_only_relative_pose, scale_regularization_sigma,
image_ids_to_optimize) are already GHOSTS — not on the struct, not in
the binding, not in videosfm. No additional cleanup needed.

* global_positioning: option flag for uncalibrated 0.5x ScaledLoss; fix BindImagePairInliers definition

Adds GlobalPositionerOptions::apply_uncalibrated_loss_downweight (bool,
default true). When true, AddPointToCameraConstraints wraps the BATA
loss in ScaledLoss(0.5) for cameras whose focal length lacks an EXIF
prior (Camera::has_prior_focal_length == false) - the mainline colmap4
heuristic for downweighting bearings that depend on a VGC-estimated
focal. When false, all cameras get full-weight residuals, matching the
glomap fork's behavior.

Default true preserves upstream colmap behavior; videosfm callers
calibrated against fork-flavored full-weight residuals can flip the
flag false at the bridge level (companion videosfm commit wires this
through GPOptions).

Bound in motion_averaging.cc.

Also fixes a leftover from the earlier _glomap rename (commit
db8a5a0d): src/pycolmap/sfm/image_pair_inliers.cc still defined the
binder as BindImagePairInliersGlomap while bindings.cc was already
calling BindImagePairInliers, producing an undefined-symbol linker
error any time the binding got fully relinked. The earlier build
succeeded only because the .so was cached. Rename the definition to
match.

* global_positioning + RA: hardening + comment cleanup

Three small hardening additions + comment-text cleanup across the
GP/RA C++ surface:

- rotation_averaging_impl.cc: SolveIRLS now mirrors SolveL1's NaN-guard
  on the step vector. Without this, a singular pose-graph silently
  corrupts cams_from_world via UpdateState and the IRLS loop returns
  true with garbage residuals on the next iteration. L1 already had
  this guard at line 755; IRLS didn't.

- rotation_averaging_impl.cc: RotationAveragingProblem ctor adds a
  THROW_CHECK that 'skip_risky_LC_pairs=true' requires a
  CorrespondenceGraph. Previously the LC-skip filter silently never
  triggered when CG was nullptr - the option appeared to work but
  did nothing.

- image.h: Image::operator== now also compares pixel_cholesky_xy_.
  This is the field that triggered the earlier copy-ctor field-drop
  bug fix (commit 985b2ecb); including it in equality means a future
  unit test of the form 'EXPECT_EQ(copy, original)' would catch an
  analogous regression. Other fork-extension fields (depth_priors,
  is_inlier, cam_from_world, etc.) are intentionally NOT added -
  those are mutable pipeline state, not identity-defining.

- global_positioning.h: cleaned 'glomap fork' / 'videosfm callers'
  vocabulary leakage in the apply_uncalibrated_loss_downweight
  comment; the comment now describes what the bool does without
  referencing internal nomenclature. Also added a doc note that the
  10 cascade LossConfigs only run under SPLIT_METRIC_DEPTH (BATA mode
  silently ignores them - prevents future caller from misconfiguring
  these in BATA mode and wondering why nothing changes).

* pycolmap: rename view_graph -> correspondence_graph in bindings

Match the C++ class name (CorrespondenceGraph). Aligns variable names,
parameter names, and dict keys with the type so callers see one term.

* rotation_averaging: drop RotationEstimatorOptions::prioritize_tracking_in_mst

The MST initializer's LC-edge penalty branch was a separate flag from
the video-aware Ceres solver, but they were always enabled together
(the fork wired prioritize_tracking_in_mst directly off
use_video_constraints). Collapse: ComputeMaximumPoseGraphSpanningTree
now reads options_.use_video_constraints, deleting the redundant flag,
its pycolmap binding, and its option-struct field. The function-level
prioritize_tracking parameter stays, callable directly by unit tests.

* rotation_averaging_test: rename Gate_PrioritizeTrackingInMst -> Gate_LcPenaltyMst

The flag was removed in 423c6e58; the tests now exercise the function-
level prioritize_tracking parameter directly. Rename the test cases
and the comment block to match the surviving concept (LC-penalty
branch in ComputeMaximumPoseGraphSpanningTree).

* ceres_loss + run_global_positioning: review follow-ups

- CreateLossFunction: replace silent `return nullptr` on unhandled
  enum with LOG(FATAL). The previous fall-through would crash with a
  nullptr deref downstream; failing at the source surfaces an enum
  extension oversight immediately.

- run_global_positioning binding: drop the duplicate
  ``dmap_scale_map_nested`` result key. It was a literal alias of
  ``dmap_scale_map`` — same py::dict assigned twice. Caller now reads
  the single key.

* comments: drop residual glomap-fork / videosfm vocabulary

Comment-only sweep across estimators / sfm / scene headers and
impls. Rewrites describe what the code does instead of where it
came from. No identifier or behavior changes.

* rotation_averaging: hoist SetPRNGSeed out of ComputeResiduals

ComputeResiduals is called from L1 and IRLS inner loops. The seed
call inside it reset the global PRNG to the same value every
iteration, replaying the identical jitter sequence in
ComputeGravityAligned1DOFResidual. Move the seed call to
RotationAveragingSolver::Solve (once per problem solve) so the
PRNG advances naturally across iterations — matches the fork's
behavior of seeding once at the GP/RA boundary.

* rotation_averaging: seed MST root + use inlier count for edge weight

Two fork-equivalent fixes to InitializeFromMaximumSpanningTree:

1. Seed cams_from_world[root] from the reconstruction's MDRP-prior
   pose before BFS-propagating relative rotations. The previous
   default-constructed identity seed rotated every descendant by
   R_root^-1 vs the prior gauge, which can shift IRLS weights through
   the max_rotation_error_deg filter.

2. Edge weight in ComputeMaximumPoseGraphSpanningTree now reads
   inliers.size() from the CorrespondenceGraph (post-RANSAC survivor
   count) when CG is plumbed through, falling back to edge.num_matches
   (raw match count) for vanilla colmap callers without a CG. Matches
   fork's MST weight semantic.

Test fixture updated: pair inliers vectors are now sized to match the
edge num_matches values so both weight sources agree on the MST shape.

* global_positioning: port InitializeDepthMapScalesFromObservations from fork

Restores the median-z_est/depth_prior seeding the fork ran at GP1
entry. Without this, GP1 lazy-inserts dmap_scales_ at 1.0 (linear) /
0.0 (log) for every image — the optimizer starts in a different basin
than the fork.

Source: glomap/glomap/estimators/global_positioning.cc:1006-1133.
Adapted to colmap4's Reconstruction model: iterates Points3D' track
elements (and lc_elements when use_lc_observations is set) to
accumulate per-image z_est/depth_prior samples, takes the median per
image, and seeds dmap_scales_ with log(median) or linear median per
use_log_scale_for_depth_map_scales.

Call site in Solve() is gated on SPLIT_METRIC_DEPTH + use_init +
initial_dmap_scales empty, so GP2 (which supplies initial_dmap_scales
from GP1 output) bypasses this path. Images without observations
consumed here still fall through to the lazy-insert path in
AddObservationToProblem.

* comments: drop residual migration-archaeology vocabulary

Sweep upstream-bound C++ comments / docstrings / test-file
references for "pre-port", "post-port", "M1-M13" milestone tags,
"glomap-fork", "fork-only", "videosfm-side", and the
videosfm-private issue #40 reference (incl. one runtime warning
string). Rewrites describe what the code does now. One identifier
rename: fork_sqnorm -> expected_sqnorm in motion_averaging_test.cc.
No behavior change.

* view_graph_manipulation: skip UNCALIBRATED-pair upgrade when cam2_from_cam1 is null

The original port of glomap's UpdateImagePairsConfig added a
THROW_CHECK around the UNCALIBRATED -> CALIBRATED upgrade requiring
``tvg.cam2_from_cam1.has_value()``. Pairs whose decomposition never
produced a relative pose (e.g. F-only RANSAC path or decomposition
failure) crash the pipeline at this site.

The fork's ImagePair carried cam2_from_cam1 as a Rigid3d (always
default-constructible to identity) so it never tripped this check;
colmap's TwoViewGeometry::cam2_from_cam1 is std::optional<Rigid3d>
so absence is real and observable.

Replace the THROW_CHECK with `continue`. Pairs without a relative
pose stay UNCALIBRATED and are dropped by downstream filters
(inlier-num / inlier-ratio) instead of crashing. Verified end-to-end
on V2-adaptive sample-10 HGE.
Zador-Pataki added a commit that referenced this pull request Apr 28, 2026
* M1-M3: drop dead RA options + extend GP options + cost functors

M1 (Hygiene):
- Drop dead glomap-fork option fields use_precomputed_weights,
  fix_non_lc_weights, fixed_non_lc_weight from
  colmap::RotationEstimatorOptions. Per audit (gp_inventory.md, ra_inventory.md)
  these are dead in fork .cc itself or hardcoded false in pipeline. Drop
  matching pycolmap def_readwrite + clean up referencing code paths in
  glomap_ra::RotationEstimator (kills the obsolete SolveWeightedLS branch
  + the no-op fix_non_lc_weights warning).
- Update comment block at rotation_averaging.h:70-71 — only skip_risky_LC_pairs
  + use_video_constraints + video_*_scale survive as fork-flag stubs.
- Gate native GP first-scale pin (global_positioning.cc:349-355) on
  point_constraint_type != SPLIT_METRIC_DEPTH. Preserves existing colmap GP
  unit tests under default BATA mode (Q1 / R10).

M2 (Foundation):
- New file colmap/util/loss_config.h: LossFunctionConfig {name, scale,
  weight} + CreateLossFromConfig factory. Names: trivial, huber, cauchy,
  arctan, softlone. weight!=1 wraps in ScaledLoss.
- Extend colmap::GlobalPositionerOptions with glomap-fork additions:
  PointConstraintType enum (BATA / SPLIT_METRIC_DEPTH), use_init,
  random_init_scale, depth toggles (use_log_scale_for_depth_map_scales,
  use_log_residual_for_depth, zero_residual_behind, smooth_log_linear_transition,
  log_linear_threshold), scale_prior_stddev, filter_depth_outliers,
  initial_dmap_scales (optional<map<image_t, double>>),
  10 LossFunctionConfig fields with names verbatim from existing glomap_ra
  port (loss_normal_geometry, loss_normal_depth, loss_lc_geometry,
  loss_lc_depth, loss_normal_geometry_inlier, loss_normal_depth_inlier,
  loss_normal_depth_outlier, loss_normal_geometry_trackstart,
  loss_normal_depth_trackstart, loss_scale_prior).
- Change random_seed default from -1 to 1 (Decision 9, preserves
  byte-identity for existing call sites that previously inherited GP_SEED=1).
- Extend GlobalPositioner state: dmap_scales_, dmap_scale_observation_counts_,
  depth_outliers_, 10 cached_loss_*_ shared_ptrs, soft_outlier_fallback_loss_.

M3 (Cost functors):
- Add WeightedBATADirectionalError to cost_functions/motion_averaging.h.
  AutoDiff <3,3,3,1> on (c_i, X_k, scale). Anisotropic per-axis weighting
  (1/sigma_x, 1/sigma_y, 1/sigma_z). Caller computes
  sigma_z = (sigma_x + sigma_y) / 2.
- New file cost_functions/metric_depth.h with 3 functors:
  MetricDepthError (5-toggle, 12 reachable combinations — smooth_transition
  only meaningful in use_log_residual=true branch),
  ScalePriorError (linear), LogScalePriorError (log-space, prior=log(1)=0).
  All ported verbatim from fork's cost_function.h.

Cluster cleanup (carried over from prior session):
- Strip 175 lines of dead Fetzer focal cost from sfm/cost_function_glomap.h.
- Strip 47 lines of FilterTracksByReprojection (unbound, unused) from
  sfm/track_filter_glomap.{h,cc}.

Verified: ATE 37.40 / 66.77 / 93.35 / 96.68 / 99.67 unchanged (M0a baseline)
on sample-10 LaMAR HGE / ios_2022-01-12_14.59.02_000-0 + canonical DB
+ GP_SEED=1.

* M4: SPLIT_METRIC_DEPTH path body in colmap::GlobalPositioner

Implement the trivial-rig case of the metric-depth residual structure.
Activated by point_constraint_type=SPLIT_METRIC_DEPTH (default still
BATA — no behavior change for native callers).

Per-observation in AddPoint3DToProblem (image.IsRefInFrame() branch only):
- When image.depth_prior_validity[fid] is true and depth_prior > 0,
  emit a 1-D MetricDepthError residual on
  (frame_centers_[image.FrameId()], point3D.xyz, dmap_scales_[image_id]).
- Lazy-insert dmap_scales_ on first valid observation per image. Initial
  value: from options_.initial_dmap_scales (caller-supplied GP1→GP2
  handoff) if provided, else 1.0 (linear) / 0.0 (log).
- Increment dmap_scale_observation_counts_ for the per-image
  ScalePriorError weight.

Per-image emission in AddPointToCameraConstraints (after track loop):
- Reset dmap_scales_ + dmap_scale_observation_counts_ at start (lazy
  filled by AddPoint3DToProblem).
- After all observations added: emit one ScalePriorError /
  LogScalePriorError per image with depth observations. Loss is wrapped
  in ceres::ScaledLoss(TrivialLoss, obs_count) so dense-depth images get
  proportionally stronger priors. M5 will replace TrivialLoss with the
  configurable cached_loss_scale_prior_.

Parameter ordering / bound (M4 step 5):
- dmap_scales_ ride alongside frame_centers_ in parameter group 2.
- Linear-space: SetParameterLowerBound(&dmap_scale, 0, 1e-5) prevents
  collapse. Log-space: no bound (parameter is log-space, positivity comes
  from exp() in MetricDepthError).

Init flag wiring (M4 steps 6-7):
- options_.use_init short-circuits InitializeRandomPositions for both
  camera centers AND track xyz (collapses fork's split flag).
- options_.random_init_scale replaces hardcoded 100.0 cube size in two
  call sites.

Verified: ATE 37.40 / 66.77 / 93.35 / 96.68 / 99.67 unchanged (default
BATA path) on sample-10 LaMAR HGE / ios_2022-01-12_14.59.02_000-0 +
canonical DB + GP_SEED=1.

* M5: GP body Cluster B — loss routing fabric + two-loop iteration

Implement glomap-fork loss-routing cascade in colmap::GlobalPositioner.
Activated by point_constraint_type=SPLIT_METRIC_DEPTH; default BATA mode
unchanged.

Refactor: extract per-observation logic into AddObservationToProblem
helper (point3D_id, observation, is_lc_observation, random_initialization,
reconstruction). AddPoint3DToProblem becomes a dispatcher iterating
track.Elements() (is_lc=false) then track.lc_elements (is_lc=true) —
mirrors fork's two-parallel-collection structure.

Cache pre-warm in AddPointToCameraConstraints: materialize 10
cached_loss_*_ via CreateLossFromConfig. Default TrivialLoss/weight=1
config is equivalent to no override; non-default Huber/Cauchy/etc take
effect through cascade.

is_excluded skip: top of AddObservationToProblem.

4-way geometry cascade (SPLIT_METRIC_DEPTH only):
  is_lc -> loss_lc_geometry, is_track_anchor -> loss_normal_geometry_trackstart,
  is_inlier -> loss_normal_geometry_inlier, else -> loss_normal_geometry.
5-way depth cascade for MetricDepth residual:
  depth_outliers_ + LC -> SKIP depth residual entirely (deletes
    metric_depth_cost to avoid leak),
  depth_outliers_ + non-LC -> ScaledLoss(HuberLoss(1), 1) hardcoded
    soft fallback,
  is_lc -> loss_lc_depth, track_anchor -> loss_normal_depth_trackstart,
  is_inlier -> loss_normal_depth_inlier, MDRP-flagged outlier ->
  loss_normal_depth_outlier, else -> loss_normal_depth.

ScalePriorError wrapper updated: replaces M4 TrivialLoss(obs_count)
with cached_loss_scale_prior_ wrapped in ScaledLoss(obs_count). Default
config preserves obs_count weighting unchanged.

Verified ATE 37.40/66.77/93.35/96.68/99.67 unchanged (default BATA)
on sample-10 LaMAR HGE.

* M6: GP filter_depth_outliers pre-Solve pass

Implement glomap-fork pre-Solve depth-outlier filter in
colmap::GlobalPositioner. Activated by
options_.filter_depth_outliers + point_constraint_type=SPLIT_METRIC_DEPTH.

In AddPointToCameraConstraints, after cache pre-warm + dmap_scales_
reset:
- Seed dmap_scales_ from options_.initial_dmap_scales when caller
  supplied them (typical GP2 path: GP1 hands off solved scales). Linear
  scale converted to log if use_log_scale_for_depth_map_scales=true.
  Lazy-insert in AddObservationToProblem then preserves these values.
- Clear depth_outliers_; if filter_depth_outliers, call
  FilterDepthOutliers(reconstruction).

FilterDepthOutliers method (new):
- Per-point3D: walk track.Elements() and track.lc_elements separately.
- For each observation: skip when depth_prior_validity[fid]=false or
  depth_prior <= 1e-6 or stddev_rel <= 1e-9.
- Apply per-image scale (dmap_scales_[image_id]) if available; else
  raw prior. Threshold check:
    |log(z_est) - log(scale * depth_prior)| >= 3 * log(1 + stddev_rel)
- Insert (image_id, point2D_idx) into depth_outliers_ on flag.

Anonymous-namespace helper DepthOutlierFlag deduplicates the regular vs
LC sweep.

Native uses image.CamFromWorld() * X_world (the canonical cam_from_world
projection) — fork's pre-Solve branch had inadvertently relied on the
in-Solve flipped translation convention via the image.cam_from_world
member directly; native's CamFromWorld() resolves cleanly because we run
this BEFORE the Solve loop temporarily flips frame_centers_.

Verified ATE 37.40/66.77/93.35/96.68/99.67 unchanged (default BATA mode +
filter_depth_outliers=false) on sample-10 LaMAR HGE.

* M7 (binding side): extend pycolmap.run_global_positioning for SPLIT_METRIC_DEPTH

Pycolmap binding for native colmap::GlobalPositioner now exposes the full
glomap-fork option surface (M2) + dict return shape (Decision 7).

Option struct extension (BindGlobalPositioner):
- New PointConstraintType enum binding (BATA / SPLIT_METRIC_DEPTH).
- Bind use_init, random_init_scale, depth toggles
  (use_log_scale_for_depth_map_scales, use_log_residual_for_depth,
  zero_residual_behind, smooth_log_linear_transition,
  log_linear_threshold), scale_prior_stddev, filter_depth_outliers.
- Bind initial_dmap_scales as Optional[Dict[image_id, float]] with None
  short-circuit; Python passes linear-space, C++ stores per
  use_log_scale_for_depth_map_scales convention.
- Bind 10 LossFunctionConfig fields as dict-typed properties via
  templated BindLossField helper accepting/emitting
  {'name', 'scale', 'weight'}. Names verbatim from glomap_ra port:
  loss_normal_geometry, loss_normal_depth, loss_lc_geometry,
  loss_lc_depth, loss_normal_geometry_inlier, loss_normal_depth_inlier,
  loss_normal_depth_outlier, loss_normal_geometry_trackstart,
  loss_normal_depth_trackstart, loss_scale_prior.

run_global_positioning return shape:
- Was: bool. Now: dict {success: bool, dmap_scale_map: Dict[image_id,
  float] (linear), dmap_scale_map_nested: Dict[image_id, {linear, log}]}.
- Reconstruction is mutated in place; Python reads images/tracks from
  rec.images / rec.points3D directly.
- Mirrors glomap_ra binding return shape so videosfm caller migration
  (Python side, deferred to next session) is a near-1:1 swap.

GetDmapScales accessor on GlobalPositioner exposes the per-image
dmap_scales_ map for the binding's dict-extraction step. Linear
conversion (exp() when use_log_scale_for_depth_map_scales=true) happens
in the lambda.

Verified: ATE 37.40/66.77/93.35/96.68/99.67 unchanged on sample-10 LaMAR
HGE — caller still on glomap_ra path; native binding extension is
additive and doesn't affect existing flow.

* M7 (C++): scales_ reservation fix + dmap_scales std::map + Ceres options bindings

Bug fixes uncovered during M7 caller migration:

1. SetupProblem scales_.reserve undercount: was only counting
   point3D.track.Length(); M5's two-loop iteration also walks
   track.lc_elements, each emplace_back-ing a new scale. Without
   reserved capacity, vector reallocation invalidates earlier &scale
   pointers stored in Ceres residual blocks → "ParameterBlock with
   size 1 has at least one invalid value". Fix: include lc_elements
   in the reservation count.

2. dmap_scales_ container: switched from unordered_map<image_t, double>
   to std::map<image_t, double>. Ceres residuals store
   &dmap_scales_[image_id] data pointers; a hash-table rehash during
   lazy-insert in AddObservationToProblem invalidates them. std::map
   (rb-tree) keeps pointers stable for the entry's lifetime.

3. Parameter group separation: dmap_scales_ now go in their own
   parameter group (group_id+1), separate from frame_centers_. Mixing
   1-D dmap_scales with 3-D frame_centers in the same Schur-ordering
   group breaks the Schur-complement preprocessor — Ceres downgrades
   to SPARSE_NORMAL_CHOLESKY then rejects the inhomogeneous block sizes.
   Separate group keeps each Schur block size-uniform.

4. Pycolmap binding: added solver_options.{num_threads,
   max_num_iterations, function_tolerance, gradient_tolerance,
   parameter_tolerance} as def_property pass-throughs on
   GlobalPositionerOptions. The solver_options nested struct is not
   directly bound in pycolmap, so caller migration would otherwise
   fail with "object has no attribute 'solver_options'".
   Mirrors the existing pyglomap binding's pattern.

Verified GP runs to CONVERGENCE (no more spurious FAILURE termination)
on sample-10 LaMAR HGE under both BATA and SPLIT_METRIC_DEPTH paths.
ATE differs from glomap_ra baseline (cross-implementation drift expected
per Decision 7); structural integrity restored.

* M8: plumb IRLS final weights via out-param (preserves bool return ABI)

Glomap-fork RA contributed a per-pair IRLS final-weight map that the
videosfm pipeline reads for its consecutive-pair-weight degeneracy
diagnostic (rotation_averaging.py:117-129). Native colmap RA discards
the IRLS weight vector after the final factorize. M8 plumbs it out.

Plumbing (4 layers):
1. RotationAveragingProblem gains final_weights_ member +
   FinalWeights() accessor + SetFinalWeightsFromIRLS(weights_irls)
   setter. The setter translates the per-row weight vector into a
   per-pair map keyed by image_pair_t. Each PairConstraint owns 1
   (gravity-aligned) or 3 (full-3DOF) consecutive rows starting at
   constraint.row_index — we read weight at row_index (all rows
   share the same weight per ComputeIRLSWeights). Gauge-fixing rows
   excluded.
2. SolveIRLS captures the last successful iteration's weight vector
   in last_weights and calls problem.SetFinalWeightsFromIRLS at the
   end (after the convergence loop). Empty if SolveIRLS doesn't run
   (e.g. L1-only path).
3. RotationEstimator::EstimateRotations + SolveRotationAveraging gain
   std::unordered_map<image_pair_t, double>* final_weights = nullptr
   out-param. When non-null, copy problem.FinalWeights() to *out
   after the solve.
4. RunRotationAveraging mirrors. Wired through both branches (with /
   without rig expansion).

Pycolmap binding:
- Adds extract_final_weights: bool = False kwarg. Default returns
  bool (preserves upstream colmap test ABI per Decision 8). When True,
  returns {success: bool, final_weights: Dict[image_pair_t, float]}
  dict. C5 fix: indexed by constraint.row_index (the row-major index
  into weights_irls), NOT by pair_info.index (the inventory's earlier
  draft).

Default behavior unchanged. Verified the C++ builds clean.

* M9: skip_risky_LC_pairs filter via 4-layer CorrespondenceGraph plumbing

Glomap-fork RA's skip_risky_LC_pairs drops pairs whose loop-closure
inlier count strictly exceeds non-LC inliers. The filter reads
ImagePair.{inliers, are_lc} fork fields — these live on
CorrespondenceGraph::ImagePair, NOT on PoseGraph::Edge (which is a
strict subset). M9 plumbs an optional CorrespondenceGraph* through 4
layers so RA can apply the filter without bloating the upstream
PoseGraph interface.

Plumbing:
1. CorrespondenceGraph gains const ImagePairsMap() accessor (the
   existing MutableImagePairs() can't be called through a const
   pointer). Used only by RA M9.
2. RotationAveragingProblem ctor takes optional CorrespondenceGraph*
   (default nullptr). Stored as correspondence_graph_ member.
3. BuildPairConstraints reads cg.ImagePairsMap()[pair_id].{inliers,
   are_lc} when options_.skip_risky_LC_pairs && correspondence_graph_
   non-null. Counts LC inliers; if lc_count > non_lc_count, continue
   (pair excluded from constraint matrix entirely).
4. RotationEstimator::EstimateRotations + SolveRotationAveraging +
   RunRotationAveraging gain optional CorrespondenceGraph* parameter
   (default nullptr; passes through to RotationAveragingProblem ctor).
5. Pycolmap binding adds correspondence_graph: CorrespondenceGraph =
   None kwarg. Default behavior unchanged for callers that don't pass
   the CG.

Default behavior: with skip_risky_LC_pairs=False (default) or
correspondence_graph=None, no filtering — exactly matches pre-M9 behavior.

Verified C++ builds clean.

* M10: prioritize_tracking MST penalty for LC-dominated edges

Glomap-fork RA's prioritize_tracking spanning-tree initialization
penalises LC-dominated edges so the MST picks tracking-dominated edges
as parents. Activated when use_video_constraints=true (M11 video-Ceres
path). Without the penalty, LC-contaminated initialization undermines
the Ceres video-aware solve.

ComputeMaximumPoseGraphSpanningTree (local helper in
rotation_averaging.cc) now accepts:
- prioritize_tracking (bool, default false): enable LC-penalty.
- correspondence_graph (CorrespondenceGraph*, default nullptr):
  source of ImagePair.{inliers, are_lc} fork fields.

When prioritize_tracking + correspondence_graph are both set, the MST
weight for each edge is reduced by 1e9 if the pair has strict LC
majority (lc_count > non_lc_count). Native MST maximises weights, so
fork's +1e9 to weights_boost becomes -1e9 to weight here
(convention inversion). Fallback to plain num_matches weight when
fork fields absent or empty.

InitializeFromMaximumSpanningTree forwards the CG; reads
options_.use_video_constraints to set prioritize_tracking. Wired
through SolveRotationAveraging from EstimateRotations + RunRotationAveraging.

Default behavior unchanged when use_video_constraints=false (no penalty
applied). Verified C++ builds clean.

* M11: video-Ceres SolveCeres + IsTrackingPair + RelativeRotationError

Glomap-fork RA video-aware Ceres path. Activated when
options_.use_video_constraints && !options_.use_gravity. Replaces L1+IRLS
with a Ceres optimization over per-frame 3-DOF angle-axis blocks. Each
pair contributes a relative-rotation residual wrapped in either
HuberLoss (tracking-dominated pair, scale=video_tracking_huber_scale)
or CauchyLoss (LC-dominated pair, scale=video_lc_cauchy_scale).

Anonymous-namespace helpers:
- RelativeRotationError: AutoDiff<3, 3, 3> functor on (r1_aa, r2_aa).
  Residual = AngleAxis(R2^T * R_rel * R1) where R_rel is precomputed
  from PairConstraint::Full3DOF::R_cam2_from_cam1. Sign convention
  matches fork (no leading negative — the resulting AngleAxis is
  identical sign to the L1+IRLS tangent residual at convergence;
  Ceres doesn't care about sign anyway since LM minimizes
  ||residual||^2).
- IsTrackingPair: counts LC inliers from CorrespondenceGraph::ImagePair
  fork fields (inliers + are_lc); returns true when non_lc_count >=
  lc_count. Reads via problem.CorrespondenceGraphPtr() (M9 plumbing).

RotationAveragingProblem accessor additions (M11):
- MutableEstimatedRotations() — direct mutable access to per-frame
  3-DOF angle-axis storage so Ceres can optimize in place.
- FrameIdToParamIdx() — frame_id -> 3-DOF block offset mapping.
- ImageIdToFrameId() — for trivial-rig path PairConstraint
  image_id -> frame_id resolution.
- FixedFrameId() — gauge-fixing anchor.
- CorrespondenceGraphPtr() — M9 plumbing surface.

RotationAveragingSolver::Solve dispatches video-Ceres path before
L1+IRLS when use_video_constraints && !use_gravity. Mutually-exclusive
warning logged when both are set (use_video_constraints disabled).

Cost is Huber+Cauchy with TAKE_OWNERSHIP semantics; no leak. Solver:
SPARSE_NORMAL_CHOLESKY, max 100 iters. M0c-equivalent verification:
default behavior unchanged when use_video_constraints=false.

* M13: delete glomap_ra GP + RA (videosfm now drives both via native)

Final deletion phase. Pre-deletion grep verified that the deleted files
have no remaining external references after M7 + M12 caller migration.
Submodule pycolmap.glomap_ra stays alive — track-filter,
track-establishment, image-pair-inliers, view-graph-manipulation, and
relative-pose-estimation continue to bind under it.

Files deleted (~3000 LOC):
- src/colmap/sfm/global_positioning_glomap.{h,cc}
- src/colmap/sfm/rotation_averaging_glomap.{h,cc}
- src/colmap/sfm/cost_function_glomap.h
- src/colmap/sfm/l1_solver_glomap.h
- src/colmap/sfm/optimization_base_glomap.h
- src/colmap/sfm/tree_glomap.{h,cc} (only RA used it)
- src/pycolmap/sfm/global_positioning_glomap.cc
- src/pycolmap/sfm/rotation_averaging_glomap.cc

Files KEPT (still used by surviving glomap_ra modules):
- rigid3d_glomap.{h,cc} — track_filter_glomap consumer
- two_view_geometry_glomap.{h,cc} — image_pair_inliers consumer
- track_filter_glomap, track_establishment_glomap, image_pair_inliers,
  view_graph_manipulation, view_graph_calibration, relative_pose_estimation

CMakeLists updates:
- src/colmap/sfm/CMakeLists.txt: drop deleted entries from SRCS list.
- src/pycolmap/sfm/bindings.cc: drop BindGlobalPositionerOptions /
  BindGlobalPositioningGlomap / BindRotationAveragingGlomap forward
  decls + dispatch calls. Other binders (BindViewGraphManipulation,
  BindRelativePoseEstimation, BindTrackEstablishmentGlomap,
  BindImagePairInliersGlomap, BindTrackFilterGlomap) preserved — those
  bind to the pycolmap.glomap_ra submodule which stays alive.

Pycolmap CMake auto-globs sources, so the .cc deletions remove
themselves from the binding build.

Verified: end-to-end pipeline runs on sample-10 LaMAR HGE +
canonical DB + GP_SEED=1 with native pycolmap.run_global_positioning +
pycolmap.run_rotation_averaging only. ATE produced (cross-impl drift
vs glomap_ra-baseline expected per Decision G2).

* M5 fix: dispatch WeightedBATADirectionalError when angular_stddevs available

Bug uncovered by post-port audit (audit_algorithmic_semantics.md
suspect #1): the M3 cost functor WeightedBATADirectionalError was
defined and built, but AddObservationToProblem unconditionally created
the unweighted BATAPairwiseDirectionCostFunctor — the M5 cascade only
swapped the LOSS function, not the COST function. Result: anisotropic
per-keypoint weighting was dropped on every geometry residual,
causing the SPLIT_METRIC_DEPTH path to trade off geometry vs metric-
depth at the wrong relative scale.

Fix: dispatch WeightedBATADirectionalError when
image.angular_stddevs[fid] is populated (videosfm pipeline always
populates). sigma_x, sigma_y from the Vector2d entry; sigma_z =
0.5*(sigma_x+sigma_y). Falls back to BATAPairwiseDirectionCostFunctor
when sigmas absent. Mirrors fork's dispatch at
glomap/glomap/estimators/global_positioning.cc:592-601, 646-657.

Verified ATE recovery on sample-10 LaMAR HGE / ios_2022-01-12_14.59.02_000-0
+ canonical DB + GP_SEED=1:
- Pre-fix:  0.00 / 0.00 / 17.22 / 57.92 / 95.79
- Post-fix: 39.44 / 68.32 / 93.66 / 96.83 / 99.68
- Baseline: 37.40 / 66.77 / 93.35 / 96.68 / 99.67 (glomap_ra)

Native port now produces parity-or-slightly-better ATE than the
pre-port glomap_ra baseline. Cross-implementation drift is within
~5% (Decision G2 sanity threshold).

* global_positioning: add GP_SEED env-var fallback in ctor

When random_seed == -1 (caller explicitly opts out of deterministic
seeding) fall back to the GP_SEED env var so the documented Tier-2
byte-identity recipe (CLAUDE.md "ATE byte-identity") works without
patching every call site for the transition cycle.

Plan-vs-execution audit flagged this: header docstring claimed
GP_SEED fallback existed but ctor only honored options_.random_seed >= 0.
Adding cstdlib for std::getenv/std::atoi.

* global_positioning: mark random_seed default + GP_SEED fallback as reproduce-fork TODOs

Per user audit ask: any place where M0–M13 changed colmap4 default
behavior to reproduce the pyglomap fork should be marked as a TODO
not silently hardcoded — the eventual goal is to converge back to
native defaults.

Two GP-side crutches:
- random_seed default 1 (upstream is -1 / random_device)
- GP_SEED env-var fallback in ctor when random_seed == -1

Both stay in place for Tier-2 byte-identity but are now marked
TODO(reproduce-fork). Companion notes:
.claude/notes/glomap_audit/audit_default_overrides.md

* sfm/*_glomap*: add TODO(dedup-glomap-vs-colmap4) banners

Six fork-imported headers in colmap/sfm/ now carry a per-file TODO
comment pointing at the dedup audit
(.claude/notes/glomap_audit/audit_glomap_files_vs_colmap4.md). Each
banner names which symbols in that TU duplicate native colmap4
helpers vs which are unique fork algorithms.

grep -rn 'TODO(dedup-glomap-vs-colmap4)' colmap/src now surfaces the
~250-350 LOC dedup follow-up scoped for after the M0-M13 PR merges.

* estimators: gate fork additions behind opt-in options (vanilla-by-default)

Per backward-compat study (audit_backward_compat.md): make vanilla
upstream colmap4 the default for both GP and RA so non-fork callers
get unmodified upstream behavior. Every fork-added feature now
requires an explicit opt-in.

GlobalPositionerOptions — 3 changes:
- New `use_observation_exclusions` (default false): gates the
  per-observation skip via `image.is_excluded[idx]` in
  AddObservationToProblem (Gate G-1).
- New `use_lc_observations` (default false): gates the
  `track.lc_elements` second loop in AddPoint3DToProblem and the
  paired sweep in FilterDepthOutliers. This IS the GP-side LC config —
  pairs with RA's `skip_risky_LC_pairs` to give two complementary LC
  controls (Gate G-2).
- `random_seed` default restored from 1 -> -1 (upstream non-deterministic
  random_device). The GP_SEED env-var fallback in the ctor stays as a
  documented escape for the Tier-2 byte-identity recipe (Gate G-3).

RotationEstimatorOptions — 1 change:
- New `prioritize_tracking_in_mst` (default false): decouples M10 MST
  LC-penalty from M11 `use_video_constraints`. Previously
  `ComputeMaximumPoseGraphSpanningTree(prioritize_tracking=...)` was
  hardcoded to `use_video_constraints`; now a separate knob (Gate R-1).

pycolmap binding: 3 new `def_readwrite` entries on GlobalPositionerOptions
(use_observation_exclusions, use_lc_observations) and RotationEstimatorOptions
(prioritize_tracking_in_mst).

Net effect:
- Vanilla pycolmap.run_global_positioning / run_rotation_averaging
  callers now get upstream colmap4 behavior unmodified.
- videosfm flips all four gates true via _to_native_gp_options /
  _to_pycolmap_ra_options so the existing pipeline is unchanged.

Smoke-tested: defaults are False/-1, post-flip True/1.

* sfm: dedup *_glomap helpers against colmap4 native

Three-step dedup pass (audit_glomap_files_vs_colmap4.md). Replace
fork-imported helpers that duplicated existing colmap4 native code.

#1 union_find_glomap.h (-42 LOC)
- DELETED. colmap::UnionFind<T> at colmap/math/union_find.h:40 is a
  strict superset (Reserve / FindIfExists / Compress / Parents) and
  the body of Find/Union is byte-equivalent.
- Added 1-line Clear() to native colmap::UnionFind so it covers the
  one method TrackEngine used that wasn't already in native.
- track_establishment_glomap.h now includes colmap/math/union_find.h
  and uses unqualified UnionFind (resolves to colmap::UnionFind via
  parent-namespace lookup).

#2 rigid3d_glomap.{h,cc} (-79 LOC)
- DELETED entirely. All 8 symbols turned out to be dead code:
  - DegToRad / RadToDeg duplicated colmap/math/math.h:59-64
  - RotationToAngleAxis / AngleAxisToRotation / Rigid3dToAngleAxis /
    CalcAngle*2 / CalcTrans / CalcTransAngle were declared and defined
    but never called from any TU after M0-M13.
- Includers (image_pair_inliers_glomap.h, track_filter_glomap.cc)
  switched to direct includes of colmap/math/math.h + colmap/geometry/rigid3.h.

#3 two_view_geometry_glomap.{h,cc} (-83 LOC)
- DROPPED EssentialFromMotion (= EssentialMatrixFromPose at
  colmap/geometry/essential_matrix.cc:97).
- DROPPED SampsonError(E, Vec2, Vec2) overload (= ComputeSquaredSampsonError
  at essential_matrix.cc:168, callers pass .homogeneous()).
- DROPPED HomographyError (= ComputeSquaredHomographyError at
  colmap/geometry/homography_matrix.cc:266, identical formula).
- DROPPED FundamentalFromMotionAndCameras (was 1-line wrapper, dead
  code — never called).
- KEPT CheckCheirality (depth-gated single-pair extension, no native
  equivalent), GetOrientationSignum (vendored from GC-RANSAC),
  SampsonError(E, Vec3, Vec3) (depth-aware z-divide variant).
- image_pair_inliers_glomap.cc updated to call native helpers; added
  geometry/essential_matrix.h + geometry/homography_matrix.h includes.

Net: -204 LOC across 4 files dropped + 6 files updated. Build clean,
pycolmap loads, defaults unchanged.

* sfm: collapse glomap_ra::Track onto colmap::Point3D (#4)

Final dedup step (audit_glomap_files_vs_colmap4.md). Replace the
vendored `glomap_ra::Track` struct with native `colmap::Point3D`
throughout TrackEngine, TrackFilter, and the 2 binding TUs.

The vendored type was almost a verbatim duplicate of Point3D:
- xyz / color / is_initialized -> already on Point3D (M0/glomap-fork)
- observations (vector<pair>) -> Point3D::track.Elements() (vector<TrackElement>)
- lc_observations              -> Point3D::track.lc_elements (already added at scene/track.h:93)
- track_id                     -> redundant with the map key, dropped

Changes:
- track_establishment_glomap.h: drop `Track` struct + `Observation`
  typedef. `track_t` now aliases `colmap::point3D_t`. API uses
  `unordered_map<track_t, Point3D>`.
- track_establishment_glomap.cc: 4 algorithm methods rewritten to
  use Point3D::track.AddElement / .Elements() / .lc_elements / .Length().
- track_filter_glomap.{h,cc}: same.
- pycolmap/sfm/track_establishment_glomap.cc: drop ToPoint3D
  conversion (35 LOC). Bindings now pass-through dict<int, Point3D>
  with no fork↔native marshalling.
- pycolmap/sfm/track_filter_glomap.cc: drop ToGlomapRaTrack +
  FromGlomapRaTrack (40 LOC). Same pass-through pattern.

Net: -115 LOC across 6 files. Build clean, pycolmap loads,
establish_full_tracks / find_tracks_for_problem /
glomap_ra.filter_tracks_by_angle / glomap_ra.filter_track_triangulation_angle
all expose the same dict<int, Point3D> shape they did before — videosfm
caller is unchanged.

* review fixes: docstrings, fail-loud emplace, dead-code notes

Round of post-review polish on the backward-compat + dedup PRs.

Correctness / fail-loud:
- track_establishment_glomap.cc: THROW_CHECK on the two
  ProcessLoopClosurePairs tracks.emplace calls (track_id
  collision was previously silent).
- track_filter_glomap.cc + track_establishment_glomap.cc: switch
  track.Elements().clear() -> track.SetElements({}) so the
  call survives a future signature drift on Elements()
  (review #7).

Docstring / comment:
- pycolmap motion_averaging.cc: random_seed binding doc now
  matches the new -1 default and surfaces the GP_SEED env-var
  escape at the Python boundary (review #1, #3).
- global_positioning.h: header note on use_observation_exclusions
  clarifies that Image::is_excluded is itself fork-only;
  vanilla pycolmap users won't typically populate it (review #2).
- rotation_averaging.h: prioritize_tracking_in_mst comment
  rephrased to describe gate semantics rather than videosfm wiring
  (the very coupling we just decoupled).
- two_view_geometry_glomap.h: header explains FundamentalFromMotionAndCameras
  was a dead-code delete (no replacement migration), unlike the
  other 3 dropped helpers which got migrated to native (review #1
  generic).
- track_establishment_glomap.h: file-level note explaining why the
  glomap_ra namespace alias is kept post-collapse (binding-path
  stability — pycolmap submodule + videosfm import paths).

* estimators: D1 dedup loss_config + strip rot-prone comment tags

Two cleanups bundled.

D1 (loss_config dedup, audit_dedup.md):
- LossFunctionConfig{name, scale, weight} (string-based) replaced with
  LossConfig{type, scale, weight} where type is the native
  CeresBundleAdjustmentOptions::LossFunctionType enum.
- Free CreateLossFromConfig factory dropped; LossConfig::CreateLossFunction()
  now reuses native colmap::CreateLossFunction(type, scale) plus a
  3-line ScaledLoss(weight) wrap.
- Native colmap::CreateLossFunction was previously in an anonymous
  namespace inside bundle_adjustment_ceres.cc; promoted to a public
  forward declaration in bundle_adjustment_ceres.h so other estimators
  can route through it.
- pycolmap binding: dropped the templated string-based BindLossField
  helper. Each loss field is now a plain def_readwrite returning a
  pycolmap.LossConfig object. videosfm side does the
  string -> LossFunctionType conversion at the bridge boundary.
- arctan support deferred — only used by gravity_refinement (separate TU,
  uses ceres::ArctanLoss directly), never used by GP cascade.

Comment cleanup pass:
- Strip M0..M13 milestone tags, Gate G-X / Gate R-X labels, audit_*.md
  cross-refs, videosfm sets X via Y_options'' caller-knowledge phrases.
- Reduce verbose --- Glomap-fork ... (M5) ---'' banners to short
  purpose lines or drop entirely. Keep WHY / hidden-constraint /
  workaround comments verbatim.
- Touched: global_positioning.{h,cc}, rotation_averaging.{h,cc},
  rotation_averaging_impl.{h,cc}, motion_averaging.cc binding.

Build + smoke clean. videosfm bridge updated in companion commit.

* estimators: delete loss_config.h, fold LossConfig into global_positioning.h

Follow-up to D1 dedup. The loss_config.h after the rewrite was just a
~25-line struct definition with one consumer (GlobalPositionerOptions).
Inline it into global_positioning.h next to PointConstraintType so the
GP-specific helpers live together.

- LossConfig struct + CreateLossFunction() method moved to
  colmap/estimators/global_positioning.h.
- loss_config.h deleted; CMakeLists.txt entry removed.
- Includers (global_positioning.cc, motion_averaging.cc binding) drop
  the include — global_positioning.h covers it transitively.

Net: 1 fewer header in the tree.

* estimators: extract LossFunctionType + factory into ceres_loss.{h,cc}

Lift the loss-creation primitive out of bundle_adjustment_ceres.{h,cc}
into a dedicated colmap/estimators/ceres_loss.{h,cc} so it's a true
shared utility — not a private detail of CeresBundleAdjustmentOptions.

Changes:
- New colmap/estimators/ceres_loss.{h,cc}: top-level
  colmap::LossFunctionType enum + colmap::CreateLossFunction(type, scale)
  factory. Definition moved verbatim from bundle_adjustment_ceres.cc.
- bundle_adjustment_ceres.h: CeresBundleAdjustmentOptions::LossFunctionType
  is now a back-compat using alias to colmap::LossFunctionType;
  the now-redundant forward declaration of CreateLossFunction is dropped.
- bundle_adjustment_ceres.cc: definition deleted (moved to ceres_loss.cc).
- global_positioning.h: GlobalPositionerOptions gains configurable
  loss_function_type + loss_function_weight fields. Default
  HUBER preserves the previous hardcoded behavior. CreateLossFunction()
  body now mirrors BA — dispatch via the factory + ScaledLoss(weight) wrap.
  Drops include of bundle_adjustment_ceres.h in favor of ceres_loss.h.
- pycolmap motion_averaging.cc: expose loss_function_type +
  loss_function_weight on GlobalPositionerOptions. The existing
  pycolmap.LossFunctionType enum (bound via BA's binding through the
  alias) is now the canonical Python-side type.

Net effect: vanilla colmap GP becomes loss-configurable like BA. The
fork's 10-bucket cascade (LossConfig) still uses the same enum — now
referenced via the top-level type rather than nested inside BA options.

* scene/image: fix Image copy/operator= dropping pixel_cholesky_xy_

Audit catch (parallel agents auditing fork-modified native files for
non-fork drift). The pre-existing fork copy-ctor and operator= for
colmap::Image carried all the per-feature fork fields (depth_priors,
angular_stddevs, is_inlier, is_excluded, is_track_anchor,
is_depth_outlier, etc.) but missed the more recently added
pixel_cholesky_xy_ private member. Same class of bug as the documented
`cam_from_world` regression in
memory/glomap_ra_port_lto_fp_nondeterminism.md — any py::cast roundtrip
or value-copy of an Image silently zeroed the field, breaking pose-cov
covariance plumbing for downstream consumers.

Also reorders the mem-init list to match the header declaration order.

Cosmetic-only follow-ups in the same audit pass:
- estimators/global_positioning.cc: dedent the extracted
  AddObservationToProblem body by 2 spaces (was inheriting the
  original loop-body indentation; cosmetic-only, no semantic effect).
- scene/track.h: collapse stray double blank line.

Build clean; default-constructed Image still works.

* estimators: D2 dedup — drop ScalePriorError/LogScalePriorError for native primitives

Per audit_dedup.md D2: replace the two fork cost functors in
cost_functions/metric_depth.h:182-244 with native
CovarianceWeightedCostFunctor<NormalPriorCostFunctor<1>> from
cost_functions/utils.h.

Mathematically equivalent:
- ScalePriorError(prior=1, sigma): r = (s - 1) / sigma
- LogScalePriorError(sigma_log):   r = log_s / sigma_log
- Native: CovarianceWeightedCostFunctor<NormalPriorCostFunctor<1>>::Create(cov, prior_vec)
  with cov = sigma², prior_vec = (1.0) for linear or (0.0) for log.

The linear/log selection in global_positioning.cc:226-232 collapses to
a single Create() call where the only branch is which constant the
prior_vec carries (0.0 vs 1.0).

Net: -73 LOC in metric_depth.h (the 2 functor bodies + their factories
+ docstrings), +9 LOC in global_positioning.cc (vec construction +
Create call). Build clean; ScalePriorError/LogScalePriorError no
longer exposed.

* estimators: D4 dedup — drop WeightedBATADirectionalError for native CWCF<BATA>

Per audit_dedup.md D4: replace the fork's anisotropic-weighted BATA
functor with native CovarianceWeightedCostFunctor<BATAPairwiseDirectionCostFunctor>.

The fork rotated the residual into camera frame inside the functor:
    r_fork = diag(1/sigma) * R * (t_obs - s*(c2-c1))

Native CWCF whitens with sqrt(cov^-1) of a passed-in covariance.
Encoding the rotation in the world-frame covariance:

    cov_world = R^T * diag(sigma^2) * R

makes ||r_native||^2 == ||r_fork||^2 (since both equal
r_world^T * cov_world^-1 * r_world). Cost contribution is identical;
residual vectors differ but Ceres loss functions act on the squared
norm so the optimization converges to the same minimum.

Why R can be baked at construction time: GP rotations are frozen
post-RA. Ceres parameter blocks are (position1, position2, scale)
only — rotation is never updated by the optimizer.

Made BATAPairwiseDirectionCostFunctor CWCF-compatible by adding the
2-line metadata (kNumResiduals + kParameterDims) the wrapper expects.
This parallels what NormalPriorCostFunctor already does and is an
upstream-friendly addition.

Net: -60 LOC functor body in motion_averaging.h, +14 LOC at the
dispatch site (cov_world construction + CWCF::Create call).
WeightedBATADirectionalError no longer exposed.

Risk: medium — WeightedBATADirectionalError was the M5 critical fix
that recovered ATE 0 -> 39. Math is provably equivalent in cost, but
autodiff Jacobian path differs vector-wise so FP precision may
shift slightly. Needs sample-10 LaMAR HGE ATE check before merge.

* sfm: Phase 5 dedup — extract track-establishment core into shared helper

Per audit_glomap_files_vs_colmap4.md Phase 5 (Strategy A): both native
GlobalMapper::EstablishTracks and fork TrackEngine::EstablishFullTracks
implemented the same union-find + intra-image consistency + length
filter + greedy subsample algorithm over post-RANSAC corr-graph
inliers. Extract the core into a new shared helper so both consumers
route through the same code; fork's LC second-pass +
2-view-depth-prior gate stay as fork-specific extensions.

New: src/colmap/sfm/track_establishment.{h,cc} (~210 LOC)
- struct TrackEstablishmentOptions{intra_image_consistency_threshold,
  min_num_views_per_track, required_tracks_per_view}
- using MatchPredicate = std::function<bool(image_t, point2D_t,
  image_t, point2D_t)> — optional, returns true to skip a match in
  the UF phase. Used by fork to skip LC inliers in the regular pass.
- EstablishTracksFromCorrGraph(valid_pair_ids, corr_graph,
  image_id_to_keypoints, options, ignore_match) — returns
  unordered_map<point3D_t, Point3D>.

GlobalMapper::EstablishTracks shrinks from ~135 LOC to ~30 LOC of
orchestration: builds the keypoint map + collects valid pair ids
from pose_graph, calls the helper, commits the result via
AddPoint3D. No behavior change for native callers — predicate
defaults to nullptr (no LC skip), options carry the same defaults
as the previous inline impl.

TrackEngine::EstablishFullTracks now:
- Builds image_id_to_keypoints from images_ (fork's Image.features
  rather than reconstruction's points2D — pre-rec state).
- Pre-computes an LC observation set from view_graph.image_pairs[*]
  inliers + are_lc, encoded as pair<uint64_t, uint64_t> for std::set
  lookup.
- Calls EstablishTracksFromCorrGraph with an ignore_match predicate
  that skips matches in the LC set.
- Calls existing ProcessLoopClosurePairs as the post-pass to populate
  Track::lc_elements (and mint two new tracks per LC-only observation
  pair, with obs-key-derived ids that don't collide with native's
  dense [0, N) range since image_id<<32 >= 2^32).

Deleted from track_establishment_glomap.{h,cc}:
- TrackEngine::BlindConcatenation (~55 LOC)
- TrackEngine::TrackCollection (~110 LOC)
- UnionFind<image_pair_t> uf_ member
- Now-unused colmap/math/union_find.h include
Total: ~165 LOC removed from fork; ~210 LOC added in the new shared
helper (most of which is moved-not-new code).

Backward compat:
- Native API gains an optional ignore_match parameter defaulting
  to nullptr — vanilla callers see identical behavior.
- Fork API unchanged (TrackEngine::EstablishFullTracks signature +
  return shape preserved).
- pycolmap binding unchanged.

Risk: medium — core algorithm is byte-equivalent (verbatim move of
the loop bodies), but unordered_map iteration order may differ
between native's local instance and fork's instance, which can
produce a different greedy-subsample tiebreak. Default options
have required_tracks_per_view=INT_MAX (no-op subsample), so
iteration order doesn't matter for native. Fork's caller passes
require_tracks_per_view=INT_MAX as well at this stage; the actual
greedy subsample lives in FindTracksForProblem (untouched).

Needs sample-10 LaMAR HGE ATE check before merge.

* track_establishment: iterate image_pair.matches/inliers directly

Phase 5 first-run verify revealed that ExtractMatchesBetweenImages reads
from CorrespondenceGraph::flat_corrs which is only populated by
FinalizeAfterMatchingComplete (native pipeline's geom-verify step). The
fork-driven videosfm pipeline writes inliers directly into
ImagePair::matches + ImagePair::inliers without going through
flat_corrs, so the helper saw zero matches and produced zero tracks.

Fix: iterate ImagePair::matches indexed by ImagePair::inliers directly.
Both the native colmap4 pipeline (which populates these fields during
geom-verify) and downstream pipelines that populate them via custom
relative-pose flows (e.g. videosfm's MDRP) work through this path.

Drops ExtractMatchesBetweenImages dependency entirely; drops the
feature/types.h include that came with FeatureMatches.

Verified end-to-end: sample-10 LaMAR HGE produces 25/25 registered
images with AUC 73.69/86.85/97.37 @ {0.05, 0.1, 0.5}m. Tier-1
structural identity holds through GP1+GP2 (n_tracks, total_observations,
total_lc_observations all match m13 baseline byte-for-byte at every
checkpoint).

* tests: 31 new gtest cases for fork additions + dedup correctness

Spread across 4 files:

- src/colmap/estimators/cost_functions/motion_averaging_test.cc (+9 tests)
  CWCF<BATAPairwiseDirectionCostFunctor> math equivalence with random
  inputs (rotation, sigma, c1, c2, scale, t_obs); zero-residual edge
  case; identity-rotation isotropic-sigma reduction. Algebraically
  proves D4 dedup (cov_world = R^T diag(sigma^2) R) preserves the
  squared residual norm of the deleted WeightedBATADirectionalError.

- src/colmap/sfm/track_establishment_test.cc (+5 tests, NEW file)
  Phase 5 helper EstablishTracksFromCorrGraph: 3-pair triangle smoke
  test, length filter drops short tracks, intra-image-consistency
  drop, ignore_match predicate, empty-input no-crash.

- src/colmap/estimators/cost_functions/metric_depth_test.cc (+7 tests, NEW)
  MetricDepthError: zero residual when prior matches predicted depth;
  linear vs log residual mode formulas; zero_residual_behind_camera
  gate; log-scale parameterization; 5-toggle smoke; rejects
  non-positive sigma.

- src/colmap/estimators/global_positioning_test.cc (+4 tests)
  LossConfig::CreateLossFunction(): TRIVIAL/HUBER/CAUCHY/SOFT_L1
  dispatch through native colmap::CreateLossFunction; weight != 1
  triggers ScaledLoss wrap (verified by 2x scaling on output rho).

CMakeLists entries added for the two new test files; existing
TESTS_ENABLED=ON build picks them up. All 4 ctest groups pass.

* track_establishment_glomap: harden LC-track id minting

Phase 5 left ProcessLoopClosurePairs minting new tracks via
static_cast<track_t>((image_id<<32)|feature_id). With image_id >= 1
this lands ids >= 2^32, well above native's dense [0, N) range, so
collisions are theoretical-only. Switch to sequential ids continuing
from native's max — eliminates the magic encoding and makes the
THROW_CHECK guard truly defensive rather than load-bearing.

* tests: 34 more gtest cases for fork-unique paths + backward-compat gates

Round 2 of unit-test additions (6 areas, 34 tests). Driven by the
'genuine coverage gaps' list — fork-unique algorithms previously
tested only via end-to-end pipeline.

ProcessLoopClosurePairs (track_establishment_test.cc, +5):
  4 branches of LC second-pass + sequential-id no-collision smoke.

FindTracksForProblem (track_establishment_test.cc, +6):
  Length filter, 2-view depth-prior gate (drop + keep + boundary),
  greedy quota, min_num_tracks_per_view default-bug enshrined.

TrackFilter (track_filter_test.cc, NEW, +7):
  FilterTracksByAngle aligned/misaligned/uncalib-double-thresh +
  point-behind-camera; FilterTrackTriangulationAngle parallel/perp +
  threshold sweep.

RelativeRotationError (rotation_averaging_test.cc, +3):
  Zero residual when consistent, non-zero on injected angular error
  matches the discrepancy norm, swap+invert symmetry.
  Required moving the struct from anonymous namespace in
  rotation_averaging_impl.cc into rotation_averaging_impl.h so tests
  can construct it directly.

ImagePairsInlierCount (image_pair_inliers_test.cc, NEW, +5):
  4-gate degeneracy (epipolar, cheirality, epipole-angle, depth-prior
  vs nodepth threshold). All 4 gates exercised on the essential-matrix
  path with controlled synthetic inputs.

Backward-compat gates G-1/G-2/R-1 (+8):
  G-1 (use_observation_exclusions) off/on/partial — global_positioning_test.cc
  G-2 (use_lc_observations) off/on — global_positioning_test.cc
  R-1 (prioritize_tracking_in_mst) off/on/no-corr-graph-fallback — rotation_averaging_test.cc.
  Required exposing ComputeMaximumPoseGraphSpanningTree as a public
  symbol in rotation_averaging.h (was anon-namespace).

Net: 65 total unit tests across 8 ctest groups, all passing.

* sfm: rename glomap_ra namespace -> sfm_ext

The ``colmap::glomap_ra::`` namespace and pycolmap.glomap_ra submodule
were named after the original rotation-averaging port from glomap, but
their current contents are track establishment, track filtering, and
pair-inlier scoring — none of which are RA. Rename to ``sfm_ext``
(SFM extensions) so the label reflects the actual contents.

Mechanical sweep across 11 C++ files + 4 binding files:
- ``namespace glomap_ra { ... }`` -> ``namespace sfm_ext { ... }``
- ``glomap_ra::Foo`` -> ``sfm_ext::Foo`` everywhere
- ``GetOrCreateGlomapRaModule`` -> ``GetOrCreateSfmExtModule``
- ``m.def_submodule("glomap_ra")`` -> ``m.def_submodule("sfm_ext")``

Python alias kept for back-compat: pycolmap/__init__.py defines
``glomap_ra = sfm_ext`` so existing videosfm callers
(``pycolmap.glomap_ra.filter_tracks_by_angle`` etc.) keep working
unchanged. Filenames keep the ``_glomap`` suffix for now — file rename
is a separate, larger churn.

Verified:
- Full build clean (warm).
- 8/8 ctests pass: track_establishment_test, track_filter_test (7/7),
  image_pair_inliers_test, motion_averaging_test, metric_depth_test,
  global_positioning_test, rotation_averaging_test (controllers +
  estimators).
- pycolmap import + alias intact: pycolmap.glomap_ra is pycolmap.sfm_ext,
  filter_tracks_by_angle resolves on both.
- videosfm GlobalPositioningPhase imports OK with the alias.

* comments(sfm_ext): drop stale glomap_ra / pyglomap / vendored references

Cleans up post-rename comment debt:
- image_pair_inliers_glomap.h: remove TODO that already shipped (Sampson/H errors now route through native helpers); drop 'vendored from glomap/types.h' framing on InlierThresholdOptions.
- track_filter_glomap.cc: replace 'glomap RA pipeline' / 'RA-shadow' wording with neutral 'dict-of-tracks state used by sfm_ext'.
- pycolmap/sfm/track_filter_glomap.cc: drop pyglomap.* Python-name mentions (module no longer exists).
- pycolmap/sfm/track_establishment_glomap.cc: drop 'Glomap-style' from establish_full_tracks docstring.

* comments(pycolmap): drop residual pyglomap migration archaeology

Second comment-cleanup pass beyond the sfm_ext-renamed files. Strips
'mirrors pyglomap.X', 'byte-for-byte parity with pyglomap', 'so the
videosfm pyglomap shim can be retired' framing now that pyglomap is
gone and the videosfm shim is removed.

Preserved: design rationale (e.g. issue #40 reference for adopting
CalibrateViewGraph wrapper, pybind11 deep-copy gotcha), open
algorithmic TODOs unrelated to the rename.

* sfm: fold sfm_ext::TrackEngine into native GlobalMapper / track_establishment

Removes the parallel colmap::sfm_ext::TrackEngine path. The fork-specific
behaviors that lived in track_establishment_glomap.{h,cc} now live as
native free functions in track_establishment.{h,cc} and as methods on
GlobalMapper. Net diff -300 LOC; one less namespace; one less file pair.

Native types already carried the data (Track::lc_elements,
ImagePair::are_lc, Image::depth_priors / depth_prior_validity were added
during the M0-M13 port). This commit fills in the missing code paths:

New free functions in colmap::namespace (track_establishment.{h,cc}):
- MakeLoopClosureMatchPredicate(valid_pair_ids, corr_graph) - returns a
  MatchPredicate that excludes ImagePair::are_lc[idx]==true inliers from
  the union-find pass, pre-computing the LC set in O(num inliers).
- AppendLoopClosureObservations(valid_pair_ids, corr_graph, tracks) -
  4-branch second pass that populates Track::lc_elements (no-track /
  both-tracks / one-track-each / same-id, mints new tracks at
  max(track_id)+1). Operates on a tracks dict so binding + GlobalMapper
  share the call site.
- struct TrackSubsampleOptions + SubsampleTracks(...) - depth-aware
  greedy length-sorted subsample. Reads depth_priors / depth_prior_validity
  / registered_image_ids passed as separate maps to keep the function
  Reconstruction-free. Asymmetric length filter preserved (lower bound
  counts Length+lc_elements, upper bound counts Length only).

GlobalMapper extensions (global_mapper.{h,cc}):
- GlobalMapperOptions gains 4 fields (default-off so vanilla colmap
  behavior is unchanged): track_lc_second_pass, track_two_view_depth_gate,
  track_max_num_views_per_track, track_max_num_tracks.
- EstablishTracks runs MakeLoopClosureMatchPredicate +
  AppendLoopClosureObservations when track_lc_second_pass=true; bypasses
  the helper-side greedy subsample in that mode.
- New SubsampleTracksForProblem method bridges Reconstruction <-> dict and
  calls SubsampleTracks.

Pycolmap binding (track_establishment_glomap.cc - file kept, contents
rewritten to use native API):
- pycolmap.establish_full_tracks(view_graph, images, options,
  lc_second_pass=False) - top-level binding (path preserved). Takes the
  native pycolmap.TrackEstablishmentOptions; lc_second_pass optional
  kwarg toggles the LC second pass.
- pycolmap.find_tracks_for_problem(view_graph, images, tracks_full,
  options) - takes pycolmap.TrackSubsampleOptions (new bound struct).
- The old pycolmap.TrackEstablishmentOptions (fork shape) is gone. The
  native TrackEstablishmentOptions has 3 fields
  (intra_image_consistency_threshold, min_num_views_per_track,
  required_tracks_per_view); the 5 subsample knobs (max_num_views_per_track,
  max_num_tracks, two_view_depth_gate, etc.) live on the new
  TrackSubsampleOptions.

Tests (track_establishment_test.cc):
- All 16 tests preserved (5 native + 6 FindTracksForProblem-now-SubsampleTracks
  + 5 ProcessLoopClosurePairs-now-AppendLoopClosureObservations).
- MinTracksPerViewBugDocumentation rewritten to describe the new int-typed
  INT_MAX default (signed comparison never trips) instead of the old
  signed/unsigned -1 -> UINT_MAX promotion. Same observable behavior.

Adjacent (track_filter_glomap.{h,cc} + binding + tests):
- Repointed track_t / feature_t typedef include from the deleted
  track_establishment_glomap.h to colmap/util/types.h (the canonical
  point3D_t / point2D_t aliases). track_t / feature_t aliases are gone.

Files DELETED:
- src/colmap/sfm/track_establishment_glomap.h (-82 LOC)
- src/colmap/sfm/track_establishment_glomap.cc (-311 LOC)

Verified: scripts/build.sh clean (cold + warm). 7 ctest groups pass
(scene/track, scene/image, sfm/track_establishment, sfm/track_filter,
sfm/image_pair_inliers, estimators/global_positioning, estimators/
rotation_averaging) - 100% in 0.3s.

* sfm: drop _glomap suffix + sfm_ext namespace from remaining fork files

Three-stage cleanup mirroring the TrackEngine fold (commit 77c0574b).
Net result: ZERO _glomap files in colmap/sfm/, ZERO sfm_ext namespace
references. All fork-extension code now lives in plain colmap::
namespace alongside upstream code, with no naming tax.

Stage 1 - two_view_geometry_glomap deletion (-79 LOC):
- 3 helpers (CheckCheirality single-pair, GetOrientationSignum,
  SampsonError Vec3 depth-aware) had ONE caller each
  (image_pair_inliers). Folded as anonymous-namespace statics in the
  consumer file. Deleted two_view_geometry_glomap.{h,cc} +
  CMakeLists entry.

Stage 2 - track_filter_glomap rename (no LOC delta, +/- ~250):
- Renamed src/colmap/sfm/track_filter_glomap.{h,cc} -> track_filter.{h,cc}.
- Dropped sfm_ext:: namespace -> plain colmap::.
- Converted struct TrackFilter::FilterX static methods to free
  functions FilterTracksByAngle / FilterTrackTriangulationAngle.
- Pycolmap binding: pycolmap.sfm_ext.filter_tracks_by_angle ->
  pycolmap.filter_tracks_by_angle (top-level). Same for
  filter_track_triangulation_angle. Test file + downstream callers
  updated.

Stage 3 - image_pair_inliers_glomap rename (no LOC delta):
- Renamed src/colmap/sfm/image_pair_inliers_glomap.{h,cc} ->
  image_pair_inliers.{h,cc}.
- Dropped sfm_ext:: namespace -> plain colmap::.
- Behavior preserved verbatim (hard-coded thres_epipole_nodepth,
  disabled Gate-3 angle check, F-branch positive_count==negative_count
  silent drop). TODOs for later cleanup left in-place.
- Pycolmap binding: pycolmap.image_pairs_inlier_count was already
  top-level; renamed BindImagePairInliersGlomap -> BindImagePairInliers.
- Renamed src/pycolmap/sfm/track_establishment_glomap.cc ->
  track_establishment.cc; renamed BindTrackEstablishmentGlomap ->
  BindTrackEstablishment. Same fold treatment.

Pycolmap top-level surface (no sfm_ext submodule any more):
- pycolmap.establish_full_tracks / find_tracks_for_problem
- pycolmap.filter_tracks_by_angle / filter_track_triangulation_angle
- pycolmap.image_pairs_inlier_count
- pycolmap.TrackEstablishmentOptions / TrackSubsampleOptions / InlierThresholdOptions

Removed back-compat alias pycolmap.glomap_ra = pycolmap.sfm_ext (no
remaining callers; sfm_ext submodule itself is gone).

Verified: scripts/build.sh clean. 7 ctest groups pass (scene/track,
scene/image, sfm/track_establishment, sfm/track_filter,
sfm/image_pair_inliers, estimators/global_positioning, estimators/
rotation_averaging) - 100% in 0.29s.

* comments: drop stale sfm_ext / TrackEngine mentions in pycolmap bindings

* rotation_averaging: drop dead RotationEstimatorOptions::gravity_dir field

Audit showed gravity_dir is exposed in the binding (motion_averaging.cc)
+ declared in the struct but has ZERO consumers in
colmap/src/colmap/estimators/. Setting it does nothing — pure footgun
for anyone who flips it expecting gravity-aware behavior. Pose-prior
data is supplied separately via the run_rotation_averaging
`pose_priors` argument; this field never participated in the up-vector
math.

The ten other dead-flag suspects from the prior audit
(use_precomputed_weights, fix_non_lc_weights, fixed_non_lc_weight,
consecutive_trivial, use_relative_pose_constraints, relative_pose_default_stddev,
loss_relative_pose, debug_only_relative_pose, scale_regularization_sigma,
image_ids_to_optimize) are already GHOSTS — not on the struct, not in
the binding, not in videosfm. No additional cleanup needed.

* global_positioning: option flag for uncalibrated 0.5x ScaledLoss; fix BindImagePairInliers definition

Adds GlobalPositionerOptions::apply_uncalibrated_loss_downweight (bool,
default true). When true, AddPointToCameraConstraints wraps the BATA
loss in ScaledLoss(0.5) for cameras whose focal length lacks an EXIF
prior (Camera::has_prior_focal_length == false) - the mainline colmap4
heuristic for downweighting bearings that depend on a VGC-estimated
focal. When false, all cameras get full-weight residuals, matching the
glomap fork's behavior.

Default true preserves upstream colmap behavior; videosfm callers
calibrated against fork-flavored full-weight residuals can flip the
flag false at the bridge level (companion videosfm commit wires this
through GPOptions).

Bound in motion_averaging.cc.

Also fixes a leftover from the earlier _glomap rename (commit
db8a5a0d): src/pycolmap/sfm/image_pair_inliers.cc still defined the
binder as BindImagePairInliersGlomap while bindings.cc was already
calling BindImagePairInliers, producing an undefined-symbol linker
error any time the binding got fully relinked. The earlier build
succeeded only because the .so was cached. Rename the definition to
match.

* global_positioning + RA: hardening + comment cleanup

Three small hardening additions + comment-text cleanup across the
GP/RA C++ surface:

- rotation_averaging_impl.cc: SolveIRLS now mirrors SolveL1's NaN-guard
  on the step vector. Without this, a singular pose-graph silently
  corrupts cams_from_world via UpdateState and the IRLS loop returns
  true with garbage residuals on the next iteration. L1 already had
  this guard at line 755; IRLS didn't.

- rotation_averaging_impl.cc: RotationAveragingProblem ctor adds a
  THROW_CHECK that 'skip_risky_LC_pairs=true' requires a
  CorrespondenceGraph. Previously the LC-skip filter silently never
  triggered when CG was nullptr - the option appeared to work but
  did nothing.

- image.h: Image::operator== now also compares pixel_cholesky_xy_.
  This is the field that triggered the earlier copy-ctor field-drop
  bug fix (commit 985b2ecb); including it in equality means a future
  unit test of the form 'EXPECT_EQ(copy, original)' would catch an
  analogous regression. Other fork-extension fields (depth_priors,
  is_inlier, cam_from_world, etc.) are intentionally NOT added -
  those are mutable pipeline state, not identity-defining.

- global_positioning.h: cleaned 'glomap fork' / 'videosfm callers'
  vocabulary leakage in the apply_uncalibrated_loss_downweight
  comment; the comment now describes what the bool does without
  referencing internal nomenclature. Also added a doc note that the
  10 cascade LossConfigs only run under SPLIT_METRIC_DEPTH (BATA mode
  silently ignores them - prevents future caller from misconfiguring
  these in BATA mode and wondering why nothing changes).

* pycolmap: rename view_graph -> correspondence_graph in bindings

Match the C++ class name (CorrespondenceGraph). Aligns variable names,
parameter names, and dict keys with the type so callers see one term.

* rotation_averaging: drop RotationEstimatorOptions::prioritize_tracking_in_mst

The MST initializer's LC-edge penalty branch was a separate flag from
the video-aware Ceres solver, but they were always enabled together
(the fork wired prioritize_tracking_in_mst directly off
use_video_constraints). Collapse: ComputeMaximumPoseGraphSpanningTree
now reads options_.use_video_constraints, deleting the redundant flag,
its pycolmap binding, and its option-struct field. The function-level
prioritize_tracking parameter stays, callable directly by unit tests.

* rotation_averaging_test: rename Gate_PrioritizeTrackingInMst -> Gate_LcPenaltyMst

The flag was removed in 423c6e58; the tests now exercise the function-
level prioritize_tracking parameter directly. Rename the test cases
and the comment block to match the surviving concept (LC-penalty
branch in ComputeMaximumPoseGraphSpanningTree).

* ceres_loss + run_global_positioning: review follow-ups

- CreateLossFunction: replace silent `return nullptr` on unhandled
  enum with LOG(FATAL). The previous fall-through would crash with a
  nullptr deref downstream; failing at the source surfaces an enum
  extension oversight immediately.

- run_global_positioning binding: drop the duplicate
  ``dmap_scale_map_nested`` result key. It was a literal alias of
  ``dmap_scale_map`` — same py::dict assigned twice. Caller now reads
  the single key.

* comments: drop residual glomap-fork / videosfm vocabulary

Comment-only sweep across estimators / sfm / scene headers and
impls. Rewrites describe what the code does instead of where it
came from. No identifier or behavior changes.

* rotation_averaging: hoist SetPRNGSeed out of ComputeResiduals

ComputeResiduals is called from L1 and IRLS inner loops. The seed
call inside it reset the global PRNG to the same value every
iteration, replaying the identical jitter sequence in
ComputeGravityAligned1DOFResidual. Move the seed call to
RotationAveragingSolver::Solve (once per problem solve) so the
PRNG advances naturally across iterations — matches the fork's
behavior of seeding once at the GP/RA boundary.

* rotation_averaging: seed MST root + use inlier count for edge weight

Two fork-equivalent fixes to InitializeFromMaximumSpanningTree:

1. Seed cams_from_world[root] from the reconstruction's MDRP-prior
   pose before BFS-propagating relative rotations. The previous
   default-constructed identity seed rotated every descendant by
   R_root^-1 vs the prior gauge, which can shift IRLS weights through
   the max_rotation_error_deg filter.

2. Edge weight in ComputeMaximumPoseGraphSpanningTree now reads
   inliers.size() from the CorrespondenceGraph (post-RANSAC survivor
   count) when CG is plumbed through, falling back to edge.num_matches
   (raw match count) for vanilla colmap callers without a CG. Matches
   fork's MST weight semantic.

Test fixture updated: pair inliers vectors are now sized to match the
edge num_matches values so both weight sources agree on the MST shape.

* global_positioning: port InitializeDepthMapScalesFromObservations from fork

Restores the median-z_est/depth_prior seeding the fork ran at GP1
entry. Without this, GP1 lazy-inserts dmap_scales_ at 1.0 (linear) /
0.0 (log) for every image — the optimizer starts in a different basin
than the fork.

Source: glomap/glomap/estimators/global_positioning.cc:1006-1133.
Adapted to colmap4's Reconstruction model: iterates Points3D' track
elements (and lc_elements when use_lc_observations is set) to
accumulate per-image z_est/depth_prior samples, takes the median per
image, and seeds dmap_scales_ with log(median) or linear median per
use_log_scale_for_depth_map_scales.

Call site in Solve() is gated on SPLIT_METRIC_DEPTH + use_init +
initial_dmap_scales empty, so GP2 (which supplies initial_dmap_scales
from GP1 output) bypasses this path. Images without observations
consumed here still fall through to the lazy-insert path in
AddObservationToProblem.

* comments: drop residual migration-archaeology vocabulary

Sweep upstream-bound C++ comments / docstrings / test-file
references for "pre-port", "post-port", "M1-M13" milestone tags,
"glomap-fork", "fork-only", "videosfm-side", and the
videosfm-private issue #40 reference (incl. one runtime warning
string). Rewrites describe what the code does now. One identifier
rename: fork_sqnorm -> expected_sqnorm in motion_averaging_test.cc.
No behavior change.

* view_graph_manipulation: skip UNCALIBRATED-pair upgrade when cam2_from_cam1 is null

The original port of glomap's UpdateImagePairsConfig added a
THROW_CHECK around the UNCALIBRATED -> CALIBRATED upgrade requiring
``tvg.cam2_from_cam1.has_value()``. Pairs whose decomposition never
produced a relative pose (e.g. F-only RANSAC path or decomposition
failure) crash the pipeline at this site.

The fork's ImagePair carried cam2_from_cam1 as a Rigid3d (always
default-constructible to identity) so it never tripped this check;
colmap's TwoViewGeometry::cam2_from_cam1 is std::optional<Rigid3d>
so absence is real and observable.

Replace the THROW_CHECK with `continue`. Pairs without a relative
pose stay UNCALIBRATED and are dropped by downstream filters
(inlier-num / inlier-ratio) instead of crashing. Verified end-to-end
on V2-adaptive sample-10 HGE.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant