Files
lidar_rendu/AGENTS.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

30 KiB
Raw Permalink Blame History

Workflow

  • install: docker build -t lidar-lidar . (deps baked into the image)
  • one-command local start (GPU optional): ./start.sh (map on 8973), ./start.sh process [options], ./start.sh logs|stop — creates input//output/ as the current uid, adds docker-compose.cpu.yml (gpus: !reset) when Docker cannot reach any GPU.
  • build: docker build -t lidar-lidar .
  • build the lightweight map (Raspberry Pi, two-machine deployment — see docs/DEPLOY_WEBAPP.md): docker compose -f docker-compose.maps.yml up -d --build (image Dockerfile.maps, no PDAL/GPU)
  • build the tile generator (processing machine): docker compose -f docker-compose.worker.yml up -d --build (service worker = mapserve on the full image: API + tiles + inventory for the remote maps)
  • test all: ./run.sh --test (rebuilds the image automatically before the tests; with a direct docker run, rebuild by hand first)
  • test file: docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>
  • test case: docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>::<TestClass>::<test_method>
  • lint: not configured
  • format: not configured
  • after every edit: ./run.sh --test
  • RULE 1 — always run through docker compose (never a direct docker run): local map/API → docker compose up -d --build serve (port 8973, mapserve on the full image); one-off processing → docker compose run --rm --build process [options]; logs → docker compose logs -f serve; stop → docker compose down.
  • RULE 2 — ALWAYS --build: the code is baked into the image (never mounted). Without --build, up/run reuse the existing image and the OLD code runs. --build is almost instant thanks to the cache (.dockerignore keeps input/ and output/ out of the context). After an edit, docker compose up -d --build serve recreates the container on fresh code.
  • quick test without rebuild (code mounted over the image): docker run --rm -e PYTHONPATH=/app -v $(pwd)/lidar_pipeline:/app/lidar_pipeline lidar-lidar python3 -m pytest --pyargs lidar_pipeline.tests -q (~3 min; prefix with timeout 600, and NO | tail pipe, which hides the progress)
  • debug: ./run.sh --debug (file:line logging); container shell: docker run --rm -it -v $(pwd)/input:/data/input -v $(pwd)/output:/data/output --entrypoint bash lidar-lidar
  • ./run.sh passes its own defaults to the CLI: -r 0.5 and -w 1 unless given (the CLI alone defaults to -r 0.2, -w auto); image quality, format and ground classification fall through to the CLI defaults (60, avif, ign).
  • updating a remote lightweight map (git checkout + local maps override, see docker-compose.maps.override.yml.example): ssh <host> "cd <checkout> && git pull && docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build" (procedure in docs/DEPLOY_WEBAPP.md).
  • backfilling the quality sidecars (tiles processed before the sidecar existed): the fixed command of process (compose) is replaced entirely as soon as any argument follows the service name, so --quality-backfill alone does not work — use docker compose run --rm --build process python3 -m lidar_pipeline /data/input -o /data/output --quality-backfill.

Conventions

  • A single web server: mapserve.py (image lidar-maps, port 8975 lightweight / 8973 worker). The old webapp (webapp.py, export.py, index.html/_APP_JS) has been removed: tile generation (the scope of the historical webapp — /api/preview, /api/generate, /api/status, /api/stop, /api/queue/clear, /api/cell) lives in mapserve.py, the interface in web/map.{html,css,js} (read by mapui.py, _MAP_* constants kept, written as app.css/app.js by write_map_assets() and baked into the images). On the lightweight image without LIDAR_GENERATION_URL, /api/status answers available: false and the interface hides the Generation tab.
  • Single-panel interface (web/map.{html,css,js}): one tabbed panel View / Tile / PDF export / Generation (hidden when the generator is unavailable or not allowed) / Share, replacing the old stack of blocks. Below 720 px wide, the panel becomes a bottom sheet with three heights (closed/half/full; handle #sheetGrip dragged or simply tapped, or the active tab tapped again). A click on a tile selects it (outline) and fills the Tile tab (extent, IGN, strip alignment) without switching the displayed tab; clicking the same tile again deselects it, clicking another moves the selection. Escape deselects the tile, then collapses the panel (icon strip on desktop, closed sheet on phone — the strip stays usable, a click on a tab expands the panel again). Keyboard shortcuts: 1–5 (tabs, no effect when the tab is hidden), P (next display mode), Escape. Relief intensity (&I=, contrast 0.5–2×, default 1×): a personal comfort setting stored in lidarMapView_v2.intensity and shared in the link, never frozen as a server default (★ Set as default ignores it). Two themes, light/dark (lidar-theme, auto by default, follows the system), and every shared setting set on :root as CSS variables (no hard-coded colour in the components). localStorage keys (through lsGet/lsSet, silent in private browsing): lidarMapView_v2 (view/layers), lidar-print (export settings), lidar-panel (tab, sheet height, collapse to the icon strip), lidar-theme.
  • index.py = catalogue + shared registries (no interface any more): VIZ_LABELS/VIZ_LEGENDS, display defaults (DEFAULT_VIZ/PRECISION_VIZ/VIEW_MODES), PANEL_VIZ/KEYWORD_TO_STEP, scan_tiles/cells_with_all_viz, thumbnails + sub-tiles + the index_tiles.json inventory (build_index). The inventory is served by mapserve's /api/tiles to the lightweight machines (LIDAR_SOURCE_URL).
  • Generation is 0.2 m only (policy): /api/generate (GENERATE_RESOLUTIONS in mapserve.py), the compose process command and the CLI -r default all produce 0.2 m exclusively; 0.5 m stays available via an explicit -r 0.5 (note: ./run.sh still passes -r 0.5 by default). Completeness detection (complete_cells) requires the viz at 0.2 m only.
  • North-to-south generation: tiles are processed by decreasing row (row = northing in km), then increasing column — find_laz_files (pipeline.py) for batch passes and _resolve_request (mapserve.py) for runs started from the map. Workers take the files in submission order: the map fills from top to bottom during a run (an explicit --file on the CLI keeps the user's order). Generation parallelism: LIDAR_WORKERS (10 in docker-compose.yml, auto in docker-compose.worker.yml and when unset).
  • Full sub-tiling: _CARTO_SUBTILED_VIZ (empty in index.py) splits ALL layers into 500 m quadrants at 0.2 m; all in AVIF 4:2:0 q75 (_SUBTILE_AVIF_QUALITY), encoded ONCE from the original raster: tif_to_crop calls index.write_subtiles (through rendering._write_subtiles_from) right after the tile (so they are newer than it, and build_index does not re-encode them; the old chain tile q60 → sub-tile q55 stacked two losses: 18.1 dB/SSIM 0.84 against 19.5 dB/0.93). 4:2:0 tops out around 19.5 dB on the relief (per-pixel hue averaged over 2 × 2): only 4:4:4 would go further (q75: 28 dB, ~1.6× heavier). Exception: flat level maps (_SUBTILE_LOSSLESS_VIZ, the precision layer) in lossless WebP (.webp, accepted by tiles.py). Pillow ignores lossless=True for AVIF (q75, lossy); in RGB no AVIF setting is exact. A layer that fails to split falls back to the whole tile (_fallback_full_dalle) without penalising the others.
  • XYZ tiles reusable outside the project: /tiles/{layer}/{z}/{x}/{y}.png follows the OpenStreetMap scheme (256 px, EPSG:3857, RGBA PNG transparent outside coverage, CORS *) — JOSM, iD, QGIS, uMap and MapLibre consume it as is, with discovery through TileJSON / WMTS / josm.imagery.xml. The 512 px variant (@2x.avif) is reserved for the internal interface (half as many requests over HTTP/1.1). Any change to the URL template breaks client configurations: changing it requires an explicit decision.
  • Naming: all code identifiers, comments, logs, help and user-facing strings are English; data keys inherited from the French era (relief_oriente, densite_sol, visualisations/, --ign-classes sol, stats key telechargees…) are stable contracts and must not be renamed.
  • Adding a visualization requires 4 edits: (1) generate_X() in visualizations.py, (2) entry in VIZ_STEPS in pipeline.py, (3) entry in COLORMAPS in rendering.py (or RGB_LEGENDS for an RGB output), (4) entry in VIZ_LEGENDS in index.py (title/legend/description + sampled cmap gradient — single text source merged into COLORMAPS at import, also served in /api/map/meta and the TileJSON). Missing any one breaks the pipeline.
  • generate_* signature is strict: (dem_file, basename, vis_dir, resolution, shared=None) returning Path on success, None on failure. IGN overlays (ortho, topo) omit shared.
  • Return None on failure, never raise: dtm.py, visualizations.py, and ign.py all return None to let the pipeline continue. Raising aborts the entire file.
  • Logger is always logging.getLogger("lidar"), never __name__. All modules route through this single logger so worker processes can configure it.
  • Joint scan-line adjustment (3rd pass of strip alignment, STRIP_ALIGN_VERSION 3): each scan line (~150 lines/s, split by _scan_line_ids: flyback of the scan_angle sawtooth) can be offset AND tilted (roll) by 1 to 15 cm — isolated sunken lines, a whole pass tilted and visible as a band (measured on LHD_FXX_0999_6882). _joint_line_corrections (dtm.py) re-fits each line (a + b·u) against the consensus of the OTHER strips in its 1 m cell (local plane through the slope), least squares trimmed at 5 MAD via bincount, damped step 0.5, stop below 1 mm, re-centring (no global drift), estimated on 1 point in 3; CuPy when the GPU is active, numpy fallback. On top of that, a calibration profile per strip and per degree of scan angle (STRIP_ANGLE_BIN, shared by all lines of the strip, mean removed, slope kept because the per-line tilt is undetermined when the swath only partly crosses the tile; sparse bins = neighbouring value) removes the non-linear across-swath errors (±1.5 cm measured at the edge), a source of steps parallel to the flight line at the swath edge. Lines without overlap: _scan_line_corrections_beam (against the surface of their own strip, line-to-line component only, σ 3 lines). Validated on never-seen blocks (10 m checkerboard). Jitter by time windows (2nd pass) is no longer computed when scan_angle exists (redundant). CPU cost ~26 s per tile for the whole alignment (73 s before). Sidecar: lines, line_window, line_cell, line_model.
  • Filling gaps between points, bounded by the envelope (GAP_FILL_VERSION 3, _fill_small_gaps in dtm.py): at 0.2 m ~80 % of the pixels contain no point. The old fillnodata at 1 m filled every pixel within 1 m of a point — each isolated point became a flat 2 m disc (hue atan2(0,0) = saturated pink in the oriented relief) and each hole received an extrapolated 1 m band (coloured fringe). Now: morphological closing of the measured pixels (nothing is extended outwards) whose radius follows the local point spacing (GAP_RADIUS_K 1.5 × spacing measured over 5 m, steps GAP_RADII_M 1/1.5/2/3 m, 1 m minimum to plug car-sized holes), then islands < GAP_MIN_ISLAND_M2 (1 m²) removed. Morphology with numpy slices (_morph_step, ~10× scipy); ~5–9 s per 5000² tile. Version stored in the GeoTIFF tag LIDAR_GAP_FILL (2 = envelope, 3 = + density sidecar file): absent/different ⇒ DTM regenerated (_gap_fill_matches in pipeline.py). rasterio.fill.fillnodata writes into its input: always pass it a copy.
  • Fixed-scale openness: generate_openness normalises with frozen references OPENNESS_POS_REF / OPENNESS_NEG_REF (degrees, medians measured on 15 tiles spread over the territory) instead of a per-tile z-score — same openness = same colour, seamless mosaic.
  • Filename special-cases in _expected_output_path(): pos_open → positive_openness, neg_open → negative_openness, hillshade → hillshade_multi.
  • Imposed generation settings: the map no longer offers a classification or stitching setting — _build_command (mapserve.py) always passes --ground-classification ign --ign-classes sol --edge-buffer 100; any ground_class/ign_classes/reclassify/edge_buffer fields sent are ignored. CLI defaults aligned (ign, 100).
  • IGN classification by direct extraction: _extract_ign_ground (dtm.py) filters the classes with laspy (returns ≥ 1) and writes the ground LAS without PDAL (4.9 s instead of 13.5 s per tile); the read done by the automatic detection is reused (_LAST_READ). PDAL remains the fallback.
  • Pyramid updated during rendering: the pipeline rebuilds the inventory after each tile by default (incremental_index unless --no-index, whatever the launcher), with a deferred pass when the 3 s debounce skips a tile. On the map side, _bg_poller watches the inventory every BG_WATCH_S = 10 s (local file or upstream payload) and starts a scan as soon as it changes; tiles of modified source tiles go to the front of the queue (_bg_enqueue(front=True)), ahead of the backlog.
  • Full pyramid generated ahead of time: by default tiles.zoom_cached stores ALL levels up to native (TILE_CACHE_MAX_Z = TILE_MAX_NATIVE_Z 19, TILE_EVEN_LEVELS off) and background maintenance — enabled with LIDAR_TILE_BACKGROUND=1, set in the Pi override — generates them ahead of time up to 18@2x (default of LIDAR_TILE_BACKGROUND_MAX_Z); on the Pi it DOWNLOADS them from the worker. Map capped at zoom 19 (maxZoom, 1 screen px = 1 LiDAR px, never an upscaled tile). Reason: on-the-fly rendering on the Pi (~170 ms/tile, 3 at a time) gave 1.5–6 s per screen at zooms 17–19. Bounded maintenance queue (queue_max): each source tile keeps in _bg["incomplete"] the index of its first tile rejected for lack of room and resumes from there on the next scans (before: the first scan filled the queue and the rest of the pyramid was never generated). ~110,000 @2x tiles for 3,240 source tiles (~5 GB). Reduced storage still possible (LIDAR_TILE_EVEN_LEVELS=1 → EvenLevelTileLayer, even levels only, native 19 requested as is).
  • Bounded map memory (Pi, mem_limit: 1g): _open_source (tiles.py) caches a detached copy of each source (an open AVIF image keeps its decoder, ~18 MB more per 2500² quadrant) and counts 4 bytes/pixel (PIL stores RGB on 32 bits); Dockerfile.maps sets MALLOC_MMAP_THRESHOLD_=1048576 so that glibc returns large buffers to the system. Without these two points, browsing at high zoom (levels rendered on the fly) climbed to ~700 MB RSS and the container was killed in a loop by the OOM killer (502 on the Traefik side).
  • Standalone map (Pi without worker): upstream (LIDAR_SOURCE_URL/LIDAR_MAPS_URL) down ⇒ the map is still served from disk. _remote_payload (tiles.py) also remembers the failure (TTL 60 s, timeout 10 s: otherwise every request paid the network timeout again and /api/map/meta stopped answering), then the local index (_build_index) takes over; circuit breakers on sources (_SOURCE_OFFLINE, 60 s) and tiles (_UPSTREAM, re-armed on expiry); a fetched source is dated to its upstream version (os.utime) so the local index keeps the same dates; no upstream request for a tile without a source tile when the inventory comes from upstream. In cache-only mode, a stale tile keeps being served (no-store, X-Tile-Pending) until the new one arrives.
  • Pyramid downloaded in the background: with LIDAR_MAPS_URL, maintenance (_bg_process_one) DOWNLOADS the tiles of the stored levels from upstream (stat telechargees), without waiting for them to be displayed, and with no pause between two downloads (the worker copes); local rendering as a fallback when upstream does not answer, which alone is followed by LIDAR_TILE_BACKGROUND_PAUSE (Pi resources).
  • First appearance of source tiles (_apply_seen, index_xyz/.sources_seen.json): effective date of a source = max(version, first entry in the inventory). A source tile written before but inventoried after an XYZ tile (upstream index TTL, inventory debounce) therefore invalidates that tile — otherwise a permanent hole at that level, on disk, in memory and in the browser (unchanged ?v= stamp). Persistent registry; absent with an existing cache (upgrade) ⇒ everything is invalidated once.
  • Browser cost of the map (measured in Chrome, Intel Iris Xe): the darkening filter of the OSM base map (.base-dark) is set on the layer container, never on each tile — identical rendering (per-pixel operations), GPU process 92 % → 56 % while panning and 99 % → 53 % on wheel zoom. LiDAR layers use keepBuffer: 1 (512 px tiles = 1 MB decoded each). Measured with no notable effect: the cards' backdrop-filter, isolation/mix-blend-mode set to "normal", will-change. JS heap 2–5 MB, idle CPU ~1 %. AVIF decoding ~12 ms/tile against ~5 ms for WebP (accepted trade-off: AVIF storage).
  • Fast AVIF encoding: AVIF_SPEED = 9 (rendering.py, tiles.py, _SUBTILE_AVIF_SPEED in index.py) — a 5000 × 5000 px tile encoded in 0.6 s instead of 4 s (+3 % size, −0.3 dB); encoding was the longest step of rendering a layer.
  • Workers bounded by VRAM: each pool process takes a fixed slot when it is created (_init_worker_slot in pipeline.py, multiprocessing queue) computed by gpu_worker_slots (gpu.py): at most (free VRAM − LIDAR_GPU_RESERVE_MIB 512) / LIDAR_GPU_WORKER_MIB (2048, estimated peak: CUDA context + joint CuPy alignment in float64 + EDT of _fill_nans) workers per GPU, at least one, interleaved; the excess runs on the CPU (force_cpu). The old round-robin by file number put 6 workers on each 8 GB RTX 5060 with LIDAR_WORKERS=auto (12): OOM. Unreadable VRAM (nvidia-smi failing): unbounded round-robin.
  • Preparation on the GPU: _fill_nans (cupyx distance transform) and the SharedDEM gradients run on the GPU when it is active (CPU fallback). NUMBA_CACHE_DIR=/tmp/numba-cache (Dockerfile) avoids recompiling the numba kernels in every worker.
  • Default output is AVIF, not WebP. Use --format webp for WebP. Quality default is 60 (visually lossless on smooth colour ramps, ~÷3 vs q98).
  • Vertical alignment of flight strips: each tile mixes several passes (1–2 PointSourceId per pass), sometimes vertically biased by a few cm (±2.5 cm measured on 1000_6882). create_dtm_fast measures the robust offset of each strip (ground points, 1 m cells, median surface iterated 3×) and subtracts offsets ≥ 0.5 cm (STRIP_ALIGN_THRESHOLD in dtm.py) before rasterization. Offsets computed per tile (they drift along a flight line: never a global table), memoised per ground LAS, recorded in DTM/*_dtm*_stripalign.json (cache sidecar: absent, or different version/threshold/parameters ⇒ DTM regenerated). Can be disabled: --no-strip-align.
  • Intra-strip jitter (2nd pass of the alignment): successive scan lines of the SAME pass can be randomly offset vertically (sensor vibration / high-frequency trajectory noise) — a constant offset per strip is not enough. _strip_jitter_offsets splits each strip into GPS time windows (STRIP_JITTER_BIN = 0.1 s, time origin specific to each strip), measures the robust offset of each window against the median surface of the OTHER strips (shared 1 m cells, ≥ STRIP_JITTER_MIN_CELLS = 40 cells), smooths the series (rolling median STRIP_JITTER_SMOOTH = 5 windows), clamps it to ± STRIP_JITTER_MAX (10 cm), then interpolates it at each point's GPS time (_apply_strip_jitter); where two strips overlap, each gets a series (each absorbs its share). Requires the gps_time dimension (silently skipped otherwise). Sidecar version 2 (series in jitter), covered by --no-strip-align.
  • Layers produced and served = PANEL_VIZ (index.py, currently ('relief_oriente', 'densite_sol')): pipeline without --only/--skip (panel_steps()), generation started from the map (_panel_viz_steps in mapserve.py) and served layers (tiles.available_layers filters: panel, /tiles/…, TileJSON, WMTS, JOSM). The other visualizations can still be computed with --only. Map tests lift the restriction via _setup(..., panel=None).
  • Precision (densite_sol) and map display: create_dtm_fast writes the density of the kept ground points (stitching band included) to DTM/*_dtm*_density.tif (1 m cells, 3 × 3 mean, pts/m²); generate_densite_sol quantises it into 16 levels (density_levels: fixed log scale, level k from 0.25 × 2^(k/2) pts/m²) and keeps it at 1 m (1000² tile, ~200 KB in lossless WebP, rendering.LOSSLESS_GRAY_KEYWORDS); nearest-neighbour tiles (tiles.NEAREST_LAYERS) to keep 16 crisp greys at zooms 18–19. Interface (web/map.js, View tab): no more layer stack — one main layer (DEFAULT_VIZ) and three modes relief / precision / compare (VIEW_MODES, sliding bar, link &P=compare&C=left:right:%); both (old product) read as relief. "How to read" text: the reading field of VIZ_LEGENDS (map + PDF).
  • Oriented relief (relief_oriente, the only relief layer of the map): a single RGB image (3-band uint8 GeoTIFF, rendered as is like ortho/topo via RGB_KEYWORDS in rendering.py) — CIELAB lightness = local positive openness (DTM − Gaussian RELIEF_DETREND_M = 10 m, radii RELIEF_RADII_M = 5/10/20 m, 16 directions) 65 % + shading 35 %; hue = aspect, fixed chroma (RELIEF_CHROMA). Fixed log scale RELIEF_OPEN_RANGE (no per-tile statistic) and a total support of ~60 m (20 m maximum ray radius + 4σ = 40 m of the 10 m Gaussian detrend), still < the 100 m stitching band: seamless tiles. Fast: detrend + radii on a decimated grid ~0.8 m (RELIEF_GRID_M), a dedicated kernel that only accumulates the mean of the angles (_mean_horizon_*: CuPy RawKernel on GPU, parallel numba on CPU, numpy fallback), fused colourisation (numba) or vectorised without trigonometry (CuPy) through an L* × hue table (_relief_lut). ~5 s of computation excluding preparation on a 12-core CPU. Any constant change changes the rendering: regenerate the tiles (--only relief_oriente --force).
  • Downsampled openness: generate_openness computes the ray tracing (the most expensive step: 532 s/tile at 0.2 m on CPU) on a block-decimated grid (OPENNESS_DOWNSAMPLE = 2: block max for positive, min for negative — preserves the reliefs that bound the horizon) then resamples bilinearly. Cost ÷ factor³: 532 s → 40 s (×13). Archaeological signal preserved (corr. 0.93 after smoothing); the sub-metre noise texture disappears. --openness-downsample 1 = full resolution. SVF is NOT affected (anisotropic openness no longer exists).
  • Edge stitching between tiles: large-kernel renderings (openness/SVF: 100 m radii; LRM: 15 m) truncate their window at the tile edge — bands of artefacts at every tile change. --edge-buffer N (default 100; always applied by generation from the map, EDGE_BUFFER_METERS = 100 m in mapserve.py, no checkbox any more; 0 = off on the command line) rasterizes the DTM over the nominal 1 km tile aligned on the grid plus an N m band filled with the ground points of the 8 neighbouring LAZ files (_neighbor_ground_points in dtm.py: streaming PDAL, crop + IGN class filter; missing neighbour = automatic download from the IGN catalogue before the run, kept apart in input/edge_neighbors/ so as not to swell the corpus of global passes (deduplicated over the whole batch, _fetch_edge_neighbors in pipeline.py); not found or failure = empty band). Visualizations compute on the extended extent, then rendering.py (_core_tile_window, via tif_to_crop/tif_to_png) crops the outputs to the exact 1 km tile read from the LHD name — the AVIF images stay aligned 1 km squares in the mosaic. Buffer recorded in the GeoTIFF tag LIDAR_EDGE_BUFFER of the DTM: changing --edge-buffer invalidates the DTM cache automatically (tag absent = 0). Neighbouring bands are not strip-aligned (context only, cropped out of the final image). Cost: ~7 s of reading per neighbour + ~44 % more pixels at 100 m/0.2 m. Name outside the LHD pattern: option ignored (header bounds, no cropping).
  • Tests use lazy imports inside each test function, never at module top, to avoid importing CuPy/GDAL at import time.
  • _-prefixed names are critical private: _create_ground_pipeline, _fallback_to_smrf, _fill_nans, _init_gpu, _process_file_standalone — do not call from outside their module.
  • build_index() writes the inventory + the source levels: output/index_tiles.json (source tiles, layers, versioned URLs — served by /api/tiles), thumbnails index_thumbs/ (≈3.9 m/px + _mid 1.56 m/px) and quadrants index_subtiles/ — levels of the XYZ pyramid (tiles.py). Each tile of the current run carries its WGS84 corners for the progress frames. No HTML any more: the interface lives in mapui.py.
  • PDF export (export_pdf.py): Pillow + pyproj + reportlab, no numpy — runs in the lightweight image alone. /api/export/frame (frame) and /api/export/pdf (sheet) in mapserve.py, never delegated to LIDAR_GENERATION_URL, single export lock (_export_lock, 429 when busy). Composed directly in L93 (tiles.sources_in_bbox/load_source, crop + Lanczos resampling), without going through the XYZ tiles. Sheet: L93 grid (step depends on the scale), WGS84 corners, north arrow accounting for meridian convergence, graphic and numeric scale, 8-point orientation rose, legend text taken from VIZ_LEGENDS, quality inset (white hatching = "not recorded", a "Missing data (no relief)" line for tiles outside coverage), 300 dpi in A4 / 250 dpi in A3 (bounds the Pi's memory). NODATA_RGB copies visualizations.RELIEF_NODATA_RGB (numpy is absent from the lightweight image); outside the tiles' extent = hatched white.
  • Quality sidecar (quality.py): output/quality/{basename}.json (QUALITY_VERSION), ground density per 50 m cell and acquisition dates, computed after the DTM (ensure_quality, including on an already cached DTM — it then measures the input LAZ directly); batch backfill --quality-backfill (see Workflow); copied into the quality table of index_tiles.json (build_index) and onto the lightweight machines (tiles._persist_remote_quality, from the upstream inventory).

Architecture Notes (from code audit 2025-09)

Module structure & data flow

  • cli.py → pipeline.py (LidarArchaeoPipeline) → per-file: dtm.py (classify + rasterize) → visualizations.py (17 products) → rendering.py (GeoTIFF→AVIF) → index.py (catalogue: thumbnails + inventory)
  • gpu.py provides CuPy/NumPy proxy (xp), lazy init, OOM fallback. safe_gpu_call wraps all non-IGN viz calls.
  • mapserve.py (FastAPI, image lidar-maps) serves the XYZ pyramid (tiles.py), the interface (mapui.py), the /api/tiles inventory + static source tiles for the lightweight machines, and the generation API: pipeline as a subprocess on the full image, delegation via LIDAR_GENERATION_URL on the lightweight image (Raspberry Pi). Single job + persistent queue (_job_lock is an RLock: /api/status calls _queue_summary() under the lock). LIDAR_API_TOKEN (worker) / LIDAR_REMOTE_TOKEN (lightweight) protect the calls; LIDAR_REGEN_CIDR restricts generation to the local network.
  • progress.py writes JSONL events (O_APPEND, atomic) read by mapserve for live progress (frames on the map).

Key design decisions (intentional, do not "fix")

  • _res_suffix hardcodes 0.5 as "no suffix": coupled to index.py parsing (_strip_res_suffix defaults to 0.5 when no suffix). Changing requires sidecar metadata.
  • GPU scoring (major*1000 + minor*100 + mem_mi): compute capability priority is intentional — a newer GPU with less VRAM is preferred.
  • mapserve.py reads env at import time: deployment-focused single-purpose server, env is set once in docker-compose.
  • Repeated try/except in visualizations (same pattern in every generate_*): intentional convention for uniform return None behavior.
  • _d8_accumulate_numba defines @njit inside the function: cache=True makes subsequent calls fast; the Python function object creation is negligible.
  • pkill -9 -f "pdal pipeline <output>/temp/" in the cli.py signal handler: belt-and-suspenders alongside os.killpg. Scoped to this run's temp directory to avoid killing unrelated PDAL processes (e.g. a concurrent generation job).

Performance characteristics

  • _priority_flood uses numba JIT binary heap (single int64 array, flat view for elevation). Python heapq fallback if numba unavailable.
  • _d8_accumulate_numba uses numba with argsort top-down sweep. Python fallback exists.
  • Ray-tracing (SVF, openness): processes one direction at a time to limit VRAM. Auto-falls back to CPU on OOM via _ray_trace_horizons.
  • Multi-resolution: 0.5 m has no filename suffix whatever its position; every other resolution (including the 0.2 m default) uses a _r0p2 style suffix. Ground classification done once, shared across resolutions.
  • ProcessPoolExecutor has a 2-hour wall-clock safety timeout (prevents indefinite hang from stuck workers).

Numba usage pattern

  • Defined at function scope with @njit(cache=True) — first call compiles (~2-3s), subsequent calls hit disk cache.
  • Must use flat 1D array views (arr.ravel()) for integer indexing — 2D arrays with a single int index return a row slice in nopython mode.
  • Pattern: try numba → return None on ImportError → caller falls back to pure Python.

Commit & Pull Request Guidelines

Commits use imperative tense, short single-line subjects (~60–80 chars), no prefixes or scopes. Compound commits are common — multiple related changes joined by commas or "and". Examples: Fix multi-GPU with lazy CuPy init + rendering improvements, Add multi-resolution support and remove PDF generation, Fix corrupted COPC detection, add CSF→SMRF fallback, improve MSRM colormap, add SVF and anisotropic openness.

No PR template, no CI pipeline, no issue tracker. This is a standalone Docker project with no formal PR process.