Files
lidar_rendu/docs/MAPS.md
2026-09-27 22:31:54 +02:00

24 KiB
Raw Blame History

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).

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.

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:

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

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:

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.