File size: 9,079 Bytes
2242b6a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 | ---
license: other
license_name: dinov3-license
license_link: https://ai.meta.com/resources/models-and-libraries/dinov3-license
base_model: facebook/dinov3-vitl16-pretrain-lvd1689m
base_model_relation: finetune
library_name: pytorch
tags:
- geolocation
- image-regression
- flow-matching
- dinov3
- street-view
- jakarta
- computer-vision
language:
- id
datasets:
- nadh0708/JKTSV-primary
---
# Img2LocJakarta 2.0 — DINOv3 + flow matching street-view geolocation
Predict a **distribution** of **where a street-view image was taken in Jakarta**. A fine-tuned
**DINOv3 ViT-L/16** backbone encodes the image, then a conditional flow-matching (CFM) head
propagates Gaussian-noise particles onto the geographic coordinates.
| | |
|---|---|
| **Input** | RGB street-level image (any size, resized to 256×256) |
| **Output** | `num_particles` predicted coordinates (100 particles → 100 `lat/lon` points), as a list or GeoJSON |
| **Scope** | Jakarta street imagery only [primary road] |
| **Backbone** | DINOv3 ViT-L/16, fine-tuned end to end |
| **Head** | Velocity MLP (4 AdaLN residual blocks, hidden 512), Heun sampler |
## Quick Start
```bash
pip install torch torchvision pillow numpy huggingface_hub safetensors
```
`git` is needed on first run: the DINOv3 *architecture* source is cloned from
`facebookresearch/dinov3` into the torch hub cache (no extra weights are downloaded).

```python
from huggingface_hub import hf_hub_download
# Download the inference code and the example image
for fname in ["inference.py", "modeling_geotag.py", "examples/kokas.png"]:
hf_hub_download("nadh0708/Img2LocJakarta2.0", fname, local_dir=".")
from inference import GeoTagPredictor
predictor = GeoTagPredictor("nadh0708/Img2LocJakarta2.0")
coords = predictor.predict("examples/kokas.png", num_particles=100)
print(len(coords), coords[0])
# 100 {'lat': -6.2257, 'lon': 106.8309} <- 100 predicted coordinates
# Batched: one list of coordinates per image
clouds = predictor.predict(["a.jpg", "b.jpg"], num_particles=100)
# Optional: mean point + uncertainty of one cloud
print(predictor.summarize(coords))
# {'lat': -6.2246, 'lon': 106.8335, 'n_particles': 100, 'sigma_m': 484.3,
# 'p50_radius_m': 372.1, 'p90_radius_m': 734.0}
# GeoJSON, one Point feature per coordinate
geojson = predictor.to_geojson(["examples/kokas.png"], num_particles=100)
```
The printed values are from a CUDA GPU with the default `seed=0`. On CPU the random draws
differ, so the cloud is slightly different (mean `-6.2247, 106.8339`, `sigma_m` 496.2).
### From a local folder
```python
predictor = GeoTagPredictor(".") # folder containing model.safetensors
```
### CLI (GeoJSON output)
```bash
python inference.py nadh0708/Img2LocJakarta2.0 examples/kokas.png --particles 100 -o out.geojson
python inference.py nadh0708/Img2LocJakarta2.0 a.jpg b.jpg --particles 512 --seed 1
python inference.py nadh0708/Img2LocJakarta2.0 a.jpg --truth 106.83 -6.22 # adds error_m
python inference.py nadh0708/Img2LocJakarta2.0 a.jpg --table # text summary
```
`--particles N` gives N coordinates per image, so N Point features. Open `out.geojson` in
QGIS or geojson.io to see them on a map.
### Low-level API
```python
import torch
from PIL import Image
from torchvision.transforms import v2
from modeling_geotag import DinoV3CFMRegressor
model = DinoV3CFMRegressor.from_pretrained("nadh0708/Img2LocJakarta2.0")
tf = v2.Compose([
v2.ToImage(),
v2.Resize((256, 256), antialias=True),
v2.ToDtype(torch.float32, scale=True),
v2.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])
x = tf(Image.open("examples/kokas.png").convert("RGB")).unsqueeze(0)
cloud = model.sample_lonlat(x, num_particles=256, steps=8) # (1, 256, 2) [lon, lat]
```
## Output
`predict(image, num_particles=N)` returns **N predicted coordinates (the predictive distribution)**
for the image, each `{"lat": ..., "lon": ...}` in WGS-84 degrees.
GeoJSON (`to_geojson` / CLI) is a `FeatureCollection` with one `Point` per coordinate
(properties: `image`, `particle`, plus `error_m` if a true location is given) and a
top-level `summary` list with one entry per image:
| `summary` field | Meaning |
|---|---|
| `lat`, `lon` | mean of the coordinates |
| `n_particles` | number of coordinates |
| `sigma_m` | RMS spread around the mean, metres |
| `p50_radius_m`, `p90_radius_m` | radius around the mean containing 50 % / 90 % of the coordinates |
| `error_m` | distance of the mean to `--truth`, when given |
`num_particles` *is* the number of samples drawn from the predicted coordinate distribution;
more particles give a smoother estimate. `seed` makes the cloud reproducible.
## Performance
Evaluated on the **full test split of `JKTSV-primary`: 137,173 images** (8 headings per
location, 0°–315° in 45° steps), with this checkpoint (stage D, epoch 80). Setup:
32 particles, Heun integrator. Error is the great-circle (haversine) distance between the
true location and the **mean of the particle cloud**.
| Metric | Value |
|---|---|
| Mean error | 1.05 km |
| Median error | 0.40 km |
| 75th percentile | 0.82 km |
| 90th percentile | 2.34 km |
| RMSE | 2.36 km |
| Within | 100 m | 250 m | 500 m | 1 km | 2 km | 5 km |
|---|---|---|---|---|---|---|
| % of test images | 6.9 | 30.4 | 59.3 | 79.4 | 88.7 | 95.2 |
### How the error shrinks while the flow runs
Mean particle error over the test set:
| Flow time t | 0.0 | 0.2 | 0.4 | 0.5 | 0.6 | 0.8 | 1.0 |
|---|---|---|---|---|---|---|---|
| Mean error (km) | 11.7 | 8.9 | 3.9 | 2.4 | 1.7 | 1.4 | 1.4 |
#### Note
* Individual particles are noisier than their mean, so the cloud mean is the better single
point estimate.
* Numbers were measured with 32 particles; the default in `predict` is 256.
## Architecture
```
DinoV3CFMRegressor
├─ encoder_net : DINOv3 ViT-L/16 (fine-tuned), CLS token -> (B, 1024)
├─ velocity_head : v(y_t, t, cond) -> R^2
│ [cond ‖ y_t] -> 512 -> 4 × AdaLN residual block (time-conditioned) -> 2
└─ target_mean/var: standardiser of the local (x, y) metre targets (stored in the weights)
sampling: y_0 ~ N(0, I) --Heun, 8 steps--> y_1 --de-standardise--> (x, y) metres
--inverse flat-earth projection--> (lon, lat)
```
The meter offsets are relative to the Jakarta origin `(lon 106.828320, lat -6.227468)`.
The checkpoint bundles the fine-tuned backbone and the head, so no separate LVD-1689M
weight download is required.
## Training
Final checkpoint: stage D (full-backbone fine-tuning), epoch 80. Best validation RMSE
logged during training: 2.32 km. Trained on
[`nadh0708/JKTSV-primary`](https://huggingface.co/datasets/nadh0708/JKTSV-primary)
(Google Street View along Jakarta motorway and primary roads). The backbone starts from
Meta's [DINOv3 ViT-L/16 (LVD-1689M)](https://huggingface.co/facebook/dinov3-vitl16-pretrain-lvd1689m).
## Limitations
- **Geographically restricted to Jakarta.**
- Uncertainty is not calibrated against a likelihood.
- Trained on street-view cutouts; other camera settings may degrade accuracy.
## Files
| File | Purpose |
|---|---|
| `modeling_geotag.py` | Model classes, Heun sampler, `from_pretrained` loader, coordinate conversion |
| `inference.py` | `GeoTagPredictor` high-level API + GeoJSON CLI |
| `model.safetensors` | Full state dict (fine-tuned backbone + head + standardiser, ~1.2 GB) |
| `requirements.txt` | Runtime dependencies |
| `examples/kokas.png` | Example street-view image used in the Quick Start |
| `LICENSE` | Apache License 2.0 (the code) |
| `LICENSE-DINOv3.md` | DINOv3 License (the weights) |
## Acknowledgment
Thanks to Meta for this onderful model & Claude for helping me write the inference code for this repo.
## Citation
The backbone is DINOv3:
```bibtex
@misc{simeoni2025dinov3,
title={{DINOv3}},
author={Sim{\'e}oni, Oriane and Vo, Huy V. and Seitzer, Maximilian and Baldassarre, Federico and Oquab, Maxime and Jose, Cijo and Khalidov, Vasil and Szafraniec, Marc and Yi, Seungeun and Ramamonjisoa, Micha{\"e}l and Massa, Francisco and Haziza, Daniel and Wehrstedt, Luca and Wang, Jianyuan and Darcet, Timoth{\'e}e and Moutakanni, Th{\'e}o and Sentana, Leonel and Roberts, Claire and Vedaldi, Andrea and Tolan, Jamie and Brandt, John and Couprie, Camille and Mairal, Julien and J{\'e}gou, Herv{\'e} and Labatut, Patrick and Bojanowski, Piotr},
year={2025},
eprint={2508.10104},
archivePrefix={arXiv},
primaryClass={cs.CV},
url={https://arxiv.org/abs/2508.10104},
}
```
## License
- **Code** (`inference.py`, `modeling_geotag.py`): Apache License 2.0, see [`LICENSE`](LICENSE).
- **Weights** (`model.safetensors`): the backbone is fine-tuned from Meta's DINOv3, so the
weights are a derivative of the DINO Materials and are distributed under the
**DINOv3 License**, see [`LICENSE-DINOv3.md`](LICENSE-DINOv3.md).
- The DINOv3 architecture source is fetched from `facebookresearch/dinov3` at runtime and
remains under its own license.
|