feat(rust/sedona-raster-functions): add RS_Value for spatial 2d rasters#974
Conversation
Add RS_Value to sample a raster pixel value at a point or 1-based grid cell (2-D). Reads materialised InDb pixel bytes directly from the band's NdBuffer via stride-offset math and marks the UDF needs_pixels so the planner injects RS_EnsureLoaded — no GDAL dependency. Returns null for out-of-bounds locations and nodata pixels; errors on non-2-D bands.
Add RS_Value cases (grid, point, and point+band) to the consolidated native-raster-functions Criterion bench, alongside the other RS_* kernels.
…d band handling - Floor (not truncate toward zero) the inverse transform so a point just outside the top/left edge is out of bounds rather than sampling an edge pixel. - Require a 2-D spatial (y, x) band via is_spatial_2d, not any two-axis band. - Propagate a NULL band argument to a NULL result; reject band 0/negative rather than coercing to band 1. - Document why the pixel read uses the lossless f64 conversion. - Add tests: floor edge case, NaN nodata, band 0, and the point CRS-mismatch / non-point-geometry error paths.
47d859b to
82b7968
Compare
…lue tests Drop the raster_con fixture + query_value helper; sample the inline RS_Example() raster and assert via assert_query_result, matching the other RS_ function tests. (The RS_EnsureLoaded planner path is covered against a real OutDb raster by test_rs_ensureloaded.)
There was a problem hiding this comment.
Thank you! I took a first pass...I am not sure how far you want to go in optimizing this now, but there are a few things I think you'll want to consider even before we take a general optimization pass through all the functions.
In most of our functions we assume that whatever operation we're doing is sufficiently expensive that the per-iteration overhead of things like null checks and input validation are not measurable. Extracting a value out of a buffer is a nanosecond-scale operation depending mostly on whether it's cached or not, so per-item null checks (and especially the branching introduced by them) in this case are something that a production-grade raster operator worth its salt needs to avoid.
We can (probably) improve this by composing operations such that the actual sample loop has no branches.
- Validate raster band selection and build a
Vec<(&[u8], AffineMatrix)>. In the possibly common case of a scalar raster, this check only has to happen once (instead of once per point). - For the geometry case, validate the point geometry and extract the x, y as floats. You can use
WkbHeaderfor this. - Resolve whether there needs to be a CRS transform (I think we have a "are all these CRSes equal" function, which could be better optimized to check if a vector of CRSes has repeated values). You may want to punt on the CRSes not equal front for now.
- Build a "selection vector" to figure out which values of the geometry/row+col items you need to consider. You can start by intersecting the NullBuffers, and then you also need to exclude empty points. A selection vector is often
Vec<usize>(DuckDB uses this trick to unify dictionary, run-end-encoding, and execution of scalar functions after a filter). - Resolve the logical row/col in a branchless loop (separate cases for scalar vs. array raster)
- When you support views, make the logical indices physical ones
- Extract from the buffer in a branchless loop.
A good way to know you're on the right track is to benchmark sd.sql("SELECT RS_Value($1, x, y)", params=(Raster.from_numpy(arr),)againstarr.take()`, particularly after #999.
(Definitely prototype that suggestion before taking on the complexity!)
| - name: colX | ||
| type: int | ||
| description: Column index (1-based). | ||
| - name: rowY | ||
| type: int | ||
| description: Row index (1-based). |
There was a problem hiding this comment.
Indexing is typically rows, cols (e.g., numpy, R) instead of cols, rows.
There was a problem hiding this comment.
punted the col/row version of the function for now
There was a problem hiding this comment.
But the signature here is based on trying to match postgis as much as possible:
Here are PostGIS's ST_Value (raster) signatures, straight from the current docs:
- ST_Value(raster rast, geometry pt, boolean exclude_nodata_value=true)
- ST_Value(raster rast, integer band, geometry pt, boolean exclude_nodata_value=true, text
resample='nearest')- ST_Value(raster rast, integer x, integer y, boolean exclude_nodata_value=true)
- ST_Value(raster rast, integer band, integer x, integer y, boolean exclude_nodata_value=true)
There was a problem hiding this comment.
also sedona spark: https://sedona.apache.org/latest/api/sql/Raster-Operators/RS_Value/
There was a problem hiding this comment.
Sure, but nobody asked me to review those 🙂 . Compared to GDAL and xarray the PostGIS and Sedona Spark APIs are not particularly popular...part of porting that to SedonaDB is to make it fast, but if anybody is ever going to use this we're also going to have to make it less awkward.
There was a problem hiding this comment.
Would it be better to keep st functions in sync between sedona and sedona_db? I understand the concern and bad decisions in the past on the orders of params, but if we'd make them different in signatures, users would need to change their SQL to adapt in between if they ever want to run on both engines.
…ple via WkbHeader - POINT EMPTY now samples to NULL instead of erroring. - Extract the point x/y via a header-only WkbHeader parse (no full geometry walk), which also yields NaN coords for empties and lets the empty check short-circuit before any CRS resolution. - Hold the cast Int32 arguments as ArrayRefs and borrow typed views instead of cloning the typed arrays. - Note that the f64->i64 index cast saturates (never panics).
…e pixel byte offset The (row, col) -> byte-offset computation multiplied and added i64 strides and offset unchecked, which could panic on overflow in debug builds given a pathological stride/offset. Use checked_mul/checked_add so a bad layout surfaces as an error instead of a panic.
…riant Ship RS_Value as point-only for now; remove the (raster, colX, rowY[, band]) grid kernels, their docs/examples, Python tests, and benchmark. The grid variant will be introduced as its own function later (its indexing order conflicts with PostGIS ST_Value). Pixel sampling by 0-based (col, row) is retained internally via sample_pixel and still covered by unit tests.
|
My inclination is to punt on the optimization work in order to add functionality. Also will eventually add a benchmark for these functions |
I think you should benchmark them now and put in a reasonable effort to optimize them now...we know we need this function to be fast and it will never be easier to get both of our eyes on it. The patterns you come up with for this function will help you with all of the other functions that come after. |
…sampling Add a scalar-raster (one raster, many points) RS_Value case alongside the existing array-raster cases. The scalar case is the one where per-point band / nodata / buffer / CRS resolution can be hoisted out of the sample loop, so it is the baseline for the upcoming optimization. Today it measures the same as the array-raster case (~172 ns/point), confirming the hoist isn't happening.
…alue For a constant (scalar) raster sampled with the default band — the common RS_Value(raster_expr, point_column) shape — resolve the band buffer, affine transform, and CRS once for the batch, then drive a selection vector + tight sample loop instead of re-resolving all of that per point. The general array-raster path (a distinct raster per row, which can't hoist) is unchanged. Factors the per-pixel read into read_pixel (shared with sample_pixel) and exposes the executor's geom WKB/CRS accessor pub(crate). ~3x faster on the scalar-raster benchmark (172 -> 59 ns/point); array-raster unchanged.
…sion Resolve once whether a column-level point CRS needs reprojecting into the raster CRS (column_reproject_decision), then skip the per-point reproject_point call — and its crs_equals, whose to_authority_code() allocates a String every point. Profiling put that at ~15 ns/point (~37% of the equal-CRS sample path), uniform across CRS types (lng/lat, EPSG:4326, EPSG:3857). A unit test verifies the equal-CRS short-circuit actually fires for the lng/lat case.
…ffset reader in RS_Value Add read_point_xy to sedona-geometry: a Point-only WKB reader that pulls the leading (x, y) from the fixed coordinate offset instead of running the general header parser. RS_Value now uses it on both the scalar fast path and the general batch path, preserving non-Point -> error and POINT EMPTY -> NULL semantics. The WKB parse was the dominant per-point cost after band/affine/CRS hoisting; the scalar (one-raster, many-points) benchmark improves ~56%, the array cases ~12%.
|
I spent a few hours doing benchmarking/profiling. I found that branch elimination did basically nothing. The big gains were in the Scalar raster case hoist, type level crs hoist, and the use of a point-specific wkb parser. Detailed write-up (generated by Claude, the AI pair on this session)What workedCRS-decision hoist — per point we were calling Band/affine/buffer hoist — the scalar fast path resolves band buffer, nodata, affine transform, and raster CRS once for the whole batch instead of per point. Point-specific WKB reader — the parse was the dominant remaining cost. A three-way comparison settled it:
Benchmark result (criterion)
What did NOT helpBranch elimination — profiling showed the branches guard operations that are already tiny: TakeawayThe two levers that mattered were both about doing work once instead of per-point (CRS decision, band/affine resolution) and not doing general-purpose work for a specific case (point-only parse). The micro-loop mechanics were never the bottleneck. Follow-up DB-97 tracks spreading |
…, fix edge cases Address review feedback on the RS_Value fast path: - Extract resolve_point_xy / xy_to_pixel shared by the scalar fast path and the general batch path, so the point->pixel logic is identical by construction instead of by hand. - Defer band/2-D validation in the scalar path until at least one point needs sampling, so an all-null/all-empty batch returns NULL rather than erroring on an unsupported raster — matching the general path. - Treat a non-finite point ordinate (NaN/Inf) as no location to sample (NULL), rather than letting a NaN index saturate to pixel column 0. - Reuse SRID_FLAG_BIT in read_point_xy instead of a magic literal. - Move geo-traits back to dev-dependencies (only used under #[cfg(test)]).
…to the band argument A constant (scalar) raster now takes the hoisted fast path regardless of the band argument, not just the 2-arg default-band case: - no band argument or a constant (scalar) band -> the band buffer is hoisted once, same as band 1; - a band column -> the band buffer is resolved per row (its NdBuffer borrows from the band and can't be cached across distinct bands), but the affine/CRS/reproject work is still hoisted out of the per-point loop. A NULL scalar band yields an all-NULL result. Selecting a different band no longer drops RS_Value(scalar_raster, point_col, band) onto the per-row path.
…ad of a WGS84 pivot Unlike the spatial predicates, RS_Value propagates a failed point->raster-CRS transform as an error rather than falling back to a WGS84 pivot: sampling must land the point in the raster's own CRS (the only space its affine/pixel grid is defined in), so there is no neutral CRS to fall back to.
paleolimbot
left a comment
There was a problem hiding this comment.
Thank you for the optimization goose chase here...50% better is great and this is also useful for non-raster PR reviews. WKB parsing and CRS equality are likely places we can improve performance elsewhere, too.
The wkb_header tests repeated `use sedona_testing::fixtures::*;` in eight functions; lift it to the `mod tests` block once.
Build an in-memory raster from a random numpy array (via Raster.from_numpy) with a known geotransform and no CRS, then sample pixel centers, off-center positions, and random interior points; assert RS_Value matches what rasterio reads at the same world coordinates. Adds rasterio to the test extras; the test skips if it is not installed.
The fixtures-import hoist left a trailing comment glued to the function's opening brace; split it onto its own line.
paleolimbot
left a comment
There was a problem hiding this comment.
Thank you!
If the R failure persists I'll fix in a separate PR.
No description provided.