From 0c54831f53f7ff27ff25e64aea12c72dc7721bee Mon Sep 17 00:00:00 2001 From: jameswillis Date: Fri, 17 Jul 2026 01:18:03 -0700 Subject: [PATCH 01/10] [GH-3112] RS_AsRaster fills uncovered pixels with noDataValue --- .../common/raster/PixelFunctionEditors.java | 8 +- .../common/raster/RasterConstructors.java | 6 +- .../sedona/common/raster/Rasterization.java | 31 ++++ .../common/raster/RasterConstructorsTest.java | 174 +++++++++++++----- docs/api/sql/Raster-Operators/RS_AsRaster.md | 2 +- 5 files changed, 164 insertions(+), 57 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/raster/PixelFunctionEditors.java b/common/src/main/java/org/apache/sedona/common/raster/PixelFunctionEditors.java index ea88350049c..bf8d68deb70 100644 --- a/common/src/main/java/org/apache/sedona/common/raster/PixelFunctionEditors.java +++ b/common/src/main/java/org/apache/sedona/common/raster/PixelFunctionEditors.java @@ -152,11 +152,11 @@ public static GridCoverage2D setValues( if (keepNoData) { noDataValue = RasterBandAccessors.getBandNoDataValue(raster, band); - rasterizedGeom = - RasterConstructors.asRaster(geom, raster, bandDataType, allTouched, value, noDataValue); - } else { - rasterizedGeom = RasterConstructors.asRaster(geom, raster, bandDataType, allTouched, value); } + // The rasterized geometry is only read as a mask below, where 0 marks pixels outside the + // geometry, so its band must not carry a nodata value (that would fill the mask's background + // with it). + rasterizedGeom = RasterConstructors.asRaster(geom, raster, bandDataType, allTouched, value); Raster rasterizedGeomData = RasterUtils.getRaster(rasterizedGeom.getRenderedImage()); double colX = RasterAccessors.getUpperLeftX(rasterizedGeom), diff --git a/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java b/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java index b12241ab8a4..474e4d37cc1 100644 --- a/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java +++ b/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java @@ -114,7 +114,8 @@ private static NetcdfFile openNetCdfBytes(byte[] bytes) throws IOException { * @param pixelType The data type of pixel/cell of resultant raster * @param allTouched When set to true, rasterizes all pixels touched by geom * @param value The value of the pixel of the resultant raster - * @param noDataValue The noDataValue of the resultant raster + * @param noDataValue The noDataValue of the resultant raster; pixels not covered by the geometry + * are set to this value * @param useGeometryExtent The way to generate extent of the resultant raster. Use the extent of * the geometry to convert if true, else use the extent of the reference raster * @return Rasterized Geometry @@ -131,7 +132,8 @@ public static GridCoverage2D asRaster( throws FactoryException { List objects = - Rasterization.rasterize(geom, raster, pixelType, value, useGeometryExtent, allTouched); + Rasterization.rasterize( + geom, raster, pixelType, value, useGeometryExtent, allTouched, noDataValue); WritableRaster writableRaster = (WritableRaster) objects.get(0); GridCoverage2D rasterized = (GridCoverage2D) objects.get(1); diff --git a/common/src/main/java/org/apache/sedona/common/raster/Rasterization.java b/common/src/main/java/org/apache/sedona/common/raster/Rasterization.java index 1449700b609..dfa7e437305 100644 --- a/common/src/main/java/org/apache/sedona/common/raster/Rasterization.java +++ b/common/src/main/java/org/apache/sedona/common/raster/Rasterization.java @@ -45,6 +45,18 @@ protected static List rasterize( boolean useGeometryExtent, boolean allTouched) throws FactoryException { + return rasterize(geom, raster, pixelType, value, useGeometryExtent, allTouched, null); + } + + protected static List rasterize( + Geometry geom, + GridCoverage2D raster, + String pixelType, + double value, + boolean useGeometryExtent, + boolean allTouched, + Double backgroundValue) + throws FactoryException { // Validate the input geometry and raster metadata double[] metadata = RasterAccessors.metadata(raster); @@ -60,6 +72,8 @@ protected static List rasterize( RasterizationParams params = calculateRasterizationParams(raster, useGeometryExtent, metadata, geomExtent, pixelType); + fillBackground(params.writableRaster, backgroundValue); + rasterizeGeometry(raster, metadata, geom, params, geomExtent, value, allTouched); // Create a GridCoverage2D for the rasterized result @@ -85,6 +99,23 @@ protected static List rasterize( return objects; } + /** + * Fills every pixel of the freshly-allocated raster with the background value, so pixels never + * covered by the geometry read back as that value instead of the allocation default of 0. A null + * or zero background keeps the zero-initialized allocation as-is. + */ + private static void fillBackground(WritableRaster writableRaster, Double backgroundValue) { + if (backgroundValue == null || backgroundValue == 0) { + return; + } + int width = writableRaster.getWidth(); + double[] row = new double[width]; + Arrays.fill(row, backgroundValue); + for (int y = 0; y < writableRaster.getHeight(); y++) { + writableRaster.setSamples(0, y, width, 1, 0, row); + } + } + private static void rasterizeGeometry( GridCoverage2D raster, double[] metadata, diff --git a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java index 7bfb37c30dc..3b0622a9017 100644 --- a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java +++ b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java @@ -92,10 +92,10 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio double[] expected = new double[] { - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, - 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 3093151.0, - 3093151.0, 3093151.0, 3093151.0, 0.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, - 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0 + 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, + 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3093151.0, + 3093151.0, 3093151.0, 3093151.0, 3.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, + 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0 }; assertArrayEquals(expected, actual, 0.1d); @@ -136,9 +136,9 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio actual = MapAlgebra.bandAsArray(rasterized, 1); expected = new double[] { - 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, - 3093151.0, 0.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, 0.0, 0.0, 3093151.0, - 3093151.0, 0.0 + 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, + 3093151.0, 3.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, 3.0, 3.0, 3093151.0, + 3093151.0, 3.0 }; assertArrayEquals(expected, actual, 0.1d); @@ -177,11 +177,11 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio actual = MapAlgebra.bandAsArray(rasterized, 1); expected = new double[] { - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, + 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, + 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, + 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, + 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0 }; assertArrayEquals(expected, actual, 0.1d); @@ -198,12 +198,12 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio actual = MapAlgebra.bandAsArray(rasterized, 1); expected = new double[] { - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0 + 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, + 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, + 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, + 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, + 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, + 3.0, 3.0, 3.0 }; assertArrayEquals(expected, actual, 0.1d); @@ -250,11 +250,11 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio expected = new double[] { - 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, - 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, - 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, + 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, + 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, + 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, + 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, + 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0 }; assertArrayEquals(expected, actual, 0.1d); @@ -271,12 +271,12 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio actual = MapAlgebra.bandAsArray(rasterized, 1); expected = new double[] { - 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, - 3093151.0, 3093151.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, - 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0 + 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, + 3093151.0, 3093151.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, + 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, + 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, + 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, + 3.0, 3.0 }; assertArrayEquals(expected, actual, 0.1d); @@ -320,10 +320,10 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio expected = new double[] { - 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 3093151.0, 3093151.0 + 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, + 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, + 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, + 3.0, 3.0, 3093151.0, 3093151.0 }; assertArrayEquals(expected, actual, 0.1d); @@ -332,10 +332,10 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio actual = MapAlgebra.bandAsArray(rasterized, 1); expected = new double[] { - 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, - 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0 + 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, + 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3.0, 3.0, 3.0, + 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0, + 3093151.0, 3093151.0, 3.0, 3.0, 3.0, 3.0, 3.0 }; assertArrayEquals(expected, actual, 0.1d); @@ -388,6 +388,80 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio assertArrayEquals(expected, actual, 0.1d); } + @Test + public void testAsRasterBackgroundIsNoDataValue() throws FactoryException, ParseException { + // Axis-aligned rings on square pixels keep the pixel selection trivial; + // the interesting part is that every pixel NOT covered by the geometry + // must read back as the noDataValue rather than 0, matching the band's + // nodata metadata (PostGIS ST_AsRaster fills untouched cells with + // nodataval; gdal_rasterize -init -a_nodata ). + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5), " + + "(104.5 497.5, 109.5 497.5, 109.5 491.5, 104.5 491.5, 104.5 497.5))", + 0); + + GridCoverage2D rasterized = + RasterConstructors.asRaster(geom, raster, "d", false, 1d, 9d, false); + double[] actual = MapAlgebra.bandAsArray(rasterized, 1); + double[] expected = + new double[] { + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1 + }; + assertArrayEquals(expected, actual, 0.1d); + assertEquals(9d, RasterUtils.getNoDataValue(rasterized.getSampleDimension(0)), 0.1d); + + // Burning value 0 must stay distinguishable from the nodata background + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 0d, 9d, false); + actual = MapAlgebra.bandAsArray(rasterized, 1); + expected = + new double[] { + 0, 0, 0, 0, 0, 0, 0, + 0, 0, 9, 9, 9, 0, 0, + 0, 0, 9, 9, 9, 0, 0, + 0, 0, 9, 9, 9, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0 + }; + assertArrayEquals(expected, actual, 0.1d); + + // Geometry-extent output (the default) fills its background the same way; + // this geometry's snapped envelope is the full grid + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 1d, 9d, true); + actual = MapAlgebra.bandAsArray(rasterized, 1); + expected = + new double[] { + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1 + }; + assertArrayEquals(expected, actual, 0.1d); + + // Without a noDataValue the background stays 0 + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 1d, null, false); + actual = MapAlgebra.bandAsArray(rasterized, 1); + expected = + new double[] { + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 1, 1, + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1 + }; + assertArrayEquals(expected, actual, 0.1d); + } + @Test public void testAsRasterLingString() throws FactoryException, ParseException { // Horizontal LineString @@ -430,19 +504,19 @@ public void testAsRasterWithRaster() throws IOException, ParseException, Factory GridCoverage2D rasterized = RasterConstructors.asRaster(geom, raster, "d", true, 612028, 5d); double[] actual = Arrays.stream(MapAlgebra.bandAsArray(rasterized, 1)).toArray(); double[] expected = { - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 612028.0, 612028.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 612028.0, 612028.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 612028.0, 612028.0, 612028.0, 612028.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 612028.0, 612028.0, 612028.0, 612028.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 612028.0, 612028.0, 612028.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 612028.0, - 612028.0, 612028.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 612028.0, 612028.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 612028.0, 612028.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 612028.0, 612028.0, 5.0, 5.0, 5.0, + 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 612028.0, 612028.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, + 5.0, 5.0, 5.0, 612028.0, 612028.0, 612028.0, 612028.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, + 5.0, 612028.0, 612028.0, 612028.0, 612028.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, + 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, + 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, + 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, + 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, + 612028.0, 612028.0, 612028.0, 612028.0, 612028.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, + 612028.0, 612028.0, 612028.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 612028.0, + 612028.0, 612028.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 612028.0, 612028.0, 5.0, + 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 612028.0, 612028.0, 5.0, 5.0, 5.0, 5.0, 5.0, + 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0 }; assertArrayEquals(expected, actual, 0.1d); diff --git a/docs/api/sql/Raster-Operators/RS_AsRaster.md b/docs/api/sql/Raster-Operators/RS_AsRaster.md index dcecf82ee4b..996d0c9fb19 100644 --- a/docs/api/sql/Raster-Operators/RS_AsRaster.md +++ b/docs/api/sql/Raster-Operators/RS_AsRaster.md @@ -26,7 +26,7 @@ Introduction: `RS_AsRaster` converts a vector geometry into a raster dataset by * `pixelType`: Defines data type of the output raster. This can be one of the following, D (double), F (float), I (integer), S (short), US (unsigned short) or B (byte). * `allTouched` (Since: `v1.7.1`): Decides the pixel selection criteria. If set to `true`, the function selects all pixels touched by the geometry, else, selects only pixels whose centroids intersect the geometry. Defaults to `false`. * `value`: The value to be used for assigning pixels covered by the geometry. Defaults to using `1.0` if not provided. -* `noDataValue`: Used for assigning the no data value of the resultant raster. Defaults to `null` if not provided. +* `noDataValue`: Used for assigning the no data value of the resultant raster; pixels not covered by the geometry are set to this value. Defaults to `null` if not provided, in which case uncovered pixels are `0` and the raster has no no data value. * `useGeometryExtent`: Defines the extent of the resultant raster. When set to `true`, it corresponds to the extent of `geom`, and when set to false, it corresponds to the extent of `raster`. Default value is `true` if not set. Format: From 7085eab06db9662ecea0328e7cd54c75eddb840c Mon Sep 17 00:00:00 2001 From: jameswillis Date: Fri, 17 Jul 2026 10:38:34 -0700 Subject: [PATCH 02/10] [GH-3112] Add seeded RS_AsRaster nodata-fill fuzz test against rasterio --- python/tests/sql/test_rasterize_nodata.py | 155 ++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 python/tests/sql/test_rasterize_nodata.py diff --git a/python/tests/sql/test_rasterize_nodata.py b/python/tests/sql/test_rasterize_nodata.py new file mode 100644 index 00000000000..a22af5fc2cb --- /dev/null +++ b/python/tests/sql/test_rasterize_nodata.py @@ -0,0 +1,155 @@ +# 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. + +import random + +import numpy as np +import rasterio.features +from affine import Affine +from pyspark.sql.functions import expr +from shapely.geometry import box +from shapely.wkt import loads as wkt_loads + +from tests.test_base import TestBase + +# (burn value, nodata value) pairs; value = 0 with a nonzero nodata is the +# case where a post-hoc background fill could not tell burned pixels apart. +VALUE_NODATA_PAIRS = [(1.0, 9.0), (0.0, 9.0), (7.0, 255.0), (1.0, 100.0)] + + +def _nodata_cases(count=60, seed=31114): + """Seeded random rectilinear polygons over anisotropic grids. + + All rings are axis-aligned (boxes, L-shapes, boxes with box holes), so + the pixel selection is identical across rasterizers and any divergence + from the GDAL reference is purely the background fill. The fixed seed + makes the corpus deterministic, so a failure reproduces from the case id + alone. + """ + rng = random.Random(seed) + cases = [] + while len(cases) < count: + width, height = rng.randint(4, 12), rng.randint(4, 12) + scale_x = round(rng.uniform(0.3, 5.0), 3) + scale_y = round(rng.uniform(0.3, 5.0), 3) * rng.choice([-1, 1]) + upper_left_x = round(rng.uniform(-1000, 1000), 2) + upper_left_y = round(rng.uniform(-1000, 1000), 2) + xs = sorted([upper_left_x, upper_left_x + width * scale_x]) + ys = sorted([upper_left_y, upper_left_y + height * scale_y]) + + def sub_box(fx0, fy0, fx1, fy1): + return box( + xs[0] + (xs[1] - xs[0]) * fx0, + ys[0] + (ys[1] - ys[0]) * fy0, + xs[0] + (xs[1] - xs[0]) * fx1, + ys[0] + (ys[1] - ys[0]) * fy1, + ) + + fx0, fy0 = rng.uniform(0.05, 0.4), rng.uniform(0.05, 0.4) + fx1, fy1 = rng.uniform(fx0 + 0.3, 0.95), rng.uniform(fy0 + 0.3, 0.95) + outer = sub_box(fx0, fy0, fx1, fy1) + kind = rng.choice(["box", "hole", "ell"]) + if kind == "hole": + hx0 = fx0 + (fx1 - fx0) * rng.uniform(0.2, 0.35) + hy0 = fy0 + (fy1 - fy0) * rng.uniform(0.2, 0.35) + hx1 = fx0 + (fx1 - fx0) * rng.uniform(0.6, 0.8) + hy1 = fy0 + (fy1 - fy0) * rng.uniform(0.6, 0.8) + geom = outer.difference(sub_box(hx0, hy0, hx1, hy1)) + elif kind == "ell": + # The second box starts inside the outer one and pokes past its + # right edge over a sub-range of its height, so the union is a + # single axis-aligned polygon with a concave corner. + geom = outer.union( + sub_box( + fx0 + (fx1 - fx0) * rng.uniform(0.3, 0.6), + fy0 + (fy1 - fy0) * rng.uniform(0.1, 0.3), + min(fx1 + rng.uniform(0.02, 0.04), 0.98), + fy0 + (fy1 - fy0) * rng.uniform(0.5, 0.8), + ) + ) + else: + geom = outer + if geom.geom_type != "Polygon": + continue + + value, nodata = VALUE_NODATA_PAIRS[len(cases) % len(VALUE_NODATA_PAIRS)] + cases.append( + ( + len(cases), + width, + height, + upper_left_x, + upper_left_y, + scale_x, + scale_y, + value, + nodata, + geom.wkt, + ) + ) + return cases + + +class TestRasterizeNoData(TestBase): + def test_as_raster_background_is_nodata(self): + """Pixels not covered by the geometry (outside it, or inside a hole) + must read back as the declared noDataValue, matching GDAL + (rasterio.features.rasterize with fill=nodata) and the band's nodata + metadata. Rings are axis-aligned so pixel selection cannot differ + between rasterizers; only the fill policy is under test. + """ + cases = _nodata_cases() + df = self.spark.createDataFrame( + cases, + "id INT, width INT, height INT, ulx DOUBLE, uly DOUBLE, " + "sx DOUBLE, sy DOUBLE, value DOUBLE, nodata DOUBLE, wkt STRING", + ) + rows = ( + df.withColumn( + "r", + expr( + "RS_AsRaster(ST_GeomFromWKT(wkt), " + "RS_MakeEmptyRaster(1, 'd', width, height, ulx, uly, sx, sy, 0, 0, 0), " + "'d', false, value, nodata, false)" + ), + ) + .selectExpr( + "id", + "RS_BandAsArray(r, 1) as band", + "RS_BandNoDataValue(r, 1) as nd", + ) + .collect() + ) + results = {row["id"]: row for row in rows} + assert len(results) == len(cases) + + for case_id, width, height, ulx, uly, sx, sy, value, nodata, wkt in cases: + row = results[case_id] + assert row["nd"] == nodata, f"case {case_id}: nodata metadata" + actual = np.array(row["band"], dtype=float).reshape(height, width) + expected = rasterio.features.rasterize( + [(wkt_loads(wkt), value)], + out_shape=(height, width), + fill=nodata, + transform=Affine(sx, 0, ulx, 0, sy, uly), + all_touched=False, + dtype="float64", + ) + assert np.array_equal(actual, expected), ( + f"case {case_id}: grid {width}x{height}, " + f"scale ({sx}, {sy}), value {value}, nodata {nodata}, {wkt}" + ) From bfea462281570bd4a02fbc9ccdb4c2ab94091772 Mon Sep 17 00:00:00 2001 From: jameswillis Date: Fri, 17 Jul 2026 10:44:23 -0700 Subject: [PATCH 03/10] Revert "[GH-3112] Add seeded RS_AsRaster nodata-fill fuzz test against rasterio" This reverts commit 7085eab06db9662ecea0328e7cd54c75eddb840c. --- python/tests/sql/test_rasterize_nodata.py | 155 ---------------------- 1 file changed, 155 deletions(-) delete mode 100644 python/tests/sql/test_rasterize_nodata.py diff --git a/python/tests/sql/test_rasterize_nodata.py b/python/tests/sql/test_rasterize_nodata.py deleted file mode 100644 index a22af5fc2cb..00000000000 --- a/python/tests/sql/test_rasterize_nodata.py +++ /dev/null @@ -1,155 +0,0 @@ -# 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. - -import random - -import numpy as np -import rasterio.features -from affine import Affine -from pyspark.sql.functions import expr -from shapely.geometry import box -from shapely.wkt import loads as wkt_loads - -from tests.test_base import TestBase - -# (burn value, nodata value) pairs; value = 0 with a nonzero nodata is the -# case where a post-hoc background fill could not tell burned pixels apart. -VALUE_NODATA_PAIRS = [(1.0, 9.0), (0.0, 9.0), (7.0, 255.0), (1.0, 100.0)] - - -def _nodata_cases(count=60, seed=31114): - """Seeded random rectilinear polygons over anisotropic grids. - - All rings are axis-aligned (boxes, L-shapes, boxes with box holes), so - the pixel selection is identical across rasterizers and any divergence - from the GDAL reference is purely the background fill. The fixed seed - makes the corpus deterministic, so a failure reproduces from the case id - alone. - """ - rng = random.Random(seed) - cases = [] - while len(cases) < count: - width, height = rng.randint(4, 12), rng.randint(4, 12) - scale_x = round(rng.uniform(0.3, 5.0), 3) - scale_y = round(rng.uniform(0.3, 5.0), 3) * rng.choice([-1, 1]) - upper_left_x = round(rng.uniform(-1000, 1000), 2) - upper_left_y = round(rng.uniform(-1000, 1000), 2) - xs = sorted([upper_left_x, upper_left_x + width * scale_x]) - ys = sorted([upper_left_y, upper_left_y + height * scale_y]) - - def sub_box(fx0, fy0, fx1, fy1): - return box( - xs[0] + (xs[1] - xs[0]) * fx0, - ys[0] + (ys[1] - ys[0]) * fy0, - xs[0] + (xs[1] - xs[0]) * fx1, - ys[0] + (ys[1] - ys[0]) * fy1, - ) - - fx0, fy0 = rng.uniform(0.05, 0.4), rng.uniform(0.05, 0.4) - fx1, fy1 = rng.uniform(fx0 + 0.3, 0.95), rng.uniform(fy0 + 0.3, 0.95) - outer = sub_box(fx0, fy0, fx1, fy1) - kind = rng.choice(["box", "hole", "ell"]) - if kind == "hole": - hx0 = fx0 + (fx1 - fx0) * rng.uniform(0.2, 0.35) - hy0 = fy0 + (fy1 - fy0) * rng.uniform(0.2, 0.35) - hx1 = fx0 + (fx1 - fx0) * rng.uniform(0.6, 0.8) - hy1 = fy0 + (fy1 - fy0) * rng.uniform(0.6, 0.8) - geom = outer.difference(sub_box(hx0, hy0, hx1, hy1)) - elif kind == "ell": - # The second box starts inside the outer one and pokes past its - # right edge over a sub-range of its height, so the union is a - # single axis-aligned polygon with a concave corner. - geom = outer.union( - sub_box( - fx0 + (fx1 - fx0) * rng.uniform(0.3, 0.6), - fy0 + (fy1 - fy0) * rng.uniform(0.1, 0.3), - min(fx1 + rng.uniform(0.02, 0.04), 0.98), - fy0 + (fy1 - fy0) * rng.uniform(0.5, 0.8), - ) - ) - else: - geom = outer - if geom.geom_type != "Polygon": - continue - - value, nodata = VALUE_NODATA_PAIRS[len(cases) % len(VALUE_NODATA_PAIRS)] - cases.append( - ( - len(cases), - width, - height, - upper_left_x, - upper_left_y, - scale_x, - scale_y, - value, - nodata, - geom.wkt, - ) - ) - return cases - - -class TestRasterizeNoData(TestBase): - def test_as_raster_background_is_nodata(self): - """Pixels not covered by the geometry (outside it, or inside a hole) - must read back as the declared noDataValue, matching GDAL - (rasterio.features.rasterize with fill=nodata) and the band's nodata - metadata. Rings are axis-aligned so pixel selection cannot differ - between rasterizers; only the fill policy is under test. - """ - cases = _nodata_cases() - df = self.spark.createDataFrame( - cases, - "id INT, width INT, height INT, ulx DOUBLE, uly DOUBLE, " - "sx DOUBLE, sy DOUBLE, value DOUBLE, nodata DOUBLE, wkt STRING", - ) - rows = ( - df.withColumn( - "r", - expr( - "RS_AsRaster(ST_GeomFromWKT(wkt), " - "RS_MakeEmptyRaster(1, 'd', width, height, ulx, uly, sx, sy, 0, 0, 0), " - "'d', false, value, nodata, false)" - ), - ) - .selectExpr( - "id", - "RS_BandAsArray(r, 1) as band", - "RS_BandNoDataValue(r, 1) as nd", - ) - .collect() - ) - results = {row["id"]: row for row in rows} - assert len(results) == len(cases) - - for case_id, width, height, ulx, uly, sx, sy, value, nodata, wkt in cases: - row = results[case_id] - assert row["nd"] == nodata, f"case {case_id}: nodata metadata" - actual = np.array(row["band"], dtype=float).reshape(height, width) - expected = rasterio.features.rasterize( - [(wkt_loads(wkt), value)], - out_shape=(height, width), - fill=nodata, - transform=Affine(sx, 0, ulx, 0, sy, uly), - all_touched=False, - dtype="float64", - ) - assert np.array_equal(actual, expected), ( - f"case {case_id}: grid {width}x{height}, " - f"scale ({sx}, {sy}), value {value}, nodata {nodata}, {wkt}" - ) From f9791714071beca7491dd338559f996e2657b67b Mon Sep 17 00:00:00 2001 From: jameswillis Date: Wed, 22 Jul 2026 11:35:17 -0700 Subject: [PATCH 04/10] [GH-3112] Reject non-representable RS_AsRaster noDataValue instead of coercing Validate the noDataValue against the target pixel type before it is used as the background fill and the band nodata metadata. An out-of-range value (for example -1.0 or 300.0 on an unsigned 8-bit band) or a fractional value on an integer band would previously be silently coerced into the sample, so the background read back as data while the recorded nodata disagreed. Reject such a value with an IllegalArgumentException naming the value and pixel type, and reuse the validated value for both the fill and the metadata. --- .../common/raster/RasterConstructors.java | 9 +++ .../sedona/common/utils/RasterUtils.java | 69 +++++++++++++++++++ .../common/raster/RasterConstructorsTest.java | 65 +++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java b/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java index 474e4d37cc1..a7c166eaa06 100644 --- a/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java +++ b/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java @@ -131,6 +131,15 @@ public static GridCoverage2D asRaster( boolean useGeometryExtent) throws FactoryException { + // Reject a noDataValue that cannot be represented in the target pixel type rather than + // silently coercing it: writing an out-of-range or fractional value into an integer sample + // stores a different number than the value recorded as the band's nodata metadata, so the + // background would read back as data. Validating once here keeps the background fill and the + // nodata metadata below using the same value. + if (noDataValue != null) { + noDataValue = RasterUtils.assertNoDataValueRepresentable(noDataValue, pixelType); + } + List objects = Rasterization.rasterize( geom, raster, pixelType, value, useGeometryExtent, allTouched, noDataValue); diff --git a/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java b/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java index 6113ee943d5..48441820593 100644 --- a/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java +++ b/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java @@ -647,6 +647,75 @@ public static boolean isDataTypeIntegral(int dataTypeCode) { } } + /** + * Verifies that {@code noDataValue} can be stored in the pixel type named by {@code pixelType} + * without silent coercion, returning it unchanged when it can. Writing a value that is out of the + * pixel type's range, or fractional for an integer pixel type, coerces the stored sample to a + * different number than the value recorded as the band's nodata metadata (for example -1.0 or + * 300.0 wraps to 255 or 44 in an unsigned 8-bit band), so the background would then read back as + * data rather than nodata. Such a value is a correctness violation, not a per-row data condition, + * so this rejects it with an {@link IllegalArgumentException} instead of coercing. + * + * @param noDataValue the candidate nodata / background value + * @param pixelType a Sedona pixel type string accepted by {@link #getDataTypeCode(String)} (for + * example {@code "B"}, {@code "I"}, {@code "D"}) + * @return {@code noDataValue}, unchanged, when it is representable in the pixel type + * @throws IllegalArgumentException when {@code noDataValue} cannot be represented in the pixel + * type + */ + public static double assertNoDataValueRepresentable(double noDataValue, String pixelType) { + int dataTypeCode = getDataTypeCode(pixelType); + long min; + long max; + String description; + switch (dataTypeCode) { + case DataBuffer.TYPE_BYTE: + min = 0; + max = 255; + description = "unsigned 8-bit"; + break; + case DataBuffer.TYPE_USHORT: + min = 0; + max = 65535; + description = "unsigned 16-bit"; + break; + case DataBuffer.TYPE_SHORT: + min = Short.MIN_VALUE; + max = Short.MAX_VALUE; + description = "signed 16-bit"; + break; + case DataBuffer.TYPE_INT: + min = Integer.MIN_VALUE; + max = Integer.MAX_VALUE; + description = "signed 32-bit"; + break; + case DataBuffer.TYPE_FLOAT: + // 32-bit float represents NaN and the infinities; reject only a finite value whose + // magnitude overflows the float range (it would silently become an infinity). Rounding a + // value to the nearest float is the inherent, expected behavior of a float band. + if (Double.isFinite(noDataValue) && Math.abs(noDataValue) > Float.MAX_VALUE) { + throw new IllegalArgumentException( + String.format( + "noDataValue %s is not representable in pixel type '%s' (32-bit float, valid range %s..%s)", + noDataValue, pixelType, -Float.MAX_VALUE, Float.MAX_VALUE)); + } + return noDataValue; + case DataBuffer.TYPE_DOUBLE: + default: + // 64-bit float represents every double value; nothing to reject. + return noDataValue; + } + + // Integer pixel types: reject out-of-range and fractional values (Math.rint also rejects NaN). + if (noDataValue < min || noDataValue > max || noDataValue != Math.rint(noDataValue)) { + throw new IllegalArgumentException( + String.format( + "noDataValue %s is not representable in pixel type '%s' (%s integer, valid range %d..%d)", + noDataValue, pixelType, description, min, max)); + } + return noDataValue; + } + /** * This is an experimental method as it does not copy the original raster properties (e.g. color * model, sample model, etc.) moved from MapAlgebra.java TODO: Copy the original raster properties diff --git a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java index 14585521815..94559c6618f 100644 --- a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java +++ b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java @@ -464,6 +464,71 @@ public void testAsRasterBackgroundIsNoDataValue() throws FactoryException, Parse assertArrayEquals(expected, actual, 0.1d); } + @Test + public void testAsRasterRejectsNonRepresentableNoDataValue() + throws FactoryException, ParseException { + // An unsigned 8-bit band ('B') cannot store a negative or >255 nodata. Silently coercing + // -1.0 -> 255 or 300.0 -> 44 would leave the background reading back as data while the + // recorded nodata metadata disagreed, so RS_AsRaster rejects such a value up front. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5))", 0); + + IllegalArgumentException negative = + Assert.assertThrows( + IllegalArgumentException.class, + () -> RasterConstructors.asRaster(geom, raster, "B", false, 1d, -1d, false)); + Assert.assertTrue(negative.getMessage(), negative.getMessage().contains("-1.0")); + Assert.assertTrue(negative.getMessage(), negative.getMessage().contains("'B'")); + + IllegalArgumentException tooLarge = + Assert.assertThrows( + IllegalArgumentException.class, + () -> RasterConstructors.asRaster(geom, raster, "B", false, 1d, 300d, false)); + Assert.assertTrue(tooLarge.getMessage(), tooLarge.getMessage().contains("300.0")); + Assert.assertTrue(tooLarge.getMessage(), tooLarge.getMessage().contains("'B'")); + + // A fractional nodata cannot be stored in a signed 32-bit integer band either. + IllegalArgumentException fractional = + Assert.assertThrows( + IllegalArgumentException.class, + () -> RasterConstructors.asRaster(geom, raster, "I", false, 1d, 1.5d, false)); + Assert.assertTrue(fractional.getMessage(), fractional.getMessage().contains("1.5")); + Assert.assertTrue(fractional.getMessage(), fractional.getMessage().contains("'I'")); + } + + @Test + public void testAsRasterRepresentableNoDataValueOnIntegerBand() + throws FactoryException, ParseException { + // A representable nodata (9 fits an unsigned 8-bit band) still fills the uncovered pixels and + // is recorded as the band's nodata, mirroring testAsRasterBackgroundIsNoDataValue on a double + // band. The exact-equality reads confirm no coercion happened on the integer band. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5), " + + "(104.5 497.5, 109.5 497.5, 109.5 491.5, 104.5 491.5, 104.5 497.5))", + 0); + + GridCoverage2D rasterized = + RasterConstructors.asRaster(geom, raster, "B", false, 1d, 9d, false); + double[] actual = MapAlgebra.bandAsArray(rasterized, 1); + double[] expected = + new double[] { + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1 + }; + assertArrayEquals(expected, actual, 0.0d); + assertEquals(9d, RasterUtils.getNoDataValue(rasterized.getSampleDimension(0)), 0.0d); + } + @Test public void testAsRasterWithNonSquarePixels() throws FactoryException, ParseException { // Pixels are 2 world units wide and 3 tall, so the x-intercept math cannot From 863c053c022fff122dca0cfabcb8f225dc863188 Mon Sep 17 00:00:00 2001 From: jameswillis Date: Wed, 22 Jul 2026 14:07:20 -0700 Subject: [PATCH 05/10] [GH-3112] RS_AsRaster inherits reference band noData when noDataValue omitted The overloads without a noDataValue argument now inherit the reference raster's first band nodata value, using it both as the output band's nodata metadata and as the fill for the pixels the geometry does not cover, and error when the reference band has no nodata value to inherit. An explicit null noDataValue on the widest overload still opts out of a nodata value and keeps a zero background. RS_Clip and RS_SetValues rasterize a 0-background mask, so they now pass an explicit null noDataValue to keep that zero background instead of relying on the omitted-argument default. --- .../common/raster/PixelFunctionEditors.java | 7 +- .../common/raster/RasterBandEditors.java | 8 +- .../common/raster/RasterConstructors.java | 39 +++++- .../common/raster/RasterConstructorsTest.java | 114 ++++++++++++++++-- docs/api/sql/Raster-Operators/RS_AsRaster.md | 6 +- .../org/apache/sedona/sql/rasterIOTest.scala | 38 +++++- 6 files changed, 183 insertions(+), 29 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/raster/PixelFunctionEditors.java b/common/src/main/java/org/apache/sedona/common/raster/PixelFunctionEditors.java index bf8d68deb70..2762317e0f7 100644 --- a/common/src/main/java/org/apache/sedona/common/raster/PixelFunctionEditors.java +++ b/common/src/main/java/org/apache/sedona/common/raster/PixelFunctionEditors.java @@ -155,8 +155,11 @@ public static GridCoverage2D setValues( } // The rasterized geometry is only read as a mask below, where 0 marks pixels outside the // geometry, so its band must not carry a nodata value (that would fill the mask's background - // with it). - rasterizedGeom = RasterConstructors.asRaster(geom, raster, bandDataType, allTouched, value); + // with it). Passing an explicit null noDataValue keeps the mask's background at 0; the + // noDataValue-less overloads would instead inherit the reference band's nodata value and fill + // the background with it. + rasterizedGeom = + RasterConstructors.asRaster(geom, raster, bandDataType, allTouched, value, null); Raster rasterizedGeomData = RasterUtils.getRaster(rasterizedGeom.getRenderedImage()); double colX = RasterAccessors.getUpperLeftX(rasterizedGeom), diff --git a/common/src/main/java/org/apache/sedona/common/raster/RasterBandEditors.java b/common/src/main/java/org/apache/sedona/common/raster/RasterBandEditors.java index 82f57610d6a..5b128e91559 100644 --- a/common/src/main/java/org/apache/sedona/common/raster/RasterBandEditors.java +++ b/common/src/main/java/org/apache/sedona/common/raster/RasterBandEditors.java @@ -365,9 +365,13 @@ public static GridCoverage2D clip( writableRaster.setSamples(0, 0, rasterWidth, rasterHeight, 0, array); } - // rasterize the geometry to iterate over the clipped raster + // rasterize the geometry to iterate over the clipped raster. The mask is read as a + // 0-background mask below (a pixel value of 0 marks "outside the geometry"), so pass an + // explicit null noDataValue to keep that zero background. The noDataValue-less overloads would + // instead inherit the reference band's nodata value and fill the background with it, which the + // mask logic would then misread as covered pixels. GridCoverage2D maskRaster = - RasterConstructors.asRaster(geometry, raster, bandType, allTouched, 150); + RasterConstructors.asRaster(geometry, raster, bandType, allTouched, 150, null); Raster maskData = RasterUtils.getRaster(maskRaster.getRenderedImage()); double[] maskMetadata = RasterAccessors.metadata(maskRaster); int maskWidth = (int) maskMetadata[2], maskHeight = (int) maskMetadata[3]; diff --git a/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java b/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java index a7c166eaa06..c500ace249e 100644 --- a/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java +++ b/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java @@ -189,7 +189,8 @@ public static GridCoverage2D asRaster( /** * Returns a raster that is converted from the geometry provided. A convenience function for - * asRaster. + * asRaster. Because this overload takes no noDataValue argument, the noDataValue is inherited + * from the reference raster band (see {@link #inheritReferenceNoDataValue}). * * @param geom The geometry to convert * @param raster The reference raster @@ -199,12 +200,13 @@ public static GridCoverage2D asRaster( */ public static GridCoverage2D asRaster(Geometry geom, GridCoverage2D raster, String pixelType) throws FactoryException { - return asRaster(geom, raster, pixelType, false, 1, null); + return asRaster(geom, raster, pixelType, false, 1, inheritReferenceNoDataValue(raster)); } /** * Returns a raster that is converted from the geometry provided. A convenience function for - * asRaster. + * asRaster. Because this overload takes no noDataValue argument, the noDataValue is inherited + * from the reference raster band (see {@link #inheritReferenceNoDataValue}). * * @param geom The geometry to convert * @param raster The reference raster @@ -216,12 +218,13 @@ public static GridCoverage2D asRaster(Geometry geom, GridCoverage2D raster, Stri public static GridCoverage2D asRaster( Geometry geom, GridCoverage2D raster, String pixelType, boolean allTouched) throws FactoryException { - return asRaster(geom, raster, pixelType, allTouched, 1, null); + return asRaster(geom, raster, pixelType, allTouched, 1, inheritReferenceNoDataValue(raster)); } /** * Returns a raster that is converted from the geometry provided. A convenience function for - * asRaster. + * asRaster. Because this overload takes no noDataValue argument, the noDataValue is inherited + * from the reference raster band (see {@link #inheritReferenceNoDataValue}). * * @param geom The geometry to convert * @param raster The reference raster @@ -234,7 +237,31 @@ public static GridCoverage2D asRaster( public static GridCoverage2D asRaster( Geometry geom, GridCoverage2D raster, String pixelType, boolean allTouched, double value) throws FactoryException { - return asRaster(geom, raster, pixelType, allTouched, value, null); + return asRaster( + geom, raster, pixelType, allTouched, value, inheritReferenceNoDataValue(raster)); + } + + /** + * Resolves the noDataValue that the overloads without a noDataValue argument inherit from the + * reference raster. Those overloads fill every pixel the geometry does not cover with, and record + * as the output band's nodata metadata, the reference raster's first band nodata value. A + * reference band that carries no nodata value leaves nothing to inherit, so this rejects the call + * rather than silently producing a raster with no nodata value and a zero background; the caller + * then either passes an explicit noDataValue, or passes a null noDataValue through the widest + * overload to opt out of a nodata value entirely. + * + * @param raster the reference raster whose first band nodata value is inherited + * @return the reference band's nodata value + * @throws IllegalArgumentException when the reference band has no nodata value to inherit + */ + private static double inheritReferenceNoDataValue(GridCoverage2D raster) { + Double referenceNoDataValue = RasterBandAccessors.getBandNoDataValue(raster, 1); + if (referenceNoDataValue == null) { + throw new IllegalArgumentException( + "RS_AsRaster: noDataValue omitted but reference raster band 1 has no nodata value to " + + "inherit; pass an explicit noDataValue, or pass NULL for no nodata"); + } + return referenceNoDataValue; } /** diff --git a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java index 94559c6618f..ac4be6bb105 100644 --- a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java +++ b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java @@ -81,6 +81,10 @@ public void fromGeoTiff() throws IOException, FactoryException { @Test public void testAsRasterWithEmptyRaster() throws FactoryException, ParseException { + // The empty reference rasters below carry no nodata value. Omitting the noDataValue argument + // would now inherit the reference band's nodata value and error when there is none, so the + // calls that exercise the default burn value of 1 with a zero background pass an explicit null + // noDataValue (the escape hatch for "no nodata value, zero background"). // Polygon GridCoverage2D raster = RasterConstructors.makeEmptyRaster(2, 255, 255, 1, -1, 2, -2, 0, 0, 4326); @@ -102,7 +106,7 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio assertArrayEquals(expected, actual, 0.1d); - rasterized = RasterConstructors.asRaster(geom, raster, "d"); + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 1d, null); actual = MapAlgebra.bandAsArray(rasterized, 1); expected = new double[] { @@ -113,7 +117,7 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio assertArrayEquals(expected, actual, 0.1d); // Test bottom-up raster case - rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d"); + rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 1d, null); expected = new double[] { 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, @@ -144,7 +148,7 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio }; assertArrayEquals(expected, actual, 0.1d); - rasterized = RasterConstructors.asRaster(geom, raster, "d"); + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 1d, null); actual = MapAlgebra.bandAsArray(rasterized, 1); expected = @@ -155,7 +159,7 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio assertArrayEquals(expected, actual, 0.1d); // Test bottom-up raster case - rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d"); + rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 1d, null); // Making sure orientation is preserved assertEquals( @@ -210,7 +214,7 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio assertArrayEquals(expected, actual, 0.1d); - rasterized = RasterConstructors.asRaster(geom, raster, "d"); + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 1d, null); actual = MapAlgebra.bandAsArray(rasterized, 1); @@ -225,7 +229,7 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio assertArrayEquals(expected, actual, 0.1d); // Test bottom-up raster case - rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d"); + rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 1d, null); // Making sure orientation is preserved assertEquals( @@ -283,7 +287,7 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio assertArrayEquals(expected, actual, 0.1d); - rasterized = RasterConstructors.asRaster(geom, raster, "d"); + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 1d, null); actual = MapAlgebra.bandAsArray(rasterized, 1); expected = @@ -297,7 +301,7 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio assertArrayEquals(expected, actual, 0.1d); // Test bottom-up raster case - rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d"); + rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 1d, null); // Making sure orientation is preserved assertEquals( @@ -342,7 +346,7 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio assertArrayEquals(expected, actual, 0.1d); - rasterized = RasterConstructors.asRaster(geom, raster, "d"); + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 1d, null); actual = MapAlgebra.bandAsArray(rasterized, 1); expected = @@ -355,7 +359,7 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio assertArrayEquals(expected, actual, 0.1d); // Test bottom-up raster case - rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d"); + rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 1d, null); actual = MapAlgebra.bandAsArray(rasterized, 1); expected = new double[] { @@ -379,13 +383,13 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio actual = MapAlgebra.bandAsArray(rasterized, 1); assertArrayEquals(expected, actual, 0.1d); - rasterized = RasterConstructors.asRaster(geom, raster, "d"); + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 1d, null); actual = MapAlgebra.bandAsArray(rasterized, 1); expected = new double[] {1.0, 1.0, 1.0, 1.0}; assertArrayEquals(expected, actual, 0.1d); // Test bottom-up raster case - rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d"); + rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 1d, null); actual = MapAlgebra.bandAsArray(rasterized, 1); assertArrayEquals(expected, actual, 0.1d); } @@ -529,6 +533,88 @@ public void testAsRasterRepresentableNoDataValueOnIntegerBand() assertEquals(9d, RasterUtils.getNoDataValue(rasterized.getSampleDimension(0)), 0.0d); } + @Test + public void testAsRasterOmittedNoDataValueInheritsReferenceBandNoData() + throws FactoryException, ParseException { + // Omitting the noDataValue argument inherits the reference band's nodata value: it fills every + // pixel the geometry does not cover (here the polygon's hole) and is recorded as the output + // band's nodata metadata. This mirrors testAsRasterBackgroundIsNoDataValue, except the value + // is taken from the reference band rather than passed explicitly. The exact-equality reads pin + // that the inherited value reaches both the samples and the metadata. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + raster = RasterBandEditors.setBandNoDataValue(raster, 1, 9d); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5), " + + "(104.5 497.5, 109.5 497.5, 109.5 491.5, 104.5 491.5, 104.5 497.5))", + 0); + + GridCoverage2D rasterized = RasterConstructors.asRaster(geom, raster, "d"); + double[] actual = MapAlgebra.bandAsArray(rasterized, 1); + double[] expected = + new double[] { + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 9, 9, 9, 1, 1, + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1 + }; + assertArrayEquals(expected, actual, 0.0d); + assertEquals(9d, RasterUtils.getNoDataValue(rasterized.getSampleDimension(0)), 0.0d); + assertEquals(Double.valueOf(9d), RasterBandAccessors.getBandNoDataValue(rasterized, 1)); + } + + @Test + public void testAsRasterOmittedNoDataValueErrorsWithoutReferenceNoData() + throws FactoryException, ParseException { + // With the noDataValue omitted and the reference band carrying no nodata value there is nothing + // to inherit, so RS_AsRaster rejects the call rather than silently producing a raster with no + // nodata value and a zero background. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5))", 0); + + IllegalArgumentException error = + Assert.assertThrows( + IllegalArgumentException.class, () -> RasterConstructors.asRaster(geom, raster, "d")); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("noDataValue")); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("band 1")); + } + + @Test + public void testAsRasterExplicitNullNoDataValueLeavesNoNoData() + throws FactoryException, ParseException { + // Passing an explicit null noDataValue is the escape hatch for "no nodata value": the output + // declares no band nodata value and every uncovered pixel stays 0, even when the reference + // band has a nodata value that the noDataValue-less overloads would otherwise inherit. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + raster = RasterBandEditors.setBandNoDataValue(raster, 1, 9d); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5), " + + "(104.5 497.5, 109.5 497.5, 109.5 491.5, 104.5 491.5, 104.5 497.5))", + 0); + + GridCoverage2D rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 1d, null); + double[] actual = MapAlgebra.bandAsArray(rasterized, 1); + double[] expected = + new double[] { + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 1, 1, + 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1 + }; + assertArrayEquals(expected, actual, 0.0d); + Assert.assertNull(RasterBandAccessors.getBandNoDataValue(rasterized, 1)); + } + @Test public void testAsRasterWithNonSquarePixels() throws FactoryException, ParseException { // Pixels are 2 world units wide and 3 tall, so the x-intercept math cannot @@ -668,7 +754,9 @@ public void testAsRasterWithRaster() throws IOException, ParseException, Factory }; assertArrayEquals(expected, actual, 0.1d); - rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 5484); + // Omitting the noDataValue would inherit test5.tiff's band nodata value; pass an explicit null + // to keep this case's original intent of no nodata value and a zero background. + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 5484, null); actual = Arrays.stream(MapAlgebra.bandAsArray(rasterized, 1)).toArray(); expected = new double[] { diff --git a/docs/api/sql/Raster-Operators/RS_AsRaster.md b/docs/api/sql/Raster-Operators/RS_AsRaster.md index 996d0c9fb19..2b001936d45 100644 --- a/docs/api/sql/Raster-Operators/RS_AsRaster.md +++ b/docs/api/sql/Raster-Operators/RS_AsRaster.md @@ -26,7 +26,7 @@ Introduction: `RS_AsRaster` converts a vector geometry into a raster dataset by * `pixelType`: Defines data type of the output raster. This can be one of the following, D (double), F (float), I (integer), S (short), US (unsigned short) or B (byte). * `allTouched` (Since: `v1.7.1`): Decides the pixel selection criteria. If set to `true`, the function selects all pixels touched by the geometry, else, selects only pixels whose centroids intersect the geometry. Defaults to `false`. * `value`: The value to be used for assigning pixels covered by the geometry. Defaults to using `1.0` if not provided. -* `noDataValue`: Used for assigning the no data value of the resultant raster; pixels not covered by the geometry are set to this value. Defaults to `null` if not provided, in which case uncovered pixels are `0` and the raster has no no data value. +* `noDataValue`: The no data value of the resultant raster. Every pixel the geometry does not cover (including interior holes) is filled with the resolved no data value, which is also recorded as the output band's no data metadata. The value must be representable in `pixelType`: an out-of-range value, or a fractional value for an integer `pixelType`, is rejected with an error rather than silently coerced. When `noDataValue` is not provided, it is inherited from the reference `raster`'s first band, and `RS_AsRaster` errors if that band has no no data value. The programmatic API additionally accepts an explicit `null` noDataValue, which produces a raster with no no data value and a `0` background. * `useGeometryExtent`: Defines the extent of the resultant raster. When set to `true`, it corresponds to the extent of `geom`, and when set to false, it corresponds to the extent of `raster`. Default value is `true` if not set. Format: @@ -84,10 +84,12 @@ GridCoverage2D["g... SQL Example +With the `noDataValue` omitted, the output inherits the reference band's no data value (here `0`, set on the empty reference raster) and fills uncovered pixels with it: + ```sql SELECT RS_AsRaster( ST_GeomFromWKT('POLYGON((15 15, 18 20, 15 24, 24 25, 15 15))'), - RS_MakeEmptyRaster(2, 255, 255, 3, -215, 2, -2, 0, 0, 4326), + RS_SetBandNoDataValue(RS_MakeEmptyRaster(2, 255, 255, 3, -215, 2, -2, 0, 0, 4326), 1, 0), 'D' ) ``` diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/rasterIOTest.scala b/spark/common/src/test/scala/org/apache/sedona/sql/rasterIOTest.scala index e0128ce3f19..b47e37ed32f 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/rasterIOTest.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/rasterIOTest.scala @@ -100,6 +100,10 @@ class rasterIOTest extends TestBaseScala with BeforeAndAfter with GivenWhenThen } it("Passed RS_AsRaster with empty raster") { + // This reference raster carries no nodata value. Omitting the noDataValue now inherits the + // reference band's nodata value (erroring when there is none), so the cases that expect a + // zero background pass an explicit noDataValue of 0 instead of relying on the old omitted + // default of no nodata value. val df = sparkSession.sql( "SELECT RS_MakeEmptyRaster(2, 255, 255, 3, 215, 2, -2, 0, 0, 4326) as raster, ST_GeomFromWKT('POLYGON((15 15, 18 20, 15 24, 24 25, 15 15))') as geom") var rasterized = @@ -113,7 +117,8 @@ class rasterIOTest extends TestBaseScala with BeforeAndAfter with GivenWhenThen "Array(255.0, 255.0, 255.0, 255.0, 0.0, 0.0, 255.0, 255.0, 0.0, 0.0, 0.0, 255.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)" assertEquals(expected, actual) - rasterized = df.selectExpr("RS_AsRaster(geom, raster, 'd', false, 3093151) as rasterized") + rasterized = + df.selectExpr("RS_AsRaster(geom, raster, 'd', false, 3093151, 0d) as rasterized") actual = rasterized .selectExpr("RS_BandAsArray(rasterized, 1)") .first() @@ -123,7 +128,7 @@ class rasterIOTest extends TestBaseScala with BeforeAndAfter with GivenWhenThen "Array(3093151.0, 3093151.0, 3093151.0, 3093151.0, 0.0, 0.0, 3093151.0, 3093151.0, 0.0, 0.0, 0.0, 3093151.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)" assertEquals(expected, actual) - rasterized = df.selectExpr("RS_AsRaster(geom, raster, 'd') as rasterized") + rasterized = df.selectExpr("RS_AsRaster(geom, raster, 'd', false, 1, 0d) as rasterized") actual = rasterized .selectExpr("RS_BandAsArray(rasterized, 1)") .first() @@ -134,6 +139,28 @@ class rasterIOTest extends TestBaseScala with BeforeAndAfter with GivenWhenThen assertEquals(expected, actual) } + it("Passed RS_AsRaster inherits reference band noData when noDataValue omitted") { + // Omitting the noDataValue makes RS_AsRaster inherit the reference band's nodata value, using + // it both as the output band's nodata metadata and as the fill for every pixel the geometry + // does not cover. This reference carries a nodata value of 2, so every uncovered pixel reads + // back as 2 while the covered pixels burn the default value of 1. + val df = sparkSession.sql( + "SELECT RS_SetBandNoDataValue(RS_MakeEmptyRaster(2, 255, 255, 3, 215, 2, -2, 0, 0, 4326), 1, 2) as raster, ST_GeomFromWKT('POLYGON((15 15, 18 20, 15 24, 24 25, 15 15))') as geom") + val rasterized = df.selectExpr("RS_AsRaster(geom, raster, 'd') as rasterized") + val actual = rasterized + .selectExpr("RS_BandAsArray(rasterized, 1)") + .first() + .getSeq(0) + .mkString("Array(", ", ", ")") + val expected = + "Array(1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 1.0, 2.0, 2.0, 2.0, 1.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0)" + assertEquals(expected, actual) + + val noData = + rasterized.selectExpr("RS_BandNoDataValue(rasterized, 1)").first().getDouble(0) + assertEquals(2.0, noData, 1e-9) + } + it("Passed RS_AsRaster LineString") { val df = sparkSession.sql( "SELECT RS_MakeEmptyRaster(2, 255, 255, 3, 215, 2, -2, 0, 0, 4326) as raster, ST_GeomFromWKT('LINESTRING(1 1, 2 1, 10 1)') as geom") @@ -159,6 +186,9 @@ class rasterIOTest extends TestBaseScala with BeforeAndAfter with GivenWhenThen } it("Passed RS_AsRaster with raster") { + // test1.tiff carries no nodata value, so the cases below that expect a zero background pass + // an explicit noDataValue of 0; omitting it would now inherit the reference band's nodata + // value and error because this band has none. var df = sparkSession.read.format("binaryFile").load(resourceFolder + "raster/test1.tiff") df = df.selectExpr( "ST_GeomFromText('POINT (-13085817.809482181 3993868.8560156375)', 3857) as geom", @@ -173,7 +203,7 @@ class rasterIOTest extends TestBaseScala with BeforeAndAfter with GivenWhenThen var expected = "Array(61784.0)" assertEquals(expected, actual) - rasterized = df.selectExpr("RS_AsRaster(geom, raster, 'd', false, 255) as rasterized") + rasterized = df.selectExpr("RS_AsRaster(geom, raster, 'd', false, 255, 0d) as rasterized") actual = rasterized .selectExpr("RS_BandAsArray(rasterized, 1)") .first() @@ -182,7 +212,7 @@ class rasterIOTest extends TestBaseScala with BeforeAndAfter with GivenWhenThen expected = "Array(255.0)" assertEquals(expected, actual) - rasterized = df.selectExpr("RS_AsRaster(geom, raster, 'd') as rasterized") + rasterized = df.selectExpr("RS_AsRaster(geom, raster, 'd', false, 1, 0d) as rasterized") actual = rasterized .selectExpr("RS_BandAsArray(rasterized, 1)") .first() From 727fcd27fc7299ce4852ba07c86f417b099f7c75 Mon Sep 17 00:00:00 2001 From: jameswillis Date: Fri, 24 Jul 2026 10:25:37 -0700 Subject: [PATCH 06/10] [GH-3112] Fill -0.0 background so nodata pixel and metadata agree fillBackground skipped the fill when the background compared equal to 0, which is true for both +0.0 and -0.0. A -0.0 noDataValue therefore left uncovered pixels at the zero-initialized +0.0 while the band's nodata metadata was -0.0. RS_Count(band, excludeNoData=true) compares pixels to the metadata with Double.compare, which distinguishes the two zeros, so the uncovered pixels were miscounted as data. Skip the fill only for +0.0 (Double.compare(x, 0.0) == 0), so -0.0 is explicitly filled and pixel value and nodata metadata agree. --- .../sedona/common/raster/Rasterization.java | 14 ++++++-- .../common/raster/RasterConstructorsTest.java | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/raster/Rasterization.java b/common/src/main/java/org/apache/sedona/common/raster/Rasterization.java index 9b9bcffc5b6..52eaec6b126 100644 --- a/common/src/main/java/org/apache/sedona/common/raster/Rasterization.java +++ b/common/src/main/java/org/apache/sedona/common/raster/Rasterization.java @@ -102,11 +102,19 @@ protected static List rasterize( /** * Fills every pixel of the freshly-allocated raster with the background value, so pixels never - * covered by the geometry read back as that value instead of the allocation default of 0. A null - * or zero background keeps the zero-initialized allocation as-is. + * covered by the geometry read back as that value instead of the allocation default of 0. + * + *

A null background, or a background of positive zero, keeps the zero-initialized allocation + * as-is. Negative zero, however, must still be filled: it is a legitimate noDataValue that is + * distinct from the allocation's {@code +0.0}. RS_Count (and other nodata-aware readers) compare + * pixels against the band's nodata metadata with {@link Double#compare}, which orders {@code + * -0.0} before {@code +0.0}, so a {@code -0.0} nodata left unfilled would leave the untouched + * pixels as {@code +0.0} and be miscounted as data. Filling makes the pixel value and the nodata + * metadata agree on the sign of zero. */ private static void fillBackground(WritableRaster writableRaster, Double backgroundValue) { - if (backgroundValue == null || backgroundValue == 0) { + // Double.compare(x, 0.0) == 0 is true only for +0.0; -0.0 compares as -1 and is filled. + if (backgroundValue == null || Double.compare(backgroundValue, 0.0) == 0) { return; } int width = writableRaster.getWidth(); diff --git a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java index 33c2df62ac7..6d20c7d41d8 100644 --- a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java +++ b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java @@ -468,6 +468,40 @@ public void testAsRasterBackgroundIsNoDataValue() throws FactoryException, Parse assertArrayEquals(expected, actual, 0.1d); } + @Test + public void testAsRasterNegativeZeroNoDataValueIsFilled() + throws FactoryException, ParseException { + // -0.0 is a legitimate noDataValue distinct from the allocation's +0.0. A 3x3 grid fully + // covered except for the centre pixel (a one-pixel hole) must burn 8 pixels and leave the + // centre as the -0.0 background. RS_Count(band, excludeNoData=true) compares each pixel to + // the nodata metadata with Double.compare, which distinguishes +0.0 from -0.0: if the hole + // were left as the zero-initialized +0.0 it would be miscounted as data (9 instead of 8). + GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, "D", 3, 3, 0, 3, 1, -1, 0, 0, 0); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((0 0, 3 0, 3 3, 0 3, 0 0), (1 1, 2 1, 2 2, 1 2, 1 1))", 0); + + GridCoverage2D rasterized = + RasterConstructors.asRaster(geom, raster, "D", false, 1d, -0.0d, false); + double[] actual = MapAlgebra.bandAsArray(rasterized, 1); + double[] expected = + new double[] { + 1, 1, 1, + 1, -0.0, 1, + 1, 1, 1 + }; + assertArrayEquals(expected, actual, 0.0d); + + // The hole pixel must be exactly -0.0 (not +0.0), matching the band's nodata metadata. + // Double.compare is sign-of-zero sensitive; assertArrayEquals with a delta is not. + assertEquals(0, Double.compare(-0.0d, actual[4])); + assertEquals( + 0, Double.compare(-0.0d, RasterUtils.getNoDataValue(rasterized.getSampleDimension(0)))); + + // 8 data pixels, not 9: the -0.0 hole is excluded as nodata. + assertEquals(8L, RasterBandAccessors.getCount(rasterized, 1, true)); + } + @Test public void testAsRasterRejectsNonRepresentableNoDataValue() throws FactoryException, ParseException { From bab1b2196280a61a2e55f3ad7954eab4e1ecea77 Mon Sep 17 00:00:00 2001 From: jameswillis Date: Fri, 24 Jul 2026 10:31:22 -0700 Subject: [PATCH 07/10] [GH-3112] Reject float noDataValue not exactly representable in the band's float32 storage A nodata value is a sentinel that must round-trip exactly through the band's storage. On a 32-bit float band, assertNoDataValueRepresentable previously rejected only magnitudes that overflowed Float.MAX_VALUE, accepting values like 0.1 that are not exactly representable in float32 (stored as 0.10000000149...). The recorded sentinel then differed from the caller's value, so comparisons against it would miss, and 0.1 in particular crashed deep in GeoTools. Reject any finite value that does not survive the double->float->double round-trip. Overflow to +/-Infinity is caught by the same test; NaN and the infinities are exactly representable and remain allowed. --- .../sedona/common/utils/RasterUtils.java | 14 ++++++---- .../common/raster/RasterConstructorsTest.java | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java b/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java index 48441820593..173fe5d639c 100644 --- a/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java +++ b/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java @@ -690,14 +690,16 @@ public static double assertNoDataValueRepresentable(double noDataValue, String p description = "signed 32-bit"; break; case DataBuffer.TYPE_FLOAT: - // 32-bit float represents NaN and the infinities; reject only a finite value whose - // magnitude overflows the float range (it would silently become an infinity). Rounding a - // value to the nearest float is the inherent, expected behavior of a float band. - if (Double.isFinite(noDataValue) && Math.abs(noDataValue) > Float.MAX_VALUE) { + // A nodata value is a sentinel, so it must round-trip exactly through the band's + // 32-bit float storage — otherwise the stored sentinel differs from the value the + // caller set (e.g. 0.1 would be stored as 0.10000000149...), and comparisons against + // the caller's value would miss it. Overflow to +/-Infinity is caught by the same + // test. NaN and +/-Infinity are exactly representable and allowed. + if (Double.isFinite(noDataValue) && (double) (float) noDataValue != noDataValue) { throw new IllegalArgumentException( String.format( - "noDataValue %s is not representable in pixel type '%s' (32-bit float, valid range %s..%s)", - noDataValue, pixelType, -Float.MAX_VALUE, Float.MAX_VALUE)); + "noDataValue %s is not exactly representable in pixel type '%s' (32-bit float)", + noDataValue, pixelType)); } return noDataValue; case DataBuffer.TYPE_DOUBLE: diff --git a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java index 6d20c7d41d8..5c9d88ca51d 100644 --- a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java +++ b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java @@ -537,6 +537,34 @@ public void testAsRasterRejectsNonRepresentableNoDataValue() Assert.assertTrue(fractional.getMessage(), fractional.getMessage().contains("'I'")); } + @Test + public void testAsRasterRejectsFloatNoDataValueNotExactlyRepresentable() + throws FactoryException, ParseException { + // A nodata sentinel must round-trip exactly through a 32-bit float band. 0.1 has no exact + // float32 representation (it would store as 0.10000000149...), so RS_AsRaster on an 'F' band + // rejects it up front rather than recording a sentinel no pixel could equal. Previously this + // value crashed deep in GeoTools. Values that ARE exactly representable in float32 -- and NaN + // -- must still construct without throwing. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5))", 0); + + IllegalArgumentException inexact = + Assert.assertThrows( + IllegalArgumentException.class, + () -> RasterConstructors.asRaster(geom, raster, "F", false, 1d, 0.1d, false)); + Assert.assertTrue(inexact.getMessage(), inexact.getMessage().contains("0.1")); + Assert.assertTrue(inexact.getMessage(), inexact.getMessage().contains("'F'")); + + // Exactly representable in float32 (0.5 = 2^-1, -9999 and 2^24 are integers below 2^24) plus + // NaN construct without throwing. + for (double exact : new double[] {0.5d, -9999.0d, 16777216.0d, Double.NaN}) { + Assert.assertNotNull(RasterConstructors.asRaster(geom, raster, "F", false, 1d, exact, false)); + } + } + @Test public void testAsRasterRepresentableNoDataValueOnIntegerBand() throws FactoryException, ParseException { From 96480c98950386944fce79dfd2df1beb23297142 Mon Sep 17 00:00:00 2001 From: jameswillis Date: Mon, 27 Jul 2026 01:51:52 -0700 Subject: [PATCH 08/10] [GH-3112] Note float32 non-exact noDataValue rejection in RS_AsRaster docs --- docs/api/sql/Raster-Operators/RS_AsRaster.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/sql/Raster-Operators/RS_AsRaster.md b/docs/api/sql/Raster-Operators/RS_AsRaster.md index 2b001936d45..eeadbdf92c2 100644 --- a/docs/api/sql/Raster-Operators/RS_AsRaster.md +++ b/docs/api/sql/Raster-Operators/RS_AsRaster.md @@ -26,7 +26,7 @@ Introduction: `RS_AsRaster` converts a vector geometry into a raster dataset by * `pixelType`: Defines data type of the output raster. This can be one of the following, D (double), F (float), I (integer), S (short), US (unsigned short) or B (byte). * `allTouched` (Since: `v1.7.1`): Decides the pixel selection criteria. If set to `true`, the function selects all pixels touched by the geometry, else, selects only pixels whose centroids intersect the geometry. Defaults to `false`. * `value`: The value to be used for assigning pixels covered by the geometry. Defaults to using `1.0` if not provided. -* `noDataValue`: The no data value of the resultant raster. Every pixel the geometry does not cover (including interior holes) is filled with the resolved no data value, which is also recorded as the output band's no data metadata. The value must be representable in `pixelType`: an out-of-range value, or a fractional value for an integer `pixelType`, is rejected with an error rather than silently coerced. When `noDataValue` is not provided, it is inherited from the reference `raster`'s first band, and `RS_AsRaster` errors if that band has no no data value. The programmatic API additionally accepts an explicit `null` noDataValue, which produces a raster with no no data value and a `0` background. +* `noDataValue`: The no data value of the resultant raster. Every pixel the geometry does not cover (including interior holes) is filled with the resolved no data value, which is also recorded as the output band's no data metadata. The value must be representable in `pixelType`: an out-of-range or fractional value for an integer `pixelType`, or a value that is not exactly representable in a floating-point `pixelType` (for example `0.1` in a 32-bit float band), is rejected with an error rather than silently coerced. When `noDataValue` is not provided, it is inherited from the reference `raster`'s first band, and `RS_AsRaster` errors if that band has no no data value. The programmatic API additionally accepts an explicit `null` noDataValue, which produces a raster with no no data value and a `0` background. * `useGeometryExtent`: Defines the extent of the resultant raster. When set to `true`, it corresponds to the extent of `geom`, and when set to false, it corresponds to the extent of `raster`. Default value is `true` if not set. Format: From 923a0620f2c37825b67d8e8ffe7851ed644c04ff Mon Sep 17 00:00:00 2001 From: jameswillis Date: Mon, 27 Jul 2026 02:31:07 -0700 Subject: [PATCH 09/10] [GH-3112] Actionable errors for RS_AsRaster nodata/value validation - Inherit path: rethrow an actionable inherit-specific error when the reference band nodata is not representable in the output pixel type, pointing at the explicit-noDataValue override. - Validate the burn value for representability (mirrors noDataValue) and reject a burn value equal to the noDataValue. - Reject non-finite (NaN / +/-Infinity) nodata and value on float/double bands up front instead of silently dropping (NaN) or crashing in GeoTools (Infinity). - Fix nodata-less RS_AsRaster tutorial examples that now error. --- .../common/raster/RasterConstructors.java | 44 ++++++- .../sedona/common/utils/RasterUtils.java | 73 ++++++++--- .../common/raster/RasterConstructorsTest.java | 124 +++++++++++++++++- docs/tutorial/raster.md | 2 +- docs/tutorial/raster.zh.md | 2 +- 5 files changed, 215 insertions(+), 30 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java b/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java index c500ace249e..4a2a5ab03c0 100644 --- a/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java +++ b/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java @@ -131,6 +131,12 @@ public static GridCoverage2D asRaster( boolean useGeometryExtent) throws FactoryException { + // Reject a burn value that cannot be represented in the target pixel type, for the same reason + // the noDataValue is rejected below: silently coercing an out-of-range or fractional value (for + // example 265 -> 9 in an unsigned 8-bit band) would store a different number than the caller + // asked to burn. Unlike noDataValue this is a primitive double, so it is always validated. + RasterUtils.assertRepresentable(value, pixelType, "value"); + // Reject a noDataValue that cannot be represented in the target pixel type rather than // silently coercing it: writing an out-of-range or fractional value into an integer sample // stores a different number than the value recorded as the band's nodata metadata, so the @@ -140,6 +146,17 @@ public static GridCoverage2D asRaster( noDataValue = RasterUtils.assertNoDataValueRepresentable(noDataValue, pixelType); } + // If the burn value equals the noDataValue, every covered pixel reads back as nodata, so the + // geometry would vanish from the output. Reject rather than produce an all-nodata raster; this + // also catches the default burn value of 1 colliding with an inherited nodata of 1.0. + if (noDataValue != null && Double.compare(value, noDataValue) == 0) { + throw new IllegalArgumentException( + String.format( + "RS_AsRaster: burn value (%s) equals the noDataValue, so every covered pixel would " + + "read as nodata; use a different value or noDataValue", + value)); + } + List objects = Rasterization.rasterize( geom, raster, pixelType, value, useGeometryExtent, allTouched, noDataValue); @@ -200,7 +217,8 @@ public static GridCoverage2D asRaster( */ public static GridCoverage2D asRaster(Geometry geom, GridCoverage2D raster, String pixelType) throws FactoryException { - return asRaster(geom, raster, pixelType, false, 1, inheritReferenceNoDataValue(raster)); + return asRaster( + geom, raster, pixelType, false, 1, inheritReferenceNoDataValue(raster, pixelType)); } /** @@ -218,7 +236,8 @@ public static GridCoverage2D asRaster(Geometry geom, GridCoverage2D raster, Stri public static GridCoverage2D asRaster( Geometry geom, GridCoverage2D raster, String pixelType, boolean allTouched) throws FactoryException { - return asRaster(geom, raster, pixelType, allTouched, 1, inheritReferenceNoDataValue(raster)); + return asRaster( + geom, raster, pixelType, allTouched, 1, inheritReferenceNoDataValue(raster, pixelType)); } /** @@ -238,7 +257,7 @@ public static GridCoverage2D asRaster( Geometry geom, GridCoverage2D raster, String pixelType, boolean allTouched, double value) throws FactoryException { return asRaster( - geom, raster, pixelType, allTouched, value, inheritReferenceNoDataValue(raster)); + geom, raster, pixelType, allTouched, value, inheritReferenceNoDataValue(raster, pixelType)); } /** @@ -251,16 +270,31 @@ public static GridCoverage2D asRaster( * overload to opt out of a nodata value entirely. * * @param raster the reference raster whose first band nodata value is inherited + * @param pixelType the output pixel type the inherited value must be representable in * @return the reference band's nodata value - * @throws IllegalArgumentException when the reference band has no nodata value to inherit + * @throws IllegalArgumentException when the reference band has no nodata value to inherit, or + * when its nodata value is not representable in {@code pixelType} */ - private static double inheritReferenceNoDataValue(GridCoverage2D raster) { + private static double inheritReferenceNoDataValue(GridCoverage2D raster, String pixelType) { Double referenceNoDataValue = RasterBandAccessors.getBandNoDataValue(raster, 1); if (referenceNoDataValue == null) { throw new IllegalArgumentException( "RS_AsRaster: noDataValue omitted but reference raster band 1 has no nodata value to " + "inherit; pass an explicit noDataValue, or pass NULL for no nodata"); } + // The inherited value must be representable in the output pixel type. The core asRaster would + // reject it too, but with a generic message that neither says the value was inherited nor how + // to override it; rethrow with actionable inherit-specific guidance instead. + try { + RasterUtils.assertNoDataValueRepresentable(referenceNoDataValue, pixelType); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + String.format( + "RS_AsRaster: the reference raster band 1 nodata value (%s) is not representable in " + + "the output pixel type '%s'; pass an explicit noDataValue representable in '%s' " + + "to override it", + referenceNoDataValue, pixelType, pixelType)); + } return referenceNoDataValue; } diff --git a/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java b/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java index 173fe5d639c..b0e68586b0d 100644 --- a/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java +++ b/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java @@ -649,12 +649,9 @@ public static boolean isDataTypeIntegral(int dataTypeCode) { /** * Verifies that {@code noDataValue} can be stored in the pixel type named by {@code pixelType} - * without silent coercion, returning it unchanged when it can. Writing a value that is out of the - * pixel type's range, or fractional for an integer pixel type, coerces the stored sample to a - * different number than the value recorded as the band's nodata metadata (for example -1.0 or - * 300.0 wraps to 255 or 44 in an unsigned 8-bit band), so the background would then read back as - * data rather than nodata. Such a value is a correctness violation, not a per-row data condition, - * so this rejects it with an {@link IllegalArgumentException} instead of coercing. + * without silent coercion, returning it unchanged when it can. Delegates to {@link + * #assertRepresentable(double, String, String)} with the argument name {@code "noDataValue"} so + * the nodata path keeps its existing error messages. * * @param noDataValue the candidate nodata / background value * @param pixelType a Sedona pixel type string accepted by {@link #getDataTypeCode(String)} (for @@ -664,6 +661,29 @@ public static boolean isDataTypeIntegral(int dataTypeCode) { * type */ public static double assertNoDataValueRepresentable(double noDataValue, String pixelType) { + return assertRepresentable(noDataValue, pixelType, "noDataValue"); + } + + /** + * Verifies that {@code value} (a nodata / background value, or a burn value) can be stored in the + * pixel type named by {@code pixelType} without silent coercion, returning it unchanged when it + * can. Writing a value that is out of the pixel type's range, or fractional for an integer pixel + * type, coerces the stored sample to a different number than the value the caller asked for (for + * example -1.0 or 300.0 wraps to 255 or 44 in an unsigned 8-bit band), so the sample would read + * back as a different number than requested. For float / double pixel types a value that does not + * round-trip exactly through 32-bit float storage, and any non-finite value (NaN or +/-Infinity), + * are rejected for the same reason. Such a value is a correctness violation, not a per-row data + * condition, so this rejects it with an {@link IllegalArgumentException} instead of coercing. + * + * @param value the candidate value + * @param pixelType a Sedona pixel type string accepted by {@link #getDataTypeCode(String)} (for + * example {@code "B"}, {@code "I"}, {@code "D"}) + * @param argName the name of the argument being validated (for example {@code "noDataValue"} or + * {@code "value"}), used in the exception message + * @return {@code value}, unchanged, when it is representable in the pixel type + * @throws IllegalArgumentException when {@code value} cannot be represented in the pixel type + */ + public static double assertRepresentable(double value, String pixelType, String argName) { int dataTypeCode = getDataTypeCode(pixelType); long min; long max; @@ -690,32 +710,49 @@ public static double assertNoDataValueRepresentable(double noDataValue, String p description = "signed 32-bit"; break; case DataBuffer.TYPE_FLOAT: + // NaN and infinite values cannot be used on a float/double band: NaN is the codebase's + // internal "no nodata" sentinel, so it cannot be stored as a distinct nodata value and + // would be silently dropped; an infinite value is not a valid GeoTools category bound and + // crashes deep in GeoTools with "Range [Infinity .. Infinity] is not valid". Reject both + // up front with a clear error (fail loud) rather than dropping them or crashing later. + if (!Double.isFinite(value)) { + throw new IllegalArgumentException( + String.format( + "%s %s is not supported for pixel type '%s' (NaN and infinite values are not supported); use a finite value", + argName, value, pixelType)); + } // A nodata value is a sentinel, so it must round-trip exactly through the band's // 32-bit float storage — otherwise the stored sentinel differs from the value the // caller set (e.g. 0.1 would be stored as 0.10000000149...), and comparisons against - // the caller's value would miss it. Overflow to +/-Infinity is caught by the same - // test. NaN and +/-Infinity are exactly representable and allowed. - if (Double.isFinite(noDataValue) && (double) (float) noDataValue != noDataValue) { + // the caller's value would miss it. + if ((double) (float) value != value) { throw new IllegalArgumentException( String.format( - "noDataValue %s is not exactly representable in pixel type '%s' (32-bit float)", - noDataValue, pixelType)); + "%s %s is not exactly representable in pixel type '%s' (32-bit float)", + argName, value, pixelType)); } - return noDataValue; + return value; case DataBuffer.TYPE_DOUBLE: default: - // 64-bit float represents every double value; nothing to reject. - return noDataValue; + // 64-bit float represents every finite double value; only NaN and infinities are rejected + // (see the float arm for why). + if (!Double.isFinite(value)) { + throw new IllegalArgumentException( + String.format( + "%s %s is not supported for pixel type '%s' (NaN and infinite values are not supported); use a finite value", + argName, value, pixelType)); + } + return value; } // Integer pixel types: reject out-of-range and fractional values (Math.rint also rejects NaN). - if (noDataValue < min || noDataValue > max || noDataValue != Math.rint(noDataValue)) { + if (value < min || value > max || value != Math.rint(value)) { throw new IllegalArgumentException( String.format( - "noDataValue %s is not representable in pixel type '%s' (%s integer, valid range %d..%d)", - noDataValue, pixelType, description, min, max)); + "%s %s is not representable in pixel type '%s' (%s integer, valid range %d..%d)", + argName, value, pixelType, description, min, max)); } - return noDataValue; + return value; } /** diff --git a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java index 5c9d88ca51d..30e3f122eb9 100644 --- a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java +++ b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java @@ -543,8 +543,8 @@ public void testAsRasterRejectsFloatNoDataValueNotExactlyRepresentable() // A nodata sentinel must round-trip exactly through a 32-bit float band. 0.1 has no exact // float32 representation (it would store as 0.10000000149...), so RS_AsRaster on an 'F' band // rejects it up front rather than recording a sentinel no pixel could equal. Previously this - // value crashed deep in GeoTools. Values that ARE exactly representable in float32 -- and NaN - // -- must still construct without throwing. + // value crashed deep in GeoTools. Values that ARE exactly representable in float32 must still + // construct without throwing. GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); Geometry geom = @@ -558,13 +558,43 @@ public void testAsRasterRejectsFloatNoDataValueNotExactlyRepresentable() Assert.assertTrue(inexact.getMessage(), inexact.getMessage().contains("0.1")); Assert.assertTrue(inexact.getMessage(), inexact.getMessage().contains("'F'")); - // Exactly representable in float32 (0.5 = 2^-1, -9999 and 2^24 are integers below 2^24) plus - // NaN construct without throwing. - for (double exact : new double[] {0.5d, -9999.0d, 16777216.0d, Double.NaN}) { + // Exactly representable in float32 (0.5 = 2^-1, -9999 and 2^24 are integers below 2^24) + // construct without throwing. + for (double exact : new double[] {0.5d, -9999.0d, 16777216.0d}) { Assert.assertNotNull(RasterConstructors.asRaster(geom, raster, "F", false, 1d, exact, false)); } } + @Test + public void testAsRasterRejectsNonFiniteNoDataValue() throws FactoryException, ParseException { + // NaN and +/-Infinity are rejected as a nodata value on float ('F') and double ('D') bands. + // NaN is the codebase's internal "no nodata" sentinel, so it would be silently dropped rather + // than recorded (leaving the NaN-filled background reading back as data); an infinite value is + // not a valid GeoTools category bound and previously crashed deep in GeoTools with + // "Range [Infinity .. Infinity] is not valid". Both are now rejected up front with a clear + // error that names the offending value and pixel type. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5))", 0); + + for (String pixelType : new String[] {"F", "D"}) { + for (double nonFinite : + new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + IllegalArgumentException error = + Assert.assertThrows( + IllegalArgumentException.class, + () -> + RasterConstructors.asRaster( + geom, raster, pixelType, false, 1d, nonFinite, false)); + Assert.assertTrue( + error.getMessage(), error.getMessage().contains(String.valueOf(nonFinite))); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("'" + pixelType + "'")); + } + } + } + @Test public void testAsRasterRepresentableNoDataValueOnIntegerBand() throws FactoryException, ParseException { @@ -595,6 +625,64 @@ public void testAsRasterRepresentableNoDataValueOnIntegerBand() assertEquals(9d, RasterUtils.getNoDataValue(rasterized.getSampleDimension(0)), 0.0d); } + @Test + public void testAsRasterRejectsNonRepresentableBurnValue() + throws FactoryException, ParseException { + // The burn value is validated for representability just like the noDataValue: 265 cannot be + // stored in an unsigned 8-bit ('B') band and would silently coerce to 9, burning a different + // number than requested, so RS_AsRaster rejects it up front. A value that fits ('B' with 200) + // still constructs. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5))", 0); + + IllegalArgumentException error = + Assert.assertThrows( + IllegalArgumentException.class, + () -> RasterConstructors.asRaster(geom, raster, "B", false, 265d, 0d, false)); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("value")); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("265")); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("'B'")); + + // A representable value/nodata pair still constructs. + Assert.assertNotNull(RasterConstructors.asRaster(geom, raster, "B", false, 200d, 0d, false)); + } + + @Test + public void testAsRasterRejectsBurnValueEqualToNoDataValue() + throws FactoryException, ParseException { + // If the burn value equals the noDataValue every covered pixel reads back as nodata, so the + // geometry vanishes. Reject that. This holds both when both are passed explicitly (value 1 == + // nodata 1 on a 'D' band) and when the default value of 1 collides with an inherited nodata of + // 1.0. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5))", 0); + + IllegalArgumentException explicit = + Assert.assertThrows( + IllegalArgumentException.class, + () -> RasterConstructors.asRaster(geom, raster, "D", false, 1d, 1d, false)); + Assert.assertTrue(explicit.getMessage(), explicit.getMessage().contains("burn value")); + Assert.assertTrue(explicit.getMessage(), explicit.getMessage().contains("noDataValue")); + + // The default-value-1 x inherited-nodata-1.0 footgun: the reference band's nodata is 1.0 and + // the noDataValue-less overload burns the default value 1, so they collide and are rejected. + GridCoverage2D rasterWithNoData = RasterBandEditors.setBandNoDataValue(raster, 1, 1d); + IllegalArgumentException inherited = + Assert.assertThrows( + IllegalArgumentException.class, + () -> RasterConstructors.asRaster(geom, rasterWithNoData, "d")); + Assert.assertTrue(inherited.getMessage(), inherited.getMessage().contains("burn value")); + + // A distinct value/nodata pair still constructs. + Assert.assertNotNull(RasterConstructors.asRaster(geom, raster, "D", false, 2d, 1d, false)); + } + @Test public void testAsRasterOmittedNoDataValueInheritsReferenceBandNoData() throws FactoryException, ParseException { @@ -647,6 +735,32 @@ public void testAsRasterOmittedNoDataValueErrorsWithoutReferenceNoData() Assert.assertTrue(error.getMessage(), error.getMessage().contains("band 1")); } + @Test + public void testAsRasterInheritedNoDataValueNotRepresentableErrors() + throws FactoryException, ParseException { + // When the noDataValue is omitted, the reference band's nodata is inherited -- but if that + // inherited value cannot be stored in the OUTPUT pixel type (here -9999.0 into an unsigned + // 8-bit 'B' band) the call is rejected. The error must say the value was inherited from the + // reference band and point the user at the explicit-noDataValue override (which works from + // SQL), rather than the generic representability message the core check would give. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + raster = RasterBandEditors.setBandNoDataValue(raster, 1, -9999d); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5))", 0); + + GridCoverage2D finalRaster = raster; + IllegalArgumentException error = + Assert.assertThrows( + IllegalArgumentException.class, + () -> RasterConstructors.asRaster(geom, finalRaster, "B")); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("reference")); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("-9999")); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("'B'")); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("override")); + } + @Test public void testAsRasterExplicitNullNoDataValueLeavesNoNoData() throws FactoryException, ParseException { diff --git a/docs/tutorial/raster.md b/docs/tutorial/raster.md index dcd3a155428..544ab1128e7 100644 --- a/docs/tutorial/raster.md +++ b/docs/tutorial/raster.md @@ -576,7 +576,7 @@ FROM ndvi_after a JOIN ndvi_before b ON a.x = b.x AND a.y = b.y SELECT RS_AsRaster( ST_GeomFromWKT('POLYGON((150 150, 220 260, 190 300, 300 220, 150 150))'), RS_MakeEmptyRaster(1, 'b', 4, 6, 1, -1, 1), - 'b', 230 + 'b', false, 230, 0 ) ``` diff --git a/docs/tutorial/raster.zh.md b/docs/tutorial/raster.zh.md index 12b409ac217..6b348a73987 100644 --- a/docs/tutorial/raster.zh.md +++ b/docs/tutorial/raster.zh.md @@ -543,7 +543,7 @@ FROM ndvi_after a JOIN ndvi_before b ON a.x = b.x AND a.y = b.y SELECT RS_AsRaster( ST_GeomFromWKT('POLYGON((150 150, 220 260, 190 300, 300 220, 150 150))'), RS_MakeEmptyRaster(1, 'b', 4, 6, 1, -1, 1), - 'b', 230 + 'b', false, 230, 0 ) ``` From 1e1f2df179c0de5fa387e0a7be212beab01c5b82 Mon Sep 17 00:00:00 2001 From: jameswillis Date: Mon, 27 Jul 2026 21:47:06 -0700 Subject: [PATCH 10/10] Split burn-value validation from the nodata sentinel check A burn value is ordinary pixel data, not a nodata sentinel, so validate it with RasterUtils.assertBurnValueRepresentable: a fractional value on a float/double band (e.g. 0.1 on 'F') is accepted and rounded to the pixel type, as PostGIS ST_AsRaster and gdal_rasterize do, while an out-of-range or fractional integer burn is still rejected. The noDataValue keeps the strict exact-storage check. Also reject negative zero as a noDataValue on integer pixel types: an integer sample cannot preserve the sign, so a -0.0 sentinel reads back as +0 and would be counted as data (it previously slipped past the range/fractional check since -0.0 compares equal to +0.0). It remains valid on float/double bands. --- .../common/raster/RasterConstructors.java | 11 ++-- .../sedona/common/utils/RasterUtils.java | 66 +++++++++++++++---- .../common/raster/RasterConstructorsTest.java | 55 ++++++++++++++-- 3 files changed, 112 insertions(+), 20 deletions(-) diff --git a/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java b/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java index 4a2a5ab03c0..a4756f3c96b 100644 --- a/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java +++ b/common/src/main/java/org/apache/sedona/common/raster/RasterConstructors.java @@ -131,11 +131,12 @@ public static GridCoverage2D asRaster( boolean useGeometryExtent) throws FactoryException { - // Reject a burn value that cannot be represented in the target pixel type, for the same reason - // the noDataValue is rejected below: silently coercing an out-of-range or fractional value (for - // example 265 -> 9 in an unsigned 8-bit band) would store a different number than the caller - // asked to burn. Unlike noDataValue this is a primitive double, so it is always validated. - RasterUtils.assertRepresentable(value, pixelType, "value"); + // Reject a burn value that cannot be stored as a whole number in an integer pixel type (for + // example 265 -> 9 in an unsigned 8-bit band), which would burn a different number than the + // caller asked for. Unlike the noDataValue this is ordinary pixel data, not a sentinel, so a + // fractional value on a float / double band is accepted and rounded to the pixel type (matching + // PostGIS ST_AsRaster and gdal_rasterize) rather than rejected. + RasterUtils.assertBurnValueRepresentable(value, pixelType); // Reject a noDataValue that cannot be represented in the target pixel type rather than // silently coercing it: writing an out-of-range or fractional value into an integer sample diff --git a/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java b/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java index b0e68586b0d..bce5ff14931 100644 --- a/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java +++ b/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java @@ -661,7 +661,27 @@ public static boolean isDataTypeIntegral(int dataTypeCode) { * type */ public static double assertNoDataValueRepresentable(double noDataValue, String pixelType) { - return assertRepresentable(noDataValue, pixelType, "noDataValue"); + // A nodata value is a sentinel: it must be stored without any coercion (an exact 32-bit float + // round-trip, and no negative-zero collapse on integer bands) so the stored background always + // matches the recorded nodata metadata. + return assertRepresentable(noDataValue, pixelType, "noDataValue", true); + } + + /** + * Verifies that a burn value can be stored in {@code pixelType}. Unlike a nodata sentinel, a burn + * value is ordinary pixel data and does not have to round-trip exactly: a fractional value on a + * float / double band (for example {@code 0.1} on {@code "F"}, stored as {@code 0.10000000149…}) + * is accepted and rounded to the pixel type, matching PostGIS {@code ST_AsRaster} and {@code + * gdal_rasterize}. An out-of-range or fractional value on an integer band is still rejected, + * since that would burn a different whole number than the caller asked for. + * + * @param value the candidate burn value + * @param pixelType a Sedona pixel type string accepted by {@link #getDataTypeCode(String)} + * @return {@code value}, unchanged, when it is storable in the pixel type + * @throws IllegalArgumentException when {@code value} cannot be stored in the pixel type + */ + public static double assertBurnValueRepresentable(double value, String pixelType) { + return assertRepresentable(value, pixelType, "value", false); } /** @@ -670,20 +690,26 @@ public static double assertNoDataValueRepresentable(double noDataValue, String p * can. Writing a value that is out of the pixel type's range, or fractional for an integer pixel * type, coerces the stored sample to a different number than the value the caller asked for (for * example -1.0 or 300.0 wraps to 255 or 44 in an unsigned 8-bit band), so the sample would read - * back as a different number than requested. For float / double pixel types a value that does not - * round-trip exactly through 32-bit float storage, and any non-finite value (NaN or +/-Infinity), - * are rejected for the same reason. Such a value is a correctness violation, not a per-row data - * condition, so this rejects it with an {@link IllegalArgumentException} instead of coercing. + * back as a different number than requested. Any non-finite value (NaN or +/-Infinity) is always + * rejected. When {@code requireExactStorage} is true the value is treated as a sentinel that must + * round-trip byte-for-byte, so a float / double value that does not survive 32-bit float storage, + * and negative zero on an integer band (whose sign an integer sample cannot preserve), are also + * rejected; burn values pass false, since rounding to the pixel type is expected of ordinary + * pixel data. Such a violation is a correctness error, not a per-row data condition, so this + * rejects it with an {@link IllegalArgumentException} instead of coercing. * * @param value the candidate value * @param pixelType a Sedona pixel type string accepted by {@link #getDataTypeCode(String)} (for * example {@code "B"}, {@code "I"}, {@code "D"}) * @param argName the name of the argument being validated (for example {@code "noDataValue"} or * {@code "value"}), used in the exception message + * @param requireExactStorage true to require the value be stored without any coercion (a nodata + * sentinel); false to allow rounding to the pixel type (a burn value) * @return {@code value}, unchanged, when it is representable in the pixel type * @throws IllegalArgumentException when {@code value} cannot be represented in the pixel type */ - public static double assertRepresentable(double value, String pixelType, String argName) { + public static double assertRepresentable( + double value, String pixelType, String argName, boolean requireExactStorage) { int dataTypeCode = getDataTypeCode(pixelType); long min; long max; @@ -721,11 +747,12 @@ public static double assertRepresentable(double value, String pixelType, String "%s %s is not supported for pixel type '%s' (NaN and infinite values are not supported); use a finite value", argName, value, pixelType)); } - // A nodata value is a sentinel, so it must round-trip exactly through the band's - // 32-bit float storage — otherwise the stored sentinel differs from the value the - // caller set (e.g. 0.1 would be stored as 0.10000000149...), and comparisons against - // the caller's value would miss it. - if ((double) (float) value != value) { + // A nodata sentinel must round-trip exactly through the band's 32-bit float storage — + // otherwise the stored sentinel differs from the value the caller set (e.g. 0.1 would be + // stored as 0.10000000149...), and comparisons against the caller's value would miss it. A + // burn value is ordinary data, so it is allowed to round to the pixel type (as PostGIS and + // gdal_rasterize do) and skips this check. + if (requireExactStorage && (double) (float) value != value) { throw new IllegalArgumentException( String.format( "%s %s is not exactly representable in pixel type '%s' (32-bit float)", @@ -745,6 +772,23 @@ public static double assertRepresentable(double value, String pixelType, String return value; } + // A nodata sentinel of negative zero cannot be stored in an integer sample: the sign is lost, + // so the background reads back as +0 while the recorded nodata metadata is -0.0, leaving the + // background counted as data (and B/US fail later constructing the category as "Ranges + // overlap"). + // Ordinary comparisons treat -0.0 as +0.0, so it slips through the range/fractional check + // below; + // reject it explicitly for integer pixel types. +0.0 and float/double -0.0 are unaffected, and + // burn values (requireExactStorage == false) are ordinary data, so -0.0 simply stores as 0. + if (requireExactStorage + && Double.doubleToRawLongBits(value) == Double.doubleToRawLongBits(-0.0d)) { + throw new IllegalArgumentException( + String.format( + "%s %s is not representable in pixel type '%s' (%s integer): negative zero cannot be " + + "stored in an integer sample; use 0", + argName, value, pixelType, description)); + } + // Integer pixel types: reject out-of-range and fractional values (Math.rint also rejects NaN). if (value < min || value > max || value != Math.rint(value)) { throw new IllegalArgumentException( diff --git a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java index 30e3f122eb9..2a246a7bd7c 100644 --- a/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java +++ b/common/src/test/java/org/apache/sedona/common/raster/RasterConstructorsTest.java @@ -628,10 +628,12 @@ public void testAsRasterRepresentableNoDataValueOnIntegerBand() @Test public void testAsRasterRejectsNonRepresentableBurnValue() throws FactoryException, ParseException { - // The burn value is validated for representability just like the noDataValue: 265 cannot be - // stored in an unsigned 8-bit ('B') band and would silently coerce to 9, burning a different - // number than requested, so RS_AsRaster rejects it up front. A value that fits ('B' with 200) - // still constructs. + // An out-of-range burn value on an integer band is rejected: 265 cannot be stored in an + // unsigned 8-bit ('B') band and would silently coerce to 9, burning a different whole number + // than requested, so RS_AsRaster rejects it up front. (Unlike the noDataValue, a fractional + // burn value on a float band is allowed to round — see + // testAsRasterAcceptsFractionalBurnValueOnFloatBand.) A value that fits ('B' with 200) still + // constructs. GridCoverage2D raster = RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); Geometry geom = @@ -683,6 +685,51 @@ public void testAsRasterRejectsBurnValueEqualToNoDataValue() Assert.assertNotNull(RasterConstructors.asRaster(geom, raster, "D", false, 2d, 1d, false)); } + @Test + public void testAsRasterAcceptsFractionalBurnValueOnFloatBand() + throws FactoryException, ParseException { + // A burn value is ordinary pixel data, not a nodata sentinel, so a fractional value that does + // not round-trip exactly through a 32-bit float band (0.1 -> 0.10000000149...) is accepted and + // rounded to the pixel type, matching PostGIS ST_AsRaster and gdal_rasterize — unlike the + // noDataValue, which must be exactly representable. The burned pixels read back as the rounded + // value. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5))", 0); + + GridCoverage2D rasterized = + RasterConstructors.asRaster(geom, raster, "F", false, 0.1d, 0d, false); + double[] actual = MapAlgebra.bandAsArray(rasterized, 1); + // Background is the nodata fill (0.0); the covered pixels are the 32-bit-float rounding of 0.1. + Assert.assertEquals((double) (float) 0.1d, Arrays.stream(actual).max().getAsDouble(), 0.0d); + } + + @Test + public void testAsRasterRejectsNegativeZeroNoDataValueOnIntegerBand() + throws FactoryException, ParseException { + // Negative zero cannot be stored in an integer sample (the sign is lost), so a -0.0 nodata + // sentinel would read back as +0 and be counted as data. It slips past the ordinary + // range/fractional check (-0.0 compares equal to +0.0), so it is rejected explicitly for + // integer pixel types. It remains valid on float / double bands, which preserve the sign. + GridCoverage2D raster = + RasterConstructors.makeEmptyRaster(1, "d", 7, 6, 100, 500, 2, -2, 0, 0, 0); + Geometry geom = + Constructors.geomFromWKT( + "POLYGON ((100.5 499.5, 113.5 499.5, 113.5 488.5, 100.5 488.5, 100.5 499.5))", 0); + + IllegalArgumentException error = + Assert.assertThrows( + IllegalArgumentException.class, + () -> RasterConstructors.asRaster(geom, raster, "B", false, 1d, -0.0d, false)); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("negative zero")); + Assert.assertTrue(error.getMessage(), error.getMessage().contains("'B'")); + + // A double band preserves the sign of zero, so -0.0 is a valid nodata value there. + Assert.assertNotNull(RasterConstructors.asRaster(geom, raster, "D", false, 1d, -0.0d, false)); + } + @Test public void testAsRasterOmittedNoDataValueInheritsReferenceBandNoData() throws FactoryException, ParseException {