diff --git a/Cargo.lock b/Cargo.lock index 7e8f27865..a6f7449a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5256,6 +5256,7 @@ dependencies = [ "sedona-common", "sedona-expr", "sedona-geometry", + "sedona-raster", "sedona-schema", "sedona-testing", "serde_json", diff --git a/rust/sedona-functions/Cargo.toml b/rust/sedona-functions/Cargo.toml index 41b26e24b..365687dfd 100644 --- a/rust/sedona-functions/Cargo.toml +++ b/rust/sedona-functions/Cargo.toml @@ -48,6 +48,7 @@ geo-traits = { workspace = true } sedona-common = { workspace = true } sedona-expr = { workspace = true } sedona-geometry = { workspace = true } +sedona-raster = { workspace = true } sedona-schema = { workspace = true } wkb = { workspace = true } wkt = { workspace = true } diff --git a/rust/sedona-functions/src/sd_format.rs b/rust/sedona-functions/src/sd_format.rs index 31d11b50d..ebf75e231 100644 --- a/rust/sedona-functions/src/sd_format.rs +++ b/rust/sedona-functions/src/sd_format.rs @@ -14,7 +14,7 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. -use std::{sync::Arc, vec}; +use std::{fmt::Write, sync::Arc, vec}; use crate::executor::WkbExecutor; use arrow_array::{ @@ -30,6 +30,8 @@ use datafusion_expr::{ scalar_doc_sections::DOC_SECTION_OTHER, ColumnarValue, Documentation, Volatility, }; use sedona_expr::scalar_udf::{SedonaScalarKernel, SedonaScalarUDF}; +use sedona_raster::array::RasterStructArray; +use sedona_raster::display::RasterDisplay; use sedona_schema::{datatypes::SedonaType, matchers::ArgMatcher}; /// SD_Format() scalar UDF implementation @@ -149,7 +151,7 @@ fn sedona_type_to_formatted_type(sedona_type: &SedonaType) -> Result _ => Ok(sedona_type.clone()), } } - SedonaType::Raster => internal_err!("SD_Format does not support Raster types"), + SedonaType::Raster => Ok(SedonaType::Arrow(DataType::Utf8)), } } @@ -167,6 +169,7 @@ fn columnar_value_to_formatted_value( SedonaType::Wkb(_, _) | SedonaType::WkbView(_, _) => { geospatial_value_to_formatted_value(sedona_type, columnar_value, maybe_width_hint) } + SedonaType::Raster => raster_value_to_formatted_value(columnar_value, maybe_width_hint), SedonaType::Arrow(arrow_type) => match arrow_type { DataType::Struct(fields) => match columnar_value { ColumnarValue::Array(array) => { @@ -211,7 +214,6 @@ fn columnar_value_to_formatted_value( }, _ => Ok(columnar_value.clone()), }, - SedonaType::Raster => internal_err!("SD_Format does not support Raster types"), } } @@ -356,6 +358,53 @@ fn list_view_value_to_formatted_value( )) } +fn raster_value_to_formatted_value( + columnar_value: &ColumnarValue, + maybe_width_hint: Option, +) -> Result { + match columnar_value { + ColumnarValue::Array(array) => { + let struct_array = array.as_struct(); + let raster_array = RasterStructArray::new(struct_array); + let min_output_size = match maybe_width_hint { + Some(width_hint) => raster_array.len() * width_hint, + None => raster_array.len() * 48, + }; + let mut builder = + StringBuilder::with_capacity(raster_array.len(), min_output_size.max(1)); + + for i in 0..raster_array.len() { + if raster_array.is_null(i) { + builder.append_null(); + continue; + } + + let raster = raster_array.get(i)?; + let mut limited_output = + LimitedSizeOutput::new(&mut builder, maybe_width_hint.unwrap_or(usize::MAX)); + let _ = write!(limited_output, "{}", RasterDisplay(&raster)); + builder.append_value(""); + } + + Ok(ColumnarValue::Array(Arc::new(builder.finish()))) + } + ColumnarValue::Scalar(ScalarValue::Struct(struct_array)) => { + let formatted = raster_value_to_formatted_value( + &ColumnarValue::Array(Arc::new(struct_array.as_ref().clone())), + maybe_width_hint, + )?; + if let ColumnarValue::Array(array) = formatted { + Ok(ColumnarValue::Scalar(ScalarValue::try_from_array( + &array, 0, + )?)) + } else { + internal_err!("Expected array formatted value for raster scalar") + } + } + _ => internal_err!("Unsupported raster columnar value"), + } +} + struct LimitedSizeOutput<'a, T> { inner: &'a mut T, current_item_size: usize, @@ -395,9 +444,11 @@ mod tests { use datafusion_expr::ScalarUDF; use rstest::rstest; use sedona_schema::datatypes::{ - WKB_GEOGRAPHY, WKB_GEOMETRY, WKB_VIEW_GEOGRAPHY, WKB_VIEW_GEOMETRY, + RASTER, WKB_GEOGRAPHY, WKB_GEOMETRY, WKB_VIEW_GEOGRAPHY, WKB_VIEW_GEOMETRY, + }; + use sedona_testing::{ + create::create_array, rasters::generate_test_rasters, testers::ScalarUdfTester, }; - use sedona_testing::{create::create_array, testers::ScalarUdfTester}; use std::sync::Arc; use super::*; @@ -556,6 +607,62 @@ mod tests { } } + #[test] + fn sd_format_formats_raster_columns() { + let udf = sd_format_udf(); + let tester = ScalarUdfTester::new(udf.into(), vec![RASTER]); + + let raster_array = generate_test_rasters(3, Some(1)).unwrap(); + let result = tester.invoke_array(Arc::new(raster_array.clone())).unwrap(); + let formatted = result.as_string::(); + + // Index 0: valid raster (no skew) + assert_eq!(formatted.value(0), "[1x2/1] @ [1 1.6 1.1 2] / OGC:CRS84"); + // Index 1: null raster should produce null output + assert!(formatted.is_null(1)); + // Index 2: valid raster (with skew) + assert_eq!( + formatted.value(2), + "[3x4/1] @ [3 2.4 3.84 4.24] skew=(0.06, 0.08) / OGC:CRS84" + ); + } + + #[test] + fn sd_format_formats_raster_columns_with_null() { + let udf = sd_format_udf(); + let tester = ScalarUdfTester::new(udf.into(), vec![RASTER]); + + // Generate 3 rasters with a null at index 1 + let raster_array = generate_test_rasters(3, Some(1)).unwrap(); + let result = tester.invoke_array(Arc::new(raster_array)).unwrap(); + let formatted = result.as_string::(); + + // Index 0: valid raster (no skew) + assert!(formatted.value(0).starts_with("[1x2/")); + // Index 1: null raster should produce null output + assert!(formatted.is_null(1)); + // Index 2: valid raster (with skew) + assert!(formatted.value(2).starts_with("[3x4/")); + } + + #[test] + fn sd_format_formats_raster_columns_with_width_hint() { + let udf = sd_format_udf(); + let tester = + ScalarUdfTester::new(udf.into(), vec![RASTER, SedonaType::Arrow(DataType::Utf8)]); + + let raster_array = generate_test_rasters(2, None).unwrap(); + let result = tester + .invoke_array_scalar(Arc::new(raster_array), r#"{"width_hint": 10}"#) + .unwrap(); + let formatted = result.as_string::(); + + // With a small width_hint, output should be truncated + let full_output = "[1x2/1] @ [1 1.6 1.1 2] / OGC:CRS84"; + assert!(formatted.value(0).starts_with("[")); + assert!(formatted.value(0).len() < full_output.len()); + } + #[rstest] fn sd_format_should_format_spatial_columns( #[values(WKB_GEOMETRY, WKB_GEOGRAPHY, WKB_VIEW_GEOMETRY, WKB_VIEW_GEOGRAPHY)] diff --git a/rust/sedona-raster/src/display.rs b/rust/sedona-raster/src/display.rs new file mode 100644 index 000000000..400658a0a --- /dev/null +++ b/rust/sedona-raster/src/display.rs @@ -0,0 +1,163 @@ +// 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. + +use std::fmt; + +use crate::affine_transformation::to_world_coordinate; +use crate::traits::RasterRef; +use sedona_schema::raster::StorageType; + +/// Wrapper for formatting a raster reference as a human-readable string. +/// +/// # Format +/// +/// Non-skewed rasters: +/// ```text +/// [WxH/nbands] @ [xmin ymin xmax ymax] / CRS +/// ``` +/// +/// Skewed rasters (includes skew parameters): +/// ```text +/// [WxH/nbands] @ [xmin ymin xmax ymax] skew=(skew_x, skew_y) / CRS +/// ``` +/// +/// With outdb bands: +/// ```text +/// [WxH/nbands] @ [xmin ymin xmax ymax] / CRS +/// ``` +/// +/// Without CRS: +/// ```text +/// [WxH/nbands] @ [xmin ymin xmax ymax] +/// ``` +/// +/// # Examples +/// +/// ```text +/// [64x32/3] @ [43.08 79.07 171.08 143.07] / OGC:CRS84 +/// [3x4/1] @ [3 2.4 3.84 4.24] skew=(0.06, 0.08) / EPSG:2193 +/// [10x10/1] @ [0 0 10 10] / OGC:CRS84 +/// ``` +pub struct RasterDisplay<'a>(pub &'a dyn RasterRef); + +impl fmt::Display for RasterDisplay<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let raster = self.0; + let metadata = raster.metadata(); + let bands = raster.bands(); + + let width = metadata.width(); + let height = metadata.height(); + let nbands = bands.len(); + + // Compute axis-aligned bounding box from 4 corners in world coordinates. + // This handles both skewed and non-skewed rasters correctly. + let w = width as i64; + let h = height as i64; + let (ulx, uly) = to_world_coordinate(raster, 0, 0); + let (urx, ury) = to_world_coordinate(raster, w, 0); + let (lrx, lry) = to_world_coordinate(raster, w, h); + let (llx, lly) = to_world_coordinate(raster, 0, h); + + let xmin = ulx.min(urx).min(lrx).min(llx); + let xmax = ulx.max(urx).max(lrx).max(llx); + let ymin = uly.min(ury).min(lry).min(lly); + let ymax = uly.max(ury).max(lry).max(lly); + + let skew_x = metadata.skew_x(); + let skew_y = metadata.skew_y(); + let has_skew = skew_x != 0.0 || skew_y != 0.0; + + let has_outdb = bands + .iter() + .any(|band| matches!(band.metadata().storage_type(), Ok(StorageType::OutDbRef))); + + // Write: [WxH/nbands] @ [xmin ymin xmax ymax] + write!( + f, + "[{width}x{height}/{nbands}] @ [{xmin} {ymin} {xmax} {ymax}]" + )?; + + // Conditionally append skew info when the raster is rotated/skewed + if has_skew { + write!(f, " skew=({skew_x}, {skew_y})")?; + } + + // Append CRS if present. For PROJJSON (starts with '{'), show compact placeholder. + if let Some(crs) = raster.crs() { + if crs.starts_with('{') { + write!(f, " / {{...}}")?; + } else { + write!(f, " / {crs}")?; + } + } + + if has_outdb { + write!(f, " ")?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::array::RasterStructArray; + use sedona_testing::rasters::generate_test_rasters; + + #[test] + fn display_non_skewed_raster() { + // i=0: w=1, h=2, scale=(0.1, -0.2), skew=(0, 0), CRS=OGC:CRS84 + // Bounds: xmin=1, ymin=1.6, xmax=1.1, ymax=2 + let rasters = generate_test_rasters(1, None).unwrap(); + let raster_array = RasterStructArray::new(&rasters); + let raster = raster_array.get(0).unwrap(); + + let display = format!("{}", RasterDisplay(&raster)); + assert_eq!(display, "[1x2/1] @ [1 1.6 1.1 2] / OGC:CRS84"); + } + + #[test] + fn display_skewed_raster() { + // i=2: w=3, h=4, scale=(0.2, -0.4), skew=(0.06, 0.08), CRS=OGC:CRS84 + // Corners: (3,4), (3.6,4.24), (3.84,2.64), (3.24,2.4) + // AABB: xmin=3, ymin=2.4, xmax=3.84, ymax=4.24 + let rasters = generate_test_rasters(3, None).unwrap(); + let raster_array = RasterStructArray::new(&rasters); + let raster = raster_array.get(2).unwrap(); + + let display = format!("{}", RasterDisplay(&raster)); + assert_eq!( + display, + "[3x4/1] @ [3 2.4 3.84 4.24] skew=(0.06, 0.08) / OGC:CRS84" + ); + } + + #[test] + fn display_write_to_fmt_write() { + // Verify RasterDisplay works with any fmt::Write target (e.g., String) + let rasters = generate_test_rasters(1, None).unwrap(); + let raster_array = RasterStructArray::new(&rasters); + let raster = raster_array.get(0).unwrap(); + + let mut buf = String::new(); + use std::fmt::Write; + write!(buf, "{}", RasterDisplay(&raster)).unwrap(); + assert_eq!(buf, "[1x2/1] @ [1 1.6 1.1 2] / OGC:CRS84"); + } +} diff --git a/rust/sedona-raster/src/lib.rs b/rust/sedona-raster/src/lib.rs index de2e9b2e4..77db0c0dd 100644 --- a/rust/sedona-raster/src/lib.rs +++ b/rust/sedona-raster/src/lib.rs @@ -18,4 +18,5 @@ pub mod affine_transformation; pub mod array; pub mod builder; +pub mod display; pub mod traits;