Model Tensor Semantics (XFeat)
Data flow & types
XFeat is a plain Image<u8,3> → XFeatResult type (no pipeline/operator
framework). Device data crosses boundaries as kornia types + TRT views:
- Input: a kornia
Image<u8,3> (device), any resolution. XFeat owns a
kornia Preprocessor::stretch and resizes each frame to its own floor-of-32
dims mh,mw = (H/32)*32, (W/32)*32 into a reused Tensor<f32,4> [1,3,mh,mw]
(reallocated only when the size changes).
- Backbone I/O:
ModelSession::run(&Tensor<f32,4>) -> TRTensorMap; outputs
read by name via TRTensorMap::get("descriptors"|"heatmap"|"reliability") →
OutputView::f32_ptr() (dtype-checked device pointer, valid until the next
run). See crates/vrt-xfeat/src/model.rs.
- VPI-style submit (caller-owned output): pre-allocate an
XFeatResult with
XFeat::alloc_result(), then xfeat.submit(&img, &mut result) (async, no sync)
→ stream.sync() → read. run() = alloc + submit + sync convenience. Hold
several results to keep multiple frames outstanding under one sync.
- Result:
XFeatResult (device kpts/descs/scores, capacity top_k) —
count() reads the pinned scalar (post-sync), kpts_to_host applies the
scale (rw,rh) (returns original pixels), scores_to_host is a plain D2H.
Coordinate spaces — the #1 source of bugs
This mirrors upstream XFeat exactly (preprocess_tensor):
- Original space — the source image pixels the caller passes in.
- Model space — the floor-of-32 resized backbone input. Keypoints are
produced HERE on device (
XFeatResult.kpts).
XFeatResult.scale = (rw, rh) = (W/mw, H/mh) maps model→original. kpts_to_host
applies it, so host keypoints are in original pixels (upstream's
mkpts * [rw, rh]). The resize is anisotropic but sub-32px, so aspect is
effectively preserved without any padding — no letterbox, no pad offset.
Descriptor matching uses descriptors only, so device kpts staying in model
space doesn't affect it.
Preprocessing (kornia Preprocessor::stretch)
- Output: CHW FP32
[1,3,mh,mw], values /255 → [0,1] (no mean/std),
anisotropic resize to floor-of-32 (matches XFeat's F.interpolate).
- XFeat downsamples ×8, so mh,mw are forced to multiples of 32.
XFeatParams is
just { top_k, threshold } — the input size is per-frame, not configured.
XFeat backbone outputs (TRT engine, FP32 on device)
| Tensor |
Shape |
Meaning |
descriptors |
(1, 64, H/8, W/8) |
dense 64-D feature map |
heatmap |
(1, 1, H, W) |
keypoint confidence |
reliability |
(1, 1, H, W) |
per-pixel reliability weight |
64 is XFeat's own width. The matcher is separate and compiles for others — see below.
Engine MUST expose exactly those three output names (model.rs errors with
MissingOutput otherwise).
Post-processing (crates/vrt-xfeat/src/postprocess.rs, NVRTC-JIT kernels)
xfeat_score_nms — 5×5 local-max NMS; score = heatmap×reliability, zeroed
below params.threshold or if any neighbour is greater.
- GPU top-K, no CPU round trip:
xfeat_topk_histogram bins survivor scores,
xfeat_topk_cutoff finds the score cutoff for ~K survivors, xfeat_topk_select
atomically gathers survivors ≥ cutoff, capped at params.top_k. Approximate
only at the boundary bucket (1024 bins). Output is atomic-append order,
NOT score-sorted.
xfeat_sample_descs — bilinear-sample 64-D descriptors at kpt/8 positions,
align_corners=False (matches PyTorch grid_sample).
xfeat_l2_norm — in-place L2-normalize each descriptor row.
Async contract: submit() enqueues preproc → backbone → NMS → top-K with no
sync; run() does the single stream.synchronize() then finish_topk to
assemble XFeatResult. Device buffers are capacity top_k; the valid count
is scores.len() — bound all device-buffer access by it.
Matching lives in a separate matching::Matcher (module crates/vrt-xfeat/src/matching.rs),
decoupled from postproc but sharing the stream. Cosine similarity (descriptors
are L2-normalized, so dot = cosine), mutual nearest-neighbor via two calls of one
tiled argmax kernel (xfeat_match_argmax, one thread per query, candidates tiled
through shared memory), min-similarity cutoff. VPI-style:
submit(Descriptors, Descriptors, cossim, &mut MatchResult) (async) → sync →
MatchResult::pairs().
The matcher is not XFeat-only. Matcher::new compiles for XFeat's 64-D descriptors;
Matcher::with_dim(stream, dim) compiles for 128-D as well, which is what matches
vrt-raco-aliked's ALIKED descriptors without LightGlue. The width is an NVRTC
compile-time constant, not a runtime argument, so a Matcher only ever handles the one
width it was built for.
That is why descriptors are passed as Descriptors::new(buf, count, dim) rather than a
bare slice and a count. The width cannot be recovered from the buffer — XFeatResult
allocates for top_k and RaCoAlikedResult for k, so both are longer than
count * dim in normal use, and no length arithmetic separates 3072x128 floats from
6144x64. Handing a 128-D set to a 64-D matcher does not crash; it strides the buffer and
returns plausible-looking matches. submit rejects a width that is not the compiled one,
along with a buffer too short for its count and a count over the output capacity — all
three as typed XFeatErrors, not debug assertions.
When validating XFeat changes
- Sanity: static scene ≈ stable keypoint count frame-to-frame; kpts cluster on
corners/texture, empty sky/walls ≈ none.
- GPU vs CPU:
cargo test -p vrt-xfeat --release -- --ignored runs
gpu_match_agrees_with_cpu_reference (64-D), gpu_match_128d_agrees_with_cpu_reference
(128-D, plus the width/capacity/empty-side rejections) and
gpu_topk_selects_correct_keypoints. gpu_match_kernel_only_timing fails on JetPack 6
with Missing symbol cuEventElapsedTime_v2 — the driver exports only
cuEventElapsedTime, so cudarc's lookup cannot resolve; environmental, not a regression.
- An independent CPU oracle is the only thing that catches a stride bug in this kernel:
a wrong width or a wrong index type yields plausible matches, never a crash.
- Wrong-normalization symptom: keypoints "almost work" with low scores — check
/255 happened exactly once (not zero, not twice).
1---2name: model-tensor-semantics3description: Use when working on XFeat pre/post-processing, keypoints, descriptors, or matching — tensor shapes, coordinate spaces, normalization, NMS/top-K, and the exact algorithm steps implemented in vrt-xfeat.4---56# Model Tensor Semantics (XFeat)78## Data flow & types910XFeat is a plain `Image<u8,3> → XFeatResult` type (no pipeline/operator11framework). Device data crosses boundaries as **kornia** types + TRT views:1213- **Input**: a kornia `Image<u8,3>` (device), **any resolution**. `XFeat` owns a14 kornia `Preprocessor::stretch` and resizes each frame to its own floor-of-3215 dims `mh,mw = (H/32)*32, (W/32)*32` into a reused `Tensor<f32,4>` `[1,3,mh,mw]`16 (reallocated only when the size changes).17- **Backbone I/O**: `ModelSession::run(&Tensor<f32,4>) -> TRTensorMap`; outputs18 read by name via `TRTensorMap::get("descriptors"|"heatmap"|"reliability")` →19 `OutputView::f32_ptr()` (dtype-checked device pointer, valid until the next20 `run`). See `crates/vrt-xfeat/src/model.rs`.21- **VPI-style submit** (caller-owned output): pre-allocate an `XFeatResult` with22 `XFeat::alloc_result()`, then `xfeat.submit(&img, &mut result)` (async, no sync)23 → `stream.sync()` → read. `run()` = alloc + submit + sync convenience. Hold24 several results to keep multiple frames outstanding under one sync.25- **Result**: `XFeatResult` (device `kpts`/`descs`/`scores`, capacity `top_k`) —26 `count()` reads the pinned scalar (post-sync), `kpts_to_host` applies the27 `scale (rw,rh)` (returns original pixels), `scores_to_host` is a plain D2H.2829## Coordinate spaces — the #1 source of bugs3031This mirrors upstream XFeat exactly (`preprocess_tensor`):32331. **Original space** — the source image pixels the caller passes in.342. **Model space** — the floor-of-32 resized backbone input. Keypoints are35 produced HERE on device (`XFeatResult.kpts`).3637`XFeatResult.scale = (rw, rh) = (W/mw, H/mh)` maps model→original. `kpts_to_host`38applies it, so host keypoints are in **original pixels** (upstream's39`mkpts * [rw, rh]`). The resize is anisotropic but sub-32px, so aspect is40effectively preserved without any padding — no letterbox, no pad offset.41Descriptor matching uses descriptors only, so device `kpts` staying in model42space doesn't affect it.4344## Preprocessing (kornia `Preprocessor::stretch`)4546- Output: CHW FP32 `[1,3,mh,mw]`, values **/255 → [0,1]** (no mean/std),47 anisotropic resize to floor-of-32 (matches XFeat's `F.interpolate`).48- XFeat downsamples ×8, so mh,mw are forced to multiples of 32. `XFeatParams` is49 just `{ top_k, threshold }` — the input size is per-frame, not configured.5051## XFeat backbone outputs (TRT engine, FP32 on device)5253| Tensor | Shape | Meaning |54|--------|-------|---------|55| `descriptors` | (1, 64, H/8, W/8) | dense 64-D feature map |56| `heatmap` | (1, 1, H, W) | keypoint confidence |57| `reliability` | (1, 1, H, W) | per-pixel reliability weight |585964 is XFeat's own width. The matcher is separate and compiles for others — see below.6061Engine MUST expose exactly those three output names (`model.rs` errors with62`MissingOutput` otherwise).6364## Post-processing (`crates/vrt-xfeat/src/postprocess.rs`, NVRTC-JIT kernels)65661. `xfeat_score_nms` — 5×5 local-max NMS; score = heatmap×reliability, zeroed67 below `params.threshold` or if any neighbour is greater.682. GPU top-K, no CPU round trip: `xfeat_topk_histogram` bins survivor scores,69 `xfeat_topk_cutoff` finds the score cutoff for ~K survivors, `xfeat_topk_select`70 atomically gathers survivors ≥ cutoff, capped at `params.top_k`. Approximate71 only at the boundary bucket (1024 bins). Output is atomic-append order,72 **NOT score-sorted**.733. `xfeat_sample_descs` — bilinear-sample 64-D descriptors at kpt/8 positions,74 **align_corners=False** (matches PyTorch `grid_sample`).754. `xfeat_l2_norm` — in-place L2-normalize each descriptor row.7677Async contract: `submit()` enqueues preproc → backbone → NMS → top-K with **no**78sync; `run()` does the single `stream.synchronize()` then `finish_topk` to79assemble `XFeatResult`. Device buffers are **capacity `top_k`**; the valid count80is `scores.len()` — bound all device-buffer access by it.8182Matching lives in a **separate** `matching::Matcher` (module `crates/vrt-xfeat/src/matching.rs`),83decoupled from postproc but sharing the stream. Cosine similarity (descriptors84are L2-normalized, so dot = cosine), mutual nearest-neighbor via two calls of one85tiled argmax kernel (`xfeat_match_argmax`, one thread per query, candidates tiled86through shared memory), min-similarity cutoff. VPI-style:87`submit(Descriptors, Descriptors, cossim, &mut MatchResult)` (async) → sync →88`MatchResult::pairs()`.8990**The matcher is not XFeat-only.** `Matcher::new` compiles for XFeat's 64-D descriptors;91`Matcher::with_dim(stream, dim)` compiles for 128-D as well, which is what matches92`vrt-raco-aliked`'s ALIKED descriptors without LightGlue. The width is an NVRTC93compile-time constant, not a runtime argument, so a `Matcher` only ever handles the one94width it was built for.9596That is why descriptors are passed as `Descriptors::new(buf, count, dim)` rather than a97bare slice and a count. The width **cannot** be recovered from the buffer — `XFeatResult`98allocates for `top_k` and `RaCoAlikedResult` for `k`, so both are longer than99`count * dim` in normal use, and no length arithmetic separates 3072x128 floats from1006144x64. Handing a 128-D set to a 64-D matcher does not crash; it strides the buffer and101returns plausible-looking matches. `submit` rejects a width that is not the compiled one,102along with a buffer too short for its count and a count over the output capacity — all103three as typed `XFeatError`s, not debug assertions.104105## When validating XFeat changes106107- Sanity: static scene ≈ stable keypoint count frame-to-frame; kpts cluster on108 corners/texture, empty sky/walls ≈ none.109- GPU vs CPU: `cargo test -p vrt-xfeat --release -- --ignored` runs110 `gpu_match_agrees_with_cpu_reference` (64-D), `gpu_match_128d_agrees_with_cpu_reference`111 (128-D, plus the width/capacity/empty-side rejections) and112 `gpu_topk_selects_correct_keypoints`. `gpu_match_kernel_only_timing` fails on JetPack 6113 with `Missing symbol cuEventElapsedTime_v2` — the driver exports only114 `cuEventElapsedTime`, so cudarc's lookup cannot resolve; environmental, not a regression.115- An independent CPU oracle is the only thing that catches a stride bug in this kernel:116 a wrong width or a wrong index type yields plausible matches, never a crash.117- Wrong-normalization symptom: keypoints "almost work" with low scores — check118 `/255` happened exactly once (not zero, not twice).