prepare: add index module, update pipeline, tests, Dockerfile, and run.sh

This commit is contained in:
Antoine Jacquin
2026-07-28 23:20:20 +02:00
parent c58ca3f477
commit 54dbec145e
9 changed files with 924 additions and 35 deletions

View File

@ -1,4 +1,4 @@
FROM nvidia/cuda:11.8.0-devel-ubuntu22.04
FROM nvidia/cuda:12.9.2-devel-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Europe/Paris
@ -45,14 +45,13 @@ RUN pip3 install --no-cache-dir \
pillow-avif-plugin \
cmcrameri
# CuPy 13.4 on CUDA 11.8 with JIT compilation (CUPY_CUDA_COMPILE_WITH_CACHE=1).
# CuPy on CUDA 12.9 with JIT compilation (CUPY_CUDA_COMPILE_WITH_CACHE=1).
# JIT allows CuPy to compile kernels at runtime for GPU architectures not in
# the pre-built wheel (sm_89 = RTX 4060 Ti). nvcc must be in PATH at runtime.
# NOTE: RTX 5060 (sm_120) is NOT yet supported by any CuPy version.
# The pipeline auto-detects the best usable GPU (falls back to 4060 Ti).
# the pre-built wheel (sm_89 = RTX 4060 Ti, sm_120 = RTX 5060 Ti).
# nvcc must be in PATH at runtime for JIT compilation.
ENV CUPY_CUDA_COMPILE_WITH_CACHE=1
ENV PATH=/usr/local/cuda/bin:${PATH}
RUN pip3 install --no-cache-dir cupy-cuda11x==13.4.0
RUN pip3 install --no-cache-dir cupy-cuda12x
# Copy and install the pipeline package
COPY setup.py .

View File

@ -195,6 +195,16 @@ def main():
action="store_true",
help="Mode debug : affiche les détails internes (fichier:ligne)"
)
parser.add_argument(
"--rebuild-index",
action="store_true",
help="Régénérer uniquement la carte globale HTML des tuiles déjà traitées (sans retraiter)"
)
parser.add_argument(
"--no-index",
action="store_true",
help="Ne pas générer la carte globale à la fin du traitement"
)
args = parser.parse_args()
@ -233,6 +243,16 @@ def main():
log_gpu_status()
try:
# Mode --rebuild-index : régénère uniquement la carte globale, sans retraiter
if args.rebuild_index:
from .index import build_index
index_path = build_index(args.output, args.format)
if index_path:
logger.info(f"Carte globale générée : {index_path}")
else:
logger.warning("Aucune tuile traitée trouvée — carte globale non générée")
return
quality = 100 if args.lossless else args.quality
# Parse --only and --skip: accept comma-separated values
only_viz = None
@ -255,6 +275,7 @@ def main():
skip_viz=skip_viz,
output_format=args.format,
gpu_ids=gpu_ids,
no_index=args.no_index,
)
# If --file is specified, process only matching files
@ -308,6 +329,16 @@ def main():
logger.info(" ✓ Fichiers temporaires supprimés")
except Exception as e:
logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}")
# Génère la carte globale après traitement --file
if not args.no_index:
try:
from .index import build_index
index_path = build_index(pipeline.output_dir, pipeline.output_format)
if index_path:
logger.info(f"Carte globale générée : {index_path}")
except Exception as e:
logger.warning(f"Index global non généré: {e}")
else:
pipeline.process_all()
except Exception as e:

632
lidar_pipeline/index.py Normal file
View File

@ -0,0 +1,632 @@
"""Carte globale interactive des tuiles LiDAR traitées.
Génère une page HTML unique (output/index.html) présentant toutes les tuiles
1×1 km traitées, positionnées sur une grille Lambert 93, avec zoom/pan natifs
et sélecteur de visualisation. Permet de choisir rapidement la tuile à examiner.
Sortie:
- output/index.html : page interactive auto-suffisante
- output/index_thumbs/*.jpg : vignettes JPEG (~256px) par tuile/visualisation
Intégration:
- Appelé automatiquement à la fin de process_all() dans pipeline.py
- Régénération solo via --rebuild-index dans cli.py
"""
import json
import logging
import re
from pathlib import Path
logger = logging.getLogger("lidar")
# Noms d'affichage (français) pour le sélecteur de visualisation.
# Clé = mot-clé dans le nom de fichier de sortie (post-basename).
VIZ_LABELS = {
'hillshade_multi': 'Hillshade multidirectionnel',
'slope': 'Pente',
'aspect': 'Aspect',
'mslrm': 'MSRM (relief multi-échelle)',
'sailore': 'SAILORE (LRM adaptatif)',
'positive_openness': 'Openness positive',
'negative_openness': 'Openness négative',
'svf': 'Sky-View Factor',
'aniso_open': 'Openness anisotropique',
'roughness': 'Rugosité',
'wavelet': 'Ondelette',
'flow_acc': 'Accumulation d\'écoulement',
'solar': 'Éclairage solaire',
'anomaly': 'Carte d\'anomalies',
'ortho': 'Orthophoto IGN',
'topo': 'Carte topographique IGN',
}
# Visualisation par défaut pour la vignette (si disponible).
DEFAULT_VIZ = 'hillshade_multi'
# Ordre préféré pour le choix de la vignette de repli.
_VIZ_FALLBACK_ORDER = [
'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness',
'negative_openness', 'aspect', 'sailore', 'aniso_open', 'roughness',
'wavelet', 'flow_acc', 'solar', 'anomaly', 'ortho', 'topo',
]
# Regex pour parser les coordonnées tuile dans le basename LHD.
# LHD_FXX_{COL}_{ROW}_PTS_LAMB93_IGN69 (COL/ROW en km, Lambert 93)
_RE_LHD_COORDS = re.compile(r'^LHD_FXX_(\d+)_(\d+)_PTS_LAMB93')
def parse_basename_coords(name):
"""Extrait les coordonnées tuile (col, row en km) depuis un basename.
Args:
name: basename potentiel (ex: 'LHD_FXX_1000_6881_PTS_LAMB93_IGN69')
ou nom de dossier avec suffixe résolution ('..._r0p2').
Returns:
(col_km, row_km) ou None si le nom ne correspond pas au pattern LHD.
"""
m = _RE_LHD_COORDS.match(name)
if not m:
return None
return int(m.group(1)), int(m.group(2))
def _strip_res_suffix(dirname):
"""Sépare le basename de base et la résolution d'un nom de dossier de viz.
'LHD_FXX_1000_6881_PTS_LAMB93_IGN69' → (basename, 0.5)
'LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2' → (basename, 0.2)
Returns:
(basename_without_suffix, resolution_float) ou (dirname, 0.5) si pas de suffixe.
"""
m = re.match(r'^(.+?)_r(\d+p\d+)$', dirname)
if m:
res_str = m.group(2).replace('p', '.')
try:
return m.group(1), float(res_str)
except ValueError:
pass
return dirname, 0.5
def scan_tiles(vis_dir):
"""Scanne le dossier des visualisations pour inventorier les tuiles traitées.
Args:
vis_dir: Path vers output/visualisations/
Returns:
Liste de dictionnaires:
{basename, col, row, resolution, dir_path,
viz: {viz_key: {filename, ext}}, dir_name}
Triée par (resolution, row, col).
"""
vis_dir = Path(vis_dir)
if not vis_dir.is_dir():
return []
tiles = []
for entry in sorted(vis_dir.iterdir()):
if not entry.is_dir():
continue
coords = parse_basename_coords(entry.name)
if coords is None:
continue
col, row = coords
basename, resolution = _strip_res_suffix(entry.name)
# Liste les fichiers image de visualisation dans le dossier.
viz = {}
for f in sorted(entry.iterdir()):
if not f.is_file():
continue
# Détection extension AVIF/WebP
ext = None
low = f.name.lower()
for e in ('.avif', '.webp'):
if low.endswith(e):
ext = e.lstrip('.')
break
if ext is None:
continue
# viz_key = nom sans le préfixe basename_ ni l'extension
stem = f.name[:-len('.' + ext)]
prefix = basename + '_'
if not stem.startswith(prefix):
continue
viz_key = stem[len(prefix):]
viz[viz_key] = {'filename': f.name, 'ext': ext}
if not viz:
# Dossier vide ou sans image valide → ignoré
continue
tiles.append({
'basename': basename,
'col': col,
'row': row,
'resolution': resolution,
'dir_name': entry.name,
'dir_path': str(entry),
'viz': viz,
})
tiles.sort(key=lambda t: (t['resolution'], -t['row'], t['col']))
return tiles
def compute_bbox(tiles):
"""Calcule la bounding box (en km) couverte par les tuiles.
Returns:
Dict {min_col, max_col, min_row, max_row} ou None si aucune tuile.
"""
if not tiles:
return None
cols = [t['col'] for t in tiles]
rows = [t['row'] for t in tiles]
return {
'min_col': min(cols),
'max_col': max(cols),
'min_row': min(rows),
'max_row': max(rows),
}
def generate_thumbnail(src_path, thumb_path, max_size=256):
"""Génère une vignette JPEG depuis une image AVIF/WebP existante.
Args:
src_path: chemin de l'image source (AVIF/WebP).
thumb_path: chemin de sortie JPEG.
max_size: taille maximale (côté le plus grand) en pixels.
Returns:
True si OK, False si échec.
"""
try:
from PIL import Image as PILImage
except ImportError:
logger.warning("PIL indisponible — impossible de générer les vignettes")
return False
try:
img = PILImage.open(str(src_path))
img = img.convert('RGB')
w, h = img.size
scale = min(1.0, max_size / max(w, h))
if scale < 1.0:
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
try:
resample = PILImage.Resampling.LANCZOS
except AttributeError:
resample = getattr(PILImage, 'LANCZOS', 1)
img = img.resize(new_size, resample)
Path(thumb_path).parent.mkdir(parents=True, exist_ok=True)
img.save(str(thumb_path), format='JPEG', quality=80)
return True
except Exception as e:
logger.debug(f"Vignette ignorée {src_path}: {e}")
return False
def _pick_display_viz(viz_keys):
"""Choisit la visualisation par défaut pour une tuile.
Privilégie hillshade_multi, sinon la première disponible selon l'ordre de repli.
"""
for v in _VIZ_FALLBACK_ORDER:
if v in viz_keys:
return v
return sorted(viz_keys)[0]
def build_index(output_dir, output_format='avif'):
"""Génère la carte interactive HTML des tuiles traitées.
Scanne output_dir/visualisations/, génère les vignettes JPEG, puis écrit
output_dir/index.html (auto-suffisant) + output_dir/index_thumbs/.
Args:
output_dir: dossier de sortie racine (contient visualisations/).
output_format: format des images ('avif' ou 'webp') — pour info.
Returns:
Path vers index.html si succès, None si échec ou aucune tuile.
"""
output_dir = Path(output_dir)
vis_dir = output_dir / 'visualisations'
tiles = scan_tiles(vis_dir)
if not tiles:
logger.info("Aucune tuile traitée trouvée — index global non généré")
return None
bbox = compute_bbox(tiles)
assert bbox is not None # garanti par le test tiles non vide ci-dessus
thumb_dir = output_dir / 'index_thumbs'
thumb_dir.mkdir(parents=True, exist_ok=True)
# Collecte toutes les visualisations disponibles (pour le sélecteur).
all_viz_keys = set()
for t in tiles:
all_viz_keys.update(t['viz'].keys())
# Génère les vignettes et construit les données pour le HTML.
tile_records = []
thumbs_generated = 0
thumbs_failed = 0
for t in tiles:
viz_thumbs = {}
for viz_key, info in t['viz'].items():
src = Path(t['dir_path']) / info['filename']
thumb_name = f"{t['dir_name']}_{viz_key}.jpg"
thumb_path = thumb_dir / thumb_name
# Régénère seulement si manquante
if not thumb_path.exists():
if generate_thumbnail(src, thumb_path):
thumbs_generated += 1
else:
thumbs_failed += 1
continue
else:
thumbs_generated += 1
viz_thumbs[viz_key] = {
'thumb': f"index_thumbs/{thumb_name}",
'full': f"visualisations/{t['dir_name']}/{info['filename']}",
}
if not viz_thumbs:
continue
display_viz = _pick_display_viz(viz_thumbs.keys())
tile_records.append({
'col': t['col'],
'row': t['row'],
'name': t['basename'],
'dir_name': t['dir_name'],
'resolution': t['resolution'],
'display_viz': display_viz,
'viz': viz_thumbs,
})
if not tile_records:
logger.warning("Aucune vignette générée — index global abandonné")
return None
# HTML avec données intégrées.
html = _render_html(tile_records, bbox, all_viz_keys, output_format)
html_path = output_dir / 'index.html'
html_path.write_text(html, encoding='utf-8')
logger.info(f"Index global généré : {html_path}")
logger.info(f" {len(tile_records)} tuile(s) • {thumbs_generated} vignette(s) générée(s)"
+ (f" • {thumbs_failed} échec(s)" if thumbs_failed else ""))
logger.info(f" Grille : {bbox['min_col']}-{bbox['max_col']} km E × "
f"{bbox['min_row']}-{bbox['max_row']} km N")
return html_path
def _render_html(tile_records, bbox, all_viz_keys, output_format):
"""Construit le HTML complet avec CSS et JS natifs (zoom/pan)."""
# Ordre des viz dans le sélecteur (selon ordre préféré puis alpha).
ordered_viz = [v for v in _VIZ_FALLBACK_ORDER if v in all_viz_keys]
for v in sorted(all_viz_keys):
if v not in ordered_viz:
ordered_viz.append(v)
data_json = json.dumps({
'tiles': tile_records,
'bbox': bbox,
'vizList': ordered_viz,
}, ensure_ascii=False)
options_html = '\n'.join(
f' <option value="{v}"{" selected" if v == DEFAULT_VIZ else ""}>'
f'{VIZ_LABELS.get(v, v)}</option>'
for v in ordered_viz
)
n_tiles = len(tile_records)
grid_w = bbox['max_col'] - bbox['min_col'] + 1
grid_h = bbox['max_row'] - bbox['min_row'] + 1
return _HTML_TEMPLATE.format(
data_json=data_json,
options_html=options_html,
n_tiles=n_tiles,
grid_w=grid_w,
grid_h=grid_h,
output_format=output_format.upper(),
)
_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carte globale des tuiles LiDAR</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
html, body {{ height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; overflow: hidden; }}
#topbar {{
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
padding: 10px 16px; background: #16213e; border-bottom: 1px solid #0f3460;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}}
#topbar h1 {{ font-size: 16px; font-weight: 600; color: #e94560; white-space: nowrap; }}
#topbar .stat {{ font-size: 13px; color: #a0a0c0; white-space: nowrap; }}
#topbar label {{ font-size: 13px; color: #a0a0c0; }}
#topbar select {{
background: #0f3460; color: #e0e0e0; border: 1px solid #1a4a7a;
padding: 5px 8px; border-radius: 4px; font-size: 13px; cursor: pointer;
}}
#topbar select:hover {{ border-color: #e94560; }}
.spacer {{ flex: 1; }}
.controls {{ display: flex; gap: 6px; }}
.controls button {{
background: #0f3460; color: #e0e0e0; border: 1px solid #1a4a7a;
width: 34px; height: 34px; border-radius: 4px; font-size: 18px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}}
.controls button:hover {{ background: #1a4a7a; border-color: #e94560; }}
.controls button:active {{ background: #e94560; }}
#viewport {{
position: absolute; top: 56px; left: 0; right: 0; bottom: 0;
overflow: hidden; cursor: grab; background: #0d0d1a;
}}
#viewport.dragging {{ cursor: grabbing; }}
#grid {{
position: absolute; transform-origin: 0 0;
/* Dimensions fixées en JS selon le nombre de cellules */
}}
.cell {{
position: absolute; border: 1px solid #2a2a4a; background: #111122;
overflow: hidden; display: block; text-decoration: none;
}}
.cell.has-tile {{ border-color: #0f3460; }}
.cell.has-tile:hover {{ border-color: #e94560; box-shadow: 0 0 12px rgba(233,69,96,0.5); z-index: 10; }}
.cell img {{ width: 100%; height: 100%; object-fit: cover; display: block; }}
.cell .label {{
position: absolute; bottom: 0; left: 0; right: 0;
background: rgba(0,0,0,0.65); color: #e0e0e0; font-size: 9px;
padding: 2px 4px; text-align: center; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; opacity: 0; transition: opacity 0.15s;
}}
.cell:hover .label {{ opacity: 1; }}
.cell.empty {{ display: flex; align-items: center; justify-content: center; opacity: 0.3; }}
.cell.empty .coords {{ font-size: 10px; color: #444466; }}
#zoomIndicator {{
position: fixed; bottom: 12px; left: 12px; z-index: 100;
background: rgba(22,33,62,0.9); padding: 6px 12px; border-radius: 4px;
font-size: 12px; color: #a0a0c0; border: 1px solid #0f3460;
}}
#hint {{
position: fixed; bottom: 12px; right: 12px; z-index: 100;
background: rgba(22,33,62,0.9); padding: 8px 12px; border-radius: 4px;
font-size: 11px; color: #808090; border: 1px solid #0f3460; max-width: 280px;
}}
</style>
</head>
<body>
<div id="topbar">
<h1>Carte globale LiDAR</h1>
<span class="stat" id="statTiles">{n_tiles} tuile(s) • {grid_w}×{grid_h} km</span>
<label>Visualisation :</label>
<select id="vizSelect">
{options_html}
</select>
<div class="spacer"></div>
<div class="controls">
<button id="zoomOut" title="Dézoomer">−</button>
<button id="zoomReset" title="Réinitialiser la vue" style="font-size:14px">⤢</button>
<button id="zoomIn" title="Zoomer">+</button>
</div>
</div>
<div id="viewport">
<div id="grid"></div>
</div>
<div id="zoomIndicator">Zoom : 100%</div>
<div id="hint">
Molette : zoom • Clic-glisser : déplacer<br>
Double-clic : zoom rapide • Clic tuile : ouvrir l'image
</div>
<script>
const DATA = {data_json};
const CELL_PX = 200; // taille d'une cellule 1×1 km (en px, à zoom 1)
const GAP_PX = 2;
const bbox = DATA.bbox;
const gridCols = bbox.max_col - bbox.min_col + 1;
const gridRows = bbox.max_row - bbox.min_row + 1;
const grid = document.getElementById('grid');
const viewport = document.getElementById('viewport');
const zoomInd = document.getElementById('zoomIndicator');
const vizSelect = document.getElementById('vizSelect');
grid.style.width = (gridCols * CELL_PX) + 'px';
grid.style.height = (gridRows * CELL_PX) + 'px';
// Index des tuiles par (col,row)
const tileMap = {{}};
for (const t of DATA.tiles) {{
tileMap[t.col + ',' + t.row] = t;
}}
// --- Construction de la grille (toutes les cellules, y compris vides) ---
function colToX(col) {{ return (col - bbox.min_col) * CELL_PX; }}
function rowToY(row) {{ return (bbox.max_row - row) * CELL_PX; }} // Y inversé (Nord en haut)
const fragment = document.createDocumentFragment();
for (let r = bbox.max_row; r >= bbox.min_row; r--) {{
for (let c = bbox.min_col; c <= bbox.max_col; c++) {{
const key = c + ',' + r;
const tile = tileMap[key];
const cell = document.createElement('a');
cell.className = 'cell' + (tile ? ' has-tile' : ' empty');
cell.style.left = colToX(c) + 'px';
cell.style.top = rowToY(r) + 'px';
cell.style.width = (CELL_PX - GAP_PX) + 'px';
cell.style.height = (CELL_PX - GAP_PX) + 'px';
if (tile) {{
cell.target = '_blank';
cell.title = tile.name;
const img = document.createElement('img');
img.loading = 'lazy';
cell.appendChild(img);
const label = document.createElement('div');
label.className = 'label';
label.textContent = tile.name;
cell.appendChild(label);
}} else {{
const coords = document.createElement('div');
coords.className = 'coords';
coords.textContent = c + '\\n' + r;
coords.style.whiteSpace = 'pre';
cell.appendChild(coords);
}}
fragment.appendChild(cell);
}}
}}
grid.appendChild(fragment);
// --- Gestion de la visualisation affichée ---
function applyViz(vizKey) {{
const cells = grid.querySelectorAll('.cell.has-tile');
cells.forEach(cell => {{
// Retrouve la tuile via la position
const left = parseFloat(cell.style.left);
const top = parseFloat(cell.style.top);
const col = bbox.min_col + Math.round(left / CELL_PX);
const row = bbox.max_row - Math.round(top / CELL_PX);
const tile = tileMap[col + ',' + row];
if (!tile) return;
const img = cell.querySelector('img');
// Viz demandée, sinon display_viz, sinon première dispo
let chosen = vizKey;
if (!tile.viz[chosen]) chosen = tile.display_viz;
if (!tile.viz[chosen]) chosen = Object.keys(tile.viz)[0];
const v = tile.viz[chosen];
if (img) {{
img.src = v.thumb;
}}
cell.href = v.full;
}});
}}
applyViz(vizSelect.value);
vizSelect.addEventListener('change', () => applyViz(vizSelect.value));
// --- Zoom / Pan ---
let zoom = 1, panX = 0, panY = 0;
let isDragging = false, dragStartX = 0, dragStartY = 0, startPanX = 0, startPanY = 0;
function applyTransform() {{
grid.style.transform = `translate(${{panX}}px, ${{panY}}px) scale(${{zoom}})`;
zoomInd.textContent = 'Zoom : ' + Math.round(zoom * 100) + '%';
}}
function clampPan() {{
const vpW = viewport.clientWidth;
const vpH = viewport.clientHeight;
const gw = gridCols * CELL_PX * zoom;
const gh = gridRows * CELL_PX * zoom;
// Permet un léger débordement pour ne pas bloquer le déplacement
panX = Math.min(vpW * 0.5, Math.max(vpW - gw - vpW * 0.5, panX));
panY = Math.min(vpH * 0.5, Math.max(vpH - gh - vpH * 0.5, panY));
// Si la grille est plus petite que le viewport, centre
if (gw < vpW) panX = (vpW - gw) / 2;
if (gh < vpH) panY = (vpH - gh) / 2;
}}
function resetView() {{
zoom = 1;
panX = (viewport.clientWidth - gridCols * CELL_PX) / 2;
panY = (viewport.clientHeight - gridRows * CELL_PX) / 2;
applyTransform();
}}
function zoomAt(factor, cx, cy) {{
const newZoom = Math.max(0.2, Math.min(20, zoom * factor));
const realFactor = newZoom / zoom;
// Garde le point (cx,cy) fixe dans la grille
panX = cx - (cx - panX) * realFactor;
panY = cy - (cy - panY) * realFactor;
zoom = newZoom;
clampPan();
applyTransform();
}}
viewport.addEventListener('wheel', (e) => {{
e.preventDefault();
const rect = viewport.getBoundingClientRect();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
zoomAt(factor, e.clientX - rect.left, e.clientY - rect.top);
}}, {{ passive: false }});
viewport.addEventListener('mousedown', (e) => {{
if (e.target.closest('a.has-tile')) return; // ne déplace pas si clic sur tuile
isDragging = true;
viewport.classList.add('dragging');
dragStartX = e.clientX;
dragStartY = e.clientY;
startPanX = panX;
startPanY = panY;
}});
window.addEventListener('mousemove', (e) => {{
if (!isDragging) return;
panX = startPanX + (e.clientX - dragStartX);
panY = startPanY + (e.clientY - dragStartY);
clampPan();
applyTransform();
}});
window.addEventListener('mouseup', () => {{
isDragging = false;
viewport.classList.remove('dragging');
}});
viewport.addEventListener('dblclick', (e) => {{
if (e.target.closest('a.has-tile')) return;
const rect = viewport.getBoundingClientRect();
zoomAt(2, e.clientX - rect.left, e.clientY - rect.top);
}});
document.getElementById('zoomIn').addEventListener('click', () => {{
zoomAt(1.3, viewport.clientWidth / 2, viewport.clientHeight / 2);
}});
document.getElementById('zoomOut').addEventListener('click', () => {{
zoomAt(1 / 1.3, viewport.clientWidth / 2, viewport.clientHeight / 2);
}});
document.getElementById('zoomReset').addEventListener('click', resetView);
// Recalcule au redimensionnement de la fenêtre
window.addEventListener('resize', () => {{ clampPan(); applyTransform(); }});
// Vue initiale centrée
resetView();
</script>
</body>
</html>
"""

View File

@ -66,7 +66,7 @@ from .visualizations import (
generate_flow_accumulation,
generate_anomaly_mask,
)
from .gpu import gpu_cleanup, num_gpus, restrict_gpus, safe_gpu_call
from .gpu import gpu_cleanup, num_gpus, available_gpu_ids, restrict_gpus, safe_gpu_call
from .ign import generate_ign_overlay
from .rendering import tif_to_png
@ -109,7 +109,7 @@ VIZ_STEPS = [
class LidarArchaeoPipeline:
"""Orchestrates the LiDAR archaeological analysis pipeline."""
def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', force_classify=False, keep_tif=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None):
def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', force_classify=False, keep_tif=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None, no_index=False):
self.input_dir = Path(input_dir)
self.output_dir = Path(output_dir)
# Accept single float or comma-separated string for multi-resolution
@ -130,6 +130,7 @@ class LidarArchaeoPipeline:
self.skip_viz = skip_viz
self.output_format = output_format
self.gpu_ids = gpu_ids
self.no_index = no_index
self.temp_dir = self.output_dir / "temp"
if not self.input_dir.exists():
@ -467,7 +468,7 @@ class LidarArchaeoPipeline:
with ProcessPoolExecutor(max_workers=self.workers) as executor:
# Round-robin assign each file to a real GPU host index
active_ids = self.gpu_ids if self.gpu_ids else _gpu_mod.available_gpu_ids()
active_ids = self.gpu_ids if self.gpu_ids else available_gpu_ids()
resolutions_str = ','.join(str(r) for r in self.resolutions)
future_to_file = {
executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), resolutions_str, self.force, self.ground_method, self.force_classify, self.keep_tif, self.quality, self.only_viz, self.skip_viz, self.output_format, active_ids[file_idx % len(active_ids)] if active_ids else None): laz_file
@ -529,6 +530,16 @@ class LidarArchaeoPipeline:
logger.info(f" • DTM : {self.dtm_dir}")
logger.info(f" • Visualisations: {self.vis_dir}")
# Génère la carte globale interactive des tuiles traitées
if not self.no_index:
try:
from .index import build_index
index_path = build_index(self.output_dir, self.output_format)
if index_path:
logger.info(f" • Carte globale : {index_path}")
except Exception as e:
logger.warning(f"Index global non généré: {e}")
# Clean up temporary files
logger.info("Nettoyage des fichiers temporaires...")
try:

View File

@ -110,8 +110,9 @@ class TestDetectGroundMethod:
mock_las.points.__len__ = lambda self: len(num_returns)
return mock_las
@patch('lidar_pipeline.dtm.laspy')
def test_urban_terrain_returns_csf(self, mock_laspy):
@patch('lidar_pipeline.dtm._read_with_pdal')
@patch('laspy.read')
def test_urban_terrain_returns_csf(self, mock_read, mock_pdal):
"""High single-return ratio (>0.6) should select CSF."""
from lidar_pipeline.dtm import detect_ground_method
@ -121,13 +122,14 @@ class TestDetectGroundMethod:
num_returns[:int(n * 0.3)] = 2 # 30% multi-return
z_values = np.random.normal(100, 5, n) # Low variance = flat terrain
mock_laspy.read.return_value = self._make_mock_las(num_returns, z_values)
mock_read.return_value = self._make_mock_las(num_returns, z_values)
result = detect_ground_method(Path("/data/input/test.laz"))
assert result == 'csf'
@patch('lidar_pipeline.dtm.laspy')
def test_natural_terrain_returns_smrf(self, mock_laspy):
@patch('lidar_pipeline.dtm._read_with_pdal')
@patch('laspy.read')
def test_natural_terrain_returns_smrf(self, mock_read, mock_pdal):
"""Low single-return ratio and moderate variance should select SMRF."""
from lidar_pipeline.dtm import detect_ground_method
@ -137,13 +139,14 @@ class TestDetectGroundMethod:
num_returns[:int(n * 0.6)] = 2 # 60% multi-return (forest)
z_values = np.random.normal(100, 15, n) # Moderate variance
mock_laspy.read.return_value = self._make_mock_las(num_returns, z_values)
mock_read.return_value = self._make_mock_las(num_returns, z_values)
result = detect_ground_method(Path("/data/input/test.laz"))
assert result == 'smrf'
@patch('lidar_pipeline.dtm.laspy')
def test_mountainous_terrain_returns_csf(self, mock_laspy):
@patch('lidar_pipeline.dtm._read_with_pdal')
@patch('laspy.read')
def test_mountainous_terrain_returns_csf(self, mock_read, mock_pdal):
"""High variance terrain (>30m std) selects CSF for complex terrain."""
from lidar_pipeline.dtm import detect_ground_method
@ -153,7 +156,7 @@ class TestDetectGroundMethod:
num_returns[:int(n * 0.5)] = 2
z_values = np.random.normal(100, 50, n) # Very high variance = mountainous
mock_laspy.read.return_value = self._make_mock_las(num_returns, z_values)
mock_read.return_value = self._make_mock_las(num_returns, z_values)
result = detect_ground_method(Path("/data/input/test.laz"))
assert result == 'csf'
@ -161,8 +164,7 @@ class TestDetectGroundMethod:
class TestClassifyGroundMethod:
@patch('lidar_pipeline.dtm.subprocess')
@patch('lidar_pipeline.dtm.laspy')
def test_classify_ground_auto_calls_detect(self, mock_laspy, mock_subprocess):
def test_classify_ground_auto_calls_detect(self, mock_subprocess):
"""classify_ground with method='auto' should call detect_ground_method."""
from lidar_pipeline.dtm import classify_ground
@ -174,8 +176,7 @@ class TestClassifyGroundMethod:
mock_detect.assert_called_once()
@patch('lidar_pipeline.dtm.subprocess')
@patch('lidar_pipeline.dtm.laspy')
def test_classify_ground_smrf_uses_smrf_pipeline(self, mock_laspy, mock_subprocess):
def test_classify_ground_smrf_uses_smrf_pipeline(self, mock_subprocess):
"""classify_ground with method='smrf' should create SMRF pipeline."""
from lidar_pipeline.dtm import classify_ground, _create_ground_pipeline
@ -195,8 +196,7 @@ class TestClassifyGroundMethod:
assert "filters.smrf" in stage_types
@patch('lidar_pipeline.dtm.subprocess')
@patch('lidar_pipeline.dtm.laspy')
def test_classify_ground_csf_uses_csf_pipeline(self, mock_laspy, mock_subprocess):
def test_classify_ground_csf_uses_csf_pipeline(self, mock_subprocess):
"""classify_ground with method='csf' should create CSF pipeline."""
from lidar_pipeline.dtm import classify_ground

View File

@ -0,0 +1,205 @@
"""Tests pour la carte globale interactive (index.py)."""
import json
from pathlib import Path
def test_parse_basename_coords_valid():
"""Parse les coordonnées d'un basename LHD valide."""
from lidar_pipeline.index import parse_basename_coords
assert parse_basename_coords("LHD_FXX_1000_6881_PTS_LAMB93_IGN69") == (1000, 6881)
assert parse_basename_coords("LHD_FXX_1049_6895_PTS_LAMB93_IGN69") == (1049, 6895)
def test_parse_basename_coords_with_res_suffix():
"""Les noms de dossier avec suffixe résolution sont aussi parsables."""
from lidar_pipeline.index import parse_basename_coords
assert parse_basename_coords("LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2") == (1000, 6881)
def test_parse_basename_coords_invalid():
"""Les noms non-LHD retournent None."""
from lidar_pipeline.index import parse_basename_coords
assert parse_basename_coords("random_dir") is None
assert parse_basename_coords("DTM") is None
assert parse_basename_coords("") is None
def test_strip_res_suffix_primary():
"""Dossier sans suffixe = résolution primaire (0.5)."""
from lidar_pipeline.index import _strip_res_suffix
base, res = _strip_res_suffix("LHD_FXX_1000_6881_PTS_LAMB93_IGN69")
assert base == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
assert res == 0.5
def test_strip_res_suffix_multi():
"""Dossier avec suffixe _r0p2 = résolution 0.2."""
from lidar_pipeline.index import _strip_res_suffix
base, res = _strip_res_suffix("LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2")
assert base == "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
assert res == 0.2
def test_compute_bbox():
"""Calcule la bounding box d'un ensemble de tuiles."""
from lidar_pipeline.index import compute_bbox
tiles = [
{'col': 1000, 'row': 6881},
{'col': 1001, 'row': 6882},
{'col': 1002, 'row': 6880},
]
bbox = compute_bbox(tiles)
assert bbox == {'min_col': 1000, 'max_col': 1002, 'min_row': 6880, 'max_row': 6882}
def test_compute_bbox_empty():
"""Aucune tuile → None."""
from lidar_pipeline.index import compute_bbox
assert compute_bbox([]) is None
def _make_fake_viz_dir(vis_dir, basename, col, row, viz_keys=('hillshade_multi', 'svf'), ext='webp', res_suffix=''):
"""Crée un faux dossier de visualisations avec de petites images."""
from PIL import Image as PILImage
import numpy as np
dir_name = f"LHD_FXX_{col}_{row}_PTS_LAMB93_IGN69{res_suffix}"
tile_dir = Path(vis_dir) / dir_name
tile_dir.mkdir(parents=True, exist_ok=True)
for v in viz_keys:
arr = np.random.randint(0, 255, (50, 50, 3), dtype=np.uint8)
img = PILImage.fromarray(arr)
fname = f"LHD_FXX_{col}_{row}_PTS_LAMB93_IGN69{res_suffix}_{v}.{ext}"
img.save(str(tile_dir / fname), format='WEBP', quality=80)
return tile_dir
def test_scan_tiles(tmp_path):
"""scan_tiles détecte les dossiers de tuiles et leurs visualisations."""
from lidar_pipeline.index import scan_tiles
vis_dir = tmp_path / "visualisations"
vis_dir.mkdir()
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi', 'svf'))
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
tiles = scan_tiles(vis_dir)
assert len(tiles) == 2
names = sorted(t['dir_name'] for t in tiles)
assert "LHD_FXX_1000_6881_PTS_LAMB93_IGN69" in names
assert "LHD_FXX_1001_6881_PTS_LAMB93_IGN69" in names
# Vérifie que les viz sont détectées
t0 = next(t for t in tiles if t['col'] == 1000)
assert 'hillshade_multi' in t0['viz']
assert 'svf' in t0['viz']
def test_scan_tiles_ignores_non_lhd(tmp_path):
"""Les dossiers non-LHD (ex: temp, DTM) sont ignorés."""
from lidar_pipeline.index import scan_tiles
vis_dir = tmp_path / "visualisations"
vis_dir.mkdir()
(vis_dir / "random_folder").mkdir()
_make_fake_viz_dir(vis_dir, "a", 1000, 6881)
tiles = scan_tiles(vis_dir)
assert len(tiles) == 1
assert tiles[0]['col'] == 1000
def test_scan_tiles_multi_resolution(tmp_path):
"""Les dossiers avec suffixe résolution sont correctement décodés."""
from lidar_pipeline.index import scan_tiles
vis_dir = tmp_path / "visualisations"
vis_dir.mkdir()
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, res_suffix='')
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, res_suffix='_r0p2')
tiles = scan_tiles(vis_dir)
assert len(tiles) == 2
resolutions = sorted(t['resolution'] for t in tiles)
assert resolutions == [0.2, 0.5]
def test_build_index_generates_html(tmp_path):
"""build_index génère index.html et les vignettes."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi', 'svf'))
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
result = build_index(output_dir)
assert result is not None
html_path = Path(result)
assert html_path.exists()
assert html_path.name == "index.html"
content = html_path.read_text(encoding='utf-8')
# Vérifie la présence des éléments clés
assert "Carte globale LiDAR" in content
assert "LHD_FXX_1000_6881" in content
assert "LHD_FXX_1001_6881" in content
# Vérifie que le JSON intégré est valide
assert "const DATA" in content
# Vérifie les vignettes générées
thumb_dir = output_dir / "index_thumbs"
assert thumb_dir.is_dir()
thumbs = list(thumb_dir.glob("*.jpg"))
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
def test_build_index_empty_returns_none(tmp_path):
"""Aucune tuile → build_index retourne None sans crash."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
(output_dir / "visualisations").mkdir(parents=True)
result = build_index(output_dir)
assert result is None
def test_build_index_embeds_valid_json(tmp_path):
"""Le JSON embarqué dans le HTML est valide et contient les tuiles."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
build_index(output_dir)
content = (output_dir / "index.html").read_text(encoding='utf-8')
# Extrait le JSON entre "const DATA = " et ";"
start = content.index("const DATA = ") + len("const DATA = ")
# Trouve le ; de fin de déclaration
depth = 0
end = start
for i, ch in enumerate(content[start:], start):
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
end = i + 1
break
data = json.loads(content[start:end])
assert 'tiles' in data
assert 'bbox' in data
assert 'vizList' in data
assert len(data['tiles']) == 1
assert data['tiles'][0]['col'] == 1000
assert data['tiles'][0]['row'] == 6881
def test_pick_display_viz_prefers_hillshade():
"""Le choix de viz par défaut privilégie hillshade_multi."""
from lidar_pipeline.index import _pick_display_viz
assert _pick_display_viz(['svf', 'hillshade_multi', 'slope']) == 'hillshade_multi'
assert _pick_display_viz(['svf', 'slope']) == 'svf'
assert _pick_display_viz(['topo']) == 'topo'

View File

@ -62,7 +62,7 @@ class TestTifToPng:
result = tif_to_png(tif_file, tmp_path, 5.0)
assert result is not None
assert result.exists()
assert result.suffix == '.webp'
assert result.suffix == '.avif'
def test_removes_source_tif(self, tmp_path):
from lidar_pipeline.rendering import tif_to_png

View File

@ -16,7 +16,7 @@ from pathlib import Path
import numpy as np
import rasterio
from .gpu import HAS_GPU, to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_filter, xp_minimum_filter, xp_maximum_filter, gpu_cleanup
from .gpu import to_gpu, to_cpu, xp_gaussian_filter, xp_uniform_filter, gpu_cleanup
from . import gpu as _gpu_mod
logger = logging.getLogger("lidar")
@ -37,7 +37,6 @@ class _XPProxy:
def __getattr__(self, name):
global _cp
from . import gpu as _gpu_mod
if _gpu_mod.HAS_GPU:
if _cp is None:
try:
@ -272,20 +271,21 @@ def _filter_nanaware(arr, filter_func, *args, use_gpu=True, **kwargs):
def _prepare_dem_for_raycast(dem_file, shared, resolution):
"""Load DEM and prepare padded array for ray-tracing.
Returns (dem_gpu_or_cpu, dem_np, rows, cols, res, nan_mask,
transform, crs, padded) ready for ray-tracing.
Returns (dem_filled, dem_np, rows, cols, res, nan_mask,
transform, crs) ready for ray-tracing.
dem_filled is a CPU numpy array (filled, no NaN).
"""
if shared:
dem_np = shared.dem_np
nan_mask = shared.nan_mask
transform = shared.transform
crs = shared.crs
dem = to_gpu(shared.filled) if _gpu_mod.HAS_GPU else shared.filled
dem = shared.filled
else:
dem_np, transform, crs = _read_dem(dem_file)
nan_mask = np.isnan(dem_np)
filled, _ = _fill_nans(dem_np)
dem = to_gpu(filled) if _gpu_mod.HAS_GPU else filled
dem = filled
res = resolution
rows, cols = dem_np.shape
return dem, dem_np, rows, cols, res, nan_mask, transform, crs
@ -304,7 +304,7 @@ def _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
processed and results are streamed back to CPU.
Args:
dem: GPU or CPU filled DEM array (rows, cols).
dem: CPU numpy array — filled DEM (no NaN), shape (rows, cols).
rows, cols: dimensions.
res: resolution in m/px.
n_dirs: number of directions.
@ -329,13 +329,14 @@ def _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m=None):
# Pad on CPU (numpy) — avoids GPU memory pressure and
# pre-compiled kernel issues (NO_BINARY_FOR_GPU on sm_89).
dem_np = to_cpu(dem)
padded_np = np.pad(dem_np, max_dist, mode='constant', constant_values=np.nan)
padded_np = np.pad(dem, max_dist, mode='constant', constant_values=np.nan)
# Transfer padded DEM to GPU for computation
padded = to_gpu(padded_np)
# GPU view of central region — reference elevation for ray-tracing
dem = padded[max_dist:max_dist+rows, max_dist:max_dist+cols]
# Free the CPU copy — we don't need it anymore
del dem_np, padded_np
del padded_np
# Process one direction at a time to limit GPU memory.
# Store results as flat CPU arrays — transfer back to GPU at the end.

10
run.sh
View File

@ -90,6 +90,8 @@ FORMAT_FLAG=""
ONLY_FLAG=""
SKIP_FLAG=""
TEST_FLAG=0
REBUILD_INDEX_FLAG=""
NO_INDEX_FLAG=""
# Parse arguments manually (more robust than getopts for mixed short/long options)
while [ $# -gt 0 ]; do
@ -121,6 +123,8 @@ while [ $# -gt 0 ]; do
--only) shift; ONLY_FLAG="--only"; while [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; do ONLY_FLAG="$ONLY_FLAG $1"; shift; done ;;
--skip) shift; SKIP_FLAG="--skip"; while [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; do SKIP_FLAG="$SKIP_FLAG $1"; shift; done ;;
--file) shift; while [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; do FILE_ARGS="$FILE_ARGS $1"; shift; done ;;
--rebuild-index) REBUILD_INDEX_FLAG="--rebuild-index"; shift ;;
--no-index) NO_INDEX_FLAG="--no-index"; shift ;;
--test) TEST_FLAG=1 ;;
-h|--help|-help)
echo "Pipeline LiDAR Archéologique"
@ -246,6 +250,12 @@ fi
if [ -n "$FILE_ARGS" ]; then
CMD_ARGS="$CMD_ARGS --file $FILE_ARGS"
fi
if [ -n "$REBUILD_INDEX_FLAG" ]; then
CMD_ARGS="$CMD_ARGS $REBUILD_INDEX_FLAG"
fi
if [ -n "$NO_INDEX_FLAG" ]; then
CMD_ARGS="$CMD_ARGS $NO_INDEX_FLAG"
fi
# Build CUDA_VISIBLE_DEVICES env var from GPU_ARG
CUDA_ENV_FLAG=""