Skip to content

Commit 6118050

Browse files
committed
geotiff: validate coord regularity in _coords_to_transform (#1720)
`_coords_to_transform` used `x[1] - x[0]` and `y[1] - y[0]` as the affine pixel sizes without checking that the rest of the spacing matched. GeoTIFF only supports an affine transform, so non-uniform coords cannot be expressed faithfully; the writer silently used the first-step spacing and produced a wrong transform. Validate `np.diff` against the median step with a 1e-6 relative tolerance and raise `ValueError` naming the offending dim and the worst residual. Median (not mean) keeps a single bad sample from shifting the reference step. Also fix `test_pixel_is_point_round_trip`, whose hand-typed coords had ~3.6e-3 relative deviation and were exactly the silent-wrong- transform case the new check now catches. Closes #1720
1 parent 0699aae commit 6118050

3 files changed

Lines changed: 135 additions & 2 deletions

File tree

xrspatial/geotiff/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,30 @@ def _coords_to_transform(da: xr.DataArray) -> GeoTransform | None:
299299
if len(x) < 2 or len(y) < 2:
300300
return None
301301

302+
# GeoTIFF only supports an affine transform; non-uniform spacing
303+
# cannot be expressed faithfully. Validate up-front instead of
304+
# silently writing a transform that only matches the first step.
305+
def _is_regular(coord, name):
306+
diffs = np.diff(coord)
307+
# Use median (not mean) so a single bad sample doesn't shift
308+
# the reference step. The 1e-6 relative tolerance is forgiving
309+
# for float artifacts in otherwise-uniform coords.
310+
step = float(np.median(diffs))
311+
if step == 0:
312+
raise ValueError(
313+
f"{name} coords are constant; cannot infer pixel size"
314+
)
315+
rel = float(np.max(np.abs(diffs - step)) / abs(step))
316+
if rel > 1e-6:
317+
raise ValueError(
318+
f"{name} coords are not uniformly spaced "
319+
f"(max relative deviation {rel:.3e} exceeds 1e-6); "
320+
f"GeoTIFF requires an affine transform."
321+
)
322+
323+
_is_regular(x, "x")
324+
_is_regular(y, "y")
325+
302326
pixel_width = float(x[1] - x[0])
303327
pixel_height = float(y[1] - y[0])
304328

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Regression test for issue #1720.
2+
3+
``_coords_to_transform`` previously read ``x[1] - x[0]`` and
4+
``y[1] - y[0]`` as the pixel sizes without checking that the rest of
5+
the spacing matched. GeoTIFF supports only an affine transform, so
6+
non-uniform coords cannot be expressed faithfully; the writer would
7+
silently use the first-step spacing and produce a wrong transform.
8+
9+
The fix validates ``np.diff(x)`` and ``np.diff(y)`` against a relative
10+
tolerance of ``1e-6`` of the median step and raises ``ValueError`` if
11+
spacing is non-uniform.
12+
"""
13+
from __future__ import annotations
14+
15+
import numpy as np
16+
import pytest
17+
import xarray as xr
18+
19+
from xrspatial.geotiff import _coords_to_transform, to_geotiff
20+
21+
22+
def _make_da(x_coords, y_coords):
23+
arr = np.zeros((len(y_coords), len(x_coords)), dtype=np.uint8)
24+
return xr.DataArray(
25+
arr,
26+
dims=['y', 'x'],
27+
coords={'y': np.asarray(y_coords), 'x': np.asarray(x_coords)},
28+
)
29+
30+
31+
def test_uniform_coords_ok():
32+
"""Uniform coords write successfully (no regression)."""
33+
da = _make_da(np.linspace(500.0, 700.0, 20), np.linspace(100.0, 200.0, 10))
34+
gt = _coords_to_transform(da)
35+
assert gt is not None
36+
np.testing.assert_allclose(gt.pixel_width, (700.0 - 500.0) / 19)
37+
np.testing.assert_allclose(gt.pixel_height, (200.0 - 100.0) / 9)
38+
39+
40+
def test_uniform_coords_roundtrip_to_geotiff_1720(tmp_path):
41+
"""End-to-end write succeeds on uniform coords."""
42+
da = _make_da(np.linspace(500.0, 700.0, 20), np.linspace(100.0, 200.0, 10))
43+
p = str(tmp_path / 'uniform_1720.tif')
44+
to_geotiff(da, p)
45+
46+
47+
def test_non_uniform_x_raises_1720():
48+
"""Non-uniform x coords raise ValueError naming x."""
49+
# Mostly-uniform x with one stretched gap to expose the bug.
50+
x = np.array([0.0, 1.0, 2.0, 3.0, 5.0])
51+
y = np.linspace(0.0, 10.0, 11)
52+
da = _make_da(x, y)
53+
with pytest.raises(ValueError, match=r"\bx coords are not uniformly spaced"):
54+
_coords_to_transform(da)
55+
56+
57+
def test_non_uniform_y_raises_1720():
58+
"""Non-uniform y coords raise ValueError naming y."""
59+
x = np.linspace(0.0, 10.0, 11)
60+
y = np.array([0.0, 1.0, 2.0, 3.0, 5.0])
61+
da = _make_da(x, y)
62+
with pytest.raises(ValueError, match=r"\by coords are not uniformly spaced"):
63+
_coords_to_transform(da)
64+
65+
66+
def test_jitter_within_tolerance_ok_1720():
67+
"""Float jitter within 1e-6 relative tolerance writes successfully."""
68+
x = np.linspace(0.0, 100.0, 11)
69+
# Add jitter at ~1e-8 relative scale — well below the 1e-6 threshold.
70+
rng = np.random.default_rng(0)
71+
x = x + rng.uniform(-1e-7, 1e-7, size=x.shape)
72+
y = np.linspace(0.0, 50.0, 6)
73+
da = _make_da(x, y)
74+
gt = _coords_to_transform(da)
75+
assert gt is not None
76+
77+
78+
def test_jitter_just_above_tolerance_raises_1720():
79+
"""Jitter just above the 1e-6 relative tolerance raises ValueError."""
80+
# Step size is 10; one diff is 10 + 1e-4 -> relative deviation ~1e-5,
81+
# which exceeds the 1e-6 threshold.
82+
x = np.array([0.0, 10.0, 20.0001, 30.0001, 40.0001])
83+
y = np.linspace(0.0, 50.0, 6)
84+
da = _make_da(x, y)
85+
with pytest.raises(ValueError, match=r"max relative deviation"):
86+
_coords_to_transform(da)
87+
88+
89+
def test_two_sample_coords_ok_1720():
90+
"""Two-sample coords (one diff) trivially pass the regularity check."""
91+
x = np.array([0.0, 10.0])
92+
y = np.array([0.0, 5.0])
93+
da = _make_da(x, y)
94+
gt = _coords_to_transform(da)
95+
assert gt is not None
96+
np.testing.assert_allclose(gt.pixel_width, 10.0)
97+
np.testing.assert_allclose(gt.pixel_height, 5.0)
98+
99+
100+
def test_constant_coords_raises_1720():
101+
"""Constant coords (step == 0) raise ValueError."""
102+
x = np.array([0.0, 0.0, 0.0, 0.0])
103+
y = np.linspace(0.0, 10.0, 4)
104+
da = _make_da(x, y)
105+
with pytest.raises(ValueError, match=r"x coords are constant"):
106+
_coords_to_transform(da)

xrspatial/geotiff/tests/test_edge_cases.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -512,8 +512,11 @@ def test_crs_projected(self, tmp_path):
512512

513513
def test_pixel_is_point_round_trip(self, tmp_path):
514514
"""PixelIsPoint raster_type preserves origin correctly."""
515-
y = np.array([41.0, 40.999722, 40.999444, 40.999167])
516-
x = np.array([-75.0, -74.999722, -74.999444, -74.999167])
515+
# Use truly uniform coords; hand-typed fractional decimals had
516+
# ~3.6e-3 relative deviation between steps and silently produced
517+
# a wrong transform before issue #1720 enforced regularity.
518+
y = np.linspace(41.0, 40.999167, 4)
519+
x = np.linspace(-75.0, -74.999167, 4)
517520
da = xr.DataArray(
518521
np.arange(16, dtype=np.float32).reshape(4, 4),
519522
dims=['y', 'x'], coords={'y': y, 'x': x},

0 commit comments

Comments
 (0)