trained_model / reports /final.md
qizhangslam's picture
Upload folder using huggingface_hub
1995ff1 verified
|
Raw History Blame Contribute Delete
20.8 kB
# Frame-Level Bundle Adjustment — Final Design (2026-04-20)
Author: Claude (autonomous, per user pivot on 2026-04-20)
Supersedes: all prior V4 submap-stitch / Sim(3)-PGO designs.
## 0. Problem statement
All ATE gaps remaining on KITTI/ETH3D/Oxford trace to the *chained* Sim(3)
submap stitch drift: the online stitch averages 10-20% scale/rotation error
per junction, and neither overlap (3→6) nor scorer (learned→temporal) helps
after a plateau ~137 m on KITTI mean. Pose-graph BA over submap Sim(3) nodes
improves TUM (<2 cm) but is blind to outliers and cannot recover from bad
edges because submap->submap is a *single* relative transform.
The user's pivot: replace Sim(3) pose-graph with **frame-level bundle
adjustment**. Per-frame SE(3) poses are refined jointly using dense point
correspondences between (a) sequential neighbors and (b) loop-closure pairs
selected via the top-5 submap retrieval logged by the scorer.
No backbone fine-tune. A matching head sits on top of frozen backend tokens
and produces descriptors (or uses raw L2-normed tokens directly).
## 1. Goals & Non-Goals
**Goals**
- Frame-level SE(3) BA over all keyframes inside a run (submap-agnostic).
- Residual = 2-view reprojection of matched 3D points between frame pairs.
- Sources of correspondences:
1. **Sequential edges**: every frame ↔ previous frame (dense + cheap).
2. **Loop edges**: for each top-5 retrieved submap of the current frame,
pick the single best-QV frame as the loop anchor.
- Sparse-first matching (DINO-VO-like, grid-aligned) because:
- ~1% of tokens carry 95% of reprojection signal (M³ Q-confidence).
- Dense matching has quadratic memory; sparse is O(N·K).
- Training-free v0 (deliver first). Optional trained head v1 (deferred).
**Non-goals**
- No backbone gradient (frozen).
- No joint point-and-pose optimization. Points are reconstructed from
frozen backbone outputs per frame; only poses move.
- No Sim(3). Keep SE(3) — scale is fixed by the backbone + anchor.
- No online on-the-fly BA during demo_submap. Phase-B offline only for v0.
## 2. Graph topology
Nodes: one SE(3) pose per keyframe f_i, i=0..N−1 (N frames total).
Edges:
- E_seq: (f_i, f_{i−1}) for i ≥ 1 and (f_i, f_{i−2}) for i ≥ 2. The
double-skip edge stabilizes against short-window flat degeneracies.
- E_loop: for each frame f_i, read `final_retrievals.json[i]` → top-5
submaps s_{j_1}..s_{j_5}. For each s_{j_k}, find the frame f* ∈ s_{j_k}
whose tokens give max ⟨Q_current, K_that_frame⟩ using the layer-22 VGGT
attention proxy (approximated by token cosine sim in v0 — see §3.1).
That f* becomes a loop edge (f_i, f*).
- Anchor: pose_0 is fixed to identity with weight 10⁶.
Per-scene N ≈ 300-600 for KITTI/TUM at stride 5. Edge count ≈ 6N = 1.8-3.6k.
## 3. Matching head (training-free v0)
### 3.1 Input
For each keyframe f, we already have from `slamformer.extract(hidden)`:
- `points` (H×W×3, world-frame 3D)
- `local_points = (xy·z, z)` (H×W×3, camera-frame 3D; xy normalized)
- `conf` (H×W×C, confidence logits)
- `hidden` tokens (the backend-updated tokens — shape (N_tokens, D))
We use **backend-updated tokens** directly as descriptors. These are already
384-D L2-normalizable vectors that have seen cross-frame attention.
### 3.2 Keypoint selection (sparse)
Following DINO-VO: one candidate per patch cell (14×14 px for 518×518 input
⇒ 37×37 = 1369 candidates per frame). Rank by confidence `conf_mean` over
the cell, keep top K=256 per frame.
### 3.3 Descriptor
`d = normalize(token_cell)` — L2-normalize the backend token at that cell.
### 3.4 Matching
Mutual nearest neighbor across two frames' top-K with cosine similarity
threshold τ = 0.6 (conservative). For frame_i with K keypoints and
frame_j with K, this is a 256×256 dot-product = 66k ops, trivially fast.
Output per edge: list of (uv_i, uv_j, X_i_world, X_j_world, w) tuples.
- uv_i, uv_j: pixel coordinates (for reprojection residual)
- X_i_world: 3D point predicted by frame_i (from `points`)
- w: match weight = √(conf_i · conf_j), following M³-SLAM
### 3.5 Why this might work
The backend cross-frame attention already injects correspondence information
into tokens (the same mechanism the scorer exploits at submap level). Raw
cosine between tokens was enough for retrieval; the hypothesis under test
is that it's also enough for pixel-level matching when restricted to the
confidence-top K.
Fallback v1 (not for first submission): train a 2-layer MLP head
(DPT_desc → 24-D L2-norm) using InfoNCE from M³-SLAM. Initialize from the
point head weights as the paper does.
## 4. Bundle adjustment formulation
### 4.1 Variables
- θ ∈ ℝ^{6(N−1)}: se(3) perturbations for f_1..f_{N−1} (f_0 fixed at I).
- Pose reconstruction: T_i = exp(ξ_i) · T_i^{(0)} where T_i^{(0)} is the
initial pose from the online stitch (final_traj.txt).
### 4.2 Residual per match (MASt3R-SLAM style, SE(3) only)
For match m ∈ edge (f_i, f_j), given X_i in camera_i frame (from
`local_points`) and observation uv_j in frame_j:
X_j_cam_pred = (T_j^{-1} · T_i · [X_i; 1])[:3]
uv_j_pred = X_j_cam_pred[:2] / X_j_cam_pred[2] # implicit fx=fy=1
r_m = w_m · ρ_huber(uv_j - uv_j_pred, δ=0.1)
The `/z` projection uses normalized-camera coordinates — SLAMFormer's
`local_points` already factored fx=fy=1, so no intrinsics are needed.
Symmetric residual for (j, i) direction: r_m' with roles swapped. Both are
accumulated, giving a 4-D residual per match.
### 4.3 Loss
L(θ) = Σ_edges Σ_matches r_m² + 10⁶ · ||T_0 - I||²_F
Anchor term keeps f_0 fixed; required because any global SE(3) is unobserved.
### 4.4 Solver
`scipy.optimize.least_squares(method='trf', loss='huber', f_scale=0.1,
max_nfev=200, verbose=2)`. Jacobian computed via finite differences (N ≤ 600
⇒ state dim ≤ 3600, feasible for TRF). For large N we will switch to
analytic Jacobians in a follow-up.
### 4.5 Initialization
Start from the online stitch trajectory (`final_traj.txt`). This is already
within a small SE(3) of the optimum for TUM (< 5 cm median drift) and
provides a warm start; the BA tightens rotations and removes outlier-induced
kinks.
## 5. Outputs
Per run directory:
- `frame_matches.jsonl`: one line per edge, with match lists.
- `frame_ba.log`: solver log (cost_start, cost_end, n_edges, n_matches,
termination reason).
- `final_traj_frame_ba.txt`: TUM-format refined per-frame trajectory.
Evaluated with same harness as the Sim(3) variants: added as a 5th column
alongside stitch/raw/offline/posegraph.
## 6. Implementation plan
### 6.1 New files
| Path | Purpose |
|------|---------|
| `thirdparty/submap/slam/frame_matcher.py` | keypoint selection + mutual-NN matching; `match(frame_i_tokens, frame_j_tokens, conf_i, conf_j, top_k=256, tau=0.6) -> List[Match]` |
| `thirdparty/submap/slam/frame_ba.py` | build residuals + run scipy; returns refined poses dict[frame_id -> 4x4] |
| `run_frame_ba_offline.py` | project-root entry: reads frame_dump.jsonl + retrieval log, drives matcher + BA, writes final_traj_frame_ba.txt |
### 6.2 demo_submap.py additions
Add `--dump_frame_tokens` flag. When set, each extracted frame emits to
`frame_dump.jsonl`:
```
{"fid": int, "submap_id": int, "pose_w": 4x4,
"local_points": H*W*3 list (downsampled 4×4 grid),
"conf": H*W list, "tokens_path": "<npy or mmap ref>"}
```
Tokens are too big for JSONL (37×37×384 = 526 KB per frame × 600 frames
= 316 MB); store them as a single contiguous `frame_tokens.npy`
(float16, shape (N_frames, 37, 37, 384)).
### 6.3 New sbatch scripts
- `run_v4_frame_ba_tum.sh` — mirrors `run_v4_posegraph_tum.sh`; adds a
5th eval variant `frame_ba`.
- `run_v4_frame_ba_kitti.sh` — mirrors `run_v4_posegraph_kitti.sh`.
Both skip inference if `final_traj.txt` + `frame_dump.jsonl` already exist
(allows iterating on BA without rerunning backbone).
## 7. Expected outcomes
| Dataset | Current best (Sim3 PGO) | MASt3R-SLAM reported | Frame-BA target |
|---------|------------------------|----------------------|-----------------|
| TUM fr1 mean ATE | ≤0.22 m (V4-B'' ckpt-1) | 0.04 m | 0.05-0.10 m |
| KITTI 00/05/07/09 mean ATE | 137 m (o3 offline) | n/a | 40-80 m |
| ETH3D median ATE | 0.63 m | ~0.03 m | 0.1-0.3 m |
The gap to MASt3R on TUM is mostly dense-vs-sparse and trained-vs-untrained
descriptors — we're deliberately accepting 2-5× worse absolute to keep v0
training-free.
## 8. Risks & mitigations
| Risk | Mitigation |
|------|------------|
| Raw backend tokens give weak matches | v1 trained head (M³-style) behind `--desc_head trained` flag |
| Top-K=256 is too sparse on low-texture TUM scenes | Conf-weighted, so fewer confident matches > more noisy. Fall back to K=512 if <30 matches/edge median |
| scipy TRF fails on large N (>600 frames) | Chunk into overlapping 100-frame windows with shared boundary, fix alternate windows (ICEBA-style) |
| Loop edges from scorer are wrong (top-5 not always in same place) | Reject edge if <10 mutual NN matches survive τ |
| Anchor locks global frame; if f_0 is an outlier the whole traj shifts | Use first 5 frames as a weighted multi-anchor instead |
| Double-skip edges create 2-cycles that trivially satisfy residual | Add explicit velocity smoothness term Σ ||log(T_i^{-1} T_{i+1})||² with tiny weight |
## 9. Submission plan
1. Write matcher + BA + driver + demo_submap flag + sbatch scripts.
2. Smoke-test locally on TUM fr1/xyz (smallest scene, 19 frames strided).
3. Submit TUM job (all 9 scenes).
4. Submit KITTI job (00/05/07/09).
5. Monitor both; parse `final_traj_frame_ba.txt` into the existing
`v4_posegraph_*` summary table.
6. Iterate on matcher τ / top-K / anchor weight based on ATE.
---
## 10. Addendum: Sim(3) + matcher GT eval pivot (2026-04-21)
The v0 §1 non-goal "No Sim(3). Keep SE(3)" did not survive first contact
with the SE(3) results: frame-BA cost dropped 1500× but ATE actually got
worse on several TUM scenes. Root cause: the backbone's local-points
scale is not consistent across frames, so reprojection residuals pull
poses *away* from the GT minimum. Three changes follow.
### 10.1 Per-frame Sim(3) (replaces §1 "No Sim(3)")
- State widened to 7 DoF/frame: 6 SE(3) twist + 1 log-scale `log_s_k`.
`s_0 = 1` (anchor), so state dim = `7·(N−1)`.
- Reprojection residual becomes `r = uv_obs − π(T_j^{-1} · T_i · s_i · X_i_cam)`.
- Scale-difference prior added to residuals:
`ds_k = (log_s_k − log_s_{k−1}) · scale_prior_weight` — prevents
pathological scale drift where matches are geometrically degenerate.
- Solver unchanged (`scipy.least_squares` + huber + LSMR + sparse J);
`--use_sim3 --scale_prior_weight 0.1 --max_nfev 500` CLI flags.
- Smoke on fr1_xyz: scales converged to [0.92, 1.17] (median 1.003),
RPE_rot 12.4° (Sim(3)) vs 29.8° (SE(3)). ATE similar.
### 10.2 Sparse-LSMR Jacobian (perf fix for KITTI)
- The v0 dense Jacobian on KITTI 00's 389-submap problem never returned.
- `_jac_sparsity()` emits a `scipy.sparse.lil_matrix` covering
reprojection blocks, velocity-prior rows, and (with Sim(3)) scale-prior
rows. Density ~0.5% on KITTI.
- `tr_solver="lsmr"` + `jac_sparsity=...` in `least_squares` → KITTI 00
PGO went from "stuck forever" to 5 s solve, and frame-BA to bounded
runtime. Applied to both `frame_ba.py` and `submap_pose_graph.py`.
### 10.3 M³-style matcher GT eval
- New script `eval_matcher_gt.py`. Given GT depth + GT pose + K, a
proposed match (token_a in frame i, token_b in frame j) is scored by
unprojecting each token's 3D via GT, then checking
`‖X_i_gt − X_j_gt‖ < err_thresh_m`.
- Loaders: `--dataset {tum|scannetpp}`; TUM uses `rgb.txt`/`depth.txt`
timestamps + fr1 K + depth factor 5000; ScanNet++ uses
`new_scene_metadata.npz` (per-frame K + c2w + mm depth).
- rc in dumps are **patch indices** (0..36 at target_size=518), not
pixels. Unproject via `rc * 14 + 7` → network pixel → scale to
original by `orig_{H,W} / new_{H,W}` where
`new_H = round(orig_H * 518 / orig_W / 14) * 14`.
- Baseline on fr1_xyz at τ=0.6: precision 8% @ 5 cm, 55% @ 20 cm, 88%
@ 50 cm; median 3D error 19 cm. Patch-level descriptors cannot reach
sub-cm precision — 14×14 patches integrate across depth jumps.
### 10.4 Online BA: deferred
See `notes/online_frame_ba_design.md`. Summary: offline BA is enough
for pose-eval; online only matters if we need corrected realtime
trajectory. Recommendation — ship offline Sim(3) first.
### 10.5 Jobs
- `22058142` TUM Sim(3) frame-BA (9 scenes, `v4_frame_ba_tum_sim3/`;
reuses dumps from the SE(3) run).
- `22058145` KITTI 00 Sim(3) frame-BA (`v4_frame_ba_kitti_sim3/`;
reuses dumps from `v4_frame_ba_kitti/`).
- `22058336` ScanNet++ matcher GT eval (FAILED — mixed aspect ratios
DSLR 1035×690 + iPhone 920×690 per scene, kf_detect torch.stack crash).
- `22058916` ScanNet++ matcher GT eval, iPhone-only filter
(`frame_*.jpg`) — COMPLETED.
### 10.6 ScanNet++ matcher eval results
| scene | n_pairs | matches | correct@10cm | prec | med_err |
|-------------|---------|---------|--------------|-------|---------|
| 036bce3393 | 51 | 1292 | 75 | 5.8% | 1.99 m |
| 079a326597 | 41 | 1319 | 172 | 13.0% | 0.68 m |
| 07ff1c45bb | 53 | 1286 | 59 | 4.6% | 1.31 m |
TUM baseline (fr1_xyz) at same threshold: 30% precision / 19 cm median.
ScanNet++ is worse because STRIDE=5 on iPhone (~30 fps) skips to ~0.17 s
gap → keyframes accept ~0.3-0.8 s gap → wider view change than TUM at
30 fps. Pose/depth/intrinsic conventions verified (c2w, 2.4 m median
depth, cx/cy match).
### 10.7 TUM Sim(3) vs SE(3) frame-BA head-to-head (22058142, 9 scenes)
| scene | SE3 fba | Sim3 fba | Δ |
|--------|---------|----------|---|
| 360 | 0.164 | 0.159 | **-0.005** win |
| desk | 0.699 | 0.615 | **-0.085** win |
| desk2 | 0.011 | 0.012 | +0.001 tie |
| floor | 0.183 | 0.178 | **-0.005** win |
| plant | -- | -- | Umeyama degenerate (known TUM coplanar-GT) |
| room | 0.283 | 0.298 | +0.016 |
| rpy | 0.044 | 0.047 | +0.003 tie |
| teddy | 0.576 | 0.685 | +0.109 |
| xyz | 0.097 | 0.105 | +0.008 tie |
| **mean** | **0.257** | **0.262** | +0.005 |
Read: TUM fr1 isn't the dataset where Sim(3) pays off (tight indoor,
scale ≈ 1.0, low drift). 3 wins, 2 losses, 3 ties. The losses on
room/teddy suggest `scale_prior_weight=0.1` is too permissive for
indoor where over-flexible scale slightly degrades rigid alignment.
The real test case is **KITTI** (SE(3) had 172 m ATE on seq 00 due
to per-frame backbone scale inconsistency).
### 10.8 KITTI Sim(3) vs SE(3) frame-BA (22058145, seq 00)
| variant | ATE_Sim3 (m) |
|------------|--------------|
| stitch | 173.052 |
| raw | 192.944 |
| offline | 173.175 |
| posegraph | **172.624** (best) |
| frame_ba | 173.193 |
SE(3) frame-BA baseline was 172.6 m. Sim(3) frame-BA arrived at
**173.2 m — marginally worse**. Solver ran 500 iters (max), cost
dropped 20× (8.0e5 → 4.0e4). Per-frame log-scale stats:
**median=0.9999, range [0.83, 1.02]** — the scale DoF was
essentially unused.
**Verdict: hypothesis falsified.** Per-frame scale drift is NOT
the ATE ceiling on KITTI. The backbone's monocular depth is
*scale-consistent* (no per-frame drift to absorb) but
*systematically biased* in absolute scale. On the 784 m Karlsruhe
loop, a ~22% uniform depth-bias × trajectory length explains
~170 m of ATE — exactly what we see. The Sim(3) DoF is the wrong
lever for this failure mode.
**Next direction** (not this sprint): global depth rescaling (one
Umeyama-fitted scalar over all frames' depth predictions) or
backbone retrain with real KITTI + L_overlap. Adding more DoF to
BA won't help when the input observations (depth maps) are
uniformly biased.
### 10.9 Pixel-accurate matching: reprojection filter + subpixel refinement
**Motivation.** §10.3's ScanNet++ matcher eval showed precision of
only ~13% at 10 cm on DSLR/iPhone wide-baseline pairs — the primary
reason frame-BA lagged on TUM and KITTI. Raw patch matching at
14 × 14 px granularity on DINOv3-like ViT features is too coarse
for BA to Huber-down the outliers (saturates around 85-95%
outlier rate). Two interventions were added; one is the lever,
one is window dressing.
**(a) Geometric reprojection filter — the lever.**
`filter_by_reprojection` (`thirdparty/submap/slam/frame_matcher.py:303`)
uses the SLAM frontend's *initial* per-frame c2w poses as a
geometric prior. For every surviving (i, j) match, it unprojects
frame-i's patch center to camera-j via `T_ji = T_j⁻¹·T_i`,
computes the reprojection error against frame-j's observed
normalized-camera pixel, and rejects pairs with
‖uv_proj − uv_obs‖₂ > `max_err_norm`. At TUM f ≈ 517 px,
`max_err_norm=0.10` corresponds to ~50 px image displacement
(~7% of width) — loose enough to tolerate VO drift, tight enough
to kill mismatches that would torpedo BA's Huber loss.
| dataset | matches pre-filter | matches post-filter | precision @ 10cm |
|-------------------|--------------------|---------------------|------------------|
| ScanNet++ 079a... | 6.1k | 1.1k (18% survive) | 13% → **76.6%** |
| ScanNet++ median err | 67.7 cm | **2.7 cm** | — |
| TUM fr1/xyz | (sequential pairs) | (no rejection) | 15.6% → 14.5% |
ScanNet++ has wide-baseline pairs (DSLR + iPhone, STRIDE=5 iPhone
keyframes) where the initial pose prior is *still* geometrically
tight enough to discriminate. TUM's sequential-pair matching is
already decent; the filter has nothing large to reject, so the
lift is small. The filter is the actual fix for §10.3's
reliability crisis.
**(b) Subpixel quadratic refinement — negligible empirical lift.**
`_subpixel_quadratic` (`thirdparty/submap/slam/frame_matcher.py:156`)
fits a 2D parabola to the 3×3 cosine-similarity neighborhood
around the winning patch `(r₀, c₀)` in frame j:
- ar = ½(f(r+1) − f(r−1)); br = ½(f(r+1) + f(r−1) − 2·f₀)
- ac = ½(f(c+1) − f(c−1)); bc = ½(f(c+1) + f(c−1) − 2·f₀)
- peak at (dr, dc) = (−ar/2br, −ac/2bc), clipped to [−0.5, 0.5]
patch units
Requires concave peak (br, bc < 0) and finite offset; else falls
back to patch center. The sub-patch offset `(dr, dc)` is then fed
to `_interp_uv_norm_at` which bilinearly interpolates
`uv_norm` across the 4 surrounding patches — giving a pixel-accurate
observation instead of one quantized to 14 px.
Data wiring:
```
MatchEdge {
uv_j_refined: (M, 2) # normalized cam, pixel-accurate
subpx_offset: (M, 2) # diagnostic, patch units
}
```
`BAEdge.from_match` (`thirdparty/submap/slam/frame_ba.py`) prefers
`uv_j_refined` over `kp_j.uv_norm[idx_j]` when populated, so BA
residuals are evaluated at the refined pixel.
**Empirical result on TUM xyz (60% refinement rate, median |offset|
= 4.5 px):**
| config | precision @ 5 cm | median err |
|--------------------------|------------------|------------|
| baseline | 5.75% | 18.79 cm |
| + subpixel refinement | 5.83% | 18.75 cm |
Negligible. The dominant error mode is **inter-surface
mismatches** (tens of cm — wrong object entirely), not patch
quantization (~7 px ceiling, ~1.4 cm at TUM depth 1-2 m). Fitting
a parabola to the cos-sim surface doesn't fix "matched to the
wrong chair leg"; only (a) can.
**Limitations of the subpixel path.**
1. *Coverage:* top-K=256 out of 37×37 = 1369 patches means only 19%
of the grid has descriptors dumped; 40% of candidate peaks hit
an incomplete 3×3 neighborhood and fall back. Full-grid dumping
would push refinement rate to ~100%, but the offset is still
bounded at ±7 px per axis — the ceiling doesn't move.
2. *No cure for inter-surface outliers:* subpixel helps only when
the match is on the right surface. The big error bars on TUM
are from mismatches that (a) already catches cheaply and (b)
leaves untouched.
3. *BA residuals vs. eval:* `eval_matcher_gt.py` was updated to
re-unproject frame-b at the refined pixel (applies
`subpx_offset` before `_unproject_tokens`) so precision
measurements reflect what BA actually sees.
**Verdict.** Ship the reprojection filter (the lever). Keep the
subpixel path as infrastructure — it's < 200 LOC, the coverage
ceiling is the real bottleneck, and if we later dump full-grid
descriptors for loop-closure verification the subpixel path is
ready to use it. The precision crisis on ScanNet++ is resolved;
TUM/KITTI ATE gains still hinge on backbone depth bias (§10.8),
not matcher precision.