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 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 1×1 km traitées, affichées en carte continue sans bordure, avec zoom/pan fluide
et sélecteur de visualisation. Permet de choisir rapidement la tuile à examiner. 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: Sortie:
- output/index.html : page interactive auto-suffisante - output/index.html : page interactive auto-suffisante
@ -42,6 +43,27 @@ VIZ_LABELS = {
'topo': 'Carte topographique IGN', '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). # Visualisation par défaut pour la vignette (si disponible).
DEFAULT_VIZ = 'hillshade_multi' DEFAULT_VIZ = 'hillshade_multi'
@ -225,7 +247,7 @@ def _pick_display_viz(viz_keys):
def build_index(output_dir, output_format='avif'): 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 Scanne output_dir/visualisations/, génère les vignettes JPEG, puis écrit
output_dir/index.html (auto-suffisant) + output_dir/index_thumbs/. 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): 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). # 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] ordered_viz = [v for v in _VIZ_FALLBACK_ORDER if v in all_viz_keys]
for v in sorted(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, 'vizList': ordered_viz,
}, ensure_ascii=False) }, 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( options_html = '\n'.join(
f' <option value="{v}"{" selected" if v == DEFAULT_VIZ else ""}>' f' <option value="{v}"{" selected" if v == DEFAULT_VIZ else ""}>'
f'{VIZ_LABELS.get(v, v)}</option>' 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( return _HTML_TEMPLATE.format(
data_json=data_json, data_json=data_json,
viz_meta_json=viz_meta_json,
options_html=options_html, options_html=options_html,
n_tiles=n_tiles, n_tiles=n_tiles,
grid_w=grid_w, grid_w=grid_w,
@ -350,10 +379,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carte globale des tuiles LiDAR</title> <title>Carte continue LiDAR</title>
<style> <style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }} * {{ 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 {{ #topbar {{
position: fixed; top: 0; left: 0; right: 0; z-index: 100; position: fixed; top: 0; left: 0; right: 0; z-index: 100;
@ -383,31 +412,32 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
#viewport {{ #viewport {{
position: absolute; top: 56px; left: 0; right: 0; bottom: 0; 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.dragging {{ cursor: grabbing; }}
#viewport.click-tile {{ cursor: pointer; }}
#grid {{ #grid {{
position: absolute; transform-origin: 0 0; position: absolute; transform-origin: 0 0;
/* Dimensions fixées en JS selon le nombre de cellules */
}} }}
.cell {{ .tile {{
position: absolute; border: 1px solid #2a2a4a; background: #111122; position: absolute;
overflow: hidden; display: block; text-decoration: none; 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; }} .tile.has-data {{ background: #0d0d1a; }}
.cell.has-tile:hover {{ border-color: #e94560; box-shadow: 0 0 12px rgba(233,69,96,0.5); z-index: 10; }} .tile img {{
.cell img {{ width: 100%; height: 100%; object-fit: cover; display: block; }} width: 100%; height: 100%; object-fit: contain; display: block;
.cell .label {{ image-rendering: auto;
position: absolute; bottom: 0; left: 0; right: 0; }}
background: rgba(0,0,0,0.65); color: #e0e0e0; font-size: 9px; .tile .coord-label {{
padding: 2px 4px; text-align: center; white-space: nowrap; overflow: hidden; position: absolute; top: 2px; left: 4px;
text-overflow: ellipsis; opacity: 0; transition: opacity 0.15s; 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 {{ #zoomIndicator {{
position: fixed; bottom: 12px; left: 12px; z-index: 100; 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; 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; 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> </style>
</head> </head>
<body> <body>
<div id="topbar"> <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> <span class="stat" id="statTiles">{n_tiles} tuile(s) • {grid_w}×{grid_h} km</span>
<label>Visualisation :</label> <label>Visualisation :</label>
<select id="vizSelect"> <select id="vizSelect">
@ -446,14 +523,32 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<div id="zoomIndicator">Zoom : 100%</div> <div id="zoomIndicator">Zoom : 100%</div>
<div id="hint"> <div id="hint">
Molette : zoom • Clic-glisser : déplacer<br> 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> </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> <script>
const DATA = {data_json}; const DATA = {data_json};
const CELL_PX = 200; // taille d'une cellule 1×1 km (en px, à zoom 1) const VIZ_META = {viz_meta_json};
const GAP_PX = 2; const CELL_PX = 256; // taille d'une cellule 1×1 km en pixels
const bbox = DATA.bbox; const bbox = DATA.bbox;
const gridCols = bbox.max_col - bbox.min_col + 1; const gridCols = bbox.max_col - bbox.min_col + 1;
const gridRows = bbox.max_row - bbox.min_row + 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 zoomInd = document.getElementById('zoomIndicator');
const vizSelect = document.getElementById('vizSelect'); const vizSelect = document.getElementById('vizSelect');
// Dimensions de la grille en px
grid.style.width = (gridCols * CELL_PX) + 'px'; grid.style.width = (gridCols * CELL_PX) + 'px';
grid.style.height = (gridRows * 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; 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 colToX(col) {{ return (col - bbox.min_col) * CELL_PX; }}
function rowToY(row) {{ return (bbox.max_row - row) * CELL_PX; }} // Y inversé (Nord en haut) 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++) {{ for (let c = bbox.min_col; c <= bbox.max_col; c++) {{
const key = c + ',' + r; const key = c + ',' + r;
const tile = tileMap[key]; const tile = tileMap[key];
const cell = document.createElement('a'); const div = document.createElement('div');
cell.className = 'cell' + (tile ? ' has-tile' : ' empty'); div.className = 'tile' + (tile ? ' has-data' : '');
cell.style.left = colToX(c) + 'px'; div.style.left = colToX(c) + 'px';
cell.style.top = rowToY(r) + 'px'; div.style.top = rowToY(r) + 'px';
cell.style.width = (CELL_PX - GAP_PX) + 'px'; div.style.width = CELL_PX + 'px';
cell.style.height = (CELL_PX - GAP_PX) + 'px'; div.style.height = CELL_PX + 'px';
div.dataset.col = c;
div.dataset.row = r;
if (tile) {{ if (tile) {{
cell.target = '_blank'; div.title = tile.name;
cell.title = tile.name; const img = document.createElement('img';
const img = document.createElement('img');
img.loading = 'lazy'; img.loading = 'lazy';
cell.appendChild(img); div.appendChild(img);
const label = document.createElement('div');
label.className = 'label'; const label = document.createElement('span');
label.textContent = tile.name; label.className = 'coord-label';
cell.appendChild(label); label.textContent = `${{c}},${{r}}`;
}} else {{ div.appendChild(label);
const coords = document.createElement('div');
coords.className = 'coords'; // Click handler for modal
coords.textContent = c + '\\n' + r; div.addEventListener('click', (e) => {{
coords.style.whiteSpace = 'pre'; if (isDragging) return;
cell.appendChild(coords); openModal(c, r);
}});
}} }}
fragment.appendChild(cell); fragment.appendChild(div);
}} }}
}} }}
grid.appendChild(fragment); grid.appendChild(fragment);
// --- Gestion de la visualisation affichée --- // --- Gestion de la visualisation affichée ---
function applyViz(vizKey) {{ function applyViz(vizKey) {{
const cells = grid.querySelectorAll('.cell.has-tile'); const tiles = grid.querySelectorAll('.tile.has-data');
cells.forEach(cell => {{ tiles.forEach(tile => {{
// Retrouve la tuile via la position const col = parseInt(tile.dataset.col);
const left = parseFloat(cell.style.left); const row = parseInt(tile.dataset.row);
const top = parseFloat(cell.style.top); const data = tileMap[col + ',' + row];
const col = bbox.min_col + Math.round(left / CELL_PX); if (!data) return;
const row = bbox.max_row - Math.round(top / CELL_PX); const img = tile.querySelector('img');
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; let chosen = vizKey;
if (!tile.viz[chosen]) chosen = tile.display_viz; if (!data.viz[chosen]) chosen = data.display_viz;
if (!tile.viz[chosen]) chosen = Object.keys(tile.viz)[0]; if (!data.viz[chosen]) chosen = Object.keys(data.viz)[0];
const v = tile.viz[chosen]; const v = data.viz[chosen];
if (img) {{ if (img) {{ img.src = v.thumb; }}
img.src = v.thumb; tile._fullSrc = v.full; // store for download
}} tile._vizKey = chosen;
cell.href = v.full;
}}); }});
}} }}
// Load initial viz
applyViz(vizSelect.value); applyViz(vizSelect.value);
vizSelect.addEventListener('change', () => applyViz(vizSelect.value)); vizSelect.addEventListener('change', () => applyViz(vizSelect.value));
// --- Zoom / Pan --- // --- Zoom / Pan ---
let zoom = 1, panX = 0, panY = 0; let zoom = 1, panX = 0, panY = 0;
let isDragging = false, dragStartX = 0, dragStartY = 0, startPanX = 0, startPanY = 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() {{ function applyTransform() {{
grid.style.transform = `translate(${{panX}}px, ${{panY}}px) scale(${{zoom}})`; grid.style.transform = `translate(${{panX}}px, ${{panY}}px) scale(${{zoom}})`;
@ -550,10 +647,8 @@ function clampPan() {{
const vpH = viewport.clientHeight; const vpH = viewport.clientHeight;
const gw = gridCols * CELL_PX * zoom; const gw = gridCols * CELL_PX * zoom;
const gh = gridRows * 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)); 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)); 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 (gw < vpW) panX = (vpW - gw) / 2;
if (gh < vpH) panY = (vpH - gh) / 2; if (gh < vpH) panY = (vpH - gh) / 2;
}} }}
@ -566,9 +661,8 @@ function resetView() {{
}} }}
function zoomAt(factor, cx, cy) {{ 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; const realFactor = newZoom / zoom;
// Garde le point (cx,cy) fixe dans la grille
panX = cx - (cx - panX) * realFactor; panX = cx - (cx - panX) * realFactor;
panY = cy - (cy - panY) * realFactor; panY = cy - (cy - panY) * realFactor;
zoom = newZoom; zoom = newZoom;
@ -584,8 +678,8 @@ viewport.addEventListener('wheel', (e) => {{
}}, {{ passive: false }}); }}, {{ passive: false }});
viewport.addEventListener('mousedown', (e) => {{ viewport.addEventListener('mousedown', (e) => {{
if (e.target.closest('a.has-tile')) return; // ne déplace pas si clic sur tuile
isDragging = true; isDragging = true;
dragDistance = 0;
viewport.classList.add('dragging'); viewport.classList.add('dragging');
dragStartX = e.clientX; dragStartX = e.clientX;
dragStartY = e.clientY; dragStartY = e.clientY;
@ -595,8 +689,11 @@ viewport.addEventListener('mousedown', (e) => {{
window.addEventListener('mousemove', (e) => {{ window.addEventListener('mousemove', (e) => {{
if (!isDragging) return; if (!isDragging) return;
panX = startPanX + (e.clientX - dragStartX); const dx = e.clientX - dragStartX;
panY = startPanY + (e.clientY - dragStartY); const dy = e.clientY - dragStartY;
dragDistance = Math.sqrt(dx * dx + dy * dy);
panX = startPanX + dx;
panY = startPanY + dy;
clampPan(); clampPan();
applyTransform(); applyTransform();
}}); }});
@ -607,7 +704,6 @@ window.addEventListener('mouseup', () => {{
}}); }});
viewport.addEventListener('dblclick', (e) => {{ viewport.addEventListener('dblclick', (e) => {{
if (e.target.closest('a.has-tile')) return;
const rect = viewport.getBoundingClientRect(); const rect = viewport.getBoundingClientRect();
zoomAt(2, e.clientX - rect.left, e.clientY - rect.top); 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); zoomAt(1 / 1.3, viewport.clientWidth / 2, viewport.clientHeight / 2);
}}); }});
document.getElementById('zoomReset').addEventListener('click', resetView); document.getElementById('zoomReset').addEventListener('click', resetView);
// Recalcule au redimensionnement de la fenêtre
window.addEventListener('resize', () => {{ clampPan(); applyTransform(); }}); window.addEventListener('resize', () => {{ clampPan(); applyTransform(); }});
// Vue initiale centrée
resetView(); 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> </script>
</body> </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 .gpu import gpu_cleanup, num_gpus, available_gpu_ids, restrict_gpus, safe_gpu_call
from .ign import generate_ign_overlay from .ign import generate_ign_overlay
from .rendering import tif_to_png from .rendering import tif_to_crop
# Ordered list of visualization steps. # Ordered list of visualization steps.
@ -304,14 +304,9 @@ class LidarArchaeoPipeline:
# Convert to output format (only newly generated TIFs, not skipped ones) # Convert to output format (only newly generated TIFs, not skipped ones)
fmt_label = self.output_format.upper() fmt_label = self.output_format.upper()
logger.info(f" Conversion images {fmt_label}:") 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(): 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(): 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: if img_file:
logger.info(f" ✓ {img_file.name}") 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 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): def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
"""Generate A3 PDF report for a LiDAR file with all visualizations. """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') content = html_path.read_text(encoding='utf-8')
# Vérifie la présence des éléments clés # 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_1000_6881" in content
assert "LHD_FXX_1001_6881" in content assert "LHD_FXX_1001_6881" in content
# Vérifie que le JSON intégré est valide # Vérifie que le JSON intégré est valide