Skip to content

Commit ed31473

Browse files
committed
feat: raster Python serde and with_bands() support
Add Python-side serialize() for InDbSedonaRaster, enabling Python UDFs to return raster objects directly instead of the lossy .tolist() + RS_MakeRaster workaround. Rasters now round-trip as contiguous bytes preserving native dtypes and all metadata (CRS, nodata, affine, etc.). Add with_bands() to InDbSedonaRaster for replacing pixel data (NumPy array) while preserving spatial metadata. Band count and dtype may differ from the source raster. Add reconcileColorModel() to DeepCopiedRenderedImage (JVM) to fix colorModel/sampleModel mismatches at deserialization when Python UDFs change band count or dtype. Cherry-picked from wherobots/wherobots-compute@e08bde1da08 with vectorized UDF wiring excluded.
1 parent b8eeb95 commit ed31473

9 files changed

Lines changed: 1033 additions & 30 deletions

File tree

common/src/main/java/org/apache/sedona/common/raster/DeepCopiedRenderedImage.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@
2323
import com.esotericsoftware.kryo.io.Input;
2424
import com.esotericsoftware.kryo.io.Output;
2525
import com.esotericsoftware.kryo.serializers.JavaSerializer;
26+
import com.sun.media.imageioimpl.common.BogusColorSpace;
2627
import com.sun.media.jai.rmi.ColorModelState;
2728
import com.sun.media.jai.util.ImageUtil;
2829
import it.geosolutions.jaiext.range.NoDataContainer;
2930
import java.awt.Image;
3031
import java.awt.Point;
3132
import java.awt.Rectangle;
33+
import java.awt.Transparency;
3234
import java.awt.image.ColorModel;
35+
import java.awt.image.ComponentColorModel;
3336
import java.awt.image.DataBuffer;
3437
import java.awt.image.Raster;
3538
import java.awt.image.RenderedImage;
@@ -39,6 +42,7 @@
3942
import java.io.ObjectInputStream;
4043
import java.io.ObjectOutputStream;
4144
import java.io.Serializable;
45+
import java.util.Arrays;
4246
import java.util.Enumeration;
4347
import java.util.Hashtable;
4448
import java.util.Vector;
@@ -384,6 +388,11 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE
384388
// The deserialized rendered image contains only one tile (imageRaster). We need to update
385389
// the sample model and tile properties to reflect this.
386390
this.sampleModel = this.imageRaster.getSampleModel();
391+
392+
// Reconcile colorModel with the actual sampleModel. Python UDFs may change the band count
393+
// or data type, producing a sampleModel that is incompatible with the serialized colorModel.
394+
this.colorModel = reconcileColorModel(this.colorModel, this.sampleModel);
395+
387396
this.tileWidth = this.width;
388397
this.tileHeight = this.height;
389398
this.numXTiles = 1;
@@ -421,6 +430,40 @@ private Hashtable<String, Object> getSerializableProperties() {
421430
return propertyTable;
422431
}
423432

433+
/**
434+
* Reconcile a deserialized ColorModel with the actual SampleModel. When a Python UDF changes the
435+
* band count or data type, the serialized colorModel blob may describe the original raster's
436+
* structure (e.g., 4 bands) while the SampleModel reflects the new structure (e.g., 1 band). This
437+
* mismatch causes {@link javax.media.jai.PlanarImage#setImageLayout} to throw {@link
438+
* IllegalArgumentException} when wrapping this image in a {@link
439+
* javax.media.jai.RenderedImageAdapter}.
440+
*
441+
* <p>This method applies the same self-healing pattern used by {@link MapAlgebra#fetchColorModel}
442+
* and {@link RasterUtils#clone}: check compatibility first, then try {@link
443+
* PlanarImage#createColorModel}, then fall back to {@link BogusColorSpace}.
444+
*
445+
* @param colorModel the deserialized ColorModel (may be incompatible)
446+
* @param sampleModel the actual SampleModel from the deserialized raster data
447+
* @return a compatible ColorModel — either the original if compatible, or a newly constructed one
448+
*/
449+
private static ColorModel reconcileColorModel(ColorModel colorModel, SampleModel sampleModel) {
450+
if (colorModel.isCompatibleSampleModel(sampleModel)) {
451+
return colorModel;
452+
}
453+
// Try PlanarImage's heuristic first (handles common cases like grayscale, RGB)
454+
ColorModel cm = PlanarImage.createColorModel(sampleModel);
455+
if (cm != null) {
456+
return cm;
457+
}
458+
// Fall back to BogusColorSpace — always works for any band count / data type
459+
int numBands = sampleModel.getNumBands();
460+
int dataType = sampleModel.getDataType();
461+
final int[] nBits = new int[numBands];
462+
Arrays.fill(nBits, DataBuffer.getDataTypeSize(dataType));
463+
return new ComponentColorModel(
464+
new BogusColorSpace(numBands), nBits, false, true, Transparency.OPAQUE, dataType);
465+
}
466+
424467
public static void registerKryo(Kryo kryo) {
425468
kryo.register(ColorModelState.class, new JavaSerializer());
426469
}
@@ -486,6 +529,11 @@ public void read(Kryo kryo, Input input) {
486529
// The deserialized rendered image contains only one tile (imageRaster). We need to update
487530
// the sample model and tile properties to reflect this.
488531
this.sampleModel = this.imageRaster.getSampleModel();
532+
533+
// Reconcile colorModel with the actual sampleModel. Python UDFs may change the band count
534+
// or data type, producing a sampleModel that is incompatible with the serialized colorModel.
535+
this.colorModel = reconcileColorModel(this.colorModel, this.sampleModel);
536+
489537
this.tileWidth = this.width;
490538
this.tileHeight = this.height;
491539
this.numXTiles = 1;

common/src/test/java/org/apache/sedona/common/raster/serde/SerdeTest.java

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@
2323
import java.io.File;
2424
import java.io.IOException;
2525
import org.apache.sedona.common.raster.RasterConstructors;
26+
import org.apache.sedona.common.raster.RasterConstructorsForTesting;
2627
import org.apache.sedona.common.raster.RasterTestBase;
28+
import org.apache.sedona.common.utils.RasterUtils;
2729
import org.geotools.api.referencing.FactoryException;
2830
import org.geotools.coverage.grid.GridCoverage2D;
2931
import org.geotools.gce.geotiff.GeoTiffReader;
32+
import org.junit.Assert;
3033
import org.junit.Test;
3134

3235
public class SerdeTest extends RasterTestBase {
@@ -85,4 +88,129 @@ private GridCoverage2D testRoundTrip(GridCoverage2D raster, int density)
8588
assertSameCoverage(raster, roundTripRaster, density);
8689
return roundTripRaster;
8790
}
91+
92+
@Test
93+
public void testDeserializeWithMismatchedColorModel() throws Exception {
94+
// Simulate what Python UDFs produce: a serialized raster where the colorModel blob
95+
// describes 4 bands but the SampleModel/DataBuffer contain only 1 band. Before the
96+
// ColorModel reconciliation fix in DeepCopiedRenderedImage.read(), this would fail with:
97+
// IllegalArgumentException: "The specified ColorModel is incompatible with the image
98+
// SampleModel"
99+
// when GridCoverageFactory.create() wraps the image in RenderedImageAdapter → PlanarImage.
100+
101+
// 1. Create a 4-band raster and a 1-band raster with the same spatial dimensions
102+
GridCoverage2D raster4band =
103+
RasterConstructorsForTesting.makeRasterForTesting(
104+
4, "F", "BandedSampleModel", 4, 3, 100, 100, 10, -10, 0, 0, 3857);
105+
GridCoverage2D raster1band =
106+
RasterConstructorsForTesting.makeRasterForTesting(
107+
1, "F", "BandedSampleModel", 4, 3, 100, 100, 10, -10, 0, 0, 3857);
108+
109+
// 2. Serialize both
110+
byte[] bytes4 = Serde.serialize(raster4band);
111+
byte[] bytes1 = Serde.serialize(raster1band);
112+
113+
// 3. Locate the colorModel region in both byte arrays using UnsafeInput to navigate
114+
// the Kryo-encoded stream (same approach as Serde.extractPixelBanks).
115+
int[] cmRegion4 = findColorModelRegion(bytes4);
116+
int cmOffset4 = cmRegion4[0];
117+
int cmEnd4 = cmRegion4[1];
118+
119+
int[] cmRegion1 = findColorModelRegion(bytes1);
120+
int cmOffset1 = cmRegion1[0];
121+
int cmEnd1 = cmRegion1[1];
122+
123+
// 4. Build spliced bytes: bytes1[0..cmOffset1) + bytes4[cmOffset4..cmEnd4) + bytes1[cmEnd1..)
124+
// This gives us: 1-band metadata, 4-band colorModel blob, 1-band raster data
125+
byte[] spliced = new byte[cmOffset1 + (cmEnd4 - cmOffset4) + (bytes1.length - cmEnd1)];
126+
System.arraycopy(bytes1, 0, spliced, 0, cmOffset1);
127+
System.arraycopy(bytes4, cmOffset4, spliced, cmOffset1, cmEnd4 - cmOffset4);
128+
System.arraycopy(
129+
bytes1, cmEnd1, spliced, cmOffset1 + (cmEnd4 - cmOffset4), bytes1.length - cmEnd1);
130+
131+
// 5. Deserialize — this would throw IllegalArgumentException before the fix
132+
GridCoverage2D deserialized = Serde.deserialize(spliced);
133+
assertNotNull(deserialized);
134+
135+
// 6. Verify the deserialized raster has correct structure
136+
Assert.assertEquals(1, deserialized.getNumSampleDimensions());
137+
java.awt.image.Raster deserializedRaster =
138+
RasterUtils.getRaster(deserialized.getRenderedImage());
139+
Assert.assertEquals(1, deserializedRaster.getNumBands());
140+
Assert.assertEquals(4, deserializedRaster.getWidth());
141+
Assert.assertEquals(3, deserializedRaster.getHeight());
142+
143+
// Verify pixel values are from the 1-band raster (band=0, pixel[y][x] = y*4+x)
144+
for (int y = 0; y < 3; y++) {
145+
for (int x = 0; x < 4; x++) {
146+
double expected = y * 4.0 + x;
147+
Assert.assertEquals(
148+
"Pixel mismatch at x=" + x + " y=" + y,
149+
expected,
150+
deserializedRaster.getSampleDouble(x, y, 0),
151+
1e-6);
152+
}
153+
}
154+
155+
// 7. Verify the raster can be re-serialized (proves the colorModel is now valid)
156+
byte[] reserialized = Serde.serialize(deserialized);
157+
GridCoverage2D reDeserialized = Serde.deserialize(reserialized);
158+
assertNotNull(reDeserialized);
159+
Assert.assertEquals(1, reDeserialized.getNumSampleDimensions());
160+
161+
reDeserialized.dispose(true);
162+
deserialized.dispose(true);
163+
raster4band.dispose(true);
164+
raster1band.dispose(true);
165+
}
166+
167+
/**
168+
* Find the byte range [startOffset, endOffset) of the colorModel length-prefixed section in a
169+
* serialized IN_DB raster. Uses Kryo's UnsafeInput to navigate the stream correctly, mirroring
170+
* the approach in {@link Serde#extractPixelBanks(byte[])}.
171+
*
172+
* @return int[2] where [0]=start offset (at length prefix), [1]=end offset (past data)
173+
*/
174+
private int[] findColorModelRegion(byte[] bytes) {
175+
try (com.esotericsoftware.kryo.io.UnsafeInput in =
176+
new com.esotericsoftware.kryo.io.UnsafeInput(bytes)) {
177+
178+
// Skip rasterType byte
179+
in.readByte();
180+
181+
// Skip name (UTF-8 string: int length + bytes)
182+
KryoUtil.skipUTF8String(in);
183+
184+
// Skip gridEnvelope2D (4 ints = 16 bytes)
185+
in.skip(16);
186+
187+
// Skip affine transform (6 doubles = 48 bytes)
188+
in.skip(48);
189+
190+
// Skip CRS (length-prefixed bytes)
191+
int crsLength = in.readInt();
192+
in.skip(crsLength);
193+
194+
// Read bandCount and skip GridSampleDimensions
195+
int bandCount = in.readInt();
196+
for (int i = 0; i < bandCount; i++) {
197+
GridSampleDimensionSerializer.skip(in);
198+
}
199+
200+
// Skip DeepCopiedRenderedImage header (minX, minY, width, height = 4 ints)
201+
in.skip(16);
202+
203+
// Skip properties (length-prefixed via writeObjectWithLength)
204+
int propsLength = in.readInt();
205+
in.skip(propsLength);
206+
207+
// Now at the colorModel length prefix
208+
int cmStart = in.position();
209+
int cmDataLength = in.readInt();
210+
in.skip(cmDataLength);
211+
int cmEnd = in.position();
212+
213+
return new int[] {cmStart, cmEnd};
214+
}
215+
}
88216
}

docs/tutorial/raster.md

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -707,8 +707,11 @@ band1 = ds.read(1) # read the first band
707707

708708
## Writing Python UDF to work with raster data
709709

710-
You can write Python UDFs to work with raster data in Python. The UDFs can take `SedonaRaster` objects as input and
711-
return any Spark data type as output. This is an example of a Python UDF that calculates the mean of the raster data.
710+
You can write Python UDFs that receive raster data, process it with NumPy, SciPy, or scikit-learn, and return either a scalar value or a new raster.
711+
712+
### Raster to scalar
713+
714+
UDFs can take `SedonaRaster` objects as input and return any Spark data type as output. This is an example of a Python UDF that calculates the mean of the raster data.
712715

713716
```python
714717
from pyspark.sql.types import DoubleType
@@ -730,35 +733,38 @@ df_raster.withColumn("mean", expr("mean_udf(rast)")).show()
730733
+--------------------+------------------+
731734
```
732735

733-
It is much trickier to write an UDF that returns a raster object, since Sedona does not support serializing Python raster
734-
objects yet. However, you can write a UDF that returns the band data as an array and then construct the raster object using
735-
`RS_MakeRaster`. This is an example of a Python UDF that creates a mask raster based on the first band of the input raster.
736+
### Raster to raster
737+
738+
You can also write UDFs that return raster objects. Use `raster.with_bands()` to replace pixel data while preserving all spatial metadata (CRS, affine transform, nodata values, etc.). Band count and dtype can change freely.
739+
740+
!!!warning
741+
Raster-to-raster UDFs using `with_bands()` only work with **in-db rasters**. Out-db rasters do not carry the metadata needed to round-trip through a UDF. Use `RS_AsInDB` to convert out-db rasters to in-db before passing them to a raster-returning UDF.
736742

737743
```python
738-
from pyspark.sql.types import ArrayType, DoubleType
739744
import numpy as np
745+
from sedona.spark.sql.types import RasterType
740746

741747

742748
def mask_udf(raster):
743749
band1 = raster.as_numpy()[0, :, :]
744-
mask = (band1 < 1400).astype(np.float64)
745-
return mask.flatten().tolist()
750+
mask = (band1 < 1400).astype(np.float32)
751+
return raster.with_bands(mask) # 1 band, preserves CRS/affine/nodata
746752

747753

748-
sedona.udf.register("mask_udf", band_udf, ArrayType(DoubleType()))
749-
df_raster.withColumn("mask", expr("mask_udf(rast)")).withColumn(
750-
"mask_rast", expr("RS_MakeRaster(rast, 'I', mask)")
751-
).show()
754+
sedona.udf.register("mask_udf", mask_udf, RasterType())
755+
df_raster.withColumn("mask_rast", expr("mask_udf(rast)")).show()
752756
```
753757

754758
```
755-
+--------------------+--------------------+--------------------+
756-
| rast| mask| mask_rast|
757-
+--------------------+--------------------+--------------------+
758-
|GridCoverage2D["g...|[0.0, 0.0, 0.0, 0...|GridCoverage2D["g...|
759-
+--------------------+--------------------+--------------------+
759+
+--------------------+--------------------+
760+
| rast| mask_rast|
761+
+--------------------+--------------------+
762+
|GridCoverage2D["g...|GridCoverage2D["g...|
763+
+--------------------+--------------------+
760764
```
761765

766+
The `with_bands()` method accepts a NumPy array in CHW order (bands × height × width) or HW order (height × width) for single-band output. The returned `SedonaRaster` carries all the original metadata and is automatically serialized back to the JVM when the UDF returns.
767+
762768
## Performance optimization
763769

764770
When working with large raster datasets, refer to the [documentation on storing raster geometries in Parquet format](storing-blobs-in-parquet.md) for recommendations to optimize performance.

python/sedona/spark/raster/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@
1414
# KIND, either express or implied. See the License for the
1515
# specific language governing permissions and limitations
1616
# under the License.
17+
18+
from .sedona_raster import SedonaRaster # noqa: F401

0 commit comments

Comments
 (0)