Files
lidar_rendu/lidar_pipeline/export.py

475 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Export multi-dalles : mosaïque d'images adjacentes pour consultation terrain.
Assemble les visualisations de plusieurs dalles LiDAR contiguës (1 km chacune,
Lambert 93) en une seule image (PNG/JPEG/WebP) ou un PDF à une page par
visualisation, avec habillage (bandeau titre, échelle, nord) et légende de la
couche exportée : titre, dégradé de la colormap avec bornes, lecture du rendu
couleurs ↔ terrain et méthode de calcul (textes de VIZ_LEGENDS, index.py).
Les dalles sont collées bord à bord, sans aucun trait ni espace entre elles :
les images sources (tif_to_crop) sont des carrés purs de 1 km qui se
joignent parfaitement.
Pensé pour un usage sur téléphone depuis la carte (webapp.py /api/export) :
Pillow seul, aucune dépendance au pipeline — fonctionne sur la webapp légère
(Raspberry Pi) depuis le cache local des tuiles.
"""
import logging
from datetime import datetime
from pathlib import Path
logger = logging.getLogger("lidar")
# Formats de sortie acceptés (clé API → format Pillow).
EXPORT_FORMATS = {"png": "PNG", "jpeg": "JPEG", "webp": "WEBP", "pdf": "PDF"}
# Extension de fichier par format.
EXPORT_EXTS = {"png": "png", "jpeg": "jpg", "webp": "webp", "pdf": "pdf"}
# Garde-fou mémoire : mosaïque RGB au-delà de ~1,6 Go (Raspberry Pi) refusée
# en pleine résolution — l'utilisateur choisit alors une taille réduite.
_MAX_CANVAS_BYTES = 1_600_000_000
# PDF multi-pages : toutes les pages vivent en mémoire au moment de
# l'enregistrement (save_all) — côté maximal ramené à cette valeur.
_PDF_PAGE_MAX_PX = 6000
def _load_font(size, bold=False):
"""Police TrueType si disponible, sinon police embarquée de Pillow."""
from PIL import ImageFont
names = ("DejaVuSans-Bold.ttf", "DejaVuSans.ttf") if bold else ("DejaVuSans.ttf",)
for name in names:
for directory in ("/usr/share/fonts/truetype/dejavu/",
"/usr/share/fonts/dejavu/",
"/usr/share/fonts/TTF/"):
path = directory + name
if Path(path).exists():
try:
return ImageFont.truetype(path, size)
except OSError:
pass
try:
return ImageFont.load_default(size=size) # Pillow >= 10.1
except TypeError:
return ImageFont.load_default()
def _hex_rgb(hx):
"""'#rrggbb' → (r, g, b)."""
hx = hx.lstrip('#')
return tuple(int(hx[i:i + 2], 16) for i in (0, 2, 4))
def _gradient_bar(gradient, width, height):
"""Barre de dégradé interpolée linéairement entre les arrêts de colormap."""
from PIL import Image
stops = [_hex_rgb(c) for c in gradient]
bar = Image.new("RGB", (width, height))
n = len(stops) - 1
for x in range(width):
t = x / max(width - 1, 1) * n
i = min(int(t), n - 1)
f = t - i
color = tuple(int(round(stops[i][k] + (stops[i + 1][k] - stops[i][k]) * f))
for k in range(3))
for y in range(height):
bar.putpixel((x, y), color)
return bar
def _wrap_text(measure, text, font, max_w):
"""Découpe un texte en lignes ≤ max_w px (les \\n explicites sont conservés)."""
lines = []
for para in text.split("\n"):
words = para.split()
if not words:
lines.append("")
continue
cur = words[0]
for word in words[1:]:
if measure.textlength(cur + " " + word, font=font) <= max_w:
cur += " " + word
else:
lines.append(cur)
cur = word
lines.append(cur)
return lines
def _find_tile_images(vis_dir, cells, viz_keys, resolution):
"""Chemins d'image par dalle : {(col, row): {viz_key: Path}}.
Raises:
ValueError: dalles absentes à la résolution demandée, ou visualisations
manquantes sur certaines dalles.
"""
from .index import scan_tiles
by_cell = {(t["col"], t["row"]): t for t in scan_tiles(vis_dir)
if t["resolution"] == resolution}
missing = [f"{c},{r}" for (c, r) in cells if (c, r) not in by_cell]
if missing:
raise ValueError(
f"dale(s) sans visualisation à {str(resolution).replace('.', ',')} m/px : "
f"{', '.join(missing)}")
images, missing_viz = {}, []
for cell in cells:
t = by_cell[cell]
per_viz = {}
for key in viz_keys:
info = t["viz"].get(key)
if not info:
missing_viz.append(f"{cell[0]},{cell[1]} ({key})")
continue
per_viz[key] = Path(t["dir_path"]) / info["filename"]
images[cell] = per_viz
if missing_viz:
raise ValueError("visualisation manquante sur : " + ", ".join(missing_viz))
return images
def _nice_scale_m(width_m):
"""Longueur d'échelle ronde (m) tenant dans ~25 % de la largeur."""
for cand in (25, 50, 100, 200, 250, 500, 1000, 2000, 5000, 10000):
if cand <= 0.25 * width_m:
return cand
return 10000
def _fmt_scale(v):
return f"{v / 1000:g} km" if v >= 1000 else f"{v} m"
def _build_mosaic_into(canvas, origin_y, images, viz_key, tile_px,
n_cols, n_rows, col_min, row_max):
"""Colle les dalles bord à bord dans un canevas existant (offset vertical).
Aucun trait, aucun espace entre dalles. Coller directement dans le canevas
final évite de garder deux mosaïques pleine résolution en mémoire.
"""
from PIL import Image
for (col, row), per_viz in images.items():
img = Image.open(str(per_viz[viz_key]))
if img.mode != "RGB":
img = img.convert("RGB")
if img.size != (tile_px, tile_px):
img = img.resize((tile_px, tile_px))
# Dalle (col, row) : X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
# Ligne 0 de l'image = nord → les rows décroissantes descendent.
x = (col - col_min) * tile_px
y = origin_y + (row_max - row) * tile_px
canvas.paste(img, (x, y))
img.close()
def _build_mosaic(images, viz_key, tile_px, n_cols, n_rows, col_min, row_max):
"""Colle les dalles bord à bord dans un canevas neuf dédié à la mosaïque."""
from PIL import Image
canvas = Image.new("RGB", (n_cols * tile_px, n_rows * tile_px), "white")
_build_mosaic_into(canvas, 0, images, viz_key, tile_px, n_cols, n_rows,
col_min, row_max)
return canvas
def _legend_layout(legend, mosaic, w):
"""Mise en page du bandeau légende (polices, lignes, hauteur), ou None.
Mesure le texte sur la mosaïque (sans la modifier) pour dimensionner le
bandeau avant la création du canevas final.
"""
from PIL import ImageDraw
measure = ImageDraw.Draw(mosaic)
# Police corps du bandeau : généreuse (lecture de terrain sur téléphone),
# bornée pour rester proportionnée aux petits exports réduits.
body = max(16, min(30, w // 100))
gap = max(8, int(body * 0.55))
line_h = int(body * 1.45)
x0 = max(18, w // 60)
max_w = max(40, w - 2 * x0)
text_font = _load_font(body)
lines = _wrap_text(measure, legend["legend"], text_font, max_w)
desc_lines = _wrap_text(measure, "Calcul : " + legend["description"],
text_font, max_w)
has_bar = bool(legend.get("gradient") and legend.get("ticks"))
bar_w = max(100, min(w // 3, 640))
bar_h = max(10, int(body * 0.9))
height = (gap + int(body * 1.75) + gap
+ (bar_h + line_h if has_bar else 0) + gap
+ len(lines) * line_h + gap + len(desc_lines) * line_h + gap)
return {"body": body, "gap": gap, "line_h": line_h, "x0": x0,
"title_font": _load_font(int(body * 1.35), bold=True),
"text_font": text_font, "lines": lines, "desc_lines": desc_lines,
"has_bar": has_bar, "bar_w": bar_w, "bar_h": bar_h, "height": height}
def _draw_legend(out, draw, legend, layout, y_base):
"""Dessine le bandeau légende sous le pied : titre, dégradé + bornes,
lecture du rendu (lignes de la légende) et méthode de calcul."""
y = y_base + layout["gap"]
draw.text((layout["x0"], y), legend["title"], fill=(20, 20, 20),
font=layout["title_font"], anchor="la")
y += int(layout["body"] * 1.75) + layout["gap"]
if layout["has_bar"]:
bar = _gradient_bar(legend["gradient"], layout["bar_w"], layout["bar_h"])
out.paste(bar, (layout["x0"], y))
# Cadre dessiné juste à l'extérieur de la barre : les pixels
# extrêmes du dégradé (bornes de l'échelle) restent visibles.
draw.rectangle((layout["x0"] - 1, y - 1,
layout["x0"] + layout["bar_w"], y + layout["bar_h"]),
outline=(90, 90, 90), width=1)
tick_y = y + layout["bar_h"] + 2
draw.text((layout["x0"], tick_y), legend["ticks"][0], fill=(20, 20, 20),
font=layout["text_font"], anchor="la")
draw.text((layout["x0"] + layout["bar_w"], tick_y), legend["ticks"][1],
fill=(20, 20, 20), font=layout["text_font"], anchor="ra")
y = tick_y + layout["line_h"]
y += layout["gap"]
for line in layout["lines"]:
draw.text((layout["x0"], y), line, fill=(20, 20, 20),
font=layout["text_font"], anchor="la")
y += layout["line_h"]
y += layout["gap"]
for line in layout["desc_lines"]:
draw.text((layout["x0"], y), line, fill=(90, 90, 90),
font=layout["text_font"], anchor="la")
y += layout["line_h"]
def _chrome_layout(w, h, legend):
"""Dimensions des bandeaux (titre, pied, légende) autour d'une mosaïque w×h."""
from PIL import Image
top_h = max(90, h // 16)
foot_h = max(84, h // 20)
# Image factice pour la mesure : seule la police compte, pas le contenu
layout = _legend_layout(legend, Image.new("RGB", (1, 1)), w) if legend else None
legend_h = layout["height"] if layout else 0
return top_h, foot_h, layout, legend_h
def _draw_chrome(out, mosaic_w, mosaic_h, top_h, foot_h, m_per_px, title,
subtitle, layout, legend):
"""Dessine sur `out` les bandeaux autour de la zone mosaïque (offset top_h)."""
from PIL import ImageDraw
w, h = mosaic_w, mosaic_h
d = ImageDraw.Draw(out)
title_font = _load_font(max(22, int(top_h * 0.34)))
sub_font = _load_font(max(14, int(top_h * 0.20)))
foot_font = _load_font(max(14, int(foot_h * 0.26)))
for text, font, cy in ((title, title_font, top_h * 0.40),
(subtitle, sub_font, top_h * 0.76)):
tw = d.textlength(text, font=font)
d.text(((w - tw) / 2, cy), text, fill=(20, 20, 20), font=font,
anchor="lm")
# Pied : échelle à gauche, nord à droite.
width_m = w * m_per_px
scale_m = _nice_scale_m(width_m)
bar_px = max(40, int(scale_m / m_per_px))
bar_y = h + top_h + foot_h * 0.42
x0, y0 = max(18, w // 60), bar_y - foot_h * 0.16
seg = bar_px / 4
for i in range(4):
cx = x0 + i * seg
d.rectangle((cx, y0, cx + seg, y0 + foot_h * 0.20),
fill=(15, 15, 15) if i % 2 == 0 else "white",
outline=(15, 15, 15), width=2)
d.text((x0 + bar_px / 2, y0 - foot_h * 0.22), _fmt_scale(scale_m),
fill=(20, 20, 20), font=foot_font, anchor="ms")
nx = w - max(30, w // 24)
ny = h + top_h + foot_h * 0.55
s = foot_h * 0.30
d.polygon([(nx, ny - s * 1.5), (nx - s, ny + s), (nx, ny + s * 0.4),
(nx + s, ny + s)], fill=(15, 15, 15))
d.text((nx, ny - s * 2.2), "N", fill=(20, 20, 20), font=foot_font,
anchor="ms")
if layout:
y_base = h + top_h + foot_h
d.line((0, y_base, w, y_base), fill=(210, 210, 210), width=1)
_draw_legend(out, d, legend, layout, y_base)
def _decorate(mosaic, m_per_px, title, subtitle, legend=None):
"""Ajoute un bandeau de titre au-dessus et un pied (échelle + nord) dessous.
Avec `legend` (entrée de VIZ_LEGENDS), un bandeau explicatif de la couche
est ajouté sous le pied : titre, dégradé de la colormap avec bornes,
lecture du rendu et méthode de calcul.
L'habillage ne touche jamais la mosaïque : les dalles restent jointives.
"""
from PIL import Image
w, h = mosaic.size
top_h, foot_h, layout, legend_h = _chrome_layout(w, h, legend)
out = Image.new("RGB", (w, h + top_h + foot_h + legend_h), "white")
out.paste(mosaic, (0, top_h))
_draw_chrome(out, w, h, top_h, foot_h, m_per_px, title, subtitle,
layout, legend)
return out
def export_filename(cells, resolution, viz_keys, fmt):
"""Nom de fichier déterministe + horodatage (évite les collisions)."""
cols = [c for c, _ in cells]
rows = [r for _, r in cells]
res = str(resolution).replace(".", "p")
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
viz = "-".join(viz_keys)[:60]
return (f"export_{min(cols)}-{max(cols)}_{min(rows)}-{max(rows)}"
f"_r{res}_{viz}_{stamp}.{EXPORT_EXTS[fmt]}")
def build_export(vis_dir, cells, viz_keys, resolution, fmt, out_dir,
max_side=0):
"""Assemble des dalles adjacentes en une image ou un PDF.
Args:
vis_dir: dossier output/visualisations (dalles scannées via index).
cells: liste (col, row) des dalles à assembler (km L93).
viz_keys: mots-clés de visualisation (ex: slope, hillshade_multi).
Plusieurs clés → un export par couche : PDF multi-pages (une page
par visualisation) ou une image par visualisation.
resolution: résolution des dalles en m/px (0.5 ou 0.2).
fmt: png, jpeg, webp ou pdf.
out_dir: dossier de sortie (output/exports, créé si besoin).
max_side: réduire le plus grand côté de la mosaïque à N pixels
(0 = pleine résolution).
Returns:
{file: Path du premier fichier, width, height, pages,
files: [{file, width, height, pages}, …] — 1 par couche en image,
1 entrée pour le PDF multi-pages}
Raises:
ValueError: dalles ou visualisations manquantes, format invalide,
zone trop grande en mémoire.
"""
from PIL import Image
resample = getattr(Image, 'LANCZOS', 1)
if fmt not in EXPORT_FORMATS:
raise ValueError(f"format invalide : {fmt!r} "
f"(attendus : {', '.join(EXPORT_FORMATS)})")
viz_keys = [str(k) for k in viz_keys if k]
if not viz_keys:
raise ValueError("aucune visualisation demandée")
images = _find_tile_images(vis_dir, cells, viz_keys, resolution)
cols = [c for c, _ in cells]
rows = [r for _, r in cells]
col_min, col_max = min(cols), max(cols)
row_min, row_max = min(rows), max(rows)
n_cols = col_max - col_min + 1
n_rows = row_max - row_min + 1
# Côté réel d'une dalle (2000 px à 0,5 m, 5000 à 0,2 m) : lu sur la
# première image plutôt que recalculé, pour coller aux fichiers réels.
first_path = next(iter(images.values()))[viz_keys[0]]
with Image.open(str(first_path)) as _im:
tile_px = max(_im.size)
tile_px = max(1, tile_px)
canvas_bytes = n_cols * tile_px * n_rows * tile_px * 3
if canvas_bytes > _MAX_CANVAS_BYTES:
raise ValueError(
f"zone trop grande en pleine résolution "
f"({n_cols}×{n_rows} dalles à {resolution} m/px ≈ "
f"{canvas_bytes / 1e9:.1f} Go) — choisissez une taille réduite")
from .index import VIZ_LABELS, VIZ_LEGENDS
res_txt = str(resolution).replace(".", ",")
date_txt = datetime.now().strftime("%d/%m/%Y")
subtitle = (f"Lambert 93 · X {col_min}–{col_max + 1} km · "
f"Y {row_min - 1}–{row_max} km · {res_txt} m/px · "
f"{len(cells)} dalle{'s' if len(cells) > 1 else ''} · {date_txt}")
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Export : {n_cols}×{n_rows} dalles à {res_txt} m/px, "
f"{' × '.join(viz_keys)} ({fmt})")
# PDF multi-pages : toutes les pages sont retenues pour save_all —
# côté plafonné pour rester en mémoire raisonnable.
page_cap = _PDF_PAGE_MAX_PX if fmt == "pdf" and len(viz_keys) > 1 else None
# Options d'enregistrement hétérogènes (str/int/float/bool)
save_kw: dict = {"format": EXPORT_FORMATS[fmt]}
if fmt == "jpeg":
save_kw["quality"] = 90
elif fmt == "webp":
save_kw["quality"] = 90
elif fmt == "pdf":
save_kw["resolution"] = 150.0
# Une couche = un export : page du PDF multi-pages, ou fichier image
# dédié (chacun porte la légende de SA couche).
pages = [] # pages PDF décorées (save_all)
files = [] # fichiers écrits (1 par couche en image, le PDF sinon)
try:
for key in viz_keys:
m_per_px = resolution
caps = [c for c in (int(max_side), page_cap) if c]
cap = min(caps) if caps else 0
w = n_cols * tile_px
h = n_rows * tile_px
if cap and max(w, h) > cap:
# Mosaïque réduite (téléphone / pages PDF) : taille bornée par
# le cap, le chemin mosaïque → resize → habillage reste simple
ratio = cap / max(w, h)
size = (max(1, round(w * ratio)), max(1, round(h * ratio)))
mosaic = _build_mosaic(images, key, tile_px, n_cols, n_rows,
col_min, row_max)
mosaic = mosaic.resize(size, resample)
m_per_px = resolution / ratio
decorated = _decorate(mosaic, m_per_px,
VIZ_LABELS.get(key, key), subtitle,
legend=VIZ_LEGENDS.get(key))
mosaic.close()
else:
# Pleine résolution : collage DIRECT dans le canevas habillé —
# jamais deux mosaïques entières en mémoire (la garde
# _MAX_CANVAS_BYTES correspond alors à la taille réelle)
legend = VIZ_LEGENDS.get(key)
top_h, foot_h, layout, legend_h = _chrome_layout(w, h, legend)
decorated = Image.new(
"RGB", (w, h + top_h + foot_h + legend_h), "white")
_build_mosaic_into(decorated, top_h, images, key, tile_px,
n_cols, n_rows, col_min, row_max)
_draw_chrome(decorated, w, h, top_h, foot_h, m_per_px,
VIZ_LABELS.get(key, key), subtitle, layout, legend)
if fmt != "pdf":
key_file = out_dir / export_filename(cells, resolution, [key], fmt)
decorated.save(str(key_file), **save_kw)
decorated.close()
files.append(key_file)
else:
pages.append(decorated)
if fmt == "pdf":
out_file = out_dir / export_filename(cells, resolution, viz_keys, fmt)
if len(pages) > 1:
save_kw["save_all"] = True
save_kw["append_images"] = pages[1:]
pages[0].save(str(out_file), **save_kw)
files.append(out_file)
finally:
for p in pages:
p.close()
result_files = []
for f in files:
entry = {"file": f, "width": 0, "height": 0, "pages": 1}
if fmt == "pdf":
# Pages de même dimension que la première mosaïque décorée.
first = pages[0] if pages else None
entry["width"], entry["height"] = first.size if first else (0, 0)
entry["pages"] = len(pages)
else:
with Image.open(str(f)) as img:
entry["width"], entry["height"] = img.size
result_files.append(entry)
head = result_files[0] if result_files else {
"file": None, "width": 0, "height": 0, "pages": 0}
result = {"file": head["file"], "width": head["width"],
"height": head["height"], "pages": head["pages"],
"files": result_files}
total = sum(f.stat().st_size for f in files)
logger.info(f"✓ Export terminé : {len(files)} fichier"
f"{'s' if len(files) > 1 else ''} ({total / 1e6:.1f} Mo)")
return result