Source code for gedih3.raster.export

# Copyright (C) 2026, University of Maryland. All Rights Reserved.
# Authors: Tiago de Conto, Amelia Grace Holcomb
# For commercial licensing inquiries, contact UM Ventures at umdtechtransfer@umd.edu

"""
Raster Export Module

This module provides utilities for exporting raster data to various formats,
with support for GeoTIFF compression, tiling, and batch operations.
"""
from typing import Dict, List, Optional, Union
import logging
import os
import xml.etree.ElementTree as ET
import numpy as np
import pandas as pd
import geopandas as gpd
import xarray as xr
import dask
import dask.dataframe
from dask.distributed import progress as dask_progress

from .config import get_geotiff_options, is_raster_format
from ..exceptions import GediRasterizationError
from ..utils import AtomicFileWriter

logger = logging.getLogger(__name__)


[docs] def export_raster( xras: xr.Dataset, output_path: str, compress: str = 'LZW', tiled: bool = True, blocksize: int = 256, bigtiff: bool = True ) -> str: """ Export xarray Dataset to GeoTIFF file. Parameters ---------- xras : xr.Dataset Raster dataset to export output_path : str Output file path compress : str Compression method ('LZW', 'ZSTD', 'DEFLATE', 'NONE') tiled : bool Use tiled output format blocksize : int Tile block size in pixels bigtiff : bool Use BigTIFF format for large files Returns ------- str Output file path """ options = get_geotiff_options(compress, tiled, blocksize, bigtiff) # Ensure directory exists os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) xras.rio.to_raster(output_path, **options) return output_path
[docs] def export_raster_partition( data: Union[pd.Series, xr.Dataset], output_dir: str, fmt: str = 'tif', compress: str = 'LZW', partition_id_attr: Optional[str] = None ) -> str: """ Export raster partition(s) to file(s). This function handles the case where a Series may contain multiple rasters from different spatial tiles. Each raster is exported to its own file based on its tile ID attribute. Parameters ---------- data : pd.Series or xr.Dataset Raster data (Series of DataArrays/Datasets or single Dataset) output_dir : str Output directory fmt : str Output format ('tif', 'nc') compress : str Compression method for GeoTIFF partition_id_attr : str, optional Attribute name containing partition ID for filename Returns ------- str Output file path(s), comma-separated if multiple files written """ import re os.makedirs(output_dir, exist_ok=True) if isinstance(data, pd.Series): if len(data) == 0: return '' # Series of Datasets - export each separately valid_rasters = [x for x in data if hasattr(x, 'data_vars') and len(x.data_vars) > 0] if not valid_rasters: return '' # Export each raster to its own file based on its tile ID output_paths = [] for xras in valid_rasters: path = _export_single_raster(xras, output_dir, fmt, compress, partition_id_attr) if path: output_paths.append(path) return ','.join(output_paths) if output_paths else '' elif isinstance(data, xr.Dataset): return _export_single_raster(data, output_dir, fmt, compress, partition_id_attr) return ''
def _export_single_raster( xras: xr.Dataset, output_dir: str, fmt: str, compress: str, partition_id_attr: Optional[str] = None ) -> str: """ Export a single xarray Dataset to file. Parameters ---------- xras : xr.Dataset Raster data output_dir : str Output directory fmt : str Output format compress : str Compression method partition_id_attr : str, optional Attribute name for partition ID Returns ------- str Output file path """ import re if len(xras.data_vars) == 0: return '' # Determine output filename from attributes basename = 'raster' for var in list(xras.data_vars)[:1]: attrs = xras[var].attrs # Look for any H3 partition ID attribute (h3_XX_id pattern) h3_part_attrs = [k for k in attrs.keys() if re.match(r'h3_\d{2}_id$', str(k))] if h3_part_attrs: basename = str(attrs[h3_part_attrs[0]]) break elif 'egi12_id' in attrs: basename = str(attrs['egi12_id']) break # Look for any EGI partition ID attribute (egiXX_id pattern) egi_part_attrs = [k for k in attrs.keys() if re.match(r'egi\d+_id$', str(k))] if egi_part_attrs: basename = str(attrs[egi_part_attrs[0]]) break elif partition_id_attr and partition_id_attr in attrs: basename = str(attrs[partition_id_attr]) break # Add extension output_path = os.path.join(output_dir, f"{basename}.{fmt}") if fmt in ('tif', 'tiff', 'geotiff'): options = get_geotiff_options(compress) xras.rio.to_raster(output_path, **options) elif fmt in ('nc', 'netcdf'): xras.to_netcdf(output_path) else: raise GediRasterizationError(f"Unsupported raster format: {fmt}") return output_path
[docs] def rasterize_and_export_partitions( gdf: Union[gpd.GeoDataFrame, dask.dataframe.DataFrame], output_dir: str, rasterize_func, columns: Optional[List[str]] = None, fmt: str = 'tif', compress: str = 'LZW', show_progress: bool = True, **rasterize_kwargs ) -> List[str]: """ Rasterize and export GeoDataFrame partitions to individual files. Parameters ---------- gdf : GeoDataFrame or dask GeoDataFrame Input spatially-indexed data output_dir : str Output directory for raster files rasterize_func : callable Function to rasterize each partition (e.g., rasterize_h3_partition) columns : list of str, optional Columns to include in rasterization fmt : str Output format ('tif', 'nc') compress : str Compression method for GeoTIFF show_progress : bool Show Dask progress bar Returns ------- list of str Paths to output files """ os.makedirs(output_dir, exist_ok=True) if hasattr(gdf, 'npartitions'): # Dask GeoDataFrame raster_parts = gdf.map_partitions( rasterize_func, columns=columns, **rasterize_kwargs, meta=pd.Series(dtype=object) ) export_func = lambda x: export_raster_partition( x, output_dir, fmt=fmt, compress=compress ) paths = raster_parts.map_partitions( export_func, meta=pd.Series(dtype=str) ) # Driver-side gather instead of .compute() — the optimizer's # RepartitionToFewer collapse step wedges on tunneled meshes past # ~1500 partitions in dask >= 2025.2. from ..utils import dask_safe_collect try: result = list(dask_safe_collect(paths, show_progress=show_progress)) except (ValueError, ImportError): # No distributed client — fall back to legacy compute. result = paths.compute().tolist() else: # Regular GeoDataFrame raster = rasterize_func(gdf, columns=columns, **rasterize_kwargs) path = export_raster_partition(raster, output_dir, fmt=fmt, compress=compress) result = [path] if path else [] # Build VRT mosaic from output tiles # Split comma-separated paths (from partitions producing multiple tiles) all_paths = [] for p in result: if p: all_paths.extend(p.split(',')) tif_files = [p for p in all_paths if p.endswith('.tif')] if len(tif_files) > 1: vrt_path = os.path.join(output_dir, 'mosaic.vrt') build_vrt_safe(tif_files, vrt_path) return result
# numpy dtype name -> GDAL type name, as spelled in VRT XML. Explicit table # rather than a rasterio private helper: the mapping is stable and the failure # mode of a wrong guess (silently misread pixels) is worse than a KeyError. _GDAL_TYPENAME = { 'uint8': 'Byte', 'int8': 'Int8', 'uint16': 'UInt16', 'int16': 'Int16', 'uint32': 'UInt32', 'int32': 'Int32', 'uint64': 'UInt64', 'int64': 'Int64', 'float32': 'Float32', 'float64': 'Float64', 'complex_int16': 'CInt16', 'complex64': 'CFloat32', 'complex128': 'CFloat64', }
[docs] def build_vrt(tif_files, vrt_path): """Build a GDAL VRT file mosaicking a list of GeoTIFF tiles. Uses the ``osgeo.gdal`` bindings when they are importable, and falls back to :func:`build_vrt_xml` otherwise. The GDAL bindings are not pip-installable without a version-matched system libgdal, so pip-only installs take the fallback; conda / HPC installs keep the authoritative backend. Parameters ---------- tif_files : list of str Paths to input GeoTIFF files vrt_path : str Output VRT file path Returns ------- str Path to the written VRT """ try: from osgeo import gdal except ImportError: return build_vrt_xml(tif_files, vrt_path) gdal.UseExceptions() # Pin the VRT resolution to the first tile's pixel size. Without an # explicit resolution, gdal.BuildVRT defaults to 'average'. ds = gdal.Open(tif_files[0]) gt = ds.GetGeoTransform() ds = None opts = gdal.BuildVRTOptions(resolution='user', xRes=gt[1], yRes=abs(gt[5])) gdal.BuildVRT(vrt_path, tif_files, options=opts) return vrt_path
[docs] def build_vrt_xml(tif_files, vrt_path): """Write a VRT mosaic as XML using rasterio only — no ``osgeo`` bindings. Handles the narrow case gedih3's own writers emit: north-up GeoTIFF tiles sharing one CRS and one pixel size. Anything outside that (rotated geotransforms, mixed CRS) raises rather than emitting a subtly wrong mosaic — use the ``osgeo`` backend for the general case. As with :func:`build_vrt`, the mosaic resolution is pinned to the first tile so partial edge tiles cannot drag it off the canonical grid. Parameters ---------- tif_files : list of str Paths to input GeoTIFF files vrt_path : str Output VRT file path Returns ------- str Path to the written VRT Raises ------ GediRasterizationError If the tiles are rotated, span multiple CRS, or use a dtype with no GDAL equivalent. """ import rasterio if not tif_files: raise GediRasterizationError("build_vrt_xml requires at least one input tile") profiles = [] for path in tif_files: with rasterio.open(path) as src: profiles.append({ 'path': path, 'transform': src.transform, 'width': src.width, 'height': src.height, 'count': src.count, 'dtypes': src.dtypes, 'nodata': src.nodata, 'crs': src.crs, 'bounds': src.bounds, 'block': src.block_shapes[0], 'colorinterp': [ci.name.capitalize() for ci in src.colorinterp], }) first = profiles[0] # Preconditions — refuse loudly instead of writing a wrong mosaic. for p in profiles: if p['transform'].b or p['transform'].d: raise GediRasterizationError( f"build_vrt_xml cannot mosaic rotated rasters [file={p['path']}]; " "install the GDAL Python bindings for the general case" ) if p['crs'] != first['crs']: raise GediRasterizationError( f"build_vrt_xml requires a single CRS across tiles: " f"{first['crs']} vs {p['crs']} [file={p['path']}]" ) unknown = {d for p in profiles for d in p['dtypes']} - set(_GDAL_TYPENAME) if unknown: raise GediRasterizationError(f"No GDAL type name for dtype(s): {sorted(unknown)}") xres = first['transform'].a yres = abs(first['transform'].e) left = min(p['bounds'].left for p in profiles) right = max(p['bounds'].right for p in profiles) bottom = min(p['bounds'].bottom for p in profiles) top = max(p['bounds'].top for p in profiles) root = ET.Element('VRTDataset', rasterXSize=str(int(round((right - left) / xres))), rasterYSize=str(int(round((top - bottom) / yres)))) if first['crs'] is not None: ET.SubElement(root, 'SRS').text = first['crs'].to_wkt() ET.SubElement(root, 'GeoTransform').text = ', '.join( f'{v:.16e}' for v in (left, xres, 0.0, top, 0.0, -yres)) vrt_dir = os.path.dirname(os.path.abspath(vrt_path)) for band in range(1, first['count'] + 1): vband = ET.SubElement(root, 'VRTRasterBand', dataType=_GDAL_TYPENAME[first['dtypes'][band - 1]], band=str(band)) if first['nodata'] is not None: ET.SubElement(vband, 'NoDataValue').text = repr(first['nodata']) ET.SubElement(vband, 'ColorInterp').text = first['colorinterp'][band - 1] for p in profiles: source = ET.SubElement(vband, 'ComplexSource') filename = ET.SubElement(source, 'SourceFilename', relativeToVRT='1') filename.text = os.path.relpath(os.path.abspath(p['path']), vrt_dir) ET.SubElement(source, 'SourceBand').text = str(band) ET.SubElement(source, 'SourceProperties', RasterXSize=str(p['width']), RasterYSize=str(p['height']), DataType=_GDAL_TYPENAME[p['dtypes'][band - 1]], BlockXSize=str(p['block'][1]), BlockYSize=str(p['block'][0])) ET.SubElement(source, 'SrcRect', xOff='0', yOff='0', xSize=str(p['width']), ySize=str(p['height'])) ET.SubElement(source, 'DstRect', xOff=str(int(round((p['bounds'].left - left) / xres))), yOff=str(int(round((top - p['bounds'].top) / yres))), xSize=str(int(round(p['width'] * p['transform'].a / xres))), ySize=str(int(round(p['height'] * abs(p['transform'].e) / yres)))) if p['nodata'] is not None: ET.SubElement(source, 'NODATA').text = repr(p['nodata']) ET.indent(root, space=' ') with AtomicFileWriter(vrt_path) as tmp_path: with open(tmp_path, 'w') as handle: handle.write(ET.tostring(root, encoding='unicode')) return vrt_path
[docs] def build_vrt_safe(tif_files, vrt_path): """Build a VRT mosaic, downgrading any failure to a warning. The ``.tif`` tiles are the deliverable; the VRT is a convenience mosaic over them. A missing GDAL backend or an unmosaicable tile set must not discard a completed rasterization job. Parameters ---------- tif_files : list of str Paths to input GeoTIFF files vrt_path : str Output VRT file path Returns ------- str or None Path to the written VRT, or None if it could not be built. """ try: return build_vrt(tif_files, vrt_path) except Exception as exc: logger.warning( f"Could not build VRT mosaic: {type(exc).__name__}: {exc} " f"[vrt={vrt_path}] — the {len(tif_files)} GeoTIFF tiles are unaffected" ) return None
[docs] def merge_and_export_rasters( gdf: Union[gpd.GeoDataFrame, dask.dataframe.DataFrame], output_path: str, rasterize_func, columns: Optional[List[str]] = None, compress: str = 'LZW', show_progress: bool = True, **rasterize_kwargs ) -> str: """ Rasterize all partitions and merge into a single output file. Parameters ---------- gdf : GeoDataFrame or dask GeoDataFrame Input spatially-indexed data output_path : str Output file path rasterize_func : callable Function to rasterize each partition columns : list of str, optional Columns to include in rasterization compress : str Compression method for GeoTIFF show_progress : bool Show Dask progress bar Returns ------- str Path to output file """ from rioxarray.merge import merge_datasets if hasattr(gdf, 'npartitions'): # Dask GeoDataFrame - rasterize partitions in parallel raster_parts = gdf.map_partitions( rasterize_func, columns=columns, **rasterize_kwargs, meta=pd.Series(dtype=object) ) # Driver-side gather instead of .compute() — see dask_safe_collect # docstring; the optimizer's RepartitionToFewer collapse wedges on # tunneled meshes past ~1500 partitions in dask >= 2025.2. from ..utils import dask_safe_collect try: rasters = dask_safe_collect(raster_parts, show_progress=show_progress) except (ValueError, ImportError): rasters = raster_parts.compute() # Filter valid rasters - handle both Series results and direct Dataset results valid_rasters = [] for r in rasters: if isinstance(r, pd.Series): # rasterize_func returns Series containing Dataset for item in r: if hasattr(item, 'data_vars') and len(item.data_vars) > 0: valid_rasters.append(item) elif hasattr(r, 'data_vars') and len(r.data_vars) > 0: valid_rasters.append(r) if not valid_rasters: raise GediRasterizationError("No valid rasters to merge") if len(valid_rasters) == 1: merged = valid_rasters[0] else: # Use merge_datasets which properly handles non-overlapping tiles # by creating a combined extent and filling NoData where tiles don't overlap # IMPORTANT: Use nodata=np.nan to ensure gaps are filled with NaN, not 0 merged = merge_datasets(valid_rasters, nodata=np.nan) else: # Single GeoDataFrame merged = rasterize_func(gdf, columns=columns, **rasterize_kwargs) if isinstance(merged, pd.Series) and len(merged) > 0: merged = merged.iloc[0] # Export return export_raster(merged, output_path, compress=compress)
[docs] def compute_raster_stats(xras: xr.Dataset) -> Dict[str, Dict[str, float]]: """ Compute basic statistics for each variable in a raster dataset. Parameters ---------- xras : xr.Dataset Input raster dataset Returns ------- dict Statistics for each variable: {var_name: {min, max, mean, std, count}} """ stats = {} for var in xras.data_vars: data = xras[var].values valid = ~np.isnan(data) stats[var] = { 'min': float(np.nanmin(data)) if valid.any() else np.nan, 'max': float(np.nanmax(data)) if valid.any() else np.nan, 'mean': float(np.nanmean(data)) if valid.any() else np.nan, 'std': float(np.nanstd(data)) if valid.any() else np.nan, 'count': int(valid.sum()), } return stats