nadh0708 commited on
Commit
1bb5a3e
Β·
verified Β·
1 Parent(s): 3a87185

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. README.md +187 -0
  2. inference.py +102 -0
  3. model.safetensors +3 -0
  4. modeling_geotag.py +192 -0
  5. 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