Skip to content

Commit abf1de3

Browse files
authored
Reject a zero or non-finite ModelPixelScale on GeoTIFF read (#3331) (#3332)
* Reject a zero or non-finite ModelPixelScale on GeoTIFF read (#3331) The direct-TIFF read path built coordinate arrays as arange(N) * pixel_width + origin straight from the ModelPixelScale (or ModelTransformation diagonal) with no finite-nonzero check. A zero pixel size collapsed the whole axis onto the origin; a NaN / Inf one filled it with NaN / Inf. Either way the read returned a degenerate raster with no error, so a downstream spatial op ran on coordinates that did not describe the data. The VRT read path already rejected a zero res_x / res_y (VRTUnsupportedError) and the writer rejected a zero-step coord axis (NonUniformCoordsError); this brings the direct-TIFF read path in line. Add DegeneratePixelSizeError (a GeoTIFFAmbiguousMetadataError subclass) and a _check_finite_nonzero_pixel_size guard in _extract_transform, covering the scale-only, ModelTiepoint + ModelPixelScale, and ModelTransformation paths. The guard sits in the shared geo-extraction path, so all four backends (numpy / dask / gpu / dask+gpu) reject the file identically. Closes #3331 * sweep-accuracy: record geotiff Pass 28 (MEDIUM #3331/PR #3332) * Add DegeneratePixelSizeError to the public-API release gate (#3331) The new error is exported from xrspatial.geotiff.__all__, so the frozen expected-name set in test_all_lists_supported_functions must list it too, or the gate fails on the set-equality assertion.
1 parent 1d11bd3 commit abf1de3

7 files changed

Lines changed: 306 additions & 46 deletions

File tree

.claude/sweep-accuracy-state.csv

Lines changed: 41 additions & 41 deletions
Large diffs are not rendered by default.

docs/source/user_guide/geotiff_safe_io.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ the ambiguous-metadata family at once.
204204
- The affine transform has non-zero rotation / shear terms.
205205
- ``allow_rotated=True`` (experimental). The opt-in returns the
206206
pixel grid without the geospatial assumption.
207+
* - :class:`~xrspatial.geotiff.DegeneratePixelSizeError`
208+
- The ``ModelPixelScale`` (or ``ModelTransformation`` diagonal)
209+
declares a zero or non-finite pixel size, which would build a
210+
constant or all-NaN coordinate axis.
211+
- No opt-in. Re-export the file with a non-zero, finite pixel size.
207212
* - :class:`~xrspatial.geotiff.NonUniformCoordsError`
208213
- The DataArray coords on write imply a non-uniform pixel grid.
209214
- Regrid the array to uniform spacing first.

xrspatial/geotiff/__init__.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@
6565
from ._coords import \
6666
transform_tuple_from_pixel_geometry as _transform_tuple_from_pixel_geometry # noqa: F401
6767
from ._crs import _resolve_crs_to_wkt, _wkt_to_epsg # noqa: F401
68-
from ._errors import (ConflictingCRSError, ConflictingNodataError, DuplicateIFDTagError,
69-
GeoTIFFAmbiguousMetadataError, InconsistentGeoKeysError, InvalidCRSCodeError,
70-
InvalidIntegerNodataError, MalformedScaleOffsetError, MixedBandMetadataError,
71-
NonRepresentableEPSGCRSError, NonUniformCoordsError,
68+
from ._errors import (ConflictingCRSError, ConflictingNodataError, DegeneratePixelSizeError,
69+
DuplicateIFDTagError, GeoTIFFAmbiguousMetadataError, InconsistentGeoKeysError,
70+
InvalidCRSCodeError, InvalidIntegerNodataError, MalformedScaleOffsetError,
71+
MixedBandMetadataError, NonRepresentableEPSGCRSError, NonUniformCoordsError,
7272
RemoteStableSourcesOnlyError, RotatedTransformError, UnknownCRSModelTypeError,
7373
UnparseableCRSError, UnsupportedGeoTIFFFeatureError,
7474
VRTStableSourcesOnlyError, VRTUnsupportedError)
@@ -110,6 +110,7 @@
110110
'CloudSizeLimitError',
111111
'ConflictingCRSError',
112112
'ConflictingNodataError',
113+
'DegeneratePixelSizeError',
113114
'DuplicateIFDTagError',
114115
'GeoTIFFAmbiguousMetadataError',
115116
'GeoTIFFFallbackWarning',

xrspatial/geotiff/_errors.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
├── InvalidCRSCodeError
1919
├── UnparseableCRSError
2020
├── RotatedTransformError
21+
├── DegeneratePixelSizeError
2122
├── NonUniformCoordsError
2223
├── MixedBandMetadataError
2324
├── ConflictingCRSError
@@ -66,6 +67,29 @@ class RotatedTransformError(GeoTIFFAmbiguousMetadataError):
6667
"""
6768

6869

70+
class DegeneratePixelSizeError(GeoTIFFAmbiguousMetadataError):
71+
"""A georeferenced transform declares a zero or non-finite pixel size.
72+
73+
Raised on read when the axis-aligned ``ModelPixelScale`` (or the
74+
``ModelTransformation`` diagonal) carries a zero, NaN, or +/-Inf
75+
``pixel_width`` / ``pixel_height``. The reader builds coordinate
76+
arrays as ``arange(N) * pixel_width + origin``, so a zero pixel size
77+
collapses the whole axis onto the origin (a constant, non-
78+
georeferenced coordinate array) and a non-finite pixel size produces
79+
an all-NaN / all-Inf axis. Either way a downstream spatial op would
80+
silently run on coordinates that do not describe the data.
81+
82+
The VRT read path already rejects a zero ``res_x`` / ``res_y`` with
83+
:class:`VRTUnsupportedError`, and the writer rejects a zero-step
84+
coordinate axis with :class:`NonUniformCoordsError`; this extends the
85+
same fail-closed contract to the direct-TIFF read path.
86+
87+
Subclasses :class:`GeoTIFFAmbiguousMetadataError` (and therefore
88+
``ValueError``) so existing ``except ValueError`` callers keep
89+
catching the case.
90+
"""
91+
92+
6993
class NonUniformCoordsError(GeoTIFFAmbiguousMetadataError):
7094
"""DataArray coords disagree with the implied transform on write.
7195
@@ -340,6 +364,7 @@ class UnsupportedGeoTIFFFeatureError(ValueError):
340364
__all__ = [
341365
"ConflictingCRSError",
342366
"ConflictingNodataError",
367+
"DegeneratePixelSizeError",
343368
"DuplicateIFDTagError",
344369
"GeoTIFFAmbiguousMetadataError",
345370
"InconsistentGeoKeysError",

xrspatial/geotiff/_geotags.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""GeoTIFF tag interpretation: CRS, affine transform, GeoKeys."""
22
from __future__ import annotations
33

4+
import math
45
from dataclasses import dataclass, field
56

67
from ._dtypes import resolve_bits_per_sample, tiff_dtype_to_numpy
7-
from ._errors import NonRepresentableEPSGCRSError, RotatedTransformError, UnknownCRSModelTypeError
8+
from ._errors import (DegeneratePixelSizeError, NonRepresentableEPSGCRSError, RotatedTransformError,
9+
UnknownCRSModelTypeError)
810
from ._header import (IFD, TAG_BITS_PER_SAMPLE, TAG_COMPRESSION, TAG_EXTRA_SAMPLES,
911
TAG_GDAL_METADATA, TAG_GDAL_NODATA, TAG_GEO_ASCII_PARAMS,
1012
TAG_GEO_DOUBLE_PARAMS, TAG_GEO_KEY_DIRECTORY, TAG_IMAGE_LENGTH,
@@ -631,6 +633,36 @@ def _validate_tiepoint_consistency(tiepoint: tuple,
631633
raise NotImplementedError(f"{primary}\n{cause}\n{hint}")
632634

633635

636+
def _check_finite_nonzero_pixel_size(pixel_width: float,
637+
pixel_height: float,
638+
source: str) -> None:
639+
"""Reject a zero or non-finite axis-aligned pixel size.
640+
641+
The reader builds coordinate arrays as ``arange(N) * pixel_width +
642+
origin``, so a zero ``pixel_width`` / ``pixel_height`` collapses the
643+
whole axis onto the origin and a NaN / +/-Inf one fills the axis with
644+
NaN / Inf -- a degenerate raster the rest of the pipeline would
645+
silently consume. The VRT read path already raises for this in
646+
``_vrt_validation`` and the writer raises ``NonUniformCoordsError``
647+
for a zero-step axis; this brings the direct-TIFF read path in line.
648+
649+
``source`` names the tag the pixel size came from so the message
650+
points at the offending bytes.
651+
"""
652+
for axis, value in (("pixel_width", pixel_width),
653+
("pixel_height", pixel_height)):
654+
if value == 0.0 or not math.isfinite(value):
655+
raise DegeneratePixelSizeError(
656+
f"{source} yields a degenerate {axis}={value!r}. The "
657+
f"reader builds pixel-to-world coordinates as "
658+
f"arange(N) * {axis} + origin, so a zero size collapses "
659+
f"the axis onto the origin and a non-finite size fills "
660+
f"it with NaN / Inf. Re-export the file with a non-zero, "
661+
f"finite ModelPixelScale (or ModelTransformation "
662+
f"diagonal)."
663+
)
664+
665+
634666
def _extract_transform(ifd: IFD,
635667
allow_rotated: bool = False
636668
) -> tuple[GeoTransform, bool]:
@@ -723,6 +755,8 @@ def _extract_transform(ifd: IFD,
723755
return GeoTransform(
724756
rotated_affine=(m[0], m[1], m[3], m[4], m[5], m[7]),
725757
), False
758+
_check_finite_nonzero_pixel_size(
759+
m[0], m[5], "ModelTransformationTag (34264) diagonal")
726760
return GeoTransform(
727761
origin_x=m[3],
728762
origin_y=m[7],
@@ -741,6 +775,11 @@ def _extract_transform(ifd: IFD,
741775
sx = scale[0] if len(scale) > 0 else 1.0
742776
sy = scale[1] if len(scale) > 1 else 1.0
743777

778+
# ``pixel_height`` is stored as ``-sy``; a zero / non-finite ``sy``
779+
# is degenerate either way, so check the magnitudes here, before
780+
# both the tiepoint+scale and the scale-only returns below.
781+
_check_finite_nonzero_pixel_size(sx, sy, "ModelPixelScaleTag (33550)")
782+
744783
if tiepoint is not None:
745784
if not isinstance(tiepoint, tuple):
746785
tiepoint = (tiepoint,)

xrspatial/geotiff/tests/release_gates/test_features.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2819,6 +2819,10 @@ def test_all_lists_supported_functions(self):
28192819
# importing from the private ``_errors`` module.
28202820
'ConflictingCRSError',
28212821
'ConflictingNodataError',
2822+
# Read-side fail-closed on a zero or non-finite ModelPixelScale
2823+
# / ModelTransformation diagonal (issue #3331), replacing the
2824+
# legacy silent build of a constant or all-NaN coordinate axis.
2825+
'DegeneratePixelSizeError',
28222826
# Issue #2483: read-side fail-closed on TIFF directories that
28232827
# repeat a tag, replacing the legacy silent last-wins parse.
28242828
'DuplicateIFDTagError',
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Reject a zero or non-finite ModelPixelScale on read (issue #3331).
2+
3+
The direct-TIFF read path built coordinate arrays as
4+
``arange(N) * pixel_width + origin`` straight from the
5+
``ModelPixelScale`` / ``ModelTransformation`` diagonal, with no
6+
finite-nonzero check. A zero pixel size collapsed the whole axis onto
7+
the origin; a NaN / Inf one filled it with NaN / Inf. Either way the
8+
read returned a degenerate raster with no error.
9+
10+
The VRT read path already rejected a zero ``res_x`` / ``res_y``
11+
(``VRTUnsupportedError``) and the writer rejected a zero-step coord axis
12+
(``NonUniformCoordsError``). This test pins the matching rejection on the
13+
direct-TIFF read path: ``DegeneratePixelSizeError``.
14+
"""
15+
from __future__ import annotations
16+
17+
import struct
18+
19+
import numpy as np
20+
import pytest
21+
22+
from xrspatial.geotiff._errors import DegeneratePixelSizeError, GeoTIFFAmbiguousMetadataError
23+
from xrspatial.geotiff._geotags import _extract_transform
24+
from xrspatial.geotiff._header import parse_all_ifds, parse_header
25+
26+
_BO = '<'
27+
28+
29+
def _assemble_tiff(extra_geo_tags: list[tuple]) -> bytes:
30+
"""Build a 2x2 single-strip TIFF with the given geo tags.
31+
32+
``extra_geo_tags`` is a list of ``(tag, doubles_tuple)`` entries
33+
appended as TIFF DOUBLE (type 12) arrays.
34+
"""
35+
width, height = 2, 2
36+
pixels = np.zeros((height, width), dtype=np.uint8)
37+
38+
tag_list = []
39+
40+
def add_short(tag, val):
41+
tag_list.append((tag, 3, 1, struct.pack(f'{_BO}H', val)))
42+
43+
def add_long(tag, val):
44+
tag_list.append((tag, 4, 1, struct.pack(f'{_BO}I', val)))
45+
46+
def add_doubles(tag, vals):
47+
tag_list.append(
48+
(tag, 12, len(vals), struct.pack(f'{_BO}{len(vals)}d', *vals)))
49+
50+
add_short(256, width) # ImageWidth
51+
add_short(257, height) # ImageLength
52+
add_short(258, 8) # BitsPerSample
53+
add_short(259, 1) # Compression: none
54+
add_short(262, 1) # PhotometricInterpretation
55+
add_short(277, 1) # SamplesPerPixel
56+
add_short(278, height) # RowsPerStrip
57+
add_long(273, 0) # StripOffsets (placeholder)
58+
add_long(279, len(pixels.tobytes())) # StripByteCounts
59+
add_short(339, 1) # SampleFormat
60+
for tag, vals in extra_geo_tags:
61+
add_doubles(tag, list(vals))
62+
63+
tag_list.sort(key=lambda t: t[0])
64+
65+
num_entries = len(tag_list)
66+
ifd_start = 8
67+
ifd_size = 2 + 12 * num_entries + 4
68+
69+
overflow = bytearray()
70+
overflow_offsets = {}
71+
for tag, _typ, _count, raw in tag_list:
72+
if len(raw) > 4:
73+
overflow_offsets[tag] = ifd_start + ifd_size + len(overflow)
74+
overflow.extend(raw)
75+
if len(overflow) % 2:
76+
overflow.append(0)
77+
78+
pixel_start = ifd_start + ifd_size + len(overflow)
79+
80+
patched = []
81+
for tag, typ, count, raw in tag_list:
82+
if tag == 273:
83+
patched.append((tag, typ, count, struct.pack(f'{_BO}I', pixel_start)))
84+
else:
85+
patched.append((tag, typ, count, raw))
86+
tag_list = patched
87+
88+
out = bytearray()
89+
out.extend(b'II')
90+
out.extend(struct.pack(f'{_BO}H', 42))
91+
out.extend(struct.pack(f'{_BO}I', ifd_start))
92+
out.extend(struct.pack(f'{_BO}H', num_entries))
93+
for tag, typ, count, raw in tag_list:
94+
out.extend(struct.pack(f'{_BO}HHI', tag, typ, count))
95+
if len(raw) <= 4:
96+
out.extend(raw.ljust(4, b'\x00'))
97+
else:
98+
out.extend(struct.pack(f'{_BO}I', overflow_offsets[tag]))
99+
out.extend(struct.pack(f'{_BO}I', 0))
100+
out.extend(overflow)
101+
out.extend(pixels.tobytes())
102+
return bytes(out)
103+
104+
105+
def _tiff_with_pixel_scale(sx: float, sy: float, with_tiepoint: bool) -> bytes:
106+
"""TIFF with ModelPixelScale (33550), optionally + ModelTiepoint (33922)."""
107+
tags = [(33550, (sx, sy, 0.0))]
108+
if with_tiepoint:
109+
tags.append((33922, (0.0, 0.0, 0.0, 500000.0, 4500000.0, 0.0)))
110+
return _assemble_tiff(tags)
111+
112+
113+
def _tiff_with_transformation(sx: float, sy: float) -> bytes:
114+
"""TIFF with an axis-aligned ModelTransformation (34264) diagonal."""
115+
matrix = (
116+
sx, 0.0, 0.0, 500000.0,
117+
0.0, sy, 0.0, 4500000.0,
118+
0.0, 0.0, 1.0, 0.0,
119+
0.0, 0.0, 0.0, 1.0,
120+
)
121+
return _assemble_tiff([(34264, matrix)])
122+
123+
124+
def _extract(data: bytes):
125+
header = parse_header(data)
126+
ifds = parse_all_ifds(data, header)
127+
return _extract_transform(ifds[0])
128+
129+
130+
_BAD = [
131+
pytest.param(0.0, 30.0, id="zero_width"),
132+
pytest.param(30.0, 0.0, id="zero_height"),
133+
pytest.param(float('nan'), 30.0, id="nan_width"),
134+
pytest.param(30.0, float('nan'), id="nan_height"),
135+
pytest.param(float('inf'), 30.0, id="inf_width"),
136+
pytest.param(30.0, float('-inf'), id="neg_inf_height"),
137+
]
138+
139+
140+
@pytest.mark.parametrize("sx,sy", _BAD)
141+
def test_pixel_scale_only_rejected(sx, sy):
142+
with pytest.raises(DegeneratePixelSizeError) as exc:
143+
_extract(_tiff_with_pixel_scale(sx, sy, with_tiepoint=False))
144+
assert 'ModelPixelScaleTag' in str(exc.value)
145+
146+
147+
@pytest.mark.parametrize("sx,sy", _BAD)
148+
def test_pixel_scale_with_tiepoint_rejected(sx, sy):
149+
with pytest.raises(DegeneratePixelSizeError) as exc:
150+
_extract(_tiff_with_pixel_scale(sx, sy, with_tiepoint=True))
151+
assert 'ModelPixelScaleTag' in str(exc.value)
152+
153+
154+
@pytest.mark.parametrize("sx,sy", _BAD)
155+
def test_transformation_diagonal_rejected(sx, sy):
156+
with pytest.raises(DegeneratePixelSizeError) as exc:
157+
_extract(_tiff_with_transformation(sx, sy))
158+
assert 'ModelTransformationTag' in str(exc.value)
159+
160+
161+
def test_degenerate_pixel_size_is_ambiguous_metadata_subclass():
162+
# Existing ``except ValueError`` / ``except
163+
# GeoTIFFAmbiguousMetadataError`` callers must keep catching this.
164+
assert issubclass(DegeneratePixelSizeError, GeoTIFFAmbiguousMetadataError)
165+
assert issubclass(DegeneratePixelSizeError, ValueError)
166+
167+
168+
@pytest.mark.parametrize("builder", [
169+
lambda: _tiff_with_pixel_scale(30.0, 30.0, with_tiepoint=False),
170+
lambda: _tiff_with_pixel_scale(30.0, 30.0, with_tiepoint=True),
171+
lambda: _tiff_with_transformation(30.0, -30.0),
172+
])
173+
def test_finite_nonzero_pixel_size_still_accepted(builder):
174+
transform, has_georef = _extract(builder())
175+
assert has_georef is True
176+
assert transform.pixel_width == pytest.approx(30.0)
177+
assert abs(transform.pixel_height) == pytest.approx(30.0)
178+
179+
180+
def test_open_geotiff_rejects_zero_pixel_scale(tmp_path):
181+
# End-to-end through the public read entry point.
182+
path = tmp_path / 'degenerate_scale_3331.tif'
183+
path.write_bytes(_tiff_with_pixel_scale(0.0, 30.0, with_tiepoint=True))
184+
from xrspatial.geotiff import open_geotiff
185+
with pytest.raises(DegeneratePixelSizeError):
186+
open_geotiff(str(path))

0 commit comments

Comments
 (0)