diff --git a/lidar_pipeline/export.py b/lidar_pipeline/export.py index e9269be..dbe6d9a 100644 --- a/lidar_pipeline/export.py +++ b/lidar_pipeline/export.py @@ -138,10 +138,14 @@ def _fmt_scale(v): return f"{v / 1000:g} km" if v >= 1000 else f"{v} m" -def _build_mosaic(images, viz_key, tile_px, n_cols, n_rows, col_min, row_max): - """Colle les dalles bord à bord — aucun trait, aucun espace entre elles.""" +def _build_mosaic_into(canvas, origin_y, images, viz_key, tile_px, + n_cols, n_rows, col_min, row_max): + """Colle les dalles bord à bord dans un canevas existant (offset vertical). + + Aucun trait, aucun espace entre dalles. Coller directement dans le canevas + final évite de garder deux mosaïques pleine résolution en mémoire. + """ from PIL import Image - canvas = Image.new("RGB", (n_cols * tile_px, n_rows * tile_px), "white") for (col, row), per_viz in images.items(): img = Image.open(str(per_viz[viz_key])) if img.mode != "RGB": @@ -151,9 +155,17 @@ def _build_mosaic(images, viz_key, tile_px, n_cols, n_rows, col_min, row_max): # Dalle (col, row) : X ∈ [col, col+1] km, Y ∈ [row-1, row] km. # Ligne 0 de l'image = nord → les rows décroissantes descendent. x = (col - col_min) * tile_px - y = (row_max - row) * tile_px + y = origin_y + (row_max - row) * tile_px canvas.paste(img, (x, y)) img.close() + + +def _build_mosaic(images, viz_key, tile_px, n_cols, n_rows, col_min, row_max): + """Colle les dalles bord à bord dans un canevas neuf dédié à la mosaïque.""" + from PIL import Image + canvas = Image.new("RGB", (n_cols * tile_px, n_rows * tile_px), "white") + _build_mosaic_into(canvas, 0, images, viz_key, tile_px, n_cols, n_rows, + col_min, row_max) return canvas @@ -221,23 +233,22 @@ def _draw_legend(out, draw, legend, layout, y_base): y += layout["line_h"] -def _decorate(mosaic, m_per_px, title, subtitle, legend=None): - """Ajoute un bandeau de titre au-dessus et un pied (échelle + nord) dessous. - - Avec `legend` (entrée de VIZ_LEGENDS), un bandeau explicatif de la couche - est ajouté sous le pied : titre, dégradé de la colormap avec bornes, - lecture du rendu et méthode de calcul. - - L'habillage ne touche jamais la mosaïque : les dalles restent jointives. - """ - from PIL import Image, ImageDraw - w, h = mosaic.size +def _chrome_layout(w, h, legend): + """Dimensions des bandeaux (titre, pied, légende) autour d'une mosaïque w×h.""" + from PIL import Image top_h = max(90, h // 16) foot_h = max(84, h // 20) - layout = _legend_layout(legend, mosaic, w) if legend else None + # Image factice pour la mesure : seule la police compte, pas le contenu + layout = _legend_layout(legend, Image.new("RGB", (1, 1)), w) if legend else None legend_h = layout["height"] if layout else 0 - out = Image.new("RGB", (w, h + top_h + foot_h + legend_h), "white") - out.paste(mosaic, (0, top_h)) + return top_h, foot_h, layout, legend_h + + +def _draw_chrome(out, mosaic_w, mosaic_h, top_h, foot_h, m_per_px, title, + subtitle, layout, legend): + """Dessine sur `out` les bandeaux autour de la zone mosaïque (offset top_h).""" + from PIL import ImageDraw + w, h = mosaic_w, mosaic_h d = ImageDraw.Draw(out) title_font = _load_font(max(22, int(top_h * 0.34))) @@ -275,6 +286,24 @@ def _decorate(mosaic, m_per_px, title, subtitle, legend=None): y_base = h + top_h + foot_h d.line((0, y_base, w, y_base), fill=(210, 210, 210), width=1) _draw_legend(out, d, legend, layout, y_base) + + +def _decorate(mosaic, m_per_px, title, subtitle, legend=None): + """Ajoute un bandeau de titre au-dessus et un pied (échelle + nord) dessous. + + Avec `legend` (entrée de VIZ_LEGENDS), un bandeau explicatif de la couche + est ajouté sous le pied : titre, dégradé de la colormap avec bornes, + lecture du rendu et méthode de calcul. + + L'habillage ne touche jamais la mosaïque : les dalles restent jointives. + """ + from PIL import Image + w, h = mosaic.size + top_h, foot_h, layout, legend_h = _chrome_layout(w, h, legend) + out = Image.new("RGB", (w, h + top_h + foot_h + legend_h), "white") + out.paste(mosaic, (0, top_h)) + _draw_chrome(out, w, h, top_h, foot_h, m_per_px, title, subtitle, + layout, legend) return out @@ -374,20 +403,36 @@ def build_export(vis_dir, cells, viz_keys, resolution, fmt, out_dir, files = [] # fichiers écrits (1 par couche en image, le PDF sinon) try: for key in viz_keys: - mosaic = _build_mosaic(images, key, tile_px, n_cols, n_rows, - col_min, row_max) m_per_px = resolution caps = [c for c in (int(max_side), page_cap) if c] cap = min(caps) if caps else 0 - if cap and max(mosaic.size) > cap: - ratio = cap / max(mosaic.size) - size = (max(1, round(mosaic.size[0] * ratio)), - max(1, round(mosaic.size[1] * ratio))) + w = n_cols * tile_px + h = n_rows * tile_px + if cap and max(w, h) > cap: + # Mosaïque réduite (téléphone / pages PDF) : taille bornée par + # le cap, le chemin mosaïque → resize → habillage reste simple + ratio = cap / max(w, h) + size = (max(1, round(w * ratio)), max(1, round(h * ratio))) + mosaic = _build_mosaic(images, key, tile_px, n_cols, n_rows, + col_min, row_max) mosaic = mosaic.resize(size, resample) m_per_px = resolution / ratio - decorated = _decorate(mosaic, m_per_px, VIZ_LABELS.get(key, key), - subtitle, legend=VIZ_LEGENDS.get(key)) - mosaic.close() + decorated = _decorate(mosaic, m_per_px, + VIZ_LABELS.get(key, key), subtitle, + legend=VIZ_LEGENDS.get(key)) + mosaic.close() + else: + # Pleine résolution : collage DIRECT dans le canevas habillé — + # jamais deux mosaïques entières en mémoire (la garde + # _MAX_CANVAS_BYTES correspond alors à la taille réelle) + legend = VIZ_LEGENDS.get(key) + top_h, foot_h, layout, legend_h = _chrome_layout(w, h, legend) + decorated = Image.new( + "RGB", (w, h + top_h + foot_h + legend_h), "white") + _build_mosaic_into(decorated, top_h, images, key, tile_px, + n_cols, n_rows, col_min, row_max) + _draw_chrome(decorated, w, h, top_h, foot_h, m_per_px, + VIZ_LABELS.get(key, key), subtitle, layout, legend) if fmt != "pdf": key_file = out_dir / export_filename(cells, resolution, [key], fmt) decorated.save(str(key_file), **save_kw)