Files
lidar_rendu/docs/MAPS.md
Antoine fb892ea9f2 Translate the whole project to English and fix outdated comments and help
Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts,
compose files and AGENTS.md are now English. Data keys stay unchanged
(relief_oriente, densite_sol, visualisations/, API JSON keys, link params).
Wrong comments and help defaults found along the way are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:16:45 +02:00

462 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# XYZ tile map (`lidar-maps` image)
A classic "slippy" map — the same tile scheme as Google Maps and
**OpenStreetMap** — served by a dedicated lightweight image. Pipeline
renders thus become a **reusable imagery basemap** in JOSM, iD, QGIS, uMap,
MapLibre or OsmAnd, on top of the browsing interface it also provides.
This is the ONLY web interface in the project (the old historical webapp has
been removed): it also embeds **tile generation** — local on the full image
(the full pipeline server, port 8973, `./run.sh --serve`), delegated via
`LIDAR_GENERATION_URL` on the lightweight machine (see
`docs/DEPLOY_WEBAPP.md`).
```bash
docker compose -f docker-compose.maps.yml up -d --build # http://localhost:8975/
docker compose -f docker-compose.maps.yml logs -f maps
./run.sh --serve-maps # equivalent, foreground container
```
## Interface
A **single tabbed panel** (`web/map.{html,css,js}`) groups all settings,
replacing the old stack of stacked blocks: **View** (main layer,
relief/precision mode, basemap), **Tile** (the selected tile's card),
**PDF** (PDF export), **Generate** (hidden if the generator is unavailable
or not authorized for that browser) and **Share**. On desktop, the panel occupies a
fixed column, collapsible into an icon strip (**‹**, always usable: clicking
a tab redeploys the panel). Below 720 px wide, it becomes a **bottom sheet**
with three heights — closed, half, full — changed by dragging the handle, by
simply tapping it (one notch), or by tapping the already-active tab again.
Clicking the map **selects** the LiDAR HD tile under the cursor (dashed
outline) and fills the **Tile** tab (footprint, IGN info, pass
alignment) without changing the displayed tab; clicking the same tile again
deselects it, clicking another moves the selection. Keyboard shortcuts:
**1–5** (panel tabs, no effect if the tab is hidden), **P** (next display
mode), **Escape** (deselects the tile, then collapses the panel). Two
light/dark themes (◐/☀/☾ button, `auto` by default = follows the system);
settings and panel state are kept in `localStorage` (`lidarMapView_v2`,
`lidar-print`, `lidar-panel`, `lidar-theme` — silently ignored in private
browsing).
## Generating tiles from the map
- **+ Area** (Generate tab) — draw a rectangle: the 1 km LHD tiles it
intersects are downloaded from the IGN geoplatform and then processed
(0.2 m, options below); successive zones and clicks are additive;
- **⤒ Complete** — all tiles already present in `input/` that are
missing at least one of the requested layers;
- **↻ Generate/Regenerate this tile** (button on the tile card, Tile tab) —
a specific tile, even with no existing data.
Run options: target layers (default: the panel layers) and forced
regeneration. Ground classification (IGN, ground only) and edge stitching (a
100 m band taken from neighboring tiles) are enforced: no setting available.
A request submitted while a run is already in progress goes into a
**persisted queue** (never interrupting work in progress); progress is shown
tile by tile (orange frames = rendering in progress, blue = waiting, red =
failed) with a log and a **Stop** button (SIGTERM then SIGKILL). During a
run, each completed tile appears on the map as it finishes: the inventory is
updated after each tile and the pyramid maintenance job prioritizes its tiles
(polled every 10 s).
The buttons are hidden if the browser's IP falls outside
`LIDAR_REGEN_CIDR` (default: localhost + private RFC1918 ranges) or if no
generation backend exists (lightweight image without `LIDAR_GENERATION_URL`).
On the lightweight machine, everything is forwarded to the worker
(`LIDAR_GENERATION_URL`), which runs the full pipeline; its tiles are then
pulled back on demand via the `/api/tiles` inventory plus versioned static
assets (`LIDAR_SOURCE_URL`).
## Tiling contract
| Point | Value |
|---|---|
| Projection | EPSG:3857, OSM XYZ scheme (north-west origin, `y` toward the south) |
| Canonical URL | `/tiles/{layer}/{z}/{x}/{y}.png` — 256 px, PNG RGBA |
| High-density variant | `/tiles/{layer}/{z}/{x}/{y}@2x.avif` — 512 px AVIF (internal interface; png/webp also accepted) |
| Zoom levels | 5 → 19 native (0.2 m/px ≈ z19 in France); beyond that, client-side over-zoom |
| Outside coverage | fully transparent tile (overlayable), header `X-Tile-Empty: 1` |
| Pending (cache-only mode) | transparent tile, headers `X-Tile-Empty: 1` + `X-Tile-Pending: 1`, never cached by the browser |
| CORS | `Access-Control-Allow-Origin: *` on `/tiles/*` |
| Attribution | `LIDAR_ATTRIBUTION`, default "LiDAR HD © IGN — Licence Ouverte 2.0" |
Discovery: `/tiles/{layer}.json` (TileJSON 3.0.0), `/tiles/wmts.xml`
(WMTS 1.0.0, `GoogleMapsCompatible` grid), `/tiles/josm.imagery.xml`
(all layers at once in JOSM).
## Using the tiles elsewhere
- **JOSM** — *Imagery → Imagery preferences → + TMS*, paste
`http://<host>:8975/tiles/slope/{zoom}/{x}/{y}.png`. To add everything at
once: *Imagery preferences → Offline/Custom → Add imagery source XML* with
`http://<host>:8975/tiles/josm.imagery.xml`.
- **iD** — *Background → Custom*, paste
`http://<host>:8975/tiles/slope/{z}/{x}/{y}.png`.
- **QGIS** — *XYZ Tiles → New Connection* (same URL, max zoom 19), or
*WMS/WMTS → New* with `http://<host>:8975/tiles/wmts.xml`.
- **uMap / MapLibre / Leaflet** — same XYZ template, or the TileJSON.
- **OsmAnd** — online tile source, XYZ template, max zoom 19.
The **XYZ imagery background** section of the map's **Share** tab shows
these URLs for the selected layer, with a **Copy** button for the XYZ template.
## Full pyramid pre-generated
By default, **all** levels up to native (19 in standard OSM numbering =
0.2 m/px, served as `18@2x` by the interface) are written to disk in AVIF
and pre-generated by the background maintenance job (on a map fed by an
upstream source: downloaded from `LIDAR_MAPS_URL`). No level is rendered
on the fly: on-demand rendering on a Raspberry Pi (~170 ms per tile, 3 at a
time) took 1.5 to 6 s per screen at zoom levels 17–19. The interface is
capped at zoom 19 (1 screen pixel = 1 LiDAR pixel): never an upscaled tile.
Measured order of magnitude: ~110,000 @2x tiles for 3,240 tiles, of which
81,000 at native level, ~5 GB.
Reduced storage (measured on disk): a lower `LIDAR_TILE_CACHE_MAX_Z` and/or
`LIDAR_TILE_EVEN_LEVELS=1` (even levels only; the interface then downsamples
the level above's tiles at odd zooms, other levels are rendered on the fly
with an in-memory cache, `LIDAR_TILE_MEMORY_CACHE_MB`).
A map fed by an upstream tile-source server (`LIDAR_SOURCE_URL`, the Pi's
case) pulls in and **keeps locally** the sources for each tile as soon as it
appears (thumbnails and 500 m quadrants; the full tile, a duplicate of its
quadrants, is not pulled in), without waiting for a visit. All levels remain
available while the rendering container is off; a source missed while it was
off is picked up on the next scan.
## Tile sheet and compass rose
Clicking the map selects the LiDAR HD tile under the cursor (dashed yellow
frame) and fills the **Tile** tab — name, Lambert 93 footprint, then,
if the tile has been rendered, resolution, generation date and vertical pass
alignment (beams, offsets, line correction) — whether it has been generated
or not, without changing the displayed tab. IGN information arrives
separately (`GET /api/map/ign?col&row`, STAC catalog cached in
`output/ign_meta/`): LiDAR scan date and time, sensors, mission, operator,
edit date, classification process, point count and a download link for the
`.copc.laz` point cloud on the geoplatform. A slow or unreachable catalog
never blocks selection. Clicking the same tile again (or Escape) deselects
it.
When the oriented relief layer is displayed, a compass rose shows the color
of each slope orientation (same CIELAB formula as the render); lightness
carries the local relief (light = bump, dark = hollow).
## Display: relief and precision
The map serves only the layers in `PANEL_VIZ` (`index.py`): the **oriented
relief** (`relief_oriente`, the main display layer, which merges local
openness and slope orientation) and the **precision** layer
(`densite_sol`: density of the ground points retained for the DTM). Other
visualizations present on disk are neither listed nor served as tiles.
There is no more layer stack. The panel offers three modes, one click each,
or the **P** key to cycle to the next:
- **Relief** — the oriented relief alone;
- **Precision** — density alone, in 16 shades of gray (fixed log
scale: level k starting at 0.25 × 2^(k/2) pts/m², black ≤ 0.35 or no
points, white ≥ 45); reads as a geometric-reliability map;
- **Compare** — a slider (mouse or touch) separates two layers
chosen from two menus (relief, precision, or bare OSM background) on
either side of the cursor; the same layer on both sides adds no split.
The View tab also carries a **relief intensity** slider
(0.5×–2×, 1× by default): a comfort contrast applied to the displayed
layer's container (CSS `contrast()`), remembered and shared in the link
(`&I=`, written only if ≠ 1×) but never fixed as a server default nor
applied to the exported PDF (which keeps the standard render). A
collapsible **How to read the map** block reuses,
for the displayed layer(s), the reading text from `VIZ_LEGENDS` (also served
by `/api/map/meta` and the TileJSON).
The precision legend (16 levels, tooltip in pts/m² on each level) is shown
as soon as precision is visible, either alone or on one side of the Compare
slider. The slider clips only the LiDAR layers (a CSS `clip-path` on each
layer's container), never the basemap.
The share link carries the main layer, the mode, the comparison and the
intensity:
`#z/lat/lng&M=relief_oriente&P=compare&C=relief:precision:30&I=1.4&B=1:85:1`
(`&C=left:right:position%` only in Compare mode). Old links from the layer
stack (`&L=…`) and the old "both" mode (`&P=both:opacity`, opacity then
ignored) open without error, in relief mode.
### Freezing the configuration
The **★ Set as default** button saves the current
display — main layer, mode, basemap — to `output/.map-defaults.json` (never
the intensity, a per-browser comfort setting). Any browser with no local
setting then starts from this configuration; **↺ Reset**
forgets the local state and reverts to it.
```bash
curl http://localhost:8975/api/map/defaults # served configuration
curl -X DELETE http://localhost:8975/api/map/defaults # revert to the registry
```
With no saved file, defaults come from the pipeline registry (`DEFAULT_VIZ`,
`PRECISION_VIZ`, `DEFAULT_VIEW_MODE` in `index.py`). Received values are
filtered: an unknown layer (or precision itself) is rejected as the main
layer, the mode is validated, basemap opacity is clamped to 0–1. A file from
the old stack (`order`/`on`/`blend`) is ignored, except for the basemap.
## PDF export (field print sheet)
The **PDF** (PDF export) tab shows the settings (A4/A3 format, landscape/portrait,
scale 1:1,000 to 1:10,000, optional title) and, while it stays open, a
**dashed yellow frame** showing the area that will be printed (it disappears
when switching tabs). The frame is **anchored to the terrain**: it is
placed at the center of the view when opened (or keeps its previous
position if it's still visible), the map zooms to show it in full above the
panel, and one can then navigate freely without it moving. It is moved by
dragging its **✥ handle** (mouse or touch); on release, the exact Lambert 93
geometry is recomputed (`GET /api/export/frame`) — a release immediately
followed by a click does not select a tile (300 ms guard). **⌖ Centre here**
brings it back to the center of the view, **⤢ Show frame** zooms onto it; changing format, orientation or scale
recenters the view. Settings and frame position are kept in the browser's
`localStorage`. **Export PDF** downloads the sheet (`GET
/api/export/pdf`), named `relief_{x_km}_{y_km}_1-{scale}.pdf` (Lambert 93
center in km, to three decimal places).
The sheet (module `lidar_pipeline/export_pdf.py`) is composed directly in
Lambert 93 from the already-rendered sources (no pass through the XYZ
tiles), then drawn vectorially (text, grid, legend) with `reportlab` —
Pillow + pyproj + reportlab only, **no numpy**: it runs equally well on the
full image and on the Pi's lightweight image alone. Contents:
- the **oriented relief** map, cropped to the requested scale (300 dpi at
A4, 250 dpi at A3 — bounds Pi memory usage, ~36 MB at A3); outside the
available tiles' coverage, the area stays white and hatched;
- a **Lambert 93 grid** (100 m spacing at scales 1:1,000/1:2,000, 500 m at
1:5,000, 1,000 m at 1:10,000) graduated in the margin, and the printed
area's **WGS84 corners** at all four angles;
- a **geographic north arrow** accounting for meridian convergence (the map
is oriented to the L93 grid north, not geographic north — the difference
is shown in degrees);
- a **graphic scale bar** (alternating bar) and the **numeric scale**;
- an **8-point orientation rose** (N/NE/E/SE/S/SW/W/NW), same CIELAB formula
as the tile sheet's rose;
- the oriented relief's legend text, reused from `VIZ_LEGENDS` (single
source shared with the interface and the TileJSON);
- a **quality panel**: a thumbnail of ground-point density (50 m cells,
color classes), key figures (average density, weakest cell, share of
interpolated area, acquisition period), areas **hatched in white** where
quality data is not available, and a "**Missing data (no relief)**" line listing the tiles in the area that
haven't been generated yet;
- a title block: title (by default, the list of covered tiles), scale,
format, dpi, L93 center, area size, export date and IGN source mention.
Only one PDF export at a time: a second call while an export is running gets
`429` (retry). Export is **never delegated** to `LIDAR_GENERATION_URL`:
unlike tile generation, the lightweight map alone (a Pi with no worker) can
export by itself, from the sources already pulled to disk.
> **License** — LiDAR HD is distributed under the **Licence Ouverte 2.0**
> ("Open License 2.0"): IGN attribution is mandatory and must remain visible
> to the end user. Before using these renders as an OpenStreetMap **survey**
> layer, check the position of the community (OSM-FR) on the source in
> question.
## How a tile is built
1. The tile's footprint is converted to Lambert 93 (5×5 sampling of the
outline: edges aren't straight in L93).
2. The 1 km tiles it intersects are found by their name
(`LHD_FXX_{col}_{row}` → X ∈ [col, col+1] km, Y ∈ [row−1, row] km).
3. For each one, the coarsest **source tier** that is still sufficient is
chosen among those the pipeline already produces: `index_thumbs`
thumbnail (≈3.9 m/px), intermediate `_mid` thumbnail (1.56 m/px),
`index_subtiles` quadrants (2500 px, 4× less to decode than the full
tile), then the full tile. The three folders are scanned
**independently**: a partial cache — a lightweight machine that only
pulls in quadrants and thumbnails, never full tiles — remains fully
usable.
4. The useful window is cropped, then reprojected via a **perspective
transform** (`Image.transform(..., PERSPECTIVE)`), tile by tile: the
measured alignment is sub-pixel.
5. The result is encoded (PNG or WebP) and written to
`output/index_xyz/{layer}/{z}/{x}/{y}[@2x].{ext}`.
No GDAL/PDAL dependency: **Pillow + pyproj** only.
### Cache and expiry
- A cached tile is re-served as long as **no contributing tile is more
recent than it**: regenerating a tile only invalidates its own tiles.
- A tile with no data is memoized with an `.empty` marker — never
recomputed.
- Decoded source images are kept in a small LRU cache: AVIF decoding
dominates the cost, and neighboring tiles reuse it.
- Client-side, the interface appends `?v=<stamp>` (most recent mtime) and
then gets an immutable cache; OSM clients use the bare URL, served with
short revalidation.
### Measurements (real 0.2 m tiles, 3×3 km block, GPU-less container)
| | first render | from cache |
|---|---|---|
| z10–z14 | 50–200 ms | 3–9 ms |
| z16–z18 (native resolution) | 46–130 ms | 3–9 ms |
Weight of a slope tile at z17, measured on the same tile:
| encoding | size | fidelity |
|---|---|---|
| PNG RGBA (default) | 210 KB | lossless |
| Palettized PNG (`LIDAR_TILE_PNG_PALETTE=1`) | 55 KB | average deviation 5.8 levels |
| WebP q78 (`…/{y}.webp`) | 38 KB | lossy, visually clean |
The canonical PNG stays **lossless** by default: these renders are meant for
interpretation, not illustration. For use as an imagery basemap where
bandwidth matters, two levers: request the `.webp` URL (supported by QGIS,
iD, MapLibre, uMap) or enable `LIDAR_TILE_PNG_PALETTE=1`.
Alignment checks performed on real data: an OSM path overlays exactly onto
the trace visible in the slope layer, and the seam gap between neighboring
tiles stays **below the terrain's natural noise** (measured gap 35–53
levels against a median of 49–53 between two random neighboring columns
within the same tile).
## Load and small machines
The service is bounded at every stage, nothing is unlimited:
| Stage | Default | Setting |
|---|---|---|
| Concurrent renders | 2 | `LIDAR_TILE_WORKERS` |
| Concurrent tile downloads | 2 | `LIDAR_TILE_FETCH_WORKERS` |
| Same tile requested in parallel | 1 render, others wait for its result | — |
| Decoded source memory | 192 MB | `LIDAR_TILE_SOURCE_CACHE_MB` |
Excess requests wait on the semaphore **before** any decoding: they consume
neither CPU nor memory. A browser over HTTP/1.1 only opens 6 connections per
origin anyway, across all layers combined; behind an HTTP/2 proxy this cap
disappears and only these settings hold the load.
Pre-warming (`/api/map/warm`) is sequential: it cannot saturate the machine,
only take time.
On a 2 GB Raspberry Pi, `LIDAR_TILE_WORKERS=1` and
`LIDAR_TILE_SOURCE_CACHE_MB=64` remain comfortable.
## Cache-only + background maintenance (small Raspberry Pi)
On a machine that must never compute while someone is browsing (a Pi that
also hosts other services), two variables invert the load:
```yaml
environment:
- LIDAR_TILE_CACHE_ONLY=1 # browsing no longer renders anything
- LIDAR_TILE_BACKGROUND=1 # a background task maintains the pyramid
```
- **`LIDAR_TILE_CACHE_ONLY=1`** — a missing or stale tile is served
transparent with `X-Tile-Pending: 1` and `Cache-Control: no-store` (the
browser re-requests it: as soon as maintenance has rendered it, it
appears). No more local rendering on the request path; with
`LIDAR_MAPS_URL`, a missing tile (not yet handled by maintenance) is
pulled in from there — a download of a few dozen KB, cached — then
served: browsing covers all zoom levels without ever computing on the
small machine.
- **`LIDAR_TILE_BACKGROUND=1`** — a poller rescans tiles at a regular
interval (`LIDAR_TILE_BACKGROUND_INTERVAL`, 120 s); each new or
regenerated tile (the worker just produced it, or the on-demand cache just
pulled it in) queues its pyramid. Low-priority renderers (`os.nice`) drain
it at a rate of one tile per `LIDAR_TILE_BACKGROUND_PAUSE` second (1 s).
First start-up: ALL tiles are unknown, the whole pyramid is rebuilt —
already-fresh tiles only cost a `stat`. Levels are queued from the
smallest zoom to the largest: the map fills in coarsely first.
| Variable | Default | Role |
|---|---|---|
| `LIDAR_TILE_BACKGROUND_MAX_Z` | native (`18` at @2x, `19` at 256 px) | maximum level maintained in the background, URL numbering |
| `LIDAR_TILE_BACKGROUND_SCALE` | `2` | tiles maintained: 2 = 512 px (the interface's) |
| `LIDAR_TILE_BACKGROUND_FMT` | `avif` | format of maintained tiles (the interface's) |
| `LIDAR_TILE_BACKGROUND_PAUSE` | `1.0` | pause (s) between two renders — the discretion lever |
| `LIDAR_TILE_BACKGROUND_INTERVAL` | `120` | seconds between two tile scans |
| `LIDAR_TILE_BACKGROUND_QUEUE_MAX` | `65536` | bounded queue — each tile remembers the first pyramid tile it was refused for lack of room and resumes from there on the next scans, until its whole pyramid has gone through |
Control: `GET /api/map/background` (state, counters, queue), `POST
/api/map/background` (immediate scan), `POST /api/map/warm` (manual
exhaustive pre-computation, all zooms/formats, see below). Finally, capping
the container itself (`mem_limit` + `memswap_limit` in a compose override)
guarantees a misbehaving render can no longer bring down the machine: the
OOM killer would only ever hit `lidar-maps`.
## Pre-warming
```bash
curl -X POST http://localhost:8975/api/map/warm \
-H 'Content-Type: application/json' \
-d '{"layers": ["slope"], "z_min": 10, "z_max": 16}'
curl http://localhost:8975/api/map/warm # progress / report
```
Without `bounds`, the coverage of available tiles is used. Useful after a
large run so the first visit is instant.
## Two machines
Two complementary upstreams, depending on what the local machine has.
`LIDAR_SOURCE_URL` designates **the full pipeline server** (port 8973): the
tile inventory comes from its `/api/tiles`, and each source image is pulled
in on the first render that needs it. The map container then starts with no
local data at all and serves the entire remote catalog:
```bash
docker run -d --name lidar-maps --network host \
-e LIDAR_SOURCE_URL=http://127.0.0.1:8973 \
-e LIDAR_OUTPUT_DIR=/data/output -e LIDAR_PORT=8975 \
-v "$PWD/output-maps:/data/output" --user "$(id -u):$(id -g)" lidar-maps
```
Measured on 849 real tiles served over an SSH tunnel: first tile in an area
0.9–3.2 s (including downloading a 1.8 MB quadrant), then **~1 ms** from the
local cache, which only keeps what has been consulted.
`LIDAR_MAPS_URL` designates an upstream tile server (the processing
machine): a tile missing locally is pulled in from there (a few dozen KB)
then cached — instead of pulling in whole tiles. A circuit breaker suspends
attempts for 2 minutes after 3 consecutive failures: the map stays usable
even with the worker off.
## Environment variables
| Variable | Default | Role |
|---|---|---|
| `LIDAR_OUTPUT_DIR` | `/data/output` | tiles read from, `index_xyz/` cache written to |
| `LIDAR_PORT` | `8975` | listening port |
| `LIDAR_SOURCE_URL` | — | full pipeline server: inventory + tiles pulled in on demand |
| `LIDAR_SOURCE_TOKEN` | — | token if the upstream server requires `LIDAR_API_TOKEN` |
| `LIDAR_MAPS_URL` | — | upstream tile server (remote map) |
| `LIDAR_ATTRIBUTION` | LiDAR HD © IGN | attribution served (TileJSON, WMTS, JOSM, map) |
| `LIDAR_TILE_PNG_PALETTE` | — | `1`: palettized PNG (~4× lighter, ~6-level deviation) |
| `LIDAR_TILE_SOURCE_CACHE_MB` | `192` | memory budget for the decoded source-image cache |
| `LIDAR_TILE_WORKERS` | `2` | concurrent tile renders |
| `LIDAR_TILE_FETCH_WORKERS` | `2` | concurrent tile downloads (`LIDAR_SOURCE_URL` mode) |
| `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE` | — | direct HTTPS (GPS on a phone) |
## Troubleshooting
- **Empty map, `layers: 0`** (`curl /healthz`): no render in
`output/visualisations/`, or the folder is mounted at the wrong path.
- **Tiles transparent everywhere**: zoom out of range (5–19) or an area with
no tiles; the `X-Tile-Empty` header confirms this.
- **First visit is slow**: normal, each tile is rendered once — pre-warm
(see above).
- **JOSM rejects the URL**: use the `{zoom}/{x}/{y}` template (JOSM), not
`{z}/{x}/{y}` (Leaflet/QGIS).
- **Layer missing from the menu**: it doesn't exist on disk for these tiles —
`/api/map/meta` lists what is actually available.
## References
- `lidar_pipeline/tiles.py` — grid, reprojection, cache, pre-warming.
- `lidar_pipeline/mapserve.py` — tile/TileJSON/WMTS/JOSM routes and the map API.
- `lidar_pipeline/web/map.{html,css,js}` — interface (tabbed panel, Leaflet `L.tileLayer`, native LOD), re-read by `lidar_pipeline/mapui.py`.
- `Dockerfile.maps`, `docker-compose.maps.yml`, `./run.sh --serve-maps`.