From 7ff9f76a260d47f1fe72b7e2811607fe6ccc159e Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Fri, 15 May 2026 13:01:14 -0700 Subject: [PATCH] geotiff: require_transform exempts int-dtype coords (no-georef sentinel parity with coords_to_transform) PR #1953 added require_transform_for_georeferenced which raises when both spatial dims are in da.coords and the resolved transform is None. PR #1954 made coords_to_transform return None for integer x/y coords as the no-georef sentinel (#1949). Their interaction broke writer calls against int-coord arrays. Mirror the int/uint exemption from coords_to_transform inside the guard so the writer accepts int coords silently. Also fixes the error wording, which previously claimed "both axes are degenerate (1x1)" even when the array was not 1x1. Adds a regression test covering 2D and 3D int-coord round-trips. --- xrspatial/geotiff/_coords.py | 21 +++-- .../test_int_coords_round_trip_hotfix_1962.py | 77 +++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 xrspatial/geotiff/tests/test_int_coords_round_trip_hotfix_1962.py diff --git a/xrspatial/geotiff/_coords.py b/xrspatial/geotiff/_coords.py index c1e4493a..717d9798 100644 --- a/xrspatial/geotiff/_coords.py +++ b/xrspatial/geotiff/_coords.py @@ -262,16 +262,23 @@ def require_transform_for_georeferenced( ydim = da.dims[-2] xdim = da.dims[-1] if xdim in da.coords and ydim in da.coords: + x = da.coords[xdim].values + y = da.coords[ydim].values + # Integer coord dtype is the read-side no-georef sentinel + # (#1710, #1753, #1949). ``coords_to_transform`` returns ``None`` + # for these so int-coord arrays round-trip cleanly without a + # synthetic unit transform; mirror that here so the writer does + # not fail-close on a legitimate no-georef write. + if x.dtype.kind in ('i', 'u') or y.dtype.kind in ('i', 'u'): + return raise ValueError( f"Cannot infer GeoTIFF transform from a " f"{tuple(da.sizes.values())} array with spatial coords on " - f"both axes: both axes are degenerate (1x1), so neither " - f"coord array carries a pixel size step. 1xN and Nx1 inputs " - f"recover the pixel size from the non-degenerate axis, but " - f"a 1x1 cannot. Supply the affine transform explicitly via " - f"``attrs['transform']`` (rasterio 6-tuple " - f"``(px, 0, ox, 0, py, oy)``) or drop the coords if a " - f"non-georeferenced TIFF is desired." + f"both axes: neither coord array could yield a pixel size " + f"(1x1 inputs, or coords spaced non-uniformly). Supply the " + f"affine transform explicitly via ``attrs['transform']`` " + f"(rasterio 6-tuple ``(px, 0, ox, 0, py, oy)``) or drop the " + f"coords if a non-georeferenced TIFF is desired." ) diff --git a/xrspatial/geotiff/tests/test_int_coords_round_trip_hotfix_1962.py b/xrspatial/geotiff/tests/test_int_coords_round_trip_hotfix_1962.py new file mode 100644 index 00000000..e7fca446 --- /dev/null +++ b/xrspatial/geotiff/tests/test_int_coords_round_trip_hotfix_1962.py @@ -0,0 +1,77 @@ +"""Regression test for the int-coord write hotfix. + +PR #1953 added ``require_transform_for_georeferenced`` which raises when +both spatial dims are present in ``da.coords`` and no transform was +resolved. PR #1954 made ``coords_to_transform`` return ``None`` for +integer-dtype x/y coords as a no-georef sentinel (#1949). Their +interaction broke every writer call against an int-coord DataArray: +the resolver returned ``None``, then the guard raised. This test +locks in the int-dtype exemption that mirrors ``coords_to_transform``. +""" +import os +import tempfile + +import numpy as np +import xarray as xr + +from xrspatial.geotiff import open_geotiff, to_geotiff + + +def _tmp_path(name): + return os.path.join(tempfile.gettempdir(), name) + + +class TestIntCoordRoundTripHotfix1962: + def test_int_coords_2d_round_trip(self): + pixels = np.arange(20, dtype=np.float32).reshape(4, 5) + da = xr.DataArray( + pixels, + dims=['y', 'x'], + coords={ + 'y': np.arange(4, dtype=np.int64), + 'x': np.arange(5, dtype=np.int64), + }, + attrs={'long_name': 'int_coord_2d'}, + ) + path = _tmp_path('hotfix_1962_int_2d.tif') + try: + to_geotiff(da, path) + da2 = open_geotiff(path) + np.testing.assert_array_equal(da2.values, pixels) + # Read-side should re-emit the int-coord no-georef + # placeholders, confirming the no-georef contract held. + assert da2.coords['x'].dtype.kind in ('i', 'u') + assert da2.coords['y'].dtype.kind in ('i', 'u') + finally: + if os.path.exists(path): + os.remove(path) + + def test_int_coords_3d_band_y_x_round_trip(self): + pixels = np.arange(2 * 4 * 5, dtype=np.float32).reshape(2, 4, 5) + da = xr.DataArray( + pixels, + dims=['band', 'y', 'x'], + coords={ + 'band': np.arange(2, dtype=np.int64), + 'y': np.arange(4, dtype=np.int64), + 'x': np.arange(5, dtype=np.int64), + }, + attrs={'long_name': 'int_coord_3d'}, + ) + path = _tmp_path('hotfix_1962_int_3d.tif') + try: + to_geotiff(da, path) + da2 = open_geotiff(path) + # open_geotiff may emit (band, y, x) or (y, x, band) layout; + # compare on a band-by-band basis instead of guessing axis order. + arr = da2.values + assert arr.shape in ((2, 4, 5), (4, 5, 2)) + if arr.shape == (2, 4, 5): + np.testing.assert_array_equal(arr, pixels) + else: + np.testing.assert_array_equal( + np.moveaxis(arr, -1, 0), pixels + ) + finally: + if os.path.exists(path): + os.remove(path)