From 56a5c60ac5f2ed26d5964d4d33e4bdb9e77e1f68 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Tue, 19 May 2026 11:51:26 -0700 Subject: [PATCH 1/2] geotiff: gate non-uniform-coord check on marker, drop dtype exemption (#2133) The write-side ``_check_write_non_uniform_coords`` validator skipped its uniformity check whenever a coord was integer-dtype, on the assumption that integer coords were the reader's ``0..N-1`` placeholder. After #2087 / #2120 that assumption no longer holds: a user-authored int-coord grid is now a normal projected grid and must be validated. This change reads ``attrs[_NO_GEOREF_KEY]`` (threaded through the validation context from all three writer entry points) instead of peeking at ``dtype.kind``, so non-uniform int-coord grids raise ``NonUniformCoordsError`` and marker-stamped placeholder grids still skip the check. Also removes ``_is_no_georef_sentinel`` from ``_coords.py`` (the helper was diagnostic-only after #2120 and only the ``test_int_coord_sentinel_2087.py`` test pinned the predicate; the predicate moves into the test file as a local helper for the on-disk shape assertions). New tests cover: - non-uniform int coords without the marker now raise via the validator - the marker survives ``da.copy()`` / ``da.assign_attrs(...)`` - the marker is present on read across CPU eager / CPU dask / GPU eager / GPU dask - 3D ``(band, y, x)`` no-georef round-trips preserve the marker Closes #2133. --- xrspatial/geotiff/_coords.py | 44 +-- xrspatial/geotiff/_validation.py | 26 +- xrspatial/geotiff/_writers/eager.py | 3 + xrspatial/geotiff/_writers/gpu.py | 2 + .../tests/test_int_coord_sentinel_2087.py | 71 ++++- .../test_no_georef_attr_migration_2133.py | 265 ++++++++++++++++++ 6 files changed, 361 insertions(+), 50 deletions(-) create mode 100644 xrspatial/geotiff/tests/test_no_georef_attr_migration_2133.py diff --git a/xrspatial/geotiff/_coords.py b/xrspatial/geotiff/_coords.py index 64969b13e..731a55902 100644 --- a/xrspatial/geotiff/_coords.py +++ b/xrspatial/geotiff/_coords.py @@ -23,6 +23,8 @@ """ from __future__ import annotations +from collections.abc import Mapping + import numpy as np import xarray as xr @@ -36,7 +38,7 @@ _BAND_DIM_NAMES = ('band', 'bands', 'channel') -def _has_no_georef_marker(da: xr.DataArray) -> bool: +def _has_no_georef_marker(da) -> bool: """True iff ``da`` was stamped by the reader as carrying no georef. The reader sets ``attrs[_NO_GEOREF_KEY] = True`` whenever it emits @@ -50,40 +52,16 @@ def _has_no_georef_marker(da: xr.DataArray) -> bool: boolean ``True`` flips the writer into no-georef mode. A stray third-party stamp like ``attrs['_xrspatial_no_georef'] = 'yes'`` should not be treated as truthy and silently drop a transform. + + Inputs that are not xarray DataArrays (e.g. a raw ``numpy.ndarray`` + or ``cupy.ndarray`` passed directly to ``write_geotiff_gpu``) carry + no attrs and therefore no marker; return ``False`` rather than + raise so callers can use this as a plain predicate. """ - return da.attrs.get(_NO_GEOREF_KEY) is True - - -# Kept for diagnostic / test pinning only. The writer no longer -# calls this helper -- it checks ``attrs[_NO_GEOREF_KEY]`` instead -# (see :func:`_has_no_georef_marker` / issue #2120). Only the tests -# in ``test_int_coord_sentinel_2087.py`` reference it now. -def _is_no_georef_sentinel(coord: np.ndarray) -> bool: - """True iff ``coord`` matches the read-side no-georef placeholder shape. - - ``coords_from_pixel_geometry`` emits ``np.arange(start, stop, - dtype=np.int64)`` for the y/x coords whenever the source file - carries no GeoTIFF transform tags -- both for full reads - (``start=0``) and windowed reads (``start=window_offset``). See - issues #1710, #1753, #1949. - - The writer no longer treats coord shape alone as the no-georef - signal: it checks ``attrs[_NO_GEOREF_KEY]`` instead. See - :func:`_has_no_georef_marker` and issue #2120. This helper remains - available as a diagnostic for the placeholder shape, and the - existing tests in ``test_int_coord_sentinel_2087.py`` still pin - the predicate. It is no longer called from the writer path, so - user-authored int64 step-1 grids that match this pattern but lack - the marker keep their georef on round-trip. - """ - if coord.dtype != np.int64: - return False - n = len(coord) - if n < 1: + attrs = getattr(da, 'attrs', None) + if not isinstance(attrs, Mapping): return False - return bool(np.array_equal( - coord, np.arange(coord[0], coord[0] + n, dtype=np.int64) - )) + return attrs.get(_NO_GEOREF_KEY) is True def coords_from_pixel_geometry( diff --git a/xrspatial/geotiff/_validation.py b/xrspatial/geotiff/_validation.py index e8095fd14..ec3363855 100644 --- a/xrspatial/geotiff/_validation.py +++ b/xrspatial/geotiff/_validation.py @@ -731,19 +731,29 @@ def _check_write_non_uniform_coords(context: Mapping[str, Any]) -> None: the coords disagree with that step (variable cell size, gaps), the on-disk file silently misrepresents the geometry. - Exemption: int-dtype coords keep the existing #1969 sentinel - contract. Those coords are the 0..N-1 fallback the no-georef - reader emits, not geographic positions, so non-uniformity is - meaningless for them. + Exemption: arrays carrying the no-georef marker + (``attrs[_NO_GEOREF_KEY] is True``, stamped by the reader on + files without GeoTIFF transform tags) skip the check. Their + coords are the placeholder pixel-index fallback rather than + projected positions, so uniformity is meaningless for them. + Issue #2120 moved this signal off coord dtype: a user-authored + int-coord grid no longer earns the exemption just by having an + integer dtype, only by carrying the marker. Context keys consumed: * ``coord_y`` -- ``data.coords['y'].values`` (or equivalent). * ``coord_x`` -- ``data.coords['x'].values``. + * ``no_georef_marker`` -- ``data.attrs[_NO_GEOREF_KEY] is True``. Missing or non-numeric coords are out of scope (handled by other writer validation upstream). """ + if context.get('no_georef_marker') is True: + # Arrays the reader stamped as no-georef carry the 0..N-1 + # placeholder coords. Uniformity is not meaningful there; + # the writer skips transform synthesis entirely. + return for axis_name in ('y', 'x'): coord = context.get(f'coord_{axis_name}') if coord is None: @@ -752,10 +762,10 @@ def _check_write_non_uniform_coords(context: Mapping[str, Any]) -> None: if arr.size < 3: # Need at least 3 samples to detect non-uniformity. continue - if np.issubdtype(arr.dtype, np.integer): - # #1969 sentinel exemption. - continue - if not np.issubdtype(arr.dtype, np.floating): + if not ( + np.issubdtype(arr.dtype, np.floating) + or np.issubdtype(arr.dtype, np.integer) + ): continue diffs = np.diff(arr.astype(np.float64)) # Use a relative tolerance pegged to the step magnitude so that diff --git a/xrspatial/geotiff/_writers/eager.py b/xrspatial/geotiff/_writers/eager.py index 012ef683a..eb108e1f8 100644 --- a/xrspatial/geotiff/_writers/eager.py +++ b/xrspatial/geotiff/_writers/eager.py @@ -31,6 +31,7 @@ from .._backends._gpu_helpers import _is_gpu_data from .._coords import ( _BAND_DIM_NAMES, + _has_no_georef_marker, coords_to_transform as _coords_to_transform, require_transform_for_georeferenced as _require_transform_for_georeferenced, transform_from_attr as _transform_from_attr, @@ -304,6 +305,7 @@ def to_geotiff(data: xr.DataArray | np.ndarray, 'attrs_nodatavals': _attrs.get('nodatavals'), 'coord_y': _coord_y, 'coord_x': _coord_x, + 'no_georef_marker': _has_no_georef_marker(data), }) # Up-front validation: catch bad compression names before they reach @@ -862,6 +864,7 @@ def _write_vrt_tiled(data, vrt_path, *, crs=None, nodata=None, 'attrs_nodatavals': _attrs.get('nodatavals'), 'coord_y': _coord_y, 'coord_x': _coord_x, + 'no_georef_marker': _has_no_georef_marker(data), }) # Validate compression_level against codec-specific range diff --git a/xrspatial/geotiff/_writers/gpu.py b/xrspatial/geotiff/_writers/gpu.py index 4581ff2ba..c4789558d 100644 --- a/xrspatial/geotiff/_writers/gpu.py +++ b/xrspatial/geotiff/_writers/gpu.py @@ -24,6 +24,7 @@ ) from .._coords import ( _BAND_DIM_NAMES, + _has_no_georef_marker, coords_to_transform as _coords_to_transform, require_transform_for_georeferenced as _require_transform_for_georeferenced, transform_from_attr as _transform_from_attr, @@ -324,6 +325,7 @@ def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray, 'attrs_nodatavals': _attrs.get('nodatavals'), 'coord_y': _coord_y, 'coord_x': _coord_x, + 'no_georef_marker': _has_no_georef_marker(data), }) if max_z_error < 0: raise ValueError( diff --git a/xrspatial/geotiff/tests/test_int_coord_sentinel_2087.py b/xrspatial/geotiff/tests/test_int_coord_sentinel_2087.py index 71897c51e..8d4e7fe6a 100644 --- a/xrspatial/geotiff/tests/test_int_coord_sentinel_2087.py +++ b/xrspatial/geotiff/tests/test_int_coord_sentinel_2087.py @@ -29,10 +29,58 @@ import xarray as xr from xrspatial.geotiff import open_geotiff, to_geotiff -from xrspatial.geotiff._coords import _is_no_georef_sentinel +from xrspatial.geotiff._coords import _has_no_georef_marker +from xrspatial.geotiff._geotags import _NO_GEOREF_KEY + + +# --- Unit checks on the no-georef marker predicate ---------------------- +# +# Pre-#2133, ``xrspatial.geotiff._coords`` exported an +# ``_is_no_georef_sentinel`` helper that inspected coord shape (int64, +# ``np.arange``-style). The writer no longer consults that predicate; +# the only signal is ``attrs[_NO_GEOREF_KEY]``. These tests pin the +# marker-based predicate ``_has_no_georef_marker`` that replaced it. + + +def _arange_int64_shape(coord: np.ndarray) -> bool: + """Test-local predicate matching the read-side placeholder shape. + + ``coords_from_pixel_geometry`` emits ``np.arange(start, stop, + dtype=np.int64)`` for the y/x coords whenever the source file + carries no transform tags -- both for full reads (``start=0``) and + windowed reads (``start=window_offset``). This helper exists only + so a few legacy round-trip assertions can verify the on-disk shape + came back unchanged; it is not the production no-georef signal. + """ + if coord.dtype != np.int64: + return False + n = len(coord) + if n < 1: + return False + return bool(np.array_equal( + coord, np.arange(coord[0], coord[0] + n, dtype=np.int64) + )) -# --- Unit checks on the sentinel helper itself -------------------------- +@pytest.mark.parametrize( + "attrs,expected", + [ + ({_NO_GEOREF_KEY: True}, True), + ({}, False), + ({_NO_GEOREF_KEY: False}, False), + ({_NO_GEOREF_KEY: 'yes'}, False), # not identity-True + ({_NO_GEOREF_KEY: 1}, False), # truthy int, not True + ({'other': True}, False), + ], +) +def test_marker_predicate_identity_check(attrs, expected): + da = xr.DataArray( + np.zeros((2, 2), dtype=np.float32), + coords={'y': np.arange(2, dtype=np.int64), 'x': np.arange(2, dtype=np.int64)}, + dims=('y', 'x'), + attrs=attrs, + ) + assert _has_no_georef_marker(da) is expected @pytest.mark.parametrize( @@ -44,8 +92,8 @@ np.array([10, 11, 12], dtype=np.int64), ], ) -def test_sentinel_accepts_arange_int64(coord): - assert _is_no_georef_sentinel(coord) +def test_arange_int64_shape_helper_accepts(coord): + assert _arange_int64_shape(coord) @pytest.mark.parametrize( @@ -59,8 +107,8 @@ def test_sentinel_accepts_arange_int64(coord): np.array([], dtype=np.int64), # empty ], ) -def test_sentinel_rejects_non_arange(coord): - assert not _is_no_georef_sentinel(coord) +def test_arange_int64_shape_helper_rejects(coord): + assert not _arange_int64_shape(coord) # --- Round-trip behaviour ------------------------------------------------ @@ -144,8 +192,13 @@ def test_user_authored_int_grid_with_explicit_transform(tmp_path): def test_non_uniform_int_coords_raise(tmp_path): # Non-uniform integer spacing under the old sentinel silently - # stripped georef. Under the tightened sentinel it falls through - # to ``coords_to_transform`` and trips the uniform-spacing check. + # stripped georef. The pre-#2133 fallback caught this via the + # lower-level ``coords_to_transform`` ("not uniformly spaced" + # message). Post-#2133, the write-metadata validator catches it + # first with a different message because the integer-dtype + # exemption has been replaced with a marker-based one. Either + # message satisfies the contract: a non-uniform write must + # raise rather than silently misrepresent the grid. da = xr.DataArray( np.zeros((3, 3), dtype=np.float32), coords={ @@ -155,7 +208,7 @@ def test_non_uniform_int_coords_raise(tmp_path): dims=('y', 'x'), ) path = str(tmp_path / "tmp_2087_non_uniform.tif") - with pytest.raises(ValueError, match="not uniformly spaced"): + with pytest.raises(ValueError, match="non.?uniform"): to_geotiff(da, path) diff --git a/xrspatial/geotiff/tests/test_no_georef_attr_migration_2133.py b/xrspatial/geotiff/tests/test_no_georef_attr_migration_2133.py new file mode 100644 index 000000000..536a99df2 --- /dev/null +++ b/xrspatial/geotiff/tests/test_no_georef_attr_migration_2133.py @@ -0,0 +1,265 @@ +"""Finish migrating the no-georef signal from coord dtype to ``attrs`` (#2133). + +#2120 introduced ``attrs[_NO_GEOREF_KEY] = True`` as the read-side +stamp for files without GeoTIFF transform tags, and switched the writer's +no-georef detection to consult that marker rather than coord shape. One +validator (``_check_write_non_uniform_coords``) still inferred no-georef +from coord dtype: it skipped its uniformity check for any integer-dtype +coord array, on the assumption that integer coords were the reader's +0..N-1 placeholder. After #2087 / #2120 that assumption is wrong --- a +user-authored int-coord grid (the exact case #2087 fixed) bypassed +uniformity validation and silently wrote a misrepresented transform. + +These tests pin the rest of the migration: + +1. A user-authored int-coord grid with non-uniform spacing now trips + ``NonUniformCoordsError`` rather than silently writing a transform + derived from the first two values. +2. The no-georef marker is present on read across every backend + (CPU eager, CPU dask, GPU eager, GPU dask) so downstream code can + trust a single attr lookup regardless of how the file was opened. +3. The marker survives ``da.copy()`` and ``da.assign_attrs(...)`` + --- xarray's default propagation occasionally drops + underscore-prefixed keys, so this is worth pinning explicitly. +4. A 3D (band, y, x) no-georef round-trip preserves the marker. +""" +from __future__ import annotations + +import importlib.util + +import numpy as np +import pytest +import xarray as xr + +from xrspatial.geotiff import ( + NonUniformCoordsError, + open_geotiff, + to_geotiff, +) +from xrspatial.geotiff._coords import _NO_GEOREF_KEY, _has_no_georef_marker +from xrspatial.geotiff._writer import write + + +def _gpu_available() -> bool: + if importlib.util.find_spec("cupy") is None: + return False + try: + import cupy + return bool(cupy.cuda.is_available()) + except Exception: + return False + + +_HAS_GPU = _gpu_available() +_gpu_only = pytest.mark.skipif(not _HAS_GPU, reason="cupy + CUDA required") + + +@pytest.fixture +def no_georef_path_2133(tmp_path): + """4x4 float32 TIFF with no GeoTIFF tags.""" + arr = np.arange(16, dtype=np.float32).reshape(4, 4) + path = str(tmp_path / "no_georef_2133.tif") + write(arr, path, compression='none', tiled=False) + return path + + +# --- Acceptance criterion 1: validator gates on the marker, not dtype ---- + + +def test_non_uniform_int_coords_without_marker_raise(tmp_path): + """User-authored int coords with non-uniform spacing must not get a free pass. + + Pre-#2133, ``_check_write_non_uniform_coords`` exempted any integer + dtype on the assumption that integer coords were the reader's + placeholder. A non-uniform int-coord grid would slip past the + validator and either reach the lower-level uniform-spacing check + in ``coords_to_transform`` (raising a different error class) or, + in some windowed paths, silently write a misrepresented transform. + Post-#2133 the validator raises ``NonUniformCoordsError`` directly + because the marker is absent. + """ + da = xr.DataArray( + np.zeros((3, 3), dtype=np.float32), + coords={ + 'y': np.array([10, 11, 12], dtype=np.int64), + 'x': np.array([1, 2, 5], dtype=np.int64), # non-uniform + }, + dims=('y', 'x'), + ) + path = str(tmp_path / "tmp_2133_non_uniform_int.tif") + with pytest.raises(NonUniformCoordsError): + to_geotiff(da, path) + + +def test_non_uniform_int_coords_with_marker_pass(tmp_path): + """Marker-stamped arrays skip uniformity entirely (they are placeholders). + + The marker contract: a DataArray carrying ``attrs[_NO_GEOREF_KEY] + is True`` is treated as no-georef, the writer does not synthesise a + transform, and uniformity of the placeholder coords is irrelevant. + """ + da = xr.DataArray( + np.zeros((3, 3), dtype=np.float32), + coords={ + 'y': np.array([10, 11, 12], dtype=np.int64), + 'x': np.array([1, 2, 5], dtype=np.int64), # non-uniform, but marked + }, + dims=('y', 'x'), + attrs={_NO_GEOREF_KEY: True}, + ) + path = str(tmp_path / "tmp_2133_non_uniform_int_marked.tif") + # Must not raise. + to_geotiff(da, path) + out = open_geotiff(path) + assert out.attrs.get('transform') is None + assert out.attrs.get(_NO_GEOREF_KEY) is True + + +def test_non_uniform_float_coords_still_raise(tmp_path): + """Pre-existing float-coord behaviour is unchanged.""" + da = xr.DataArray( + np.zeros((3, 3), dtype=np.float32), + coords={ + 'y': np.array([10.0, 11.0, 12.0], dtype=np.float64), + 'x': np.array([1.0, 2.0, 5.0], dtype=np.float64), + }, + dims=('y', 'x'), + ) + path = str(tmp_path / "tmp_2133_non_uniform_float.tif") + with pytest.raises(NonUniformCoordsError): + to_geotiff(da, path) + + +def test_uniform_int_coords_still_write(tmp_path): + """A uniform int-coord grid without the marker writes a real transform. + + Regression guard: making the validator stricter must not break the + common case of user-authored int-coord projected grids + (the #2087 / #2120 fix). + """ + da = xr.DataArray( + np.zeros((3, 3), dtype=np.float32), + coords={ + 'y': np.array([200, 201, 202], dtype=np.int64), + 'x': np.array([100, 101, 102], dtype=np.int64), + }, + dims=('y', 'x'), + ) + path = str(tmp_path / "tmp_2133_uniform_int.tif") + to_geotiff(da, path) + out = open_geotiff(path) + assert out.attrs.get('transform') is not None + + +# --- Acceptance criterion 2: marker present after every read backend ----- + + +class TestMarkerOnRead: + """A no-georef file must surface the marker on every read path.""" + + def test_cpu_eager(self, no_georef_path_2133): + da = open_geotiff(no_georef_path_2133) + assert _has_no_georef_marker(da) + + def test_cpu_dask(self, no_georef_path_2133): + da = open_geotiff(no_georef_path_2133, chunks=2) + assert _has_no_georef_marker(da) + + @_gpu_only + def test_gpu_eager(self, no_georef_path_2133): + da = open_geotiff(no_georef_path_2133, gpu=True) + assert _has_no_georef_marker(da) + + @_gpu_only + def test_gpu_dask(self, no_georef_path_2133): + da = open_geotiff(no_georef_path_2133, gpu=True, chunks=2) + assert _has_no_georef_marker(da) + + +def test_georef_file_has_no_marker(tmp_path): + """Files with a transform must NOT carry the marker. + + Negative companion to the read-side tests: a real georeferenced + write/read round-trip must leave the marker absent so downstream + consumers can rely on its presence as a positive signal. + """ + da = xr.DataArray( + np.zeros((3, 3), dtype=np.float32), + coords={ + 'y': np.array([200.5, 201.5, 202.5], dtype=np.float64), + 'x': np.array([100.5, 101.5, 102.5], dtype=np.float64), + }, + dims=('y', 'x'), + ) + path = str(tmp_path / "tmp_2133_real_transform.tif") + to_geotiff(da, path) + out = open_geotiff(path) + assert not _has_no_georef_marker(out) + assert _NO_GEOREF_KEY not in out.attrs + + +# --- Acceptance criterion 3: marker survives copy / assign_attrs --------- + + +def test_marker_survives_copy(): + da = xr.DataArray( + np.zeros((2, 2), dtype=np.float32), + coords={'y': np.arange(2, dtype=np.int64), + 'x': np.arange(2, dtype=np.int64)}, + dims=('y', 'x'), + attrs={_NO_GEOREF_KEY: True}, + ) + copied = da.copy() + assert _has_no_georef_marker(copied) + + +def test_marker_survives_assign_attrs(): + da = xr.DataArray( + np.zeros((2, 2), dtype=np.float32), + coords={'y': np.arange(2, dtype=np.int64), + 'x': np.arange(2, dtype=np.int64)}, + dims=('y', 'x'), + attrs={_NO_GEOREF_KEY: True}, + ) + # assign_attrs returns a new array with the merged attrs dict. + # The marker must persist because attrs are passed through, not + # filtered on underscore prefix. + out = da.assign_attrs(extra='added') + assert _has_no_georef_marker(out) + assert out.attrs.get('extra') == 'added' + + +# --- Acceptance criterion 4: 3D no-georef round-trip --------------------- + + +def test_3d_no_georef_round_trip(tmp_path): + """A (band, y, x) no-georef array round-trips with the marker intact.""" + arr = np.zeros((3, 4, 4), dtype=np.float32) + for i in range(3): + arr[i] = i + src = xr.DataArray( + arr, + coords={ + 'band': np.arange(1, 4, dtype=np.int64), + 'y': np.arange(4, dtype=np.int64), + 'x': np.arange(4, dtype=np.int64), + }, + dims=('band', 'y', 'x'), + attrs={_NO_GEOREF_KEY: True}, + ) + path = str(tmp_path / "tmp_2133_3d_no_georef.tif") + to_geotiff(src, path) + out = open_geotiff(path) + assert _has_no_georef_marker(out) + assert out.attrs.get('transform') is None + # Spatial coords come back as int64 placeholders. + assert out.coords['y'].dtype == np.int64 + assert out.coords['x'].dtype == np.int64 + # The reader returns ``(y, x, band)``; transpose before comparing + # to the ``(band, y, x)`` source. + band_dim = next( + d for d in out.dims if d not in ('y', 'x') + ) + np.testing.assert_array_equal( + out.transpose(band_dim, 'y', 'x').values, src.values + ) From c1b88b124bd2337344c39521e93ee50077e50834 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Tue, 19 May 2026 11:54:58 -0700 Subject: [PATCH 2/2] Address review nits: document identity-check invariant, type-annotate predicate (#2133) - attrs_contract.rst: pin the ``is True`` identity-check semantics for ``_xrspatial_no_georef`` so a third-party stamp like ``'yes'`` cannot silently flip the writer into no-georef mode. - _coords.py: restore an ``Any`` annotation on ``_has_no_georef_marker`` now that it accepts non-DataArray inputs (raw ``numpy.ndarray`` / ``cupy.ndarray`` passed straight to ``write_geotiff_gpu``). Keeps the signature self-documenting. The third review nit (bool dtype falling through the dtype guard) was a false alarm: ``np.issubdtype(np.bool_, np.integer)`` is ``False`` in numpy, so bool coord arrays are already rejected. --- docs/source/user_guide/attrs_contract.rst | 7 ++++++- xrspatial/geotiff/_coords.py | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/source/user_guide/attrs_contract.rst b/docs/source/user_guide/attrs_contract.rst index 7bb80a310..a8f03caca 100644 --- a/docs/source/user_guide/attrs_contract.rst +++ b/docs/source/user_guide/attrs_contract.rst @@ -113,7 +113,12 @@ write. fake unit transform. Absence of the marker means the array has spatial coords the writer can interpret as georef. A caller can opt into no-georef writes on a hand-built array - by setting this attr explicitly. See issue #2120. + by setting this attr explicitly. The writer uses an identity + check (``attrs[_xrspatial_no_georef] is True``), so only the + exact boolean ``True`` flips the no-georef path; truthy + strings like ``'yes'`` or ``1`` are ignored and the writer + proceeds with normal transform synthesis. See issues #2120 + and #2133. Compatibility aliases diff --git a/xrspatial/geotiff/_coords.py b/xrspatial/geotiff/_coords.py index 731a55902..8feb664c9 100644 --- a/xrspatial/geotiff/_coords.py +++ b/xrspatial/geotiff/_coords.py @@ -24,6 +24,7 @@ from __future__ import annotations from collections.abc import Mapping +from typing import Any import numpy as np import xarray as xr @@ -38,7 +39,7 @@ _BAND_DIM_NAMES = ('band', 'bands', 'channel') -def _has_no_georef_marker(da) -> bool: +def _has_no_georef_marker(da: Any) -> bool: """True iff ``da`` was stamped by the reader as carrying no georef. The reader sets ``attrs[_NO_GEOREF_KEY] = True`` whenever it emits