"""Download of IGN LiDAR HD tiles for tiles not yet generated. STAC catalogue (current) : https://browser.stac.teledetection.fr/collections/lidarhd API : https://api.stac.teledetection.fr/collections/lidarhd/items Files (Géoplateforme) : https://data.geopf.fr/telechargement/download/... Each tile covers 1 km × 1 km in Lambert 93 and is named after its north-west corner: LHD_FXX_{col}_{row}_PTS_LAMB93_IGN69.copc.laz (col = west X in km, row = north Y in km, see the STAC property "lidarhd:coordonnees_NW" in the "0816-6847" format). """ import json import logging import time import urllib.parse import urllib.request from pathlib import Path logger = logging.getLogger("lidar") _STAC_ITEMS_URL = "https://api.stac.teledetection.fr/collections/lidarhd/items" _HEADERS = {"User-Agent": "Mozilla/5.0 (lidar-archeo-pipeline)"} def parse_tile_specs(args): """Convert "col,row" or "col:row" specifications into a list of tuples. Args: args: list of strings (e.g. ["1055,6882", "1056:6883"]). Returns: List of (col, row) tuples. Raises: ValueError: if a specification is malformed. """ specs = [] for raw in args: text = raw.strip().replace(":", ",").replace(";", ",") parts = [p.strip() for p in text.split(",") if p.strip()] if len(parts) != 2: raise ValueError(f"Invalid tile specification: {raw!r} (expected: col,row)") try: col, row = int(parts[0]), int(parts[1]) except ValueError: raise ValueError(f"Invalid tile specification: {raw!r} (col and row must be integers)") specs.append((col, row)) return specs def tile_filename(col, row): """Standard LAZ file name of a (col, row) tile.""" return f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69.copc.laz" def _report_download(output_dir, tile, state, detail=None): """Emit a download event for the generation queue (best-effort).""" if output_dir is None: return from .progress import report_event report_event(output_dir, tile, "download", state, detail=detail) def _bbox_wgs84(col, row): """WGS84 bbox of the (col,row) tile for the STAC query (padded).""" try: from rasterio.warp import transform as warp_transform xs = [col * 1000, (col + 1) * 1000, col * 1000, (col + 1) * 1000] ys = [(row - 1) * 1000] * 2 + [row * 1000] * 2 lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys) except Exception: lons = None if lons is None: try: # lightweight (map) image: pyproj without rasterio from pyproj import Transformer tr = Transformer.from_crs("EPSG:2154", "EPSG:4326", always_xy=True) xs = [col * 1000, (col + 1) * 1000, col * 1000, (col + 1) * 1000] ys = [(row - 1) * 1000] * 2 + [row * 1000] * 2 lons, lats = tr.transform(xs, ys) except Exception: lons = None if lons is None: from .index import _approx_l93_to_wgs84 pts = [_approx_l93_to_wgs84(x, y) for x in (col * 1000, (col + 1) * 1000) for y in ((row - 1) * 1000, row * 1000)] lons = [p[0] for p in pts] lats = [p[1] for p in pts] pad = 0.005 # ~500 m margin to absorb rounding errors at the edges return (min(lons) - pad, min(lats) - pad, max(lons) + pad, max(lats) + pad) def match_feature(features, col, row): """Return the STAC item matching the (col,row) tile, else None.""" want = f"{col:04d}-{row:04d}" for feature in features: props = feature.get("properties", {}) if props.get("lidarhd:coordonnees_NW") == want: return feature return None def find_tile_url(col, row, timeout=20, max_pages=5): """Look up the download URL of the (col,row) tile in the STAC catalogue. Returns: URL (str), or None if the tile is not (yet) published by IGN. """ feature = find_tile_feature(col, row, timeout=timeout, max_pages=max_pages) return feature.get("assets", {}).get("data", {}).get("href") if feature else None def tile_ign_metadata(col, row, timeout=10): """IGN record of a tile: acquisition, sensors, edition, download. Returns: Normalised dict (found=False if the tile is not published). """ feature = find_tile_feature(col, row, timeout=timeout) if not feature: return {"found": False} p = feature.get("properties", {}) self_link = next((link.get("href") for link in feature.get("links", []) if link.get("rel") == "self"), None) return { "found": True, "download_url": feature.get("assets", {}).get("data", {}).get("href"), "acquisition_start": p.get("start_datetime") or p.get("lidarhd:date_debut_acquisition"), "acquisition_end": p.get("end_datetime") or p.get("lidarhd:date_fin_acquisition"), "sensors": p.get("lidarhd:capteur") or [], "mission": p.get("lidarhd:code_mission"), "acquisition_operator": p.get("lidarhd:moe_acquisition"), "acquisition_owner": p.get("lidarhd:moa_acquisition"), "edition_date": p.get("lidarhd:date_edition"), "classification_process": p.get("lidarhd:procede_classement"), "points": p.get("lidarhd:nombre_points") or p.get("pc:count"), "altimetry": p.get("lidarhd:systeme_altimetrique"), "stac_url": self_link, } def find_tile_feature(col, row, timeout=20, max_pages=5): """STAC item of the (col,row) tile, or None if not published. Follows the catalogue pagination (rel=next link): without it, a tile beyond the first page of results appeared "not found". """ w, s, e, n = _bbox_wgs84(col, row) query = urllib.parse.urlencode({"bbox": f"{w:.6f},{s:.6f},{e:.6f},{n:.6f}", "limit": 50}) url = f"{_STAC_ITEMS_URL}?{query}" for _ in range(max_pages): req = urllib.request.Request(url, headers=_HEADERS) with urllib.request.urlopen(req, timeout=timeout) as response: data = json.loads(response.read().decode("utf-8")) feature = match_feature(data.get("features", []), col, row) if feature: return feature nxt = next((link.get("href") for link in data.get("links", []) if link.get("rel") == "next" and link.get("href")), None) if not nxt: return None url = nxt return None def download_file(url, dest_path, timeout=120, chunk=1024 * 1024): """Stream url to dest_path (atomic write). Writes to a .part file then renames it: an interrupted run (SIGKILL, crash) never leaves a partial LAZ that a later run would treat as already downloaded. Returns the size in bytes. """ import os dest_path = Path(dest_path) tmp = dest_path.with_name(dest_path.name + ".part") req = urllib.request.Request(url, headers=_HEADERS) t0 = time.time() try: with urllib.request.urlopen(req, timeout=timeout) as response, open(tmp, "wb") as out: done = 0 while True: block = response.read(chunk) if not block: break out.write(block) done += len(block) elapsed = time.time() - t0 logger.info(f" {done / 1e6:.0f} MB in {elapsed:.0f}s" f" ({done / 1e6 / max(elapsed, 0.1):.1f} MB/s)") os.replace(tmp, dest_path) except BaseException: tmp.unlink(missing_ok=True) raise return done def fetch_tiles(input_dir, specs, output_dir=None, only_viz=None, resolutions=(0.5,), force=False): """Download the given IGN tiles, except those not needed for processing. Args: input_dir: LAZ files directory (must be writable). specs: list of (col, row) tuples. output_dir: output directory (optional) — used to skip tiles that are already complete. only_viz: step names of the requested visualisations (e.g. ['aspect']). If given, a tile is skipped only if it already has all these visualisations at the requested resolutions — otherwise its LAZ is (re)downloaded to fill in the missing ones. If not given, a tile is skipped as soon as any visualisation exists for it. resolutions: expected resolutions (m/px) for a tile to count as complete when only_viz is given. force: True to download even complete tiles (regeneration: their LAZ is needed to reprocess with --force). Returns: List of downloaded paths. """ from .dtm import EDGE_NEIGHBORS_DIRNAME input_dir = Path(input_dir) complete = None if output_dir is not None and only_viz and not force: from .index import cells_with_all_viz, step_to_keyword keys = [step_to_keyword(v) for v in only_viz] complete = cells_with_all_viz(Path(output_dir) / "visualisations", keys, resolutions) # Explicit: never promote when downloading directly INTO edge_neighbors/ # (_fetch_edge_neighbors call with input_dir=edge_dir) — otherwise a # neighbour tile could end up moved onto itself. is_edge_dir = input_dir.name == EDGE_NEIGHBORS_DIRNAME downloaded = [] for col, row in specs: name = tile_filename(col, row) tile = name[:-len(".copc.laz")] if name.endswith(".copc.laz") else name dest = input_dir / name if dest.exists(): logger.info(f" {name}: already in input/ — no download") _report_download(output_dir, tile, "skip", "already in input/") continue if not is_edge_dir: # dest.exists() above only checks the exact name: a neighbouring # ".part" never matches it, so .exists() is enough to rule out a # neighbour download still in progress. neighbor_copy = input_dir / EDGE_NEIGHBORS_DIRNAME / name if neighbor_copy.exists(): import os os.replace(neighbor_copy, dest) logger.info(f" {name}: already downloaded as a neighbour " f"— moved to input/") _report_download(output_dir, tile, "skip", "already downloaded as a neighbour — moved to input/") continue if output_dir is not None and not force: if complete is not None: if (col, row) in complete: logger.info(f" {name}: visualisations already complete — skipped") _report_download(output_dir, tile, "skip", "visualisations already complete") continue else: vis_dir = Path(output_dir) / "visualisations" if list(vis_dir.glob(f"LHD_FXX_{col:04d}_{row:04d}_PTS*")): logger.info(f" {name}: visualisations already generated — skipped") _report_download(output_dir, tile, "skip", "visualisations already generated") continue logger.info(f" {name}: searching the IGN catalogue...") _report_download(output_dir, tile, "start", "IGN catalogue") try: url = find_tile_url(col, row) except Exception as e: logger.warning(f" ✗ {name}: catalogue error ({e})") _report_download(output_dir, tile, "fail", f"catalogue error: {e}") continue if not url: logger.warning(f" ✗ {name}: not found in the IGN catalogue (area not published?)") _report_download(output_dir, tile, "fail", "not found in the IGN catalogue") continue logger.info(f" {name}: downloading from the Géoplateforme...") _report_download(output_dir, tile, "start", "Géoplateforme") try: download_file(url, dest) logger.info(f" ✓ {name} downloaded") _report_download(output_dir, tile, "ok") downloaded.append(dest) except Exception as e: dest.unlink(missing_ok=True) logger.warning(f" ✗ {name}: download failed ({e})") _report_download(output_dir, tile, "fail", f"download failed: {e}") return downloaded