You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ja...@apache.org on 2026/09/25 18:42:41 UTC
(sedona-db) branch main updated: feat(rust/sedona-raster-functions): add RS_MinConvexHull (#1351)
This is an automated email from the ASF dual-hosted git repository.
james-willis pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/sedona-db.git
The following commit(s) were added to refs/heads/main by this push:
new 998a0813b feat(rust/sedona-raster-functions): add RS_MinConvexHull (#1351)
998a0813b is described below
commit 998a0813b91ce1df8275d557761aac6bf3f11dca
Author: James Willis <ja...@users.noreply.github.com>
AuthorDate: Fri Sep 25 11:42:35 2026 -0700
feat(rust/sedona-raster-functions): add RS_MinConvexHull (#1351)
---
docs/reference/sql/rs_minconvexhull.qmd | 56 +++
integration/spark-parity/test_rs_minconvexhull.py | 181 ++++++++
.../tests/functions/test_rs_minconvexhull.py | 115 +++++
rust/sedona-raster-functions/src/lib.rs | 2 +
rust/sedona-raster-functions/src/pixel_scan.rs | 301 +++++++++++++
rust/sedona-raster-functions/src/register.rs | 1 +
.../src/rs_minconvexhull.rs | 481 +++++++++++++++++++++
7 files changed, 1137 insertions(+)
diff --git a/docs/reference/sql/rs_minconvexhull.qmd b/docs/reference/sql/rs_minconvexhull.qmd
new file mode 100644
index 000000000..e2d29b6c2
--- /dev/null
+++ b/docs/reference/sql/rs_minconvexhull.qmd
@@ -0,0 +1,56 @@
+---
+# 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.
+
+title: RS_MinConvexHull
+description: >
+ Returns the footprint of the raster's data pixels: the polygon over the
+ smallest pixel-aligned rectangle of the grid that holds every pixel not equal
+ to its band's nodata value. Returns null if no pixel holds data.
+kernels:
+ - returns: geometry
+ args: [raster]
+ - returns: geometry
+ args:
+ - raster
+ - name: band
+ type: integer
+ description: >
+ Band index (1-based). When omitted, a pixel holds data if any band
+ holds data there.
+---
+
+## Description
+
+`RS_MinConvexHull` is `RS_ConvexHull` with the nodata margin trimmed off. The
+rectangle is aligned to the raster's grid, so on a rotated or skewed raster the
+result follows the grid axes, like `RS_ConvexHull`. A band without a nodata
+value holds data everywhere, so it spans the whole grid. Only 2-D rasters are
+supported.
+
+## Examples
+
+```sql
+SELECT RS_MinConvexHull(RS_Example());
+```
+
+Every pixel of `RS_Example()`'s first band but the upper-left one holds 1, so
+declaring 1 as its nodata value trims the footprint to that single cell:
+
+```sql
+SELECT RS_MinConvexHull(RS_SetBandNoDataValue(RS_Example(), 1, 1), 1);
+```
diff --git a/integration/spark-parity/test_rs_minconvexhull.py b/integration/spark-parity/test_rs_minconvexhull.py
new file mode 100644
index 000000000..389ad01b9
--- /dev/null
+++ b/integration/spark-parity/test_rs_minconvexhull.py
@@ -0,0 +1,181 @@
+# 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.
+
+"""SedonaDB vs Sedona Spark parity for RS_MinConvexHull.
+
+Each case writes one GeoTIFF of nodata with a few data pixels that both
+engines read. The hull is the outer corners of the cells bounding the data
+pixels, in the same ring order on both engines (upper-left, upper-right,
+lower-right, lower-left), so the anchor is the exact WKT.
+
+The skewed case needs exactly representable coefficients: SedonaDB maps a
+corner as (x0 + col*sx) + row*kx and Sedona Spark's AffineTransform as
+(col*sx + row*kx) + x0, which differ in the last bit for ordinary decimal
+skews. Two differences from the 1.9.1 release the suite pins, both fixed on
+Sedona's main branch by apache/sedona#3366, are xfails until the pin moves:
+an all-nodata raster (NULL) and a NaN nodata value (matching NaN pixels).
+"""
+
+import numpy as np
+import pytest
+
+from sedonadb.raster_testing import write_geotiff
+from sedonadb.testing import SedonaDB, compare
+from sedonadb.testing_spark import SedonaSpark
+
+# Grids for the 7x6 fixtures: north-up by bbox, skewed by a raw GDAL transform
+# (a bbox cannot express skew).
+NORTH_UP = {"bbox": (100, 482, 114, 500)}
+SKEWED = {"gdal_transform": (100.0, 2.0, 0.5, 500.0, 0.25, -3.0)}
+
+
+def _engines(name, tmp_path, data, *, grid=NORTH_UP, nodata=0, crs=None):
+ path = tmp_path / f"{name}.tif"
+ write_geotiff(path, data, **grid, nodata=nodata, crs=crs)
+ sedona, spark = SedonaDB(), SedonaSpark()
+ for eng in (sedona, spark):
+ eng.create_raster_view(name, path)
+ return sedona, spark
+
+
+def _sparse():
+ """A 7x6 raster of nodata (0) with data in band 1 at rows 1-2, columns 2-3,
+ and in band 2 at row 4, column 5."""
+ data = np.zeros((2, 6, 7), dtype="uint8")
+ data[0, 1, 3] = 10
+ data[0, 2, 2] = 20
+ data[1, 4, 5] = 30
+ return data
+
+
+@pytest.mark.parametrize(
+ "band_arg,expected",
+ [
+ pytest.param(
+ ", 1",
+ "POLYGON ((104 497, 108 497, 108 491, 104 491, 104 497))",
+ id="band-1",
+ ),
+ pytest.param(
+ ", 2",
+ "POLYGON ((110 488, 112 488, 112 485, 110 485, 110 488))",
+ id="band-2",
+ ),
+ # Without a band, a cell holds data when any band holds data there.
+ pytest.param(
+ "",
+ "POLYGON ((104 497, 112 497, 112 485, 104 485, 104 497))",
+ id="all-bands",
+ ),
+ ],
+)
+def test_rs_minconvexhull(band_arg, expected, tmp_path):
+ sedona, spark = _engines("mch_src", tmp_path, _sparse())
+ sql = f"SELECT RS_MinConvexHull(rast{band_arg}) FROM mch_src"
+ compare(sql, sedona, spark, expected=expected)
+
+
+def test_rs_minconvexhull_skewed(tmp_path):
+ """On a skewed grid the ring follows the grid axes: each corner is the
+ affine image of a cell corner, not a world-space bounding box."""
+ sedona, spark = _engines("mch_skew_src", tmp_path, _sparse(), grid=SKEWED)
+ compare(
+ "SELECT RS_MinConvexHull(rast, 1) FROM mch_skew_src",
+ sedona,
+ spark,
+ expected="POLYGON ((104.5 497.5, 108.5 498, 109.5 492, 105.5 491.5, 104.5 497.5))",
+ )
+
+
+def test_rs_minconvexhull_crs(tmp_path):
+ """Both engines carry the raster's CRS on the hull (SedonaDB as an
+ item-level CRS, Sedona Spark as a per-geometry SRID)."""
+ sedona, spark = _engines("mch_crs_src", tmp_path, _sparse(), crs="EPSG:3857")
+ hull = "POLYGON ((110 488, 112 488, 112 485, 110 485, 110 488))"
+ compare(
+ "SELECT RS_MinConvexHull(rast, 2) FROM mch_crs_src",
+ sedona,
+ spark,
+ expected=[(("EPSG:3857", hull),)],
+ )
+
+
+def test_rs_minconvexhull_null_band(tmp_path):
+ sedona, spark = _engines("mch_src", tmp_path, _sparse())
+ sql = "SELECT RS_MinConvexHull(rast, CAST(NULL AS INT)) FROM mch_src"
+ compare(sql, sedona, spark, expected=[(None,)])
+
+
+def test_rs_minconvexhull_without_nodata(tmp_path):
+ """A band with no nodata value holds data everywhere, so the hull is the
+ whole grid (zeros included)."""
+ sedona, spark = _engines(
+ "mch_none_src", tmp_path, np.zeros((1, 6, 7), dtype="uint8"), nodata=None
+ )
+ compare(
+ "SELECT RS_MinConvexHull(rast) FROM mch_none_src",
+ sedona,
+ spark,
+ expected="POLYGON ((100 500, 114 500, 114 482, 100 482, 100 500))",
+ )
+
+
+def test_rs_minconvexhull_band_out_of_range(tmp_path):
+ """Both engines refuse a band the raster does not have. Error types
+ differ, so parity here is parity on refusal."""
+ sedona, spark = _engines("mch_src", tmp_path, _sparse())
+ sql = "SELECT RS_MinConvexHull(rast, 3) FROM mch_src"
+ for eng in (sedona, spark):
+ with pytest.raises(Exception):
+ eng.result_to_tuples(eng.execute_and_collect(sql))
+
+
+@pytest.mark.xfail(
+ reason="Sedona Spark 1.9.1 builds a polygon from its Integer.MAX_VALUE "
+ "sentinels when no pixel holds data; apache/sedona#3366 made it NULL "
+ "after the release"
+)
+def test_rs_minconvexhull_all_nodata(tmp_path):
+ sedona, spark = _engines(
+ "mch_empty_src", tmp_path, np.zeros((1, 6, 7), dtype="uint8")
+ )
+ compare(
+ "SELECT RS_MinConvexHull(rast) FROM mch_empty_src",
+ sedona,
+ spark,
+ expected=[(None,)],
+ )
+
+
+@pytest.mark.xfail(
+ reason="Sedona Spark 1.9.1 treats a NaN nodata value as matching no pixel, "
+ "so the NaN margin counts as data; apache/sedona#3366 fixed it after the "
+ "release"
+)
+def test_rs_minconvexhull_nan_nodata(tmp_path):
+ """NaN pixels are nodata under a NaN nodata value, so the NaN margin is
+ trimmed off."""
+ data = np.full((1, 6, 7), np.nan, dtype="float32")
+ data[0, 1, 3] = 1.5
+ data[0, 2, 2] = 2.5
+ sedona, spark = _engines("mch_nan_src", tmp_path, data, nodata=np.nan)
+ compare(
+ "SELECT RS_MinConvexHull(rast, 1) FROM mch_nan_src",
+ sedona,
+ spark,
+ expected="POLYGON ((104 497, 108 497, 108 491, 104 491, 104 497))",
+ )
diff --git a/python/sedonadb/tests/functions/test_rs_minconvexhull.py b/python/sedonadb/tests/functions/test_rs_minconvexhull.py
new file mode 100644
index 000000000..c7cb6a889
--- /dev/null
+++ b/python/sedonadb/tests/functions/test_rs_minconvexhull.py
@@ -0,0 +1,115 @@
+# 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_MinConvexHull against a rasterio reference.
+
+Each fixture is written to a GeoTIFF and read back through RS_FromPath. The
+reference finds the data pixels with numpy and maps the outer corners of their
+bounding cells through the file's affine transform. A skewed transform is in
+the parameter set because skew is what separates mapping the four corners from
+taking a world-space bounding box. The transforms and cell indices are small
+dyadic values, so both sides compute every coordinate exactly and compare with
+`==`.
+"""
+
+import numpy as np
+import pyarrow as pa
+import pytest
+
+from sedonadb.raster_testing import write_geotiff
+
+rasterio = pytest.importorskip("rasterio")
+shapely = pytest.importorskip("shapely")
+
+# Each grid places the 7x6 fixture: north-up by bbox, skewed by a raw GDAL
+# transform (a bbox cannot express skew).
+GRIDS = {
+ "north-up": {"bbox": (100.0, 482.0, 114.0, 500.0)},
+ "skewed": {"gdal_transform": (100.0, 2.0, 0.5, 500.0, 0.25, -3.0)},
+}
+
+
+def _rasterio_min_convex_hull(path, bands):
+ with rasterio.open(path) as src:
+ nodata = src.nodatavals[0]
+ pixels = src.read(bands)
+ affine = src.transform
+ rows, cols = np.nonzero((pixels != nodata).any(axis=0))
+ if len(rows) == 0:
+ return None
+ left, top, right, bottom = cols.min(), rows.min(), cols.max() + 1, rows.max() + 1
+ ring = [(left, top), (right, top), (right, bottom), (left, bottom), (left, top)]
+ return [affine * (int(col), int(row)) for col, row in ring]
+
+
+def _sedonadb_min_convex_hull(con, path, band=None):
+ """Arguments travel as table columns so the kernel runs its real array
+ path (literals constant-fold)."""
+ con.create_data_frame(
+ pa.table({"path": [str(path)], "band": pa.array([band], pa.int32())})
+ ).to_view("minconvexhull_src", overwrite=True)
+ band_arg = "" if band is None else ", band"
+ sql = (
+ f"SELECT ST_AsText(RS_MinConvexHull(RS_FromPath(path){band_arg})) "
+ "FROM minconvexhull_src"
+ )
+ wkt = con.sql(sql).to_arrow_table().column(0)[0].as_py()
+ if wkt is None:
+ return None
+ return list(shapely.from_wkt(wkt).exterior.coords)
+
+
+@pytest.fixture(params=list(GRIDS), ids=list(GRIDS))
+def tiff(request, tmp_path):
+ """A 7x6 two-band raster of nodata (0) with data in band 1 at rows 1-2,
+ columns 2-3, and in band 2 at row 4, column 5."""
+ data = np.zeros((2, 6, 7), dtype="uint8")
+ data[0, 1, 3] = 10
+ data[0, 2, 2] = 20
+ data[1, 4, 5] = 30
+ path = tmp_path / f"minconvexhull_{request.param}.tif"
+ write_geotiff(path, data, **GRIDS[request.param], nodata=0)
+ return path
+
+
+@pytest.mark.parametrize("band", [1, 2])
+def test_rs_minconvexhull_one_band(con, tiff, band):
+ expected = _rasterio_min_convex_hull(tiff, [band])
+ assert _sedonadb_min_convex_hull(con, tiff, band) == expected
+
+
+def test_rs_minconvexhull_all_bands(con, tiff):
+ """Without a band, a cell holds data when any band holds data there."""
+ expected = _rasterio_min_convex_hull(tiff, [1, 2])
+ assert _sedonadb_min_convex_hull(con, tiff) == expected
+
+
+def test_rs_minconvexhull_all_nodata_is_null(con, tmp_path):
+ path = tmp_path / "all_nodata.tif"
+ write_geotiff(
+ path,
+ np.zeros((1, 6, 7), dtype="uint8"),
+ **GRIDS["north-up"],
+ nodata=0,
+ )
+ assert _rasterio_min_convex_hull(path, [1]) is None
+ assert _sedonadb_min_convex_hull(con, path) is None
+
+
+def test_rs_minconvexhull_band_out_of_range(con, tiff):
+ with pytest.raises(Exception, match="RS_MinConvexHull"):
+ _sedonadb_min_convex_hull(con, tiff, 3)
diff --git a/rust/sedona-raster-functions/src/lib.rs b/rust/sedona-raster-functions/src/lib.rs
index aa53e4d48..129944da0 100644
--- a/rust/sedona-raster-functions/src/lib.rs
+++ b/rust/sedona-raster-functions/src/lib.rs
@@ -19,6 +19,7 @@ pub mod crs_utils;
mod executor;
pub use executor::RasterExecutor;
pub mod footprint;
+mod pixel_scan;
pub mod register;
pub mod rs_band_accessors;
pub mod rs_bandpath;
@@ -31,6 +32,7 @@ pub mod rs_example;
pub mod rs_georeference;
pub mod rs_geotransform;
pub mod rs_isempty;
+pub mod rs_minconvexhull;
pub mod rs_numbands;
pub mod rs_pixel_functions;
pub mod rs_rastercoordinate;
diff --git a/rust/sedona-raster-functions/src/pixel_scan.rs b/rust/sedona-raster-functions/src/pixel_scan.rs
new file mode 100644
index 000000000..a857eb2ba
--- /dev/null
+++ b/rust/sedona-raster-functions/src/pixel_scan.rs
@@ -0,0 +1,301 @@
+// 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.
+
+//! Whole-band pixel scans shared by the kernels that read every pixel of a
+//! band rather than sampling one (see [`crate::sampling`] for that).
+//!
+//! [`scan_pixels`] walks a 2-D band in row-major order through its
+//! [`NdBuffer`] strides, so a strided, reversed, or broadcast view is read in
+//! place without being materialised. Pixels are handed out as raw bytes, so
+//! [`NodataMatcher`] can test them against the band's nodata without decoding
+//! and a caller decodes only the pixels whose values it needs.
+
+use std::ops::ControlFlow;
+
+use datafusion_common::{Result, exec_datafusion_err, exec_err};
+use sedona_raster::error::RasterResultExt;
+use sedona_raster::traits::{BandRef, NdBuffer};
+use sedona_schema::raster::BandDataType;
+
+/// The pixel buffer of a 2-D `(y, x)` band. Errors for a band with any other
+/// dimensions, since a whole-band scan has no meaning for it. `func` names the
+/// calling UDF for the error messages.
+pub(crate) fn spatial_2d_buffer<'a>(func: &str, band: &'a dyn BandRef) -> Result<NdBuffer<'a>> {
+ if !band.is_spatial_2d() {
+ return exec_err!("{func} supports 2-D rasters only; band is not a 2-D (y, x) grid");
+ }
+ Ok(band.nd_buffer().context(func)?)
+}
+
+/// Visit every pixel of a 2-D band buffer in row-major order as
+/// `(col, row, bytes)`, with `col` and `row` 0-based. The scan stops early when
+/// `visit` returns [`ControlFlow::Break`].
+///
+/// The byte range the scan touches is bounds-checked once up front, so a
+/// corrupt stride or offset is an error rather than a panic partway through.
+pub(crate) fn scan_pixels(
+ func: &str,
+ buffer: &NdBuffer,
+ mut visit: impl FnMut(i64, i64, &[u8]) -> ControlFlow<()>,
+) -> Result<()> {
+ let (Ok([height, width]), Ok([row_stride, col_stride])) = (
+ <[i64; 2]>::try_from(buffer.shape.as_slice()),
+ <[i64; 2]>::try_from(buffer.strides.as_slice()),
+ ) else {
+ return exec_err!("{func}: expected a 2-D band buffer");
+ };
+ if height < 0 || width < 0 {
+ return exec_err!("{func}: band buffer has a negative shape [{height}, {width}]");
+ }
+ if height == 0 || width == 0 {
+ return Ok(());
+ }
+
+ let size = buffer.data_type.byte_size() as i64;
+ let overflow = || exec_datafusion_err!("{func}: pixel byte offset overflow");
+ // A stride can be negative (a reversed view), so the lowest and highest
+ // bytes touched come from whichever end of each axis the stride points at.
+ let row_span = row_stride.checked_mul(height - 1).ok_or_else(overflow)?;
+ let col_span = col_stride.checked_mul(width - 1).ok_or_else(overflow)?;
+ let offset = i64::try_from(buffer.offset).map_err(|_| overflow())?;
+ let lowest = [row_span.min(0), col_span.min(0)]
+ .into_iter()
+ .try_fold(offset, i64::checked_add)
+ .ok_or_else(overflow)?;
+ let end = [row_span.max(0), col_span.max(0), size]
+ .into_iter()
+ .try_fold(offset, i64::checked_add)
+ .ok_or_else(overflow)?;
+ if lowest < 0 || end > buffer.buffer.len() as i64 {
+ return exec_err!("{func}: band view reaches outside its buffer");
+ }
+
+ // Every offset below lies within [lowest, end), which was checked above.
+ for row in 0..height {
+ let row_start = offset + row * row_stride;
+ for col in 0..width {
+ let start = (row_start + col * col_stride) as usize;
+ if visit(col, row, &buffer.buffer[start..start + size as usize]).is_break() {
+ return Ok(());
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Tests raw pixel bytes against a band's nodata value.
+///
+/// Integer pixels compare by bytes, which is exact at every width (a 64-bit
+/// sentinel beyond 2^53 still matches, where comparing as `f64` could not even
+/// represent it). Float pixels compare numerically, so `-0.0` matches a `0.0`
+/// nodata and any NaN matches a NaN nodata.
+pub(crate) struct NodataMatcher<'a> {
+ nodata: &'a [u8],
+ data_type: BandDataType,
+}
+
+impl<'a> NodataMatcher<'a> {
+ /// The matcher for `band`, or `None` when the band has no nodata value.
+ pub(crate) fn for_band(func: &str, band: &'a dyn BandRef) -> Result<Option<Self>> {
+ let Some(nodata) = band.nodata() else {
+ return Ok(None);
+ };
+ let data_type = band.data_type();
+ if nodata.len() != data_type.byte_size() {
+ return exec_err!(
+ "{func}: nodata value is {} bytes, but a {data_type:?} pixel is {}",
+ nodata.len(),
+ data_type.byte_size()
+ );
+ }
+ Ok(Some(Self { nodata, data_type }))
+ }
+
+ /// Whether `pixel` (one pixel's bytes, as handed out by [`scan_pixels`])
+ /// holds the nodata value.
+ pub(crate) fn matches(&self, pixel: &[u8]) -> bool {
+ fn float_eq(pixel: f64, nodata: f64) -> bool {
+ pixel == nodata || (pixel.is_nan() && nodata.is_nan())
+ }
+ match self.data_type {
+ BandDataType::Float32 => float_eq(
+ f32::from_le_bytes(pixel.try_into().unwrap()) as f64,
+ f32::from_le_bytes(self.nodata.try_into().unwrap()) as f64,
+ ),
+ BandDataType::Float64 => float_eq(
+ f64::from_le_bytes(pixel.try_into().unwrap()),
+ f64::from_le_bytes(self.nodata.try_into().unwrap()),
+ ),
+ // Listed rather than matched with `_`, so a new float type has to
+ // choose a comparison instead of silently comparing bytes.
+ BandDataType::UInt8
+ | BandDataType::Int8
+ | BandDataType::UInt16
+ | BandDataType::Int16
+ | BandDataType::UInt32
+ | BandDataType::Int32
+ | BandDataType::UInt64
+ | BandDataType::Int64 => pixel == self.nodata,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use sedona_raster::array::RasterStructArray;
+ use sedona_raster::traits::RasterRef;
+ use sedona_testing::raster_spec::RasterSpec;
+
+ fn buffer_of(bytes: &[u8], shape: [i64; 2], strides: [i64; 2], offset: u64) -> NdBuffer<'_> {
+ NdBuffer {
+ buffer: bytes,
+ shape: shape.to_vec(),
+ strides: strides.to_vec(),
+ offset,
+ data_type: BandDataType::UInt8,
+ }
+ }
+
+ fn scan(buffer: &NdBuffer) -> Result<Vec<(i64, i64, u8)>> {
+ let mut seen = Vec::new();
+ scan_pixels("test", buffer, |col, row, bytes| {
+ seen.push((col, row, bytes[0]));
+ ControlFlow::Continue(())
+ })?;
+ Ok(seen)
+ }
+
+ #[test]
+ fn scans_row_major() {
+ let bytes = [1, 2, 3, 4, 5, 6];
+ let seen = scan(&buffer_of(&bytes, [2, 3], [3, 1], 0)).unwrap();
+ assert_eq!(
+ seen,
+ vec![
+ (0, 0, 1),
+ (1, 0, 2),
+ (2, 0, 3),
+ (0, 1, 4),
+ (1, 1, 5),
+ (2, 1, 6)
+ ]
+ );
+ }
+
+ #[test]
+ fn scans_reversed_and_strided_views_in_place() {
+ // Rows reversed (negative row stride, offset at the last row) and every
+ // other column (stride 2) of a 2x4 buffer.
+ let bytes = [1, 2, 3, 4, 5, 6, 7, 8];
+ let seen = scan(&buffer_of(&bytes, [2, 2], [-4, 2], 4)).unwrap();
+ assert_eq!(seen, vec![(0, 0, 5), (1, 0, 7), (0, 1, 1), (1, 1, 3)]);
+ }
+
+ #[test]
+ fn scans_broadcast_views_in_place() {
+ // Zero strides broadcast one byte across a 2x3 view.
+ let seen = scan(&buffer_of(&[7], [2, 3], [0, 0], 0)).unwrap();
+ assert_eq!(seen.len(), 6);
+ assert!(seen.iter().all(|&(_, _, value)| value == 7));
+ }
+
+ #[test]
+ fn empty_band_visits_nothing() {
+ assert!(scan(&buffer_of(&[], [0, 3], [3, 1], 0)).unwrap().is_empty());
+ }
+
+ #[test]
+ fn negative_shape_errors() {
+ let err = scan(&buffer_of(&[1], [-1, 1], [1, 1], 0)).unwrap_err();
+ assert!(err.to_string().contains("negative shape"), "{err}");
+ }
+
+ #[test]
+ fn break_stops_the_scan() {
+ let bytes = [1, 2, 3, 4];
+ let mut visits = 0;
+ scan_pixels("test", &buffer_of(&bytes, [2, 2], [2, 1], 0), |_, _, _| {
+ visits += 1;
+ ControlFlow::Break(())
+ })
+ .unwrap();
+ assert_eq!(visits, 1);
+ }
+
+ #[test]
+ fn out_of_bounds_view_errors() {
+ let bytes = [1, 2, 3];
+ let err = scan(&buffer_of(&bytes, [2, 2], [2, 1], 0)).unwrap_err();
+ assert!(err.to_string().contains("outside its buffer"), "{err}");
+ let err = scan(&buffer_of(&bytes, [2, 2], [-2, 1], 0)).unwrap_err();
+ assert!(err.to_string().contains("outside its buffer"), "{err}");
+ }
+
+ fn matcher_matches(spec: RasterSpec, pixels: &[&[u8]]) -> Vec<bool> {
+ let array = spec.build();
+ let rasters = RasterStructArray::try_new(&array).unwrap();
+ let raster = rasters.get(0).unwrap();
+ let band = raster.band(0).unwrap();
+ let matcher = NodataMatcher::for_band("test", band.as_ref())
+ .unwrap()
+ .unwrap();
+ pixels.iter().map(|p| matcher.matches(p)).collect()
+ }
+
+ #[test]
+ fn integer_nodata_matches_by_bytes() {
+ // u64::MAX is beyond 2^53, so it could not be compared as an f64.
+ let spec = RasterSpec::d2(1, 1).band_values(&[0u64]).nodata(u64::MAX);
+ assert_eq!(
+ matcher_matches(
+ spec,
+ &[&u64::MAX.to_le_bytes(), &(u64::MAX - 1).to_le_bytes()]
+ ),
+ vec![true, false]
+ );
+ }
+
+ #[test]
+ fn float_nodata_matches_numerically() {
+ let spec = RasterSpec::d2(1, 1).band_values(&[0f64]).nodata(0f64);
+ assert_eq!(
+ matcher_matches(spec, &[&(-0f64).to_le_bytes(), &1f64.to_le_bytes()]),
+ vec![true, false]
+ );
+ // A NaN pixel matches a NaN nodata whatever its payload.
+ let spec = RasterSpec::d2(1, 1).band_values(&[0f32]).nodata(f32::NAN);
+ let other_nan = f32::from_bits(f32::NAN.to_bits() | 1);
+ assert_eq!(
+ matcher_matches(spec, &[&other_nan.to_le_bytes(), &0f32.to_le_bytes()]),
+ vec![true, false]
+ );
+ }
+
+ #[test]
+ fn band_without_nodata_has_no_matcher() {
+ let array = RasterSpec::d2(1, 1).band_values(&[0u8]).build();
+ let rasters = RasterStructArray::try_new(&array).unwrap();
+ let raster = rasters.get(0).unwrap();
+ let band = raster.band(0).unwrap();
+ assert!(
+ NodataMatcher::for_band("test", band.as_ref())
+ .unwrap()
+ .is_none()
+ );
+ }
+}
diff --git a/rust/sedona-raster-functions/src/register.rs b/rust/sedona-raster-functions/src/register.rs
index 09651d6ac..404d0011e 100644
--- a/rust/sedona-raster-functions/src/register.rs
+++ b/rust/sedona-raster-functions/src/register.rs
@@ -59,6 +59,7 @@ pub fn default_function_set() -> FunctionSet {
crate::rs_geotransform::rs_skewy_udf,
crate::rs_geotransform::rs_upperleftx_udf,
crate::rs_geotransform::rs_upperlefty_udf,
+ crate::rs_minconvexhull::rs_minconvexhull_udf,
crate::rs_numbands::rs_numbands_udf,
crate::rs_pixel_functions::rs_pixelaspoint_udf,
crate::rs_pixel_functions::rs_pixelascentroid_udf,
diff --git a/rust/sedona-raster-functions/src/rs_minconvexhull.rs b/rust/sedona-raster-functions/src/rs_minconvexhull.rs
new file mode 100644
index 000000000..0951d74da
--- /dev/null
+++ b/rust/sedona-raster-functions/src/rs_minconvexhull.rs
@@ -0,0 +1,481 @@
+// 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_MinConvexHull` — the footprint of a raster's data pixels.
+//!
+//! ```text
+//! RS_MinConvexHull(raster) -> Geometry -- data in any band
+//! RS_MinConvexHull(raster, band) -> Geometry -- data in one band
+//! ```
+//!
+//! Returns the polygon over the smallest pixel-aligned rectangle of the grid
+//! that holds every data (non-nodata) pixel, in world coordinates: the analogue
+//! of `RS_ConvexHull` with the nodata margin trimmed off. Without a band a pixel
+//! counts as data when any band holds data there. The result is `NULL` when no
+//! pixel holds data.
+
+use std::ops::ControlFlow;
+use std::sync::Arc;
+
+use arrow_array::Array;
+use arrow_array::builder::{BinaryBuilder, StringViewBuilder};
+use datafusion_common::cast::as_int32_array;
+use datafusion_common::{Result, ScalarValue, exec_datafusion_err};
+use datafusion_expr::{ColumnarValue, Volatility};
+use sedona_expr::item_crs::make_item_crs;
+use sedona_expr::scalar_udf::{SedonaScalarKernel, SedonaScalarUDF};
+use sedona_geometry::types::Edges;
+use sedona_raster::affine_transformation::to_world_coordinate;
+use sedona_raster::traits::{NdBuffer, RasterRef};
+use sedona_schema::{datatypes::SedonaType, matchers::ArgMatcher};
+
+use crate::executor::RasterExecutor;
+use crate::footprint::write_footprint_wkb;
+use crate::pixel_scan::{NodataMatcher, scan_pixels, spatial_2d_buffer};
+use crate::rs_ensure_loaded::NEEDS_PIXELS_METADATA_KEY;
+use crate::sampling::{int32_array_arg, resolve_band};
+
+/// `RS_MinConvexHull()` scalar UDF — the footprint of a raster's data pixels.
+pub fn rs_minconvexhull_udf() -> SedonaScalarUDF {
+ SedonaScalarUDF::new(
+ "rs_minconvexhull",
+ vec![
+ Arc::new(RsMinConvexHull { with_band: false }), // RS_MinConvexHull(raster)
+ Arc::new(RsMinConvexHull { with_band: true }), // RS_MinConvexHull(raster, band)
+ ],
+ Volatility::Immutable,
+ )
+ // The kernel reads 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")
+}
+
+#[derive(Debug)]
+struct RsMinConvexHull {
+ with_band: bool,
+}
+
+impl SedonaScalarKernel for RsMinConvexHull {
+ fn return_type(&self, args: &[SedonaType]) -> Result<Option<SedonaType>> {
+ let out_type = SedonaType::new_item_crs(&SedonaType::Wkb(Edges::Planar, None))?;
+ let mut matchers = vec![ArgMatcher::is_raster()];
+ if self.with_band {
+ matchers.push(ArgMatcher::is_integer());
+ }
+ ArgMatcher::new(matchers, out_type).match_args(args)
+ }
+
+ fn invoke_batch(
+ &self,
+ arg_types: &[SedonaType],
+ args: &[ColumnarValue],
+ ) -> Result<ColumnarValue> {
+ let executor = RasterExecutor::new(arg_types, args);
+ let num_iterations = executor.num_iterations();
+ // 1 (byte order) + 4 (type) + 4 (num rings) + 4 (num points) + 80 (5 points * 16 bytes)
+ let bytes_per_poly = 93;
+ let mut builder =
+ BinaryBuilder::with_capacity(num_iterations, num_iterations * bytes_per_poly);
+ let mut crs_builder = StringViewBuilder::with_capacity(num_iterations);
+
+ let band_arr = if self.with_band {
+ Some(int32_array_arg(&args[1], num_iterations)?)
+ } else {
+ None
+ };
+ let band = band_arr.as_ref().map(|a| as_int32_array(a)).transpose()?;
+
+ executor.execute_raster_void(|i, raster_opt| {
+ let Some(raster) = raster_opt else {
+ builder.append_null();
+ crs_builder.append_null();
+ return Ok(());
+ };
+ let bounds = match band {
+ None => data_bounds(raster, 1..=raster.num_bands())?,
+ Some(band) if band.is_null(i) => None,
+ // Clamp a negative band to 0 so resolve_band rejects it as not
+ // 1-based rather than wrapping it into a huge usize.
+ Some(band) => data_bounds(raster, [band.value(i).max(0) as usize])?,
+ };
+ match bounds {
+ Some(bounds) => {
+ write_footprint_wkb(bounds.corners(raster), &mut builder)?;
+ builder.append_value([]);
+ crs_builder.append_value(raster.crs().unwrap_or("0"));
+ }
+ None => {
+ builder.append_null();
+ crs_builder.append_null();
+ }
+ }
+ Ok(())
+ })?;
+
+ let item_result = executor.finish(Arc::new(builder.finish()))?;
+ let crs_array = crs_builder.finish();
+ let crs_value = if matches!(item_result, ColumnarValue::Scalar(_)) {
+ ColumnarValue::Scalar(ScalarValue::try_from_array(&crs_array, 0)?)
+ } else {
+ ColumnarValue::Array(Arc::new(crs_array))
+ };
+
+ make_item_crs(
+ &SedonaType::Wkb(Edges::Planar, None),
+ item_result,
+ &crs_value,
+ None,
+ )
+ }
+}
+
+/// An inclusive, 0-based range of grid cells: columns `min_col..=max_col` of
+/// rows `min_row..=max_row`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct PixelBounds {
+ min_col: i64,
+ min_row: i64,
+ max_col: i64,
+ max_row: i64,
+}
+
+impl PixelBounds {
+ fn single(col: i64, row: i64) -> Self {
+ Self {
+ min_col: col,
+ min_row: row,
+ max_col: col,
+ max_row: row,
+ }
+ }
+
+ fn union(self, other: Self) -> Self {
+ Self {
+ min_col: self.min_col.min(other.min_col),
+ min_row: self.min_row.min(other.min_row),
+ max_col: self.max_col.max(other.max_col),
+ max_row: self.max_row.max(other.max_row),
+ }
+ }
+
+ /// World coordinates of the outer corners of the cells, in footprint ring
+ /// order: upper-left, upper-right, lower-right, lower-left. A cell's far
+ /// corners are the near corners of the next column and row.
+ fn corners(&self, raster: &dyn RasterRef) -> [(f64, f64); 4] {
+ [
+ to_world_coordinate(raster, self.min_col, self.min_row),
+ to_world_coordinate(raster, self.max_col + 1, self.min_row),
+ to_world_coordinate(raster, self.max_col + 1, self.max_row + 1),
+ to_world_coordinate(raster, self.min_col, self.max_row + 1),
+ ]
+ }
+}
+
+const FUNC: &str = "RS_MinConvexHull";
+
+/// The cells holding data in any of the 1-based bands `band_nums`, or `None`
+/// when no pixel of those bands holds data.
+fn data_bounds(
+ raster: &dyn RasterRef,
+ band_nums: impl IntoIterator<Item = usize>,
+) -> Result<Option<PixelBounds>> {
+ let mut bounds: Option<PixelBounds> = None;
+ for band_num in band_nums {
+ // Resolve every band, even once the grid is covered, so a bad band
+ // errors regardless of what the earlier bands hold.
+ let band = resolve_band(FUNC, raster, band_num)?;
+ let buffer = spatial_2d_buffer(FUNC, band.as_ref())?;
+ let nodata = NodataMatcher::for_band(FUNC, band.as_ref())?;
+ let (height, width) = (buffer.shape[0], buffer.shape[1]);
+ if height <= 0 || width <= 0 {
+ continue;
+ }
+ let grid = PixelBounds::single(0, 0).union(PixelBounds::single(width - 1, height - 1));
+ if bounds == Some(grid) {
+ // The grid is already covered, so no band can widen it.
+ continue;
+ }
+ let band_bounds = match nodata {
+ // Without a nodata value every pixel is data.
+ None => Some(grid),
+ Some(nodata) => band_data_bounds(&buffer, &nodata)?,
+ };
+ if let Some(band_bounds) = band_bounds {
+ bounds = Some(bounds.map_or(band_bounds, |b| b.union(band_bounds)));
+ }
+ }
+ Ok(bounds)
+}
+
+/// The cells of one band holding data, or `None` when every pixel is nodata.
+///
+/// Scans inward from each edge and stops at the first data pixel: rows down
+/// from the top, rows up from the bottom, then columns in from the left and
+/// right over the rows in between. A band that is mostly data costs a handful
+/// of pixels rather than a full scan; a band that is mostly nodata costs at
+/// most about two full scans. The bottom, left and right scans read the band
+/// through reversed and transposed views of the same buffer.
+fn band_data_bounds(buffer: &NdBuffer, nodata: &NodataMatcher) -> Result<Option<PixelBounds>> {
+ // The first data pixel of `view` in row-major order, as its view-space
+ // (column, row).
+ let first_data = |view: &NdBuffer| -> Result<Option<(i64, i64)>> {
+ let mut hit = None;
+ scan_pixels(FUNC, view, |col, row, pixel| {
+ if nodata.matches(pixel) {
+ ControlFlow::Continue(())
+ } else {
+ hit = Some((col, row));
+ ControlFlow::Break(())
+ }
+ })?;
+ Ok(hit)
+ };
+
+ // The top scan runs over the band's own view, so it also bounds-checks
+ // every byte the derived views below can reach.
+ let Some((_, min_row)) = first_data(buffer)? else {
+ return Ok(None);
+ };
+ let (height, width) = (buffer.shape[0], buffer.shape[1]);
+ let (row_stride, col_stride) = (buffer.strides[0], buffer.strides[1]);
+ let offset = buffer.offset as i64;
+ let view = |offset: i64, shape: [i64; 2], strides: [i64; 2]| NdBuffer {
+ buffer: buffer.buffer,
+ shape: shape.to_vec(),
+ strides: strides.to_vec(),
+ offset: offset as u64,
+ data_type: buffer.data_type,
+ };
+ // A data pixel exists in row `min_row`, so every scan below hits one.
+ let found = || exec_datafusion_err!("{FUNC}: data pixel vanished between scans");
+
+ // Rows from the bottom, up to `min_row`.
+ let bottom_up = view(
+ offset + (height - 1) * row_stride,
+ [height - min_row, width],
+ [-row_stride, col_stride],
+ );
+ let (_, rows_from_bottom) = first_data(&bottom_up)?.ok_or_else(found)?;
+ let max_row = height - 1 - rows_from_bottom;
+
+ // Columns (as view rows) over rows `min_row..=max_row`, from each side.
+ let rows = max_row - min_row + 1;
+ let left_in = view(
+ offset + min_row * row_stride,
+ [width, rows],
+ [col_stride, row_stride],
+ );
+ let (_, min_col) = first_data(&left_in)?.ok_or_else(found)?;
+ let right_in = view(
+ offset + min_row * row_stride + (width - 1) * col_stride,
+ [width - min_col, rows],
+ [-col_stride, row_stride],
+ );
+ let (_, cols_from_right) = first_data(&right_in)?.ok_or_else(found)?;
+
+ Ok(Some(PixelBounds {
+ min_col,
+ min_row,
+ max_col: width - 1 - cols_from_right,
+ max_row,
+ }))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use arrow_array::Int32Array;
+ use arrow_schema::DataType;
+ use datafusion_expr::ScalarUDF;
+ use sedona_raster::array::RasterStructArray;
+ use sedona_schema::datatypes::{RASTER, WKB_GEOMETRY};
+ use sedona_testing::compare::assert_array_equal;
+ use sedona_testing::create::create_array_item_crs;
+ use sedona_testing::raster_spec::{RasterSpec, raster_array};
+ use sedona_testing::testers::ScalarUdfTester;
+
+ fn bounds(spec: RasterSpec, band_nums: &[usize]) -> Result<Option<PixelBounds>> {
+ let array = spec.build();
+ let rasters = RasterStructArray::try_new(&array).unwrap();
+ data_bounds(&rasters.get(0).unwrap(), band_nums.iter().copied())
+ }
+
+ fn cells(min_col: i64, min_row: i64, max_col: i64, max_row: i64) -> Option<PixelBounds> {
+ Some(PixelBounds {
+ min_col,
+ min_row,
+ max_col,
+ max_row,
+ })
+ }
+
+ /// A 4x3 band of nodata (0) with data at (col 1, row 0) and (col 2, row 1):
+ ///
+ /// ```text
+ /// 0 5 0 0
+ /// 0 0 7 0
+ /// 0 0 0 0
+ /// ```
+ fn sparse_band() -> RasterSpec {
+ RasterSpec::d2(4, 3)
+ .band_values(&[0u8, 5, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0])
+ .nodata(0u8)
+ }
+
+ #[test]
+ fn udf_metadata() {
+ let udf: ScalarUDF = rs_minconvexhull_udf().into();
+ assert_eq!(udf.name(), "rs_minconvexhull");
+ assert_eq!(
+ rs_minconvexhull_udf()
+ .metadata()
+ .get(NEEDS_PIXELS_METADATA_KEY)
+ .map(String::as_str),
+ Some("true")
+ );
+ }
+
+ #[test]
+ fn trims_the_nodata_margin() {
+ assert_eq!(bounds(sparse_band(), &[1]).unwrap(), cells(1, 0, 2, 1));
+ }
+
+ #[test]
+ fn band_without_nodata_covers_the_grid() {
+ let spec = RasterSpec::d2(4, 3).band_values(&[0u8; 12]);
+ assert_eq!(bounds(spec, &[1]).unwrap(), cells(0, 0, 3, 2));
+ }
+
+ #[test]
+ fn all_nodata_is_none() {
+ let spec = RasterSpec::d2(2, 2).band_values(&[0u8; 4]).nodata(0u8);
+ assert_eq!(bounds(spec, &[1]).unwrap(), None);
+ // NaN pixels are nodata under a NaN nodata value.
+ let spec = RasterSpec::d2(2, 1)
+ .band_values(&[f32::NAN, f32::NAN])
+ .nodata(f32::NAN);
+ assert_eq!(bounds(spec, &[1]).unwrap(), None);
+ }
+
+ #[test]
+ fn bands_union_their_data() {
+ // Band 2 holds data only at (col 3, row 2).
+ let spec = sparse_band()
+ .band_values(&[0u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9])
+ .nodata(0u8);
+ assert_eq!(bounds(spec.clone(), &[2]).unwrap(), cells(3, 2, 3, 2));
+ assert_eq!(bounds(spec, &[1, 2]).unwrap(), cells(1, 0, 3, 2));
+ }
+
+ /// The edge-inward scan against a full scan over every data mask of a few
+ /// small grids (all 2^12 masks of a 4x3 grid, plus strips).
+ #[test]
+ fn edge_scan_matches_a_full_scan() {
+ for (width, height) in [(4usize, 3usize), (1, 5), (6, 1), (2, 2)] {
+ let cells = width * height;
+ for mask in 0u32..(1 << cells) {
+ let pixels: Vec<u8> = (0..cells).map(|i| ((mask >> i) & 1) as u8).collect();
+ let spec = RasterSpec::d2(width as i64, height as i64)
+ .band_values(&pixels)
+ .nodata(0u8);
+ let expected = pixels
+ .iter()
+ .enumerate()
+ .filter(|&(_, &p)| p != 0)
+ .map(|(i, _)| PixelBounds::single((i % width) as i64, (i / width) as i64))
+ .reduce(PixelBounds::union);
+ assert_eq!(
+ bounds(spec, &[1]).unwrap(),
+ expected,
+ "{width}x{height} mask {mask:b}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn band_out_of_range_errors() {
+ let err = bounds(sparse_band(), &[2]).unwrap_err().to_string();
+ assert!(err.contains("RS_MinConvexHull"), "{err}");
+ let err = bounds(sparse_band(), &[0]).unwrap_err().to_string();
+ assert!(err.contains("1-based"), "{err}");
+ }
+
+ #[test]
+ fn udf_invoke_places_cells_in_world_coordinates() {
+ // Two-unit pixels from origin (10, 20), north-up: cells (1..=2, 0..=1)
+ // span x 12..16 and y 20..16.
+ let raster = sparse_band().bbox(10.0, 14.0, 18.0, 20.0);
+ // Rotated grid: the corners follow the skewed axes, not the world axes.
+ let skewed = sparse_band().transform([10.0, 2.0, 1.0, 20.0, 1.0, -2.0]);
+ let all_nodata = RasterSpec::d2(1, 1).band_values(&[0u8]).nodata(0u8);
+ let rasters = raster_array([Some(raster), Some(skewed), Some(all_nodata), None]);
+
+ let udf: ScalarUDF = rs_minconvexhull_udf().into();
+ let tester = ScalarUdfTester::new(udf, vec![RASTER]);
+ let result = tester.invoke_array(Arc::new(rasters)).unwrap();
+ let expected = create_array_item_crs(
+ &[
+ Some("POLYGON ((12 20, 16 20, 16 16, 12 16, 12 20))"),
+ Some("POLYGON ((12 21, 16 23, 18 19, 14 17, 12 21))"),
+ None,
+ None,
+ ],
+ [Some("OGC:CRS84"), Some("OGC:CRS84"), None, None],
+ &WKB_GEOMETRY,
+ );
+ assert_array_equal(&result, &expected);
+ }
+
+ #[test]
+ fn udf_invoke_negative_band_errors() {
+ let udf: ScalarUDF = rs_minconvexhull_udf().into();
+ let tester = ScalarUdfTester::new(udf, vec![RASTER, SedonaType::Arrow(DataType::Int32)]);
+ let err = tester
+ .invoke_arrays(vec![
+ Arc::new(raster_array([Some(sparse_band())])),
+ Arc::new(Int32Array::from(vec![-1])),
+ ])
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("1-based"), "{err}");
+ }
+
+ #[test]
+ fn udf_invoke_with_band() {
+ let spec = sparse_band()
+ .band_values(&[0u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9])
+ .nodata(0u8);
+ let rasters = raster_array([Some(spec.clone()), Some(spec)]);
+
+ let udf: ScalarUDF = rs_minconvexhull_udf().into();
+ let tester = ScalarUdfTester::new(udf, vec![RASTER, SedonaType::Arrow(DataType::Int32)]);
+ let result = tester
+ .invoke_arrays(vec![
+ Arc::new(rasters),
+ Arc::new(Int32Array::from(vec![Some(2), None])),
+ ])
+ .unwrap();
+ // Default transform: unit pixels, north-up, origin (0, 0).
+ let expected = create_array_item_crs(
+ &[Some("POLYGON ((3 -2, 4 -2, 4 -3, 3 -3, 3 -2))"), None],
+ [Some("OGC:CRS84"), None],
+ &WKB_GEOMETRY,
+ );
+ assert_array_equal(&result, &expected);
+ }
+}