Translate the whole project to English and fix outdated comments and help

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>
This commit is contained in:
Antoine
2026-09-27 23:16:45 +02:00
parent cc1c22d2b8
commit fb892ea9f2
52 changed files with 4356 additions and 4323 deletions

View File

@ -1,9 +1,14 @@
"""Rendering module: colormap registry, GeoTIFF-to-image conversion, and PDF report generation.
"""Rendering module: colormap registry and GeoTIFF-to-image conversion.
Contains:
- COLORMAPS: registry mapping filename keywords to (cmap, title, legend, description)
- tif_to_png(): convert a GeoTIFF to a WebP/AVIF visualization with legend, scale bar, north arrow
- generate_pdf_report(): generate an A3 PDF report with all visualizations
- COLORMAPS: registry mapping filename keywords to a normalization (cmap,
vmin/vmax or quantile knots) plus the texts merged from VIZ_LEGENDS
- tif_to_crop(): convert a GeoTIFF to a bare 1 km AVIF/WebP tile (used by the
pipeline for the map mosaic)
- tif_to_png(): convert a GeoTIFF to an annotated AVIF/WebP sheet (legend,
scale bar, north arrow) — not called by the pipeline
- generate_pdf_report(): legacy per-tile A3 PDF report, no longer called
(the map's PDF export lives in export_pdf.py)
"""
import logging
@ -69,13 +74,14 @@ _FRANCE_OUTLINE_L93 = np.array([
# ============================================================
# Each entry: keyword → (cmap, vmin_mode, vmax_mode)
# vmin_mode/vmax_mode: 'percentile_X_Y' or '0_max_X' or 'symmetric_X_Y' or 'fixed_0_1'
# For RGB images (ortho/topo), special handling is done in tif_to_png.
# Les textes (title/legend/description) viennent de VIZ_LEGENDS (index.py,
# source unique partagée avec l'export multi-dalles) — fusion ci-dessous.
# For RGB images (ortho/topo/relief_oriente), see RGB_LEGENDS below.
# The texts (title/legend/description) come from VIZ_LEGENDS (index.py, the
# single source shared with the map UI, the TileJSON and the PDF export) —
# merged below.
COLORMAPS = {
# === Famille RELIEF : rouge=surélévation, bleu=dépression ===
# Diverging: rouge vif=positif, bleu vif=négatif, blanc=plat
# === RELIEF family: red = raised, blue = depression ===
# Diverging: bright red = positive, bright blue = negative, white = flat
'mslrm': {
'cmap': 'seismic',
'vmin_mode': 'fixed', 'vmin_val': -3,
@ -86,7 +92,7 @@ COLORMAPS = {
'vmin_mode': 'fixed', 'vmin_val': -3,
'vmax_mode': 'fixed', 'vmax_val': 3,
},
# === Famille OUVERTURE : séquentiel, valeurs normalisées ===
# === OPENNESS family: sequential, normalized values ===
'positive_openness': {
'cmap': 'YlOrBr',
'vmin_mode': 'fixed', 'vmin_val': -3,
@ -102,8 +108,8 @@ COLORMAPS = {
'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'fixed', 'vmax_val': 1,
},
# === Famille SCALAIRE : propriétés non divergentes ===
# Clé = mot-clé exact du nom de fichier de sortie (hillshade_multi).
# === SCALAR family: non-diverging properties ===
# Key = exact keyword of the output file name (hillshade_multi).
'hillshade_multi': {
'cmap': 'gray',
'vmin_mode': 'fixed', 'vmin_val': 0,
@ -121,19 +127,19 @@ COLORMAPS = {
},
'roughness': {
'cmap': 'plasma',
# vmax fixe (p98 médian mesuré sur 20 dalles réelles) : un vmax au
# percentile par tuile rendait l'échelle non jointive entre tuiles.
# Fixed vmax (median p98 measured on 20 real tiles): a per-tile
# percentile vmax made the scale inconsistent from tile to tile.
'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'fixed', 'vmax_val': 3.8,
},
'wavelet': {
'cmap': 'inferno',
# Nœuds (percentile → valeur) mesurés sur 20 tuiles réelles par
# résolution avec l'algorithme actuel (détendage 35 m, échelles
# 1-50 m) : médiane inter-tuiles des percentiles par tuile, robuste
# aux tuiles très structurées. Distribution plus large qu'avant
# détendage : le fond macro-relief supprimé, les petites structures
# percent bien plus au-dessus du bruit (p98 ≈ 9 vs 1,65 avant).
# Knots (value → percentile) measured on 20 real tiles per
# resolution with the current algorithm (35 m detrending, 1-50 m
# scales): cross-tile median of the per-tile percentiles, robust to
# highly structured tiles. Wider distribution than before
# detrending: with the macro-relief background removed, small
# structures stand out far more above the noise (p98 ≈ 9 vs 1.65 before).
'knots': {
0.5: ([0.089, 0.213, 0.296, 0.445, 0.602, 0.783, 1.0, 1.274,
1.661, 2.32, 3.842, 5.661, 8.936, 11.93, 15.06],
@ -160,7 +166,7 @@ COLORMAPS = {
'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'fixed', 'vmax_val': 1,
},
# Densité de points sol : niveau 0..15 (visualizations.density_levels)
# Ground point density: level 0..15 (visualizations.density_levels)
'densite_sol': {
'cmap': 'gray',
'vmin_mode': 'fixed', 'vmin_val': 0,
@ -168,28 +174,29 @@ COLORMAPS = {
},
}
# Couches en aplats de gris codés (niveaux discrets) : image niveaux de gris
# (mode L) encodée SANS PERTE (AVIF q100 en L, seul réglage exact) — l'AVIF q55-60 déplace ~20 % des pixels jusqu'à 4
# niveaux sur ces aplats. Elles restent à leur résolution propre (1 m pour
# la densité : ~150–260 Ko par dalle).
# Layers made of coded flat gray levels (discrete levels): grayscale image
# (mode L) encoded LOSSLESSLY as WebP (tif_to_crop; Pillow ignores
# lossless=True in AVIF) — AVIF q55-60 shifts ~20% of the pixels by up to 4
# levels on these flat areas. They stay at their own resolution (1 m for
# the density: ~150–260 KB per tile).
LOSSLESS_GRAY_KEYWORDS = ('densite_sol',)
# RGB entries (ortho/topo) are handled specially
# RGB entries (ortho/topo/relief_oriente) are handled specially
RGB_LEGENDS = {
'ortho': {},
'topo': {},
'relief_oriente': {}, # RGB calculé (visualizations.generate_relief_oriente)
'relief_oriente': {}, # computed RGB (visualizations.generate_relief_oriente)
}
RGB_KEYWORDS = tuple(RGB_LEGENDS)
# Vitesse d'encodage AVIF (libavif, 0 = lent/compact … 10 = rapide). Mesuré
# sur une dalle 5000 × 5000 px (q60) : défaut 4,1 s ; speed 9 0,6 s pour +3 %
# de taille et −0,3 dB de PSNR, invisible. L'encodage était l'étape la plus
# longue du rendu d'une couche.
# AVIF encoding speed (libavif, 0 = slow/compact … 10 = fast). Measured on
# a 5000 × 5000 px tile (q60): default 4.1 s; speed 9 0.6 s for +3% size and
# −0.3 dB PSNR, invisible. Encoding was the longest step of rendering a
# layer.
AVIF_SPEED = 9
# Fusion des textes de légende (titre / lecture du rendu / méthode de calcul)
# depuis la source unique VIZ_LEGENDS (index.py, sans dépendance lourde).
# Merge the legend texts (title / how to read the rendering / computation
# method) from the single source VIZ_LEGENDS (index.py, no heavy dependency).
from .index import VIZ_LEGENDS, parse_basename_coords
for _key, _info in COLORMAPS.items():
@ -199,21 +206,21 @@ for _key, _info in RGB_LEGENDS.items():
def _core_tile_window(tif_file, src):
"""Fenêtre raster de la dalle nominale 1 km (raccord des bords).
"""Raster window of the nominal 1 km tile (edge stitching).
Un TIF produit sur une emprise étendue (bande de raccord remplie avec les
tuiles voisines, cf. --edge-buffer dans dtm.py) est recadré sur la dalle
LHD exacte : les images finales restent des carrés de 1 km alignés sur la
grille multi-tuiles, sans artefact au passage d'une tuile à l'autre.
Retourne None si le TIF ne déborde pas (rien à recadrer) ou si le nom ne
porte pas de coordonnées LHD.
A TIF produced over an extended footprint (edge buffer filled with the
neighboring tiles, see --edge-buffer in dtm.py) is cropped to the exact
LHD tile: the final images remain 1 km squares aligned on the
multi-tile grid, with no artifact from one tile to the next.
Returns None if the TIF does not extend beyond it (nothing to crop) or if
the name carries no LHD coordinates.
"""
coords = parse_basename_coords(Path(tif_file).stem)
if coords is None:
return None
col_km, row_km = coords
# Grille LHD : (col, row) = coin nord-ouest en km → X ∈ [col, col+1] km,
# Y ∈ [row-1, row] km (bord nord = row).
# LHD grid: (col, row) = north-west corner in km → X ∈ [col, col+1] km,
# Y ∈ [row-1, row] km (north edge = row).
west = float(col_km) * 1000.0
north = float(row_km) * 1000.0
south = north - 1000.0
@ -234,7 +241,7 @@ def _apply_colormap(data, tif_file, resolution=None):
"""Apply the registered colormap normalization to data based on filename.
Returns (data, cmap, title, legend_label, description, is_rgb, vmin, vmax)
où vmin/vmax sont les bornes physiques de la plage rendue (None si sans objet).
where vmin/vmax are the physical bounds of the rendered range (None when not applicable).
"""
name = str(tif_file).lower()
@ -244,8 +251,8 @@ def _apply_colormap(data, tif_file, resolution=None):
info = RGB_LEGENDS[key]
return data, None, info['title'], info['legend'], info['description'], True, None, None
# Find matching colormap — tri par longueur décroissante : en cas de
# chevauchement de mots-clés dans un nom de fichier, le plus long prime
# Find matching colormap — sorted by decreasing length: when keywords
# overlap in a file name, the longest one wins
for key in sorted(COLORMAPS.keys(), key=len, reverse=True):
info = COLORMAPS[key]
if key in name:
@ -253,18 +260,18 @@ def _apply_colormap(data, tif_file, resolution=None):
valid_data = valid_data[~np.isnan(valid_data)]
if len(valid_data) == 0:
logger.warning(f" Aucune donnée valide pour {Path(tif_file).name} — colormap ignorée")
logger.warning(f" No valid data in {Path(tif_file).name} — colormap skipped")
return data, 'terrain', Path(tif_file).stem.replace('_', ' ').title(), '', '', False, None, None
vmin = vmax = None
knots = info.get('knots')
if knots is not None:
# Étalonnage quantile figé (appariement d'histogramme global,
# cf. normalisation radiométrique des mosaïques) : fonction de
# transfert en nœuds mesurés une fois sur un échantillon de
# tuiles — même valeur → même couleur sur toutes les tuiles,
# toute la palette utilisée, insensible aux queues locales.
# Frozen quantile calibration (global histogram matching,
# as in radiometric normalization of mosaics): transfer
# function given as knots measured once on a sample of
# tiles — same value → same color on every tile, the whole
# palette is used, insensitive to local tails.
if isinstance(knots, dict):
key = min(knots, key=lambda r: abs(float(r) - float(resolution or 0.5)))
knots = knots[key]
@ -307,11 +314,11 @@ def _apply_colormap(data, tif_file, resolution=None):
if len(valid_data) == 0:
return data, 'terrain', Path(tif_file).stem.replace('_', ' ').title(), '', '', False
p2, p98 = np.percentile(valid_data, (2, 98))
# Garde contre les tuiles quasi constantes : plage nulle → division par zéro
# Guard against near-constant tiles: zero range → division by zero
span = max(p98 - p2, 1e-6)
data = np.clip((data - p2) / span, 0, 1)
title = Path(tif_file).stem.replace('_', ' ').title()
return data, 'terrain', title, 'Altitude normalisée', '', False, p2, p98
return data, 'terrain', title, 'Normalized elevation', '', False, p2, p98
def _download_location_map(min_x, max_x, min_y, max_y):
@ -371,8 +378,8 @@ def _download_location_map(min_x, max_x, min_y, max_y):
'min_y': context_min_y, 'max_y': context_max_y,
}
cached = (result, bounds)
# Éviction FIFO : chaque entrée ~12 Mo (carte ~30 km au zoom 10) —
# un run long sur plusieurs zones ne doit pas empiler sans limite
# FIFO eviction: each entry ~12 MB (~30 km map at zoom 10) — a long
# run over several areas must not pile up without limit
while len(_location_map_cache) >= 4:
_location_map_cache.pop(next(iter(_location_map_cache)))
_location_map_cache[cache_key] = cached
@ -380,7 +387,7 @@ def _download_location_map(min_x, max_x, min_y, max_y):
return result
except Exception as e:
logger.debug(f" Carte de localisation IGN non disponible: {e}")
logger.debug(f" IGN location map unavailable: {e}")
return None
@ -412,7 +419,8 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
resolution: Grid resolution in m/px.
keep_tif: If True, keep the source TIFF after conversion.
source_info: Dict with method/date/basename for metadata.
quality: Image quality (1-100). Use 100 for lossless. Default 60.
quality: Image quality (1-100). Default 60. 100 requests lossless,
which only WebP honors (Pillow ignores lossless=True in AVIF).
output_format: Output format ('webp' or 'avif'). Default 'avif'.
Returns:
@ -438,8 +446,8 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
transform = src.transform
crs = src.crs
# Raccord des bords : recadrage sur la dalle nominale 1 km (les
# coordonnées GPS, l'échelle et la légende suivent le recadrage).
# Edge stitching: crop to the nominal 1 km tile (GPS coordinates,
# scale and legend follow the crop).
core_win = _core_tile_window(tif_file, src)
if core_win is not None:
rows = slice(core_win.row_off, core_win.row_off + core_win.height)
@ -518,7 +526,7 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
# data is normalized 0-1 from _apply_colormap; apply cmap to get RGBA
# Save the colormap for colorbar before converting to RGBA
saved_cmap = plt.get_cmap(cmap) if isinstance(cmap, str) else cmap
# Bornes physiques de la colorbar (unités réelles, pas 0-1)
# Physical colorbar bounds (real units, not 0-1)
if cmap_vmin is not None and cmap_vmax is not None:
saved_vmin, saved_vmax = float(cmap_vmin), float(cmap_vmax)
else:
@ -542,7 +550,7 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
fig = plt.figure(figsize=(fig_width, fig_height), facecolor='white')
# Fixed data area position — identical for ALL visualization types
# This ensures overlay/superposition works across all WebP images
# This ensures overlay/superposition works across all output images
data_left = 0.08
data_bottom = 0.19
data_width_frac = 0.74
@ -614,14 +622,14 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
x_labels = [f"{(min_x + xp * pixel_size_x)/1000:.1f}" for xp in x_positions]
ax.set_xticks(x_positions)
ax.set_xticklabels(x_labels, fontsize=8)
ax.set_xlabel('Est (km) - Lambert 93', fontsize=9, fontweight='bold')
ax.set_xlabel('Easting (km) - Lambert 93', fontsize=9, fontweight='bold')
y_ticks_count = 5
y_positions = np.linspace(0, height - 1, y_ticks_count)
y_labels = [f"{(max_y - yp * pixel_size_y)/1000:.1f}" for yp in y_positions]
ax.set_yticks(y_positions)
ax.set_yticklabels(y_labels, fontsize=8)
ax.set_ylabel('Nord (km) - Lambert 93', fontsize=9, fontweight='bold')
ax.set_ylabel('Northing (km) - Lambert 93', fontsize=9, fontweight='bold')
ax.tick_params(axis='both', which='both', direction='out', length=3,
width=0.8, colors='black')
@ -652,7 +660,7 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
north_ax.add_patch(MplPolygon([[0, 0.5], [-0.2, 0.7], [0, 1.0], [0.2, 0.7]],
closed=True, facecolor='#b22222', edgecolor='#b22222', zorder=9))
# Cardinal ticks — all centered at (0, 0)
for angle, label in [(90, 'N'), (0, 'E'), (180, 'O'), (270, 'S')]:
for angle, label in [(90, 'N'), (0, 'E'), (180, 'W'), (270, 'S')]:
rad = np.radians(angle)
north_ax.plot([1.0*np.cos(rad), 1.2*np.cos(rad)],
[1.0*np.sin(rad), 1.2*np.sin(rad)],
@ -686,7 +694,7 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
# Round resolution to avoid ugly decimals like 0.499999959
res_display = round(resolution, 2) if resolution < 1 else round(resolution, 1)
line1_parts.append(f"Res: {res_display}m/px")
line1_parts.append(f"Emprise: {extent_km_x:.1f}×{extent_km_y:.1f}km")
line1_parts.append(f"Extent: {extent_km_x:.1f}×{extent_km_y:.1f}km")
if not is_rgb:
line1_parts.append(f"Alt: {alt_min:.1f}–{alt_max:.1f}m")
@ -832,13 +840,13 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
return output_file
except Exception as e:
logger.error(f" Erreur conversion {ext.upper()}: {e}", exc_info=True)
logger.error(f" {ext.upper()} conversion error: {e}", exc_info=True)
return None
def _write_subtiles_from(img, tif_file, vis_dir, resolution, subtiles_dir):
"""Sous-tuiles d'une dalle depuis son image d'origine (best-effort :
en cas d'échec, build_index les découpera depuis la dalle)."""
"""Sub-tiles of a tile from its original image (best-effort: on
failure, build_index will cut them from the tile image)."""
from .index import VIZ_LABELS, _subdivision_k, write_subtiles
try:
k = _subdivision_k(resolution)
@ -849,7 +857,7 @@ def _write_subtiles_from(img, tif_file, vis_dir, resolution, subtiles_dir):
return
write_subtiles(subtiles_dir, Path(vis_dir).name, viz_key, img, k)
except Exception as e:
logger.warning(f" Sous-tuiles non écrites ({Path(tif_file).name}) : {e}")
logger.warning(f" Sub-tiles not written ({Path(tif_file).name}): {e}")
def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=60, output_format='avif',
@ -864,11 +872,12 @@ def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=60, outpu
vis_dir: Output directory for the image file.
resolution: Grid resolution in m/px.
keep_tif: If True, keep the source TIFF after conversion.
quality: Image quality (1-100). Use 100 for lossless.
quality: Image quality (1-100). 100 requests lossless, which only
WebP honors (Pillow ignores lossless=True in AVIF).
output_format: Output format ('webp' or 'avif').
subtiles_dir: dossier de sortie du pipeline : les sous-tuiles de la
carte (index_subtiles) y sont écrites depuis l'image d'origine,
un seul encodage avec perte (build_index les trouve à jour).
subtiles_dir: pipeline output directory: the map sub-tiles
(index_subtiles) are written there from the original image, a
single lossy encoding (build_index finds them up to date).
Returns:
Path to output image file, or None on failure.
@ -889,13 +898,13 @@ def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=60, outpu
data = np.moveaxis(data, 0, -1)
else:
data = src.read(1)
# Nodata → NaN : sinon les pixels bord (ex. -9999, 3.4e38)
# polluent les percentiles d'étalonnage de la colormap
# Nodata → NaN: otherwise edge pixels (e.g. -9999, 3.4e38)
# pollute the colormap calibration percentiles
if src.nodata is not None:
data = data.astype(np.float32, copy=False)
data[data == src.nodata] = np.nan
# Raccord des bords : recadrage sur la dalle nominale 1 km
# Edge stitching: crop to the nominal 1 km tile
core_win = _core_tile_window(tif_file, src)
if core_win is not None:
@ -911,7 +920,7 @@ def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=60, outpu
# Convert to RGB using colormap
if is_rgb_result:
# RGB images are already in RGB (uint8 depuis le TIF IGN, ou float 0-1)
# RGB images are already in RGB (uint8 from the TIF, or float 0-1)
if data.dtype == np.uint8:
rgb_data = data
else:
@ -926,8 +935,8 @@ def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=60, outpu
img = PILImage.fromarray(rgb_data)
pil_format = 'AVIF' if output_format == 'avif' else 'WEBP'
if lossless_gray:
# WebP sans perte en niveaux de gris : exact et 3× plus léger que
# l'AVIF q100 (Pillow ignore `lossless=True` en AVIF : q75 avec perte)
# Lossless grayscale WebP: exact and 3× lighter than AVIF q100
# (Pillow ignores `lossless=True` in AVIF: lossy q75)
img = PILImage.fromarray(rgb_data[:, :, 0])
img.save(str(output_file), format='WEBP', lossless=True)
elif quality >= 100:
@ -936,8 +945,8 @@ def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=60, outpu
img.save(str(output_file), format=pil_format, quality=quality,
**({'speed': AVIF_SPEED} if pil_format == 'AVIF' else {}))
# Sous-tuiles de la carte depuis l'image d'origine (après la dalle :
# plus récentes qu'elle, build_index ne les ré-encode pas)
# Map sub-tiles from the original image (after the tile: newer than
# it, so build_index does not re-encode them)
if subtiles_dir is not None:
_write_subtiles_from(img, tif_file, vis_dir, resolution, subtiles_dir)
@ -948,19 +957,22 @@ def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=60, outpu
return output_file
except Exception as e:
logger.error(f" Erreur conversion crop {ext.upper()}: {e}", exc_info=True)
logger.error(f" {ext.upper()} crop conversion error: {e}", exc_info=True)
return None
def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
"""Generate A3 PDF report for a LiDAR file with all visualizations.
"""Generate an A3 PDF report for a LiDAR file with all visualizations.
Page 1: Mise en situation (ortho + topo IGN side by side)
Legacy: no longer called by the pipeline (the map's PDF export lives in
export_pdf.py).
Page 1: Location context (IGN ortho + topo side by side)
Pages 2+: Other visualizations (2 per page)
Args:
basename: Base name for the report file.
vis_dir: Directory containing WebP visualization files.
vis_dir: Directory containing AVIF/WebP visualization files.
pdf_dir: Directory for output PDF.
resolution: Grid resolution (used in info text).
@ -970,7 +982,7 @@ def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
from matplotlib.backends.backend_pdf import PdfPages
pdf_file = pdf_dir / f"{basename}_rapport.pdf"
logger.info(f" → Génération rapport PDF A3: {pdf_file.name}")
logger.info(f" → Generating A3 PDF report: {pdf_file.name}")
t0 = time.time()
# Look for images in per-file subdirectory first, then fallback to main dir
@ -988,7 +1000,7 @@ def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
seen.add(f)
unique_files.append(f)
if not unique_files:
logger.warning(f" ✗ Aucune image trouvée pour {basename}")
logger.warning(f" ✗ No image found for {basename}")
return None
png_files = unique_files
@ -1024,7 +1036,7 @@ def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
try:
with PdfPages(str(pdf_file)) as pdf:
# Page 1: Mise en situation
# Page 1: location context
if situ_files:
fig = plt.figure(figsize=(a3_w, a3_h), facecolor='white')
n_situ = len(situ_files)
@ -1035,7 +1047,7 @@ def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
gs = fig.add_gridspec(1, max(n_situ, 1), wspace=0.05,
left=0.03, right=0.97, top=0.92, bottom=0.06)
fig.text(0.5, 0.97, f"Mise en situation - {basename}",
fig.text(0.5, 0.97, f"Location context - {basename}",
fontsize=20, fontweight='bold', ha='center', va='top')
for i, f in enumerate(situ_files):
@ -1079,9 +1091,9 @@ def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
pdf.savefig(fig, dpi=150)
plt.close(fig)
logger.info(f" ✓ Rapport PDF terminé ({time.time()-t0:.1f}s)")
logger.info(f" ✓ PDF report done ({time.time()-t0:.1f}s)")
return pdf_file
except Exception as e:
logger.error(f" ✗ Erreur PDF: {e}", exc_info=True)
logger.error(f" ✗ PDF error: {e}", exc_info=True)
return None