Skip to content

Commit e5f2512

Browse files
committed
draft integration of sdtagnet sd map encoder
1 parent 9e1d0af commit e5f2512

24 files changed

Lines changed: 2591 additions & 48 deletions

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,12 @@ __pycache__/
22
*.py[cod]
33
._*
44
benchmark_results.json
5+
# Downloaded SDTagNet NLP encoder checkpoint (default download dir)
6+
checkpoints/
7+
# Generated OSM SD-map artifacts: caches, raw Overpass fetches, tokenized
8+
# shards, built road graphs, and visualization output.
9+
cache/
10+
*.osm.gz
11+
osm_bbox_*.xml.gz
12+
graph_*.pkl
13+
osm_map_vis.png

Model/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,20 @@ trajectory, ego_hidden, future_visual_features = model(
3434
**To learn the driving policy:**
3535
- Imitation Learning is used to penalize trajectory prediction as well as World Model Simulation based Reinforcement Learning
3636

37+
## Map priors
38+
39+
Two interchangeable map-branch flavours feed road-network priors into the BEV:
40+
41+
- **`map_type="rasterized"`** (default): a rendered nav-map image is encoded to a
42+
spatial map BEV and fused with the camera BEV (`map_fusion_mode="residual"` or
43+
`"cross_attn"`). See [data_parsing/map_rendering](./data_parsing/map_rendering).
44+
- **`map_type="osm_vector"`**: OpenStreetMap SD-map elements are encoded as
45+
**text-annotated vector tokens** (ported from
46+
[SDTagNet](https://arxiv.org/abs/2506.08997)) and the camera BEV
47+
cross-attends to them (`map_fusion_mode="osm_cross_attn"`). This keeps arbitrary
48+
OSM semantics (lane counts, surface, turn restrictions, ...) without a fixed
49+
class taxonomy. See [data_parsing/osm_sd_map](./data_parsing/osm_sd_map) for the
50+
offline pipeline and [model_components/map_encoder/osm_vector](./model_components/map_encoder/osm_vector)
51+
for the encoder. Both branches share one on-disk OSM cache.
52+
3753

Model/data_parsing/l2d/dataset.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,17 @@ def __init__(
6868
episodes: list[int] | None = None,
6969
backbone_name: str = "swinv2_tiny_window8_256",
7070
local_files_only: bool = False,
71+
osm_cache_dir: str | None = None,
7172
) -> None:
7273
from lerobot.common.datasets.lerobot_dataset import LeRobotDataset
7374

7475
self.repo_id = repo_id
76+
# When set, per-episode tokenised OSM shards (built offline via
77+
# data_parsing.osm_sd_map.cache.build_episode_osm_cache) are merged into
78+
# each sample as osm_map_* keys for the OSM vector encoder. When None,
79+
# samples are unchanged (existing behaviour).
80+
self.osm_cache_dir = osm_cache_dir
81+
self._osm_shard_cache: tuple[int | None, dict | None] = (None, None)
7582

7683
self.lerobot_dataset = LeRobotDataset(
7784
repo_id=repo_id,
@@ -162,11 +169,49 @@ def __getitem__(self, idx: int) -> L2DSample:
162169

163170
visual_history = torch.zeros(_VISUAL_HISTORY_DIM, dtype=torch.float32)
164171

165-
return L2DSample(
172+
sample = L2DSample(
166173
visual_tiles=visual_tiles,
167174
egomotion_history=egomotion_history,
168175
visual_history=visual_history,
169176
trajectory_target=trajectory_target,
170177
episode_index=ep_idx,
171178
frame_index=local_frame_idx,
172179
)
180+
181+
if self.osm_cache_dir is not None:
182+
osm_sample = self._load_osm_sample(ep_idx, local_frame_idx)
183+
if osm_sample is not None:
184+
# Merge osm_map_* keys; collate_osm_batch groups them per batch.
185+
sample.update(osm_sample)
186+
187+
return sample
188+
189+
def _load_osm_sample(self, ep_idx: int, local_frame_idx: int):
190+
"""Load one frame's tokenised OSM data from the per-episode shard cache."""
191+
from data_parsing.osm_sd_map.cache import load_episode_osm_cache
192+
193+
cached_ep, shard = self._osm_shard_cache
194+
if cached_ep != ep_idx:
195+
shard = load_episode_osm_cache(self.osm_cache_dir, ep_idx)
196+
self._osm_shard_cache = (ep_idx, shard)
197+
if shard is None:
198+
return None
199+
return shard.get(local_frame_idx)
200+
201+
def episode_ego_poses(self, ep_idx: int) -> list[tuple[int, float, float, float]]:
202+
"""Per-valid-frame ``(local_frame_idx, lat, lon, heading)`` for OSM caching.
203+
204+
Feed the result to ``osm_sd_map.cache.build_episode_osm_cache``. Uses
205+
the vehicle state columns ``[1]=heading, [3]=lat, [4]=lon``.
206+
"""
207+
episode_data_index = self.lerobot_dataset.episode_data_index
208+
ep_start = episode_data_index["from"][ep_idx].item()
209+
210+
poses = []
211+
for sample_ep_idx, global_frame_idx in self._samples:
212+
if sample_ep_idx != ep_idx:
213+
continue
214+
local_frame_idx = global_frame_idx - ep_start
215+
state = self.lerobot_dataset[global_frame_idx]["observation.state.vehicle"].numpy()
216+
poses.append((local_frame_idx, float(state[3]), float(state[4]), float(state[1])))
217+
return poses

Model/data_parsing/map_rendering/cache.py

Lines changed: 100 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ def render_and_cache_tiles(
6363
output_dir: where rendered PNG tiles are written (`{clip_id}.png`).
6464
radius_m: render radius around each clip's centroid.
6565
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.
66+
network_cache_dir: if given, fetched graphs and the shared raw-OSM XML
67+
are persisted here (keyed by the clip's trajectory bbox) so
68+
neighbouring clips and the OSM vector branch reuse downloads.
6869
skip_existing: do not re-render clips whose PNG already exists.
6970
7071
Returns:
@@ -91,8 +92,11 @@ def render_and_cache_tiles(
9192
ego_lon = float(lons[-1])
9293
ego_heading = _heading_from_trace(lats, lons, ego_lat)
9394

95+
# Fetch sized to the whole trajectory + the render radius, so the route
96+
# and the ±radius draw window are always covered (a centroid radius can
97+
# miss long clips).
9498
graph = _load_or_fetch_network(
95-
ego_lat, ego_lon, radius_m, net_cache
99+
list(lats), list(lons), radius_m, net_cache
96100
)
97101
if graph is None:
98102
logger.warning("clip %s: failed to obtain road network; skipping", clip_id)
@@ -146,35 +150,108 @@ def _heading_from_trace(
146150
return math.atan2(dx, dy)
147151

148152

153+
def _graph_from_shared_osm(
154+
lats: Sequence[float],
155+
lons: Sequence[float],
156+
margin_m: int,
157+
cache_dir: Path | None,
158+
) -> nx.MultiDiGraph | None:
159+
"""Build the road graph from the shared raw-OSM cache (trace bbox + margin).
160+
161+
The SD-map (OSM vector) branch and this rendered-tile branch share a single
162+
on-disk artifact: the raw Overpass OSM XML (see
163+
``data_parsing.osm_sd_map.overpass``). Both fetch the bounding box of the
164+
clip's trajectory expanded by their own margin (here the render radius); the
165+
shared cache reuses any already-fetched XML that covers the request, so one
166+
download serves both. Returns ``None`` (caller falls back to a direct fetch)
167+
if the shared path is unavailable for any reason.
168+
"""
169+
try:
170+
import os
171+
import tempfile
172+
173+
import osmnx as ox
174+
175+
from ..osm_sd_map.overpass import load_or_fetch_osm_for_trace
176+
except Exception as exc: # noqa: BLE001 — optional path; never break rendering
177+
logger.debug("shared OSM path unavailable (%s); using direct fetch", exc)
178+
return None
179+
180+
xml = load_or_fetch_osm_for_trace(lats, lons, margin_m, cache_dir)
181+
if xml is None:
182+
return None
183+
184+
tmp = tempfile.NamedTemporaryFile("w", suffix=".osm", delete=False, encoding="utf-8")
185+
try:
186+
tmp.write(xml)
187+
tmp.close()
188+
return ox.graph_from_xml(tmp.name, bidirectional=False, simplify=True, retain_all=True)
189+
except Exception as exc: # noqa: BLE001 — osmnx version/parse differences
190+
logger.warning("graph_from_xml failed (%s); using direct fetch", exc)
191+
return None
192+
finally:
193+
os.unlink(tmp.name)
194+
195+
196+
def _trace_centroid_radius(
197+
lats: Sequence[float], lons: Sequence[float], margin_m: int
198+
) -> tuple[float, float, int]:
199+
"""Centroid + a radius that covers the whole trace plus ``margin_m``."""
200+
clat = sum(lats) / len(lats)
201+
clon = sum(lons) / len(lons)
202+
deg_to_m = EARTH_RADIUS_M * math.pi / 180.0
203+
cos_lat = math.cos(math.radians(clat))
204+
max_d = 0.0
205+
for la, lo in zip(lats, lons):
206+
dx = (lo - clon) * cos_lat * deg_to_m
207+
dy = (la - clat) * deg_to_m
208+
max_d = max(max_d, math.hypot(dx, dy))
209+
return clat, clon, int(max_d + margin_m)
210+
211+
212+
def _graph_cache_path(
213+
lats: Sequence[float], lons: Sequence[float], margin_m: int, cache_dir: Path
214+
) -> Path | None:
215+
"""Pickle path for the built graph, keyed by the trace bbox."""
216+
try:
217+
from ..osm_sd_map.overpass import bbox_from_points
218+
except Exception: # noqa: BLE001
219+
return None
220+
s, w, n, e = bbox_from_points(lats, lons, margin_m)
221+
return cache_dir / f"graph_{s:.4f}_{w:.4f}_{n:.4f}_{e:.4f}.pkl"
222+
223+
149224
def _load_or_fetch_network(
150-
center_lat: float,
151-
center_lon: float,
152-
radius_m: int,
225+
lats: Sequence[float],
226+
lons: Sequence[float],
227+
margin_m: int,
153228
cache_dir: Path | None,
154229
) -> nx.MultiDiGraph | None:
155-
"""Return a cached graph if available, otherwise fetch and cache it.
230+
"""Return a cached graph if available, otherwise build/fetch and cache it.
156231
157-
Centroids are quantized to ~100 m so nearby clips reuse the same download.
232+
The fetch is sized to the trajectory bounding box plus ``margin_m`` (the
233+
render radius), so long clips are fully covered (a fixed centroid radius can
234+
miss them). The graph is built from the shared raw-OSM cache when possible
235+
(reused by the OSM vector branch via bbox containment); otherwise it falls
236+
back to a direct osmnx fetch sized to cover the trace. The built graph is
237+
memoized as a pickle keyed by the trace bbox.
158238
"""
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
239+
cache_path = _graph_cache_path(lats, lons, margin_m, cache_dir) if cache_dir is not None else None
240+
if cache_path is not None:
162241
cached = load_cached_network(cache_path)
163242
if cached is not None:
164243
return cached
165-
else:
166-
cache_path = None
167244

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
245+
graph = _graph_from_shared_osm(lats, lons, margin_m, cache_dir)
246+
if graph is None:
247+
clat, clon, radius = _trace_centroid_radius(lats, lons, margin_m)
248+
try:
249+
graph = fetch_road_network(clat, clon, radius_m=radius)
250+
except Exception as exc: # noqa: BLE001 — network/Overpass failures
251+
logger.warning(
252+
"fetch_road_network(%.4f, %.4f) failed: %s", clat, clon, exc
253+
)
254+
return None
178255

179256
if cache_path is not None:
180257
cache_network(graph, cache_path)

0 commit comments

Comments
 (0)