~/satyajit

MetricTrail: a single-image depth prior turns DPVO into a long-video pose estimator

mdjsonmcp

2026-11-09 · 18 min · 3d · slam · visual-odometry · depth · state-estimation · nvidia

Why read this

Notabletop 60%

How a metric-depth Huber factor stops DPVO's scale drift, read from NVIDIA's code, measured in the checkpoint and rebuilt as a CPU toy.

  • Interactive explanations
  • Original analysis
  • A lasting reference

3D & spatialNeeds a workstation GPUNon-commercial licenceResearch paper

How this was scored
Is it new?
1 of 3: An incremental tweak
Can I trust it?
2 of 3: Measures key facts from files, code or configs
Can I run it?
1 of 3: API-only, gated or restrictive licence
Will I understand it?
3 of 3: Mechanism carried by interactives built from real code or data
Can I act on it?
2 of 3: A concrete recipe, numbers or comparison
Will it last?
2 of 3: A reference for a year or more
Does it affect many?
1 of 3: A specialist community
Only here?
2 of 3: A teardown or measurement few others did

Score 63 of 100, ranked 317 of 701 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored

Long-video pose estimation has spent the last year going the transformer way. LingBot-Map carries geometric context across frames in structured attention memory; HorizonStream couples local attention with a bounded recurrent state. Both need training on long sequences to learn to remember. MetricTrail, from NVIDIA with Shanghai Jiao Tong, Toronto and Stanford, goes the other way. Its first author's thread puts it plainly: "revisit good old DPVO, add MoGe-2 depth priors and DINOv3 features." The paper is MetricTrail: Revisiting the Power of Metric Depth and Local Optimization for Long-Sequence Streaming 3D Reconstruction, the code lives inside NVIDIA's ViPE repository under vipe/metrictrail/, and the trained matcher is on Hugging Face as nvidia/MetricTrail.

The claim worth taking apart is narrow and checkable: a sparse visual odometry with a fixed, small optimisation window stops drifting over thousands of frames once each patch's depth is tied to a single-image metric depth prediction. No bigger window, no long-sequence training, no correspondence network retraining for that part. This piece explains why that works from the residuals up, shows what the released code actually does, and reproduces the effect in a toy I could run on a CPU. I could not run the real pipeline: it needs CUDA kernels, and this box has no GPU.

System overview diagram. On the left, a film strip of KITTI street frames feeds two boxes, a metric depth model and DINOv3. In the middle, a green panel labelled Reprojection shows a patch being initialised from a metric depth map, the DPVO reprojection energy plus a sum of Huber terms on log inverse depth, and an Augmentation block where adapters A_m and A_c add DINOv3 features to the matching map F_i and the context C_k. On the right, a sparse patch graph of frames and patches feeds a metric-prior differentiable bundle adjustment, which outputs a camera trajectory and per-frame depth maps.
MetricTrail is DPVO with two additions: metric depth initialises patches and stays in bundle adjustment as a Huber factor (Sec. 3.3), and DINOv3 features enter matching through learned adapters. The sparse patch graph and local solver are DPVO's (paper, Figure 2).

Why a small window drifts

DPVO (Teed, Lipson and Deng, NeurIPS 2023) is the estimator underneath. Every frame contributes a few dozen 3x3 patches. A patch kk has a fixed pixel centre uk\mathbf{u}_k in its source frame and one free number, its inverse depth ρk\rho_k. An edge connects a patch to a nearby target frame jj, and the projection of the patch into that frame is

qkj=πK ⁣(Tj Ts(k)−1 πK−1(uk,ρk)).\mathbf{q}_{kj} = \pi_K\!\left(\mathbf{T}_j\,\mathbf{T}_{s(k)}^{-1}\,\pi_K^{-1}(\mathbf{u}_k, \rho_k)\right).

A recurrent network looks at feature correlations around qkj\mathbf{q}_{kj} and predicts where the patch really is, q^kj\hat{\mathbf{q}}_{kj}, plus a confidence wkj\mathbf{w}_{kj}. Bundle adjustment then moves the poses and the inverse depths to agree with those targets:

EDPVO(T,ρ)=∑(k,j)∥qkj(Tj,Ts(k),ρk)−q^kj∥diag⁡(wkj)2.E_{\text{DPVO}}(\mathbf{T}, \rho) = \sum_{(k,j)} \left\lVert \mathbf{q}_{kj}(\mathbf{T}_j, \mathbf{T}_{s(k)}, \rho_k) - \hat{\mathbf{q}}_{kj} \right\rVert^2_{\operatorname{diag}(\mathbf{w}_{kj})}.

The loop runs inside a bounded window. In the shipped metrictrail_local preset that is OPTIMIZATION_WINDOW: 10 poses and PATCH_LIFETIME: 13 frames, with 96 patches per frame. Older poses leave the optimiser and are frozen into the output trajectory.

Here is the problem. Every term in EDPVOE_{\text{DPVO}} is a pixel residual, and pixel residuals are blind to scale: multiply every translation and every depth by the same factor and nothing changes. Inside one window the scale is pinned only by the frozen poses at its old end. Each window inherits the scale of the last one, plus a little error, and over 4,541 frames of KITTI 00 those little errors compound into a random walk. DPVO makes it worse at the seam where new geometry enters: a new patch starts at the median inverse depth of the patches from the preceding three frames. That seed carries no information about where this particular patch is, and it copies forward whatever scale the estimator has already drifted to. The paper's own phrasing is that the median "carries forward the current monocular scale rather than correcting it."

The result, in the paper's Table 1: the official DPVO checkpoint with calibrated intrinsics averages 52.69 m of trajectory error across KITTI 00-10, and 113.11 m on sequence 00 alone. That is the number MetricTrail starts from.

Change one: metric depth that stays in the optimiser

Modern single-image depth models predict depth in metres. MoGe-2 does it with intrinsics estimated from the same image. MetricTrail uses that prediction twice.

  1. Initialisation. A new patch starts at ρk(0)=1/D^s(k)(uk)\rho_k^{(0)} = 1 / \hat{D}_{s(k)}(\mathbf{u}_k), the predicted depth at its own pixel, in metres.
  2. A persistent factor. The same observation stays in bundle adjustment as a Huber-weighted unary on log inverse depth:
Eours(T,ρ)=EDPVO(T,ρ)+λm∑k∈MHuber⁡δ ⁣(log⁡ρk−log⁡ρ^k).E_{\text{ours}}(\mathbf{T}, \rho) = E_{\text{DPVO}}(\mathbf{T}, \rho) + \lambda_m \sum_{k \in \mathcal{M}} \operatorname{Huber}_\delta\!\left(\log \rho_k - \log \hat{\rho}_k\right).

The second is what does the work over long sequences. A seed is forgotten the moment BA moves the patch; a factor keeps pulling. Because every patch in the window now has a term that measures its depth in metres, the window's scale is anchored to the depth network's notion of a metre, not to the frozen poses behind it. The scale random walk loses its integrator. DPVO's own scale normalisation is switched off in metric runs, since it would wash that anchor out.

The code is short enough to read in full. In vipe/metrictrail/dpvo/ba.py, _metric_unary_terms() turns each patch's prior into one diagonal entry and one right-hand-side entry of the depth block of DPVO's Schur system, as an iteratively reweighted Huber:

# vipe/metrictrail/dpvo/ba.py, _metric_unary_terms (abridged)
residual = safe_prior.log() - safe_current.log()          # log(rho_hat) - log(rho)
robust = torch.where(abs_residual <= huber_delta,
                     torch.ones_like(abs_residual),
                     huber_delta / abs_residual.clamp_min(1e-6))
weight = prior_lambda * safe_confidence * robust
jacobian = safe_current.clamp_min(1e-4).reciprocal()      # d log(rho) / d rho
C = weight * jacobian.square()                            # added to the depth Hessian diagonal
rhs = weight * residual * jacobian                        # added to the depth gradient

The constants are in configs/slam/metrictrail_local.yaml and match the paper's appendix: METRIC_DEPTH_PRIOR_WEIGHT: 1.0 (λm\lambda_m), METRIC_DEPTH_PRIOR_HUBER: 0.3 (δ\delta), and METRIC_DEPTH_PRIOR_WARMUP: 12, so the factor is off for the first 12 accepted frames and the first regular BA update with it lands on frame 13. Depths outside 0.2-500 m count as invalid: such a patch is seeded with the frame's median and gets zero factor weight.

Two details matter. The residual is in log inverse depth, so the factor measures ratios: a 10% error costs the same at 3 m and at 80 m, which is what you want from a depth network whose errors scale with depth. And δ=0.3\delta = 0.3 in log units is a depth ratio of about 1.35. Inside that band the prior pulls in proportion to the disagreement; past it, the pull is capped. A wrong prediction on a window or a sky patch cannot drag the geometry further than a merely imprecise one, and the reprojection terms from the other frames get the final say. The widget evaluates the exact formula above.

Change two: DINOv3 features in the matcher, through 41,536 parameters

The prior fixes where the matcher starts looking. It does not help the matcher choose between two similar-looking candidates in a row of identical windows. For that MetricTrail adds features from a frozen DINOv3 ViT-S/16. A 1x1 convolution projects the 384-dimensional DINO tokens to 64 channels, split into two 32-channel branches: one is projected to 128 channels and added to DPVO's matching feature map, the other is sampled at each patch centre, projected to 384 channels and added to the recurrent context. The final projections are zero-initialised, so the fused model starts out exactly reproducing the pretrained DPVO predictions.

I downloaded the released checkpoint (dino_dpvo.pth, 13,739,570 bytes) and loaded it with torch.load(..., weights_only=True) in PyTorch on CPU, which reads tensors and runs no pickled code. Measured:

PartParameters
Whole checkpoint (fp32)3,425,860
update (the recurrent update operator)3,004,804
patchify (feature and context encoders, with adapters)421,056
Adapters: dino_projection 64x384 conv + bias24,640
Adapters: match_fusion 32 to 128 + bias4,224
Adapters: context_fusion 32 to 384 + bias12,672
All adapter parameters41,536

So 41,536 of 3,425,860 parameters, 1.2%, are new; the rest is DPVO's architecture, fine-tuned. The DINOv3 backbone itself is not in the file: the checkpoint's metadata lists patchify.dino.backbone. under omitted_external_state_prefixes and records the SHA-256 of the dinov3_vits16_pretrain_lvd1689m weights it expects, which you fetch separately under Meta's DINOv3 licence. The zero-initialised projections did move in training: match_fusion.weight ended at a Frobenius norm of 0.52 and context_fusion.weight at 3.40, so the context branch is the one the network learned to lean on more.

Training is short and local. Every sample is a 15-frame clip from TartanAir, TartanGround, CarlaOcc, LightwheelOcc or OmniWorld-Game; 15,000 steps on 16 A100s, 240,000 clips, about seven hours. MoGe-2 never appears in training. The metric prior enters only at inference, which is why the paper can bolt it onto the original DPVO weights and still get most of the gain.

What the depth prior does, rebuilt on a CPU

The real pipeline needs three CUDA extensions (cuda_corr, cuda_ba, lietorch_backends), the 1.3 GB MoGe-2 ViT-L checkpoint and the gated DINOv3 weights, and this machine has no GPU or nvcc. So I rebuilt the one idea I wanted to test, in about 170 lines of numpy: a DPVO-shaped estimator in the plane, small enough to run thousands of frames on one core.

The toy keeps DPVO's shape and drops everything learned. A camera drives 3,000 frames along a winding path of about 3 km at roughly 1 m per frame, like a car in KITTI. Every frame gets 16 patches: a pixel on a 1-D image (focal 300 px) and one inverse depth, with true depths drawn between 4 and 80 m. The "matcher" is the true projection plus 1 px of Gaussian noise. The window is 12 frames: patches from all 12, edges to every other frame in it, the 10 newest poses free and the 2 oldest frozen, 4 Gauss-Newton steps per new frame. The "single-image depth network" is the true depth times a log-normal error: 10% per patch, 5% shared by the whole frame, and 5% of patches off by a factor of 1.6 to 4.5. Three rules for a new patch's depth:

The factor is ported by reading the code: I meant to call the repository's own _metric_unary_terms() on the same inputs as a cross-check, and the sandbox refused to execute the downloaded code, so that comparison is not done. Same trajectory, same scene and same measurement noise for all three. ATE is the RMSE after one Sim(2) alignment of the prefix, so a constant scale error is forgiven and only drift counts. Measured, mean of three seeds, on one CPU core at about 34 frames per second:

Framesmedian seedmetric init onlyinit + factor
50012.00 m2.40 m1.54 m
1,00032.20 m8.57 m2.54 m
2,000105.09 m44.32 m4.34 m
3,000192.30 m122.08 m6.52 m
Sim(2) scale needed at 3,0003.801.721.04

Three things fall out, and they line up with the paper's argument.

The median seed drifts without bound. By frame 3,000 the estimate has shrunk to about a quarter of true size (the alignment needs a scale of 3.80), and the error is still growing. That is the scale random walk, with the median rule copying each step forward.

A metric seed alone only delays it. Seeding from the depth network keeps the first few hundred frames honest, because every new patch arrives at roughly the right depth. But the BA is free to move patches once they are in, the frozen poses still pass scale from window to window, and by 3,000 frames the error is 122 m on average, with one seed at 198 m. This is the paper's point that "initialization alone cannot prevent later BA updates from drifting away from the predicted metric scale", reproduced.

Keeping it as a factor stops the drift. With the factor the error at 3,000 frames is 6.52 m on average (4.23 to 8.14 m across seeds) and the scale stays within 4% of true the whole way. What is left grows slowly, which is heading drift: the depth prior pins scale, not rotation.

Two variations, one seed or three as marked, also measured:

Tripling the per-patch noise to 30% costs the factor little (7.06 m, seed 1) and does nothing for init alone (167.74 m). What the toy does not have: a learned matcher, outliers in the matches, keyframing, rotation drift of any realistic size, dynamic objects. It tests one mechanism, and that mechanism behaves the way the paper says it does.

The paper's numbers

All of this section is reported: the paper's tables, which I checked for internal consistency but did not reproduce.

Four top-down trajectory plots of KITTI sequence 00, ground truth in grey and estimate in green. DPVO's estimate wanders far off the grey loop with 113.11 m error; DPVO plus metric prior follows it closely at 7.61 m; without DINOv3 6.05 m; the full model 5.22 m. Below each, a zoom on a revisited corner shows the first pass and the revisit as blue and orange lines, with gaps of 98.90, 14.27, 6.88 and 3.33 m. On the right, two street images with correspondence lines: 77.8% within 5 px for DPVO, 97.2% for MetricTrail.
KITTI 00 with each ingredient added. The metric prior alone takes the original DPVO from 113.11 m to 7.61 m; multi-dataset training and DINOv3 then take it to 5.22 m, and raise correspondences within 5 px from 77.8% to 97.2% (paper, Figure 5).

The ablation is where the claim lives. Table 5, calibrated intrinsics, no loop closure:

ComponentsKITTIOxford SpiresVBR
DPVO52.692.5972.25
+ metric depth12.771.8519.57
+ multi-dataset training10.321.7622.51
+ DINOv39.101.7118.90

The metric prior is worth more than everything else combined, on every dataset. The other two ingredients are not monotone: multi-dataset training makes VBR worse, and DINOv3 earns it back. Table 6 repeats the ladder with five other depth providers in place of MoGe-2, which is the honest test of "the depth model is swappable":

The swap mostly holds. On KITTI and VBR every provider takes DPVO far below its no-prior error. On Oxford Spires, where DPVO alone is already at 2.59 m, two providers make it worse (Pi3X at 5.57 m, UniDepthV2 at 3.89 m), which is what a confident, wrong prior does to a working estimator. DA3Metric and Metric3Dv2 beat MoGe-2 in this calibrated setting; the paper keeps MoGe-2 as the default because it also estimates intrinsics from RGB alone, and the other two need a focal length to produce metric depth at all. If you have calibrated cameras, the paper's own table says to try DA3Metric first.

Against the rest of the field, under the RGB-only protocol (intrinsics estimated from the video), Table 1 and Table 2:

Average ATE (m)KITTIOxford SpiresVBR
ViPE28.687.0646.20
LingBot-Map25.295.1527.53
HorizonStream19.758.7525.30
MetricTrail, uncalibrated14.051.7716.36
MetricTrail, uncalibrated, loop closure9.781.7710.88

The thread's "cuts ViPE's average trajectory error by 51% on KITTI and 75% on Oxford Spires" checks out against these rows: 28.68 to 14.05 m is 51.0%, and 7.06 to 1.77 m is 74.9%. With calibrated stereo on KITTI the error falls to 3.66 m, and 3.00 m with loop closure. The loop closure is simple: SALAD retrieves near-duplicate frames, and each pair adds a weak identity relative-pose edge to an SE(3) pose graph, with no geometric verification of the pair.

Left, input frames moving from a patio to an indoor living room. Centre, a coloured point cloud of the house with the camera path drawn through it, and three smaller, deformed reconstructions labelled LingBot-Map, LoGeR and Scal3R. Right, bar charts of average trajectory error: Oxford Spires 1.77 m for MetricTrail against 4.32 to 8.75 for others; KITTI 9.78 against 16.44 to 28.68; VBR 10.88 against 18.84 to 46.20.
An indoor-outdoor reconstruction and the headline bars. MetricTrail's KITTI and VBR bars here are the loop-closure results, 9.78 m and 10.88 m (paper, Figure 1).

Three things the headline rows do not say.

VBR depends on an offline focal refinement. The 16.36 m uses a "revisit-based focal refinement" from the appendix: it takes the base trajectory, finds temporally distant revisit pairs, matches them with RoMa, fits fundamental matrices with MAGSAC and re-estimates the focal length. That step reads the whole trajectory, so it is not streaming, and it averages 7.1 minutes per routed sequence. The paper says it "was motivated by our analysis of VBR", where the base calibration overestimated focal length by 7-9% on six of seven sequences. Without it, Table 16 reports 33.51 m on VBR, which is worse than LoGeR* (25.24 m) and HorizonStream (25.30 m). The paper discloses all of this, and the thread's percentages are for KITTI and Oxford Spires only.

Dynamic scenes are not a win. On TUM-Dynamic dense reconstruction, MetricTrail's 0.13 m Chamfer distance trails TTT3R's 0.06 m and LingBot-Map's 0.08 m. The paper says so itself: "the gains on static indoor and large outdoor scenes do not extend uniformly to scenes with dynamic content."

The runtime number is calibrated. 12.70 FPS on KITTI-02 with 3.21 GB peak GPU memory and about 0.7 s to first output, on an A100, assumes supplied intrinsics. Estimating them from RGB adds about 175 s of start-up and raises peak memory to 6.81 GB; throughput afterwards is the same 12.70 FPS. For comparison the paper measures LingBot-Map at 11.37 FPS and HorizonStream at 9.92 FPS on the same sequence.

What you can run, and under which licence

nv-tlabs/vipe@ead2e00 · snapshot 2026-11-10
tracked files
551
license
Apache-2.0
branch
main
tests
24 files
source
4.3 MB
commit date
2026-11-05
source by language
Python3.5 MB(407)CUDA471.8 kB(25)C++161.0 kB(18)C158.3 kB(28)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-11-10 at ead2e00 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount

shallow clone: counts describe the pinned tree, not the history

nvidia/MetricTrail@f5a3ce1 · snapshot 2026-11-10
repo size
13.7 MB
license
other
largest file
13.7 MB
files
4
downloads
281
likes
23

repo last modified 2026-11-05

The licensing is in three layers, and the strictest one governs.

To run it you need an NVIDIA GPU, the ViPE install, then python -m vipe.metrictrail.build to compile the DPVO kernels, then python run.py pipeline=metrictrail streams.base_path=<video>. The evaluation scripts live on a separate metrictrail_eval branch. I did not run any of it.

Where this leaves long-video pose

The interesting result is not that MetricTrail beats LingBot-Map. It is how little it took. The trajectory-level problem everyone has been throwing memory at is, for a classical sparse estimator, mostly one missing term: an absolute reference for scale at every patch, which a single-image depth network now supplies for free. The window stays at 10 poses. The matcher never sees a sequence longer than 15 frames. What changed is that the window can no longer lose track of what a metre is.

The costs are real. You inherit the depth network's biases, and the provider table shows that a confident wrong prior hurts a working estimator. You inherit its calibration needs, which is where VBR's offline focal fix comes from. And dynamic content still breaks the static-scene assumption that both DPVO and the prior make. For the transformer side of this same problem, see R³ and AMB3R-SLAM on a frozen Depth Anything 3 and the second 3D reconstruction roundup; for what a metric depth model needs from its wrapper, Depth Anything 3 in ROS 2; and for odometry with a sensor that measures metres directly, FAST-LIO2 from scratch.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "MetricTrail: a single-image depth prior turns DPVO into a long-video pose estimator", ai.thesatyajit.com, November 2026.

bibtex
@misc{ghana2026metrictrail,
  author = {Satyajit Ghana},
  title  = {MetricTrail: a single-image depth prior turns DPVO into a long-video pose estimator},
  url    = {https://ai.thesatyajit.com/articles/metrictrail},
  year   = {2026}
}
share