james-willis commented on code in PR #1027: URL: https://github.com/apache/sedona-db/pull/1027#discussion_r3590131553
########## rust/sedona-raster-functions/src/rs_values.rs: ########## @@ -0,0 +1,956 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! `RS_Values` — sample a raster's pixel value at each point of a MultiPoint. +//! +//! ```text +//! RS_Values(raster, points) -> List<Double> -- single-band rasters only +//! RS_Values(raster, points, band) -> List<Double> +//! ``` +//! +//! The plural companion of [`RS_Value`](crate::rs_value): where `RS_Value` takes +//! one Point and returns one Double, `RS_Values` takes a MultiPoint (a single +//! Point is also accepted) and returns a `List<Double>` — one element per +//! sub-point, in input order. Each element is `NULL` when its sub-point is empty, +//! out of bounds, or reads the band's nodata; the whole list is `NULL` when the +//! raster, geometry, or band is `NULL`. An empty MultiPoint yields an empty list. +//! +//! Sampling, CRS handling, and pixel decoding are shared with `RS_Value` via +//! [`crate::sampling`]; this module only adds the per-sub-point iteration and the +//! list-shaped output. +//! +//! Like `RS_Value`, the function is tagged [`NEEDS_PIXELS_METADATA_KEY`] so the +//! planner materialises the raster InDb before a kernel runs, and only 2-D +//! rasters are supported. + +use std::sync::Arc; + +use arrow_array::builder::{Float64Builder, ListBuilder}; +use arrow_array::{Array, ArrayRef, StructArray}; +use arrow_schema::{DataType, Field}; +use datafusion_common::cast::as_int32_array; +use datafusion_common::{exec_datafusion_err, exec_err, Result, ScalarValue}; +use datafusion_expr::{ColumnarValue, Volatility}; +use geo_traits::{CoordTrait, GeometryTrait, GeometryType, MultiPointTrait, PointTrait}; +use sedona_expr::scalar_udf::{SedonaScalarKernel, SedonaScalarUDF}; +use sedona_proj::transform::with_global_proj_engine; +use sedona_raster::affine_transformation::AffineMatrix; +use sedona_raster::array::RasterStructArray; +use sedona_raster::traits::{BandRef, NdBuffer, RasterRef}; +use sedona_schema::{datatypes::SedonaType, matchers::ArgMatcher}; +use wkb::reader::read_wkb; + +use crate::crs_utils::resolve_crs; +use crate::executor::RasterExecutor; +use crate::rs_ensure_loaded::NEEDS_PIXELS_METADATA_KEY; +use crate::sampling::{ + column_reproject_decision, default_band, int32_array_arg, next_band, read_pixel, reproject_wkb, + xy_to_pixel, +}; + +/// The `List<Float64>` output type, matching what a default +/// `ListBuilder<Float64Builder>` produces (field "item", nullable). +fn list_float64_type() -> DataType { + DataType::List(Arc::new(Field::new("item", DataType::Float64, true))) +} + +/// `RS_Values()` scalar UDF — sample pixel values at each point of a MultiPoint. +pub fn rs_values_udf() -> SedonaScalarUDF { + SedonaScalarUDF::new( + "rs_values", + vec![ + Arc::new(RsValues { with_band: false }), // RS_Values(raster, points) + Arc::new(RsValues { with_band: true }), // RS_Values(raster, points, band) + ], + Volatility::Immutable, + ) + // The kernels read pixel bytes, so the raster argument must be materialised + // InDb first; the planner injects RS_EnsureLoaded based on this flag. + .with_metadata(NEEDS_PIXELS_METADATA_KEY, "true") +} + +/// Kernel for `RS_Values(raster, points[, band])`. +#[derive(Debug)] +struct RsValues { + with_band: bool, +} + +impl SedonaScalarKernel for RsValues { + fn return_type(&self, args: &[SedonaType]) -> Result<Option<SedonaType>> { + let mut matchers = vec![ + ArgMatcher::is_raster(), + ArgMatcher::is_geometry_or_geography(), + ]; + if self.with_band { + matchers.push(ArgMatcher::is_integer()); + } + let matcher = ArgMatcher::new(matchers, SedonaType::Arrow(list_float64_type())); + matcher.match_args(args) + } + + fn invoke_batch( + &self, + arg_types: &[SedonaType], + args: &[ColumnarValue], + ) -> Result<ColumnarValue> { + // Fast path: a constant (scalar) raster lets us resolve the affine + // transform, CRS, and band buffer once for the whole batch instead of + // per row — the common RS_Values(raster_expr, points_column[, band]) + // shape. Only a band *column* falls back to per-row band resolution. + if let ColumnarValue::Scalar(ScalarValue::Struct(raster_struct)) = &args[0] { + return self.invoke_scalar_raster(arg_types, args, raster_struct.as_ref()); + } + + let executor = RasterExecutor::new(arg_types, args); + let num_iterations = executor.num_iterations(); + let mut list_builder = ListBuilder::new(Float64Builder::new()); + // Per-row scratch for the parsed sub-point coordinates, reused across + // rows so the parse does not allocate per row. + let mut points_scratch: Vec<Option<(f64, f64)>> = Vec::new(); + + // The optional band argument, materialised once as an Int32 array. Held + // as an `ArrayRef` so the typed view below borrows it instead of cloning + // the typed `Int32Array`. + let band_arr = if self.with_band { + Some(int32_array_arg(&args[2], num_iterations)?) + } else { + None + }; + let band_array = band_arr.as_ref().map(|a| as_int32_array(a)).transpose()?; + let mut band_iter = band_array.map(|a| a.iter()); + + // Reprojecting the points into the raster CRS needs a PROJ engine. + with_global_proj_engine(|engine| { + executor.execute_raster_wkb_crs_void(|raster_opt, wkb_opt, geom_crs| { + // Advance the band column every row so it stays in lockstep with + // the row index (a no-op when there is no band argument). + let band_arg = next_band(&mut band_iter); + let (raster, geom_wkb) = match (raster_opt, wkb_opt) { + (Some(raster), Some(geom_wkb)) => (raster, geom_wkb), + // A NULL raster or geometry yields a NULL row. + _ => { + list_builder.append_null(); + return Ok(()); + } + }; + + // Resolve the band to sample. An explicit band column drives it + // (a NULL element yields a NULL row); with no band argument it + // defaults to band 1, but only for a single-band raster — sampling + // an unspecified band of a multiband raster is ambiguous, so it + // errors rather than silently picking band 1. + let band_num = if self.with_band { + match band_arg { + Some(band_num) => band_num, + None => { + list_builder.append_null(); + return Ok(()); + } + } + } else { + default_band("RS_Values", raster.num_bands())? + }; + + // Resolve the band buffer, nodata, and affine transform once for + // this row, then sample every sub-point against them. + let raster_crs = resolve_crs(raster.crs())?; + let band = resolve_band_2d(raster, band_num)?; + let buffer = band + .nd_buffer() + .map_err(|e| exec_datafusion_err!("RS_Values: {e}"))?; + let nodata = band + .nodata_as_f64() + .map_err(|e| exec_datafusion_err!("RS_Values: {e}"))?; + let affine = AffineMatrix::from_metadata(&raster.metadata()); + + // Reproject the whole geometry into the raster CRS (a no-op that + // borrows the original bytes when the CRSes match or are absent). + let reprojected = reproject_wkb( + "RS_Values", + geom_wkb, + geom_crs, + raster_crs.as_deref(), + engine, + )?; + let effective = reprojected.as_deref().unwrap_or(geom_wkb); + + points_scratch.clear(); + collect_point_xys(effective, &mut points_scratch)?; + for xy in &points_scratch { + append_sample(*xy, &affine, &buffer, nodata, &mut list_builder)?; + } + list_builder.append(true); + Ok(()) + }) + })?; + + executor.finish(Arc::new(list_builder.finish())) + } +} + +impl RsValues { + /// Optimized path for a constant (scalar) raster: the affine transform and + /// raster CRS are resolved once for the whole batch, and the per-row work + /// reduces to parsing the points and reading pixels. This serves every band + /// shape: + /// - no band argument or a constant band → the band buffer is hoisted too; + /// - a band *column* → the band buffer is resolved per row (its `NdBuffer` + /// borrows from the band, which can't be cached across distinct bands), + /// but the affine/CRS/reproject work is still hoisted. + /// + /// Sampling behaviour matches the general path: it uses the same + /// [`collect_point_xys`]/[`append_sample`] helpers, so per-element and + /// per-row NULL semantics are identical. Band resolution — including the + /// default-band ambiguity check and the 2-D check — is deferred until at + /// least one row has a geometry to sample, so an all-null batch returns + /// NULL without touching the band — as the general path does. + fn invoke_scalar_raster( + &self, + arg_types: &[SedonaType], + args: &[ColumnarValue], + raster_struct: &StructArray, + ) -> Result<ColumnarValue> { + let executor = RasterExecutor::new(arg_types, args); + let n = executor.num_iterations(); + + let all_null = |executor: &RasterExecutor| { + let mut builder = ListBuilder::new(Float64Builder::new()); + for _ in 0..n { + builder.append_null(); + } + executor.finish(Arc::new(builder.finish())) + }; + + let rasters = RasterStructArray::try_new(raster_struct)?; + if rasters.is_null(0) { + // A NULL raster makes every output NULL. + return all_null(&executor); + } + let raster = rasters.get(0)?; + + // Band selection: a missing band argument (default band 1, single-band + // rasters only) or a scalar band is constant for the batch and lets us + // hoist the band buffer; a band column is resolved per row. A NULL scalar + // band makes every output NULL. With no band argument the default-band + // ambiguity check is deferred until a row needs sampling (below), so an + // all-null batch over a multiband raster stays NULL rather than erroring + // — matching the general path, which only resolves the band on rows that + // actually have a geometry. + let mut const_band: Option<usize> = None; + let mut band_values: Option<ArrayRef> = None; + if self.with_band { + match &args[2] { + ColumnarValue::Scalar(scalar) => { + let arr = ColumnarValue::Scalar(scalar.clone()) + .cast_to(&DataType::Int32, None)? + .into_array(1)?; + let arr = as_int32_array(&arr)?; + if arr.is_null(0) { + return all_null(&executor); + } + // Match `next_band`: clamp to 0 so band 0/negative surface as a + // not-1-based error from `Bands::band` rather than being coerced. + const_band = Some(arr.value(0).max(0) as usize); + } + other => band_values = Some(int32_array_arg(other, n)?), + } + } + let band_array = band_values + .as_ref() + .map(|a| as_int32_array(a)) + .transpose()?; + + // Affine transform and raster CRS, resolved once for all rows. Decide + // reprojection once when the geometry CRS is column-level (the common + // case), skipping a per-row `crs_equals` and its String allocation. + let affine = AffineMatrix::from_metadata(&raster.metadata()); + let raster_crs = resolve_crs(raster.crs())?; + let needs_reproject = + column_reproject_decision("RS_Values", &arg_types[1], raster_crs.as_deref())?; + let skip_reproject = needs_reproject == Some(false); + + let mut geom = executor.make_geom_wkb_crs_accessor(1)?; + + // Phase 1 — parse each row's Point/MultiPoint (reprojected into the + // raster CRS) into owned sub-point coordinates. All rows share one + // coordinate arena with per-row (start, len, band) spans; a `None` row + // is NULL output (NULL geometry or band element). + let mut coords: Vec<Option<(f64, f64)>> = Vec::new(); + let mut rows: Vec<Option<(usize, usize, usize)>> = Vec::with_capacity(n); + let mut band_iter = band_array.map(|a| a.iter()); + with_global_proj_engine(|engine| { Review Comment: done -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
