Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts, compose files and AGENTS.md are now English. Data keys stay unchanged (relief_oriente, densite_sol, visualisations/, API JSON keys, link params). Wrong comments and help defaults found along the way are corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
696 lines
28 KiB
Python
696 lines
28 KiB
Python
"""PDF export of an area: printable field sheet of the oriented relief.
|
||
|
||
Composed directly in Lambert 93 from the pyramid sources (tiles.py) — exact
|
||
scale, grid aligned on the tiles — then drawn with reportlab (vector text,
|
||
grid and legend). Runs in the lightweight image: Pillow + pyproj +
|
||
reportlab, without 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") # API values ("paysage" = landscape)
|
||
_ORIENT_LABELS = {"portrait": "portrait", "paysage": "landscape"}
|
||
SCALES = (1000, 2000, 5000, 10000)
|
||
DPI = {"A4": 300, "A3": 250} # bounds the Pi's memory (~36 MB in A3)
|
||
NATIVE_RES_M = 0.2
|
||
MARGIN_MM = 10.0 # non-printable edge
|
||
ANNOT_MM = 7.0 # coordinate band around the map
|
||
PANEL_SIDE_MM = 64.0 # side panel on the right (landscape)
|
||
PANEL_BOTTOM_MM = 72.0 # bottom panel (portrait)
|
||
_GRID_STEPS = {1000: 100, 2000: 100, 5000: 500, 10000: 1000}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Layout:
|
||
"""Sheet geometry, in mm, origin at the bottom left (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):
|
||
"""Geometry of a sheet; ValueError if a setting is invalid."""
|
||
if paper not in PAPERS_MM:
|
||
raise ValueError(f"unknown paper size: {paper} (A4 or A3)")
|
||
if orient not in ORIENTS:
|
||
raise ValueError(f"unknown orientation: {orient} (portrait or paysage)")
|
||
try:
|
||
scale = int(scale)
|
||
except (TypeError, ValueError):
|
||
raise ValueError(f"invalid scale: {scale}") from None
|
||
if scale not in SCALES:
|
||
raise ValueError("scale not offered: 1:" + str(scale)
|
||
+ " (1:1000, 1:2000, 1:5000 or 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):
|
||
"""WGS84 center → Lambert 93; ValueError if not finite."""
|
||
from .tiles import wgs84_to_l93
|
||
lat, lon = float(lat), float(lon)
|
||
if not (math.isfinite(lat) and math.isfinite(lon)):
|
||
raise ValueError("invalid center coordinates")
|
||
cx, cy = wgs84_to_l93(lon, lat)
|
||
if not (math.isfinite(cx) and math.isfinite(cy)):
|
||
raise ValueError("center outside the Lambert 93 domain")
|
||
return cx, cy
|
||
|
||
|
||
def map_bbox(cx, cy, lay):
|
||
"""L93 ground footprint of the map area (paper × scale)."""
|
||
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):
|
||
"""Ground size of a printed pixel (m), never finer than the native resolution."""
|
||
return max(NATIVE_RES_M, lay.scale * 0.0254 / lay.dpi)
|
||
|
||
|
||
def grid_step(scale):
|
||
"""L93 grid spacing (m) for the scale."""
|
||
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):
|
||
"""Printable frame for the map: L93 footprint and WGS84 corners (NW, NE, SE, SW)."""
|
||
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])}
|
||
|
||
|
||
# Color of the oriented relief's no-data pixels (copy of
|
||
# visualizations.RELIEF_NODATA_RGB: that module imports numpy, absent from
|
||
# the lightweight image; equality checked by the tests).
|
||
NODATA_RGB = (38, 38, 41)
|
||
_NODATA_TOLERANCE = 3 # tolerated per-channel difference (resampling)
|
||
_HATCH_STEP_PX = 14
|
||
|
||
|
||
def _paste_l93(canvas, mask, src, bbox, px):
|
||
"""Crop and resample an L93 source into the sheet image."""
|
||
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):
|
||
"""White pattern with gray hatching (areas without data)."""
|
||
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):
|
||
"""RGB image of the L93 footprint at px_size m/px, mask of the painted
|
||
pixels and contributing tiles. Outside the data: hatched white."""
|
||
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
|
||
# Ground point density classes (pts/m²): red = weak data.
|
||
DENSITY_CLASSES = [
|
||
(0.0, "under 1", "#d7301f"),
|
||
(1.0, "1 to 3", "#fc8d59"),
|
||
(3.0, "3 to 6", "#fdcc8a"),
|
||
(6.0, "6 to 10", "#a1d99b"),
|
||
(10.0, "10 and over", "#31a354"),
|
||
]
|
||
|
||
|
||
def lab_to_rgb(L, a, b):
|
||
"""CIELAB (D65) → 8-bit sRGB, same formula as the map (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):
|
||
"""Color of a slope orientation (0 = N, clockwise), like the map's rose."""
|
||
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):
|
||
"""Class color of a ground density (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):
|
||
"""Text encodable by the standard fonts (WinAnsi/cp1252)."""
|
||
return "".join(ch if ch.encode("cp1252", "ignore") else "?" for ch in str(s))
|
||
|
||
|
||
def _cells_of_bbox(bbox):
|
||
"""1 km LHD tiles intersecting an L93 footprint."""
|
||
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):
|
||
"""Aggregate the tile quality over a footprint (area-weighted)."""
|
||
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):
|
||
"""No oriented-relief tile in the requested footprint."""
|
||
|
||
|
||
_JPEG_QUALITY = 90
|
||
_SCALEBAR_STEPS = (10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000)
|
||
|
||
|
||
def _fmt_int(n):
|
||
return f"{int(n):,}"
|
||
|
||
|
||
def _hex(rgb):
|
||
return "#%02x%02x%02x" % tuple(rgb)
|
||
|
||
|
||
def _convergence_deg(cx, cy):
|
||
"""Meridian convergence: azimuth (°) of L93 grid north measured from true
|
||
north (clockwise), positive east of the central meridian (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):
|
||
"""reportlab rotation angle (counter-clockwise, `Canvas.rotate`) so that
|
||
the north arrow — drawn pointing up, i.e. towards grid north — points to
|
||
true north."""
|
||
return _convergence_deg(cx, cy)
|
||
|
||
|
||
def _wrap_text(c, text, font, size, max_width, sep=" "):
|
||
"""Split `text` on `sep` into lines that each fit in `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):
|
||
"""Shrink the font size down to `min_size`, then truncate with an
|
||
ellipsis so that `text` fits in `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 # width (mm) reserved for the north arrow + its "N", top-right corner
|
||
|
||
|
||
def _cartouche_text_max_width(w, mm):
|
||
"""Width (pt) available for the title block's title/subtitle, excluding
|
||
the column reserved for the north arrow (top-right corner of the block,
|
||
same line)."""
|
||
return max(0.0, w - _ARROW_COLUMN_MM) * mm
|
||
|
||
|
||
def _draw_hatch(c, x, y, w, h, mm, step=3.0):
|
||
"""Gray hatching on a white background (cell without data), as on the map."""
|
||
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):
|
||
"""Panel rectangles (x, y, w, h) in mm: legend, quality, title block."""
|
||
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):
|
||
"""L93 grid + values in the margin + WGS84 corners."""
|
||
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}"
|
||
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}"
|
||
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 "W"
|
||
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"Lambert 93 grid (km), {_fmt_int(step)} m spacing - corners in WGS84"))
|
||
|
||
|
||
def _draw_legend(c, box, mm):
|
||
"""Lightness bar + orientation rose + text from 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("Legend - oriented relief"))
|
||
# Lightness bar (L* 20 → 90, neutral gray)
|
||
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("hollow, ditch"))
|
||
c.drawRightString((bx + bw) * mm, (by - 2.8) * mm, _pdf_text("bump, ridge"))
|
||
c.drawString(bx * mm, (by + bh + 0.8) * mm, _pdf_text("Lightness = micro-relief"))
|
||
# Orientation rose (color = slope orientation)
|
||
r_out, r_in = 8.5, 3.9 # leaves room for the reading sentences
|
||
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),
|
||
("SW", 225), ("W", 270), ("NW", 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("Hue = orientation"))
|
||
c.drawString((rcx + r_out + 5) * mm, (rcy - 1) * mm, _pdf_text("of the slope"))
|
||
# Legend text ("How to read": sentences wrapped to the box width)
|
||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||
ty = rcy - r_out - 6
|
||
c.setFont("Helvetica", 5.2) # the reading sentences fit in the box, A4 included
|
||
for sentence in VIZ_LEGENDS[LAYER].get("reading") or VIZ_LEGENDS[LAYER]["legend"].split("\n"):
|
||
# On the sheet, pixels without a ground point are hatched white
|
||
# (not black as on the map): same sentence, adapted cue.
|
||
if sentence.startswith("Black gaps"):
|
||
sentence = "White hatched areas" + sentence[len("Black gaps"):]
|
||
# The hue is already explained next to the rose: redundant sentence.
|
||
if sentence.startswith("Hue = slope 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 # slightly larger spacing between two sentences
|
||
|
||
|
||
def _draw_quality(c, box, bbox, zq, mm):
|
||
"""Quality inset: ground density thumbnail + key figures."""
|
||
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("Data quality"))
|
||
# Thumbnail: footprint of the area, 50 m cells colored by class
|
||
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) # hatching = not available
|
||
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("Ground points / 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("not available"))
|
||
# Key figures
|
||
lines = []
|
||
if zq["density_mean"] is None:
|
||
lines.append("Ground density: not available")
|
||
else:
|
||
lines.append(f"Mean ground density: {zq['density_mean']:.1f} pts/m²")
|
||
lines.append(f"Weakest cell (50 m): {zq['density_min']:.1f} pts/m²")
|
||
lines.append(f"Area without ground points (interpolated): {zq['empty_fraction'] * 100:.0f}%")
|
||
if zq["acq_start"] is None:
|
||
lines.append("Acquisition: not available")
|
||
else:
|
||
period = zq["acq_start"] if zq["acq_start"] == zq["acq_end"] else \
|
||
f"{zq['acq_start']} to {zq['acq_end']}"
|
||
label = "Acquisition" if zq["acq_sources"] == {"gps"} else "File production date"
|
||
lines.append(f"{label}: {period}")
|
||
if zq["missing_relief"]:
|
||
lines.append("Missing data (no relief): " + ", ".join(
|
||
f"{c_}_{r_}" for c_, r_ in zq["missing_relief"]))
|
||
if zq["missing_quality"]:
|
||
lines.append("Quality not available: " + ", ".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):
|
||
"""Title, graphic and numeric scale, north, date, source."""
|
||
from reportlab.lib.colors import black, white
|
||
x, y, w, h = box
|
||
# Title and subtitle share the top line with the north arrow (top-right
|
||
# corner): bounded width so they never overlap it.
|
||
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"Scale 1:{_fmt_int(lay.scale)} - {lay.paper} "
|
||
f"{_ORIENT_LABELS.get(lay.orient, 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)
|
||
# Graphic scale: round length ≤ 40% of the block width
|
||
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")
|
||
# True north arrow (the map is oriented to grid north)
|
||
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()
|
||
# Orientation note: a line of its own (never at the height of the
|
||
# numeric scale — avoids overlapping the right- and left-aligned texts).
|
||
side = "west" if gamma >= 0 else "east"
|
||
north_label = f"True north {abs(gamma):.2f}° {side} of grid north"
|
||
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"L93 center: X {_fmt_int(round(cx))} m Y {_fmt_int(round(cy))} m",
|
||
f"Area: {_fmt_int(round(bbox[2] - bbox[0]))} x {_fmt_int(round(bbox[3] - bbox[1]))} m",
|
||
f"Exported on {now:%Y-%m-%d %H:%M}",
|
||
"Source: LiDAR HD © IGN - rendered by 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):
|
||
"""PDF sheet of an area. Returns (PDF bytes, file name).
|
||
|
||
Raises:
|
||
ValueError: invalid setting.
|
||
NoDataError: no relief tile in the footprint.
|
||
"""
|
||
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("no oriented-relief tile in this area")
|
||
now = now or datetime.now()
|
||
zq = zone_quality(bbox, load_quality_table(output_dir), cells)
|
||
title = (title or "").strip()[:120] or \
|
||
"Oriented relief - " + ", ".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")
|
||
# Map image as JPEG (embedded as is: lightweight PDF)
|
||
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
|