|
Download reports/final.md from qizhangslam/trained_model: direct link, hf CLI and curl.
- Browser
- Download file 20.8 kB
-
https://huggingface.co/qizhangslam/trained_model/resolve/main/reports/final.md
- Command line
-
hf download hf://qizhangslam/trained_model/reports/final.md
-
curl -L -o final.md https://huggingface.co/qizhangslam/trained_model/resolve/main/reports/final.md
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. | |