diff --git a/.claude/sweep-metadata-state.csv b/.claude/sweep-metadata-state.csv index 80e68447..a3f53c87 100644 --- a/.claude/sweep-metadata-state.csv +++ b/.claude/sweep-metadata-state.csv @@ -1,4 +1,3 @@ module,last_inspected,issue,severity_max,categories_found,notes -geotiff,2026-05-12,1710,MEDIUM,2,"open_geotiff/read_geotiff_dask/read_geotiff_gpu windowed reads of non-georef TIFFs produced float64 half-pixel-shifted coords while full reads produced int64 [0,1,2,...] coords. Affected every backend the same way; not a backend parity bug, a windowed-vs-full inconsistency. _populate_attrs_from_geo_info also fabricated an identity transform attr on non-georef files. Fixed by threading has_georef through all windowed coord paths and through the transform attr emitter (#1710)." -geotiff,2026-05-12,1739,HIGH,1;4,"COG overview reads dropped attrs['nodata'] from level 0, so the writer-baked sentinel survived as raw data in the overview pixels (silent numerical corruption). extract_geo_info_with_overview_inheritance was inheriting CRS-side fields only; extended to per-IFD pass-through tags (nodata, gdal_metadata*, resolution*, colormap, extra_tags, image_description, extra_samples). All four backends affected (numpy/dask/cupy/dask+cupy). Fixed in #1739." +geotiff,2026-05-12,1753,HIGH,2,"read_geotiff_gpu stripped-fallback windowed read on a non-georef TIFF emitted float64 [-0.5, -1.5, ...] coords via the default unit GeoTransform placeholder, while the eager numpy and dask paths emit int64 file-relative pixel coords. Regression of #1710 (fix missed the stripped GPU branch). The tiled-GPU helper _gpu_apply_window_band already gates on has_georef correctly; the stripped fallback only checked t is None. Fixed by routing both non-georef and t-is-None cases through the integer pixel-coord branch and using file-relative offsets to match every other backend. Verified 4-backend parity (numpy / cupy / dask+numpy / dask+cupy)." reproject,2026-05-10,1572;1573,HIGH,1;3;4,geoid_height_raster dropped input attrs and used dims[-2:] for 3D inputs (#1572). reproject/merge ignored nodatavals (rasterio convention) when rioxarray absent (#1573). Fixed in same branch. diff --git a/xrspatial/geotiff/__init__.py b/xrspatial/geotiff/__init__.py index feffe706..ef2a98f7 100644 --- a/xrspatial/geotiff/__init__.py +++ b/xrspatial/geotiff/__init__.py @@ -2720,14 +2720,21 @@ def read_geotiff_gpu(source: str, *, arr_gpu = arr_gpu.astype(target) # ``read_to_array`` already applied window + band slicing, so # ``arr_gpu`` is at output shape. Compute coords for that - # shape without re-slicing. + # shape without re-slicing. Mirror the eager-numpy / + # ``read_geotiff_dask`` / ``_gpu_apply_window_band`` checks + # against ``has_georef``: a non-georef TIFF carries a + # default ``GeoTransform()`` placeholder (``t is None`` is + # never true here) so a transform-based coord path would + # emit synthetic ``[-0.5, -1.5, ...]`` floats instead of + # the integer pixel coords every other backend produces + # (#1753 / regression of #1710). if window is not None: r0, c0, r1, c1 = window t = geo_info.transform - if t is None: + if t is None or not getattr(geo_info, 'has_georef', True): coords = { - 'y': np.arange(r1 - r0, dtype=np.int64), - 'x': np.arange(c1 - c0, dtype=np.int64), + 'y': np.arange(r0, r1, dtype=np.int64), + 'x': np.arange(c0, c1, dtype=np.int64), } elif geo_info.raster_type == RASTER_PIXEL_IS_POINT: coords = { diff --git a/xrspatial/geotiff/tests/test_no_georef_windowed_coords_1710.py b/xrspatial/geotiff/tests/test_no_georef_windowed_coords_1710.py index 4c719ef0..9117d8eb 100644 --- a/xrspatial/geotiff/tests/test_no_georef_windowed_coords_1710.py +++ b/xrspatial/geotiff/tests/test_no_georef_windowed_coords_1710.py @@ -119,6 +119,34 @@ def test_dask_cupy_windowed_integer_coords(self, no_georef_path_1710): assert da.y.dtype == np.int64 np.testing.assert_array_equal(da.y.values, np.arange(4)) + def test_offset_window_integer_coords(self, no_georef_path_1710): + """GPU windowed read at a non-zero origin: the stripped-GPU + fallback in ``read_geotiff_gpu`` checked ``t is None`` instead + of ``has_georef`` (issue #1753 / regression of #1710), so a + non-georef TIFF emitted ``[-0.5, -1.5, ...]`` via the unit + ``GeoTransform`` placeholder. Pin the contract that the offset + window produces file-relative integer coords identical to the + eager numpy path. + """ + da = open_geotiff(no_georef_path_1710, gpu=True, + window=(2, 3, 6, 7)) + assert da.y.dtype == np.int64 + assert da.x.dtype == np.int64 + np.testing.assert_array_equal(da.y.values, np.arange(2, 6)) + np.testing.assert_array_equal(da.x.values, np.arange(3, 7)) + + def test_offset_window_no_transform_attr(self, no_georef_path_1710): + """Non-georef GPU windowed read still must not advertise a + fabricated ``attrs['transform']`` -- ``_populate_attrs_from_geo_info`` + gates on ``has_georef`` too, so flag the round-trip here. + """ + da = open_geotiff(no_georef_path_1710, gpu=True, + window=(2, 3, 6, 7)) + assert 'transform' not in da.attrs, ( + f"non-georef GPU windowed read should not carry a " + f"fabricated transform; got attrs={dict(da.attrs)}" + ) + class TestBackendParity: """Full read and windowed read must agree on coord dtype and values