From 3a374a3ab431a5d0049855849184391623a0b9ad Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sat, 19 Sep 2026 21:26:13 +0200 Subject: [PATCH] =?UTF-8?q?AVIF=20q60=20par=20d=C3=A9faut=20et=20s=C3=A9le?= =?UTF-8?q?cteur=20de=20mode=20de=20fusion=20par=20couche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- lidar_pipeline/cli.py | 4 +- lidar_pipeline/index.py | 96 ++++++++++++++++++++++++++++++++++--- lidar_pipeline/pipeline.py | 4 +- lidar_pipeline/rendering.py | 6 +-- scripts/transcode_q60.py | 54 +++++++++++++++++++++ 6 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 scripts/transcode_q60.py diff --git a/AGENTS.md b/AGENTS.md index 23d84ec..bb44ca7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ - **Return `None` on failure, never raise**: `dtm.py`, `visualizations.py`, and `ign.py` all return `None` to let the pipeline continue. Raising aborts the entire file. - **Logger is always `logging.getLogger("lidar")`**, never `__name__`. All modules route through this single logger so worker processes can configure it. - **Filename special-cases** in `_expected_output_path()`: `pos_open` → `positive_openness`, `neg_open` → `negative_openness`, `hillshade` → `hillshade_multi`. -- **Default output is AVIF**, not WebP. Use `--format webp` for WebP. Quality default is 98. +- **Default output is AVIF**, not WebP. Use `--format webp` for WebP. Quality default is 60 (visually lossless on smooth color ramps, ~÷3 vs q98). - **Calage vertical des faisceaux de vol** : chaque tuile mélange plusieurs passes (1-2 `PointSourceId` par passe) parfois biaisées verticalement de quelques cm (±2,5 cm mesurés sur 1000_6882). `create_dtm_fast` mesure l'offset robuste de chaque faisceau (points sol, maille 1 m, surface médiane itérée 3×) et retranche les offsets ≥ 0,5 cm (`STRIP_ALIGN_THRESHOLD` dans `dtm.py`) avant rastérisation. Offsets calculés **par tuile** (ils dérivent le long d'une ligne de vol : jamais de table globale), mémoïsés par LAS sol, consignés dans `DTM/*_dtm*_stripalign.json` (sidecar de cache : absent, ou version/seuil/paramètres différents ⇒ régénération du DTM). Désactivable : `--no-strip-align`. - **Gigue intra-faisceau (2ᵉ passe du calage)** : les lignes de balayage successives d'une MÊME passe peuvent être décalées verticalement de façon aléatoire (vibration capteur / bruit haute fréquence de trajectoire) — un offset constant par faisceau n'y suffit pas. `_strip_jitter_offsets` découpe chaque faisceau en fenêtres de temps GPS (`STRIP_JITTER_BIN` = 0,1 s, origine de temps propre à chaque faisceau), mesure l'offset robuste de chaque fenêtre contre la surface médiane des AUTRES faisceaux (maille 1 m partagée, ≥ `STRIP_JITTER_MIN_CELLS` = 40 cellules), lisse la série (médiane glissante `STRIP_JITTER_SMOOTH` = 5 fenêtres), la borne à ± `STRIP_JITTER_MAX` (10 cm) puis l'interpole au temps GPS de chaque point (`_apply_strip_jitter`) ; en recouvrement à deux faisceaux, chacun reçoit une série (chacun absorbe sa part). Requiert la dimension `gps_time` (silencieusement ignorée sinon). Sidecar version 2 (séries dans `jitter`), couverte par `--no-strip-align`. - **Openness sous-échantillonnée** : `generate_openness` calcule le lancé de rayons (l'étape la plus coûteuse : 532 s/tuile à 0,2 m sur CPU) sur une grille décimée par blocs (`OPENNESS_DOWNSAMPLE = 2` : max par bloc en positive, min en négative — préserve les reliefs qui bornent l'horizon) puis rééchantillonne en bilinéaire. Coût ÷ facteur³ : 532 s → 40 s (×13). Signal archéologique préservé (corr. 0,93 après lissage) ; la texture de bruit sub-métrique disparaît. `--openness-downsample 1` = pleine résolution. SVF et openness anisotrope ne sont PAS concernés. diff --git a/lidar_pipeline/cli.py b/lidar_pipeline/cli.py index 960c68d..585ffe5 100644 --- a/lidar_pipeline/cli.py +++ b/lidar_pipeline/cli.py @@ -201,8 +201,8 @@ def main(): parser.add_argument( "--quality", type=int, - default=98, - help="Qualité image (1-100, défaut: 98). Utilisez 100 pour lossless." + default=60, + help="Qualité image (1-100, défaut: 60). Utilisez 100 pour lossless." ) parser.add_argument( "--lossless", diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index 1d07fb6..bc0b347 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -212,6 +212,12 @@ DEFAULT_LAYERS = ('positive_openness', 'aspect') # Opacité par défaut des couches allumées (0–1) ; les absentes restent à 1. DEFAULT_OPACITY = {'aspect': 0.6} +# Mode de fusion (mix-blend-mode CSS) par couche : les couches d'ombrage en +# niveaux de gris fondent en « multiply » — elles assombrissent le relief sans +# délaver la rampe de couleurs en dessous, contrairement à la transparence. +# Les couches absentes restent en « normal » (simple transparence). +DEFAULT_BLEND = {'hillshade_multi': 'multiply', 'solar': 'multiply', 'svf': 'multiply'} + # Couche « principale » (mise en avant ; index_tiles.json + affichage en # direct de la webapp). DEFAULT_VIZ = DEFAULT_LAYERS[0] @@ -1123,6 +1129,7 @@ def build_index(output_dir, output_format='avif'): 'stats': {'default_viz': default_viz, 'default_layers': default_layers_present(all_viz_keys), 'default_opacity': dict(DEFAULT_OPACITY), + 'default_blend': dict(DEFAULT_BLEND), 'n_tiles': len(display_tiles)}, }, ensure_ascii=False), encoding='utf-8') @@ -1239,6 +1246,7 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format): {'default_viz': default_viz, 'default_layers': default_layers_present(all_viz_keys), 'default_opacity': dict(DEFAULT_OPACITY), + 'default_blend': dict(DEFAULT_BLEND), 'n_tiles': len(tiles)}, ) @@ -1589,6 +1597,15 @@ select, input[type="checkbox"] { accent-color: var(--accent); } padding: 5px 6px; border-radius: 6px; user-select: none; } +/* Ligne allumée : un sélecteur de mode de fusion s'insère avant le slider + (les lignes éteintes gardent la grille large pour un nom lisible). */ +.layer-row.on { grid-template-columns: 16px 24px minmax(0, 1fr) 64px 62px 24px; } +.layer-blend { + font-size: 10.5px; color: var(--muted); background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--panel-border); border-radius: 4px; + padding: 2px 2px; min-width: 0; width: 100%; height: 22px; +} +.layer-blend:hover { color: var(--text); } #osmRow { grid-template-columns: 24px minmax(0, 1fr) 80px 30px; } .layer-row:hover { background: rgba(255, 255, 255, 0.05); } .layer-row.dragging { opacity: 0.45; } @@ -1667,6 +1684,10 @@ input[type="range"]::-moz-range-thumb { /* Fond transparent obligatoire : l'opacité des couches doit laisser voir les couches du dessous et le fond de carte, pas un rectangle noir. */ background: transparent; + /* Les modes de fusion (mix-blend-mode) des se cantonnent à la pile de + couches de la tuile : sans isolation, « multiply » assombrirait aussi le + fond de carte OSM sous les zones sans données. */ + isolation: isolate; /* Layout et peinture des tuiles hors écran skippés par le navigateur */ content-visibility: auto; } @@ -1998,8 +2019,28 @@ L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo // État des couches (persisté en localStorage) // ============================================================ // order : pile de visualisations, index 0 = bas de pile. +// blend : mode de fusion CSS (mix-blend-mode) de chaque couche — les couches +// se fondent entre elles au sein de la tuile (isolation: isolate sur .ltile) +// au lieu de simplement se superposer en transparence. const LS_KEY = 'lidarLayers_v1'; +// Modes de fusion proposés : [valeur CSS, libellé]. « multiply » assombrit le +// relief (ombrage par-dessus une rampe), « overlay » / « soft-light » creusent +// le contraste local, « screen » éclaircit (openness négative sur fond sombre). +const BLEND_MODES = [ + ['normal', 'Normal'], + ['multiply', 'Produit'], + ['overlay', 'Superposé'], + ['soft-light', 'Doux'], + ['screen', 'Écran'], +]; +const BLEND_KEYS = BLEND_MODES.map(m => m[0]); +const blendLabel = v => { + const m = BLEND_MODES.find(x => x[0] === v); + return m ? m[1] : 'Normal'; +}; +const validBlend = v => BLEND_KEYS.includes(v) ? v : null; + function defaultLayers() { const order = Object.keys(VIZ_META); // Couches activées par défaut : seules celles-ci sont allumées, toutes les @@ -2023,14 +2064,18 @@ function defaultLayers() { // Opacités par défaut (STATS.default_opacity) ; absentes → 1. const defOp = (STATS.default_opacity && typeof STATS.default_opacity === 'object') ? STATS.default_opacity : {}; + // Modes de fusion par défaut (STATS.default_blend) ; absents → normal. + const defBl = (STATS.default_blend && typeof STATS.default_blend === 'object') + ? STATS.default_blend : {}; const onSet = new Set(defOrder); - const on = {}, opacity = {}; + const on = {}, opacity = {}, blend = {}; for (const k of order) { on[k] = onSet.has(k); const op = Number(defOp[k]); opacity[k] = Number.isNaN(op) ? 1 : Math.min(1, Math.max(0, op)); + blend[k] = validBlend(defBl[k]) || 'normal'; } - return { order, on, opacity, osm: { on: true, opacity: 0.85, dark: true } }; + return { order, on, opacity, blend, osm: { on: true, opacity: 0.85, dark: true } }; } function loadLayers() { @@ -2045,19 +2090,28 @@ function loadLayers() { for (const k of Object.keys(VIZ_META)) { if (!order.includes(k)) order.push(k); } - const on = {}, opacity = {}; + const on = {}, opacity = {}, blend = {}; + const savedBlend = (saved.blend && typeof saved.blend === 'object') ? saved.blend : {}; for (const k of order) { const op = saved.opacity ? saved.opacity[k] : undefined; on[k] = saved.on && saved.on[k] === true; opacity[k] = (op === undefined || op === null || Number.isNaN(Number(op))) ? 1 : Math.min(1, Math.max(0, Number(op))); + blend[k] = validBlend(savedBlend[k]) || def.blend[k] || 'normal'; } - st = { order, on, opacity, osm: Object.assign({}, def.osm, saved.osm || {}) }; + st = { order, on, opacity, blend, osm: Object.assign({}, def.osm, saved.osm || {}) }; } } catch (e) { st = def; } // Lien partagé (vue + couches + transparences) : il prime sur l'état local. + // Le lien ne transporte pas le mode de fusion : repli sur les défauts. if (sharedHash) { - if (sharedHash.layers) st = sharedHash.layers; + if (sharedHash.layers) { + st = sharedHash.layers; + if (!st.blend || typeof st.blend !== 'object') st.blend = {}; + for (const k of st.order) { + if (!validBlend(st.blend[k])) st.blend[k] = def.blend[k] || 'normal'; + } + } if (sharedHash.osm) st = Object.assign({}, st, { osm: sharedHash.osm }); } return st; @@ -2216,6 +2270,7 @@ function syncTileLayers() { } img.style.zIndex = String(i + 1); img.style.opacity = String(state.layers.opacity[k]); + img.style.mixBlendMode = state.layers.blend[k] || 'normal'; }); } updateImages(); @@ -2357,11 +2412,21 @@ function renderLayerPanel() { row.draggable = !!(e.target.closest && e.target.closest('.layer-grip')); }); row.title = meta.label; + const curBlend = state.layers.blend[key] || 'normal'; + // Sélecteur de mode de fusion seulement sur les couches allumées : + // les lignes éteintes gardent la grille large (nom lisible). + const blendSel = + ''; row.innerHTML = '⠿' + '' + '' + escapeHtml(meta.label) + '' + + (state.layers.on[key] ? blendSel : '') + '' + '' + Math.round(state.layers.opacity[key] * 100) + '%'; @@ -2391,6 +2456,17 @@ function renderLayerPanel() { }); range.addEventListener('change', saveLayers); + const sel = row.querySelector('.layer-blend'); + if (sel) sel.addEventListener('change', () => { + state.layers.blend[key] = sel.value; + saveLayers(); + // Applique directement sans reconstruire le DOM + for (const entry of markers.values()) { + const img = entry.imgs[key]; + if (img) img.style.mixBlendMode = sel.value; + } + }); + row.addEventListener('dragstart', () => { dragKey = key; row.classList.add('dragging'); }); row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); row.draggable = false; }); row.addEventListener('dragover', e => e.preventDefault()); @@ -2488,7 +2564,9 @@ const BUILTIN_PRESETS = [ function presetDetail(p) { const parts = (p.on || []).filter(k => VIZ_META[k]) .map(k => (VIZ_META[k].label || k) + ' ' + - Math.round(((p.opacity && p.opacity[k] != null) ? Number(p.opacity[k]) : 1) * 100) + '%'); + Math.round(((p.opacity && p.opacity[k] != null) ? Number(p.opacity[k]) : 1) * 100) + '%' + + ((p.blend && validBlend(p.blend[k]) && p.blend[k] !== 'normal') + ? ' (' + blendLabel(p.blend[k]) + ')' : '')); if (p.osm) parts.push('Fond ' + (p.osm.on ? Math.round((p.osm.opacity != null ? Number(p.osm.opacity) : 0.85) * 100) + '%' : 'masqué')); @@ -2504,7 +2582,10 @@ function applyPreset(p) { for (const k of L.order) { const on = want.includes(k); L.on[k] = on; - if (on) L.opacity[k] = (p.opacity && k in p.opacity) ? Number(p.opacity[k]) : 1; + if (on) { + L.opacity[k] = (p.opacity && k in p.opacity) ? Number(p.opacity[k]) : 1; + if (p.blend && validBlend(p.blend[k])) L.blend[k] = p.blend[k]; + } } // Niveaux de transparence du fond de carte (visibilité, opacité, sombre) if (p.osm) { @@ -2569,6 +2650,7 @@ presetSaveEl.addEventListener('click', () => { label: name.trim(), on: enabled, opacity: Object.fromEntries(enabled.map(k => [k, state.layers.opacity[k]])), + blend: Object.fromEntries(enabled.map(k => [k, state.layers.blend[k] || 'normal'])), // Niveaux de transparence du fond de carte osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark }, }; diff --git a/lidar_pipeline/pipeline.py b/lidar_pipeline/pipeline.py index 0de7cea..04f212a 100644 --- a/lidar_pipeline/pipeline.py +++ b/lidar_pipeline/pipeline.py @@ -110,7 +110,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', ign_classes="sol", force_classify=False, keep_tif=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None, no_index=False, incremental_index=False, strip_align=True, openness_downsample=None, edge_buffer=0.0): + def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, quality=60, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None, no_index=False, incremental_index=False, strip_align=True, openness_downsample=None, edge_buffer=0.0): self.input_dir = Path(input_dir) self.output_dir = Path(output_dir) # Accept single float or comma-separated string for multi-resolution @@ -803,7 +803,7 @@ class LidarArchaeoPipeline: logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}") -def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_id=None, openness_downsample=None, edge_buffer=0.0): +def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, quality=60, only_viz=None, skip_viz=None, output_format='avif', gpu_id=None, openness_downsample=None, edge_buffer=0.0): """Standalone function for multiprocessing — creates its own pipeline instance. Each worker gets its own temp directory to avoid file conflicts. diff --git a/lidar_pipeline/rendering.py b/lidar_pipeline/rendering.py index a4dc384..85b1b5a 100644 --- a/lidar_pipeline/rendering.py +++ b/lidar_pipeline/rendering.py @@ -383,7 +383,7 @@ def _nice_scale(extent_m): return chosen, f"{chosen} m" -def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None, quality=98, output_format='avif'): +def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None, quality=60, output_format='avif'): """Convert GeoTIFF to visualization image (WebP or AVIF) with GPS coordinates, legend, and scale bar. Args: @@ -392,7 +392,7 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None, resolution: Grid resolution in m/px. keep_tif: If True, keep the source TIFF after conversion. source_info: Dict with method/date/basename for metadata. - quality: Image quality (1-100). Use 100 for lossless. Default 98. + quality: Image quality (1-100). Use 100 for lossless. Default 60. output_format: Output format ('webp' or 'avif'). Default 'avif'. Returns: @@ -815,7 +815,7 @@ 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'): +def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=60, 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. diff --git a/scripts/transcode_q60.py b/scripts/transcode_q60.py new file mode 100644 index 0000000..23b42a4 --- /dev/null +++ b/scripts/transcode_q60.py @@ -0,0 +1,54 @@ +"""Transcode les AVIF q98 des visualisations en AVIF q60 (one-shot). + +Usage (dans le conteneur, volume output monté) : + python3 /tmp/transcode_q60.py [--quality 60] [--workers N] + +Ne remplace un fichier que si la version q60 est plus légère (garde-fou). +""" +import sys +import os +from pathlib import Path +from concurrent.futures import ProcessPoolExecutor + +from PIL import Image + +QUALITY = int(sys.argv[sys.argv.index('--quality') + 1]) if '--quality' in sys.argv else 60 +WORKERS = int(sys.argv[sys.argv.index('--workers') + 1]) if '--workers' in sys.argv else (os.cpu_count() or 4) +ROOT = Path('/data/output/visualisations') + + +def transcode(path): + try: + before = path.stat().st_size + img = Image.open(path) + img.load() + tmp = path.with_name(path.name + '.q60.tmp.avif') + img.save(tmp, format='AVIF', quality=QUALITY) + after = tmp.stat().st_size + if after < before: + os.replace(tmp, path) + return (path.name, before, after, 'remplacé') + tmp.unlink() + return (path.name, before, after, 'conservé (q60 plus lourd)') + except Exception as exc: # noqa: BLE001 + return (path.name, 0, 0, f'ERREUR {exc}') + + +if __name__ == '__main__': + files = sorted(ROOT.rglob('*.avif')) + files = [f for f in files if '.q60.tmp' not in f.name] + print(f'{len(files)} AVIF à transcoder en q{QUALITY}, {WORKERS} workers', flush=True) + total_before = total_after = n_ok = 0 + with ProcessPoolExecutor(max_workers=WORKERS) as pool: + for i, (name, before, after, status) in enumerate(pool.map(transcode, files), 1): + total_before += before + total_after += min(before, after) + if status.startswith('remplacé'): + n_ok += 1 + if i % 50 == 0 or i == len(files): + print(f' {i}/{len(files)} — {n_ok} remplacés — ' + f'{total_before / 2**30:.1f} → {total_after / 2**30:.1f} Gio', flush=True) + elif status.startswith('ERREUR'): + print(f' {name}: {status}', flush=True) + print(f'FIN: {total_before / 2**30:.1f} → {total_after / 2**30:.1f} Gio ' + f'({n_ok} fichiers réencodés)', flush=True)