Skip to content

Commit 5747a6f

Browse files
Merge pull request #52 from riita10069/feat/gps-map-rendering
feat(data_parsing): add GPS-to-BEV map tile rendering utility
2 parents bab73b8 + 450e2cc commit 5747a6f

5 files changed

Lines changed: 868 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# GPS → BEV Map Tile Rendering
2+
3+
Offline preprocessing utility that turns raw GPS waypoints into BEV map tiles
4+
in the style of the L2D dataset's pre-rendered map. Use it for datasets that
5+
do not natively ship map images (e.g. KIT Scenes, NVIDIA PhysicalAI).
6+
7+
## Ego-centric framing
8+
9+
Tiles are rendered **ego-centric**, matching the L2D / NVIDIA / KIT Scenes
10+
convention:
11+
12+
- Center: the ego pose `(ego_lat, ego_lon)` (defaults to the last GPS sample
13+
when omitted in `gps_to_tensor`).
14+
- Orientation: rotated so the ego forward direction points **up** in the
15+
image (forward = +y).
16+
- Frame: a local equirectangular projection — coordinates are converted from
17+
lon/lat to metres relative to the ego, then rotated by `-ego_heading`. The
18+
axes share a common metric scale (`ax.set_aspect("equal")`), so there is
19+
no lat/lon aspect distortion.
20+
- Extent: a fixed metric window of `±radius_m` around the origin.
21+
22+
`ego_heading` is in radians, measured CCW from north (so `0` = north, the
23+
ego is facing north and the tile is north-up).
24+
25+
## When to use
26+
27+
- You have GPS lat/lon traces per clip and want a model input equivalent to
28+
L2D's BEV map tile.
29+
- You are building a dataset offline and can pre-render every tile.
30+
- You do **not** want to render at training time — fetching road networks via
31+
Overpass takes seconds per call and requires internet access.
32+
33+
## Workflow
34+
35+
1. Build a `{clip_id: (latitudes, longitudes)}` mapping from your dataset.
36+
2. Run `render_and_cache_tiles(...)` once to produce one PNG per clip plus a
37+
shared road-network pickle cache.
38+
3. In the DataLoader, read the PNG, push it through your timm transform like
39+
any other camera tile.
40+
41+
## Module layout
42+
43+
| File | Purpose |
44+
| --- | --- |
45+
| `gps_to_map.py` | Core: fetch network, map-match, render, end-to-end tensor. |
46+
| `cache.py` | Pickle network graphs and batch-render dataset tiles. |
47+
| `test_gps_to_map.py` | Offline tests; `osmnx.graph_from_point` is mocked. |
48+
49+
## Public API
50+
51+
```python
52+
from data_parsing.map_rendering import (
53+
fetch_road_network,
54+
map_match_waypoints,
55+
render_map_tile,
56+
gps_to_tensor,
57+
)
58+
from data_parsing.map_rendering.cache import (
59+
cache_network,
60+
load_cached_network,
61+
render_and_cache_tiles,
62+
)
63+
```
64+
65+
### Single-clip example
66+
67+
```python
68+
import timm
69+
from data_parsing.map_rendering import gps_to_tensor
70+
71+
backbone = timm.create_model("swinv2_tiny_window8_256", pretrained=False)
72+
data_cfg = timm.data.resolve_model_data_config(backbone)
73+
transform = timm.data.create_transform(**data_cfg, is_training=False)
74+
75+
tensor = gps_to_tensor(
76+
latitudes=lats,
77+
longitudes=lons,
78+
transform=transform,
79+
ego_heading=heading_rad, # radians CCW from north
80+
# ego_lat / ego_lon default to the last GPS sample
81+
radius_m=800,
82+
)
83+
# tensor.shape == (3, H, W) — drop straight into the visual_tiles slot.
84+
```
85+
86+
### Batch preprocessing
87+
88+
```python
89+
from data_parsing.map_rendering.cache import render_and_cache_tiles
90+
91+
paths = render_and_cache_tiles(
92+
dataset_gps_data={clip_id: (lats, lons) for clip_id, lats, lons in clips},
93+
output_dir="cache/map_tiles",
94+
network_cache_dir="cache/road_networks",
95+
radius_m=800,
96+
)
97+
```
98+
99+
`network_cache_dir` quantizes centroids to ~100 m so adjacent clips reuse the
100+
same downloaded graph.
101+
102+
## Style
103+
104+
Defaults match the L2D BEV map palette and dimensions:
105+
106+
| Element | Default |
107+
| --- | --- |
108+
| Image size | 640 × 360 |
109+
| Background | `#111111` |
110+
| Road network | `#444444` |
111+
| Route | `#00CCFF` |
112+
| Raw GPS markers | `#FF3333` |
113+
| DPI | 200 |
114+
115+
All of these are arguments on `render_map_tile` and `gps_to_tensor`.
116+
117+
## Dependencies
118+
119+
- `osmnx` (and its transitive `geopandas` / `shapely` chain)
120+
- `matplotlib` (headless `Agg` backend is selected automatically)
121+
- `Pillow`
122+
- `networkx`
123+
- `torch` (only for the tensor output path)
124+
125+
Install with:
126+
127+
```
128+
pip install osmnx geopandas matplotlib pillow networkx
129+
```
130+
131+
## Notes
132+
133+
- This is a **data preprocessing** utility. It lives in `data_parsing/`, not
134+
`model_components/`. Do not call it from a `Dataset.__getitem__`.
135+
- Map matching can fail (waypoints outside the fetched bbox, disconnected
136+
components). When it does, the renderer falls back to drawing the network
137+
plus raw GPS markers.
138+
- `render_and_cache_tiles` estimates `ego_heading` from the last segment of
139+
each GPS trace. If you have a more accurate heading source (IMU, GNSS
140+
course-over-ground), prefer calling `render_map_tile` directly with it.
141+
- Tests must not require internet — `osmnx.graph_from_point` and
142+
`osmnx.distance.nearest_nodes` are mocked.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Offline GPS-to-map-tile rendering for datasets that lack BEV map images.
2+
3+
See README.md for the full preprocessing workflow. The rendered tiles match
4+
the L2D BEV map format and can be fed through the same timm transform as
5+
camera tiles.
6+
"""
7+
8+
from .gps_to_map import (
9+
fetch_road_network,
10+
gps_to_tensor,
11+
map_match_waypoints,
12+
render_map_tile,
13+
)
14+
15+
__all__ = [
16+
"fetch_road_network",
17+
"gps_to_tensor",
18+
"map_match_waypoints",
19+
"render_map_tile",
20+
]
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Caching helpers for the map rendering pipeline.
2+
3+
Network fetches via osmnx are slow (seconds each, internet required). This
4+
module persists fetched graphs to disk and renders/persists tiles for an
5+
entire dataset in one batch so the DataLoader only ever reads PNGs.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
import math
12+
import pickle
13+
from pathlib import Path
14+
from typing import Mapping, Sequence
15+
16+
import networkx as nx
17+
18+
from .gps_to_map import (
19+
DEFAULT_IMAGE_SIZE,
20+
DEFAULT_RADIUS_M,
21+
EARTH_RADIUS_M,
22+
fetch_road_network,
23+
map_match_waypoints,
24+
render_map_tile,
25+
)
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
def cache_network(graph: nx.MultiDiGraph, filepath: str | Path) -> None:
31+
"""Pickle a road-network graph to disk."""
32+
path = Path(filepath)
33+
path.parent.mkdir(parents=True, exist_ok=True)
34+
with path.open("wb") as f:
35+
pickle.dump(graph, f)
36+
37+
38+
def load_cached_network(filepath: str | Path) -> nx.MultiDiGraph | None:
39+
"""Load a pickled road-network graph, or `None` if the file is missing."""
40+
path = Path(filepath)
41+
if not path.exists():
42+
return None
43+
try:
44+
with path.open("rb") as f:
45+
return pickle.load(f)
46+
except (OSError, pickle.UnpicklingError) as exc:
47+
logger.warning("failed to load cached network %s: %s", path, exc)
48+
return None
49+
50+
51+
def render_and_cache_tiles(
52+
dataset_gps_data: Mapping[str, tuple[Sequence[float], Sequence[float]]],
53+
output_dir: str | Path,
54+
radius_m: int = DEFAULT_RADIUS_M,
55+
image_size: tuple[int, int] = DEFAULT_IMAGE_SIZE,
56+
network_cache_dir: str | Path | None = None,
57+
skip_existing: bool = True,
58+
) -> list[Path]:
59+
"""Pre-render and persist a BEV map tile for every clip in a dataset.
60+
61+
Args:
62+
dataset_gps_data: mapping of `clip_id -> (latitudes, longitudes)`.
63+
output_dir: where rendered PNG tiles are written (`{clip_id}.png`).
64+
radius_m: render radius around each clip's centroid.
65+
image_size: output `(W, H)`.
66+
network_cache_dir: if given, fetched graphs are persisted here keyed by
67+
centroid so neighbouring clips share a cached download.
68+
skip_existing: do not re-render clips whose PNG already exists.
69+
70+
Returns:
71+
List of paths to the rendered tile files (including pre-existing ones).
72+
"""
73+
out = Path(output_dir)
74+
out.mkdir(parents=True, exist_ok=True)
75+
net_cache = Path(network_cache_dir) if network_cache_dir else None
76+
if net_cache is not None:
77+
net_cache.mkdir(parents=True, exist_ok=True)
78+
79+
rendered: list[Path] = []
80+
for clip_id, (lats, lons) in dataset_gps_data.items():
81+
tile_path = out / f"{clip_id}.png"
82+
if skip_existing and tile_path.exists():
83+
rendered.append(tile_path)
84+
continue
85+
86+
if not lats:
87+
logger.warning("clip %s has no GPS samples; skipping", clip_id)
88+
continue
89+
90+
ego_lat = float(lats[-1])
91+
ego_lon = float(lons[-1])
92+
ego_heading = _heading_from_trace(lats, lons, ego_lat)
93+
94+
graph = _load_or_fetch_network(
95+
ego_lat, ego_lon, radius_m, net_cache
96+
)
97+
if graph is None:
98+
logger.warning("clip %s: failed to obtain road network; skipping", clip_id)
99+
continue
100+
101+
_, route = map_match_waypoints(graph, list(lats), list(lons))
102+
raw_points = list(zip(lats, lons))
103+
try:
104+
image = render_map_tile(
105+
graph,
106+
route_nodes=route,
107+
ego_lat=ego_lat,
108+
ego_lon=ego_lon,
109+
ego_heading=ego_heading,
110+
raw_gps_points=raw_points,
111+
radius_m=radius_m,
112+
image_size=image_size,
113+
)
114+
except Exception as exc: # noqa: BLE001 — matplotlib/osmnx errors vary
115+
logger.warning(
116+
"clip %s: render failed (%s); skipping",
117+
clip_id,
118+
exc,
119+
exc_info=True,
120+
)
121+
continue
122+
123+
image.save(tile_path)
124+
rendered.append(tile_path)
125+
126+
return rendered
127+
128+
129+
def _heading_from_trace(
130+
lats: Sequence[float], lons: Sequence[float], ref_lat: float
131+
) -> float:
132+
"""Estimate ego heading (radians) from the last segment of the GPS trace.
133+
134+
Uses atan2(east, north) so 0 rad ≡ north and the value matches the
135+
`ego_heading` convention in `render_map_tile`. Falls back to 0 when the
136+
trace has fewer than two distinct samples.
137+
"""
138+
if len(lats) < 2:
139+
return 0.0
140+
cos_lat = math.cos(math.radians(ref_lat))
141+
deg_to_m = EARTH_RADIUS_M * math.pi / 180.0
142+
dx = (lons[-1] - lons[-2]) * cos_lat * deg_to_m
143+
dy = (lats[-1] - lats[-2]) * deg_to_m
144+
if dx == 0.0 and dy == 0.0:
145+
return 0.0
146+
return math.atan2(dx, dy)
147+
148+
149+
def _load_or_fetch_network(
150+
center_lat: float,
151+
center_lon: float,
152+
radius_m: int,
153+
cache_dir: Path | None,
154+
) -> nx.MultiDiGraph | None:
155+
"""Return a cached graph if available, otherwise fetch and cache it.
156+
157+
Centroids are quantized to ~100 m so nearby clips reuse the same download.
158+
"""
159+
if cache_dir is not None:
160+
key = f"{round(center_lat, 3)}_{round(center_lon, 3)}_{radius_m}.pkl"
161+
cache_path = cache_dir / key
162+
cached = load_cached_network(cache_path)
163+
if cached is not None:
164+
return cached
165+
else:
166+
cache_path = None
167+
168+
try:
169+
graph = fetch_road_network(center_lat, center_lon, radius_m=radius_m)
170+
except Exception as exc: # noqa: BLE001 — network/Overpass failures
171+
logger.warning(
172+
"fetch_road_network(%.4f, %.4f) failed: %s",
173+
center_lat,
174+
center_lon,
175+
exc,
176+
)
177+
return None
178+
179+
if cache_path is not None:
180+
cache_network(graph, cache_path)
181+
return graph

0 commit comments

Comments
 (0)