From 516e4050ddede0d47c9f60bce12c0ef976bdd1af Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 27 Sep 2026 15:38:45 +0200 Subject: [PATCH] Composer l'image de la planche en Lambert 93 depuis les sources de la pyramide Co-Authored-By: Claude Sonnet 5 --- lidar_pipeline/export_pdf.py | 75 +++++++++++++++++++++++++ lidar_pipeline/tests/test_export_pdf.py | 52 +++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/lidar_pipeline/export_pdf.py b/lidar_pipeline/export_pdf.py index 6502a72..5da5edc 100644 --- a/lidar_pipeline/export_pdf.py +++ b/lidar_pipeline/export_pdf.py @@ -117,3 +117,78 @@ def frame(lat, lon, paper, orient, scale): 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) diff --git a/lidar_pipeline/tests/test_export_pdf.py b/lidar_pipeline/tests/test_export_pdf.py index d787dd5..0cb21f9 100644 --- a/lidar_pipeline/tests/test_export_pdf.py +++ b/lidar_pipeline/tests/test_export_pdf.py @@ -67,3 +67,55 @@ def test_export_modules_import_without_numpy(): "import lidar_pipeline.export_pdf, lidar_pipeline.tiles; print('ok')") r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) assert r.returncode == 0, r.stderr + + +def _dalle(tmp_path, col, row, color=(200, 30, 30), px=100, layer="relief_oriente"): + from lidar_pipeline import index, tiles + from lidar_pipeline.tests.test_tiles import _make_dalle + _make_dalle(tmp_path, col, row, [layer], color=color, px=px) + tiles.source_index(tmp_path, force=True) + + +def test_nodata_color_matches_visualizations(): + from lidar_pipeline.export_pdf import NODATA_RGB + from lidar_pipeline.visualizations import RELIEF_NODATA_RGB + assert NODATA_RGB == tuple(RELIEF_NODATA_RGB) + + +def test_compose_l93_full_cell(tmp_path, monkeypatch): + from lidar_pipeline import index + from lidar_pipeline.export_pdf import compose_l93 + monkeypatch.setattr(index, "PANEL_VIZ", None) + _dalle(tmp_path, 1054, 6882) + img, mask, cells = compose_l93(tmp_path, (1054200.0, 6881200.0, 1054400.0, 6881300.0), 1.0) + assert img.size == (200, 100) and cells == [(1054, 6882)] + assert mask.getextrema() == (255, 255) + r, g, b = img.getpixel((100, 50)) + assert r > 150 and g < 80 and b < 80 + + +def test_compose_l93_half_outside_is_white_hatched(tmp_path, monkeypatch): + from lidar_pipeline import index + from lidar_pipeline.export_pdf import compose_l93 + monkeypatch.setattr(index, "PANEL_VIZ", None) + _dalle(tmp_path, 1054, 6882) + # moitié ouest dans la dalle, moitié est hors données + img, mask, cells = compose_l93(tmp_path, (1054900.0, 6881400.0, 1055100.0, 6881500.0), 1.0) + assert mask.getpixel((50, 50)) == 255 and mask.getpixel((150, 50)) == 0 + east = img.crop((110, 0, 200, 100)).convert("L").getextrema() + assert east[1] == 255 and east[0] < 255 # blanc + hachures + + +def test_compose_l93_nodata_pixels_masked(tmp_path, monkeypatch): + from lidar_pipeline import index + from lidar_pipeline.export_pdf import NODATA_RGB, compose_l93 + monkeypatch.setattr(index, "PANEL_VIZ", None) + _dalle(tmp_path, 1054, 6882, color=NODATA_RGB) + img, mask, cells = compose_l93(tmp_path, (1054200.0, 6881200.0, 1054400.0, 6881300.0), 1.0) + assert mask.getextrema() == (0, 0) + + +def test_compose_l93_no_data_returns_empty_cells(tmp_path): + from lidar_pipeline.export_pdf import compose_l93 + img, mask, cells = compose_l93(tmp_path, (0.0, 0.0, 100.0, 100.0), 1.0) + assert cells == [] and mask.getextrema() == (0, 0)