Upload folder using huggingface_hub
Browse files- README.md +187 -0
- inference.py +102 -0
- model.safetensors +3 -0
- modeling_geotag.py +192 -0
- requirements.txt +6 -0
README.md
CHANGED
|
@@ -1,3 +1,190 @@
|
|
| 1 |
---
|
| 2 |
license: apache-2.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
license: apache-2.0
|
| 3 |
+
tags:
|
| 4 |
+
- geolocation
|
| 5 |
+
- image-regression
|
| 6 |
+
- dinov3
|
| 7 |
+
- street-view
|
| 8 |
+
- jakarta
|
| 9 |
+
- computer-vision
|
| 10 |
+
library_name: pytorch
|
| 11 |
+
language:
|
| 12 |
+
- id
|
| 13 |
+
datasets:
|
| 14 |
+
- nadh0708/JKTSV-primary
|
| 15 |
---
|
| 16 |
+
|
| 17 |
+
# Img2LocJakarta β DINOv3 street-view geolocation for Jakarta
|
| 18 |
+
|
| 19 |
+
Predict **(latitude, longitude)** of a Google Street View perspective crop taken
|
| 20 |
+
on a Jakarta road. A frozen **DINOv3 ViT-L/16** backbone extracts features; a
|
| 21 |
+
U-shaped MLP head regresses a local flat-earth (x, y) offset which is converted
|
| 22 |
+
to WGS-84 degrees.
|
| 23 |
+
|
| 24 |
+
| | |
|
| 25 |
+
|---|---|
|
| 26 |
+
| **Input** | RGB street-level image (any size, resized to 224Γ224) |
|
| 27 |
+
| **Output** | `{"lat": float, "lon": float}` β WGS-84 degrees |
|
| 28 |
+
| **Scope** | Trained on Jakarta (5 administrative cities, motorway + primary roads) |
|
| 29 |
+
| **Backbone** | DINOv3 ViT-L/16 β frozen; only the regression head was trained |
|
| 30 |
+
|
| 31 |
+
## Quick Start
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
pip install torch torchvision pillow huggingface_hub safetensors
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
```python
|
| 38 |
+
from huggingface_hub import hf_hub_download
|
| 39 |
+
import sys, os
|
| 40 |
+
|
| 41 |
+
# Download the inference code
|
| 42 |
+
for fname in ["inference.py", "modeling_geotag.py"]:
|
| 43 |
+
hf_hub_download("nadh0708/Img2LocJakarta", fname, local_dir=".")
|
| 44 |
+
|
| 45 |
+
from inference import GeoTagPredictor
|
| 46 |
+
|
| 47 |
+
predictor = GeoTagPredictor("nadh0708/Img2LocJakarta")
|
| 48 |
+
print(predictor.predict("street.jpg"))
|
| 49 |
+
# {'lat': -6.2261, 'lon': 106.8123}
|
| 50 |
+
|
| 51 |
+
# Batched
|
| 52 |
+
print(predictor.predict(["a.jpg", "b.jpg"]))
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
### From a local checkpoint
|
| 56 |
+
|
| 57 |
+
```python
|
| 58 |
+
predictor = GeoTagPredictor("modelD_40e_2.pth")
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### CLI
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
python inference.py nadh0708/Img2LocJakarta street.jpg
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
### Low-level API
|
| 68 |
+
|
| 69 |
+
```python
|
| 70 |
+
import torch
|
| 71 |
+
from torchvision import transforms
|
| 72 |
+
from PIL import Image
|
| 73 |
+
from modeling_geotag import DinoGeoRegressor
|
| 74 |
+
|
| 75 |
+
model = DinoGeoRegressor.from_pretrained("nadh0708/Img2LocJakarta")
|
| 76 |
+
model.eval()
|
| 77 |
+
|
| 78 |
+
tf = transforms.Compose([
|
| 79 |
+
transforms.Resize((224, 224)),
|
| 80 |
+
transforms.ToTensor(),
|
| 81 |
+
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
|
| 82 |
+
])
|
| 83 |
+
x = tf(Image.open("street.jpg").convert("RGB")).unsqueeze(0)
|
| 84 |
+
lonlat = model.predict_lonlat(x) # tensor([[lon, lat]])
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## Architecture
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
DinoGeoRegressor
|
| 91 |
+
ββ encoder : DINOv3 ViT-L/16 (frozen, patch_size=16)
|
| 92 |
+
β last block tokens (B, 201, 1024) ββflattenβββΊ (B, 205824)
|
| 93 |
+
β tokens: 196 patch + 1 CLS + 4 storage
|
| 94 |
+
ββ head : UNet-MLP
|
| 95 |
+
205824 ββenc1βββΊ 512 ββenc2βββΊ 512
|
| 96 |
+
β bottleneck 512
|
| 97 |
+
512 βββdec2ββ 1024 (cat)
|
| 98 |
+
512 βββdec1ββ 1024 (cat) skip connections from enc1, enc2
|
| 99 |
+
β out linear
|
| 100 |
+
2 (x_metres, y_metres)
|
| 101 |
+
ββcoord_convertβββΊ (lon, lat) degrees
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
The output is a flat-earth offset in metres relative to Jakarta origin
|
| 105 |
+
`(lon 106.828320, lat -6.227468)`, inverted to degrees at inference time.
|
| 106 |
+
|
| 107 |
+
The published checkpoint bundles the **frozen backbone + trained head** β no
|
| 108 |
+
separate LVD-1689M weight download is required. Only the DINOv3 **code** is
|
| 109 |
+
pulled from `torch.hub` on first load.
|
| 110 |
+
|
| 111 |
+
## Performance
|
| 112 |
+
|
| 113 |
+
Evaluated on **137,173 test samples** (Google Street View perspective crops of
|
| 114 |
+
Jakarta roads, 8 headings: 0Β°β315Β° in 45Β° steps).
|
| 115 |
+
|
| 116 |
+
| Epoch | Mean error | Median error | % < 1 km | % < 5 km | % < 25 km |
|
| 117 |
+
|-------|-----------|-------------|---------|---------|---------|
|
| 118 |
+
| e15 | 3.313 km | 1.966 km | 25.6% | 79.4% | 100.0% |
|
| 119 |
+
| e35 | 3.158 km | 1.588 km | 33.8% | 80.2% | 100.0% |
|
| 120 |
+
| **e40** | **2.743 km** | **1.272 km** | **42.2%** | **83.1%** | **100.0%** |
|
| 121 |
+
|
| 122 |
+
Error is geodesic (haversine) distance between the predicted and true GPS
|
| 123 |
+
coordinate. **This checkpoint is epoch 40** (`modelD_40e_2.pth`).
|
| 124 |
+
|
| 125 |
+
## Training
|
| 126 |
+
|
| 127 |
+
| Hyperparameter | Value |
|
| 128 |
+
|---|---|
|
| 129 |
+
| Backbone | DINOv3 ViT-L/16 (`facebookresearch/dinov3`) β frozen |
|
| 130 |
+
| Head | UNet-MLP, hidden 512, skip connections |
|
| 131 |
+
| Optimiser | AdamW |
|
| 132 |
+
| Learning rate | 1e-3 |
|
| 133 |
+
| Weight decay | 1e-4 |
|
| 134 |
+
| Batch size | 64 |
|
| 135 |
+
| Epochs | 40 |
|
| 136 |
+
| Input size | 224 Γ 224 |
|
| 137 |
+
| Loss | MSE on flat-earth (x, y) metres |
|
| 138 |
+
|
| 139 |
+
**Dataset:** [`nadh0708/JKTSV-primary`](https://huggingface.co/datasets/nadh0708/JKTSV-primary) β
|
| 140 |
+
685,848 perspective crops (548,675 train / 137,173 test) sampled along
|
| 141 |
+
Jakarta motorway and primary road segments at 50 m intervals, 8 headings.
|
| 142 |
+
|
| 143 |
+
## Preprocessing
|
| 144 |
+
|
| 145 |
+
Images are resized to **224 Γ 224** and normalised with ImageNet statistics:
|
| 146 |
+
|
| 147 |
+
```python
|
| 148 |
+
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
`GeoTagPredictor` applies this automatically. If you call `DinoGeoRegressor`
|
| 152 |
+
directly, apply the transform before passing `pixel_values`.
|
| 153 |
+
|
| 154 |
+
## Limitations
|
| 155 |
+
|
| 156 |
+
- **Geographically restricted to Jakarta.** Out-of-domain images return
|
| 157 |
+
coordinates near the projection origin.
|
| 158 |
+
- Trained on 2023+ Street View panoramas projected to 8 fixed headings
|
| 159 |
+
(0Β°, 45Β°, 90Β°, 135Β°, 180Β°, 225Β°, 270Β°, 315Β°).
|
| 160 |
+
- Backbone is frozen; only the 3 M-parameter regression head was trained.
|
| 161 |
+
- Hard examples (48% of the test set, defined as error > 1 km across all
|
| 162 |
+
measured epochs) tend to cluster in peripheral and coastal areas with
|
| 163 |
+
low visual distinctiveness.
|
| 164 |
+
|
| 165 |
+
## Files
|
| 166 |
+
|
| 167 |
+
| File | Purpose |
|
| 168 |
+
|---|---|
|
| 169 |
+
| `modeling_geotag.py` | Model classes + `from_pretrained` loader + coordinate conversion |
|
| 170 |
+
| `inference.py` | `GeoTagPredictor` high-level API + CLI |
|
| 171 |
+
| `model.safetensors` | Full state dict (frozen backbone + trained head, ~1.2 GB) |
|
| 172 |
+
| `requirements.txt` | Runtime dependencies |
|
| 173 |
+
|
| 174 |
+
## Citation
|
| 175 |
+
|
| 176 |
+
If you use this model or the dataset, please cite:
|
| 177 |
+
|
| 178 |
+
```bibtex
|
| 179 |
+
@misc{img2locjakarta2025,
|
| 180 |
+
author = {Nadhif},
|
| 181 |
+
title = {Img2LocJakarta: DINOv3 Street-View Geolocation for Jakarta},
|
| 182 |
+
year = {2025},
|
| 183 |
+
url = {https://huggingface.co/nadh0708/Img2LocJakarta}
|
| 184 |
+
}
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
## License
|
| 188 |
+
|
| 189 |
+
This model is released under the **Apache License 2.0**, consistent with the
|
| 190 |
+
DINOv3 backbone license. See `LICENSE` for details.
|
inference.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
High-level inference for the JKTSV DINOv3 geolocation model.
|
| 3 |
+
|
| 4 |
+
Example
|
| 5 |
+
-------
|
| 6 |
+
from inference import GeoTagPredictor
|
| 7 |
+
|
| 8 |
+
predictor = GeoTagPredictor("nadh0708/JKTSV-modelD") # HF repo id
|
| 9 |
+
# ...or a local checkpoint:
|
| 10 |
+
predictor = GeoTagPredictor("model/modelD_40e_2.pth")
|
| 11 |
+
|
| 12 |
+
result = predictor.predict("street.jpg")
|
| 13 |
+
# {'lat': -6.21, 'lon': 106.84}
|
| 14 |
+
|
| 15 |
+
results = predictor.predict(["a.jpg", "b.jpg"]) # batched
|
| 16 |
+
# [{'lat': ..., 'lon': ...}, ...]
|
| 17 |
+
|
| 18 |
+
The model was trained on Google Street View perspective crops of Jakarta roads
|
| 19 |
+
(8 headings, 0-315 deg). Predictions are only meaningful for Jakarta street
|
| 20 |
+
imagery.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
from typing import Union
|
| 26 |
+
|
| 27 |
+
import torch
|
| 28 |
+
from PIL import Image
|
| 29 |
+
from torchvision import transforms
|
| 30 |
+
|
| 31 |
+
from modeling_geotag import DinoGeoRegressor
|
| 32 |
+
|
| 33 |
+
_IMAGENET_MEAN = [0.485, 0.456, 0.406]
|
| 34 |
+
_IMAGENET_STD = [0.229, 0.224, 0.225]
|
| 35 |
+
|
| 36 |
+
ImageInput = Union[str, Image.Image]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class GeoTagPredictor:
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
model_id_or_path: str,
|
| 43 |
+
device: str | torch.device | None = None,
|
| 44 |
+
filename: str = "pytorch_model.bin",
|
| 45 |
+
):
|
| 46 |
+
self.device = torch.device(
|
| 47 |
+
device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 48 |
+
)
|
| 49 |
+
self.model = DinoGeoRegressor.from_pretrained(
|
| 50 |
+
model_id_or_path, filename=filename, device=self.device
|
| 51 |
+
)
|
| 52 |
+
self.transform = transforms.Compose([
|
| 53 |
+
transforms.Resize((224, 224)),
|
| 54 |
+
transforms.ToTensor(),
|
| 55 |
+
transforms.Normalize(mean=_IMAGENET_MEAN, std=_IMAGENET_STD),
|
| 56 |
+
])
|
| 57 |
+
|
| 58 |
+
def _load(self, image: ImageInput) -> Image.Image:
|
| 59 |
+
if isinstance(image, Image.Image):
|
| 60 |
+
return image.convert("RGB")
|
| 61 |
+
return Image.open(image).convert("RGB")
|
| 62 |
+
|
| 63 |
+
@torch.no_grad()
|
| 64 |
+
def predict(
|
| 65 |
+
self, images: Union[ImageInput, list[ImageInput]]
|
| 66 |
+
) -> Union[dict, list[dict]]:
|
| 67 |
+
"""Predict (lat, lon) for one image or a list of images."""
|
| 68 |
+
single = not isinstance(images, (list, tuple))
|
| 69 |
+
batch = [images] if single else list(images)
|
| 70 |
+
|
| 71 |
+
pixel_values = torch.stack([self.transform(self._load(im)) for im in batch])
|
| 72 |
+
pixel_values = pixel_values.to(self.device)
|
| 73 |
+
|
| 74 |
+
lonlat = self.model.predict_lonlat(pixel_values).cpu()
|
| 75 |
+
results = [
|
| 76 |
+
{"lat": float(lat), "lon": float(lon)}
|
| 77 |
+
for lon, lat in lonlat.tolist()
|
| 78 |
+
]
|
| 79 |
+
return results[0] if single else results
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _cli() -> None:
|
| 83 |
+
import argparse
|
| 84 |
+
import json
|
| 85 |
+
|
| 86 |
+
parser = argparse.ArgumentParser(description="Geolocate Jakarta street imagery.")
|
| 87 |
+
parser.add_argument("model", help="HF repo id or local checkpoint path")
|
| 88 |
+
parser.add_argument("images", nargs="+", help="image file path(s)")
|
| 89 |
+
parser.add_argument("--device", default=None)
|
| 90 |
+
parser.add_argument(
|
| 91 |
+
"--filename", default="pytorch_model.bin",
|
| 92 |
+
help="weights filename inside the HF repo",
|
| 93 |
+
)
|
| 94 |
+
args = parser.parse_args()
|
| 95 |
+
|
| 96 |
+
predictor = GeoTagPredictor(args.model, device=args.device, filename=args.filename)
|
| 97 |
+
out = predictor.predict(args.images)
|
| 98 |
+
print(json.dumps(out, indent=2))
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
_cli()
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1c4f4e2ba04dec22eeb1fc9f39ed442c7303c0e916b582fed4a7670302045f25
|
| 3 |
+
size 1640787200
|
modeling_geotag.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model definition + loading for the JKTSV DINOv3 geolocation regressor ("modelD").
|
| 3 |
+
|
| 4 |
+
Architecture (must match the training checkpoint exactly):
|
| 5 |
+
|
| 6 |
+
DinoGeoRegressor
|
| 7 |
+
βββ encoder : DinoLastBlockEncoder
|
| 8 |
+
β βββ backbone : DINOv3 ViT-L/16 (frozen)
|
| 9 |
+
β forward = run backbone, grab the LAST transformer block
|
| 10 |
+
β output (B, 201, 1024) via a forward hook, then
|
| 11 |
+
β flatten -> (B, 205824)
|
| 12 |
+
βββ head : UNetMLPHead(embed_dim=205824, hidden_dim=512, out_dim=2)
|
| 13 |
+
|
| 14 |
+
The head regresses a *local flat-earth (x, y) offset in metres* relative to a
|
| 15 |
+
fixed Jakarta origin. `local_xy_to_lonlat` inverts that projection to recover
|
| 16 |
+
(lon, lat) degrees. These constants are baked into the trained weights β do not
|
| 17 |
+
change them for inference.
|
| 18 |
+
|
| 19 |
+
The published checkpoint bundles the full (frozen) backbone weights together
|
| 20 |
+
with the trained head, so loading needs only the DINOv3 *architecture* from
|
| 21 |
+
``torch.hub`` (``pretrained=False``) β no separate LVD-1689M download.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import os
|
| 27 |
+
from typing import Optional
|
| 28 |
+
|
| 29 |
+
import torch
|
| 30 |
+
import torch.nn as nn
|
| 31 |
+
import torch.nn.functional as F
|
| 32 |
+
|
| 33 |
+
# --- constants that are part of the trained model -----------------------------
|
| 34 |
+
|
| 35 |
+
DINOV3_REPO = "facebookresearch/dinov3"
|
| 36 |
+
BACKBONE_NAME = "dinov3_vitl16"
|
| 37 |
+
EMBED_DIM = 205824 # 201 tokens (1 CLS + 4 storage + 196 patch) * 1024
|
| 38 |
+
HIDDEN_DIM = 512
|
| 39 |
+
OUT_DIM = 2
|
| 40 |
+
|
| 41 |
+
# Local flat-earth projection origin (Jakarta city centre) used during training.
|
| 42 |
+
ORIGIN_LON = 106.828320
|
| 43 |
+
ORIGIN_LAT = -6.227468
|
| 44 |
+
EARTH_RADIUS_M = 6371000.0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# --- coordinate conversion -----------------------------------------------------
|
| 48 |
+
|
| 49 |
+
def local_xy_to_lonlat(xy_meters: torch.Tensor) -> torch.Tensor:
|
| 50 |
+
"""Invert the flat-earth projection used as the regression target.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
xy_meters: (B, 2) tensor of [x (east), y (north)] in metres.
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
(B, 2) tensor of [lon, lat] in degrees.
|
| 57 |
+
"""
|
| 58 |
+
lat0_rad = torch.deg2rad(torch.tensor(ORIGIN_LAT, device=xy_meters.device))
|
| 59 |
+
x = xy_meters[:, 0]
|
| 60 |
+
y = xy_meters[:, 1]
|
| 61 |
+
|
| 62 |
+
dlon_rad = x / (EARTH_RADIUS_M * torch.cos(lat0_rad))
|
| 63 |
+
dlat_rad = y / EARTH_RADIUS_M
|
| 64 |
+
|
| 65 |
+
lon = torch.rad2deg(dlon_rad) + ORIGIN_LON
|
| 66 |
+
lat = torch.rad2deg(dlat_rad) + ORIGIN_LAT
|
| 67 |
+
return torch.stack([lon, lat], dim=-1)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# --- modules -------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
class UNetMLPHead(nn.Module):
|
| 73 |
+
"""U-shaped MLP with 1-D skip connections. Input (B, embed_dim) -> (B, out_dim)."""
|
| 74 |
+
|
| 75 |
+
def __init__(self, embed_dim: int, hidden_dim: int, out_dim: int):
|
| 76 |
+
super().__init__()
|
| 77 |
+
self.enc1 = nn.Linear(embed_dim, hidden_dim)
|
| 78 |
+
self.enc2 = nn.Linear(hidden_dim, hidden_dim)
|
| 79 |
+
self.bottleneck = nn.Linear(hidden_dim, hidden_dim)
|
| 80 |
+
self.dec2 = nn.Linear(hidden_dim * 2, hidden_dim)
|
| 81 |
+
self.dec1 = nn.Linear(hidden_dim * 2, hidden_dim)
|
| 82 |
+
self.out = nn.Linear(hidden_dim, out_dim)
|
| 83 |
+
|
| 84 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 85 |
+
e1 = F.gelu(self.enc1(x))
|
| 86 |
+
e2 = F.gelu(self.enc2(e1))
|
| 87 |
+
b = F.gelu(self.bottleneck(e2))
|
| 88 |
+
d2 = F.gelu(self.dec2(torch.cat([b, e2], dim=-1)))
|
| 89 |
+
d1 = F.gelu(self.dec1(torch.cat([d2, e1], dim=-1)))
|
| 90 |
+
return self.out(d1)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class DinoLastBlockEncoder(nn.Module):
|
| 94 |
+
"""Run a DINOv3 ViT and return the flattened token sequence of its last block.
|
| 95 |
+
|
| 96 |
+
A forward hook captures the last transformer block output (B, N, C); the
|
| 97 |
+
tokens are flattened to (B, N*C). The backbone is frozen.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
def __init__(self, backbone: nn.Module):
|
| 101 |
+
super().__init__()
|
| 102 |
+
self.backbone = backbone
|
| 103 |
+
self._last_block_out = None
|
| 104 |
+
self.backbone.blocks[-1].register_forward_hook(self._hook)
|
| 105 |
+
for p in self.backbone.parameters():
|
| 106 |
+
p.requires_grad = False
|
| 107 |
+
|
| 108 |
+
def _hook(self, module, inputs, output):
|
| 109 |
+
self._last_block_out = output
|
| 110 |
+
|
| 111 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 112 |
+
self._last_block_out = None
|
| 113 |
+
_ = self.backbone(x)
|
| 114 |
+
feats = self._last_block_out[0] # (B, N, C)
|
| 115 |
+
return feats.flatten(start_dim=1) # (B, N*C)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class DinoGeoRegressor(nn.Module):
|
| 119 |
+
"""Frozen DINOv3 encoder + trainable UNet-MLP regression head.
|
| 120 |
+
|
| 121 |
+
forward(pixel_values) -> (B, 2) local (x, y) metres.
|
| 122 |
+
predict_lonlat(pixel_values) -> (B, 2) [lon, lat] degrees.
|
| 123 |
+
|
| 124 |
+
`pixel_values` must already be resized to 224x224 and ImageNet-normalised
|
| 125 |
+
(see ``GeoTagPredictor`` / the transform in ``inference.py``).
|
| 126 |
+
"""
|
| 127 |
+
|
| 128 |
+
def __init__(
|
| 129 |
+
self,
|
| 130 |
+
backbone: nn.Module,
|
| 131 |
+
embed_dim: int = EMBED_DIM,
|
| 132 |
+
hidden_dim: int = HIDDEN_DIM,
|
| 133 |
+
out_dim: int = OUT_DIM,
|
| 134 |
+
):
|
| 135 |
+
super().__init__()
|
| 136 |
+
self.encoder = DinoLastBlockEncoder(backbone)
|
| 137 |
+
self.head = UNetMLPHead(embed_dim, hidden_dim, out_dim)
|
| 138 |
+
|
| 139 |
+
def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
|
| 140 |
+
feats = self.encoder(pixel_values)
|
| 141 |
+
return self.head(feats)
|
| 142 |
+
|
| 143 |
+
@torch.no_grad()
|
| 144 |
+
def predict_lonlat(self, pixel_values: torch.Tensor) -> torch.Tensor:
|
| 145 |
+
return local_xy_to_lonlat(self.forward(pixel_values))
|
| 146 |
+
|
| 147 |
+
# -- construction helpers --------------------------------------------------
|
| 148 |
+
|
| 149 |
+
@staticmethod
|
| 150 |
+
def build_backbone(device: str | torch.device = "cpu") -> nn.Module:
|
| 151 |
+
"""Instantiate the DINOv3 ViT-L/16 architecture (no pretrained download)."""
|
| 152 |
+
backbone = torch.hub.load(
|
| 153 |
+
DINOV3_REPO, BACKBONE_NAME, pretrained=False, trust_repo=True
|
| 154 |
+
)
|
| 155 |
+
return backbone.to(device)
|
| 156 |
+
|
| 157 |
+
@classmethod
|
| 158 |
+
def from_pretrained(
|
| 159 |
+
cls,
|
| 160 |
+
model_id_or_path: str,
|
| 161 |
+
*,
|
| 162 |
+
filename: str = "pytorch_model.bin",
|
| 163 |
+
device: str | torch.device = "cpu",
|
| 164 |
+
backbone: Optional[nn.Module] = None,
|
| 165 |
+
) -> "DinoGeoRegressor":
|
| 166 |
+
"""Load weights from a local ``.pth``/``.bin`` file or a HuggingFace repo id.
|
| 167 |
+
|
| 168 |
+
The checkpoint is a full state_dict with ``encoder.backbone.*`` and
|
| 169 |
+
``head.*`` keys (i.e. it includes the frozen backbone weights).
|
| 170 |
+
"""
|
| 171 |
+
if os.path.isfile(model_id_or_path):
|
| 172 |
+
weights_path = model_id_or_path
|
| 173 |
+
else:
|
| 174 |
+
from huggingface_hub import hf_hub_download
|
| 175 |
+
|
| 176 |
+
weights_path = hf_hub_download(repo_id=model_id_or_path, filename=filename)
|
| 177 |
+
|
| 178 |
+
if backbone is None:
|
| 179 |
+
backbone = cls.build_backbone(device)
|
| 180 |
+
|
| 181 |
+
model = cls(backbone).to(device)
|
| 182 |
+
|
| 183 |
+
if weights_path.endswith(".safetensors"):
|
| 184 |
+
from safetensors.torch import load_file
|
| 185 |
+
|
| 186 |
+
state_dict = load_file(weights_path, device=str(device))
|
| 187 |
+
else:
|
| 188 |
+
state_dict = torch.load(weights_path, map_location=device)
|
| 189 |
+
|
| 190 |
+
model.load_state_dict(state_dict, strict=True)
|
| 191 |
+
model.eval()
|
| 192 |
+
return model
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.2
|
| 2 |
+
torchvision>=0.17
|
| 3 |
+
pillow
|
| 4 |
+
huggingface_hub
|
| 5 |
+
termcolor
|
| 6 |
+
safetensors
|