#! 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
"""
GEDI Raster Sampling Tool
Sample raster pixel values at GEDI shot locations from an H3 database or
simplified dataset. Supports single files, VRT mosaics, and tile directories.
Optional moving-window statistics (sum, mean, median, mode).
"""
import os
import re
import sys
import argparse
[docs]
def get_cmd_args():
"""Parse command line arguments for raster sampling tool."""
from gedih3.cliutils import add_dask_args, add_verbosity_args, add_storage_args
p = argparse.ArgumentParser(
description="Sample raster pixel values at GEDI shot locations"
)
# Image input
p.add_argument("-i", "--image", dest="image", required=True, type=str,
help="path to raster file, VRT, or tile directory")
p.add_argument("-if", "--image-format", dest="image_format", type=str, default='tif',
help="file extension for tile directory globbing [default=tif]")
# Band configuration
p.add_argument("-b", "--band-names", dest="band_names", nargs='+', type=str, default=None,
help="custom band names for output columns")
p.add_argument("-B", "--bands", dest="bands", nargs='+', type=int, default=None,
help="select specific bands by 0-based index (default: all bands)")
# Data source
p.add_argument("-d", "--database", dest="database", type=str, default=None,
help="path to H3 database or simplified dataset directory")
# Output
p.add_argument("-o", "--output", dest="output", required=True, type=str,
help="output directory")
p.add_argument("-f", "--format", dest="format", type=str, default='parquet',
help="output format [default=parquet]")
p.add_argument("-m", "--merge", dest="merge", action='store_true',
help="merge all partitions into single file")
# Window operations
p.add_argument("-w", "--window", dest="window", nargs='+', type=str, default=None,
help="window ops in 3-digit format: band(0-9) size(1-9,odd) op(0=sum,1=mean,2=median,3=mode,4=std,5=min,6=max,7=count,8=range)")
# Spatial/quality filtering
p.add_argument("-r", "--region", dest="region", type=str, default=None,
help="additional spatial filter: vector file, bbox 'W,S,E,N', or ISO3 code")
p.add_argument("-y", "--quality", dest="quality", action='store_true',
help="apply quality filtering")
p.add_argument("-q", "--query", dest="query", type=str, default=None,
help="pandas query string for filtering")
p.add_argument("--beam-type", dest="beam_type", type=str, default=None,
choices=["power", "coverage"],
help="filter by beam type: 'power' (full-power beams) or 'coverage' (coverage beams)")
# Temporal filtering
p.add_argument("-t0", "--time-start", dest="time_start", type=str, default=None,
help="start date [YYYY-MM-DD]")
p.add_argument("-t1", "--time-end", dest="time_end", type=str, default=None,
help="end date [YYYY-MM-DD]")
# Raster handling
p.add_argument("-g", "--geo", dest="geo", action='store_true',
help="include geometry in output")
p.add_argument("-F", "--fillna", dest="fillna", type=float, default=None,
help="fill raster NaN/NoData with this value")
p.add_argument("--dropna", dest="dropna", action='store_true',
help="drop rows where all band columns are NaN")
p.add_argument("--resume", dest="resume", action='store_true',
help="skip already-processed partitions")
# Dask, storage, and verbosity
add_dask_args(p)
add_storage_args(p)
add_verbosity_args(p)
return p.parse_args()
[docs]
def main():
args = get_cmd_args()
from gedih3.cliutils import cli_exception_handler
with cli_exception_handler(args):
import glob
import geopandas as gpd
from dask.distributed import Client
import gedih3.gh3driver as gh3
from gedih3.cliutils import (
setup_logging, print_banner, print_success,
configure_database_path, parse_region, parse_dask_args,
h3_col_name, get_dataset_index_info, build_query_string,
resolve_path_args, setup_storage
)
from gedih3.imgutils import (
resolve_raster_source, get_raster_info, parse_window_specs,
sample_raster_at_points, _compute_sampling_meta
)
# Setup
logger = setup_logging(args, __name__)
setup_storage(args, logger=logger)
print_banner("GEDI Raster Sampling Tool", logger=logger)
resolve_path_args(args, ['database', 'output', 'image'], logger=logger)
# Resolve raster source
logger.info(f"Image source: {args.image}")
raster_path, is_vrt, tile_count = resolve_raster_source(args.image, args.image_format, odir=args.output)
if tile_count > 1:
logger.info(f" Built VRT mosaic from {tile_count} tiles")
raster_info = get_raster_info(raster_path)
logger.info(f" CRS: {raster_info['crs']}")
logger.info(f" Bounds (WGS84): {raster_info['bounds_wgs84']}")
logger.info(f" Bands: {raster_info['band_count']} {raster_info['band_names']}")
logger.info(f" Resolution: {raster_info['resolution']}")
# Parse window specs (before band selection so we can auto-include window bands)
window_ops = parse_window_specs(args.window)
if window_ops:
# Validate window band indices against full raster band count
for wop in window_ops:
if wop['band'] >= raster_info['band_count']:
raise ValueError(
f"Window spec references band {wop['band']} but raster "
f"only has {raster_info['band_count']} bands (0-indexed)"
)
# Band selection (-B flag)
all_band_names = raster_info['band_names'] # Full raster band names
# Auto-prefix unnamed bands with image basename (no extension, spaces→_)
if not args.band_names:
expected_defaults = [f'b{i}' for i in range(raster_info['band_count'])]
if raster_info['band_names'] == expected_defaults:
raw = os.path.basename(args.image.rstrip('/\\'))
prefix = re.sub(r'\s+', '_', os.path.splitext(raw)[0])
all_band_names = [f'{prefix}_{name}' for name in expected_defaults]
logger.info(f" Auto-prefixing unnamed bands: {all_band_names}")
band_indices = args.bands # None means all bands
if band_indices is not None:
# Validate band indices against raster
for bi in band_indices:
if bi < 0 or bi >= raster_info['band_count']:
raise ValueError(
f"Band index {bi} out of range for raster with "
f"{raster_info['band_count']} bands (0-indexed)"
)
# Auto-include bands referenced by window ops
if window_ops:
window_band_set = {wop['band'] for wop in window_ops}
extra_bands = window_band_set - set(band_indices)
if extra_bands:
logger.info(f" Auto-including bands {sorted(extra_bands)} for window operations")
# Note: extra bands are loaded for window ops but not output as columns
# Band names for selected bands
if args.band_names:
band_names = args.band_names
if len(band_names) != len(band_indices):
raise ValueError(
f"Number of band names ({len(band_names)}) doesn't match "
f"number of selected bands ({len(band_indices)})"
)
else:
band_names = [all_band_names[i] for i in band_indices]
logger.info(f" Selected bands: {band_indices} → {band_names}")
else:
# All bands
if args.band_names:
band_names = args.band_names
if len(band_names) != raster_info['band_count']:
raise ValueError(
f"Number of band names ({len(band_names)}) doesn't match "
f"raster band count ({raster_info['band_count']})"
)
else:
band_names = all_band_names
all_band_names = band_names # No selection, all_band_names == band_names
if window_ops:
logger.info(f"Window operations: {[w['name'] for w in window_ops]}")
# Configure database
configure_database_path(args, logger=logger)
from gedih3.utils import smart_exists
if not smart_exists(args.database):
logger.error(f"Database directory not found: {args.database}")
sys.exit(1)
# Detect data source type
from gedih3.config import BUILD_LOG_FILENAME, DATASET_META_FILENAME
from gedih3.utils import smart_join
build_log = smart_join(args.database, BUILD_LOG_FILENAME)
dataset_meta = smart_join(args.database, DATASET_META_FILENAME)
is_h3_database = smart_exists(build_log)
is_simplified_dataset = smart_exists(dataset_meta)
if not is_h3_database and not is_simplified_dataset:
raise FileNotFoundError(
f"No database metadata found in {args.database}. "
f"Expected {BUILD_LOG_FILENAME} (H3 DB) or {DATASET_META_FILENAME} (simplified dataset)."
)
# Parse region
region = None
if args.region:
logger.info(f"Parsing region: {args.region}")
region = parse_region(args.region)
# Build query string
# For H3 database, use build_query_string with quality flags
# For simplified dataset, use raw query only
query_str = None
if is_h3_database:
query_str = build_query_string(args)
else:
query_str = args.query
if query_str:
logger.info(f"Query filter: {query_str}")
# Dask
dask_kwargs = parse_dask_args(args)
with Client(**dask_kwargs) as client:
logger.info(f"Dask dashboard: {client.dashboard_link}")
# Load GEDI data based on source type
if is_h3_database:
# Mode 1: H3 database — ROI = image bounds ∩ user region
from shapely.geometry import box as shapely_box
img_bounds = raster_info['bounds_wgs84']
img_geom = shapely_box(*img_bounds)
img_gdf = gpd.GeoDataFrame(geometry=[img_geom], crs='EPSG:4326')
if region is not None:
# Intersect image bounds with user region
roi = gpd.overlay(img_gdf, region.to_crs('EPSG:4326'), how='intersection')
if roi.empty:
raise ValueError("Image bounds do not overlap with specified region")
else:
roi = img_gdf
logger.info("Loading GEDI data from H3 database...")
logger.info(f" ROI: image bounds ∩ region")
# Always load geometry for coordinate extraction
columns = ['geometry']
ddf = gh3.gh3_load(
columns=columns,
region=roi,
query=query_str,
source=args.database
)
# Get partition column
part_level = gh3.gh3_read_meta('h3_partition_level', gh3_root_dir=args.database)
partition_col = h3_col_name(part_level)
index_type = 'h3'
index_level = part_level
else:
# Mode 2: Simplified dataset — load all tiles
logger.info("Loading GEDI data from simplified dataset...")
logger.info(" ROI: entire dataset (all tiles)")
# Detect index type before loading to route to correct loader
ds_info = get_dataset_index_info(args.database)
index_type = ds_info.get('index_type', 'h3')
index_level = ds_info.get('index_level')
if index_type == 'egi':
ddf = gh3.egi_load(args.database)
else:
ddf = gh3.gh3_load(args.database)
if query_str:
ddf = ddf.query(query_str)
if index_type == 'egi':
from gedih3.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
else:
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
# Fallback partition detection: scan columns if metadata didn't provide it
if partition_col is None:
h3_cols = sorted([c for c in ddf.columns if re.match(r'^h3_\d{2}$', c)])
egi_cols = sorted([c for c in ddf.columns if re.match(r'^egi\d{2}$', str(c))])
if h3_cols:
partition_col = h3_cols[0]
part_level = int(partition_col.replace('h3_', ''))
index_type = index_type or 'h3'
logger.info(f" Detected partition column from data: {partition_col}")
elif egi_cols:
partition_col = egi_cols[0]
part_level = int(partition_col.replace('egi', ''))
index_type = index_type or 'egi'
logger.info(f" Detected partition column from data: {partition_col}")
# Validate geometry
if 'geometry' not in ddf.columns:
raise ValueError(
"Input data must contain geometry column for coordinate extraction. "
"For simplified datasets, re-extract with the -g flag."
)
logger.info(f" Loaded {ddf.npartitions} partitions")
if partition_col:
logger.info(f" Partition column: {partition_col}")
# Resume: filter out existing partitions.
# NOTE: unconditional makedirs(args.output) would turn the merge
# target (a file path) into a directory, and the subsequent
# gh3_export.atomic_parquet_write would fail with IsADirectoryError
# on os.replace(tmp, output). gh3_export handles directory creation
# itself for both modes, so we only need to ensure the dir exists
# here in the non-merge resume path that globs inside it.
if not args.merge:
os.makedirs(args.output, exist_ok=True)
if args.resume:
existing = glob.glob(os.path.join(args.output, f'*.{args.format}'))
if existing:
existing_ids = {os.path.splitext(os.path.basename(f))[0] for f in existing}
logger.info(f" Resume: skipping {len(existing_ids)} existing partitions")
# Filter out partitions whose ID is already exported
if partition_col and partition_col in ddf.columns:
ddf = ddf[~ddf[partition_col].isin(existing_ids)]
elif partition_col and ddf.index.name == partition_col:
ddf = ddf[~ddf.index.isin(existing_ids)]
# Detect spatial columns from loaded data for schema
from gedih3.imgutils import _detect_spatial_cols
spatial_cols = _detect_spatial_cols(ddf)
if not spatial_cols and partition_col:
spatial_cols = {partition_col: 'object'}
# Compute sampling meta for Dask
meta = _compute_sampling_meta(band_names, window_ops, args.geo, partition_col,
spatial_cols=spatial_cols,
all_band_names=all_band_names if band_indices else None)
# Apply sampling via map_partitions
logger.info("Sampling raster at GEDI shot locations...")
sampled = ddf.map_partitions(
sample_raster_at_points,
raster_path=raster_path,
band_names=band_names,
window_ops=window_ops,
fillna=args.fillna,
dropna=args.dropna,
geo=args.geo,
partition_col=partition_col,
band_indices=band_indices,
all_band_names=all_band_names if band_indices else None,
meta=meta
)
# Export
logger.info("Exporting data...")
# Build image-specific metadata
from gedih3.imgutils import _resolve_window_col_name
window_col_names = [_resolve_window_col_name(w, all_band_names) for w in (window_ops or [])]
meta_kwargs = {
'query_filter': query_str,
'image_source': args.image if args.image.startswith(('http://', 'https://', 's3://', '/vsicurl/', '/vsis3/')) else os.path.abspath(args.image),
'raster_crs': str(raster_info['crs']),
'raster_resolution': list(raster_info['resolution']),
'raster_bands': band_names,
}
if band_indices is not None:
meta_kwargs['raster_band_indices'] = band_indices
if index_type == 'h3' and part_level is not None:
meta_kwargs['h3_partition_level'] = part_level
elif index_type == 'egi' and part_level is not None:
meta_kwargs['egi_partition_level'] = part_level
if index_level is not None:
meta_kwargs['egi_index_level'] = index_level
if window_ops:
meta_kwargs['window_operations'] = window_col_names
gh3.gh3_export(
sampled, output=args.output, fmt=args.format, merge=args.merge,
show_progress=not getattr(args, 'quiet', False),
drop_internal=False,
source_database=args.database, tool='gh3_from_img',
**meta_kwargs
)
print_success(f"Raster sampling complete → {args.output}", logger=logger)
if __name__ == '__main__':
main()