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)