Skip to content

Commit 1924122

Browse files
committed
Route sidecar georef tags through the sidecar's bytes (#2315)
The local eager reader and the metadata-only path both swap pixel bytes over to the sidecar when the selected IFD lives there, but the georef extraction call right below the swap still passed the base file's data + header.byte_order. This worked under the usual GDAL convention -- sidecar IFDs carry no geokeys -- but silently broke when a sidecar declared its own GeoKeyDirectory or model tags. extract_geo_info_with_overview_inheritance now accepts an optional sidecar_origin mapping from id(ifd) to (data, byte_order). When the selected IFD is in the mapping AND declares one of the georef-bearing tags (33550, 33922, 34264, 34735), the helper resolves tag offsets against the sidecar's bytes. Sidecars without geokeys still inherit from the base file's level-0 IFD as before. Both call sites (_reader.py and __init__.py:_read_geo_info) now build and forward the mapping. Includes a synthetic regression test covering both cases: sidecar with its own ModelPixelScale / ModelTiepoint / GeoKeyDirectory, and sidecar with those tags stripped (today's GDAL-convention behavior).
1 parent bb12555 commit 1924122

4 files changed

Lines changed: 466 additions & 13 deletions

File tree

xrspatial/geotiff/__init__.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -273,24 +273,38 @@ def _read_geo_info(source, *, overview_level: int | None = None,
273273
# so ``overview_level`` indexes both internal and external
274274
# overviews (issue #2112). Local file paths only.
275275
from ._sidecar import attach_sidecar_origin, find_sidecar, load_sidecar
276+
sidecar_origin: dict[int, tuple] = {}
276277
sidecar_path = find_sidecar(source)
277278
if sidecar_path is not None:
278279
sidecar = load_sidecar(sidecar_path)
279-
# Metadata-only path: drop the origin mapping. The reader
280-
# only needs the merged IFD list to resolve the requested
281-
# ``overview_level`` against; strip/tile bytes are sliced by
282-
# ``read_to_array`` on the actual read.
283-
attach_sidecar_origin(
280+
# The origin mapping is consumed below for georef extraction
281+
# only -- strip/tile bytes are sliced by ``read_to_array`` on
282+
# the actual read. A sidecar IFD that carries its own
283+
# GeoKeyDirectory / ModelPixelScale / ModelTiepoint /
284+
# ModelTransformation needs the sidecar's byte order to
285+
# parse cleanly; without the mapping the helper falls back
286+
# to the base file's bytes (today's default, correct under
287+
# the usual GDAL convention). See issue #2315.
288+
sidecar_origin = attach_sidecar_origin(
284289
sidecar.ifds, sidecar.data, sidecar.header)
285290
ifds = ifds + sidecar.ifds
286291
ifd = select_overview_ifd(ifds, overview_level)
287292
# Inherit georef from the level-0 IFD when the overview itself
288293
# has no geokeys (issue #1640). Pass-through for level 0. The
289294
# sidecar IFDs typically lack geokeys so the inheritance pulls
290-
# from the base file's full-resolution IFD as GDAL does.
295+
# from the base file's full-resolution IFD as GDAL does. When a
296+
# sidecar IFD does declare its own georef payload, ``georef_origin``
297+
# routes the parse to the sidecar's bytes / byte order so the
298+
# sidecar's georef wins. See issue #2315.
299+
georef_origin = (
300+
{iid: (od, oh.byte_order)
301+
for iid, (od, oh) in sidecar_origin.items()}
302+
if sidecar_origin else None
303+
)
291304
geo_info = extract_geo_info_with_overview_inheritance(
292305
ifd, ifds, data, header.byte_order,
293-
allow_rotated=allow_rotated)
306+
allow_rotated=allow_rotated,
307+
sidecar_origin=georef_origin)
294308
bps = resolve_bits_per_sample(ifd.bits_per_sample)
295309
file_dtype = tiff_dtype_to_numpy(bps, ifd.sample_format)
296310
_validate_predictor_sample_format(ifd.predictor, ifd.sample_format)

xrspatial/geotiff/_geotags.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1085,13 +1085,43 @@ def extract_geo_info(ifd: IFD, data: bytes | memoryview,
10851085
)
10861086

10871087

1088+
# Tag IDs that carry georeferencing payload (offsets into either the
1089+
# IFD entry's inline value or the file's byte stream). When a sidecar
1090+
# IFD declares any of these, the georef extractor must parse against
1091+
# the sidecar's bytes / byte order, not the base file's. See issue
1092+
# #2315.
1093+
_GEOREF_PAYLOAD_TAGS = frozenset({
1094+
33550, # TAG_MODEL_PIXEL_SCALE
1095+
33922, # TAG_MODEL_TIEPOINT
1096+
34264, # TAG_MODEL_TRANSFORMATION
1097+
34735, # TAG_GEO_KEY_DIRECTORY
1098+
})
1099+
1100+
1101+
def _ifd_has_georef_payload(ifd: IFD) -> bool:
1102+
"""Return True if ``ifd`` declares any georef-bearing tag.
1103+
1104+
The GDAL convention is that an external ``.tif.ovr`` sidecar
1105+
inherits georef from the base file -- the sidecar IFDs do not
1106+
re-declare ModelPixelScale / ModelTiepoint / GeoKeyDirectory.
1107+
Hand-rolled sidecars and non-GDAL writers can break that convention,
1108+
and when they do the sidecar's georef tags must be parsed against
1109+
the sidecar's byte buffer rather than the base file's. This helper
1110+
is the conservative gate that flips the data / byte_order swap in
1111+
:func:`extract_geo_info_with_overview_inheritance`. See issue #2315.
1112+
"""
1113+
entries = ifd.entries
1114+
return any(tag in entries for tag in _GEOREF_PAYLOAD_TAGS)
1115+
1116+
10881117
def extract_geo_info_with_overview_inheritance(
10891118
ifd: IFD,
10901119
ifds: list,
10911120
data: bytes | memoryview,
10921121
byte_order: str,
10931122
*,
10941123
allow_rotated: bool = False,
1124+
sidecar_origin: dict | None = None,
10951125
) -> GeoInfo:
10961126
"""Extract geo metadata, inheriting from level 0 when the IFD lacks it.
10971127
@@ -1141,12 +1171,36 @@ def extract_geo_info_with_overview_inheritance(
11411171
Full file bytes (forwarded to ``extract_geo_info``).
11421172
byte_order : str
11431173
``'<'`` or ``'>'`` (forwarded to ``extract_geo_info``).
1174+
sidecar_origin : dict, optional
1175+
Mapping from ``id(ifd)`` to ``(data, byte_order)`` for IFDs
1176+
that live in an external ``.tif.ovr`` sidecar. When the
1177+
selected IFD is from a sidecar that declares its own georef
1178+
payload (ModelPixelScale / ModelTiepoint /
1179+
ModelTransformation / GeoKeyDirectory), this mapping tells
1180+
the extractor which byte buffer the tag offsets resolve
1181+
against. IFDs absent from the mapping fall through to
1182+
``(data, byte_order)`` for the base file. The conservative
1183+
default is ``None``, which preserves the legacy behavior
1184+
(read sidecar IFDs against the base file's bytes -- correct
1185+
only when the sidecar has no geokeys, which is the GDAL
1186+
convention). See issue #2315.
11441187
11451188
Returns
11461189
-------
11471190
GeoInfo
11481191
"""
1149-
info = extract_geo_info(ifd, data, byte_order,
1192+
# When the selected IFD lives in a sidecar AND it carries its own
1193+
# georef payload, resolve tag offsets against the sidecar's bytes /
1194+
# byte order. The sidecar-without-geokeys convention (GDAL) keeps
1195+
# ``(data, byte_order)`` from the base file and inherits below.
1196+
# See issue #2315.
1197+
sel_data, sel_byte_order = data, byte_order
1198+
if sidecar_origin is not None:
1199+
origin = sidecar_origin.get(id(ifd))
1200+
if origin is not None and _ifd_has_georef_payload(ifd):
1201+
sel_data, sel_byte_order = origin
1202+
1203+
info = extract_geo_info(ifd, sel_data, sel_byte_order,
11501204
allow_rotated=allow_rotated)
11511205

11521206
# Overview IFDs have NewSubfileType bit 0 set; mask IFDs (bit 2) and

xrspatial/geotiff/_reader.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,13 +227,24 @@ def _read_to_array(source, *, window=None, overview_level: int | None = None,
227227
# Inherit georef from level 0 when an overview IFD lacks its own
228228
# geokeys (issue #1640). For overview_level=0 (or None) this is a
229229
# no-op: the helper short-circuits when the IFD is not a
230-
# NewSubfileType=overview entry. Sidecar IFDs always lack
231-
# geokeys, so the inheritance pulls from the base file's
232-
# level-0 IFD (kept first in the merged list) which is the
233-
# GDAL convention.
230+
# NewSubfileType=overview entry. Sidecar IFDs typically lack
231+
# geokeys (the GDAL convention), so the inheritance pulls from
232+
# the base file's level-0 IFD (kept first in the merged list).
233+
# A sidecar that does declare its own georef payload is a corner
234+
# case: ``georef_origin`` maps the sidecar IFDs to
235+
# ``(data, byte_order)`` from the sidecar so the helper resolves
236+
# those tags against the right buffer. Sidecar IFDs without
237+
# geokeys still inherit from the base file via the existing
238+
# overview-inheritance path. See issues #1640 and #2315.
239+
georef_origin = (
240+
{iid: (od, oh.byte_order)
241+
for iid, (od, oh) in sidecar_origin.items()}
242+
if sidecar_origin else None
243+
)
234244
geo_info = extract_geo_info_with_overview_inheritance(
235245
ifd, ifds, data, header.byte_order,
236-
allow_rotated=allow_rotated)
246+
allow_rotated=allow_rotated,
247+
sidecar_origin=georef_origin)
237248

238249
# Orientation tag (274): values 2-8 mean the stored pixel order
239250
# differs from display order. We need to remap the array post

0 commit comments

Comments
 (0)