AVIF q60 par défaut et sélecteur de mode de fusion par couche

This commit is contained in:
Antoine Jacquin
2026-09-19 21:26:13 +02:00
parent 5f018fe2b6
commit 3a374a3ab4
6 changed files with 151 additions and 15 deletions

View File

@ -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.

View File

@ -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",

View File

@ -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 <img> 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 =
'<select class="layer-blend" title="Mode de fusion avec les couches en dessous">' +
BLEND_MODES.map(m =>
'<option value="' + m[0] + '"' + (m[0] === curBlend ? ' selected' : '') + '>' +
m[1] + '</option>').join('') +
'</select>';
row.innerHTML =
'<span class="layer-grip" title="Glisser pour réordonner">⠿</span>' +
'<button class="eye' + (state.layers.on[key] ? ' on' : '') + '" title="Afficher / masquer">' +
(state.layers.on[key] ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
'<span class="layer-name">' + escapeHtml(meta.label) + '</span>' +
(state.layers.on[key] ? blendSel : '') +
'<input type="range" min="0" max="100" step="1" value="' +
Math.round(state.layers.opacity[key] * 100) + '" title="Opacité">' +
'<span class="layer-pct">' + Math.round(state.layers.opacity[key] * 100) + '%</span>';
@ -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 },
};

View File

@ -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.

View File

@ -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.

54
scripts/transcode_q60.py Normal file
View File

@ -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)