Files
lidar_rendu/lidar_pipeline/export_pdf.py
2026-09-27 21:11:49 +02:00

694 lines
29 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 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,
}
class NoDataError(Exception):
"""Aucune dalle du relief orienté dans l'emprise demandée."""
_JPEG_QUALITY = 90
_SCALEBAR_STEPS = (10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000)
def _fmt_int(n):
return f"{int(n):,}".replace(",", " ")
def _hex(rgb):
return "#%02x%02x%02x" % tuple(rgb)
def _convergence_deg(cx, cy):
"""Convergence du méridien : azimut (°) du nord du quadrillage L93 mesuré
depuis le nord géographique (sens horaire), positif à l'est du méridien
central (3°E)."""
lat0, lon0 = _to_wgs84(cx, cy)
lat1, lon1 = _to_wgs84(cx, cy + 100.0)
return math.degrees(math.atan2((lon1 - lon0) * math.cos(math.radians(lat0)), lat1 - lat0))
def _north_arrow_angle(cx, cy):
"""Angle de rotation reportlab (sens antihoraire, `Canvas.rotate`) pour que
la flèche du nord — dessinée vers le haut, c'est-à-dire vers le nord du
quadrillage — pointe vers le nord géographique."""
return _convergence_deg(cx, cy)
def _wrap_text(c, text, font, size, max_width, sep=" "):
"""Découpe `text` sur `sep` en lignes tenant chacune dans `max_width` (pt)."""
parts = text.split(sep)
lines, cur = [], parts[0]
for part in parts[1:]:
candidate = cur + sep + part
if c.stringWidth(candidate, font, size) <= max_width:
cur = candidate
else:
lines.append(cur)
cur = part
lines.append(cur)
return lines
def _fit_title(c, text, font, size, max_width, min_size=7.0):
"""Réduit la taille de police jusqu'à `min_size` puis tronque avec des
points de suspension pour tenir `text` dans `max_width` (pt)."""
while size > min_size and c.stringWidth(text, font, size) > max_width:
size -= 0.5
if c.stringWidth(text, font, size) > max_width:
while text and c.stringWidth(text + "...", font, size) > max_width:
text = text[:-1]
text = text.rstrip() + "..."
return text, size
_ARROW_COLUMN_MM = 16.0 # largeur (mm) réservée à la flèche du nord + son "N", coin haut droit
def _cartouche_text_max_width(w, mm):
"""Largeur (pt) disponible pour le titre/sous-titre de la cartouche, hors
colonne réservée à la flèche du nord (coin haut droit du bloc, même ligne)."""
return max(0.0, w - _ARROW_COLUMN_MM) * mm
def _draw_hatch(c, x, y, w, h, mm, step=3.0):
"""Hachures grises sur fond blanc (maille sans donnée), comme sur la carte."""
from reportlab.lib.colors import Color, white
if w <= 0 or h <= 0:
return
c.saveState()
p = c.beginPath(); p.rect(x * mm, y * mm, w * mm, h * mm)
c.clipPath(p, stroke=0, fill=0)
c.setFillColor(white)
c.rect(x * mm, y * mm, w * mm, h * mm, stroke=0, fill=1)
c.setStrokeColor(Color(0.75, 0.75, 0.75)); c.setLineWidth(0.3)
n = int((w + h) / step) + 2
for k in range(-n, n):
x0 = x + k * step
c.line(x0 * mm, y * mm, (x0 + h) * mm, (y + h) * mm)
c.restoreState()
def _panel_boxes(lay):
"""Rectangles (x, y, w, h) mm du bandeau : légende, qualité, cartouche."""
gap = 4.0
if lay.orient == "paysage":
h = (lay.panel_h - 2 * gap) / 3
x, w = lay.panel_x, lay.panel_w
top = lay.panel_y + lay.panel_h
return [(x, top - h, w, h), (x, top - 2 * h - gap, w, h), (x, lay.panel_y, w, h)]
w = (lay.panel_w - 2 * gap) / 3
y, h = lay.panel_y, lay.panel_h
return [(lay.panel_x, y, w, h), (lay.panel_x + w + gap, y, w, h),
(lay.panel_x + 2 * (w + gap), y, w, h)]
def _draw_grid(c, lay, bbox, mm):
"""Quadrillage L93 + valeurs en marge + coins WGS84."""
from reportlab.lib.colors import black
step = grid_step(lay.scale)
sx = lay.map_w / (bbox[2] - bbox[0])
sy = lay.map_h / (bbox[3] - bbox[1])
c.saveState()
c.setStrokeColor(black); c.setStrokeAlpha(0.55); c.setLineWidth(0.3)
c.setFont("Helvetica", 5.5); c.setFillColor(black)
x = math.ceil(bbox[0] / step) * step
while x <= bbox[2]:
px = (lay.map_x + (x - bbox[0]) * sx) * mm
c.line(px, lay.map_y * mm, px, (lay.map_y + lay.map_h) * mm)
label = f"{x / 1000:.3f}".replace(".", ",")
c.drawCentredString(px, (lay.map_y - 3.2) * mm, label)
c.drawCentredString(px, (lay.map_y + lay.map_h + 1.4) * mm, label)
x += step
y = math.ceil(bbox[1] / step) * step
while y <= bbox[3]:
py = (lay.map_y + (y - bbox[1]) * sy) * mm
c.line(lay.map_x * mm, py, (lay.map_x + lay.map_w) * mm, py)
label = f"{y / 1000:.3f}".replace(".", ",")
c.saveState(); c.translate((lay.map_x - 1.4) * mm, py); c.rotate(90)
c.drawCentredString(0, 0, label); c.restoreState()
c.saveState(); c.translate((lay.map_x + lay.map_w + 3.2) * mm, py); c.rotate(90)
c.drawCentredString(0, 0, label); c.restoreState()
y += step
c.restoreState()
c.setFont("Helvetica", 5.5)
for (gx, gy), (px, py, align) in (
((bbox[0], bbox[3]), (lay.map_x, lay.map_y + lay.map_h + 4.2, "l")),
((bbox[2], bbox[3]), (lay.map_x + lay.map_w, lay.map_y + lay.map_h + 4.2, "r")),
((bbox[0], bbox[1]), (lay.map_x, lay.map_y - 6.2, "l")),
((bbox[2], bbox[1]), (lay.map_x + lay.map_w, lay.map_y - 6.2, "r"))):
lat, lon = _to_wgs84(gx, gy)
hemi = "E" if lon >= 0 else "O"
txt = f"{lat:.5f} N {abs(lon):.5f} {hemi}"
(c.drawString if align == "l" else c.drawRightString)(px * mm, py * mm, txt)
c.setFont("Helvetica", 5.5)
c.drawString(lay.map_x * mm, (lay.map_y + lay.map_h + 6.2) * mm,
_pdf_text(f"Quadrillage Lambert 93 (km), pas {_fmt_int(step)} m - coins en WGS84"))
def _draw_legend(c, box, mm):
"""Barre de clarté + rose des orientations + texte de VIZ_LEGENDS."""
from reportlab.lib.colors import HexColor, black, white
from .index import VIZ_LEGENDS
x, y, w, h = box
c.setFillColor(black); c.setFont("Helvetica-Bold", 8)
c.drawString(x * mm, (y + h - 4) * mm, _pdf_text("Légende - relief orienté"))
# Barre de clarté (L* 20 → 90, gris neutre)
bx, by, bw, bh = x, y + h - 13, min(w, 55.0), 4.0
n = 40
for k in range(n):
L = 20 + 70 * k / (n - 1)
c.setFillColor(HexColor(_hex(lab_to_rgb(L, 0, 0))))
c.rect((bx + bw * k / n) * mm, by * mm, (bw / n + 0.05) * mm, bh * mm, stroke=0, fill=1)
c.setFillColor(black); c.setFont("Helvetica", 6)
c.drawString(bx * mm, (by - 2.8) * mm, _pdf_text("creux, fossé"))
c.drawRightString((bx + bw) * mm, (by - 2.8) * mm, _pdf_text("bosse, crête"))
c.drawString(bx * mm, (by + bh + 0.8) * mm, _pdf_text("Clarté = micro-relief"))
# Rose des orientations (couleur = orientation de la pente)
r_out, r_in = 8.5, 3.9 # place pour les 4 phrases de lecture
rcx, rcy = x + r_out + 2, by - 5 - r_out - 2
for deg in range(0, 360, 5):
c.setFillColor(HexColor(_hex(rose_color(deg))))
start = 90 - deg - 2.5
c.wedge((rcx - r_out) * mm, (rcy - r_out) * mm, (rcx + r_out) * mm, (rcy + r_out) * mm,
start, 5.2, stroke=0, fill=1)
c.setFillColor(white)
c.circle(rcx * mm, rcy * mm, r_in * mm, stroke=0, fill=1)
c.setFillColor(black); c.setFont("Helvetica-Bold", 5.0)
for label, deg in (("N", 0), ("NE", 45), ("E", 90), ("SE", 135), ("S", 180),
("SO", 225), ("O", 270), ("NO", 315)):
a = math.radians(deg)
c.drawCentredString((rcx + (r_out + 2.4) * math.sin(a)) * mm,
(rcy + (r_out + 2.4) * math.cos(a) - 0.8) * mm, label)
c.setFont("Helvetica", 6)
c.drawString((rcx + r_out + 5) * mm, (rcy + 2) * mm, _pdf_text("Teinte = orientation"))
c.drawString((rcx + r_out + 5) * mm, (rcy - 1) * mm, _pdf_text("de la pente"))
# Texte de légende ("Comment lire" : phrases habillées à la largeur de la boîte)
from reportlab.pdfbase.pdfmetrics import stringWidth
ty = rcy - r_out - 6
c.setFont("Helvetica", 5.2) # 4 phrases dans la boîte, A4 compris
for sentence in VIZ_LEGENDS[LAYER].get("reading") or VIZ_LEGENDS[LAYER]["legend"].split("\n"):
# Sur la planche, les pixels sans point sol sont hachurés en blanc
# (pas noirs comme sur la carte) : même phrase, repère adapté.
if sentence.startswith("Trous noirs"):
sentence = "Hachures blanches" + sentence[len("Trous noirs"):]
# La teinte est déjà légendée à côté de la rose : phrase redondante.
if sentence.startswith("Teinte = orientation"):
continue
line = ""
for word in _pdf_text(sentence).split(" "):
test = (line + " " + word).strip()
if stringWidth(test, "Helvetica", 5.2) * 0.3528 > w and line: # pt → mm
if ty < y + 1:
return
c.drawString(x * mm, ty * mm, line)
ty -= 2.5
line = word
else:
line = test
if line:
if ty < y + 1:
return
c.drawString(x * mm, ty * mm, line)
ty -= 3.1 # interligne un peu plus grand entre deux phrases
def _draw_quality(c, box, bbox, zq, mm):
"""Encart qualité : miniature de densité sol + chiffres clés."""
from reportlab.lib.colors import HexColor, black, white
x, y, w, h = box
c.setFillColor(black); c.setFont("Helvetica-Bold", 8)
c.drawString(x * mm, (y + h - 4) * mm, _pdf_text("Qualité des données"))
# Miniature : emprise de la zone, mailles 50 m colorées par classe
avail_w, avail_h = w * 0.45, h - 10
k = min(avail_w / (bbox[2] - bbox[0]), avail_h / (bbox[3] - bbox[1]))
mw, mh = (bbox[2] - bbox[0]) * k, (bbox[3] - bbox[1]) * k
mx, my = x, y + h - 7 - mh
_draw_hatch(c, mx, my, mw, mh, mm) # hachures = non renseigné
for gx0, gy0, gx1, gy1, v in zq["grid_cells"]:
x0, x1 = max(gx0, bbox[0]), min(gx1, bbox[2])
y0, y1 = max(gy0, bbox[1]), min(gy1, bbox[3])
c.setFillColor(HexColor(density_color(v)))
c.rect((mx + (x0 - bbox[0]) * k) * mm, (my + (y0 - bbox[1]) * k) * mm,
((x1 - x0) * k + 0.02) * mm, ((y1 - y0) * k + 0.02) * mm, stroke=0, fill=1)
c.setStrokeColor(black); c.setLineWidth(0.4)
c.rect(mx * mm, my * mm, mw * mm, mh * mm, stroke=1, fill=0)
# Classes
lx, ly = x + mw + 3, y + h - 8
c.setFillColor(black); c.setFont("Helvetica", 5.8)
c.drawString(lx * mm, ly * mm, _pdf_text("Points sol / m²"))
for low, label, color in DENSITY_CLASSES:
ly -= 3.2
c.setFillColor(HexColor(color)); c.rect(lx * mm, ly * mm, 3 * mm, 2.2 * mm, stroke=0, fill=1)
c.setFillColor(black); c.drawString((lx + 4) * mm, (ly + 0.4) * mm, _pdf_text(label))
ly -= 3.2
c.setFillColor(white); c.rect(lx * mm, ly * mm, 3 * mm, 2.2 * mm, stroke=0, fill=1)
_draw_hatch(c, lx, ly, 3.0, 2.2, mm, step=1.4)
c.setStrokeColor(black); c.setLineWidth(0.3)
c.rect(lx * mm, ly * mm, 3 * mm, 2.2 * mm, stroke=1, fill=0)
c.setFillColor(black); c.drawString((lx + 4) * mm, (ly + 0.4) * mm, _pdf_text("non renseigné"))
# Chiffres clés
lines = []
if zq["density_mean"] is None:
lines.append("Densité sol : non renseigné")
else:
lines.append(f"Densité sol moyenne : {zq['density_mean']:.1f} pts/m²".replace(".", ","))
lines.append(f"Maille la plus faible (50 m) : {zq['density_min']:.1f} pts/m²".replace(".", ","))
lines.append(f"Surface sans point sol (interpolée) : {zq['empty_fraction'] * 100:.0f} %")
if zq["acq_start"] is None:
lines.append("Acquisition : non renseigné")
else:
period = zq["acq_start"] if zq["acq_start"] == zq["acq_end"] else \
f"{zq['acq_start']} au {zq['acq_end']}"
label = "Acquisition" if zq["acq_sources"] == {"gps"} else "Date de production du fichier"
lines.append(f"{label} : {period}")
if zq["missing_relief"]:
lines.append("Donnée manquante (sans relief) : " + ", ".join(
f"{c_}_{r_}" for c_, r_ in zq["missing_relief"]))
if zq["missing_quality"]:
lines.append("Qualité non renseignée : " + ", ".join(
f"{c_}_{r_}" for c_, r_ in zq["missing_quality"]))
ty = min(my, ly) - 3.5
font_q, size_q = "Helvetica", 5.8
c.setFillColor(black); c.setFont(font_q, size_q)
max_w = w * mm
for line in lines:
sep = ", " if ", " in line else " "
for part in _wrap_text(c, line, font_q, size_q, max_w, sep=sep):
if ty < y + 1:
return
c.drawString(x * mm, ty * mm, _pdf_text(part))
ty -= 2.9
def _draw_cartouche(c, box, lay, bbox, cx, cy, title, now, mm):
"""Titre, échelle graphique et numérique, nord, date, source."""
from reportlab.lib.colors import black, white
x, y, w, h = box
# Titre et sous-titre partagent la ligne du haut avec la flèche du nord
# (coin haut droit) : largeur bornée pour ne jamais la chevaucher.
max_w_head = _cartouche_text_max_width(w, mm)
txt, title_size = _fit_title(c, _pdf_text(title), "Helvetica-Bold", 10, max_w_head)
c.setFillColor(black); c.setFont("Helvetica-Bold", title_size)
c.drawString(x * mm, (y + h - 5) * mm, txt)
subtitle, subtitle_size = _fit_title(
c, _pdf_text(f"Échelle 1:{_fmt_int(lay.scale)} - {lay.paper} {lay.orient} - {lay.dpi} dpi"),
"Helvetica", 7, max_w_head, min_size=6.0)
c.setFont("Helvetica", subtitle_size)
c.drawString(x * mm, (y + h - 9) * mm, subtitle)
# Échelle graphique : longueur ronde ≤ 40 % de la largeur du bloc
max_m = w * 0.4 / 1000 * lay.scale
length = max((s for s in _SCALEBAR_STEPS if s <= max_m), default=_SCALEBAR_STEPS[0])
bar_mm = length / lay.scale * 1000
bx, by = x, y + h - 15
for k in range(4):
c.setFillColor(black if k % 2 == 0 else white)
c.rect((bx + bar_mm * k / 4) * mm, by * mm, bar_mm / 4 * mm, 1.6 * mm, stroke=1, fill=1)
c.setFillColor(black); c.setFont("Helvetica", 6)
c.drawString(bx * mm, (by - 2.8) * mm, "0")
c.drawRightString((bx + bar_mm) * mm, (by - 2.8) * mm, f"{_fmt_int(length)} m")
# Flèche du nord géographique (la carte est orientée nord du quadrillage)
gamma = _north_arrow_angle(cx, cy)
ax, ay = x + w - 8, y + h - 12
c.saveState(); c.translate(ax * mm, ay * mm); c.rotate(gamma)
p = c.beginPath(); p.moveTo(0, 5 * mm); p.lineTo(-1.8 * mm, -3 * mm); p.lineTo(0, -1.5 * mm)
p.lineTo(1.8 * mm, -3 * mm); p.close()
c.drawPath(p, stroke=0, fill=1)
c.setFont("Helvetica-Bold", 6); c.drawCentredString(0, 6 * mm, "N")
c.restoreState()
# Note d'orientation : ligne à part (jamais sur la même hauteur que l'échelle
# numérique — évite le chevauchement des deux textes alignés à droite/gauche).
side = "ouest" if gamma >= 0 else "est"
north_label = f"nord géographique à {abs(gamma):.2f}° à l'{side} du quadrillage".replace(".", ",")
ty = by - 7
font_c, size_c = "Helvetica", 6
c.setFillColor(black); c.setFont(font_c, size_c)
max_w_c = w * mm
for line in (north_label,
f"Centre L93 : X {_fmt_int(round(cx))} m Y {_fmt_int(round(cy))} m",
f"Zone : {_fmt_int(round(bbox[2] - bbox[0]))} x {_fmt_int(round(bbox[3] - bbox[1]))} m",
f"Exporté le {now:%d/%m/%Y %H:%M}",
"Source : LiDAR HD (c) IGN - rendu lidar_rendu"):
for part in _wrap_text(c, line, font_c, size_c, max_w_c):
if ty < y + 1:
return
c.drawString(x * mm, ty * mm, _pdf_text(part))
ty -= 3.0
def build_pdf(output_dir, lat, lon, paper="A4", orient="paysage", scale=2000,
title=None, now=None, compress=True):
"""Planche PDF d'une zone. Returns (octets PDF, nom de fichier).
Raises:
ValueError: réglage invalide.
NoDataError: aucune dalle du relief dans l'emprise.
"""
import os
import tempfile
from datetime import datetime
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas as rl_canvas
from io import BytesIO
from .quality import load_quality_table
lay = layout(paper, orient, scale)
cx, cy = center_l93(lat, lon)
bbox = map_bbox(cx, cy, lay)
img, _mask, cells = compose_l93(output_dir, bbox, pixel_size(lay))
if not cells:
raise NoDataError("aucune dalle du relief orienté dans cette zone")
now = now or datetime.now()
zq = zone_quality(bbox, load_quality_table(output_dir), cells)
title = (title or "").strip()[:120] or \
"Relief orienté - " + ", ".join(f"{c_}_{r_}" for c_, r_ in cells[:4]) + \
(" ..." if len(cells) > 4 else "")
buf = BytesIO()
c = rl_canvas.Canvas(buf, pagesize=(lay.page_w * mm, lay.page_h * mm),
pageCompression=1 if compress else 0)
c.setTitle(_pdf_text(title)); c.setAuthor("lidar_rendu")
# Image carte en JPEG (incorporée telle quelle : PDF léger)
fd, jpg = tempfile.mkstemp(suffix=".jpg")
os.close(fd)
try:
img.save(jpg, format="JPEG", quality=_JPEG_QUALITY, subsampling=0)
del img
c.drawImage(jpg, lay.map_x * mm, lay.map_y * mm, lay.map_w * mm, lay.map_h * mm)
finally:
os.unlink(jpg)
c.setLineWidth(0.6)
c.rect(lay.map_x * mm, lay.map_y * mm, lay.map_w * mm, lay.map_h * mm, stroke=1, fill=0)
_draw_grid(c, lay, bbox, mm)
legend_box, quality_box, cart_box = _panel_boxes(lay)
_draw_legend(c, legend_box, mm)
_draw_quality(c, quality_box, bbox, zq, mm)
_draw_cartouche(c, cart_box, lay, bbox, cx, cy, title, now, mm)
c.showPage(); c.save()
name = f"relief_{cx / 1000:.3f}_{cy / 1000:.3f}_1-{lay.scale}.pdf"
return buf.getvalue(), name