You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ja...@apache.org on 2026/09/02 19:27:49 UTC

(sedona-db) branch main updated: chore(python/sedonadb): accept a bbox in create_random_raster_view (#1211)

This is an automated email from the ASF dual-hosted git repository.

james-willis pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/sedona-db.git


The following commit(s) were added to refs/heads/main by this push:
     new 76186eed9 chore(python/sedonadb): accept a bbox in create_random_raster_view (#1211)
76186eed9 is described below

commit 76186eed9c7083da677ddabb886909c6082ba0ea
Author: James Willis <ja...@users.noreply.github.com>
AuthorDate: Wed Sep 2 12:27:43 2026 -0700

    chore(python/sedonadb): accept a bbox in create_random_raster_view (#1211)
---
 python/sedonadb/python/sedonadb/raster_testing.py | 82 +++++++++++++++++------
 python/sedonadb/python/sedonadb/testing.py        | 22 ++++--
 python/sedonadb/tests/test_raster_testing.py      | 79 ++++++++++++++++++++++
 3 files changed, 155 insertions(+), 28 deletions(-)

diff --git a/python/sedonadb/python/sedonadb/raster_testing.py b/python/sedonadb/python/sedonadb/raster_testing.py
index 40e9f8cf1..0a8870d9b 100644
--- a/python/sedonadb/python/sedonadb/raster_testing.py
+++ b/python/sedonadb/python/sedonadb/raster_testing.py
@@ -36,7 +36,7 @@ then every side carries the same CRS.
 """
 
 import math
-from dataclasses import dataclass
+from dataclasses import InitVar, dataclass
 from typing import Any, List, Mapping, Optional, Tuple
 
 import numpy as np
@@ -46,18 +46,38 @@ import numpy as np
 class DecodedRaster:
     """One raster decoded to plain values.
 
-    `pixels` is `(band, rows, cols)`, `gdal_transform` is GDAL-order
-    `(origin_x, scale_x, skew_x, origin_y, skew_y, scale_y)`, and `nodata`
-    holds one sentinel per band (unpacked in the band's dtype).
+    `pixels` is `(band, rows, cols)` and `nodata` holds one sentinel per band
+    (unpacked in the band's dtype). The grid is stated as exactly one of
+    `gdal_transform` — GDAL-order `(origin_x, scale_x, skew_x, origin_y,
+    skew_y, scale_y)` — or, for a north-up grid, the readable `bbox` spelling
+    `(minx, miny, maxx, maxy)` (pixel size derived from the data's shape),
+    mirroring `write_geotiff`. Either way the instance carries a
+    `gdal_transform`: decoders produce transforms and `assert_decoded_equal`
+    compares them, so `bbox` is construction sugar for anchors, not a second
+    stored representation.
     `compression` is the codec name of the decoded container when one was
     read (GeoTIFF decodes only); it is carried for encoder tests to assert
     on and deliberately not part of `assert_decoded_equal`.
     """
 
     pixels: "np.ndarray"
-    gdal_transform: Tuple[float, ...]
-    nodata: List[Any]
+    gdal_transform: Optional[Tuple[float, ...]] = None
+    nodata: Optional[List[Any]] = None
     compression: Optional[str] = None
+    bbox: InitVar[Optional[Tuple[float, float, float, float]]] = None
+
+    def __post_init__(self, bbox):
+        if self.nodata is None:
+            raise ValueError("nodata is required (one entry per band)")
+        # A plain-transform construction skips _resolve_transform so decoders
+        # (which never pass bbox) stay rasterio-free.
+        if bbox is not None or self.gdal_transform is None:
+            _, height, width = self.pixels.shape
+            self.gdal_transform = tuple(
+                _resolve_transform(
+                    bbox, self.gdal_transform, width=width, height=height
+                ).to_gdal()
+            )
 
 
 def _is_nodata(sampled, nodata) -> bool:
@@ -127,19 +147,33 @@ def decode_geotiff_bytes(data: bytes) -> DecodedRaster:
         )
 
 
+def _resolve_transform(bbox, gdal_transform, *, width, height):
+    """The rasterio `Affine` from exactly one of `bbox`/`gdal_transform`."""
+    from rasterio.transform import Affine, from_bounds
+
+    if (gdal_transform is None) == (bbox is None):
+        raise ValueError("pass exactly one of gdal_transform or bbox")
+    if bbox is not None:
+        return from_bounds(*bbox, width=width, height=height)
+    return Affine.from_gdal(*gdal_transform)
+
+
 def write_geotiff(
-    path, data: "np.ndarray", *, gdal_transform, nodata=None, crs=None
+    path, data: "np.ndarray", *, bbox=None, gdal_transform=None, nodata=None, crs=None
 ) -> None:
     """Write a `(bands, height, width)` array as a GeoTIFF.
 
-    `gdal_transform` is GDAL-order `(origin_x, scale_x, skew_x, origin_y,
-    skew_y, scale_y)`; `nodata` (optional) becomes the per-band nodata of
-    every band. `crs` (optional) is any CRS rasterio accepts; parity fixtures
-    stay CRS-less unless an engine requires one, and then use the same CRS
-    everywhere so nothing reprojects.
+    The grid is placed by exactly one of `bbox` — `(minx, miny, maxx, maxy)`,
+    the readable spelling: north-up, no skew, pixel size derived from the
+    data's shape (rasterio's `transform.from_bounds`) — or `gdal_transform` —
+    GDAL-order `(origin_x, scale_x, skew_x, origin_y, skew_y, scale_y)`, for
+    grids a bbox cannot express (skew, south-up).
+    `nodata` (optional) becomes the per-band nodata of every band. `crs`
+    (optional) is any CRS rasterio accepts; parity fixtures stay CRS-less
+    unless an engine requires one, and then use the same CRS everywhere so
+    nothing reprojects.
     """
     import rasterio
-    from rasterio.transform import Affine
 
     bands, height, width = data.shape
     with rasterio.open(
@@ -150,7 +184,7 @@ def write_geotiff(
         width=width,
         count=bands,
         dtype=str(data.dtype),
-        transform=Affine.from_gdal(*gdal_transform),
+        transform=_resolve_transform(bbox, gdal_transform, width=width, height=height),
         nodata=nodata,
         crs=crs,
     ) as dst:
@@ -164,7 +198,8 @@ def write_random_geotiff(
     bands,
     height,
     width,
-    gdal_transform,
+    bbox=None,
+    gdal_transform=None,
     crs=None,
     nodata=None,
     plants=None,
@@ -173,23 +208,28 @@ def write_random_geotiff(
 
     Combines `random_raster_data` (dtype extremes planted in opposite corners)
     with `write_geotiff` — the input-raster fixture shape shared by raster warp
-    parity tests. `gdal_transform`, `crs`, and `nodata` are as `write_geotiff`;
-    `plants` is as `random_raster_data`.
+    parity tests. `bbox`/`gdal_transform` (exactly one), `crs`, and `nodata`
+    are as `write_geotiff`; `plants` is as `random_raster_data`.
     """
     data = random_raster_data(
         dtype, bands=bands, height=height, width=width, plants=plants
     )
-    write_geotiff(path, data, gdal_transform=gdal_transform, nodata=nodata, crs=crs)
+    write_geotiff(
+        path, data, bbox=bbox, gdal_transform=gdal_transform, nodata=nodata, crs=crs
+    )
 
 
-def write_grid_geotiff(path, *, gdal_transform, width, height, crs=None) -> None:
+def write_grid_geotiff(
+    path, *, bbox=None, gdal_transform=None, width, height, crs=None
+) -> None:
     """Write a zeroed single-band GeoTIFF whose only role is to define a grid.
 
     Its pixels are never read — only its extent, resolution, and CRS matter,
-    e.g. as the reference grid for `RS_ReprojectMatch`.
+    e.g. as the reference grid for `RS_ReprojectMatch`. `bbox`/`gdal_transform`
+    (exactly one) are as `write_geotiff`.
     """
     data = np.zeros((1, height, width), dtype="uint8")
-    write_geotiff(path, data, gdal_transform=gdal_transform, crs=crs)
+    write_geotiff(path, data, bbox=bbox, gdal_transform=gdal_transform, crs=crs)
 
 
 def assert_transform_and_nodata(got: DecodedRaster, expected: DecodedRaster) -> None:
diff --git a/python/sedonadb/python/sedonadb/testing.py b/python/sedonadb/python/sedonadb/testing.py
index af7e88050..a619c2aef 100644
--- a/python/sedonadb/python/sedonadb/testing.py
+++ b/python/sedonadb/python/sedonadb/testing.py
@@ -196,28 +196,36 @@ class DBEngine:
         bands=2,
         height=6,
         width=7,
-        gdal_transform=(100.0, 2.0, 0.0, 500.0, 0.0, -3.0),
+        bbox=None,
+        gdal_transform=None,
         nodata=None,
         plants=None,
     ) -> "DBEngine":
         """Write a random GeoTIFF at `path` and register it as view `name`
         (see `create_raster_view`).
 
-        The default grid is small and north-up/CRS-less so nothing reprojects
-        and results stay bit-comparable across engines. The pixels come from
-        `sedonadb.raster_testing.random_raster_data` with a fixed seed, so
-        registering the same `path` on several engines rewrites identical
-        bytes and every engine sees the same raster. `nodata` and `plants` are
-        as `write_random_geotiff`.
+        The grid is placed by `bbox` (`(minx, miny, maxx, maxy)`, north-up,
+        no skew) or, for grids a bbox cannot express, a raw GDAL
+        `gdal_transform` — at most one; the default is
+        `bbox=(100, 482, 114, 500)`, whose extent divided by the default 7x6
+        grid gives 2x3 pixels. The grid is small and north-up/CRS-less so
+        nothing reprojects and results stay bit-comparable across engines.
+        The pixels come from `sedonadb.raster_testing.random_raster_data`
+        with a fixed seed, so registering the same `path` on several engines
+        rewrites identical bytes and every engine sees the same raster.
+        `nodata` and `plants` are as `write_random_geotiff`.
         """
         from sedonadb.raster_testing import write_random_geotiff
 
+        if bbox is None and gdal_transform is None:
+            bbox = (100.0, 482.0, 114.0, 500.0)
         write_random_geotiff(
             path,
             dtype,
             bands=bands,
             height=height,
             width=width,
+            bbox=bbox,
             gdal_transform=gdal_transform,
             nodata=nodata,
             plants=plants,
diff --git a/python/sedonadb/tests/test_raster_testing.py b/python/sedonadb/tests/test_raster_testing.py
new file mode 100644
index 000000000..f8e620dd7
--- /dev/null
+++ b/python/sedonadb/tests/test_raster_testing.py
@@ -0,0 +1,79 @@
+# 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.
+"""Tests for the grid-placement helpers in `sedonadb.raster_testing`.
+
+The bbox spelling of grid placement is only checked here: the parity suite
+compares engines against each other on the same file (and anchors against
+bbox-constructed `DecodedRaster`s), so a wrong bbox-derived geotransform
+would agree with itself there.
+"""
+
+import numpy as np
+import pytest
+
+from sedonadb.raster_testing import (
+    DecodedRaster,
+    decode_geotiff,
+    write_geotiff,
+    write_random_geotiff,
+)
+
+# The default extent of `DBEngine.create_random_raster_view`.
+BBOX = (100.0, 482.0, 114.0, 500.0)
+
+
+def test_write_geotiff_bbox_places_the_grid(tmp_path):
+    path = tmp_path / "bbox.tif"
+    write_random_geotiff(path, "uint8", bands=1, height=6, width=7, bbox=BBOX)
+    assert decode_geotiff(path).gdal_transform == (100.0, 2.0, 0.0, 500.0, 0.0, -3.0)
+
+
+def test_decoded_raster_bbox_places_the_grid():
+    data = np.zeros((1, 6, 7), dtype="uint8")
+    by_bbox = DecodedRaster(data, nodata=[None], bbox=BBOX)
+    assert by_bbox.gdal_transform == (100.0, 2.0, 0.0, 500.0, 0.0, -3.0)
+
+
+def test_decoded_raster_requires_exactly_one_grid_placement():
+    data = np.zeros((1, 6, 7), dtype="uint8")
+    with pytest.raises(ValueError, match="exactly one"):
+        DecodedRaster(data, nodata=[None])
+    with pytest.raises(ValueError, match="exactly one"):
+        DecodedRaster(
+            data,
+            (100.0, 2.0, 0.0, 500.0, 0.0, -3.0),
+            nodata=[None],
+            bbox=BBOX,
+        )
+
+
+def test_decoded_raster_requires_nodata():
+    with pytest.raises(ValueError, match="nodata"):
+        DecodedRaster(np.zeros((1, 6, 7), dtype="uint8"), bbox=BBOX)
+
+
+def test_write_geotiff_requires_exactly_one_grid_placement(tmp_path):
+    data = np.zeros((1, 6, 7), dtype="uint8")
+    with pytest.raises(ValueError, match="exactly one"):
+        write_geotiff(tmp_path / "neither.tif", data)
+    with pytest.raises(ValueError, match="exactly one"):
+        write_geotiff(
+            tmp_path / "both.tif",
+            data,
+            bbox=BBOX,
+            gdal_transform=(100.0, 2.0, 0.0, 500.0, 0.0, -3.0),
+        )