Philipp Lindenberger · Paul-Edouard Sarlin · Ondrej Miksik · Marc Pollefeys
One model, four matching modes: FlexGlue provides matches tailored to each use-case.
FlexGlue is robust under extreme viewpoint changes, low-texture environments, rotations, and lighting conditions.
To match or not to match: A covisibility tells whether a pair is an unmatchable doppelganger and FlexGlue stops early on the latter.
This repository hosts the inference code of FlexGlue, a feature matcher that works with any keypoints, with dense grids, or with no keypoints at all. FlexGlue matches through dense features: we use a DINOv3-ConvNeXt to encode an image into a stride-8 feature map, a transformer refines both maps jointly, and each query point finds its correspondence in the other image by correlation. On top, FlexGlue predicts whether the two images are covisible at all, which rejects look-alike pairs such as repeated facades.
A pair of images is encoded with a CNN backbone. Dense tokens are forwarded through a series of transformers gathering context within and between images.
The aggregated tokens are interpolated at sparse keypoints, and their coarse correspondence is estimated with a windowed soft-argmax.
A simple refinement head yields subpixel accuracy. Unmatchable image pairs are detected in every layer.
Install this repo using pip:
git clone https://github.com/cvg/FlexGlue.git && cd FlexGlue
python -m pip install -e .
# or, without cloning:
python -m pip install git+https://github.com/cvg/FlexGlue.gitFlexGlue works with any keypoints. The examples below detect them with the extractors of LightGlue:
# requires the latest LightGlue commit
python -m pip install git+https://github.com/cvg/LightGlue.gitWe provide a demo notebook which shows all matching modes on an image pair and the rejection of a look-alike pair.
Here is a minimal script to match two images. Detection is optional:
from lightglue import ALIKED
from flexglue import FlexGlue
from flexglue.utils import load_image, pack_matches, rbd
# load each image as a torch.Tensor on GPU with shape (3,H,W), normalized in [0,1]
image0 = load_image("assets/berlin1.jpg").cuda()
image1 = load_image("assets/berlin2.jpg").cuda()
inputs0, inputs1 = {"image": image0[None]}, {"image": image1[None]}
# optional: detect keypoints to be matched
detector = ALIKED(origin="corner", max_num_keypoints=2048).eval().cuda()
inputs0.update(detector.extract(image0)) # {keypoints, ...}
inputs1.update(detector.extract(image1))
# match: each keypoint (or sampled point) is paired with its predicted point in the other image
matcher = FlexGlue().eval().cuda()
matches01 = rbd(pack_matches(matcher.match(inputs0, inputs1)))
matches = matches01["matches"] # indices with shape (K,2)
points0 = matches01["keypoints0"][matches[..., 0]] # coordinates in image #0, shape (K,2)
points1 = matches01["keypoints1"][matches[..., 1]] # coordinates in image #1, shape (K,2)With keypoints in both images, matcher.match(inputs0, inputs1, sparse=True) instead matches the keypoints to each other, as LightGlue does: matches then indexes inputs0["keypoints"] and inputs1["keypoints"].
We also provide a convenience method to match a pair of images directly:
matches01 = rbd(pack_matches(matcher.match_pair(image0, image1, detector=detector)))
points0 = matches01["keypoints0"][matches01["matches"][..., 0]]
points1 = matches01["keypoints1"][matches01["matches"][..., 1]]Important
FlexGlue assumes keypoints in corner coordinates (COLMAP convention): the center of the top-left pixel is at (0.5, 0.5). LightGlue assumes center coordinates (a 0.5 px offset), so pass origin="corner" to its detectors, e.g. ALIKED(origin="corner").
FlexGlue supports multiple matching modes, depending on whether keypoints are provided for each image and the selected mode (sparse):
| neither has keypoints | one has keypoints (i) |
both have keypoints |
|---|---|---|
| sample correspondences | each keypoint of i gets a predicted point in j |
matches in both directions concatenated (sparse=False) or mutual nearest neighbors (sparse=True) |
matches always indexes keypoints0 and keypoints1. Without sparse=True, these are row-aligned lists of corresponding points (keypoints or sampled points paired with their predicted points), so row k of one matches row k of the other. With sparse=True, they are the input keypoints, matched to each other.
The snippet below shows how to run each mode, one after the other. The demo notebook shows the matches of each mode on an example pair.
inputs0 = matcher.descriptor.describe(image0) # {features, image_size}
inputs1 = matcher.descriptor.describe(image1)
# neither has keypoints: correspondences are sampled
pred = matcher.match(inputs0, inputs1)
# >>>
# keypoints0/1: (1, S, 2), sampled points
# matches0: (1, S), k for row k, or -1 if filtered
# one has keypoints: each keypoint of image #0 with its predicted point in image #1
inputs0.update(detector.extract(image0)) # {keypoints, ...}
pred = matcher.match(inputs0, inputs1)
# >>>
# keypoints0: (1, M, 2), detected keypoints <-- inputs0["keypoints"]
# keypoints1: (1, M, 2), predictions
# both have keypoints: concatenate bidirectional (default, recommended)
inputs1.update(detector.extract(image1))
pred = matcher.match(inputs0, inputs1)
# >>>
# keypoints0: (1, M+N, 2), [keypoints0, p2d1_i0]
# keypoints1: (1, M+N, 2), [p2d0_i1, keypoints1]
# both have keypoints: mutual nearest neighbors between the keypoints
pred = matcher.match(inputs0, inputs1, sparse=True)
# >>>
# keypoints0/1: (1, M, 2) / (1, N, 2)
# matches0: (1, M), index of the matched keypoint in image #1, or -1
# all modes return the same output format
matches01 = rbd(pack_matches(pred))
matches = matches01["matches"] # indices with shape (K,2)
points0 = matches01["keypoints0"][matches[..., 0]] # coordinates in image #0, shape (K,2)
points1 = matches01["keypoints1"][matches[..., 1]] # coordinates in image #1, shape (K,2)FlexGlue can also return dense correspondences directly:
# dense grids only, without matching
pred = matcher.match_dense(inputs0, inputs1)
# dense_p2d0_i1: (B, h, w, 2), h x w = describe_resize / coarse_stride (64x64 by default)For increased sub-pixel accuracy, we recommend to use the excellent RoMa v2 refiner, whose weights are downloaded automatically:
# refined with the RoMa v2 refiners, at refine_resize resolution
matcher = FlexGlue(refine=True).eval().cuda()
pred = matcher.match_dense(inputs0, inputs1)
# dense_p2d0_i1: (B, h_r, w_r, 2), h_r x w_r = refine_resize (512x512 by default)
# or
pred = matcher.match(inputs0, inputs1) # refined matchesTo improve matching accuracy and robustness, the biggest lever is increased resolution (see Table 2 in the paper):
# descriptor input at 640x640 instead of the default 512x512
matcher = FlexGlue(describe_resize=(640, 640)).eval().cuda()[Detail of all outputs - click to expand]
Inputs per image (inputs0, inputs1), as returned by DINOv3Descriptor.describe, merged with a LightGlue detector:
features:(B, 256, h, w)stride-8 feature map.image_size:(B, 2)image size(w, h)in pixels.image:(B, 3, H, W)the image, read by the refiner.keypoints:(B, M, 2)keypoints in pixels, optional.descriptors:(B, M, 64)descriptors of the keypoints, withdescriptor.describe(image, keypoints).
Outputs of matcher(...) and matcher.match(...), for each direction i -> j whose image i has keypoints:
p2d{i}_i{j}:(B, M, 2)predicted location of each keypoint of imageiin imagej, in pixels.p2d{i}_n{j}:(B, M, 2)the same, in normalized coordinates[-1, 1].confidence{i}:(B, M)confidence that the keypoint has a correspondence. With the cycle check, the geometric mean of the forward confidence and the confidence of the prediction mapped back toi.valid{i}:(B, M)whether the keypoint passes the covisibility, border and cycle checks.cycle_error{i}:(B, M)cycle error in pixels, withmax_distance_pxand notsparse=True.covisibility_score:(B,)probability that the two images show the same place.stop_layer: the layer at which the matcher stopped.
Matches, as in LightGlue, also returned by matcher(...) and matcher.match(...) in every mode:
keypoints0,keypoints1: the points thatmatches0andmatches1index.- With
sparse=True: the input keypoints,(B, M, 2)and(B, N, 2). - Otherwise: row-aligned lists of corresponding points,
(B, K, 2). K is M+N with keypoints in both images, M with keypoints in one, and at mostmax_num_sampleswithout keypoints.
- With
matches0,matches1: index of the match in the other image, or -1.- With
sparse=True:(B, M)and(B, N), from mutual nearest neighbors. - Otherwise:
(B, K). Both are the same tensor: row k matches row k, or -1 if filtered out.
- With
matching_scores0,matching_scores1: confidence of each match, 0 where unmatched. Same shapes asmatches0andmatches1.
Added by pack_matches(pred), not returned by the matcher:
matches: list of(S, 2)index pairs per batch element.scores: list of(S,)match confidences per batch element.
Dense matches, from match_dense and the sampled mode, per direction i -> j. The coarse grid has h x w = describe_resize / coarse_stride cells (64x64 by default), the refined grid h_r x w_r = refine_resize (512x512 by default):
dense_p2d{i}_i{j}:(B, h, w, 2)correspondence of each cell of imageiin imagej, in pixels. At the refiner resolution(B, h_r, w_r, 2)withrefine=True.dense_p2d{i}_n{j}: the same, in normalized coordinates.dense_confidence{i}:(B, h, w)confidence of each correspondence, zero where it fails a check.dense_valid{i}:(B, h, w)whether the correspondence passes the covisibility, filter, border and cycle checks.dense_cov{i}_i{j}:(B, h_r, w_r, 2, 2)covariance of the refined correspondences in pixels, withrefine=True.
pred["covisibility_score"] is the probability that the two images show the same place. With min_covisibility > 0, all matches of a pair below this score are rejected.
With early_stop_decay, FlexGlue also stops at an early layer once the pair is confidently rejected, which saves most of the compute on non-pairs (no batch support):
pred = matcher.match(inputs0, inputs1, min_covisibility=0.8, early_stop_decay=0.2)
pred["is_covisible"] # whether covisibility_score >= min_covisibility, only with min_covisibility > 0
pred["stop_layer"] # the layer at which FlexGlue stoppedNote that all matches of a pair are invalidated if its covisibility_score is below min_covisibility, and FlexGlue returns empty matches in this case.
FlexGlue matches a batch of image pairs in a single call. Images are passed as a batched tensor (B, 3, H, W) or as a list of (3, H, W) tensors, which may have different sizes:
images0 = [load_image(path).cuda() for path in paths0] # B images, any size
images1 = [load_image(path).cuda() for path in paths1]
# images only, or with a detector that returns a fixed number of keypoints
detector = ALIKED(origin="corner", max_num_keypoints=2048, detection_threshold=0.0).eval().cuda()
pred = matcher.match_pair(images0, images1, detector=detector)
# or describe first, e.g. to cache the descriptors
inputs0 = matcher.descriptor.describe(images0) # {features, image_size, image}
inputs1 = matcher.descriptor.describe(images1)
pred = matcher.match(inputs0, inputs1)
pred = pack_matches(pred) # lists of matches and scores, one entry per pair
for b in range(len(images0)):
matches = pred["matches"][b] # (K, 2)
points0 = pred["keypoints0"][b][matches[..., 0]] # pixels of images0[b]
points1 = pred["keypoints1"][b][matches[..., 1]] # pixels of images1[b]Batching has a few requirements:
- Images of different sizes are squashed to the same
describe_resize(the default(512, 512)). With an intdescribe_resizeorrefine_resize, all images must share the same aspect ratio. - All images need the same number of keypoints, for example a detector with
max_num_keypointsanddetection_threshold=0.0. - Early stopping only stops once all pairs of the batch are rejected.
[Detail of all parameters - click to expand]
DINOv3Descriptor:
resize: Backbone input size: an int sets the long side (aspect kept, sides floored to multiples of 32),(h, w)squashes. Default: (512, 512).describe: Also return descriptors at the given keypoints. Default: True.weights: Path or URL of the weights. Default: the release weights.
FlexGlue:
n_layers: Number of transformer layers, fewer is faster but less accurate. Default: 9 (all).filter_threshold: Minimum match confidence. Default: 0.1.max_distance_px: Maximum cycle error, or mutual nearest-neighbor distance in sparse mode, in pixels of the resized image. None disables it. Default: 3.0.min_covisibility: Reject all matches of pairs with a lowercovisibility_score, e.g. 0.8 for noisy image sets. Default: 0.0 (off).early_stop_decay: Stop after layerkifcovisibility_score < min_covisibility * (1 - early_stop_decay) ** (9 - k), lower stops earlier. Default: None (off).remove_border_px: Drop matches this close to the border, in pixels of the resized image. Default: 0.0.mp: Transformer mixed precision:"float16","bfloat16"orNone(float32). Default:"float16".describe_resize: Input size of the built-in descriptor, asresizeabove. Default: (512, 512).gemm_nn: Sparse mode: mutual nearest neighbors in one matrix multiplication, faster for many keypoints. Default: True.query_chunk_size: Points per correspondence-head call, bounds memory without changing results. Default: 4096.weights: Path or URL of the weights. Default: the release weights.coarse_stride: Grid pitch ofmatch_denseand the sampled mode, in pixels of the resized image (4 doubles the resolution). Default: 8.max_num_samples: Maximum number of sampled matches without keypoints. Default: 5000.kde_balance: Spread the sampled matches over the image as RoMa v2, instead of drawing by confidence. Default: True.refine: Refine with RoMa v2 refiners (needsimagein the inputs). Default: False.refine_resize: Input size of the refiners, asresizeabove. Default: (512, 512).refine_scales: Refiner strides, coarse to fine, fixed at construction. Default: (2, 1).
The default values give a good trade-off between speed and accuracy. For increased efficiency, use fewer layers to linearly reduce run time:
matcher = FlexGlue(n_layers=6)n_layers=0 is also supported and falls back to matching the descriptors, without any transformer layer.
To obtain fewer but more reliable matches, raise the confidence threshold and bound the cycle error in pixels of the resized image. These parameters can be specified at initialization or overwritten per-call:
matcher = FlexGlue(filter_threshold=0.2, max_distance_px=2.0)
pred = matcher.match(inputs0, inputs1, filter_threshold=0.2, max_distance_px=2.0)For keypoint-less matching, you can specify a maximum number of samples (correspondences are still filtered):
pred = matcher.match_pair(image0, image1, max_num_samples=1024)FlexGlue can be compiled with torch.compile, as LightGlue:
matcher = FlexGlue().eval().cuda().compile(mode="reduce-overhead")The transformer layers and the correspondence heads are compiled into CUDA graphs. Keypoints are padded to the next of static_lengths (512, 1024, 2048, 4096 by default), larger inputs fall back to eager mode. The first calls of each new input size are slow while compiling.
FlexGlue can also be loaded with torch.hub, without installing this repository. Arguments are passed to FlexGlue:
import torch
matcher = torch.hub.load("cvg/FlexGlue", "flexglue", trust_repo=True).eval().cuda()
matcher = torch.hub.load("cvg/FlexGlue", "flexglue", trust_repo=True, refine=True) # with arguments- Publish the inference code
- Integration into COLMAP
- Integration into hloc
- Release the training and evaluation code in glue-factory
The code provided in this repository is released under the MIT license. The model weights (flexglue.pth) contain the DINOv3 ConvNeXt-B backbone (extractor.backbone.*), which is distributed under the DINOv3 License and restricts its use. All other weights, the matcher and the descriptor heads, are released under the MIT license. The refiners in refiner.py and their weights come from RoMa v2 and follow its MIT license. The keypoint detectors are not part of this repository, see LightGlue for their licenses.
If you use any ideas from the paper or code from this repo, please consider citing:
@article{lindenberger2026flexglue,
author = {Philipp Lindenberger and
Paul-Edouard Sarlin and
Ondrej Miksik and
Marc Pollefeys},
title = {{FlexGlue: Sparse-to-Dense Feature Matching and Visual Disambiguation}},
journal = {arXiv preprint arXiv:2610.32976},
year = {2026}
}