Three papers that rebuilt visual place recognition: CosPlace turns geo-localization into classification with a large-margin loss; EigenPlaces makes that training viewpoint-robust; MixVPR replaces the aggregation head with a stack of feature-mixing MLPs. Together they set the modern standard for compact, accurate place descriptors.
You have a single photo taken somewhere in San Francisco — a street corner, a row of houses, a tree-lined block. You also have a database of tens of millions of geo-tagged street-view images covering the whole city. Your job: find which database images depict the same place as the query, so you can read off its GPS coordinate. This is Visual Place Recognition (VPR), the visual front-end of every "where am I?" system — autonomous driving, AR navigation, loop closure in SLAM, and image-based geo-localization.
The obvious approach is image retrieval: turn every image into a fixed-length vector (a descriptor), then for a query, find the database vectors closest to it. If two photos show the same place, their descriptors should be near each other; if they show different places, far apart. The whole game is learning a function that maps a pixel grid to a descriptor with exactly that property.
So what was broken before 2022? The dominant recipe was NetVLAD (Arandjelovic et al., 2016) trained with a triplet loss: pull a query toward a matching "positive" image and push it away from a non-matching "negative." That recipe has two structural problems that get worse as the map grows.
Left: triplet training must, for each anchor, search the whole database for a "hard negative" that looks similar but is a different place. Right: classification training assigns each image a class label up front — no per-step search. Press "Add images" and watch the mining cost (left, quadratic-ish) explode while classification cost (right) stays flat.
First, mining. A triplet is only informative if the negative is hard — a different place that nevertheless looks similar. Finding hard negatives means, at each step, comparing the anchor against a huge pool of database descriptors. As the database grows from thousands to tens of millions of images, mining dominates the wall-clock cost and you must constantly re-cache descriptors that drift as the network trains.
Second, memory and label noise. To form a positive pair you need to know two images depict the same place; GPS gives you a noisy hint, but two photos taken 10 meters apart facing opposite directions show completely different scenes. Triplet training quietly assumes geographic proximity equals visual similarity, which is often false.
That reframing is the through-line of this Veanor. CosPlace (Berton et al., CVPR 2022) trains VPR as a classification problem with a face-recognition loss. EigenPlaces (Berton et al., ICCV 2023) fixes a viewpoint blind spot in that scheme. MixVPR (Ali-bey et al., WACV 2023) attacks a different bottleneck — the aggregation step that turns a CNN feature map into a single descriptor — replacing it with a stack of feature-mixing MLPs. By the end you will be able to derive the CosFace margin and implement the MixVPR mixer from scratch.
Before we change how we train, let's nail how VPR is used at inference, because that constraint shapes every design decision. At test time there is no classifier and no labels — only nearest-neighbor search in descriptor space.
Formally, an image I is passed through a backbone CNN f (a ResNet, frozen-then-finetuned) to produce a feature map of shape [C, H, W] — C channels over an H×W spatial grid. An aggregation module g collapses that grid into a single vector and an L2 normalization makes it a unit vector:
Here D is the descriptor dimension — 512 is the canonical choice (compact enough that 10M descriptors fit comfortably in memory, expressive enough to separate places). Because every descriptor is unit-norm, the dot product between two descriptors equals their cosine similarity:
At inference, the query descriptor is compared against every database descriptor by this dot product, and the database images are ranked by similarity. The top-ranked image's GPS is the predicted location. Performance is reported as Recall@N (R@N): the fraction of queries for which at least one of the top-N retrieved images is within a threshold distance (commonly 25 m) of the true location.
Let's do a tiny worked example. Suppose D = 3 and we have unit descriptors:
| Image | Descriptor | d · dq | Rank |
|---|---|---|---|
| Query q | [0.80, 0.60, 0.00] | — | — |
| m1 (same place) | [0.77, 0.64, 0.00] | 0.80·0.77 + 0.60·0.64 = 0.999 | 1 |
| m2 (nearby, diff view) | [0.00, 0.80, 0.60] | 0.80·0 + 0.60·0.80 = 0.480 | 2 |
| m3 (other place) | [-0.60, 0.00, 0.80] | 0.80·(-0.6) = -0.480 | 3 |
The match m1 wins because its descriptor points in almost the same direction as the query. Notice m2 is geographically nearby but faces a different way — its descriptor is only mildly similar. That single fact (same GPS, different appearance) is the wedge EigenPlaces will later drive into.
A query (warm) and three database descriptors on a 2D unit circle. Drag the query angle; the database images re-rank live by cosine similarity (dot product). The thicker the connecting arc, the higher the similarity. Watch how a small angle change flips the top match.
CosPlace's central move: stop training on pairs/triplets and instead train on a classification proxy. The label space of VPR is continuous — every image has a (latitude, longitude, heading). CosPlace discretizes that continuous space into a finite set of classes, then trains the backbone to classify which class an image belongs to. The classifier head is thrown away at the end; what we keep is the backbone that learned descriptors which separate the classes.
How is a class defined? By a cell in geographic space. The world is divided into a grid of square cells of side length M meters (e.g. M = 10 m). The heading (compass) is divided into bins of α degrees (e.g. α = 30°). A class is one (cell, heading-bin) pair — all images taken roughly from the same spot, facing roughly the same direction.
The heading bin matters a great deal. Two photos taken at the same GPS but facing opposite directions share no visual content — making them the same class would force the network to map two unrelated scenes to one label, which is impossible and harms training. Splitting by heading keeps each class visually coherent: same place, same direction, genuinely similar pictures.
A city map (top-down). Cells of side M tile the ground; each cell is split into heading wedges of α. Click a cell to highlight its classes — every (cell, heading) wedge is one classification target. Drag the sliders to change M and α and watch the class count change.
Worked example. Suppose an image is at east = 327 m, north = 154 m (relative to a city origin), heading = 71°, with M = 10 m and α = 30°:
Now training is a plain classification problem with cross-entropy: feed an image, predict its class, backprop. No mining, no triplets, no per-step database search. Each image's label is fixed for the whole run — computed once from its metadata. This is the property that lets CosPlace train on San Francisco eXtra Large (SF-XL), a dataset of tens of millions of images covering the entire city.
This is the mathematical heart of CosPlace, borrowed from face recognition (Wang et al., CosFace / Large Margin Cosine Loss, 2018). We derive it from ordinary softmax in four steps, because each step removes a specific weakness.
A linear classifier has a weight vector Wj per class j and a bias bj. The logit for class j on a descriptor x (the backbone output before normalization) is zj = WjTx + bj. The softmax loss for an example with true class y is:
Set bj = 0. A dot product equals the product of norms times the cosine of the angle between the vectors:
where θj is the angle between the descriptor x and the class direction Wj. The problem: the loss can be reduced by inflating the norms ‖Wj‖ or ‖x‖ instead of improving the angle — but at test time, only the angle (cosine) is used. The training objective and the test metric disagree.
Force ‖Wj‖ = 1 (normalize each class weight) and ‖x‖ = s (rescale every descriptor to a fixed radius s). Now the logit is purely the cosine, scaled by s:
The constant s (the scale, e.g. s = 30) is needed because cosines live in [−1, 1]; without scaling, the largest possible logit gap is 2, and softmax can never become confident. Multiplying by s widens the usable range of logits so the softmax can sharpen.
Here is the CosFace idea. We make the network's job harder for the correct class by subtracting a fixed margin m from its cosine before the softmax:
To classify the example correctly, the network can no longer settle for cosθy just barely exceeding the other cosines. It must satisfy:
The correct-class cosine must beat every other cosine by at least m. This carves an angular decision margin between classes. The descriptors of one place are pulled into a tight cone and a buffer of width set by m is left empty around it — exactly the compactness that retrieval needs.
Worked numerical example. Take s = 30, m = 0.35, and a 3-class problem. Suppose for some descriptor the cosines to the three class directions are cosθ = [0.70 (true class y=0), 0.55, 0.20].
So the margin keeps the gradient alive long after a plain softmax would have given up, relentlessly compacting each class. Let's see the geometry.
Two class directions (warm, teal) on the unit circle, with their decision boundary. Increase the margin m: the single boundary splits into two, opening a forbidden gap of angular width set by m. Descriptors of each class are forced strictly into their own cone. Drag s to see how scale sharpens the softmax (the green probability bar).
python import torch import torch.nn.functional as F def cosface_loss(x, W, labels, s=30.0, m=0.35): """ x: [B, D] backbone descriptors (pre-norm) W: [D, C] class direction matrix labels: [B] integer class ids """ x = F.normalize(x, dim=1) # put descriptors on unit sphere Wn = F.normalize(W, dim=0) # unit class directions cos = x @ Wn # [B, C] = cos(theta) to every class # subtract margin ONLY from the true-class column onehot = F.one_hot(labels, cos.size(1)).float() logits = s * (cos - m * onehot) # [B, C] return F.cross_entropy(logits, labels)
GeoClassification plus CosFace gives a beautiful objective, but there's a practical wall: SF-XL has hundreds of thousands of classes. The CosFace classifier head is a matrix W of shape [D, C]. With D = 512 and C in the hundreds of thousands, that single layer is enormous, and worse, every class direction is updated on every batch through the softmax denominator, which is both slow and memory-hungry.
CosPlace's fix is the CosPlace Group. The key observation: adjacent cells in the grid are visually correlated (a photo in cell (32,15) and one in the neighbor cell (33,15) might overlap). If two classes can appear in the same training batch and they're nearly the same scene, the margin loss fights itself. So CosPlace partitions the grid so that classes within one group are guaranteed far apart, and trains on one group at a time.
Concretely, group membership is defined by the cell indices modulo a spacing N:
With N = 2, this creates groups in a checkerboard pattern: group (0,0) contains cells (0,0), (0,2), (2,0), (2,2), … — every class in this group is at least N×M meters from every other, so no two classes in a group can be confused by overlap. Each group has far fewer classes than the full set, so its CosFace head is small. Training cycles through groups round-robin: load group's classifier, train some iterations, save it, move to the next group.
The grid of cells, colored by group membership under (east mod N, north mod N). Increase N: cells of the same color spread further apart, guaranteeing classes within a group never overlap. Each group is trained with its own small CosFace head. Click "Cycle group" to watch training rotate through the groups.
Let's count. Say the city covers 1000×1000 cells (M = 10 m → 10×10 km) with 12 heading bins (α = 30°). That's 1000×1000×12 = 12 million classes total. With N = 2, there are N×N = 4 spatial groups (times heading), so each group holds about 12M / 4 = 3 million classes — and at any moment only one group's head (3M, not 12M directions) is in memory and being updated. The backbone, shared across all groups, accumulates everything it learns from each group, while no single CosFace head ever has to be the full size.
| Quantity | Symbol | Typical value | Role |
|---|---|---|---|
| Cell size | M | 10–15 m | Spatial granularity of a class |
| Heading bin | α | 30° | Splits a cell by viewing direction |
| Group spacing | N | 2–5 | Guarantees intra-group separation |
| Descriptor dim | D | 512 | Compact, fits 10M+ in memory |
| Scale / margin | s, m | 30, 0.4 | CosFace hyperparameters |
CosPlace works beautifully when the query and database images share a viewpoint. But a fundamental VPR failure mode remains: lateral viewpoint shift. A car-mounted camera drives down a street facing forward; later a pedestrian photographs the same building from across the street, facing sideways. Same place, radically different image. CosPlace's heading-bin split actually worsens this — by definition it puts the two views in different classes, so the loss never directly tells the network they depict one place.
EigenPlaces (Berton et al., ICCV 2023) fixes this at the level of how classes are constructed. The idea: instead of one class per (cell, heading), build classes that deliberately span multiple viewpoints of the same physical location, so that during training the margin loss is forced to pull together images of one place taken from genuinely different angles.
Where does "eigen" come from? For each cell, EigenPlaces collects the 2D ground positions of all cameras that took images there. Those positions, for a typical street, lie roughly along a line — the street. EigenPlaces runs PCA on the camera positions: the top eigenvector of their covariance is the dominant axis of camera motion (the street direction). That direction defines two canonical viewpoints to look at the place from:
Mechanically: take the cameras in a cell, compute the covariance of their (x, y) positions, eigendecompose it. The eigenvector with the larger eigenvalue is the street axis e1; the smaller is e2, the across-street axis. EigenPlaces then defines a focal point — a target 3D location a fixed distance out along e2 — and groups together all images whose camera is positioned-and-oriented to be looking toward that focal point, regardless of which street segment the camera sits on.
Dots are camera positions in one cell. The warm arrow is the top eigenvector e₁ (street direction); the teal arrow is e₂ (across-street). The star is the lateral focal point a fixed distance along e₂. Drag the spread slider to make the cameras more/less collinear and watch the eigenvectors snap to the dominant axis.
Worked example of the PCA step. Suppose four cameras sit at positions (0,0), (2,0.1), (4,−0.1), (6,0). Their mean is (3, 0). The mean-centered points are (−3,0), (−1,0.1), (1,−0.1), (3,0). The covariance:
The large diagonal entry (5.0) is along x and the tiny one (0.005) along y, so the top eigenvector is essentially e1 ≈ (1, 0) — the street runs east–west, exactly as the points suggest. The across-street axis is e2 ≈ (0, 1). A lateral class then collects every camera (on any nearby street segment) oriented to look in the ±e2 direction at a focal point — a building facade. Forced into one class, the frontal-driving and lateral-photographing views of that facade get pulled together by the margin loss.
CosPlace and EigenPlaces are about training. MixVPR (Ali-bey et al., WACV 2023) is about a different component entirely: the aggregation g that turns the CNN feature map into the descriptor. This is the step that NetVLAD, GeM pooling, and average pooling all try to do. MixVPR replaces it with a stack of MLPs and beats them all while being smaller.
Run an image through a backbone (ResNet-50, truncated) and take an intermediate feature map of shape [C, H, W] — for example [C=1024, H=20, W=20]. MixVPR's first move is to view this not as one 3D tensor but as C feature maps, each a flattened H·W vector of length P = H×W (here 400). Reshape to a matrix:
The heart of MixVPR is the Feature-Mixer block, lifted from the MLP-Mixer idea (Tolstikhin 2021). A Feature-Mixer is a tiny two-layer MLP applied across the spatial dimension P, shared across all C channels, wrapped in a residual connection:
Read it carefully. LN is layer norm. We transpose so the MLP mixes along P (the H·W positions): W1 is [P′, P], σ is ReLU/GELU, W2 is [P, P′]. The MLP takes a length-P vector (one channel's spatial map) and returns a length-P vector — a learned, global recombination of every spatial location. The same MLP weights are applied to all C channels. The residual F + means each block refines rather than replaces.
Why mix across space? A convolution only ever combines a local neighborhood. NetVLAD aggregates by soft-assigning each location to cluster centers. The Feature-Mixer instead lets every spatial position talk to every other through a dense MLP — a global, content-adaptive aggregation. Crucially it operates on the flattened map, so it discards the rigid 2D grid and learns which spatial regions matter for distinguishing places (e.g. building facades over sky/road).
MixVPR stacks L Feature-Mixer blocks (e.g. L = 4). Because each is residual and shares weights across channels, the parameter count stays modest. After the stack, two linear projections compress the [C, P] matrix to the final descriptor:
Wdepth projects the channel dimension C → C′ (e.g. 1024 → 1024 or fewer), Wrow projects the spatial dimension P → R (e.g. 400 → 4). The result is a [C′, R] matrix, flattened and normalized into the descriptor of dimension D = C′·R (e.g. 1024×4 = 4096, or projected down to 512). No clustering, no parameters-per-cluster, just MLPs.
A single channel's flattened spatial map (left, P values) is fed through a two-layer MLP shared across all channels, then added back via the residual (right). Increase "mixing strength" to see the MLP blend distant spatial locations together — something convolution and pooling cannot do. Each row is one channel; watch all channels transform with the same weights.
python import torch import torch.nn as nn import torch.nn.functional as F class FeatureMixer(nn.Module): # a 2-layer MLP that mixes ACROSS the P=H*W spatial positions, # shared across all channels, with a residual connection def __init__(self, P, hidden): super().__init__() self.ln = nn.LayerNorm(P) self.fc1 = nn.Linear(P, hidden) self.fc2 = nn.Linear(hidden, P) def forward(self, F_): # F_: [B, C, P] h = self.ln(F_) h = self.fc2(F.relu(self.fc1(h))) # mix along P return F_ + h # residual class MixVPR(nn.Module): def __init__(self, C, P, L=4, hidden=512, c_out=1024, r_out=4): super().__init__() self.mixers = nn.Sequential(*[FeatureMixer(P, hidden) for _ in range(L)]) self.depth = nn.Linear(C, c_out) # project channels C -> c_out self.row = nn.Linear(P, r_out) # project positions P -> r_out def forward(self, fmap): # fmap: [B, C, H, W] B, C, H, Wd = fmap.shape F_ = fmap.flatten(2) # [B, C, P] P = H*W F_ = self.mixers(F_) # L Feature-Mixer blocks F_ = self.depth(F_.transpose(1,2)).transpose(1,2) # [B, c_out, P] F_ = self.row(F_) # [B, c_out, r_out] d = F.normalize(F_.flatten(1), dim=1) # [B, c_out*r_out] return d
What did these three papers actually buy? Let's read the headline numbers and the ablations that justify each design choice. (Exact figures depend on backbone and benchmark; the relationships below are the robust, reported findings.)
CosPlace's two claims are scale and compactness. It introduced SF-XL (tens of millions of images, the whole of San Francisco) and showed that classification-without-mining trains efficiently at that scale where triplet mining becomes impractical. Critically, it reached state-of-the-art recall with a compact 512-D descriptor using a small ResNet-18 backbone that fits in under 4 GB of VRAM — and improved further with ResNet-50. Trained on one city, it generalized strongly to entirely different test sets (Pitts250k, Tokyo, etc.), evidence that GeoClassification learns transferable place features, not city-specific shortcuts.
EigenPlaces' claim is viewpoint robustness at no extra inference cost. By rebuilding classes around PCA-derived focal points, it improved recall over CosPlace specifically on benchmarks with strong lateral viewpoint variation, while keeping the same compact descriptor, the same CosFace loss, and the same fast classification training. It became a standard strong baseline for the 2023 generation of VPR systems.
MixVPR's claim is better aggregation, fewer parameters. With a ResNet-50 backbone and the Feature-Mixer stack, it reported recall@1 around 94.6% on Pitts250k-test, 88.0% on MapillarySLS, and strong results on the very hard Nordland (seasonal change) benchmark — outperforming NetVLAD and CosPlace while using less than half the parameters of those aggregations. The win comes from replacing cluster-based or pooling aggregation with global feature mixing.
| Paper | Core change | What it buys | Descriptor |
|---|---|---|---|
| CosPlace (CVPR'22) | VPR as classification + CosFace + Groups | City-scale training, no mining, SOTA at 512-D | 512-D, compact |
| EigenPlaces (ICCV'23) | Viewpoint classes via PCA focal points | Robustness to lateral viewpoint shift | 512-D, same |
| MixVPR (WACV'23) | Feature-Mixer (MLP) aggregation | Higher recall, <half the params of NetVLAD/CosPlace | e.g. 4096 or 512-D |
Illustrative Recall@N curves (R@1 … R@20) for a weak baseline vs a strong modern system. A good system has high R@1 AND the curve saturates fast (the right answer is almost always in the top few). Toggle which curve is highlighted.
This is the payoff. Below is a complete, live VPR pipeline on a toy 2D "city." Database images are 2D descriptors on the unit circle, colored by the place they belong to. You control the CosFace margin m and scale s, run gradient steps to see the classes compact under the margin, then issue a query and watch nearest-neighbor retrieval rank the database. Everything you learned is here at once.
Left circle: descriptors of 3 places (warm/teal/blue), with the CosFace decision wedges. Press "Train step" repeatedly to apply the margin loss — watch each place's descriptors tighten into a cone with a gap (margin) around it. Then "Query" drops a random query (white) and the right panel ranks the database by cosine similarity; the predicted place is the top bar. Raise the margin and re-train to see retrieval get cleaner.
Things to try, and what they reveal:
These three papers define the modern classification-and-aggregation lineage of VPR, but they are not the end of the story. Honest limitations:
The CosFace margin and the feature-mixing aggregator both reuse ideas you can study elsewhere on Engineermaxxing:
| Concept | One-line summary |
|---|---|
| GeoClassification | Discretize (GPS, heading) into classes; train VPR as classification — no mining, no triplets. |
| Class definition | class = (⌊east/M⌋, ⌊north/M⌋, ⌊heading/α⌋); each class = same spot, same direction. |
| CosFace logit | zj = s·cosθj, with the true class using s·(cosθy − m). |
| Margin effect | Forces cosθy > cosθj + m → tight, separated class cones. |
| Scale s | Widens the logit range (cosines are in [−1,1]) so softmax can become confident. |
| CosPlace Group | group = (east mod N, north mod N, heading); guarantees intra-group separation, small head. |
| EigenPlaces | PCA on camera positions → street axis → frontal/lateral focal-point classes → viewpoint robustness. |
| Feature-Mixer | FM(F) = F + W2σ(W1·LN(F)T)T; 2-layer MLP mixing across P=H·W, residual, shared over channels. |
| MixVPR aggregation | Stack L mixers, then depth + row projections → flatten → L2-norm → descriptor. |
| Inference | d = normalize(g(f(I))); rank database by dq·dm; report Recall@N. |
"What I cannot create, I do not understand." — You have now created the CosFace loss and the MixVPR mixer by hand. You understand them.