The pool of tile workers used to cancel every remaining tile after a
hardcoded 2-hour wall clock, silently truncating large batches (a
670-tile completion run lost its last 348 tiles that way). The timeout
now defaults to unlimited and can be capped per deployment with the
LIDAR_BATCH_TIMEOUT environment variable (seconds); the local worker
compose sets it to 6 hours.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
30 KiB
30 KiB
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— createsinput//output/as the current uid, addsdocker-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(imageDockerfile.maps, no PDAL/GPU) - build the tile generator (processing machine):
docker compose -f docker-compose.worker.yml up -d --build(serviceworker= 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 directdocker 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/runreuse the existing image and the OLD code runs.--buildis almost instant thanks to the cache (.dockerignorekeeps input/ and output/ out of the context). After an edit,docker compose up -d --build serverecreates 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 withtimeout 600, and NO| tailpipe, 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.shpasses its own defaults to the CLI:-r 0.5and-w 1unless 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 indocs/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-backfillalone does not work — usedocker compose run --rm --build process python3 -m lidar_pipeline /data/input -o /data/output --quality-backfill.
Conventions
- A single web server:
mapserve.py(imagelidar-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 inmapserve.py, the interface inweb/map.{html,css,js}(read bymapui.py,_MAP_*constants kept, written asapp.css/app.jsbywrite_map_assets()and baked into the images). On the lightweight image withoutLIDAR_GENERATION_URL,/api/statusanswersavailable: falseand 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#sheetGripdragged 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 inlidarMapView_v2.intensityand shared in the link, never frozen as a server default (★ Set as defaultignores it). Two themes, light/dark (lidar-theme,autoby default, follows the system), and every shared setting set on:rootas CSS variables (no hard-coded colour in the components).localStoragekeys (throughlsGet/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 + theindex_tiles.jsoninventory (build_index). The inventory is served by mapserve's/api/tilesto the lightweight machines (LIDAR_SOURCE_URL).- Generation is 0.2 m only (policy):
/api/generate(GENERATE_RESOLUTIONSinmapserve.py), the composeprocesscommand and the CLI-rdefault all produce 0.2 m exclusively; 0.5 m stays available via an explicit-r 0.5(note:./run.shstill passes-r 0.5by 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--fileon the CLI keeps the user's order). Generation parallelism:LIDAR_WORKERS(10 indocker-compose.yml,autoindocker-compose.worker.ymland when unset). - Full sub-tiling:
_CARTO_SUBTILED_VIZ(empty inindex.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_cropcallsindex.write_subtiles(throughrendering._write_subtiles_from) right after the tile (so they are newer than it, andbuild_indexdoes 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 bytiles.py). Pillow ignoreslossless=Truefor 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}.pngfollows 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 keytelechargees…) are stable contracts and must not be renamed. - Adding a visualization requires 4 edits: (1)
generate_X()invisualizations.py, (2) entry inVIZ_STEPSinpipeline.py, (3) entry inCOLORMAPSinrendering.py(orRGB_LEGENDSfor an RGB output), (4) entry inVIZ_LEGENDSinindex.py(title/legend/description + sampled cmap gradient — single text source merged intoCOLORMAPSat import, also served in/api/map/metaand the TileJSON). Missing any one breaks the pipeline. generate_*signature is strict:(dem_file, basename, vis_dir, resolution, shared=None)returningPathon success,Noneon failure. IGN overlays (ortho,topo) omitshared.- Return
Noneon failure, never raise:dtm.py,visualizations.py, andign.pyall returnNoneto 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_VERSION3): each scan line (~150 lines/s, split by_scan_line_ids: flyback of thescan_anglesawtooth) 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 viabincount, 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 whenscan_angleexists (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_VERSION3,_fill_small_gapsindtm.py): at 0.2 m ~80 % of the pixels contain no point. The oldfillnodataat 1 m filled every pixel within 1 m of a point — each isolated point became a flat 2 m disc (hueatan2(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_K1.5 × spacing measured over 5 m, stepsGAP_RADII_M1/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 tagLIDAR_GAP_FILL(2 = envelope, 3 = + density sidecar file): absent/different ⇒ DTM regenerated (_gap_fill_matchesinpipeline.py).rasterio.fill.fillnodatawrites into its input: always pass it a copy. - Fixed-scale openness:
generate_opennessnormalises with frozen referencesOPENNESS_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; anyground_class/ign_classes/reclassify/edge_bufferfields 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_indexunless--no-index, whatever the launcher), with a deferred pass when the 3 s debounce skips a tile. On the map side,_bg_pollerwatches the inventory everyBG_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_cachedstores ALL levels up to native (TILE_CACHE_MAX_Z=TILE_MAX_NATIVE_Z19,TILE_EVEN_LEVELSoff) and background maintenance — enabled withLIDAR_TILE_BACKGROUND=1, set in the Pi override — generates them ahead of time up to18@2x(default ofLIDAR_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.mapssetsMALLOC_MMAP_THRESHOLD_=1048576so 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/metastopped 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 (stattelechargees), 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 byLIDAR_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 usekeepBuffer: 1(512 px tiles = 1 MB decoded each). Measured with no notable effect: the cards'backdrop-filter,isolation/mix-blend-modeset 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_SPEEDinindex.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_slotinpipeline.py, multiprocessing queue) computed bygpu_worker_slots(gpu.py): at most (free VRAM −LIDAR_GPU_RESERVE_MIB512) /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 withLIDAR_WORKERS=auto(12): OOM. Unreadable VRAM (nvidia-smi failing): unbounded round-robin. - Preparation on the GPU:
_fill_nans(cupyxdistance transform) and theSharedDEMgradients 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 webpfor 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
PointSourceIdper pass), sometimes vertically biased by a few cm (±2.5 cm measured on 1000_6882).create_dtm_fastmeasures the robust offset of each strip (ground points, 1 m cells, median surface iterated 3×) and subtracts offsets ≥ 0.5 cm (STRIP_ALIGN_THRESHOLDindtm.py) before rasterization. Offsets computed per tile (they drift along a flight line: never a global table), memoised per ground LAS, recorded inDTM/*_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_offsetssplits 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 medianSTRIP_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 thegps_timedimension (silently skipped otherwise). Sidecar version 2 (series injitter), 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_stepsinmapserve.py) and served layers (tiles.available_layersfilters: 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_fastwrites the density of the kept ground points (stitching band included) toDTM/*_dtm*_density.tif(1 m cells, 3 × 3 mean, pts/m²);generate_densite_solquantises 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 asrelief. "How to read" text: thereadingfield ofVIZ_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 viaRGB_KEYWORDSinrendering.py) — CIELAB lightness = local positive openness (DTM − GaussianRELIEF_DETREND_M= 10 m, radiiRELIEF_RADII_M= 5/10/20 m, 16 directions) 65 % + shading 35 %; hue = aspect, fixed chroma (RELIEF_CHROMA). Fixed log scaleRELIEF_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_*: CuPyRawKernelon 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_opennesscomputes 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 inmapserve.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_pointsindtm.py: streaming PDAL, crop + IGN class filter; missing neighbour = automatic download from the IGN catalogue before the run, kept apart ininput/edge_neighbors/so as not to swell the corpus of global passes (deduplicated over the whole batch,_fetch_edge_neighborsinpipeline.py); not found or failure = empty band). Visualizations compute on the extended extent, thenrendering.py(_core_tile_window, viatif_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 tagLIDAR_EDGE_BUFFERof the DTM: changing--edge-bufferinvalidates 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), thumbnailsindex_thumbs/(≈3.9 m/px +_mid1.56 m/px) and quadrantsindex_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 inmapui.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) inmapserve.py, never delegated toLIDAR_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 fromVIZ_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_RGBcopiesvisualizations.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 thequalitytable ofindex_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.pyprovides CuPy/NumPy proxy (xp), lazy init, OOM fallback.safe_gpu_callwraps all non-IGN viz calls.mapserve.py(FastAPI, imagelidar-maps) serves the XYZ pyramid (tiles.py), the interface (mapui.py), the/api/tilesinventory + static source tiles for the lightweight machines, and the generation API: pipeline as a subprocess on the full image, delegation viaLIDAR_GENERATION_URLon the lightweight image (Raspberry Pi). Single job + persistent queue (_job_lockis an RLock:/api/statuscalls_queue_summary()under the lock).LIDAR_API_TOKEN(worker) /LIDAR_REMOTE_TOKEN(lightweight) protect the calls;LIDAR_REGEN_CIDRrestricts generation to the local network.progress.pywrites JSONL events (O_APPEND, atomic) read by mapserve for live progress (frames on the map).
Key design decisions (intentional, do not "fix")
_res_suffixhardcodes 0.5 as "no suffix": coupled toindex.pyparsing (_strip_res_suffixdefaults 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.pyreads 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 uniformreturn Nonebehavior. _d8_accumulate_numbadefines@njitinside the function:cache=Truemakes 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 alongsideos.killpg. Scoped to this run's temp directory to avoid killing unrelated PDAL processes (e.g. a concurrent generation job).
Performance characteristics
_priority_flooduses numba JIT binary heap (single int64 array, flat view for elevation). Python heapq fallback if numba unavailable._d8_accumulate_numbauses numba withargsorttop-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
_r0p2style suffix. Ground classification done once, shared across resolutions. ProcessPoolExecutorbatch runs are unlimited by default; setLIDAR_BATCH_TIMEOUT(seconds) to cap a batch's wall clock and cancel the remaining workers past it.
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.