From 1336571da75d7ed384ee2405fb1d539cbfb31953 Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 27 Sep 2026 15:47:12 +0200 Subject: [PATCH] =?UTF-8?q?Dessiner=20la=20planche=20PDF=20:=20quadrillage?= =?UTF-8?q?=20L93,=20l=C3=A9gende,=20encart=20qualit=C3=A9=20et=20cartouch?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- lidar_pipeline/export_pdf.py | 284 ++++++++++++++++++++++++ lidar_pipeline/tests/test_export_pdf.py | 66 ++++++ 2 files changed, 350 insertions(+) diff --git a/lidar_pipeline/export_pdf.py b/lidar_pipeline/export_pdf.py index 21ddf79..34614dc 100644 --- a/lidar_pipeline/export_pdf.py +++ b/lidar_pipeline/export_pdf.py @@ -305,3 +305,287 @@ def zone_quality(bbox, table, cells_with_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): + """Angle (°) du nord géographique par rapport au nord du quadrillage L93.""" + 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 _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) + txt = f"{lat:.5f} N {lon:.5f} E" + (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 = 11.0, 5.0 + 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.5) + for label, deg in (("N", 0), ("E", 90), ("S", 180), ("O", 270)): + a = math.radians(deg) + c.drawCentredString((rcx + (r_out + 2) * math.sin(a)) * mm, + (rcy + (r_out + 2) * 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 + ty = rcy - r_out - 6 + c.setFont("Helvetica", 5.8) + for line in VIZ_LEGENDS[LAYER]["legend"].split("\n"): + for part in line.split(" | "): + if ty < y + 1: + return + c.drawString(x * mm, ty * mm, _pdf_text(part)) + ty -= 2.9 + + +def _draw_quality(c, box, bbox, zq, mm): + """Encart qualité : miniature de densité sol + chiffres clés.""" + from reportlab.lib.colors import HexColor, black, Color + 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 + c.setFillColor(Color(0.85, 0.85, 0.85)) + c.rect(mx * mm, my * mm, mw * mm, mh * mm, stroke=0, fill=1) # gris = 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.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(Color(0.85, 0.85, 0.85)); 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("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("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 + c.setFont("Helvetica", 5.8) + for line in lines: + if ty < y + 1: + break + c.drawString(x * mm, ty * mm, _pdf_text(line)) + 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 + c.setFillColor(black); c.setFont("Helvetica-Bold", 10) + c.drawString(x * mm, (y + h - 5) * mm, _pdf_text(title)[:80]) + c.setFont("Helvetica", 7) + c.drawString(x * mm, (y + h - 9) * mm, + _pdf_text(f"Échelle 1:{_fmt_int(lay.scale)} - {lay.paper} {lay.orient} - {lay.dpi} dpi")) + # É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 = _convergence_deg(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() + c.setFont("Helvetica", 5.5) + c.drawRightString((x + w) * mm, (ay - 6) * mm, + _pdf_text(f"convergence L93 {gamma:+.2f}°".replace(".", ","))) + ty = by - 7 + c.setFont("Helvetica", 6) + for line in (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"): + if ty < y + 1: + break + c.drawString(x * mm, ty * mm, _pdf_text(line)) + 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 diff --git a/lidar_pipeline/tests/test_export_pdf.py b/lidar_pipeline/tests/test_export_pdf.py index 346411e..76cd494 100644 --- a/lidar_pipeline/tests/test_export_pdf.py +++ b/lidar_pipeline/tests/test_export_pdf.py @@ -175,3 +175,69 @@ def test_zone_quality_missing_cells(): assert z["density_mean"] is None and z["acq_start"] is None assert z["missing_quality"] == [(1054, 6882), (1055, 6882)] assert z["missing_relief"] == [(1055, 6882)] + + +def _mediabox(pdf): + import re + m = re.search(rb"/MediaBox \[\s*0 0 ([\d.]+) ([\d.]+)\s*\]", pdf) + return float(m.group(1)), float(m.group(2)) + + +def _cell_center_wgs84(col, row): + from lidar_pipeline.tiles import _transformer + lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(col * 1000 + 500, (row - 1) * 1000 + 500) + return lat, lon + + +def test_build_pdf_a4_landscape(tmp_path, monkeypatch): + from lidar_pipeline import index + from lidar_pipeline.export_pdf import build_pdf + from lidar_pipeline.quality import write_quality + monkeypatch.setattr(index, "PANEL_VIZ", None) + _dalle(tmp_path, 1054, 6882, px=200) + write_quality(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", _q(6.5)) + lat, lon = _cell_center_wgs84(1054, 6882) + pdf, name = build_pdf(tmp_path, lat, lon, "A4", "paysage", 2000, + title="Prospection 🚀 bois", compress=False) + assert pdf.startswith(b"%PDF") + w, h = _mediabox(pdf) + assert abs(w - 841.89) < 0.5 and abs(h - 595.28) < 0.5 + assert name.startswith("relief_1054.") and name.endswith("_1-2000.pdf") + for s in (b"Prospection ? bois", b"1:2 000", b"Densit", b"2023-03-15", b"LiDAR HD"): + assert s in pdf, s + + +def test_build_pdf_a3_portrait_size(tmp_path, monkeypatch): + from lidar_pipeline import index + from lidar_pipeline.export_pdf import build_pdf + monkeypatch.setattr(index, "PANEL_VIZ", None) + _dalle(tmp_path, 1054, 6882) + lat, lon = _cell_center_wgs84(1054, 6882) + pdf, _name = build_pdf(tmp_path, lat, lon, "A3", "portrait", 5000) + w, h = _mediabox(pdf) + assert abs(w - 841.89) < 0.5 and abs(h - 1190.55) < 0.5 + + +def test_build_pdf_partial_zone_hatched(tmp_path, monkeypatch): + """Zone à cheval sur le bord des données : planche produite, dalle absente listée.""" + from lidar_pipeline import index + from lidar_pipeline.export_pdf import build_pdf + from lidar_pipeline.tiles import _transformer + monkeypatch.setattr(index, "PANEL_VIZ", None) + _dalle(tmp_path, 1054, 6882) + lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(1055000.0, 6881500.0) + pdf, _ = build_pdf(tmp_path, lat, lon, "A4", "paysage", 2000, compress=False) + assert pdf.startswith(b"%PDF") and b"1055_6882" in pdf + assert b"non renseign" in pdf + + +def test_build_pdf_no_data_raises(tmp_path): + import pytest + from lidar_pipeline.export_pdf import NoDataError, build_pdf + with pytest.raises(NoDataError): + build_pdf(tmp_path, 46.5, 2.5, "A4", "paysage", 2000) + + +def test_fmt_int(): + from lidar_pipeline.export_pdf import _fmt_int + assert _fmt_int(2000) == "2 000" and _fmt_int(500) == "500"