"""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(images, viz_key, tile_px, n_cols, n_rows, col_min, row_max): """Colle les dalles bord à bord — aucun trait, aucun espace entre elles.""" from PIL import Image canvas = Image.new("RGB", (n_cols * tile_px, n_rows * tile_px), "white") 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 = (row_max - row) * tile_px canvas.paste(img, (x, y)) img.close() 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 _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, ImageDraw w, h = mosaic.size top_h = max(90, h // 16) foot_h = max(84, h // 20) layout = _legend_layout(legend, mosaic, w) if legend else None legend_h = layout["height"] if layout else 0 out = Image.new("RGB", (w, h + top_h + foot_h + legend_h), "white") out.paste(mosaic, (0, top_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) 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: mosaic = _build_mosaic(images, key, tile_px, n_cols, n_rows, col_min, row_max) m_per_px = resolution caps = [c for c in (int(max_side), page_cap) if c] cap = min(caps) if caps else 0 if cap and max(mosaic.size) > cap: ratio = cap / max(mosaic.size) size = (max(1, round(mosaic.size[0] * ratio)), max(1, round(mosaic.size[1] * ratio))) 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() 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