carte continue: cropped images + continuous map + PDF download via jsPDF

This commit is contained in:
Antoine Jacquin
2026-07-28 23:37:40 +02:00
parent 54dbec145e
commit 3d79a5ee72
4 changed files with 438 additions and 88 deletions

View File

@ -1,8 +1,9 @@
"""Carte globale interactive des tuiles LiDAR traitées.
"""Carte continue 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.
1×1 km traitées, affichées en carte continue sans bordure, avec zoom/pan fluide
et sélecteur de visualisation. Au clic sur une tuile, un modal affiche l'image
avec légende et permet de télécharger en PDF.
Sortie:
- output/index.html : page interactive auto-suffisante
@ -42,6 +43,27 @@ VIZ_LABELS = {
'topo': 'Carte topographique IGN',
}
# Informations de colormap pour génération de légende côté navigateur.
# Utilisées par le JS pour dessiner la légende sur le canvas avant export PDF.
VIZ_COLORMAPS = {
'hillshade_multi': {'cmap': 'gray', 'diverging': False},
'slope': {'cmap': 'inferno', 'diverging': False},
'aspect': {'cmap': 'twilight', 'diverging': False},
'mslrm': {'cmap': 'seismic', 'diverging': True},
'sailore': {'cmap': 'seismic', 'diverging': True},
'positive_openness': {'cmap': 'YlOrBr', 'diverging': False},
'negative_openness': {'cmap': 'PuBu', 'diverging': False},
'svf': {'cmap': 'hot_r', 'diverging': False},
'aniso_open': {'cmap': 'seismic', 'diverging': True},
'roughness': {'cmap': 'plasma', 'diverging': False},
'wavelet': {'cmap': 'cividis', 'diverging': False},
'flow_acc': {'cmap': 'YlGn', 'diverging': False},
'solar': {'cmap': 'gray', 'diverging': False},
'anomaly': {'cmap': 'YlOrRd', 'diverging': False},
'ortho': {'cmap': 'rgb', 'diverging': False},
'topo': {'cmap': 'rgb', 'diverging': False},
}
# Visualisation par défaut pour la vignette (si disponible).
DEFAULT_VIZ = 'hillshade_multi'
@ -225,7 +247,7 @@ def _pick_display_viz(viz_keys):
def build_index(output_dir, output_format='avif'):
"""Génère la carte interactive HTML des tuiles traitées.
"""Génère la carte continue 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/.
@ -312,7 +334,7 @@ def build_index(output_dir, output_format='avif'):
def _render_html(tile_records, bbox, all_viz_keys, output_format):
"""Construit le HTML complet avec CSS et JS natifs (zoom/pan)."""
"""Construit le HTML complet avec CSS et JS (carte continue + modal PDF)."""
# 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):
@ -325,6 +347,12 @@ def _render_html(tile_records, bbox, all_viz_keys, output_format):
'vizList': ordered_viz,
}, ensure_ascii=False)
viz_meta_json = json.dumps({
k: {'label': VIZ_LABELS.get(k, k), 'cmap': VIZ_COLORMAPS.get(k, {}).get('cmap', 'terrain'),
'diverging': VIZ_COLORMAPS.get(k, {}).get('diverging', False)}
for k in 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>'
@ -337,6 +365,7 @@ def _render_html(tile_records, bbox, all_viz_keys, output_format):
return _HTML_TEMPLATE.format(
data_json=data_json,
viz_meta_json=viz_meta_json,
options_html=options_html,
n_tiles=n_tiles,
grid_w=grid_w,
@ -350,10 +379,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carte globale des tuiles LiDAR</title>
<title>Carte continue 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; }}
html, body {{ height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0a0a14; color: #e0e0e0; overflow: hidden; }}
#topbar {{
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
@ -383,31 +412,32 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
#viewport {{
position: absolute; top: 56px; left: 0; right: 0; bottom: 0;
overflow: hidden; cursor: grab; background: #0d0d1a;
overflow: hidden; cursor: grab; background: #0a0a14;
}}
#viewport.dragging {{ cursor: grabbing; }}
#viewport.click-tile {{ cursor: pointer; }}
#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;
.tile {{
position: absolute;
width: {grid_w}000px; height: {grid_h}000px;
overflow: hidden;
display: flex; align-items: center; justify-content: center;
background: #111122;
}}
.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;
.tile.has-data {{ background: #0d0d1a; }}
.tile img {{
width: 100%; height: 100%; object-fit: contain; display: block;
image-rendering: auto;
}}
.tile .coord-label {{
position: absolute; top: 2px; left: 4px;
font-size: 9px; color: rgba(224,224,224,0.4);
pointer-events: none; user-select: none;
}}
.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;
@ -420,12 +450,59 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
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;
}}
/* --- Modal overlay --- */
#modal {{
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
z-index: 200; background: rgba(0,0,0,0.75);
align-items: center; justify-content: center;
}}
#modal.active {{ display: flex; }}
#modal-content {{
position: relative; max-width: 90vw; max-height: 90vh;
background: #16213e; border-radius: 8px; overflow: hidden;
box-shadow: 0 4px 24px rgba(0,0,0,0.6);
display: flex; flex-direction: column;
}}
#modal-header {{
padding: 12px 16px; display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid #0f3460;
}}
#modal-header h2 {{ font-size: 15px; color: #e94560; }}
#modal-close {{
background: none; border: none; color: #e0e0e0; font-size: 22px;
cursor: pointer; padding: 4px 8px;
}}
#modal-close:hover {{ color: #e94560; }}
#modal-body {{
display: flex; flex-direction: row; padding: 12px; gap: 12px;
overflow: auto; max-height: calc(90vh - 80px);
}}
#modal-canvas {{ flex: 1; min-width: 0; }}
#modal-canvas canvas {{
width: 100%; height: auto; display: block; border-radius: 4px;
}}
#modal-actions {{
display: flex; flex-direction: column; gap: 8px; padding: 12px 16px;
border-top: 1px solid #0f3460;
}}
#modal-actions button {{
background: #0f3460; color: #e0e0e0; border: 1px solid #1a4a7a;
padding: 8px 16px; border-radius: 4px; font-size: 13px; cursor: pointer;
transition: background 0.15s;
}}
#modal-actions button:hover {{ background: #1a4a7a; border-color: #e94560; }}
#modal-actions button.primary {{ background: #e94560; border-color: #e94560; }}
#modal-actions button.primary:hover {{ background: #c73652; }}
</style>
</head>
<body>
<div id="topbar">
<h1>Carte globale LiDAR</h1>
<h1>Carte continue LiDAR</h1>
<span class="stat" id="statTiles">{n_tiles} tuile(s) • {grid_w}×{grid_h} km</span>
<label>Visualisation :</label>
<select id="vizSelect">
@ -446,14 +523,32 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<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
Clic sur tuile : ouvrir avec légende<br>
Double-clic : zoom rapide
</div>
<!-- Modal pour affichage avec légende -->
<div id="modal">
<div id="modal-content">
<div id="modal-header">
<h2 id="modal-title">Titre</h2>
<button id="modal-close">&times;</button>
</div>
<div id="modal-body">
<div id="modal-canvas"><canvas id="legendCanvas"></canvas></div>
</div>
<div id="modal-actions">
<button id="downloadPdf" class="primary">Télécharger PDF</button>
<button id="downloadFull">Télécharger image complète</button>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<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 VIZ_META = {viz_meta_json};
const CELL_PX = 256; // taille d'une cellule 1×1 km en pixels
const bbox = DATA.bbox;
const gridCols = bbox.max_col - bbox.min_col + 1;
const gridRows = bbox.max_row - bbox.min_row + 1;
@ -463,6 +558,7 @@ const viewport = document.getElementById('viewport');
const zoomInd = document.getElementById('zoomIndicator');
const vizSelect = document.getElementById('vizSelect');
// Dimensions de la grille en px
grid.style.width = (gridCols * CELL_PX) + 'px';
grid.style.height = (gridRows * CELL_PX) + 'px';
@ -472,7 +568,7 @@ for (const t of DATA.tiles) {{
tileMap[t.col + ',' + t.row] = t;
}}
// --- Construction de la grille (toutes les cellules, y compris vides) ---
// --- Construction de la grille ---
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)
@ -481,64 +577,65 @@ 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';
const div = document.createElement('div');
div.className = 'tile' + (tile ? ' has-data' : '');
div.style.left = colToX(c) + 'px';
div.style.top = rowToY(r) + 'px';
div.style.width = CELL_PX + 'px';
div.style.height = CELL_PX + 'px';
div.dataset.col = c;
div.dataset.row = r;
if (tile) {{
cell.target = '_blank';
cell.title = tile.name;
const img = document.createElement('img');
div.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);
div.appendChild(img);
const label = document.createElement('span');
label.className = 'coord-label';
label.textContent = `${{c}},${{r}}`;
div.appendChild(label);
// Click handler for modal
div.addEventListener('click', (e) => {{
if (isDragging) return;
openModal(c, r);
}});
}}
fragment.appendChild(cell);
fragment.appendChild(div);
}}
}}
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
const tiles = grid.querySelectorAll('.tile.has-data');
tiles.forEach(tile => {{
const col = parseInt(tile.dataset.col);
const row = parseInt(tile.dataset.row);
const data = tileMap[col + ',' + row];
if (!data) return;
const img = tile.querySelector('img');
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;
if (!data.viz[chosen]) chosen = data.display_viz;
if (!data.viz[chosen]) chosen = Object.keys(data.viz)[0];
const v = data.viz[chosen];
if (img) {{ img.src = v.thumb; }}
tile._fullSrc = v.full; // store for download
tile._vizKey = chosen;
}});
}}
// Load initial viz
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;
let dragThreshold = 3; // px to distinguish click from drag
let dragDistance = 0;
function applyTransform() {{
grid.style.transform = `translate(${{panX}}px, ${{panY}}px) scale(${{zoom}})`;
@ -550,10 +647,8 @@ function clampPan() {{
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;
}}
@ -566,9 +661,8 @@ function resetView() {{
}}
function zoomAt(factor, cx, cy) {{
const newZoom = Math.max(0.2, Math.min(20, zoom * factor));
const newZoom = Math.max(0.1, Math.min(50, 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;
@ -584,8 +678,8 @@ viewport.addEventListener('wheel', (e) => {{
}}, {{ passive: false }});
viewport.addEventListener('mousedown', (e) => {{
if (e.target.closest('a.has-tile')) return; // ne déplace pas si clic sur tuile
isDragging = true;
dragDistance = 0;
viewport.classList.add('dragging');
dragStartX = e.clientX;
dragStartY = e.clientY;
@ -595,8 +689,11 @@ viewport.addEventListener('mousedown', (e) => {{
window.addEventListener('mousemove', (e) => {{
if (!isDragging) return;
panX = startPanX + (e.clientX - dragStartX);
panY = startPanY + (e.clientY - dragStartY);
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
dragDistance = Math.sqrt(dx * dx + dy * dy);
panX = startPanX + dx;
panY = startPanY + dy;
clampPan();
applyTransform();
}});
@ -607,7 +704,6 @@ window.addEventListener('mouseup', () => {{
}});
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);
}});
@ -619,12 +715,207 @@ 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();
// --- Modal avec légende ---
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modal-title');
const modalClose = document.getElementById('modal-close');
const canvas = document.getElementById('legendCanvas');
const ctx = canvas.getContext('2d');
let currentTileData = null;
let currentVizKey = null;
function openModal(col, row) {{
const key = col + ',' + row;
const tile = tileMap[key];
if (!tile) return;
const vizKey = vizSelect.value;
let chosen = tile.viz[vizKey] ? vizKey : tile.display_viz;
if (!tile.viz[chosen]) chosen = Object.keys(tile.viz)[0];
currentTileData = tile;
currentVizKey = chosen;
const meta = VIZ_META[chosen] || {{ label: chosen, cmap: 'terrain', diverging: false }};
modalTitle.textContent = `${{meta.label}} — ${{tile.name}}`;
const v = tile.viz[chosen];
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {{
drawWithLegend(img, chosen, tile, meta);
modal.classList.add('active');
}};
img.src = v.thumb;
}}
function closeModal() {{
modal.classList.remove('active');
}}
modalClose.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => {{ if (e.target === modal) closeModal(); }});
document.addEventListener('keydown', (e) => {{ if (e.key === 'Escape') closeModal(); }});
// --- Dessin de la légende sur canvas ---
function drawWithLegend(img, vizKey, tile, meta) {{
// Canvas size: image width + legend bar
const imgW = img.width;
const imgH = img.height;
const legendW = 40;
const padding = 60;
const footerH = 80;
canvas.width = imgW + legendW + 40;
canvas.height = imgH + padding + footerH;
// Draw image
ctx.drawImage(img, 0, padding, imgW, imgH);
// Draw legend bar
const legendX = imgW + 20;
const grad = ctx.createLinearGradient(legendX, padding, legendX, padding + imgH);
// Generate gradient colors based on colormap
const colors = getColormapColors(meta.cmap, meta.diverging);
for (let i = 0; i < colors.length; i++) {{
const t = i / (colors.length - 1);
grad.addColorStop(t, colors[i]);
}}
ctx.fillStyle = grad;
ctx.fillRect(legendX, padding, legendW, imgH);
ctx.strokeStyle = '#555';
ctx.lineWidth = 1;
ctx.strokeRect(legendX, padding, legendW, imgH);
// Legend labels
ctx.fillStyle = '#e0e0e0';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Max', legendX + legendW + 6, padding + 12);
ctx.fillText('Min', legendX + legendW + 6, padding + imgH - 2);
// Title
ctx.fillStyle = '#e94560';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(meta.label, canvas.width / 2, 20);
// Footer info
ctx.fillStyle = '#a0a0c0';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
const infoLine = `GPS: ${{tile.col}},${{tile.row}} (Lambert 93) | Résolution: ${{tile.resolution}}m/px | 1×1 km`;
ctx.fillText(infoLine, canvas.width / 2, canvas.height - 20);
}}
function getColormapColors(cmap, diverging) {{
const N = 64;
const colors = [];
for (let i = 0; i < N; i++) {{
const t = i / (N - 1);
colors.push(cmapColor(cmap, t, diverging));
}}
return colors;
}}
function cmapColor(cmap, t, diverging) {{
// Approximate matplotlib colormaps
const maps = {{
'seismic': (t) => {{
const c = diverging ? t : t;
if (c < 0.5) return lerpColor([100, 149, 237], [255, 255, 255], c * 2);
return lerpColor([255, 255, 255], [220, 50, 50], (c - 0.5) * 2);
}},
'gray': (t) => {{ const v = Math.round(t * 255); return [v, v, v]; }},
'inferno': (t) => {{
if (t < 0.25) return lerpColor([17, 100, 36], [117, 74, 14], t * 4);
if (t < 0.5) return lerpColor([117, 74, 14], [217, 112, 14], (t - 0.25) * 4);
if (t < 0.75) return lerpColor([217, 112, 14], [244, 206, 13], (t - 0.5) * 4);
return lerpColor([244, 206, 13], [255, 255, 255], (t - 0.75) * 4);
}},
'YlOrBr': (t) => {{
if (t < 0.33) return lerpColor([255, 255, 180], [255, 183, 77], t * 3);
if (t < 0.66) return lerpColor([255, 183, 77], [204, 123, 5], (t - 0.33) * 3);
return lerpColor([204, 123, 5], [103, 57, 0], (t - 0.66) * 3);
}},
'PuBu': (t) => {{
if (t < 0.5) return lerpColor([161, 211, 249], [60, 104, 180], t * 2);
return lerpColor([60, 104, 180], [33, 40, 102], (t - 0.5) * 2);
}},
'hot_r': (t) => {{ return lerpColor([0, 0, 0], [255, 255, 255], 1 - t); }},
'plasma': (t) => {{
if (t < 0.33) return lerpColor([13, 8, 92], [75, 14, 160], t * 3);
if (t < 0.66) return lerpColor([75, 14, 160], [150, 50, 80], (t - 0.33) * 3);
return lerpColor([150, 50, 80], [255, 200, 50], (t - 0.66) * 3);
}},
'cividis': (t) => {{
if (t < 0.5) return lerpColor([0, 0, 130], [20, 140, 160], t * 2);
return lerpColor([20, 140, 160], [255, 230, 50], (t - 0.5) * 2);
}},
'YlGn': (t) => {{
if (t < 0.5) return lerpColor([255, 255, 150], [150, 220, 100], t * 2);
return lerpColor([150, 220, 100], [0, 100, 0], (t - 0.5) * 2);
}},
'YlOrRd': (t) => {{
if (t < 0.33) return lerpColor([255, 255, 200], [255, 200, 100], t * 3);
if (t < 0.66) return lerpColor([255, 200, 100], [230, 100, 50], (t - 0.33) * 3);
return lerpColor([230, 100, 50], [150, 0, 0], (t - 0.66) * 3);
}},
'twilight': (t) => {{
const hue = (t * 360 + 180) % 360;
return hslToRgb(hue, 70, 50);
}},
'rgb': (t) => {{ return [100, 180, 100]; }},
}};
const fn = maps[cmap] || maps['gray'];
return fn(t);
}}
function lerpColor(a, b, t) {{
t = Math.max(0, Math.min(1, t));
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}}
function hslToRgb(h, s, l) {{
s /= 100; l /= 100;
const k = (n) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}}
// --- PDF download ---
document.getElementById('downloadPdf').addEventListener('click', () => {{
if (!currentTileData || !currentVizKey) return;
const {{ jsPDF }} = window.jspdf;
const orientation = canvas.width > canvas.height ? 'landscape' : 'portrait';
const pdf = new jsPDF(orientation, 'px', [canvas.width, canvas.height]);
pdf.addImage(canvas, 'PNG', 0, 0, canvas.width, canvas.height);
const vizKey = currentVizKey;
const name = currentTileData.name;
pdf.save(`${{name}}_${{vizKey}}.pdf`);
}});
document.getElementById('downloadFull').addEventListener('click', () => {{
if (!currentTileData || !currentVizKey) return;
const tile = grid.querySelector(`.tile[data-col="${{currentTileData.col}}"][data-row="${{currentTileData.row}}"]`);
if (tile && tile._fullSrc) {{
const a = document.createElement('a');
a.href = tile._fullSrc;
a.download = tile._fullSrc.split('/').pop();
a.click();
}}
}});
</script>
</body>

View File

@ -68,7 +68,7 @@ from .visualizations import (
)
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
from .rendering import tif_to_crop
# Ordered list of visualization steps.
@ -304,14 +304,9 @@ class LidarArchaeoPipeline:
# Convert to output format (only newly generated TIFs, not skipped ones)
fmt_label = self.output_format.upper()
logger.info(f" Conversion images {fmt_label}:")
source_info = {
'method': self.ground_method,
'date': datetime.now().strftime('%Y-%m-%d'),
'basename': basename,
}
for name, tif_file in vis_results.items():
if tif_file and isinstance(tif_file, Path) and tif_file.suffix == '.tif' and tif_file.exists():
img_file = tif_to_png(tif_file, file_vis_dir, resolution, keep_tif=self.keep_tif, source_info=source_info, quality=self.quality, output_format=self.output_format)
img_file = tif_to_crop(tif_file, file_vis_dir, resolution, keep_tif=self.keep_tif, quality=self.quality, output_format=self.output_format)
if img_file:
logger.info(f" ✓ {img_file.name}")

View File

@ -780,6 +780,70 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
return None
def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=98, output_format='avif'):
"""Convert GeoTIFF to a cropped visualization image (no legend, no overlay).
Applies colormap and saves the image as a pure 1×1 km square.
Used for grid/map display where images must tile seamlessly.
Args:
tif_file: Path to input GeoTIFF.
vis_dir: Output directory for the image file.
resolution: Grid resolution in m/px.
keep_tif: If True, keep the source TIFF after conversion.
quality: Image quality (1-100). Use 100 for lossless.
output_format: Output format ('webp' or 'avif').
Returns:
Path to output image file, or None on failure.
"""
if not tif_file or not tif_file.exists():
return None
ext = 'avif' if output_format == 'avif' else 'webp'
output_file = vis_dir / f"{tif_file.stem}.{ext}"
try:
with rasterio.open(tif_file) as src:
is_rgb = src.count >= 3 and any(k in str(tif_file) for k in ('ortho', 'topo'))
if is_rgb:
data = src.read([1, 2, 3])
data = np.moveaxis(data, 0, -1)
else:
data = src.read(1)
# Apply colormap normalization
data, cmap_name, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file)
# Convert to RGB using colormap
if is_rgb_result:
# RGB images are already in RGB
rgb_data = (data * 255).astype(np.uint8)
else:
# Normalize data to 0-1 range for colormap
cmap = plt.get_cmap(cmap_name)
rgb_float = cmap(data.clip(0, 1))
rgb_data = (rgb_float[:, :, :3] * 255).astype(np.uint8)
# Save as AVIF/WebP
img = PILImage.fromarray(rgb_data)
pil_format = 'AVIF' if output_format == 'avif' else 'WEBP'
if quality >= 100:
img.save(str(output_file), format=pil_format, lossless=True)
else:
img.save(str(output_file), format=pil_format, quality=quality)
# Delete source TIFF (unless --keep-tif)
if not keep_tif:
tif_file.unlink(missing_ok=True)
return output_file
except Exception as e:
logger.error(f" Erreur conversion crop {ext.upper()}: {e}", exc_info=True)
return None
def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
"""Generate A3 PDF report for a LiDAR file with all visualizations.

View File

@ -142,7 +142,7 @@ def test_build_index_generates_html(tmp_path):
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 "Carte continue 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