308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""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)
|
||
|
||
|
||
ROSE_L = 64.0
|
||
ROSE_CHROMA = 60.0 # = visualizations.RELIEF_CHROMA
|
||
# Classes de densité de points sol (pts/m²) : rouge = donnée faible.
|
||
DENSITY_CLASSES = [
|
||
(0.0, "moins de 1", "#d7301f"),
|
||
(1.0, "1 à 3", "#fc8d59"),
|
||
(3.0, "3 à 6", "#fdcc8a"),
|
||
(6.0, "6 à 10", "#a1d99b"),
|
||
(10.0, "10 et plus", "#31a354"),
|
||
]
|
||
|
||
|
||
def lab_to_rgb(L, a, b):
|
||
"""CIELAB (D65) → sRGB 8 bits, même formule que la carte (labToRgb)."""
|
||
fy = (L + 16) / 116
|
||
fx, fz = fy + a / 500, fy - b / 200
|
||
|
||
def finv(t):
|
||
return t ** 3 if t > 6 / 29 else 3 * (6 / 29) ** 2 * (t - 4 / 29)
|
||
|
||
X, Y, Z = 0.95047 * finv(fx), finv(fy), 1.08883 * finv(fz)
|
||
lin = (3.2406 * X - 1.5372 * Y - 0.4986 * Z,
|
||
-0.9689 * X + 1.8758 * Y + 0.0415 * Z,
|
||
0.0557 * X - 0.2040 * Y + 1.0570 * Z)
|
||
out = []
|
||
for c in lin:
|
||
v = 12.92 * c if c <= 0.0031308 else 1.055 * max(c, 0.0) ** (1 / 2.4) - 0.055
|
||
out.append(int(round(min(1.0, max(0.0, v)) * 255)))
|
||
return tuple(out)
|
||
|
||
|
||
def rose_color(compass_deg):
|
||
"""Couleur d'une orientation de pente (0 = N, sens horaire), comme la rose de la carte."""
|
||
chroma = ROSE_CHROMA * min(1.0, ROSE_L * (100 - ROSE_L) / 2500)
|
||
h = math.radians((compass_deg + 90) % 360)
|
||
return lab_to_rgb(ROSE_L, chroma * math.cos(h), chroma * math.sin(h))
|
||
|
||
|
||
def density_color(v):
|
||
"""Couleur de classe d'une densité sol (pts/m²)."""
|
||
color = DENSITY_CLASSES[0][2]
|
||
for low, _label, c in DENSITY_CLASSES:
|
||
if v >= low:
|
||
color = c
|
||
return color
|
||
|
||
|
||
def _pdf_text(s):
|
||
"""Texte encodable par les polices standard (WinAnsi/cp1252)."""
|
||
return "".join(ch if ch.encode("cp1252", "ignore") else "?" for ch in str(s))
|
||
|
||
|
||
def _cells_of_bbox(bbox):
|
||
"""Dalles LHD 1 km intersectant une emprise L93."""
|
||
c0, c1 = int(math.floor(bbox[0] / 1000)), int(math.ceil(bbox[2] / 1000))
|
||
r0, r1 = int(math.floor(bbox[1] / 1000)) + 1, int(math.ceil(bbox[3] / 1000))
|
||
return [(c, r) for c in range(c0, c1) for r in range(r1, r0 - 1, -1)]
|
||
|
||
|
||
def zone_quality(bbox, table, cells_with_relief):
|
||
"""Agrège la qualité des dalles sur une emprise (pondérée par la surface)."""
|
||
from .index import parse_basename_coords
|
||
from .quality import DENSITY_CELL_M
|
||
|
||
by_cell = {}
|
||
for base, data in table.items():
|
||
coords = parse_basename_coords(base)
|
||
if coords is not None:
|
||
by_cell[tuple(coords)] = data
|
||
cells = _cells_of_bbox(bbox)
|
||
relief = set(map(tuple, cells_with_relief))
|
||
total_w = dens_w = empty_w = 0.0
|
||
dmin = None
|
||
starts, ends, sources = [], [], set()
|
||
grid_cells, missing_q = [], []
|
||
for col, row in cells:
|
||
q = by_cell.get((col, row))
|
||
if q is None:
|
||
missing_q.append((col, row))
|
||
continue
|
||
x0, y1 = col * 1000.0, row * 1000.0
|
||
grid = q.get("density_grid") or []
|
||
for j, line in enumerate(grid):
|
||
for i, v in enumerate(line):
|
||
gx0, gx1 = x0 + i * DENSITY_CELL_M, x0 + (i + 1) * DENSITY_CELL_M
|
||
gy1, gy0 = y1 - j * DENSITY_CELL_M, y1 - (j + 1) * DENSITY_CELL_M
|
||
ox = min(gx1, bbox[2]) - max(gx0, bbox[0])
|
||
oy = min(gy1, bbox[3]) - max(gy0, bbox[1])
|
||
if ox <= 0 or oy <= 0:
|
||
continue
|
||
area = ox * oy
|
||
total_w += area
|
||
dens_w += v * area
|
||
empty_w += float(q.get("empty_fraction") or 0.0) * area
|
||
dmin = v if dmin is None else min(dmin, v)
|
||
grid_cells.append((gx0, gy0, gx1, gy1, v))
|
||
if q.get("acq_start"):
|
||
starts.append(q["acq_start"]); ends.append(q["acq_end"] or q["acq_start"])
|
||
sources.add(q.get("acq_source"))
|
||
return {
|
||
"density_mean": dens_w / total_w if total_w else None,
|
||
"density_min": dmin,
|
||
"empty_fraction": empty_w / total_w if total_w else None,
|
||
"acq_start": min(starts) if starts else None,
|
||
"acq_end": max(ends) if ends else None,
|
||
"acq_sources": sources,
|
||
"cells": cells,
|
||
"missing_relief": [c for c in cells if c not in relief],
|
||
"missing_quality": missing_q,
|
||
"grid_cells": grid_cells,
|
||
}
|