#! python
# 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 Image Sampling at GEDI Shot Locations
Sample raster pixel values at true GEDI shot coordinates (Points), with
optional moving-window statistics (sum, mean, median, mode).
Supports single raster files, VRT mosaics, and tile directories (auto-mosaicked
via VRT). Works with both H3 databases and simplified datasets from gh3_extract.
"""
import os
import re
import logging
import glob as _glob
import numpy as np
import pandas as pd
import geopandas as gpd
from .exceptions import GediImageSamplingError
logger = logging.getLogger(__name__)
# Window operation IDs (matches legacy 3-digit spec)
_WINDOW_OPS = {0: 'sum', 1: 'mean', 2: 'median', 3: 'mode', 4: 'std', 5: 'min', 6: 'max', 7: 'count', 8: 'range'}
# =============================================================================
# VRT Resolution
# =============================================================================
[docs]
def resolve_raster_source(image_path, file_format='tif', odir=None):
"""Resolve image input to a single raster source.
- Single file (.tif, .vrt, etc.) -> return as-is
- Directory of tiles -> build VRT mosaic, return VRT path
Parameters
----------
image_path : str
Path to raster file, VRT file, or directory of tiles
file_format : str
File extension to glob when image_path is a directory
odir : str, optional
Output directory for saving VRT beside output. If provided, VRT is
saved as ``{odir}/{tiles_dirname}.vrt`` instead of inside the tiles
directory.
Returns
-------
tuple
(raster_path, is_temp_vrt, tile_count)
Raises
------
GediImageSamplingError
If no valid raster found
"""
# Remote URLs are valid GDAL sources — pass through directly
if image_path.startswith(('http://', 'https://', 's3://', '/vsicurl/', '/vsis3/')):
return image_path, False, 1
if os.path.isfile(image_path):
return image_path, False, 1
if not os.path.isdir(image_path):
raise GediImageSamplingError(f"Image path not found: {image_path}")
# Directory: glob for tiles
tiles = sorted(_glob.glob(os.path.join(image_path, f'*.{file_format}')))
if not tiles:
raise GediImageSamplingError(
f"No .{file_format} files found in {image_path}"
)
if len(tiles) == 1:
return tiles[0], False, 1
# Build VRT mosaic — save beside output dir with tiles directory basename
from .raster.export import build_vrt
tiles_basename = os.path.basename(os.path.normpath(image_path))
if odir:
os.makedirs(odir, exist_ok=True)
vrt_path = os.path.join(odir, f'{tiles_basename}.vrt')
else:
vrt_path = os.path.join(image_path, '_gedih3_mosaic.vrt')
# Fatal here, unlike the export paths: the VRT *is* the sampling source,
# so there is nothing to fall back to. Externally-supplied tiles are also
# the likeliest place to hit the pure-Python writer's preconditions.
try:
build_vrt(tiles, vrt_path)
except Exception as exc:
raise GediImageSamplingError(
f"Could not mosaic {len(tiles)} tiles from {image_path}: "
f"{type(exc).__name__}: {exc}"
) from exc
logger.info(f"Built VRT mosaic from {len(tiles)} tiles: {vrt_path}")
return vrt_path, True, len(tiles)
# =============================================================================
# Raster Metadata
# =============================================================================
[docs]
def get_raster_info(raster_path):
"""Read raster metadata (CRS, bounds, resolution, bands, nodata).
Also computes bounds in WGS84 (EPSG:4326) for spatial filtering of GEDI data.
Parameters
----------
raster_path : str
Path to raster file or VRT
Returns
-------
dict
Keys: crs, bounds, bounds_wgs84, resolution, shape, band_count,
band_names, nodata
"""
import rioxarray
with rioxarray.open_rasterio(raster_path) as ras:
crs = ras.rio.crs
bounds = ras.rio.bounds() # (minx, miny, maxx, maxy)
resolution = ras.rio.resolution() # (x_res, y_res)
shape = (ras.sizes.get('y', ras.shape[-2]), ras.sizes.get('x', ras.shape[-1]))
band_count = ras.sizes.get('band', ras.shape[0]) if len(ras.shape) == 3 else 1
nodata = ras.rio.nodata
# Band names from long_name attribute or fallback to b0, b1, ...
if hasattr(ras, 'long_name') and ras.long_name is not None:
long_name = ras.long_name
if isinstance(long_name, str):
band_names = [long_name]
else:
band_names = list(long_name)
else:
band_names = [f'b{i}' for i in range(band_count)]
# Compute WGS84 bounds for spatial filtering
from pyproj import Transformer
if crs and not crs.to_epsg() == 4326:
transformer = Transformer.from_crs(crs, 'EPSG:4326', always_xy=True)
x_coords = [bounds[0], bounds[2], bounds[0], bounds[2]]
y_coords = [bounds[1], bounds[1], bounds[3], bounds[3]]
lons, lats = transformer.transform(x_coords, y_coords)
bounds_wgs84 = (min(lons), min(lats), max(lons), max(lats))
else:
bounds_wgs84 = bounds
return {
'crs': crs,
'bounds': bounds,
'bounds_wgs84': bounds_wgs84,
'resolution': resolution,
'shape': shape,
'band_count': band_count,
'band_names': band_names,
'nodata': nodata,
}
# =============================================================================
# Window Operations (ported from legacy, with median fix)
# =============================================================================
def _window_sum(data, size):
"""Sum within a moving window."""
from scipy.ndimage import convolve
kernel = np.ones((size, size))
return convolve(data, kernel, mode='constant', cval=0)
def _window_mean(data, size):
"""Mean within a moving window."""
from scipy.ndimage import convolve
kernel = np.ones((size, size)) / (size * size)
return convolve(data, kernel, mode='nearest')
def _window_median(data, size):
"""Median within a moving window."""
from scipy.ndimage import percentile_filter
return percentile_filter(data, 50, size=(size, size), mode='nearest')
def _window_mode(data, size):
"""Mode within a moving window (optimized binary-mask convolution).
For each unique value, creates a binary mask and convolves with a ones
kernel to count occurrences in the window. Tracks the value with the
highest count at each pixel.
"""
from scipy.ndimage import convolve
if not np.issubdtype(data.dtype, np.integer):
data = data.astype(int)
kernel = np.ones((size, size))
uvals = np.unique(data)
result = np.full_like(data, uvals[0])
best_count = convolve((data == uvals[0]).astype(np.uint8), kernel, mode='constant', cval=0)
for u in uvals[1:]:
count = convolve((data == u).astype(np.uint8), kernel, mode='constant', cval=0)
mask = count > best_count
result[mask] = u
best_count[mask] = count[mask]
return result
def _window_std(data, size):
"""Std deviation within a moving window via sum-of-squares (exact, O(n))."""
from scipy.ndimage import convolve
kernel = np.ones((size, size)) / (size * size)
d = data.astype(float)
mean = convolve(d, kernel, mode='nearest')
mean_sq = convolve(d ** 2, kernel, mode='nearest')
return np.sqrt(np.maximum(0.0, mean_sq - mean ** 2))
def _window_min(data, size):
"""Minimum within a moving window."""
from scipy.ndimage import minimum_filter
return minimum_filter(data, size=(size, size), mode='nearest')
def _window_max(data, size):
"""Maximum within a moving window."""
from scipy.ndimage import maximum_filter
return maximum_filter(data, size=(size, size), mode='nearest')
def _window_count(data, size):
"""Count of valid (non-NaN/non-zero) pixels within a moving window."""
from scipy.ndimage import convolve
kernel = np.ones((size, size))
valid = (data != 0).astype(np.uint16) if not np.issubdtype(data.dtype, np.floating) \
else np.isfinite(data).astype(np.uint16)
return convolve(valid, kernel, mode='constant', cval=0)
def _window_range(data, size):
"""Range (max - min) within a moving window."""
from scipy.ndimage import maximum_filter, minimum_filter
return maximum_filter(data, size=(size, size), mode='nearest') \
- minimum_filter(data, size=(size, size), mode='nearest')
_WINDOW_FUNCS = {
'sum': _window_sum,
'mean': _window_mean,
'median': _window_median,
'mode': _window_mode,
'std': _window_std,
'min': _window_min,
'max': _window_max,
'count': _window_count,
'range': _window_range,
}
# =============================================================================
# Window Spec Parsing
# =============================================================================
[docs]
def parse_window_specs(specs):
"""Parse legacy 3-digit format for window operations.
Format: each spec is a 3-character string 'BZO' where:
- B = band number (0-indexed)
- Z = window size (1-9, must be odd)
- O = operation ID (0=sum, 1=mean, 2=median, 3=mode, 4=std, 5=min, 6=max, 7=count, 8=range)
Parameters
----------
specs : list of str
Window spec strings, e.g. ['033', '151']
Returns
-------
list of dict
Each dict has: band (int), size (int), op (str), name (str)
Raises
------
GediImageSamplingError
If any spec is invalid
"""
if specs is None:
return []
result = []
for spec in specs:
spec = str(spec)
if len(spec) != 3:
raise GediImageSamplingError(
f"Window spec must be 3 digits (band/size/op), got '{spec}'"
)
try:
band = int(spec[0])
size = int(spec[1])
op_id = int(spec[2])
except ValueError:
raise GediImageSamplingError(
f"Window spec must be 3 digits (band/size/op), got '{spec}'"
)
if size < 1 or size > 9 or size % 2 == 0:
raise GediImageSamplingError(
f"Window size must be odd 1-9, got {size} in spec '{spec}'"
)
if op_id not in _WINDOW_OPS:
raise GediImageSamplingError(
f"Window op must be 0-8, got {op_id} in spec '{spec}'"
)
op_name = _WINDOW_OPS[op_id]
result.append({
'band': band,
'size': size,
'op': op_name,
'name': f'b{band}_{op_name}_{size}x{size}',
})
return result
# =============================================================================
# Core Sampling Function (called via map_partitions)
# =============================================================================
[docs]
def sample_raster_at_points(df, raster_path, band_names=None,
window_ops=None, fillna=None, dropna=False,
geo=False, partition_col=None, band_indices=None,
all_band_names=None, pixel_distance=False):
"""Sample raster values at GEDI shot locations within a single partition.
Designed to be called via Dask map_partitions. For each partition:
1. Extract true point coordinates from geometry column
2. Compute partition bbox with buffer for window operations
3. Open raster, clip to bbox (reads only relevant VRT tiles)
4. Reproject points to raster CRS if needed
5. Sample nearest pixel for each shot
6. Compute relative_pixel_distance (if pixel_distance=True)
7. Apply window operations if specified
Parameters
----------
df : DataFrame or GeoDataFrame
Input partition with geometry column (Point geometries)
raster_path : str
Path to raster file or VRT
band_names : list of str, optional
Names for the output band columns. When ``band_indices`` is provided,
should have length == len(band_indices).
window_ops : list of dict, optional
Parsed window operation specs from parse_window_specs().
Band indices in window_ops refer to original raster bands (0-indexed).
fillna : float, optional
Value to fill raster NaN/NoData before sampling
dropna : bool
Drop rows where all band columns are NaN
geo : bool
Include geometry in output
partition_col : str, optional
Partition column name to preserve in output
band_indices : list of int, optional
0-based indices of raster bands to sample. If None, all bands are sampled.
all_band_names : list of str, optional
Full list of raster band names (all bands). Used for resolving window
operation column names when ``band_indices`` selects a subset. If None,
defaults to ``band_names``.
pixel_distance : bool
If True, include ``relative_pixel_distance`` column in output.
Defaults to False.
Returns
-------
DataFrame or GeoDataFrame
Sampled data with band columns, optional relative_pixel_distance,
and optional window operation columns
"""
import xarray as xr
import rioxarray
if df is None or (hasattr(df, 'empty') and df.empty) or len(df) == 0:
spatial_cols = None
if partition_col:
spatial_cols = {partition_col: 'object'}
return _empty_sampling_result(band_names, window_ops, geo, partition_col, spatial_cols=spatial_cols,
pixel_distance=pixel_distance)
# --- Extract coordinates ---
if 'geometry' in df.columns and hasattr(df['geometry'], 'geom_type'):
geom = df['geometry']
# Ensure points, not other geometry types
pts_lon = geom.x.values
pts_lat = geom.y.values
else:
# Fallback to coordinate columns
from .cliutils import find_coordinate_column
lon_col = find_coordinate_column(df.columns, 'lon_lowestmode')
lat_col = find_coordinate_column(df.columns, 'lat_lowestmode')
if lon_col is None or lat_col is None:
raise GediImageSamplingError(
"Cannot extract coordinates: no geometry column and no "
"lon_lowestmode/lat_lowestmode columns found"
)
pts_lon = df[lon_col].values
pts_lat = df[lat_col].values
if len(pts_lon) == 0:
spatial_cols = _detect_spatial_cols(df) or ({partition_col: 'object'} if partition_col else None)
return _empty_sampling_result(band_names, window_ops, geo, partition_col, spatial_cols=spatial_cols,
pixel_distance=pixel_distance)
# --- Resolve band selection ---
# Build merged band index set (selected + window-referenced) for efficient loading
if band_indices is not None:
window_band_set = {wop['band'] for wop in (window_ops or [])}
load_indices = sorted(set(band_indices) | window_band_set)
load_map = {orig: pos for pos, orig in enumerate(load_indices)}
else:
load_indices = None
load_map = None
# --- Open raster and clip to partition bbox ---
max_win_size = max((w['size'] for w in window_ops), default=0) if window_ops else 0
with rioxarray.open_rasterio(raster_path, masked=True) as ras:
ras_crs = ras.rio.crs
ras_bounds = ras.rio.bounds() # (minx, miny, maxx, maxy)
ras_res = np.abs(ras.rio.resolution()).mean()
ras_nodata = ras.rio.nodata
# Determine band names from raster if not provided
if band_names is None:
if hasattr(ras, 'long_name') and ras.long_name is not None:
ln = ras.long_name
band_names = [ln] if isinstance(ln, str) else list(ln)
else:
nb = ras.sizes.get('band', ras.shape[0]) if len(ras.shape) == 3 else 1
band_names = [f'b{i}' for i in range(nb)]
# Ensure all_band_names is set (for window column naming)
# Must be set after band_names auto-detection from raster
if all_band_names is None:
all_band_names = band_names
# Reproject points to raster CRS if needed
if ras_crs and ras_crs.to_epsg() != 4326:
from pyproj import Transformer
transformer = Transformer.from_crs('EPSG:4326', ras_crs, always_xy=True)
pts_x, pts_y = transformer.transform(pts_lon, pts_lat)
else:
pts_x = pts_lon
pts_y = pts_lat
# Identify shots inside raster bounds
inside_mask = (
(pts_x >= ras_bounds[0]) & (pts_x <= ras_bounds[2]) &
(pts_y >= ras_bounds[1]) & (pts_y <= ras_bounds[3])
)
# Initialize output columns
n_shots = len(df)
band_values = {bn: np.full(n_shots, np.nan) for bn in band_names}
distances = np.full(n_shots, np.nan)
window_values = {}
if window_ops:
for wop in window_ops:
col_name = _resolve_window_col_name(wop, all_band_names)
window_values[col_name] = np.full(n_shots, np.nan)
if not inside_mask.any():
# No shots inside raster — return all NaN
pass
else:
# Compute bbox of inside points with buffer
inside_x = pts_x[inside_mask]
inside_y = pts_y[inside_mask]
buffer = max_win_size * ras_res if max_win_size > 0 else ras_res
clip_minx = float(inside_x.min()) - buffer
clip_miny = float(inside_y.min()) - buffer
clip_maxx = float(inside_x.max()) + buffer
clip_maxy = float(inside_y.max()) + buffer
# Clip raster to bbox (efficient for VRT — reads only overlapping tiles)
try:
ras_clip = ras.rio.clip_box(
minx=clip_minx, miny=clip_miny,
maxx=clip_maxx, maxy=clip_maxy
)
except Exception:
# clip_box can fail if bbox doesn't overlap at all
ras_clip = None
if ras_clip is not None and ras_clip.sizes.get('x', 0) > 0 and ras_clip.sizes.get('y', 0) > 0:
if fillna is not None:
ras_clip = ras_clip.fillna(fillna)
# Select only needed bands when band_indices is provided
if load_indices is not None and len(ras_clip.shape) == 3:
ras_clip = ras_clip.isel(band=load_indices)
# Sample nearest pixel for inside shots
tgt_x = xr.DataArray(inside_x, dims='points')
tgt_y = xr.DataArray(inside_y, dims='points')
sampled = ras_clip.sel(x=tgt_x, y=tgt_y, method='nearest')
# Extract band values
inside_idx = np.where(inside_mask)[0]
if len(ras_clip.shape) == 3:
if band_indices is not None:
# Map selected band indices to positions in the loaded subset
for i, bn in enumerate(band_names):
pos = load_map[band_indices[i]]
band_values[bn][inside_idx] = sampled.isel(band=pos).values
else:
for i, bn in enumerate(band_names):
band_values[bn][inside_idx] = sampled.isel(band=i).values
else:
band_values[band_names[0]][inside_idx] = sampled.values
# Compute relative_pixel_distance
# Get actual sampled x,y coordinates
sampled_x = sampled.coords['x'].values
sampled_y = sampled.coords['y'].values
offset_x = np.abs(inside_x - sampled_x)
offset_y = np.abs(inside_y - sampled_y)
dist = (offset_x + offset_y) / 2.0 / ras_res
distances[inside_idx] = dist
# Apply window operations
if window_ops:
for wop in window_ops:
band_idx = wop['band']
if len(ras_clip.shape) == 3:
# Use load_map to find position in loaded band subset
pos = load_map[band_idx] if load_map else band_idx
band_data = ras_clip.isel(band=pos).values.copy()
else:
band_data = ras_clip.values.copy()
if len(band_data.shape) == 3:
band_data = band_data[0]
# Handle nodata: replace with NaN for window ops
if ras_nodata is not None and fillna is None:
band_data = band_data.astype(float)
band_data[band_data == ras_nodata] = np.nan
# Apply window function
wfunc = _WINDOW_FUNCS[wop['op']]
filtered = wfunc(band_data, wop['size'])
# Sample filtered raster at inside points
filtered_xr = xr.DataArray(
filtered,
coords={'y': ras_clip.coords['y'], 'x': ras_clip.coords['x']},
dims=['y', 'x']
)
w_sampled = filtered_xr.sel(x=tgt_x, y=tgt_y, method='nearest')
col_name = _resolve_window_col_name(wop, all_band_names)
window_values[col_name][inside_idx] = w_sampled.values
# --- Build output DataFrame ---
out = {}
# Preserve all spatial index columns (h3_XX, egiXX)
# Check DataFrame index first
if df.index.name and re.match(r'^(h3_\d{2}|egi\d{2})$', str(df.index.name)):
out[df.index.name] = df.index.values
# Check columns
for col in df.columns:
if re.match(r'^(h3_\d{2}|egi\d{2})$', str(col)):
out[str(col)] = df[col].values
# Warn if partition_col was specified but not found
if partition_col and partition_col not in out:
logger.warning(f"Partition column '{partition_col}' not found in data")
# shot_number
sn_col = None
for c in df.columns:
if c.startswith('shot_number'):
sn_col = c
break
if sn_col:
out['shot_number'] = df[sn_col].values
else:
logger.warning(
"shot_number column not found in partition — output will lack shot identifiers. "
"Use an H3 database or gh3_extract output as the data source."
)
# Band values
out.update(band_values)
# Distance
if pixel_distance:
out['relative_pixel_distance'] = distances
# Window values
out.update(window_values)
result = pd.DataFrame(out)
# Geometry
if geo and 'geometry' in df.columns:
result = gpd.GeoDataFrame(result, geometry=df['geometry'].values, crs='EPSG:4326')
# dropna: drop rows where ALL band columns are NaN
if dropna:
result = result.dropna(subset=band_names, how='all')
# Set finest spatial column as the DataFrame index
idx_col = _finest_spatial_col(result.columns)
if idx_col:
result = result.set_index(idx_col)
return result
def _resolve_window_col_name(wop, band_names):
"""Resolve a window op's output column name using actual band names."""
bname = band_names[wop['band']] if band_names and wop['band'] < len(band_names) else f"b{wop['band']}"
return wop['name'].replace(f"b{wop['band']}_", f"{bname}_")
def _detect_spatial_cols(df):
"""Detect all spatial index columns (h3_XX, egiXX) from a DataFrame.
Inspects both columns and index name for spatial patterns.
Returns
-------
dict
Mapping of column_name -> dtype string for all detected spatial columns.
"""
spatial = {}
# Check DataFrame index
if df.index.name and re.match(r'^(h3_\d{2}|egi\d{2})$', str(df.index.name)):
spatial[df.index.name] = str(df.index.dtype)
# Check columns
for col in df.columns:
if re.match(r'^(h3_\d{2}|egi\d{2})$', str(col)):
spatial[str(col)] = str(df[col].dtype)
return spatial
def _finest_spatial_col(col_names):
"""Return the finest-resolution spatial column name from a list.
For H3: highest level number is finest (h3_12 > h3_03).
For EGI: lowest level number is finest (egi01 < egi12).
Returns None if no spatial columns found.
"""
h3_cols = sorted([c for c in col_names if re.match(r'^h3_\d{2}$', str(c))], reverse=True)
egi_cols = sorted([c for c in col_names if re.match(r'^egi\d{2}$', str(c))])
if h3_cols:
return h3_cols[0]
if egi_cols:
return egi_cols[0]
return None
def _empty_sampling_result(band_names, window_ops, geo, partition_col,
spatial_cols=None, all_band_names=None,
pixel_distance=False):
"""Return empty DataFrame matching the sampling output schema.
Parameters
----------
band_names : list of str
Band column names for output
window_ops : list of dict
Parsed window specs
geo : bool
Include geometry column
partition_col : str or None
Partition column name (included in spatial_cols if provided)
spatial_cols : dict, optional
Dict of spatial column name -> dtype to include in output.
If None, falls back to including just partition_col.
all_band_names : list of str, optional
Full raster band names for window column naming. Defaults to band_names.
"""
if all_band_names is None:
all_band_names = band_names
cols = {}
if spatial_cols:
for col_name, dtype in spatial_cols.items():
cols[col_name] = pd.Series(dtype=dtype)
elif partition_col:
cols[partition_col] = pd.Series(dtype=str)
# uint64 matches the on-disk GEDI shot_number dtype. Using int64 here lets
# Dask reconcile a mixed (int64-meta, uint64-data) graph by upcasting to
# float64 across the whole column — and float64 has only ~15 significant
# digits, while shot_numbers are 19+ digit integers, so the cast silently
# collapses thousands of distinct shots onto the same float value. That
# corrupts every downstream merge-on-shot_number and produces a Cartesian
# blowup (one float-collapsed key → many real shots).
cols['shot_number'] = pd.Series(dtype='uint64')
if band_names:
for bn in band_names:
cols[bn] = pd.Series(dtype='float64')
if pixel_distance:
cols['relative_pixel_distance'] = pd.Series(dtype='float64')
if window_ops:
for wop in window_ops:
col_name = _resolve_window_col_name(wop, all_band_names)
cols[col_name] = pd.Series(dtype='float64')
if geo:
result = gpd.GeoDataFrame(cols, geometry=gpd.GeoSeries(dtype='geometry'))
else:
result = pd.DataFrame(cols)
# Set finest spatial column as index (matches sample_raster_at_points behavior)
idx_col = _finest_spatial_col(result.columns)
if idx_col:
result = result.set_index(idx_col)
return result
# =============================================================================
# Meta computation for Dask map_partitions
# =============================================================================
def _compute_sampling_meta(band_names, window_ops, geo, partition_col,
spatial_cols=None, all_band_names=None,
pixel_distance=False):
"""Build empty DataFrame with correct schema for Dask map_partitions meta.
Parameters
----------
band_names : list of str
Band column names for output
window_ops : list of dict
Parsed window specs
geo : bool
Include geometry column
partition_col : str or None
Partition column name
spatial_cols : dict, optional
Dict of spatial column name -> dtype to include in output schema
all_band_names : list of str, optional
Full raster band names for window column naming. Defaults to band_names.
Returns
-------
DataFrame or GeoDataFrame
Empty frame matching output schema
"""
return _empty_sampling_result(band_names, window_ops, geo, partition_col,
spatial_cols=spatial_cols, all_band_names=all_band_names,
pixel_distance=pixel_distance)
# =============================================================================
# High-Level Python API
# =============================================================================
[docs]
def from_image(image_path, data_source=None, region=None,
query=None, band_names=None, band_indices=None,
window_ops=None, fillna=None,
dropna=False, geo=False, file_format='tif',
pixel_distance=False):
"""Sample raster values at GEDI shot locations.
Supports two input modes with different ROI logic:
Mode 1 - H3 database (via data_source pointing to an H3 database):
ROI = image boundaries (intersected with user region if provided).
Only H3 partitions overlapping the image are loaded.
Mode 2 - Simplified dataset (via data_source pointing to a simplified dataset):
ROI = entire dataset (all tiles loaded regardless of image coverage).
Shots outside image bounds get NaN values.
In both cases, uses TRUE shot geometry for coordinate extraction.
Parameters
----------
image_path : str
Path to raster file, VRT, or tile directory
data_source : str, optional
Path to H3 database or simplified dataset directory
region : GeoDataFrame or bbox, optional
Additional spatial filter
query : str, optional
Pandas query string for filtering shots
band_names : list of str, optional
Custom band names for output columns. When ``band_indices`` is
provided, should have length == len(band_indices).
band_indices : list of int, optional
0-based indices of raster bands to sample. If None, all bands.
window_ops : list of dict, optional
Parsed window specs from parse_window_specs()
fillna : float, optional
Value to fill raster NaN/NoData
dropna : bool
Drop rows where all band columns are NaN
geo : bool
Include geometry in output
file_format : str
Tile file extension for directory input
pixel_distance : bool
If True, include ``relative_pixel_distance`` column in output.
Defaults to False.
Returns
-------
dask.dataframe.DataFrame
Sampled data as Dask DataFrame
Raises
------
GediImageSamplingError
If inputs are invalid or sampling fails
"""
import dask.dataframe
import gedih3.gh3driver as gh3
if data_source is None:
raise GediImageSamplingError(
"Must provide data_source (H3 database or simplified dataset)"
)
# Resolve raster source
raster_path, is_vrt, tile_count = resolve_raster_source(image_path, file_format)
raster_info = get_raster_info(raster_path)
logger.info(f"Raster: {raster_path} ({raster_info['band_count']} bands, "
f"CRS={raster_info['crs']})")
# Resolve band names
all_band_names = raster_info['band_names']
if band_names is None:
if band_indices is not None:
band_names = [all_band_names[i] for i in band_indices]
else:
band_names = all_band_names
# Detect input type and load data
from .cliutils import get_dataset_index_info
ds_info = get_dataset_index_info(data_source)
if ds_info['source_type'] == 'h3_database':
# Mode 1: H3 database — ROI = image bounds (intersected with user region)
from shapely.geometry import box
img_box = box(*raster_info['bounds_wgs84'])
if region is not None:
if hasattr(region, 'geometry'):
roi = gpd.GeoDataFrame(geometry=[img_box], crs='EPSG:4326')
roi = gpd.overlay(roi, region.to_crs('EPSG:4326'), how='intersection')
else:
roi = gpd.GeoDataFrame(geometry=[img_box], crs='EPSG:4326')
else:
roi = gpd.GeoDataFrame(geometry=[img_box], crs='EPSG:4326')
columns = ['geometry'] # Always need geometry for coordinate extraction
ddf = gh3.gh3_load(source=data_source, columns=columns, region=roi, query=query)
# Detect partition column
part_level = gh3.gh3_read_meta('h3_partition_level', gh3_root_dir=data_source)
from .cliutils import h3_col_name
partition_col = h3_col_name(part_level)
else:
# Mode 2: Simplified dataset — load all tiles
ddf = gh3.gh3_load(data_source)
if query:
ddf = ddf.query(query)
# Detect partition column from dataset (ds_info already computed above)
if ds_info['index_type'] == 'egi':
from .egi.config import egi_col_name
part_level = ds_info.get('partition_level') or ds_info.get('egi_partition_level')
partition_col = egi_col_name(part_level) if part_level else None
elif ds_info['index_type'] == 'h3':
from .cliutils import h3_col_name
part_level = ds_info.get('partition_level') or ds_info.get('h3_partition_level')
partition_col = h3_col_name(part_level) if part_level else None
else:
partition_col = None
# Validate geometry is available
if 'geometry' not in ddf.columns:
raise GediImageSamplingError(
"Input data must contain geometry column for coordinate extraction. "
"For simplified datasets, re-extract with the -g flag."
)
# Compute meta for map_partitions
meta = _compute_sampling_meta(band_names, window_ops, geo, partition_col,
all_band_names=all_band_names if band_indices else None,
pixel_distance=pixel_distance)
# Apply sampling via map_partitions
result = ddf.map_partitions(
sample_raster_at_points,
raster_path=raster_path,
band_names=band_names,
window_ops=window_ops,
fillna=fillna,
dropna=dropna,
geo=geo,
partition_col=partition_col,
band_indices=band_indices,
all_band_names=all_band_names if band_indices else None,
pixel_distance=pixel_distance,
meta=meta
)
return result