- Zoom WMTS calculé automatiquement à partir de la résolution DTM (0.5m/px → zoom 19, 0.2m/px → zoom 20) au lieu de zoom 15 fixe - Orthophoto: zoom max 20 (~0.15m/px), carte topo: zoom max 19 - Limite image 10000x10000px (au lieu de 5000x5000) - Log du nombre de tuiles et de la taille avant téléchargement Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
256 lines
8.8 KiB
Python
256 lines
8.8 KiB
Python
"""IGN (French National Geographic Institute) tile download and overlay generation.
|
|
|
|
Downloads WMTS tiles from the IGN Geoportail and composites them into
|
|
GeoTIFF overlays matching the LiDAR DTM extent.
|
|
"""
|
|
|
|
import logging
|
|
import math
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import rasterio
|
|
|
|
try:
|
|
from rasterio.warp import transform as warp_transform
|
|
HAS_WARP = True
|
|
except ImportError:
|
|
HAS_WARP = False
|
|
|
|
logger = logging.getLogger("lidar")
|
|
|
|
|
|
def _optimal_zoom_level(resolution, lat, layer):
|
|
"""Compute optimal WMTS zoom level for a given ground resolution.
|
|
|
|
Chooses the zoom level where the WMTS ground resolution is at least
|
|
as fine as the requested resolution, capped to avoid downloading
|
|
too many tiles.
|
|
|
|
Args:
|
|
resolution: Desired ground resolution in m/px (DTM resolution).
|
|
lat: Latitude in degrees (for Web Mercator resolution calculation).
|
|
layer: IGN WMTS layer name (different max zoom per layer).
|
|
|
|
Returns:
|
|
Integer zoom level.
|
|
"""
|
|
# Web Mercator ground resolution: m/px = (2π * R * cos(lat)) / (256 * 2^z)
|
|
# Solve for z: z = log2(2π * R * cos(lat) / (256 * resolution))
|
|
R = 6378137.0 # WGS84 equatorial radius
|
|
lat_rad = math.radians(lat)
|
|
numerator = 2 * math.pi * R * math.cos(lat_rad)
|
|
z_exact = math.log2(numerator / (256 * resolution))
|
|
|
|
zoom = max(15, min(20, int(math.ceil(z_exact))))
|
|
|
|
# Cap zoom per layer: orthophoto supports up to 20, topographic map up to 19
|
|
if 'ORTHO' in layer:
|
|
zoom = min(zoom, 20)
|
|
elif 'PLAN' in layer:
|
|
zoom = min(zoom, 19)
|
|
|
|
logger.debug(f" Zoom WMTS: {zoom} (résolution cible: {resolution}m/px)")
|
|
return zoom
|
|
|
|
|
|
def _lat_lon_to_tile(lat, lon, zoom):
|
|
"""Convert lat/lon to Web Mercator tile coordinates."""
|
|
n = 2 ** zoom
|
|
col = int((lon + 180) / 360 * n)
|
|
lat_rad = math.radians(lat)
|
|
row = int((1 - math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi) / 2 * n)
|
|
return col, row
|
|
|
|
|
|
def _lat_lon_to_px(lat, lon, zoom, tile_size=256):
|
|
"""Convert lat/lon to Web Mercator pixel coordinates."""
|
|
n = 2 ** zoom
|
|
px_x = (lon + 180) / 360 * n * tile_size
|
|
lat_rad = math.radians(lat)
|
|
px_y = (1 - math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi) / 2 * n * tile_size
|
|
return px_x, px_y
|
|
|
|
|
|
def download_ign_tiles(min_x, max_x, min_y, max_y, layer, zoom_level=15):
|
|
"""Download IGN WMTS tiles for the given bounds using Web Mercator (PM).
|
|
|
|
Args:
|
|
min_x, max_x, min_y, max_y: Bounds in Lambert 93.
|
|
layer: IGN WMTS layer name.
|
|
zoom_level: WMTS zoom level (default 15).
|
|
|
|
Returns:
|
|
numpy array (H, W, 3) uint8, or None on failure.
|
|
"""
|
|
import urllib.request
|
|
import io
|
|
from PIL import Image as PILImage
|
|
|
|
if not HAS_WARP:
|
|
logger.error(" ✗ rasterio.warp non disponible pour conversion de coordonnées")
|
|
return None
|
|
|
|
try:
|
|
l93_xs = [min_x, max_x]
|
|
l93_ys = [max_y, min_y]
|
|
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', l93_xs, l93_ys)
|
|
nw_lat, nw_lon = lats[0], lons[0]
|
|
se_lat, se_lon = lats[1], lons[1]
|
|
except Exception:
|
|
logger.error(" ✗ Impossible de convertir les coordonnées")
|
|
return None
|
|
|
|
wmts_url = "https://data.geopf.fr/wmts"
|
|
tile_matrix_set = "PM"
|
|
tile_size = 256
|
|
|
|
col_min, row_min = _lat_lon_to_tile(nw_lat, nw_lon, zoom_level)
|
|
col_max, row_max = _lat_lon_to_tile(se_lat, se_lon, zoom_level)
|
|
|
|
nw_px_x, nw_px_y = _lat_lon_to_px(nw_lat, nw_lon, zoom_level)
|
|
se_px_x, se_px_y = _lat_lon_to_px(se_lat, se_lon, zoom_level)
|
|
|
|
out_width = int(se_px_x - nw_px_x)
|
|
out_height = int(se_px_y - nw_px_y)
|
|
|
|
if out_width <= 0 or out_height <= 0 or out_width > 10000 or out_height > 10000:
|
|
logger.warning(f" Image IGN trop grande: {out_width}x{out_height}px — abandon")
|
|
return None
|
|
|
|
total_tiles = (col_max - col_min + 1) * (row_max - row_min + 1)
|
|
logger.info(f" Zoom {zoom_level}: {total_tiles} tuiles à télécharger ({out_width}x{out_height}px)")
|
|
|
|
composite = np.full((out_height, out_width, 3), 255, dtype=np.uint8)
|
|
|
|
tiles_downloaded = 0
|
|
fmt = "image/png" if 'PLAN' in layer else "image/jpeg"
|
|
|
|
for col in range(col_min, col_max + 1):
|
|
for row in range(row_min, row_max + 1):
|
|
url = (
|
|
f"{wmts_url}?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile"
|
|
f"&LAYER={layer}&STYLE=normal"
|
|
f"&TILEMATRIXSET={tile_matrix_set}"
|
|
f"&TILEMATRIX={zoom_level}&TILECOL={col}&TILEROW={row}"
|
|
f"&FORMAT={fmt}"
|
|
)
|
|
|
|
try:
|
|
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
|
with urllib.request.urlopen(req, timeout=10) as response:
|
|
tile_data = response.read()
|
|
tile_img = PILImage.open(io.BytesIO(tile_data)).convert('RGB')
|
|
tile_arr = np.array(tile_img)
|
|
|
|
tile_origin_x = col * tile_size
|
|
tile_origin_y = row * tile_size
|
|
|
|
px_x = int(tile_origin_x - nw_px_x)
|
|
px_y = int(tile_origin_y - nw_px_y)
|
|
|
|
x_off = max(0, -px_x)
|
|
y_off = max(0, -px_y)
|
|
dst_x_start = max(0, px_x)
|
|
dst_y_start = max(0, px_y)
|
|
dst_x_end = min(out_width, px_x + tile_size)
|
|
dst_y_end = min(out_height, px_y + tile_size)
|
|
|
|
src_x = x_off
|
|
src_y = y_off
|
|
src_w = dst_x_end - dst_x_start
|
|
src_h = dst_y_end - dst_y_start
|
|
|
|
if src_w > 0 and src_h > 0 and tile_arr.shape[0] >= src_y + src_h and tile_arr.shape[1] >= src_x + src_w:
|
|
composite[dst_y_start:dst_y_end, dst_x_start:dst_x_end] = \
|
|
tile_arr[src_y:src_y+src_h, src_x:src_x+src_w]
|
|
tiles_downloaded += 1
|
|
|
|
except Exception as e:
|
|
if tiles_downloaded == 0 and col == col_min and row == row_min:
|
|
logger.error(f" ✗ Erreur tuile IGN: {e}")
|
|
continue
|
|
|
|
logger.info(f" → {tiles_downloaded} tuiles IGN téléchargées ({layer})")
|
|
if tiles_downloaded == 0:
|
|
return None
|
|
return composite
|
|
|
|
|
|
def generate_ign_overlay(dem_file, basename, vis_dir, resolution, layer, title, legend_label, description, out_suffix):
|
|
"""Generate an IGN basemap overlay visualization.
|
|
|
|
Args:
|
|
dem_file: Path to DTM GeoTIFF.
|
|
basename: Base name for output file.
|
|
vis_dir: Output directory for visualizations.
|
|
resolution: Grid resolution in m/px.
|
|
layer: IGN WMTS layer name.
|
|
title: Title for the overlay.
|
|
legend_label: Legend text.
|
|
description: Description text.
|
|
out_suffix: Suffix for output filename.
|
|
|
|
Returns:
|
|
Path to output GeoTIFF, or None on failure.
|
|
"""
|
|
logger.info(f" → {title}...")
|
|
t0 = time.time()
|
|
|
|
output = vis_dir / f"{basename}_{out_suffix}.tif"
|
|
|
|
try:
|
|
with rasterio.open(dem_file) as src:
|
|
dem = src.read(1)
|
|
transform = src.transform
|
|
crs = src.crs
|
|
height, width = dem.shape
|
|
|
|
top_left_x = transform.c
|
|
top_left_y = transform.f
|
|
pixel_size_x = transform.a
|
|
pixel_size_y = abs(transform.e)
|
|
|
|
min_x = top_left_x
|
|
max_x = top_left_x + width * pixel_size_x
|
|
max_y = top_left_y
|
|
min_y = top_left_y - height * pixel_size_y
|
|
|
|
# Compute center latitude for zoom calculation
|
|
center_lat = (max_y + min_y) / 2
|
|
center_lon = (min_x + max_x) / 2
|
|
try:
|
|
clons, clats = warp_transform('EPSG:2154', 'EPSG:4326', [center_lon], [center_lat])
|
|
center_lat_wgs84 = clats[0]
|
|
except Exception:
|
|
center_lat_wgs84 = 46.0 # fallback: center of France
|
|
|
|
zoom = _optimal_zoom_level(resolution, center_lat_wgs84, layer)
|
|
|
|
result = download_ign_tiles(min_x, max_x, min_y, max_y, layer, zoom)
|
|
|
|
if result is None:
|
|
logger.error(" ✗ Impossible de télécharger les tuiles IGN")
|
|
return None
|
|
|
|
from PIL import Image as PILImage
|
|
ign_pil = PILImage.fromarray(result)
|
|
ign_resized = ign_pil.resize((width, height), PILImage.LANCZOS)
|
|
ign_arr = np.array(ign_resized)
|
|
|
|
with rasterio.open(
|
|
output, 'w',
|
|
driver='GTiff', height=height, width=width,
|
|
count=3, dtype='uint8',
|
|
crs=crs, transform=transform,
|
|
) as dst:
|
|
for i in range(3):
|
|
dst.write(ign_arr[:, :, i], i + 1)
|
|
|
|
logger.info(f" ✓ {title} terminé ({time.time()-t0:.1f}s)")
|
|
return output
|
|
|
|
except Exception as e:
|
|
logger.error(f" ✗ Erreur IGN overlay: {e}", exc_info=True)
|
|
return None |