Skip to content

Commit f48addf

Browse files
authored
geotiff: deprecate vertical-CRS GeoKey attrs in DataArray.attrs (#1984) (#2010)
The writer in xrspatial.geotiff._geotags.build_geo_tags never emits the vertical GeoKey block, so attrs['vertical_crs'], attrs['vertical_citation'] and attrs['vertical_units'] cannot round-trip. PR6 (#2009) locked this "dropped on round-trip" behaviour. PR7 deprecates the three read-side emissions with a DeprecationWarning for one release cycle before removal. The attrs still emit; only the warning is new.
1 parent 198a397 commit f48addf

3 files changed

Lines changed: 297 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Unreleased
66

77
#### Bug fixes and improvements
8+
- Deprecate read-side emission of vertical-CRS GeoKey attrs (vertical_crs, vertical_citation, vertical_units); the writer does not emit vertical GeoKeys so they do not round-trip. These attrs still emit for now but trigger a DeprecationWarning. Removal planned for a future release. (#1984)
89
- Refresh the geotiff mmap cache when a file is replaced under the same path so re-reads after an atomic-rename overwrite no longer return stale bytes
910
- Decode TIFF predictor=3 un-transpose by file byte order so big-endian floating-point TIFFs read back exactly
1011
- Default internal `_vrt.read_vrt` `missing_sources` to `'raise'` so an unreadable VRT source no longer produces a silent zero-fill hole on integer rasters; pass `missing_sources='warn'` to opt back into the previous lenient behaviour (#1843)

xrspatial/geotiff/_attrs.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,20 @@
6969
- ``semi_major_axis``: GeogSemiMajorAxisGeoKey value.
7070
- ``inv_flattening``: GeogInvFlatteningGeoKey value.
7171
- ``projection_code``: ProjectedCSTypeGeoKey value.
72-
- ``vertical_crs``: VerticalCSTypeGeoKey value.
73-
- ``vertical_citation``: VerticalCitationGeoKey value.
74-
- ``vertical_units``: VerticalUnitsGeoKey value.
7572
- ``image_description``: TIFF ImageDescription tag.
7673
- ``extra_samples``: TIFF ExtraSamples tag.
7774
- ``colormap``, ``colormap_rgba``, ``cmap``: palette data attached to
7875
single-band paletted images.
76+
77+
Deprecated (will be removed in a future release; see issue #1984):
78+
79+
- ``vertical_crs``: VerticalCSTypeGeoKey value. The writer never emits
80+
the vertical GeoKey block, so this attr cannot round-trip. It still
81+
appears on read but triggers a ``DeprecationWarning``.
82+
- ``vertical_citation``: VerticalCitationGeoKey value. Same deprecation
83+
reason as ``vertical_crs``.
84+
- ``vertical_units``: VerticalUnitsGeoKey value. Same deprecation reason
85+
as ``vertical_crs``.
7986
"""
8087
from __future__ import annotations
8188

@@ -256,10 +263,34 @@ def _populate_attrs_from_geo_info(attrs: dict, geo_info, *, window=None) -> None
256263
if geo_info.projection_code is not None:
257264
attrs['projection_code'] = geo_info.projection_code
258265
if geo_info.vertical_epsg is not None:
266+
warnings.warn(
267+
"xrspatial.geotiff: attrs['vertical_crs'] is deprecated; "
268+
"the writer cannot reconstruct vertical-CRS GeoKeys so it "
269+
"will not round-trip. It will be removed in a future "
270+
"release. See issue #1984.",
271+
DeprecationWarning,
272+
stacklevel=2,
273+
)
259274
attrs['vertical_crs'] = geo_info.vertical_epsg
260275
if geo_info.vertical_citation is not None:
276+
warnings.warn(
277+
"xrspatial.geotiff: attrs['vertical_citation'] is deprecated; "
278+
"the writer cannot reconstruct vertical-CRS GeoKeys so it "
279+
"will not round-trip. It will be removed in a future "
280+
"release. See issue #1984.",
281+
DeprecationWarning,
282+
stacklevel=2,
283+
)
261284
attrs['vertical_citation'] = geo_info.vertical_citation
262285
if geo_info.vertical_units is not None:
286+
warnings.warn(
287+
"xrspatial.geotiff: attrs['vertical_units'] is deprecated; "
288+
"the writer cannot reconstruct vertical-CRS GeoKeys so it "
289+
"will not round-trip. It will be removed in a future "
290+
"release. See issue #1984.",
291+
DeprecationWarning,
292+
stacklevel=2,
293+
)
263294
attrs['vertical_units'] = geo_info.vertical_units
264295

265296
if geo_info.gdal_metadata is not None:
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""DeprecationWarning tests for the vertical-CRS GeoKey attrs (PR7 of #1984).
2+
3+
The writer in ``xrspatial.geotiff._geotags.build_geo_tags`` does not emit
4+
the vertical GeoKey block, so ``attrs['vertical_crs']`` /
5+
``attrs['vertical_citation']`` / ``attrs['vertical_units']`` set on a
6+
DataArray are silently dropped on round-trip. The PR6 pass-through
7+
locking test (``test_attrs_contract_passthrough_1984.py``) pins that
8+
behaviour as "dropped".
9+
10+
PR7 deprecates these three read-side emissions for one release cycle
11+
before removal. Each test here builds a minimal TIFF carrying the
12+
relevant vertical GeoKey and asserts that ``open_geotiff`` raises a
13+
``DeprecationWarning`` whose message points at issue #1984. A final
14+
test confirms the attrs still appear on the returned DataArray during
15+
the deprecation window so existing consumers keep working.
16+
"""
17+
from __future__ import annotations
18+
19+
import struct
20+
import warnings
21+
22+
import pytest
23+
24+
from xrspatial.geotiff import open_geotiff
25+
from xrspatial.geotiff.tests.conftest import make_minimal_tiff
26+
27+
28+
# GeoKey IDs from xrspatial.geotiff._geotags
29+
_GEOKEY_VERTICAL_CS_TYPE = 4096
30+
_GEOKEY_VERTICAL_CITATION = 4097
31+
_GEOKEY_VERTICAL_UNITS = 4099
32+
33+
# TIFF tag IDs
34+
_TAG_GEO_KEY_DIRECTORY = 34735
35+
_TAG_GEO_ASCII_PARAMS = 34737
36+
37+
38+
def _build_tiff_with_vertical_geokeys(
39+
*,
40+
vertical_epsg: int | None = None,
41+
vertical_citation: str | None = None,
42+
vertical_units_code: int | None = None,
43+
) -> bytes:
44+
"""Build a minimal float32 TIFF with horizontal EPSG 4326 and optional
45+
vertical GeoKey entries.
46+
47+
Starts from ``make_minimal_tiff(epsg=4326)`` and rewrites the
48+
GeoKeyDirectory tag (34735) to append the requested vertical block.
49+
The format is the standard GeoTIFF GeoKey directory:
50+
``(version, revision, minor, n_keys)`` header followed by ``n_keys``
51+
quadruples of ``(key_id, location, count, value_or_offset)``. A
52+
``location`` of 0 means the value is inline; ``location == 34737``
53+
points into GeoAsciiParams.
54+
"""
55+
# Start with the minimal 4326 TIFF.
56+
base = make_minimal_tiff(
57+
4, 4,
58+
geo_transform=(-120.0, 45.0, 0.001, -0.001),
59+
epsg=4326,
60+
)
61+
62+
# Find the GeoKeyDirectory tag (34735) in the IFD and replace it.
63+
# The IFD lives at offset 8 in the byte stream produced by
64+
# ``make_minimal_tiff``. We rebuild a fresh TIFF with the extra
65+
# vertical entries appended to the GeoKey directory instead of
66+
# surgically editing ``base``; this keeps the byte layout valid.
67+
# The simplest approach: build a new TIFF using a small ad-hoc
68+
# serializer that mirrors ``make_minimal_tiff`` but takes a
69+
# pre-built GeoKey directory and optional GeoAsciiParams payload.
70+
71+
width = height = 4
72+
import numpy as np
73+
74+
pixel_data = np.zeros((height, width), dtype=np.float32)
75+
pixel_bytes = pixel_data.tobytes()
76+
77+
# Build the GeoKey directory: start with the minimal 4326 keys
78+
# (model_type=2 geographic + GEOKEY_GEOGRAPHIC_CS_TYPE=2048 -> 4326),
79+
# then append any requested vertical entries.
80+
# Header: version=1, revision=1, minor=0, n_keys (filled later).
81+
gkd_entries: list[int] = [
82+
1024, 0, 1, 2, # GTModelType -> 2 (geographic)
83+
2048, 0, 1, 4326, # GeographicTypeGeoKey -> 4326
84+
]
85+
86+
geo_ascii_buf = bytearray()
87+
88+
if vertical_epsg is not None:
89+
gkd_entries.extend([
90+
_GEOKEY_VERTICAL_CS_TYPE, 0, 1, int(vertical_epsg),
91+
])
92+
93+
if vertical_citation is not None:
94+
s = vertical_citation + '|' # GeoTIFF citation strings end with '|'
95+
ascii_offset = len(geo_ascii_buf)
96+
geo_ascii_buf.extend(s.encode('ascii'))
97+
gkd_entries.extend([
98+
_GEOKEY_VERTICAL_CITATION,
99+
_TAG_GEO_ASCII_PARAMS,
100+
len(s),
101+
ascii_offset,
102+
])
103+
104+
if vertical_units_code is not None:
105+
gkd_entries.extend([
106+
_GEOKEY_VERTICAL_UNITS, 0, 1, int(vertical_units_code),
107+
])
108+
109+
n_keys = len(gkd_entries) // 4
110+
# Prepend header
111+
gkd = [1, 1, 0, n_keys] + gkd_entries
112+
113+
# Now serialize a minimal TIFF carrying the standard image tags and
114+
# this enriched GeoKey directory plus a GeoAsciiParams tag when
115+
# populated. Reuses the layout strategy of ``make_minimal_tiff``.
116+
bo = '<'
117+
118+
tag_list: list[tuple[int, int, int, bytes]] = []
119+
120+
def add_short(tag, val):
121+
tag_list.append((tag, 3, 1, struct.pack(f'{bo}H', val)))
122+
123+
def add_long(tag, val):
124+
tag_list.append((tag, 4, 1, struct.pack(f'{bo}I', val)))
125+
126+
def add_shorts(tag, vals):
127+
tag_list.append((tag, 3, len(vals), struct.pack(f'{bo}{len(vals)}H', *vals)))
128+
129+
def add_doubles(tag, vals):
130+
tag_list.append((tag, 12, len(vals), struct.pack(f'{bo}{len(vals)}d', *vals)))
131+
132+
def add_ascii(tag, raw_bytes: bytes):
133+
# ASCII type, count includes the terminating NUL.
134+
if not raw_bytes.endswith(b'\x00'):
135+
raw_bytes = raw_bytes + b'\x00'
136+
tag_list.append((tag, 2, len(raw_bytes), raw_bytes))
137+
138+
add_short(256, width)
139+
add_short(257, height)
140+
add_short(258, 32) # BitsPerSample
141+
add_short(259, 1) # Compression none
142+
add_short(262, 1) # Photometric BlackIsZero
143+
add_short(277, 1) # SamplesPerPixel
144+
add_short(339, 3) # SampleFormat IEEE float
145+
add_short(278, height) # RowsPerStrip
146+
add_long(273, 0) # StripOffsets placeholder
147+
add_long(279, len(pixel_bytes)) # StripByteCounts
148+
add_doubles(33550, [0.001, 0.001, 0.0])
149+
add_doubles(33922, [0.0, 0.0, 0.0, -120.0, 45.0, 0.0])
150+
add_shorts(_TAG_GEO_KEY_DIRECTORY, gkd)
151+
if geo_ascii_buf:
152+
add_ascii(_TAG_GEO_ASCII_PARAMS, bytes(geo_ascii_buf))
153+
154+
tag_list.sort(key=lambda t: t[0])
155+
156+
num_entries = len(tag_list)
157+
ifd_start = 8
158+
ifd_size = 2 + 12 * num_entries + 4
159+
overflow_start = ifd_start + ifd_size
160+
161+
overflow_buf = bytearray()
162+
tag_offsets: dict[int, int | None] = {}
163+
for tag, _typ, _count, raw in tag_list:
164+
if len(raw) > 4:
165+
tag_offsets[tag] = len(overflow_buf)
166+
overflow_buf.extend(raw)
167+
if len(overflow_buf) % 2:
168+
overflow_buf.append(0)
169+
else:
170+
tag_offsets[tag] = None
171+
172+
pixel_data_start = overflow_start + len(overflow_buf)
173+
174+
# Patch StripOffsets now that we know where pixel data lives.
175+
patched = []
176+
for tag, typ, count, raw in tag_list:
177+
if tag == 273:
178+
patched.append((tag, typ, count, struct.pack(f'{bo}I', pixel_data_start)))
179+
else:
180+
patched.append((tag, typ, count, raw))
181+
tag_list = patched
182+
183+
out = bytearray()
184+
out.extend(b'II')
185+
out.extend(struct.pack(f'{bo}H', 42))
186+
out.extend(struct.pack(f'{bo}I', ifd_start))
187+
out.extend(struct.pack(f'{bo}H', num_entries))
188+
for tag, typ, count, raw in tag_list:
189+
out.extend(struct.pack(f'{bo}HHI', tag, typ, count))
190+
if len(raw) <= 4:
191+
out.extend(raw.ljust(4, b'\x00'))
192+
else:
193+
ptr = overflow_start + tag_offsets[tag]
194+
out.extend(struct.pack(f'{bo}I', ptr))
195+
out.extend(struct.pack(f'{bo}I', 0))
196+
out.extend(overflow_buf)
197+
out.extend(pixel_bytes)
198+
199+
# Silence the unused-import lint for ``base`` -- we deliberately
200+
# rebuild rather than patch.
201+
_ = base
202+
return bytes(out)
203+
204+
205+
def _write(tmp_path, blob: bytes, name: str) -> str:
206+
path = tmp_path / name
207+
path.write_bytes(blob)
208+
return str(path)
209+
210+
211+
def test_vertical_crs_emits_deprecation_warning(tmp_path):
212+
"""Reading a TIFF with VerticalCSTypeGeoKey warns about
213+
``attrs['vertical_crs']`` deprecation."""
214+
blob = _build_tiff_with_vertical_geokeys(vertical_epsg=5703)
215+
path = _write(tmp_path, blob, 'vertical_crs.tif')
216+
217+
with pytest.warns(DeprecationWarning, match=r"vertical_crs.*#1984"):
218+
open_geotiff(path)
219+
220+
221+
def test_vertical_citation_emits_deprecation_warning(tmp_path):
222+
"""Reading a TIFF with VerticalCitationGeoKey warns about
223+
``attrs['vertical_citation']`` deprecation."""
224+
blob = _build_tiff_with_vertical_geokeys(vertical_citation='NAVD88')
225+
path = _write(tmp_path, blob, 'vertical_citation.tif')
226+
227+
with pytest.warns(DeprecationWarning, match=r"vertical_citation.*#1984"):
228+
open_geotiff(path)
229+
230+
231+
def test_vertical_units_emits_deprecation_warning(tmp_path):
232+
"""Reading a TIFF with VerticalUnitsGeoKey warns about
233+
``attrs['vertical_units']`` deprecation."""
234+
# 9001 is the GeoTIFF code for linear metre, mapped to 'metre' on read.
235+
blob = _build_tiff_with_vertical_geokeys(vertical_units_code=9001)
236+
path = _write(tmp_path, blob, 'vertical_units.tif')
237+
238+
with pytest.warns(DeprecationWarning, match=r"vertical_units.*#1984"):
239+
open_geotiff(path)
240+
241+
242+
def test_vertical_attrs_still_emit_during_deprecation(tmp_path):
243+
"""Deprecation does not yet remove the attrs: a read of a TIFF with
244+
all three vertical GeoKeys still surfaces all three keys on
245+
``DataArray.attrs``."""
246+
blob = _build_tiff_with_vertical_geokeys(
247+
vertical_epsg=5703,
248+
vertical_citation='NAVD88',
249+
vertical_units_code=9001,
250+
)
251+
path = _write(tmp_path, blob, 'vertical_all.tif')
252+
253+
with warnings.catch_warnings():
254+
# The whole point of this test is that the attrs still emit;
255+
# the per-attr warning is asserted in the three tests above, so
256+
# silence it here to keep this test focused on the attr values.
257+
warnings.simplefilter('ignore', DeprecationWarning)
258+
da = open_geotiff(path)
259+
260+
assert da.attrs.get('vertical_crs') == 5703
261+
assert da.attrs.get('vertical_citation') == 'NAVD88'
262+
assert da.attrs.get('vertical_units') == 'metre'

0 commit comments

Comments
 (0)