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. - **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. - **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`. - **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`. - **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`. - **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. - **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( parser.add_argument(
"--quality", "--quality",
type=int, type=int,
default=98, default=60,
help="Qualité image (1-100, défaut: 98). Utilisez 100 pour lossless." help="Qualité image (1-100, défaut: 60). Utilisez 100 pour lossless."
) )
parser.add_argument( parser.add_argument(
"--lossless", "--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. # Opacité par défaut des couches allumées (0–1) ; les absentes restent à 1.
DEFAULT_OPACITY = {'aspect': 0.6} 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 # Couche « principale » (mise en avant ; index_tiles.json + affichage en
# direct de la webapp). # direct de la webapp).
DEFAULT_VIZ = DEFAULT_LAYERS[0] DEFAULT_VIZ = DEFAULT_LAYERS[0]
@ -1123,6 +1129,7 @@ def build_index(output_dir, output_format='avif'):
'stats': {'default_viz': default_viz, 'stats': {'default_viz': default_viz,
'default_layers': default_layers_present(all_viz_keys), 'default_layers': default_layers_present(all_viz_keys),
'default_opacity': dict(DEFAULT_OPACITY), 'default_opacity': dict(DEFAULT_OPACITY),
'default_blend': dict(DEFAULT_BLEND),
'n_tiles': len(display_tiles)}, 'n_tiles': len(display_tiles)},
}, ensure_ascii=False), encoding='utf-8') }, 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_viz': default_viz,
'default_layers': default_layers_present(all_viz_keys), 'default_layers': default_layers_present(all_viz_keys),
'default_opacity': dict(DEFAULT_OPACITY), 'default_opacity': dict(DEFAULT_OPACITY),
'default_blend': dict(DEFAULT_BLEND),
'n_tiles': len(tiles)}, 'n_tiles': len(tiles)},
) )
@ -1589,6 +1597,15 @@ select, input[type="checkbox"] { accent-color: var(--accent); }
padding: 5px 6px; border-radius: 6px; padding: 5px 6px; border-radius: 6px;
user-select: none; 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; } #osmRow { grid-template-columns: 24px minmax(0, 1fr) 80px 30px; }
.layer-row:hover { background: rgba(255, 255, 255, 0.05); } .layer-row:hover { background: rgba(255, 255, 255, 0.05); }
.layer-row.dragging { opacity: 0.45; } .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 /* Fond transparent obligatoire : l'opacité des couches doit laisser voir
les couches du dessous et le fond de carte, pas un rectangle noir. */ les couches du dessous et le fond de carte, pas un rectangle noir. */
background: transparent; 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 */ /* Layout et peinture des tuiles hors écran skippés par le navigateur */
content-visibility: auto; content-visibility: auto;
} }
@ -1998,8 +2019,28 @@ L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo
// État des couches (persisté en localStorage) // État des couches (persisté en localStorage)
// ============================================================ // ============================================================
// order : pile de visualisations, index 0 = bas de pile. // 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'; 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() { function defaultLayers() {
const order = Object.keys(VIZ_META); const order = Object.keys(VIZ_META);
// Couches activées par défaut : seules celles-ci sont allumées, toutes les // 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. // Opacités par défaut (STATS.default_opacity) ; absentes → 1.
const defOp = (STATS.default_opacity && typeof STATS.default_opacity === 'object') const defOp = (STATS.default_opacity && typeof STATS.default_opacity === 'object')
? STATS.default_opacity : {}; ? 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 onSet = new Set(defOrder);
const on = {}, opacity = {}; const on = {}, opacity = {}, blend = {};
for (const k of order) { for (const k of order) {
on[k] = onSet.has(k); on[k] = onSet.has(k);
const op = Number(defOp[k]); const op = Number(defOp[k]);
opacity[k] = Number.isNaN(op) ? 1 : Math.min(1, Math.max(0, op)); 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() { function loadLayers() {
@ -2045,19 +2090,28 @@ function loadLayers() {
for (const k of Object.keys(VIZ_META)) { for (const k of Object.keys(VIZ_META)) {
if (!order.includes(k)) order.push(k); 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) { for (const k of order) {
const op = saved.opacity ? saved.opacity[k] : undefined; const op = saved.opacity ? saved.opacity[k] : undefined;
on[k] = saved.on && saved.on[k] === true; on[k] = saved.on && saved.on[k] === true;
opacity[k] = (op === undefined || op === null || Number.isNaN(Number(op))) opacity[k] = (op === undefined || op === null || Number.isNaN(Number(op)))
? 1 : Math.min(1, Math.max(0, 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; } } catch (e) { st = def; }
// Lien partagé (vue + couches + transparences) : il prime sur l'état local. // 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) {
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 }); if (sharedHash.osm) st = Object.assign({}, st, { osm: sharedHash.osm });
} }
return st; return st;
@ -2216,6 +2270,7 @@ function syncTileLayers() {
} }
img.style.zIndex = String(i + 1); img.style.zIndex = String(i + 1);
img.style.opacity = String(state.layers.opacity[k]); img.style.opacity = String(state.layers.opacity[k]);
img.style.mixBlendMode = state.layers.blend[k] || 'normal';
}); });
} }
updateImages(); updateImages();
@ -2357,11 +2412,21 @@ function renderLayerPanel() {
row.draggable = !!(e.target.closest && e.target.closest('.layer-grip')); row.draggable = !!(e.target.closest && e.target.closest('.layer-grip'));
}); });
row.title = meta.label; 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 = row.innerHTML =
'<span class="layer-grip" title="Glisser pour réordonner">⠿</span>' + '<span class="layer-grip" title="Glisser pour réordonner">⠿</span>' +
'<button class="eye' + (state.layers.on[key] ? ' on' : '') + '" title="Afficher / masquer">' + '<button class="eye' + (state.layers.on[key] ? ' on' : '') + '" title="Afficher / masquer">' +
(state.layers.on[key] ? SVG_EYE : SVG_EYE_OFF) + '</button>' + (state.layers.on[key] ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
'<span class="layer-name">' + escapeHtml(meta.label) + '</span>' + '<span class="layer-name">' + escapeHtml(meta.label) + '</span>' +
(state.layers.on[key] ? blendSel : '') +
'<input type="range" min="0" max="100" step="1" value="' + '<input type="range" min="0" max="100" step="1" value="' +
Math.round(state.layers.opacity[key] * 100) + '" title="Opacité">' + Math.round(state.layers.opacity[key] * 100) + '" title="Opacité">' +
'<span class="layer-pct">' + Math.round(state.layers.opacity[key] * 100) + '%</span>'; '<span class="layer-pct">' + Math.round(state.layers.opacity[key] * 100) + '%</span>';
@ -2391,6 +2456,17 @@ function renderLayerPanel() {
}); });
range.addEventListener('change', saveLayers); 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('dragstart', () => { dragKey = key; row.classList.add('dragging'); });
row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); row.draggable = false; }); row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); row.draggable = false; });
row.addEventListener('dragover', e => e.preventDefault()); row.addEventListener('dragover', e => e.preventDefault());
@ -2488,7 +2564,9 @@ const BUILTIN_PRESETS = [
function presetDetail(p) { function presetDetail(p) {
const parts = (p.on || []).filter(k => VIZ_META[k]) const parts = (p.on || []).filter(k => VIZ_META[k])
.map(k => (VIZ_META[k].label || 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 if (p.osm) parts.push('Fond ' + (p.osm.on
? Math.round((p.osm.opacity != null ? Number(p.osm.opacity) : 0.85) * 100) + '%' ? Math.round((p.osm.opacity != null ? Number(p.osm.opacity) : 0.85) * 100) + '%'
: 'masqué')); : 'masqué'));
@ -2504,7 +2582,10 @@ function applyPreset(p) {
for (const k of L.order) { for (const k of L.order) {
const on = want.includes(k); const on = want.includes(k);
L.on[k] = on; 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) // Niveaux de transparence du fond de carte (visibilité, opacité, sombre)
if (p.osm) { if (p.osm) {
@ -2569,6 +2650,7 @@ presetSaveEl.addEventListener('click', () => {
label: name.trim(), label: name.trim(),
on: enabled, on: enabled,
opacity: Object.fromEntries(enabled.map(k => [k, state.layers.opacity[k]])), 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 // Niveaux de transparence du fond de carte
osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark }, osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark },
}; };

View File

@ -110,7 +110,7 @@ VIZ_STEPS = [
class LidarArchaeoPipeline: class LidarArchaeoPipeline:
"""Orchestrates the LiDAR archaeological analysis pipeline.""" """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.input_dir = Path(input_dir)
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
# Accept single float or comma-separated string for multi-resolution # 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}") 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. """Standalone function for multiprocessing — creates its own pipeline instance.
Each worker gets its own temp directory to avoid file conflicts. 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" 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. """Convert GeoTIFF to visualization image (WebP or AVIF) with GPS coordinates, legend, and scale bar.
Args: 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. resolution: Grid resolution in m/px.
keep_tif: If True, keep the source TIFF after conversion. keep_tif: If True, keep the source TIFF after conversion.
source_info: Dict with method/date/basename for metadata. 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'. output_format: Output format ('webp' or 'avif'). Default 'avif'.
Returns: Returns:
@ -815,7 +815,7 @@ 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'): 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). """Convert GeoTIFF to a cropped visualization image (no legend, no overlay).
Applies colormap and saves the image as a pure 1×1 km square. 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)