Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions c/sedona-gdal/src/gdal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ impl Gdal {
vsi::get_vsi_mem_file_bytes_owned(self.api, file_name)
}

/// Take ownership of a VSI in-memory file's buffer without copying: the
/// file is unlinked and its GDAL-allocated bytes are returned as a
/// [`vsi::VSIBuffer`] (freed on drop). Prefer this over
/// [`Self::get_vsi_mem_file_bytes_owned`] when the bytes are consumed as a
/// slice — it skips the `Vec` copy.
pub fn get_vsi_mem_file_buffer_owned(&self, file_name: &str) -> Result<vsi::VSIBuffer> {
vsi::get_vsi_mem_file_buffer_owned(self.api, file_name)
}

// -- Raster operations ---------------------------------------------------

/// Create a bare in-memory MEM dataset with GDAL-owned bands.
Expand Down
1 change: 1 addition & 0 deletions c/sedona-gdal/src/vsi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use crate::errors::{GdalError, Result};
use crate::gdal_api::{call_gdal_api, GdalApi};

/// An owned GDAL-allocated VSI memory buffer.
#[derive(Debug)]
pub struct VSIBuffer {
api: &'static GdalApi,
ptr: *mut u8,
Expand Down
103 changes: 103 additions & 0 deletions docs/reference/sql/rs_asgeotiff.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
---
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

title: RS_AsGeoTiff
description: >
Encodes a raster as a GeoTIFF and returns the bytes as binary, with optional
compression and tiling.
kernels:
- returns: binary
args:
- raster
- returns: binary
args:
- raster
- {name: tile_size, type: integer}
- returns: binary
args:
- raster
- {name: compression, type: string}
- {name: quality, type: double}
- returns: binary
args:
- raster
- {name: compression, type: string}
- {name: quality, type: double}
- {name: tile_size, type: integer}
- returns: binary
args:
- raster
- name: compression
type: string
description: >
GeoTIFF compression codec (case-insensitive): None, PackBits, Deflate,
Huffman, LZW, or JPEG. Deflate and LZW additionally enable a predictor
to improve the compression ratio (horizontal differencing for integer
bands, floating-point prediction for float bands). Huffman maps to
CCITT RLE, which GDAL only accepts for 1-bit single-band data — it is
kept for Apache Sedona parity but errors on ordinary rasters.
- name: quality
type: double
description: >
Quality factor for lossy JPEG compression as a fraction between 0.0
and 1.0 (e.g. 0.75 for JPEG quality 75, matching Apache Sedona);
values outside that range raise an error. Ignored by the other codecs.
- name: tile_width
type: integer
description: >
Tile width in pixels. When given together with tile_height, the
GeoTIFF is written tiled rather than stripped. TIFF tile dimensions
must be multiples of 16.
- name: tile_height
type: integer
description: Tile height in pixels; must be a multiple of 16.
---

::: callout-warning
**Experimental.** This function is experimental; its behavior may change without notice.
:::

## Description

`RS_AsGeoTiff` encodes a raster as a GeoTIFF (via GDAL) and returns the file
Comment thread
james-willis marked this conversation as resolved.
bytes as a binary value — the inverse of reading a GeoTIFF into a raster. The
result is `NULL` for a `NULL` raster. A `NULL` option argument means "not
specified": e.g. a `NULL` compression writes an uncompressed GeoTIFF rather
than propagating `NULL` to the result.

The two-argument form `RS_AsGeoTiff(raster, tile_size)` writes a tiled GeoTIFF
with square `tile_size` blocks. Compression is selected by name; a JPEG quality
factor and explicit tile dimensions can be supplied via the longer overloads.

## Examples

```sql
SELECT RS_AsGeoTiff(RS_Example()) IS NOT NULL;
```

```sql
SELECT RS_AsGeoTiff(RS_Example(), 16) IS NOT NULL;
```

```sql
SELECT RS_AsGeoTiff(RS_Example(), 'DEFLATE', 0.85) IS NOT NULL;
```

```sql
SELECT RS_AsGeoTiff(RS_Example(), 'LZW', 0.85, 16, 16) IS NOT NULL;
```
66 changes: 57 additions & 9 deletions python/sedonadb/tests/functions/test_raster_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
# specific language governing permissions and limitations
# under the License.

import pytest
import numpy as np
import pandas as pd
import pytest

from sedonadb.testing import SedonaDB
from sedonadb.raster import Raster
Expand Down Expand Up @@ -149,15 +150,11 @@ def test_rs_value_matches_rasterio(con):
positions per pixel (toward the corners, kept inside the pixel to avoid floor
ambiguity at exact boundaries) and a batch of random interior points.
"""
import numpy as np
import pandas as pd

pytest.importorskip("rasterio")
from rasterio.io import MemoryFile
from rasterio.transform import Affine

from sedonadb.raster import Raster

rng = np.random.default_rng(42)
height, width = 7, 5
data = rng.random((height, width)) * 1000.0
Expand Down Expand Up @@ -283,6 +280,61 @@ def test_rs_setbandnodatavalue_two_arg_requires_single_band():
)


# RS_AsGeoTiff smoke coverage: the GDAL-backed export is tested in depth in
# Rust (rust/sedona-raster-gdal/src/rs_as_geotiff.rs); these only guard that
# the function is registered in the Python build and returns GeoTIFF bytes.
@pytest.mark.parametrize(
Comment thread
james-willis marked this conversation as resolved.
"expr",
[
"RS_AsGeoTiff(RS_Example())",
"RS_AsGeoTiff(RS_Example(), 16)",
"RS_AsGeoTiff(RS_Example(), 'DEFLATE', 0.85)",
"RS_AsGeoTiff(RS_Example(), 'LZW', 0.85, 16, 16)",
],
)
def test_rs_asgeotiff_returns_tiff_bytes(con, expr):
result = con.sql(f"SELECT {expr} AS t").to_arrow_table()["t"][0].as_py()
assert result[:2] in (b"II", b"MM"), "should start with a TIFF byte-order mark"


def test_rs_asgeotiff_out_of_range_quality_errors(con):
# Quality is a 0.0-1.0 fraction; a 0-100 style value errors rather than
# silently clamping to maximum quality.
with pytest.raises(Exception, match="between 0.0 and 1.0"):
con.sql("SELECT RS_AsGeoTiff(RS_Example(), 'JPEG', 75)").to_arrow_table()


# Cross-check RS_AsGeoTiff against rasterio: export a random raster of each
# band data type and confirm rasterio decodes the bytes back to the identical
# array, dtype, and geotransform. The DEFLATE/LZW variants also exercise the
# per-dtype predictor selection (horizontal differencing for integers,
# floating-point prediction for float bands).
@pytest.mark.parametrize("dtype", ["uint8", "uint16", "int32", "float32", "float64"])
@pytest.mark.parametrize("compression_args", ["", ", 'DEFLATE', 0.85", ", 'LZW', 0.85"])
def test_rs_asgeotiff_roundtrips_contents(con, dtype, compression_args):
pytest.importorskip("rasterio")
from rasterio.io import MemoryFile

rng = np.random.default_rng(7)
data = (rng.random((5, 4)) * 100).astype(dtype)
gdal_transform = (10.0, 1.0, 0.0, 20.0, 0.0, -1.0)
raster = Raster.from_numpy(data, transform=gdal_transform)

tiff_bytes = (
con.sql(
f"SELECT RS_AsGeoTiff($1{compression_args}) AS t",
params=(raster,),
)
.to_arrow_table()["t"]
.to_pylist()[0]
)
with MemoryFile(bytes(tiff_bytes)) as mem, mem.open() as src:
decoded = src.read(1)
assert decoded.dtype == data.dtype
np.testing.assert_array_equal(decoded, data)
assert src.transform.to_gdal() == gdal_transform


def _rs_as_raster_sql(
pixel_type, all_touched, burn_value, nodata_value, use_geometry_extent
):
Expand Down Expand Up @@ -348,8 +400,6 @@ def test_rs_as_raster_matches_rasterio(


def test_rs_as_raster_all_touched_changes_pixels(con, sedona_testing):
import numpy as np

pytest.importorskip("rasterio")
from rasterio.features import rasterize
from rasterio.transform import Affine
Expand Down Expand Up @@ -426,8 +476,6 @@ def test_rs_as_raster_rejects_fractional_integer_nodata(con, sedona_testing):


def test_rs_as_raster_sets_output_nodata(con, sedona_testing):
import numpy as np

path = sedona_testing / "data/raster/test4.tiff"
tab = con.sql(
"""
Expand Down
5 changes: 5 additions & 0 deletions rust/sedona-raster-gdal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ sedona-testing = { workspace = true, features = ["criterion"] }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

[[bench]]
harness = false
name = "rs_as_geotiff"
path = "benches/rs_as_geotiff.rs"

[[bench]]
harness = false
name = "rs_clip"
Expand Down
120 changes: 120 additions & 0 deletions rust/sedona-raster-gdal/benches/rs_as_geotiff.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// 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.

//! Benchmarks for the RS_AsGeoTiff UDF.
//!
//! Exercises the raster → GeoTIFF export path (`RS_Example` rasters, no external
//! fixtures): the uncompressed default and the compression axis (none/lzw/deflate).

use std::hint::black_box;
use std::sync::Arc;

use arrow_array::ArrayRef;
use arrow_schema::DataType;
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use datafusion_common::ScalarValue;
use datafusion_expr::{ColumnarValue, ScalarUDF};
use sedona_schema::datatypes::{SedonaType, RASTER};
use sedona_schema::raster::BandDataType;
use sedona_testing::raster_spec::RasterSpec;
use sedona_testing::testers::ScalarUdfTester;

fn raster_array(rows: usize) -> ArrayRef {
assert!(rows > 0, "benchmark rows must be positive");

let example: ScalarUDF = sedona_raster_functions::rs_example::rs_example_udf().into();
let tester = ScalarUdfTester::new(example, vec![]);
match tester.invoke(vec![]).unwrap() {
ColumnarValue::Scalar(value) => value.to_array_of_size(rows).unwrap(),
ColumnarValue::Array(array) => array,
}
}

fn bench_rs_as_geotiff_basic(c: &mut Criterion) {
let udf: ScalarUDF = sedona_raster_gdal::rs_as_geotiff_udf().into();
let tester = ScalarUdfTester::new(udf, vec![RASTER]);

let mut group = c.benchmark_group("rs_as_geotiff");
for rows in [1usize, 32] {
let rasters = raster_array(rows);
group.throughput(Throughput::Elements(rows as u64));
group.bench_with_input(BenchmarkId::new("basic", rows), &rasters, |b, input| {
b.iter(|| black_box(tester.invoke_arrays(vec![input.clone()]).unwrap()))
});
}

// A single ~16 MB raster: large enough that per-row byte handling
// (vsimem buffer -> output array) registers against the GDAL encode.
const LARGE: i64 = 4096;
let large: ArrayRef = Arc::new(
RasterSpec::d2(LARGE, LARGE)
.band(BandDataType::UInt8)
.build(),
);
group.throughput(Throughput::Bytes((LARGE * LARGE) as u64));
group.bench_with_input(BenchmarkId::new("large", "16MiB"), &large, |b, input| {
b.iter(|| black_box(tester.invoke_arrays(vec![input.clone()]).unwrap()))
});
group.finish();
}

fn bench_rs_as_geotiff_compression(c: &mut Criterion) {
let udf: ScalarUDF = sedona_raster_gdal::rs_as_geotiff_udf().into();
let tester = ScalarUdfTester::new(
udf,
vec![
RASTER,
SedonaType::Arrow(DataType::Utf8),
SedonaType::Arrow(DataType::Float64),
],
);

let rasters = raster_array(32);
// Quality is a 0.0-1.0 fraction (ignored by these codecs, but must be valid).
let quality = ColumnarValue::Scalar(ScalarValue::Float64(Some(0.75)));

let mut group = c.benchmark_group("rs_as_geotiff_compression");
group.throughput(Throughput::Elements(rasters.len() as u64));
for compression in ["none", "lzw", "deflate"] {
let comp = ColumnarValue::Scalar(ScalarValue::Utf8(Some(compression.to_string())));
group.bench_with_input(
BenchmarkId::new("compression", compression),
&(&rasters, &comp, &quality),
|b, (rasters, comp, quality)| {
b.iter(|| {
black_box(
tester
.invoke(vec![
ColumnarValue::Array((*rasters).clone()),
(*comp).clone(),
(*quality).clone(),
])
.unwrap(),
)
})
},
);
}
group.finish();
}

criterion_group!(
benches,
bench_rs_as_geotiff_basic,
bench_rs_as_geotiff_compression
);
criterion_main!(benches);
2 changes: 2 additions & 0 deletions rust/sedona-raster-gdal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mod gdal_common;
mod gdal_dataset_provider;

mod raster_loader;
mod rs_as_geotiff;
mod rs_as_raster;
mod rs_clip;
mod rs_frompath;
Expand All @@ -47,6 +48,7 @@ pub use gdal_common::{
nodata_bytes_to_f64, nodata_f64_to_bytes, GdalBandLayout, GdalBandPlan,
};
pub use raster_loader::{GdalLoader, GDAL_FORMAT};
pub use rs_as_geotiff::rs_as_geotiff_udf;
pub use rs_as_raster::rs_as_raster_udf;
pub use rs_clip::rs_clip_udf;
pub use rs_frompath::rs_frompath_udf;
Expand Down
1 change: 1 addition & 0 deletions rust/sedona-raster-gdal/src/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use sedona_expr::function_set::FunctionSet;
/// Export the set of GDAL-backed functions defined in this crate.
pub fn default_function_set() -> FunctionSet {
let mut function_set = FunctionSet::new();
function_set.insert_scalar_udf(crate::rs_as_geotiff::rs_as_geotiff_udf());
function_set.insert_scalar_udf(crate::rs_as_raster::rs_as_raster_udf());
function_set.insert_scalar_udf(crate::rs_clip::rs_clip_udf());
function_set.insert_scalar_udf(crate::rs_frompath::rs_frompath_udf());
Expand Down
Loading
Loading