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..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 @@ -152,11 +152,14 @@ 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). 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 b12241ab8a4..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 @@ -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 @@ -130,8 +131,36 @@ public static GridCoverage2D asRaster( boolean useGeometryExtent) throws FactoryException { + // 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 + // 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); + } + + // 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); + Rasterization.rasterize( + geom, raster, pixelType, value, useGeometryExtent, allTouched, noDataValue); WritableRaster writableRaster = (WritableRaster) objects.get(0); GridCoverage2D rasterized = (GridCoverage2D) objects.get(1); @@ -178,7 +207,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 @@ -188,12 +218,14 @@ 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, pixelType)); } /** * 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 @@ -205,12 +237,14 @@ 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, pixelType)); } /** * 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 @@ -223,7 +257,46 @@ 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, pixelType)); + } + + /** + * 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 + * @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, or + * when its nodata value is not representable in {@code pixelType} + */ + 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/raster/Rasterization.java b/common/src/main/java/org/apache/sedona/common/raster/Rasterization.java index 5546f532856..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 @@ -46,6 +46,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); @@ -61,6 +73,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 @@ -86,6 +100,31 @@ 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 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) { + // 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(); + 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/main/java/org/apache/sedona/common/utils/RasterUtils.java b/common/src/main/java/org/apache/sedona/common/utils/RasterUtils.java index 6113ee943d5..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 @@ -647,6 +647,158 @@ 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. 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 + * 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) { + // 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); + } + + /** + * 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. 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, boolean requireExactStorage) { + 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: + // 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 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)", + argName, value, pixelType)); + } + return value; + case DataBuffer.TYPE_DOUBLE: + default: + // 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; + } + + // 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( + String.format( + "%s %s is not representable in pixel type '%s' (%s integer, valid range %d..%d)", + argName, value, pixelType, description, min, max)); + } + return value; + } + /** * 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 a26c4f3da82..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 @@ -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); @@ -88,21 +92,21 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio Geometry geom = Constructors.geomFromWKT("POLYGON((15 -15, 18 -20, 15 -24, 24 -25, 15 -15))", 0); - GridCoverage2D rasterized = RasterConstructors.asRaster(geom, raster, "d", true, 3093151, 0d); + GridCoverage2D rasterized = RasterConstructors.asRaster(geom, raster, "d", true, 3093151, 3d); double[] actual = MapAlgebra.bandAsArray(rasterized, 1); double[] expected = new double[] { - 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, 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, 0.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, 3.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, 3.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); - 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, @@ -134,17 +138,17 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio "MULTIPOLYGON ( ((2 -2, 4 -2, 4 -4, 2 -4, 2 -2)), ((4 -4, 6 -4, 6 -6, 5 -7, 4 -6, 4 -4)), ((6 -6, 8 -6, 8 -8, 6 -8, 6 -6)), ((8 -6, 10 -6, 10 -4, 9 -3, 8 -4, 8 -6)) )", 0); - rasterized = RasterConstructors.asRaster(geom, raster, "d", true, 3093151, 0d, true); + rasterized = RasterConstructors.asRaster(geom, raster, "d", true, 3093151, 3d, true); actual = MapAlgebra.bandAsArray(rasterized, 1); expected = new double[] { - 3093151.0, 3093151.0, 0.0, 0.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, - 3093151.0, 0.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, 0.0, 3093151.0, 3093151.0, - 3093151.0, 0.0 + 3093151.0, 3093151.0, 3.0, 3.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, + 3093151.0, 3.0, 3093151.0, 3093151.0, 3093151.0, 3093151.0, 3.0, 3093151.0, 3093151.0, + 3093151.0, 3.0 }; 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( @@ -174,22 +178,22 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio // MultiLineString geom = Constructors.geomFromWKT("MULTILINESTRING ((5 -5, 10 -10), (10 -10, 15 -15, 20 -20))", 0); - rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 3093151, 0d); + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 3093151, 3d); 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); // Test bottom-up raster case - rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 3093151, 0d); + rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 3093151, 3d); // Making sure orientation is preserved assertEquals( @@ -200,17 +204,17 @@ 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); - 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( @@ -247,22 +251,22 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio // LinearRing geom = Constructors.geomFromWKT("LINEARRING (10 -10, 18 -20, 15 -24, 24 -25, 10 -10)", 0); - rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 3093151, 0d); + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 3093151, 3d); actual = MapAlgebra.bandAsArray(rasterized, 1); 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); // Test bottom-up raster case - rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 3093151, 0d); + rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 3093151, 3d); // Making sure orientation is preserved assertEquals( @@ -273,17 +277,17 @@ 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); - 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( @@ -317,32 +321,32 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio // MultiPoints geom = Constructors.geomFromWKT("MULTIPOINT ((5 -5), (10 -10), (15 -15))", 0); - rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 3093151, 0d); + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 3093151, 3d); actual = MapAlgebra.bandAsArray(rasterized, 1); 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); // Test bottom-up raster case - rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 3093151, 0d); + rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 3093151, 3d); 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); - 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[] { @@ -367,7 +371,7 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio // Point geom = Constructors.geomFromWKT("POINT (5 -5)", 0); - rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 3093151, 0d); + rasterized = RasterConstructors.asRaster(geom, raster, "d", false, 3093151, 3d); actual = MapAlgebra.bandAsArray(rasterized, 1); @@ -375,19 +379,463 @@ public void testAsRasterWithEmptyRaster() throws FactoryException, ParseExceptio assertArrayEquals(expected, actual, 0.1d); // Test bottom-up raster case - rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 3093151, 0d); + rasterized = RasterConstructors.asRaster(geom, raster_bottom_up, "d", false, 3093151, 3d); 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); + } + + @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 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 { + // 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 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 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) + // 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 { + // 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 testAsRasterRejectsNonRepresentableBurnValue() + throws FactoryException, ParseException { + // 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 = + 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 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 { + // 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 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 { + // 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 @@ -553,29 +1001,31 @@ public void testAsRasterWithRaster() throws IOException, ParseException, Factory rasterFromGeoTiff(resourceFolder + "raster/raster_with_no_data/test5.tiff"); Geometry geom = Constructors.geomFromWKT("POLYGON((1.5 1.5, 3.8 3.0, 4.5 4.4, 3.4 3.5, 1.5 1.5))", 0); - GridCoverage2D rasterized = RasterConstructors.asRaster(geom, raster, "d", true, 612028, 0d); + GridCoverage2D rasterized = RasterConstructors.asRaster(geom, raster, "d", true, 612028, 5d); double[] actual = Arrays.stream(MapAlgebra.bandAsArray(rasterized, 1)).toArray(); // Matches GDAL/rasterio all_touched=True except where a vertex lands exactly on a grid line: // there the geometry touches a cell only at a corner or edge and Sedona burns it (a superset // consistent with "all pixels touched"), while GDAL omits it. Tracked separately; see the PR. 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, - 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, 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, + 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, 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); - 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 dcecf82ee4b..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`: Used for assigning the no data value of the resultant raster. Defaults to `null` 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 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: @@ -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/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 ) ``` 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()