24 KiB
24 KiB
Workflow
- install:
docker build -t lidar-lidar .(deps baked into image) - build:
docker build -t lidar-lidar . - build carte légère (Raspberry Pi, déploiement 2 machines — cf.
docs/DEPLOY_WEBAPP.md) :docker compose -f docker-compose.maps.yml up -d --build(imageDockerfile.maps, sans PDAL/GPU) - build générateur de tuiles (machine de traitement) :
docker compose -f docker-compose.worker.yml up -d --build(serviceworker= mapserve sur l'image complète, API + tuiles + inventaire pour les cartes distantes) - test all:
./run.sh --test(rebuild automatique de l'image avant les tests ; endocker rundirect, rebuild manuellement d'abord) - 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 - RÈGLE 1 — toujours lancer via docker compose (jamais
docker rundirect) : carte/API locale →docker compose up -d --build serve(port 8973, mapserve sur image complète) ; traitement ponctuel →docker compose run --rm --build process [options]; logs →docker compose logs -f serve; arrêt →docker compose down. - RÈGLE 2 — TOUJOURS
--build: le code est baké dans l'image (jamais monté). Sans--build,up/runréutilisent l'image existante et l'ANCIEN code tourne.--buildest quasi instantané grâce au cache (le .dockerignore exclut input/ et output/ du contexte). Après édition :docker compose up -d --build serverecrée le conteneur sur du neuf. - test rapide sans rebuild (code monté par-dessus l'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 ; ajoutertimeout 600devant, et PAS de pipe| tailqui masque la progression) - 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 - mise à jour du Pi de prod (192.168.3.10, checkout
/srv/lidar_rendu, override maps + Traefik) :ssh—ssh 192.168.3.10 "cd /srv/lidar_rendu && git pull && docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build"(procédure dansdocs/DEPLOY_WEBAPP.md).
Conventions
- Un seul serveur web :
mapserve.py(imagelidar-maps, port 8975 léger / 8973 worker). L'ancienne webapp (webapp.py,export.py, index.html/_APP_JS) a été supprimée : la génération de tuiles (portée de la webapp historique —/api/preview,/api/generate,/api/status,/api/stop,/api/queue/clear,/api/cell) vit dansmapserve.py, l'interface dansmapui.py(constantes_MAP_HTML/_MAP_CSS/_MAP_JS, écrites parwrite_map_assets()et bâchées dans les images). Sur l'image légère sansLIDAR_GENERATION_URL,/api/statusrépondavailable: falseet l'interface masque les boutons. index.py= catalogue + registres partagés (plus d'interface) :VIZ_LABELS/VIZ_LEGENDS, défauts d'affichage (DEFAULT_LAYERS/DEFAULT_OPACITY/DEFAULT_BLEND),PANEL_VIZ/KEYWORD_TO_STEP,scan_tiles/cells_with_all_viz, vignettes + sous-tuiles + inventaireindex_tiles.json(build_index). L'inventaire est servi par/api/tilesde mapserve aux machines légères (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 explicit-r 0.5. Completeness detection (complete_cells) requires the viz at 0.2 m only. - Génération du nord au sud : les tuiles sont traitées par ligne décroissante (row = nord en km), colonnes croissantes —
find_laz_files(pipeline.py) pour les passes batch et_resolve_request(mapserve.py) pour les runs lancés depuis la carte. Les workers prennent les fichiers dans l'ordre de soumission : la carte se remplit de haut en bas pendant un run (--fileexplicite au CLI = ordre utilisateur préservé). Parallélisme de génération :LIDAR_WORKERS(10 dans les compose ;autosinon). - Sub-tuilage intégral :
_CARTO_SUBTILED_VIZ(vide dansindex.py) découpe TOUTES les couches en quadrants 500 m à 0,2 m ; ortho/topo sont encodées en AVIF q75 (_SUBTILE_DETAIL_VIZ) contre q55 pour les rampes de couleur. Une couche qui échoue à la découpe retombe en dalle entière (_fallback_full_dalle) sans pénaliser les autres. - Tuiles XYZ réutilisables hors du projet :
/tiles/{layer}/{z}/{x}/{y}.pngsuit le schéma OpenStreetMap (256 px, EPSG:3857, PNG RGBA transparent hors emprise, CORS*) — JOSM, iD, QGIS, uMap et MapLibre le consomment tel quel, avec découverte via TileJSON / WMTS /josm.imagery.xml. Le 512 px (@2x.webp) est réservé à l'interface interne (moitié moins de requêtes en HTTP/1.1). Toute évolution du gabarit d'URL casse des configurations clientes : la changer demande une décision explicite. - Bilingual naming: all code identifiers are English; every user-facing string, log message, argparse help, and comment is French.
- 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 le 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. - Ajustement conjoint des lignes de balayage (3ᵉ passe du calage,
STRIP_ALIGN_VERSION3) : chaque ligne de balayage (~150 lignes/s, découpée par_scan_line_ids: retour de la dent de scie descan_angle) peut être décalée ET inclinée (roulis) de 1 à 15 cm — lignes en creux isolées, passe entière basculée visible en bande (mesuré sur LHD_FXX_0999_6882)._joint_line_corrections(dtm.py) recale chaque ligne (a + b·u) contre le consensus des AUTRES faisceaux de sa maille 1 m (plan local par la pente), moindres carrés tronqués à 5 MAD parbincount, pas amorti 0,5, arrêt sous 1 mm, recentrage (pas de dérive d'ensemble), estimation sur 1 point sur 3 ; CuPy si GPU actif, repli numpy. S'y ajoute un profil d'étalonnage par faisceau et par degré d'angle (STRIP_ANGLE_BIN, commun à toutes les lignes du faisceau, moyenne retirée, pente conservée car l'inclinaison par ligne est indéterminable quand la fauchée ne traverse la dalle qu'en partie ; classes pauvres = valeur voisine) : il retire les écarts non linéaires en travers de la fauchée (±1,5 cm mesurés au bord), sources de marches parallèles au vol au bord de fauchée. Lignes sans recouvrement :_scan_line_corrections_beam(contre la surface de leur propre faisceau, composante ligne à ligne seule, σ 3 lignes). Validé sur blocs jamais vus (damier 10 m). La gigue par fenêtres de temps (2ᵉ passe) n'est plus calculée quandscan_angleexiste (redondante). Coût CPU ~26 s par dalle pour tout le calage (73 s avant). Sidecar :lines,line_window,line_cell,line_model. - Openness à échelle fixe :
generate_opennessnormalise par des références figéesOPENNESS_POS_REF/OPENNESS_NEG_REF(degrés, médianes mesurées sur 15 dalles réparties sur le territoire) et plus par z-score de dalle — même ouverture = même couleur, mosaïque jointive. - Filename special-cases in
_expected_output_path():pos_open→positive_openness,neg_open→negative_openness,hillshade→hillshade_multi. - Génération imposée : la carte ne propose plus de réglage de classification ni de raccord —
_build_command(mapserve.py) passe toujours--ground-classification ign --ign-classes sol --edge-buffer 100; les champsground_class/ign_classes/reclassify/edge_bufferéventuellement envoyés sont ignorés. Défauts CLI alignés (ign,100). - Classification IGN par extraction directe :
_extract_ign_ground(dtm.py) filtre les classes avec laspy (retours ≥ 1) et écrit le LAS sol sans passer par PDAL (4,9 s au lieu de 13,5 s par dalle) ; la lecture de la détection automatique est réutilisée (_LAST_READ). PDAL reste le repli. - Pyramide mise à jour pendant le rendu : le pipeline régénère l'inventaire après chaque dalle par défaut (
incremental_indexsauf--no-index, quel que soit le lanceur), avec passe différée quand l'anti-rebond de 3 s ignore une dalle. Côté carte,_bg_pollersurveille l'inventaire toutes lesBG_WATCH_S= 10 s (fichier local ou charge utile amont) et lance un scan dès qu'il change ; les tuiles des dalles modifiées passent en tête de file (_bg_enqueue(front=True)), devant l'arriéré. - Pyramide complète générée d'avance : par défaut
tiles.zoom_cachedstocke TOUS les niveaux jusqu'au natif (TILE_CACHE_MAX_Z=TILE_MAX_NATIVE_Z19,TILE_EVEN_LEVELSoff) et la maintenance de fond les génère d'avance jusqu'à18@2x(défaut deLIDAR_TILE_BACKGROUND_MAX_Z) ; sur le Pi elle les TÉLÉCHARGE du worker. Carte plafonnée au zoom 19 (maxZoom, 1 px écran = 1 px LiDAR, jamais de tuile agrandie). Raison : le rendu à la volée du Pi (~170 ms/tuile, 3 à la fois) donnait 1,5–6 s par écran aux zooms 17–19. File de maintenance bornée (queue_max) : chaque dalle garde dans_bg["incomplete"]l'indice de sa première tuile refusée faute de place et reprend de là aux scans suivants (avant : le premier scan saturait la file et le reste de la pyramide n'était jamais généré). ~110 000 tuiles @2x pour 3 240 dalles (~5 Go). Stockage réduit toujours possible (LIDAR_TILE_EVEN_LEVELS=1→EvenLevelTileLayer, niveaux pairs, natif 19 demandé tel quel). - Mémoire bornée de la carte (Pi,
mem_limit: 1g) :_open_source(tiles.py) met en cache une copie détachée de chaque source (une image AVIF ouverte garde son décodeur, ~18 Mo de plus par quadrant 2500²) et compte 4 octets/pixel (PIL stocke le RGB sur 32 bits) ;Dockerfile.mapsfixeMALLOC_MMAP_THRESHOLD_=1048576pour que glibc rende les grands tampons au système. Sans ces deux points, la navigation à fort zoom (niveaux rendus à la volée) montait à ~700 Mo de RSS et le conteneur était tué en boucle par l'OOM killer (502 côté Traefik). - Carte autonome (Pi sans worker) : amont (
LIDAR_SOURCE_URL/LIDAR_MAPS_URL) éteint ⇒ la carte reste servie depuis le disque._remote_payloadmémorise aussi l'échec (TTL 60 s, délai 10 s : sinon chaque requête repayait le délai réseau et/api/map/metane répondait plus), puis l'index local (_build_index) prend le relais ; coupe-circuits sur les sources (_SOURCE_OFFLINE, 60 s) et les tuiles (_UPSTREAM, réarmé à expiration) ; source rapatriée datée à sa version amont (os.utime) pour que l'index local garde les mêmes dates ; pas de requête amont pour une tuile sans dalle quand l'inventaire vient de l'amont. En cache seule, une tuile périmée reste servie (no-store,X-Tile-Pending) en attendant la nouvelle. - Pyramide téléchargée en tâche de fond : avec
LIDAR_MAPS_URL, la maintenance (_bg_process_one) TÉLÉCHARGE les tuiles des niveaux stockés depuis l'amont (stattelechargees), sans attendre l'affichage, et sans pause entre deux téléchargements (le worker encaisse) ; rendu local en repli si l'amont ne répond pas, lui seul suivi deLIDAR_TILE_BACKGROUND_PAUSE(ressources du Pi). - Première apparition des dalles (
_apply_seen,index_xyz/.sources_seen.json) : date effective d'une source = max(version, première entrée dans l'inventaire). Une dalle écrite avant mais inventoriée après une tuile (TTL de l'index amont, anti-rebond de l'inventaire) périme donc la tuile — sinon trou permanent à ce niveau, sur disque, en mémoire et dans le navigateur (stamp?v=inchangé). Registre persistant ; absent avec un cache existant (mise à jour) ⇒ tout est périmé une fois. - Coût navigateur de la carte (mesuré sous Chrome, Intel Iris Xe) : le filtre d'assombrissement du fond OSM (
.base-dark) est posé sur le conteneur de la couche, jamais sur chaque tuile — rendu identique (opérations par pixel), processus GPU 92 % → 56 % en déplacement et 99 % → 53 % au zoom molette. Couches LiDAR enkeepBuffer: 1(tuiles 512 px = 1 Mo décodé chacune). Mesuré sans effet notable :backdrop-filterdes cartes,isolation/mix-blend-modeen « normal »,will-change. Tas JS 2-5 Mo, CPU au repos ~1 %. Décodage AVIF ~12 ms/tuile contre ~5 ms en WebP (compromis assumé : stockage AVIF). - Encodage AVIF rapide :
AVIF_SPEED = 9(rendering.py,tiles.py,_SUBTILE_AVIF_SPEEDdansindex.py) — dalle 5000 × 5000 px encodée en 0,6 s au lieu de 4 s (+3 % de taille, −0,3 dB) ; l'encodage était l'étape la plus longue du rendu d'une couche. - Workers bornés par la VRAM : chaque processus du pool prend une place fixe à sa création (
_init_worker_slotdanspipeline.py, file multiprocessing) calculée pargpu_worker_slots(gpu.py) : au plus (VRAM libre −LIDAR_GPU_RESERVE_MIB512) /LIDAR_GPU_WORKER_MIB(2048, pic estimé : contexte CUDA + calage conjoint CuPy en float64 + EDT de_fill_nans) workers par GPU, au moins un, entrelacés ; l'excédent tourne en CPU (force_cpu). L'ancien round-robin par numéro de fichier mettait 6 workers par RTX 5060 de 8 Go avecLIDAR_WORKERS=auto(12) : OOM. VRAM illisible (nvidia-smi en échec) : round-robin sans borne. - Préparation sur GPU :
_fill_nans(transformée de distancecupyx) et les gradients deSharedDEMpassent sur GPU quand il est actif (repli CPU).NUMBA_CACHE_DIR=/tmp/numba-cache(Dockerfile) évite la recompilation des noyaux numba à chaque worker. - Default output is AVIF, not WebP. Use
--format webpfor WebP. Quality default is 60 (visually lossless on smooth color ramps, ~÷3 vs q98). - Calage vertical des faisceaux de vol : chaque tuile mélange plusieurs passes (1-2
PointSourceIdpar passe) parfois biaisées verticalement de quelques cm (±2,5 cm mesurés sur 1000_6882).create_dtm_fastmesure l'offset robuste de chaque faisceau (points sol, maille 1 m, surface médiane itérée 3×) et retranche les offsets ≥ 0,5 cm (STRIP_ALIGN_THRESHOLDdansdtm.py) avant rastérisation. Offsets calculés par tuile (ils dérivent le long d'une ligne de vol : jamais de table globale), mémoïsés par LAS sol, consignés dansDTM/*_dtm*_stripalign.json(sidecar de cache : absent, ou version/seuil/paramètres différents ⇒ régénération du DTM). Désactivable :--no-strip-align. - Gigue intra-faisceau (2ᵉ passe du calage) : les lignes de balayage successives d'une MÊME passe peuvent être décalées verticalement de façon aléatoire (vibration capteur / bruit haute fréquence de trajectoire) — un offset constant par faisceau n'y suffit pas.
_strip_jitter_offsetsdécoupe chaque faisceau en fenêtres de temps GPS (STRIP_JITTER_BIN= 0,1 s, origine de temps propre à chaque faisceau), mesure l'offset robuste de chaque fenêtre contre la surface médiane des AUTRES faisceaux (maille 1 m partagée, ≥STRIP_JITTER_MIN_CELLS= 40 cellules), lisse la série (médiane glissanteSTRIP_JITTER_SMOOTH= 5 fenêtres), la borne à ±STRIP_JITTER_MAX(10 cm) puis l'interpole au temps GPS de chaque point (_apply_strip_jitter) ; en recouvrement à deux faisceaux, chacun reçoit une série (chacun absorbe sa part). Requiert la dimensiongps_time(silencieusement ignorée sinon). Sidecar version 2 (séries dansjitter), couverte par--no-strip-align. - Couches produites et servies =
PANEL_VIZ(index.py, aujourd'hui('relief_oriente',)) : pipeline sans--only/--skip(panel_steps()), génération lancée depuis la carte (_panel_viz_stepsdansmapserve.py) et couches servies (tiles.available_layersfiltre : panneau,/tiles/…, TileJSON, WMTS, JOSM). Les autres visualisations restent calculables avec--only. Les tests de la carte lèvent la restriction via_setup(..., panel=None). - Relief orienté (
relief_oriente, seule couche de la carte) : image RGB unique (GeoTIFF uint8 3 bandes, rendue telle quelle comme ortho/topo viaRGB_KEYWORDSdansrendering.py) — clarté CIELAB = openness positive locale (MNT − gaussienneRELIEF_DETREND_M= 10 m, rayonsRELIEF_RADII_M= 5/10/20 m, 16 directions) 65 % + ombrage 35 % ; teinte = aspect, chroma fixe (RELIEF_CHROMA). Échelle log fixeRELIEF_OPEN_RANGE(pas de statistique par dalle) et support total 40 m < bande de raccord 100 m : dalles jointives. Rapide : détendance + rayons sur grille décimée ~0,8 m (RELIEF_GRID_M), noyau dédié qui n'accumule que la moyenne des angles (_mean_horizon_*: CuPyRawKernelsur GPU, numba parallèle sur CPU, numpy en repli), colorisation fusionnée (numba) ou vectorisée sans trigonométrie (CuPy) via une table L* × teinte (_relief_lut). ~5 s de calcul hors préparation sur CPU 12 cœurs. Tout changement de constante change le rendu : régénérer les dalles (--only relief_oriente --force). - Openness sous-échantillonnée :
generate_opennesscalcule le lancé de rayons (l'étape la plus coûteuse : 532 s/tuile à 0,2 m sur CPU) sur une grille décimée par blocs (OPENNESS_DOWNSAMPLE = 2: max par bloc en positive, min en négative — préserve les reliefs qui bornent l'horizon) puis rééchantillonne en bilinéaire. Coût ÷ facteur³ : 532 s → 40 s (×13). Signal archéologique préservé (corr. 0,93 après lissage) ; la texture de bruit sub-métrique disparaît.--openness-downsample 1= pleine résolution. SVF et openness anisotrope ne sont PAS concernées. - Raccord des bords entre tuiles : les rendus à grand noyau (openness/SVF : rayons 100 m ; LRM : 15 m) tronquent leur fenêtre au bord de dalle — bandes d'artefacts à chaque changement de tuile.
--edge-buffer N(défaut 100 ; toujours appliqué par la génération depuis la carte,EDGE_BUFFER_METERS= 100 m dansmapserve.py, plus de case à cocher ; 0 = off en ligne de commande) fait rastériser le DTM sur la dalle nominale 1 km alignée sur la grille plus une bande de N m remplie avec les points sol des 8 LAZ voisines (_neighbor_ground_pointsdansdtm.py: PDAL en flux, découpe + filtre de classes IGN ; voisine absente = téléchargement automatique depuis le catalogue IGN avant le run, isolée dansinput/edge_neighbors/pour ne pas gonfler le corpus des passes globales (dédupliqué sur tout le lot,_fetch_edge_neighborsdanspipeline.py) ; introuvable ou échec = bande vide). Les visualisations calculent sur l'emprise étendue puisrendering.py(_core_tile_window, viatif_to_crop/tif_to_png) recadre les sorties sur la dalle 1 km exacte lue dans le nom LHD — les AVIF restent des carrés 1 km alignés dans la mosaïque. Tampon consigné dans le tag GeoTIFFLIDAR_EDGE_BUFFERdu DTM : changer--edge-bufferinvalide le cache DTM automatiquement (tag absent = 0). Bandes voisines non calées par faisceaux (contexte seul, recadrée hors image finale). Coût : ~7 s de lecture par voisine + ~44 % de pixels en plus à 100 m/0,2 m. Nom hors pattern LHD : option ignorée (bornes d'en-tête, pas de recadrage). - 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()écrit l'inventaire + les paliers sources :output/index_tiles.json(dalles, couches, URLs versionnées — servi par/api/tiles), vignettesindex_thumbs/(≈3,9 m/px +_mid1,56 m/px) et quadrantsindex_subtiles/— paliers de la pyramide XYZ (tiles.py). Chaque tuile du run en cours porte ses coins WGS84 pour les cadres de progression. Plus d'HTML : l'interface vit dansmapui.py.
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 : vignettes + inventaire)gpu.pyprovides CuPy/NumPy proxy (xp), lazy init, OOM fallback.safe_gpu_callwraps all non-IGN viz calls.mapserve.py(FastAPI, imagelidar-maps) sert la pyramide XYZ (tiles.py), l'interface (mapui.py), l'inventaire/api/tiles+ statiques dalles pour les machines légères, et l'API de génération : pipeline en sous-processus sur l'image complète, délégation viaLIDAR_GENERATION_URLsur l'image légère (Raspberry Pi). Job unique + file persistante (_job_lockest un RLock :/api/statusappelle_queue_summary()sous verrou).LIDAR_API_TOKEN(worker) /LIDAR_REMOTE_TOKEN(léger) protègent les appels ;LIDAR_REGEN_CIDRréserve la génération au réseau local.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 (14× same pattern): intentional convention for uniform
return Nonebehavior. _d8_accumulate_numbadefines@njitinside the function:cache=Truemakes subsequent calls fast; the Python function object creation is negligible.pkill -9 -f "pdal pipeline"in cli.py signal handler: belt-and-suspenders alongsideos.killpg. Scoped to "pdal pipeline" to avoid killing unrelated PDAL processes.
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: primary res (default 0.5) has no filename suffix; additional resolutions use
_r0p2style suffix. Ground classification done once, shared across resolutions. ProcessPoolExecutorhas 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.