Source code for marche_a_lombre.mns_downloader

# -*- coding: utf-8 -*-
"""
Part of MarcheALOmbre QGIS Plugin
Copyright (C) 2025 Yolanda Seifert
Licensed under GPL v2+
"""
import os
import time
import math
import tempfile
import shutil
import xml.etree.ElementTree as ET

from qgis.core import (
    QgsNetworkAccessManager, 
    QgsRectangle, 
    QgsCoordinateReferenceSystem,
    QgsCoordinateTransform
)
from qgis.PyQt.QtCore import QUrl, QCoreApplication, QEventLoop
from qgis.PyQt.QtNetwork import QNetworkRequest, QNetworkReply
from osgeo import gdal, osr

from .geo_definitions import MANUAL_DEFS

[docs] class MNSDownloader: BASE_URL = "https://data.geopf.fr/wms-r" CAPABILITIES_URL = f"{BASE_URL}?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities" TILE_SIZE_PX = 4000 def __init__(self, crs, transform_context, feedback=None): """ Initializes the downloader Args: crs (str): The epsg string of the target CRS transform_context (QgsCoordinateTransformContext): Context for coordinate transforms feedback (QgsProcessingFeedback, optional): Feedback object for logging """ self.manager = QgsNetworkAccessManager.instance() self.crs = crs self.transform_context = transform_context self.feedback = feedback self._capabilities_xml_cache = None
[docs] def log(self, message): if self.feedback: self.feedback.pushInfo(message) else: print(message)
def _fetch_capabilities(self): """ Fetches WMS Capabilities to find layers dynamically Returns: xml.etree.ElementTree.Element: root element of the parsed XML Raises: Exception: If the network request fails """ if self._capabilities_xml_cache is not None: return self._capabilities_xml_cache self.log("Fetching WMS Capabilities...") request = QNetworkRequest(QUrl(self.CAPABILITIES_URL)) request.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True) reply = self.manager.get(request) loop = QEventLoop() reply.finished.connect(loop.quit) loop.exec_() if reply.error() != QNetworkReply.NoError: raise Exception(f"Capabilities failed: {reply.errorString()}") content = reply.readAll() self._capabilities_xml_cache = ET.fromstring(content) return self._capabilities_xml_cache
[docs] def get_layer_candidates(self, wgs84_point, is_mns=True): """ Parses capabilities to find the best layer for the location Args: wgs84_point (QgsPointXY): point to query in WGS84 coordinates is_mns (bool, optional): True -> searches for Surface Models (MNS) False -> searches for Terrain Models (MNT) Returns: list[dict]: A list of layer candidates containing 'name' and 'score'. """ try: root = self._fetch_capabilities() except Exception as e: self.log(f"Error fetching capabilities: {e}") return [] ns = {'wms': 'http://www.opengis.net/wms'} candidates = [] if is_mns: search_type = "MNS" fallback_lidar_global = "IGNF_LIDAR-HD_MNS_ELEVATION.ELEVATIONGRIDCOVERAGE.WGS84G" fallback_highres = "ELEVATION.ELEVATIONGRIDCOVERAGE.HIGHRES.MNS" else: search_type = "MNT" fallback_lidar_global = "IGNF_LIDAR-HD_MNT_ELEVATION.ELEVATIONGRIDCOVERAGE.WGS84G" fallback_highres = "ELEVATION.ELEVATIONGRIDCOVERAGE.HIGHRES" for layer in root.findall('.//wms:Layer', ns): name_elem = layer.find('wms:Name', ns) if name_elem is None: continue name = name_elem.text if "SHADOW" in name: continue geo_bbox = layer.find('wms:EX_GeographicBoundingBox', ns) if geo_bbox is None: continue try: w = float(geo_bbox.find('wms:westBoundLongitude', ns).text) e = float(geo_bbox.find('wms:eastBoundLongitude', ns).text) s = float(geo_bbox.find('wms:southBoundLatitude', ns).text) n = float(geo_bbox.find('wms:northBoundLatitude', ns).text) # Check if point is inside the layer px, py = wgs84_point.x(), wgs84_point.y() if not (w <= px <= e and s <= py <= n): continue score = 0 if search_type in name and "LIDAR-HD" in name and "WGS84G" not in name: score = 1000 elif name == fallback_lidar_global: score = 500 elif name == fallback_highres: score = 100 if score == 0: continue candidates.append({'name': name, 'score': score}) except: continue return sorted(candidates, key=lambda x: x['score'], reverse=True)
[docs] def validate_raster_content(self, file_path): """ Validates the downloaded file by reading it Args: file_path (str): Path to the file to check Returns: tuple[bool, str]: (isValid, statusMessage) """ try: if os.path.getsize(file_path) < 1000: return False, "File too small (< 1KB)" with open(file_path, 'rb') as f: header = f.read(512) if b"ServiceException" in header or b"<?xml" in header: return False, "Contains WMS XML Error" gdal.PushErrorHandler('CPLQuietErrorHandler') ds = gdal.Open(file_path) if not ds: gdal.PopErrorHandler() return False, "GDAL could not open file" band = ds.GetRasterBand(1) try: data = band.ReadAsArray() except Exception as e: ds = None gdal.PopErrorHandler() return False, f"File Truncated/Corrupt: {str(e)}" if data is None: ds = None gdal.PopErrorHandler() return False, "ReadAsArray returned None" mn = data.min() mx = data.max() ds = None gdal.PopErrorHandler() if mn <= -9000 and mx <= -9000: return False, f"All NoData (Min:{mn} Max:{mx})" if mn == mx: return False, f"Flat Data (Val:{mn})" return True, "Valid" except Exception as e: return False, f"Validation Exception: {str(e)}"
[docs] def download_dual_quality_mns(self, trail_extent, high_res_path, low_res_path, trail_lat, input_crs, high_res=0.5, low_res=15.0): """Download two MNS, one high quality around the trail and one low resolution with a greater extent for longer shadows Args: trail_extent (QgsRectangle): Extent around the hiking trail high_res_path (str): Path to High-Res MNS low_res_path (str): Path to Low-Res MNS trail_lat (float): latitude of trail_extent center input_crs (str): Coordinate Reference System high_res (float, optional): High resolution. Defaults to 0.5. low_res (float, optional): Low resolution. Defaults to 30.0. Returns: bool: True if download successful """ # High-Res self.read_tif(trail_extent, high_res, high_res_path, input_crs=input_crs) # Low-Res (greater extent) self.log("Downloading large Low Resolution MNS for obstacles at greater distance (e.g. mountains)") buffer_dist = 22000.0 # altitude difference of 2000m with solar elevation of 5° casts 22km shadow buffer_n = buffer_dist buffer_s = buffer_dist if trail_lat > 23.4: # north/south buffer not necessary for high/low lat buffer_n = 0 if trail_lat < -23.4: buffer_s = 0 horizon_extent = QgsRectangle( trail_extent.xMinimum() - buffer_dist, trail_extent.yMinimum() - buffer_s, trail_extent.xMaximum() + buffer_dist, trail_extent.yMaximum() + buffer_n ) return self.read_tif(horizon_extent, low_res, low_res_path, input_crs=input_crs)
[docs] def read_tif(self, extent, resolution, output_path, input_crs, is_mns=True): """ Dowloads the MNS/MNT data for a specific extent and resolution Args: extent (QgsRectangle): The area to download resolution (float): Pixel resolution in meters output_path (str): File path to save the GeoTIFF input_crs (str): epsg code of the CRS is_mns (bool, optional): True -> MNS (Surface), False -> MNT (Terrain). Defaults to True. Returns: bool: True if successful, False otherwise """ source_ref = QgsCoordinateReferenceSystem(input_crs) wgs84_ref = QgsCoordinateReferenceSystem("EPSG:4326") tr_to_wgs84 = QgsCoordinateTransform(source_ref, wgs84_ref, self.transform_context) tr_to_wgs84.setBallparkTransformsAreAppropriate(True) center_input = extent.center() center_wgs84 = tr_to_wgs84.transform(center_input) auth_id = source_ref.authid() is_identity = (abs(center_input.x() - center_wgs84.x()) < 0.1) and (auth_id != wgs84_ref.authid()) if is_identity: # transformation failed if auth_id in MANUAL_DEFS: self.log(f"Switching to Manual Definition for Coordinate Transformation.") # CRS from manual definition source_ref = QgsCoordinateReferenceSystem.fromProj4(MANUAL_DEFS[auth_id]) # Redo transform tr_to_wgs84 = QgsCoordinateTransform(source_ref, wgs84_ref, self.transform_context) tr_to_wgs84.setBallparkTransformsAreAppropriate(True) center_wgs84 = tr_to_wgs84.transform(center_input) else: self.log("Warning: Transform returned identity and no manual definition available.") # Search available layers using WGS84 center candidates = self.get_layer_candidates(center_wgs84, is_mns) if not candidates: # Default based on detected CRS to avoid "Metropole" layer in DOM-TOM if "2154" in self.crs: default = "IGNF_LIDAR-HD_MNS_ELEVATION.ELEVATIONGRIDCOVERAGE.LAMB93" else: # Use the generic WGS84G layer for DOM-TOM if specific one isn't found default = "IGNF_LIDAR-HD_MNS_ELEVATION.ELEVATIONGRIDCOVERAGE.WGS84G" self.log(f"No candidates found. Using default: {default}") candidates = [{'name': default}] # Calculate size in pixels based on the projected extent width = int(extent.width() / resolution) height = int(extent.height() / resolution) for i, cand in enumerate(candidates): layer_name = cand['name'] self.log(f"Attempting layer {layer_name}...") success = False # Check if the request is small enough for a single download if width <= self.TILE_SIZE_PX and height <= self.TILE_SIZE_PX: success = self._download_single_tile(extent, width, height, output_path, layer_name) else: # If too big switch to tiled download self.log(f"Large request ({width}x{height}). Switching to tiled download...") success = self._download_tiled(extent, resolution, width, height, output_path, layer_name) if success: is_valid, msg = self.validate_raster_content(output_path) if is_valid: self.log(f"Success: {msg}") return True else: self.log(f"Layer {layer_name} INVALID: {msg}") return False
def _download_tiled(self, extent, resolution, total_w, total_h, output_path, layer_name): """ Splits the extent into chunks of max 4000px, downloads them, and merges them """ cols = math.ceil(total_w / self.TILE_SIZE_PX) rows = math.ceil(total_h / self.TILE_SIZE_PX) temp_dir = tempfile.mkdtemp() tile_files = [] current_y = extent.yMaximum() try: for i in range(rows): current_x = extent.xMinimum() # Calculate height of this row (last row might be smaller) row_height_px = min(self.TILE_SIZE_PX, total_h - (i * self.TILE_SIZE_PX)) row_height_m = row_height_px * resolution for j in range(cols): if self.feedback and self.feedback.isCanceled(): return False # Calculate width of this col col_width_px = min(self.TILE_SIZE_PX, total_w - (j * self.TILE_SIZE_PX)) col_width_m = col_width_px * resolution # Define tile extent tile_extent = QgsRectangle( current_x, current_y - row_height_m, current_x + col_width_m, current_y ) tile_path = os.path.join(temp_dir, f"tile_{i}_{j}.tif") self.log(f"Downloading tile {i+1},{j+1} / {rows},{cols} ({col_width_px}x{row_height_px})...") success = self._download_single_tile( tile_extent, col_width_px, row_height_px, tile_path, layer_name ) if not success: raise Exception("Tile download failed") tile_files.append(tile_path) current_x += col_width_m current_y -= row_height_m # Merge tiles using GDAL VRT self.log("Merging tiles...") vrt_options = gdal.BuildVRTOptions(resampleAlg='nearest') vrt_path = os.path.join(temp_dir, "merged.vrt") vrt = gdal.BuildVRT(vrt_path, tile_files, options=vrt_options) vrt = None # Flush to disk # Translate VRT to final TIFF translate_options = gdal.TranslateOptions(format='GTiff', creationOptions=['COMPRESS=DEFLATE', 'TILED=YES']) gdal.Translate(output_path, vrt_path, options=translate_options) return True except Exception as e: self.log(f"Error during tiled download: {e}") return False finally: shutil.rmtree(temp_dir, ignore_errors=True) def _download_single_tile(self, extent, width, height, output_path, layer_name): """ Requests a single tile from IGN Wep Map Service """ params = [ f"SERVICE=WMS", f"VERSION=1.3.0", f"REQUEST=GetMap", f"LAYERS={layer_name}", f"STYLES=normal", f"FORMAT=image/tiff", f"CRS={self.crs}", f"BBOX={extent.xMinimum()},{extent.yMinimum()},{extent.xMaximum()},{extent.yMaximum()}", f"WIDTH={int(width)}", f"HEIGHT={int(height)}", f"TRANSPARENT=false" ] full_url_str = f"{self.BASE_URL}?" + "&".join(params) # self.log(f"Requesting URL: {full_url_str}") request = QNetworkRequest(QUrl(full_url_str)) request.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True) reply = self.manager.get(request) while reply.isRunning(): if self.feedback and self.feedback.isCanceled(): reply.abort() self.feedback.reportError("Download canceled by user.") return False QCoreApplication.processEvents() # Check HTTP Status if reply.error() != QNetworkReply.NoError: if self.feedback: if reply.error() != QNetworkReply.OperationCanceledError: self.feedback.reportError(f"HTTP Error: {reply.errorString()}") self.log(f"QNetworkReply Error: {reply.errorString()}") return False content = reply.readAll() # Write Content to Disk if not content or len(content) < 100: if self.feedback: self.feedback.reportError(f"Download failed (File too small). Server returned: {bytes(content)}") return False try: with open(output_path, 'wb') as f: f.write(content) # Validation Step is_valid, status_msg = self.validate_raster_content(output_path) if not is_valid: self.log(f"Downloaded file validation failed: {status_msg}") return False self._embed_georeferencing(output_path, extent, width, height) # self.log(f"Saved to {output_path}") return True except Exception as e: if self.feedback: self.feedback.reportError(f"GDAL Error: {e}") return False def _embed_georeferencing(self, tif_path, extent, width, height): """ Opens the existing TIFF using GDAL and injects spatial metadata (GeoTransform and Projection) """ ds = gdal.Open(tif_path, 1) if ds is None: raise Exception("Could not open file with GDAL.") nodata_val = -9999.0 band = ds.GetRasterBand(1) data = band.ReadAsArray() data[data < -1000] = nodata_val band.WriteArray(data) if band: band.SetNoDataValue(nodata_val) x_res = (extent.xMaximum() - extent.xMinimum()) / width y_res = (extent.yMaximum() - extent.yMinimum()) / height # GeoTransform list format: # [0] Top-Left X Coordinate # [1] W-E Pixel Resolution # [2] Rotation # [3] Top-Left Y Coordinate # [4] Rotation # [5] N-S Pixel Resolution (negative for north-up) geotransform = [ extent.xMinimum(), x_res, 0, extent.yMaximum(), 0, -y_res ] ds.SetGeoTransform(geotransform) srs = osr.SpatialReference() srs.SetFromUserInput(self.crs) ds.SetProjection(srs.ExportToWkt()) # Close the file band.FlushCache() ds = None