"""Export PDF d'une zone : planche d'impression terrain du relief orienté. Composée directement en Lambert 93 depuis les sources de la pyramide (tiles.py) — échelle exacte, quadrillage aligné sur les dalles — puis dessinée avec reportlab (texte, grille et légende vectoriels). Tourne dans l'image légère : Pillow + pyproj + reportlab, sans numpy. """ import logging import math from dataclasses import dataclass logger = logging.getLogger("lidar") LAYER = "relief_oriente" PAPERS_MM = {"A4": (210.0, 297.0), "A3": (297.0, 420.0)} ORIENTS = ("portrait", "paysage") SCALES = (1000, 2000, 5000, 10000) DPI = {"A4": 300, "A3": 250} # borne la mémoire du Pi (~36 Mo en A3) NATIVE_RES_M = 0.2 MARGIN_MM = 10.0 # bord non imprimable ANNOT_MM = 7.0 # bande des coordonnées autour de la carte PANEL_SIDE_MM = 64.0 # bandeau à droite (paysage) PANEL_BOTTOM_MM = 72.0 # bandeau en bas (portrait) _GRID_STEPS = {1000: 100, 2000: 100, 5000: 500, 10000: 1000} @dataclass(frozen=True) class Layout: """Géométrie de la planche, en mm, origine en bas à gauche (reportlab).""" paper: str orient: str scale: int dpi: int page_w: float page_h: float map_x: float map_y: float map_w: float map_h: float panel_x: float panel_y: float panel_w: float panel_h: float def layout(paper, orient, scale): """Géométrie d'une planche ; ValueError si un réglage est invalide.""" if paper not in PAPERS_MM: raise ValueError(f"format inconnu : {paper} (A4 ou A3)") if orient not in ORIENTS: raise ValueError(f"orientation inconnue : {orient} (portrait ou paysage)") try: scale = int(scale) except (TypeError, ValueError): raise ValueError(f"échelle invalide : {scale}") from None if scale not in SCALES: raise ValueError("échelle non proposée : 1:" + str(scale) + " (1:1000, 1:2000, 1:5000 ou 1:10000)") w, h = PAPERS_MM[paper] if orient == "paysage": w, h = h, w inner = MARGIN_MM + ANNOT_MM if orient == "paysage": map_w = w - 2 * inner - PANEL_SIDE_MM map_h = h - 2 * inner return Layout(paper, orient, scale, DPI[paper], w, h, inner, inner, map_w, map_h, w - MARGIN_MM - PANEL_SIDE_MM, MARGIN_MM, PANEL_SIDE_MM, h - 2 * MARGIN_MM) map_w = w - 2 * inner map_h = h - 2 * inner - PANEL_BOTTOM_MM return Layout(paper, orient, scale, DPI[paper], w, h, inner, MARGIN_MM + PANEL_BOTTOM_MM + ANNOT_MM, map_w, map_h, MARGIN_MM, MARGIN_MM, w - 2 * MARGIN_MM, PANEL_BOTTOM_MM) def center_l93(lat, lon): """Centre WGS84 → Lambert 93 ; ValueError si non fini.""" from .tiles import wgs84_to_l93 lat, lon = float(lat), float(lon) if not (math.isfinite(lat) and math.isfinite(lon)): raise ValueError("coordonnées du centre invalides") cx, cy = wgs84_to_l93(lon, lat) if not (math.isfinite(cx) and math.isfinite(cy)): raise ValueError("centre hors du domaine Lambert 93") return cx, cy def map_bbox(cx, cy, lay): """Emprise terrain L93 de la zone carte (papier × échelle).""" half_w = lay.map_w / 1000.0 * lay.scale / 2 half_h = lay.map_h / 1000.0 * lay.scale / 2 return (cx - half_w, cy - half_h, cx + half_w, cy + half_h) def pixel_size(lay): """Taille terrain d'un pixel imprimé (m), jamais plus fine que le natif.""" return max(NATIVE_RES_M, lay.scale * 0.0254 / lay.dpi) def grid_step(scale): """Pas du quadrillage L93 (m) selon l'échelle.""" return _GRID_STEPS[int(scale)] def _to_wgs84(x, y): from .tiles import _transformer lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(x, y) return lat, lon def frame(lat, lon, paper, orient, scale): """Cadre imprimable pour la carte : emprise L93 et coins WGS84 (NO, NE, SE, SO).""" lay = layout(paper, orient, scale) cx, cy = center_l93(lat, lon) b = map_bbox(cx, cy, lay) corners = [_to_wgs84(b[0], b[3]), _to_wgs84(b[2], b[3]), _to_wgs84(b[2], b[1]), _to_wgs84(b[0], b[1])] return {"cx": cx, "cy": cy, "bbox_l93": list(b), "corners": [[round(a, 7), round(o, 7)] for a, o in corners], "width_m": round(b[2] - b[0]), "height_m": round(b[3] - b[1])} # Couleur des pixels sans donnée du relief orienté (recopie de # visualizations.RELIEF_NODATA_RGB : ce module importe numpy, absent de # l'image légère ; égalité vérifiée par les tests). NODATA_RGB = (38, 38, 41) _NODATA_TOLERANCE = 3 # écart par canal toléré (rééchantillonnage) _HATCH_STEP_PX = 14 def _paste_l93(canvas, mask, src, bbox, px): """Recadre et rééchantillonne une source L93 dans l'image de la planche.""" from PIL import Image, ImageChops from . import tiles img = tiles.load_source(src) if img is None: return False w, h = img.size sx0, sy0, sx1, sy1 = src.bounds ix0, ix1 = max(bbox[0], sx0), min(bbox[2], sx1) iy0, iy1 = max(bbox[1], sy0), min(bbox[3], sy1) if ix1 <= ix0 or iy1 <= iy0: return False dx0 = int(round((ix0 - bbox[0]) / px)); dx1 = int(round((ix1 - bbox[0]) / px)) dy0 = int(round((bbox[3] - iy1) / px)); dy1 = int(round((bbox[3] - iy0) / px)) if dx1 <= dx0 or dy1 <= dy0: return False rx, ry = (sx1 - sx0) / w, (sy1 - sy0) / h gx0, gx1 = bbox[0] + dx0 * px, bbox[0] + dx1 * px gy1, gy0 = bbox[3] - dy0 * px, bbox[3] - dy1 * px box = (max(0.0, (gx0 - sx0) / rx), max(0.0, (sy1 - gy1) / ry), min(float(w), (gx1 - sx0) / rx), min(float(h), (sy1 - gy0) / ry)) part = img.resize((dx1 - dx0, dy1 - dy0), Image.LANCZOS, box=box) rgb = part.convert("RGB") diff = ImageChops.difference(rgb, Image.new("RGB", rgb.size, NODATA_RGB)) r, g, b = diff.split() valid = ImageChops.lighter(ImageChops.lighter(r, g), b).point( lambda v: 255 if v > _NODATA_TOLERANCE else 0) if part.mode == "RGBA": valid = ImageChops.multiply(valid, part.getchannel("A").point( lambda v: 255 if v >= 128 else 0)) canvas.paste(rgb, (dx0, dy0), valid) mask.paste(255, (dx0, dy0, dx1, dy1), valid) return True def _hatch(size): """Motif blanc à hachures grises (zones sans donnée).""" from PIL import Image, ImageDraw w, h = size pat = Image.new("RGB", size, (255, 255, 255)) draw = ImageDraw.Draw(pat) for k in range(-h, w, _HATCH_STEP_PX): draw.line([(k, h), (k + h, 0)], fill=(200, 200, 200), width=2) return pat def compose_l93(output_dir, bbox, px_size, layer=LAYER): """Image RGB de l'emprise L93 à px_size m/px, masque des pixels peints et dalles contributrices. Hors données : blanc hachuré.""" from PIL import Image, ImageOps from . import tiles width = max(1, int(round((bbox[2] - bbox[0]) / px_size))) height = max(1, int(round((bbox[3] - bbox[1]) / px_size))) canvas = Image.new("RGB", (width, height), (255, 255, 255)) mask = Image.new("L", (width, height), 0) cells = set() for cell, src in tiles.sources_in_bbox(output_dir, layer, bbox, px_size): if _paste_l93(canvas, mask, src, bbox, px_size): cells.add(cell) if mask.getextrema() != (255, 255): canvas.paste(_hatch(canvas.size), (0, 0), ImageOps.invert(mask)) return canvas, mask, sorted(cells)