Source code for gedih3.raster.h3_raster

# 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

"""
H3 Hexagon Rasterization Module

This module provides functions for converting H3-indexed GeoDataFrames
to raster format. Unlike EGI (which has native square pixels), H3 hexagons
require interpolation/resampling for raster output.

The workflow:
1. Determine appropriate resolution from H3 level
2. Find optimal UTM zone for the data extent
3. Rasterize using geocube with bilinear interpolation
4. Reproject to target CRS (default: EPSG:4326)
"""
from typing import List, Optional, Tuple
import warnings
import numpy as np
import pandas as pd
import geopandas as gpd
import xarray as xr
import h3
import pyproj
import rasterio.errors
from geocube.api.core import make_geocube

from .config import H3_RASTER_CRS
from ..cliutils import filter_raster_columns as _filter_raster_columns
from ..exceptions import GediRasterizationError
from ..utils import object_series


[docs] def get_h3_resolution_meters(h3_level: int) -> float: """ Get the approximate pixel resolution in meters for an H3 level. Uses the average hexagon edge length * 2 (diameter) as the pixel size. Parameters ---------- h3_level : int H3 resolution level (0-15) Returns ------- float Approximate pixel size in meters """ return h3.average_hexagon_edge_length(h3_level, 'm') * 2
[docs] def get_optimal_utm(gdf: gpd.GeoDataFrame) -> int: """ Find the optimal UTM zone EPSG code for a GeoDataFrame's extent. Parameters ---------- gdf : GeoDataFrame Input GeoDataFrame (any CRS) Returns ------- int EPSG code for the optimal UTM zone """ # Ensure we have bounds in WGS84 if gdf.crs.to_epsg() != 4326: bounds = gdf.to_crs(4326).total_bounds else: bounds = gdf.total_bounds # Query UTM CRS for the area of interest aoi = pyproj.aoi.AreaOfInterest(*bounds) utm_list = pyproj.database.query_utm_crs_info( datum_name='WGS 84', area_of_interest=aoi ) if not utm_list: # Fallback to WGS84 Pseudo-Mercator return 3857 return utm_list[0].code
def _detect_partition_level(gdf: gpd.GeoDataFrame) -> Optional[int]: """ Detect the H3 partition level from a GeoDataFrame. Looks for H3 partition columns (e.g., h3_03, h3_05) to determine the partition level used in the database. Parameters ---------- gdf : GeoDataFrame H3-indexed GeoDataFrame Returns ------- int or None Detected partition level, or None if not found """ import re # Look for partition columns like h3_03, h3_05, etc. h3_cols = [c for c in gdf.columns if re.match(r'h3_\d{2}$', str(c))] if h3_cols: # Get the lowest resolution (smallest number = coarsest = partition level) levels = [int(str(c).split('_')[1]) for c in h3_cols] return min(levels) return None
[docs] def h3_to_raster( gdf: gpd.GeoDataFrame, resolution: Optional[Tuple[float, float]] = None, columns: Optional[List[str]] = None, fill_value: float = np.nan, output_crs: str = H3_RASTER_CRS, partition_level: Optional[int] = None ) -> xr.Dataset: """ Convert H3-indexed GeoDataFrame to raster (xarray Dataset). This function rasterizes H3 hexagon data using geocube, with automatic resolution determination based on the H3 level. The data is first reprojected to UTM for accurate rasterization, then to the output CRS. Parameters ---------- gdf : GeoDataFrame H3-indexed GeoDataFrame with polygon geometries resolution : tuple of float, optional Output resolution as (y_res, x_res) in target CRS units. If None, automatically determined from H3 level. columns : list of str, optional Columns to rasterize. If None, all numeric columns are used. Internal columns (h3 indices, egi indices) are automatically excluded. fill_value : float Value for pixels with no data (default: NaN) output_crs : str Output coordinate reference system (default: EPSG:4326) partition_level : int, optional H3 partition level for metadata. If None, auto-detected from data. Returns ------- xr.Dataset Raster dataset with one data variable per column Examples -------- >>> # Rasterize H3 aggregated data >>> raster = h3_to_raster(h3_gdf, columns=['agbd_mean']) >>> raster.rio.to_raster("output.tif") """ if gdf.empty: raise GediRasterizationError("Cannot rasterize empty GeoDataFrame") # Filter out internal columns from rasterization columns = _filter_raster_columns(columns, gdf) # Get H3 level and partition ID h3_index = gdf.index[0] if gdf.index.name and gdf.index.name.startswith('h3_') else None if h3_index is None and len(gdf.columns) > 0: h3_cols = [c for c in gdf.columns if str(c).startswith('h3_')] if h3_cols: h3_index = gdf[h3_cols[0]].iloc[0] # Determine partition level dynamically if partition_level is None: partition_level = _detect_partition_level(gdf) if h3_index: h3_level = h3.get_resolution(h3_index) # Only compute partition ID if we have a valid partition level if partition_level is not None and partition_level < h3_level: partition_id = h3.cell_to_parent(h3_index, partition_level) else: partition_id = None else: # Try to get H3 level from index name (e.g., "h3_12") if gdf.index.name and gdf.index.name.startswith('h3_'): try: h3_level = int(gdf.index.name.split('_')[1]) except (ValueError, IndexError): h3_level = None else: h3_level = None partition_id = None # Determine resolution if not provided if resolution is None: if h3_level is None: raise GediRasterizationError("Cannot determine raster resolution: no H3 level found in data and no resolution provided") res_meters = get_h3_resolution_meters(h3_level) # Get optimal UTM for accurate resolution utm_epsg = get_optimal_utm(gdf) # Create raster in UTM gdf_utm = gdf.to_crs(epsg=utm_epsg) with warnings.catch_warnings(): warnings.filterwarnings('ignore', message='Dataset has no geotransform', category=UserWarning) warnings.filterwarnings('ignore', category=rasterio.errors.NotGeoreferencedWarning) xras = make_geocube( gdf_utm.reset_index(), measurements=columns, resolution=(-res_meters, res_meters), fill=fill_value ) # Reproject to output CRS xras = xras.rio.reproject(output_crs) xres, yres = xras.rio.resolution() resolution = (yres, xres) else: # Use provided resolution directly with warnings.catch_warnings(): warnings.filterwarnings('ignore', message='Dataset has no geotransform', category=UserWarning) warnings.filterwarnings('ignore', category=rasterio.errors.NotGeoreferencedWarning) xras = make_geocube( gdf.reset_index(), measurements=columns, resolution=resolution, fill=fill_value ) # Ensure NoData is tagged so GeoTIFF exports mask empty pixels for var in xras.data_vars: if np.issubdtype(xras[var].dtype, np.floating): xras[var] = xras[var].rio.write_nodata(np.nan) # Add metadata attrs = {'source': 'gedih3'} if h3_level is not None: attrs['h3_level'] = h3_level if partition_level is not None: attrs['h3_partition_level'] = partition_level xras = xras.assign_attrs(**attrs) if partition_id and partition_level is not None: # Use dynamic attribute name based on actual partition level partition_attr = f'h3_{partition_level:02d}_id' xras = xras.assign_attrs(**{partition_attr: partition_id}) for var in list(xras.data_vars): xras[var] = xras[var].assign_attrs(**{partition_attr: partition_id}) return xras
[docs] def rasterize_h3_partition( gdf: gpd.GeoDataFrame, columns: Optional[List[str]] = None, output_crs: str = H3_RASTER_CRS, include_partition_id: bool = True, partition_level: Optional[int] = None ) -> pd.Series: """ Rasterize a single H3 partition (for use with Dask map_partitions). Splits data by a coarser H3 parent level to create separate raster tiles, each named by the parent cell ID. This ensures manageable tile sizes and proper file naming for tiled output. Parameters ---------- gdf : GeoDataFrame H3-indexed GeoDataFrame partition columns : list of str, optional Columns to rasterize output_crs : str Output coordinate reference system include_partition_id : bool If True, include H3 partition ID in raster attributes partition_level : int, optional H3 partition level for grouping tiles. If None, auto-detected from data columns or computed as 3 levels coarser than data level. Returns ------- pd.Series Series containing xarray Dataset(s), one per spatial tile Examples -------- >>> # With Dask >>> rasters = ddf.map_partitions(rasterize_h3_partition, meta=pd.Series(dtype=object)) """ import logging logger = logging.getLogger(__name__) if gdf.empty or len(gdf) == 0: return pd.Series(dtype=object) try: # Detect partition level from columns if not provided if partition_level is None: partition_level = _detect_partition_level(gdf) # Get H3 level from index h3_index = gdf.index[0] if gdf.index.name and gdf.index.name.startswith('h3_') else None if h3_index is None: # Fallback: try to find H3 cells in columns h3_cols = [c for c in gdf.columns if str(c).startswith('h3_')] if h3_cols: h3_index = gdf[h3_cols[0]].iloc[0] if h3_index is None: return pd.Series(dtype=object) h3_level = h3.get_resolution(h3_index) # Determine grouping level for spatial tiles if partition_level is None: # No partition columns found — treat entire partition as one tile. # When called via map_partitions, each partition is already a spatial tile. partition_level = h3_level # Pre-aggregated data: partition_level >= h3_level means each # partition IS a complete tile - rasterize without splitting if partition_level >= h3_level: try: xras = h3_to_raster( gdf, columns=columns, output_crs=output_crs, partition_level=partition_level ) if len(xras.data_vars) > 0 and include_partition_id: partition_attr = f'h3_{h3_level:02d}_id' tile_id = str(gdf.index[0]) for var in list(xras.data_vars): xras[var] = xras[var].assign_attrs( **{partition_attr: tile_id} ) return object_series([xras]) if len(xras.data_vars) > 0 else pd.Series(dtype=object) except Exception as e: logger.warning(f"Rasterization failed for partition: {e}") return pd.Series(dtype=object) # Group H3 cells by parent at partition level if partition_level < h3_level: parents = gdf.index.map(lambda x: h3.cell_to_parent(x, partition_level)) else: # Data is already at or coarser than partition level parents = gdf.index unique_parents = parents.unique() results = [] for parent_id in unique_parents: mask = parents == parent_id tile_gdf = gdf.loc[mask] if len(tile_gdf) == 0: continue try: xras = h3_to_raster( tile_gdf, columns=columns, output_crs=output_crs, partition_level=partition_level ) if len(xras.data_vars) > 0 and include_partition_id: partition_attr = f'h3_{partition_level:02d}_id' for var in list(xras.data_vars): xras[var] = xras[var].assign_attrs(**{partition_attr: parent_id}) results.append(xras) except Exception as e: logger.debug(f"Rasterization failed for tile {parent_id}: {e}") continue if not results: return pd.Series(dtype=object) return object_series(results) except Exception as e: # Whole-partition failure — never benign, unlike the per-tile skips # above, so it is logged loudly rather than at debug. logger.warning(f"Rasterization failed for the whole partition: {e}") return pd.Series(dtype=object)
[docs] def compute_raster_mosaic( raster_series: pd.Series, show_progress: bool = False ) -> xr.Dataset: """ Merge multiple raster partitions into a single mosaic. Parameters ---------- raster_series : pd.Series Series of xarray Datasets from rasterize_h3_partition show_progress : bool If True, show progress during computation Returns ------- xr.Dataset Merged raster mosaic """ from rioxarray import merge as rio_merge # Filter out empty/invalid partitions valid = raster_series.apply( lambda x: hasattr(x, 'data_vars') and len(x.data_vars) > 0 ) valid_rasters = raster_series[valid] if len(valid_rasters) == 0: raise ValueError("No valid raster partitions to merge") if len(valid_rasters) == 1: return valid_rasters.iloc[0] # Merge all partitions raster_list = valid_rasters.tolist() merged = xr.merge([ rio_merge.merge_arrays([r[var] for r in raster_list if var in r]) for var in raster_list[0].data_vars ]) return merged
[docs] def get_h3_raster_resolution( gdf: gpd.GeoDataFrame, npartitions: int = 1 ) -> Tuple[float, float]: """ Determine the appropriate raster resolution for H3 data. Parameters ---------- gdf : GeoDataFrame or dask GeoDataFrame H3-indexed data npartitions : int Number of partitions to sample (for dask) Returns ------- tuple (y_resolution, x_resolution) in degrees (EPSG:4326) """ # Get sample data if hasattr(gdf, 'npartitions'): sample = gdf.head(npartitions=min(gdf.npartitions, npartitions)) else: sample = gdf if sample.empty: raise ValueError("Cannot determine resolution from empty GeoDataFrame") # Get H3 level h3_index = sample.index[0] if sample.index.name and sample.index.name.startswith('h3_') else None if h3_index is None: h3_cols = [c for c in sample.columns if str(c).startswith('h3_')] if h3_cols: h3_index = sample[h3_cols[0]].iloc[0] if h3_index is None: raise ValueError("Could not find H3 index in GeoDataFrame") h3_level = h3.get_resolution(h3_index) res_meters = get_h3_resolution_meters(h3_level) # Get UTM for accurate conversion utm_epsg = get_optimal_utm(sample) # Create a small test raster to get the resolution in degrees sample_utm = sample.head(10).to_crs(epsg=utm_epsg) test_raster = make_geocube( sample_utm.reset_index(), resolution=(-res_meters, res_meters) ) test_raster = test_raster.rio.reproject(H3_RASTER_CRS) return test_raster.rio.resolution()