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);
+    }
+}