464 lines
24 KiB
Markdown
464 lines
24 KiB
Markdown
# 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: **Affichage** (Display) (main
|
||
layer, relief/precision mode, basemap), **Export PDF**, **Génération**
|
||
(Generation) (hidden if the generator is unavailable or not authorized for
|
||
that browser) and **Partager** (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 **Dalle** (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
|
||
|
||
- **+ Zone** (Génération 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;
|
||
- **⤒ Compléter** (Complete) — all tiles already present in `input/` that are
|
||
missing at least one of the requested layers;
|
||
- **↻ Générer/Régénérer cette dalle** (Generate/Regenerate this tile) (button
|
||
on the tile sheet, Dalle 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.webp` — 512 px (internal interface) |
|
||
| 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 **"Utiliser dans JOSM / QGIS"** ("Use in JOSM / QGIS") button on the map
|
||
shows and copies these URLs for the selected layer.
|
||
|
||
## 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 **Dalle** (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 **relief
|
||
orienté** (oriented relief, the main display layer, which merges local
|
||
openness and slope orientation) and the **précision** (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;
|
||
- **Précision** (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;
|
||
- **Comparer** (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 Affichage (Display) 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 **Comment lire la carte** (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. LiDAR layers live in an **isolated** container (`isolation:
|
||
isolate`): the slider only splits the LiDAR layers, 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 **★ Définir par défaut** ("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; **↺ Réinitialiser** (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 **Export PDF** 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). "⌖ Centrer ici"
|
||
("Center here") brings it back to the center of the view, "⤢ Voir le cadre"
|
||
("View the frame") zooms onto it; changing format, orientation or scale
|
||
recenters the view. Settings and frame position are kept in the browser's
|
||
`localStorage`. **Exporter le PDF** ("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 "**Donnée manquante (sans relief)**"
|
||
("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` | `webp` | 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`.
|