Metadata-Version: 2.4
Name: simple-lanelet2
Version: 1.2.1
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: BSD License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Scientific/Engineering :: GIS
License-File: LICENSE
License-File: NOTICE
Summary: A drop-in reimplementation of the Lanelet2 Python API, in Rust
Keywords: lanelet2,hd-map,autonomous-driving,autoware,gis
Home-Page: https://github.com/hakuturu583/simple_lanelet2
License: BSD-3-Clause
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Homepage, https://github.com/hakuturu583/simple_lanelet2
Project-URL: Issues, https://github.com/hakuturu583/simple_lanelet2/issues
Project-URL: Repository, https://github.com/hakuturu583/simple_lanelet2

# simple_lanelet2

A reimplementation of the [Lanelet2](https://github.com/fzi-forschungszentrum-informatik/Lanelet2)
Python API with a Rust core and PyO3 bindings.

The goal is a **drop-in replacement**: `import lanelet2` keeps working, unchanged, but
installation is a single wheel with no Boost, no GeographicLib and no C++ toolchain.

```python
import lanelet2
from lanelet2.core import Point3d, LineString3d, Lanelet, getId

left = LineString3d(getId(), [Point3d(getId(), 0, 0, 0), Point3d(getId(), 1, 0, 0)])
right = LineString3d(getId(), [Point3d(getId(), 0, 1, 0), Point3d(getId(), 1, 1, 0)])
lanelet = Lanelet(getId(), left, right)
```

**Status.** All seven submodules are implemented. The library exposes **100% of
the reference's public API** — 633 names across every module and class — and
upstream's own test suite passes against it **unmodified**, in both modes.

## Installation

Wheels are published to PyPI for Linux (`x86_64`, `aarch64`) and Windows (`x64`),
one abi3 wheel per platform covering CPython 3.9 through 3.13:

```bash
pip install simple-lanelet2      # or: uv pip install simple-lanelet2
```

Every wheel is built on a runner of its own architecture, and the exact file that
goes to PyPI is installed clean and run against upstream's own tests in both modes
first. On Windows and linux `aarch64` that is the whole check; the three-way diff
against the reference runs on linux `x86_64` only, because that is the only platform
the reference `lanelet2` wheel exists for.

## Bug-compatibility mode

Upstream has a number of outright defects — a `__hash__` that contradicts `__eq__`, a
misnamed keyword argument on `Origin`, a routing filter that silently does nothing.
By default this library **fixes** them. Setting `LANELET2_BUG_COMPAT` restores
upstream's behaviour exactly:

```bash
LANELET2_BUG_COMPAT=1 python my_script.py   # byte-for-byte upstream behaviour
python my_script.py                         # repaired behaviour (default)
```

The flag is read once at import time and is reported as `lanelet2.BUG_COMPAT`.
Every switched behaviour is listed in [`docs/DIVERGENCE.md`](docs/DIVERGENCE.md) and
enforced by the test harness.

## Container image

A runtime image is published to GHCR for `linux/amd64` and `linux/arm64`:

```bash
# an interpreter with `import lanelet2` already working
docker run --rm -it ghcr.io/hakuturu583/simple_lanelet2:latest

# the working directory is /work, so a map on the host is one mount away
docker run --rm -v "$PWD:/work" ghcr.io/hakuturu583/simple_lanelet2:latest python3 -c "
import lanelet2
from lanelet2.projection import UtmProjector
from lanelet2.io import Origin
m = lanelet2.io.load('map.osm', UtmProjector(Origin(49.0, 8.4)))
print(len(m.laneletLayer), 'lanelets')"
```

Tags are `latest`, `X.Y.Z`, `X.Y` and `X` from releases, plus `main` and
`sha-<commit>` from the tip of the default branch.

It carries a CPython and the wheel and nothing else: no Rust toolchain, no source
tree, no pip — around 40 MB unpacked, of which the extension module is 2.4 MB and
almost all of the rest is CPython itself. That is enforced rather than intended; the
build fails if `pip`, `cargo`, `rustc` or a C compiler can be found in the image, and
its smoke test exercises the map, geometry, projection, routing and I/O paths through
the extension before anything is pushed.

[`Dockerfile`](Dockerfile) builds it in three stages: a Rust toolchain produces one
musl wheel, a second stage unpacks that wheel against the interpreter that will load
it, and the third takes the finished filesystem onto `scratch` — `rm` in a layer
above a base image writes a whiteout and ships the deleted bytes anyway, so copying
what survived is what actually makes it smaller. The image runs as an unprivileged
user with `/work` as its working directory.

The wheel inside it is musllinux and is built only for this image; the manylinux
wheels on PyPI are the ones to install with `pip`.

## Map viewer

**<https://hakuturu583.github.io/simple_lanelet2/>** — drop a Lanelet2 `.osm` on the
page and look at it. The file is parsed and styled by this library compiled to
WebAssembly, in the tab; nothing is uploaded anywhere.

It is a component rather than a page, so it embeds: a Foxglove panel extension or
any application that runs your JavaScript imports `web/viewer.js` and mounts
`<lanelet2-viewer>` — shadow DOM, `ResizeObserver`, no globals, a real `destroy()`
— and a host that can only place a URL, such as a wandb HTML panel or a notebook
cell, frames `web/embed.html` and drives it over `postMessage`.
[`web/EMBEDDING.md`](web/EMBEDDING.md) has both, and
[both are live](https://hakuturu583.github.io/simple_lanelet2/embed-example.html).

None of this is part of the drop-in Lanelet2 surface. Upstream has no viewer to be
compatible with, so `ll2-viz`, `ll2-wasm` and `ll2-rerun` are outside the
compatibility claim and have no Python bindings — nothing in the wheel imports
them, and the diff harness does not touch them.

Three crates carry it, and none of them needs a browser:

- [`ll2-viz`](crates/ll2-viz) turns a `LaneletMap` into a `Scene` — a list of styled
  polylines and polygons in map coordinates — and renders one to SVG. It classifies
  primitives the way the Lanelet2 tagging document and Autoware's
  `lanelet2_extension` describe them, so `line_thin`/`dashed` comes out as a dashed
  hairline, `stop_line` as a red bar, a `crosswalk` lanelet in its own colour. Those
  coordinates keep each node's elevation, and a `View` decides where the map is seen
  from: straight down by default, or tilted, which is what shows an overpass as an
  overpass rather than as a road crossing another at the same height.
- [`ll2-wasm`](crates/ll2-wasm) hands a scene across the WebAssembly boundary as
  typed arrays, which is what lets the demo's `<canvas>` draw a city-scale map from
  a few dozen calls per frame.
- [`ll2-rerun`](crates/ll2-rerun) logs the map into [Rerun](https://rerun.io) as 3D
  archetypes, for a Spatial3D view. It reads the same tag-to-style table, so a map
  is coloured the same there as it is here — but it keeps every node's `ele`
  instead of flattening it, because the point of that viewer is having the map in
  the same space as the lidar sweep, the ego pose and the trajectory beside it.

```rust
// a .osm to an .svg, no browser involved
let svg = ll2_viz::svg_from_osm(&text, &ll2_viz::VizOptions::default())?;

// the same map drawn in relief, from a camera 55 degrees above the horizon
let scene = ll2_viz::Scene::from_map(&map, &ll2_viz::VizOptions::default());
let svg = ll2_viz::render_svg(&scene, &ll2_viz::SvgOptions {
    view: ll2_viz::View::three_quarter(),
    ..Default::default()
});

// the same map in 3D, in a Rerun viewer it opens for you
ll2_rerun::spawn_osm(&text, &ll2_rerun::MapOptions::default())?;
```

```bash
just svg tests/data/mapping_example.osm map.svg   # the same thing from a shell
just svg3d tests/data/mapping_example.osm 3d.svg  # or in relief, elevation and all
just scene tests/data/mapping_example.osm s.json  # or the scene itself, to draw elsewhere
just rrd tests/data/mapping_example.osm map.rrd   # or a Rerun recording, to open in 3D
just web-serve                                    # the demo, on localhost:8000
```

A `Scene` being renderer-agnostic is meant literally: the SVG writer, the demo's
`<canvas>` and anything else are peers, and the third one costs a serialiser.
[`examples/scene2json.rs`](crates/ll2-viz/examples/scene2json.rs) is that
serialiser — it writes the styled shapes, both palettes and the layer table as
JSON, which is enough to draw the map somewhere this repository has never heard
of.

The third dimension is in the `Scene`, not in the renderers: every vertex carries the
elevation its node had, and flattening happens once, at the very end, through the
`View` a renderer is given. So the SVG writer and the demo's `<canvas>` both draw the
map in relief without either of them knowing how — what crosses to the browser is
still two floats per vertex, already projected and already in painter's order, and
the canvas needs no depth buffer to show a bridge over a road. Turning the camera
rebuilds the scene rather than redrawing it, which is the right trade for a viewpoint
that is chosen and then looked at. The JSON serialiser is the exception, and
deliberately so: it hands over the unprojected three dimensions and leaves the camera
to whatever is drawing them.

`ll2-rerun` is the one that does not read a `Scene`, and the reason survives that: it
builds surfaces rather than outlines — ear-clipped rings, lanelets stitched into
ribbons — and hands Rerun the map in three dimensions rather than a projection of it,
one entity per layer under `map/`, so the groups the SVG writer emits are the
checkboxes in the viewer's blueprint tree. Painter's order becomes two centimetres of
real separation per layer, which is what stops a lane marking and the road it is
painted on from fighting over the same depth value.

The viewer differs from `lanelet2.io.load` in one deliberate way. It has no origin
to be given, so it takes the median of the file's own latitudes and longitudes and
projects through UTM from there — and if that collapses the map to a point while the
file's `local_x`/`local_y` tags do not, it uses those instead. That is the case for
Autoware maps written with a placeholder `lat`/`lon`, which would otherwise draw as
nothing. The Python API makes no such guess; see [Autoware maps](#autoware-maps)
below. Both behaviours are reachable from the viewer's *Coordinates* control.

[`web/README.md`](web/README.md) covers building and deploying it.

## Verification

Compatibility is not asserted, it is measured. Every case in `tests/cases/` is run
three ways and the JSON-Lines output is compared:

| run | interpreter | environment |
| --- | --- | --- |
| `REF` | `.venv-ref` | the real `lanelet2==1.2.3` from PyPI |
| `COMPAT` | `.venv` | ours, `LANELET2_BUG_COMPAT=1` |
| `FIXED` | `.venv` | ours, default |

`REF` and `COMPAT` must agree exactly. `COMPAT` and `FIXED` must differ in exactly
the places listed in `tests/compat_matrix.toml` — no more, and no fewer, so neither
an accidental behaviour change nor an unwired repair can slip through.

```bash
just venvs           # create both virtualenvs
just build           # build and install into .venv
just diff            # run the harness
just upstream-tests  # upstream's own tests, unmodified, in both modes
just test-rust       # the Rust unit tests
```

What the harness checks, beyond "it runs":

- the 594 KB example map from the Lanelet2 repository loads, and writing it back
  reproduces the reference's file **byte for byte** — every node, way, relation and
  tag, in the same order — and a second pass is identical again;
- every lanelet's full centerline matches, on that map and on forty procedurally
  generated shapes chosen to exercise the parts of the algorithm a rectangular
  lanelet never reaches;
- the projections agree with GeographicLib to **7e-15 m** across zone edges, the
  Norway and Svalbard zone irregularities, both hemispheres and the antimeridian;
- the traffic-rule tables are swept exhaustively — every participant against every
  way subtype, both directions, both locations — rather than spot-checked;
- the routing graph's whole edge list is compared before any query is.

## Autoware maps

`autoware_lanelet2_extension`'s regulatory elements and its transverse Mercator
projector are provided under the upstream import path, in this same wheel:

```python
import lanelet2
import autoware_lanelet2_extension_python.regulatory_elements  # registers the subtypes
from autoware_lanelet2_extension_python.projection import TransverseMercatorProjector
```

Importing `regulatory_elements` is what makes `road_marking`, `crosswalk`,
`detection_area` and the rest resolvable, and it makes `traffic_light` resolve to
`AutowareTrafficLight`. Before that import a map carrying them is refused, exactly as
stock Lanelet2 refuses it. That is upstream's behaviour, not an accident of packaging:
registration there happens when the extension's shared library loads. Note that it is
process-wide and cannot be undone, so an unrelated module importing the extension
changes what `lanelet2.io.load` produces from that point on.

**Coordinates come from latitude and longitude, not from `local_x`/`local_y`.**
Autoware's C++ `AutowareOsmParser` prefers those tags, and a real Autoware map is full
of them — the Nishi-Shinjuku example carries 36,936. That parser is not reachable from
Python in the reference either, so `lanelet2.io.load` behaves the same in both; our
output on that map is byte-identical to the reference's. But it does mean the numbers
differ from what Autoware's own C++ tooling produces.

`utility.query` and `utility.utilities` are provided apart from their ROS-dependent
halves, which are defined but raise when called — upstream imports `geometry_msgs` and
`rclpy` at module top, so its versions cannot be imported at all without ROS. Several
of upstream's own bindings do not work; those are repaired here and reproduced under
`LANELET2_BUG_COMPAT=1`. See [`docs/DIVERGENCE.md`](docs/DIVERGENCE.md) for the list.

## CI

Four jobs, arranged around the compatibility claim rather than around the test suite:

| job | what it proves | needs |
|---|---|---|
| `check` | it builds, lints, and the wheel installs into an empty venv and imports | nothing |
| `diff` | 20 cases against the PyPI `lanelet2==1.2.3`, plus upstream's vendored tests | a wheel |
| `upstream` | upstream's *own* tests, cloned at HEAD each run, unmodified, both modes | network |
| `oracle` | 31 cases, including the Autoware extension and the two-reference skew check | pixi + colcon |

The map viewer likewise has its own workflow, [`pages.yml`](.github/workflows/pages.yml):
it builds the wasm module, instantiates it in node and puts the example map through
it, drives the demo and both embedding routes in a real Chromium, and deploys to
GitHub Pages from `main`. Pull requests build and test without deploying. Both test
scripts earn their place: `cargo test` runs on the host, where wasm-bindgen's glue is
inert, so it cannot catch a module that fails to instantiate — and instantiating is
not the same as a canvas in a shadow root receiving a wheel event.

The container image has its own workflow rather than a fifth job here, because it
costs a full Rust build per architecture and is wanted on a different set of events
— see [`docker.yml`](.github/workflows/docker.yml). It builds on any pull request
that touches the crates, the Python package or the Dockerfile, and publishes only
from `main` and from releases.

One case is not in that count. `1150_aw_map` loads a real 10.5 MB Autoware map and
checks it writes back byte for byte; the map it was developed against is CC BY-NC
licensed, so it can neither be vendored here nor fetched by CI. Point
`SIMPLE_LL2_AW_MAP` at a real Autoware map to run it. `1160_aw_synthetic_map` covers
the same behaviour on a map small enough to write inline, and does run in CI.

One caveat on what `upstream` proves. Lanelet2's own tests are substantive; the
Autoware extension's Python tests are import smoke tests, and its real suite is C++
gtest that cannot run against a Python implementation. The extension is verified by the
`# ORACLE: aw` diff cases instead — see [`tests/upstream-awext/README.md`](tests/upstream-awext/README.md).

`upstream` fetching at HEAD rather than a pin is deliberate. A vendored copy proves
compatibility with whatever upstream looked like the day it was copied; fetching proves
it against upstream as it stands, and a test they change becomes a signal rather than a
silent drift. It runs on a schedule too, since upstream moves without anyone touching
this repository. Locally: `just upstream-fresh`.

## Licence

BSD-3-Clause, matching upstream Lanelet2. See [`NOTICE`](NOTICE) for vendored assets.

