Skip to content

Commit 7c6fefd

Browse files
committed
geotiff: honour ModelTiepointTag when ModelPixelScaleTag is absent (#1750)
`_extract_transform` previously returned the default `GeoTransform()` (origin (0, 0), unit pixel size) with `has_georef=True` whenever a ModelTiepointTag was present but ModelPixelScaleTag was missing. The tiepoint's tp_x / tp_y values were silently dropped, so any downstream code trusting `has_georef` constructed coordinates from origin (0, 0) and relocated the raster. Use the tiepoint's X / Y to build the origin (matching the tiepoint-with-scale branch) and fall back to the spec-documented unit pixel scale (1.0, -1.0). The alternative of returning has_georef=False would still mislabel the raster as having no georeferencing even though a real model X / Y is encoded in the tiepoint. Added `TestTiepointWithoutScale_1750` in test_geotags.py with a helper that emits a TIFF carrying ModelTiepointTag but no ModelPixelScaleTag. Verified the new tests fail on main (origin_x = 0.0 instead of the tiepoint X) and pass with the fix. Closes #1750.
1 parent e7b9cde commit 7c6fefd

2 files changed

Lines changed: 153 additions & 2 deletions

File tree

xrspatial/geotiff/_geotags.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -463,9 +463,29 @@ def _extract_transform(ifd: IFD) -> tuple[GeoTransform, bool]:
463463

464464
return GeoTransform(pixel_width=sx, pixel_height=-sy), True
465465

466-
# Tiepoint without scale: still flag as georeferenced (origin known)
466+
# Tiepoint without scale: honour the tiepoint origin and fall back to
467+
# unit pixel size. Per the GeoTIFF spec a ModelTiepointTag encodes a
468+
# real-world (X, Y) for pixel (I, J); dropping it would silently relocate
469+
# the raster to (0, 0). Unit scale (1.0, -1.0) is the documented fallback
470+
# when ModelPixelScaleTag is absent.
467471
if tiepoint is not None:
468-
return GeoTransform(), True
472+
if not isinstance(tiepoint, tuple):
473+
tiepoint = (tiepoint,)
474+
tp_i = tiepoint[0] if len(tiepoint) > 0 else 0.0
475+
tp_j = tiepoint[1] if len(tiepoint) > 1 else 0.0
476+
tp_x = tiepoint[3] if len(tiepoint) > 3 else 0.0
477+
tp_y = tiepoint[4] if len(tiepoint) > 4 else 0.0
478+
479+
# Unit scale: pixel_width = 1.0, pixel_height = -1.0
480+
origin_x = tp_x - tp_i * 1.0
481+
origin_y = tp_y + tp_j * 1.0
482+
483+
return GeoTransform(
484+
origin_x=origin_x,
485+
origin_y=origin_y,
486+
pixel_width=1.0,
487+
pixel_height=-1.0,
488+
), True
469489

470490
return GeoTransform(), False
471491

xrspatial/geotiff/tests/test_geotags.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,134 @@ def test_z_coupling_raises(self, tmp_path):
253253
from xrspatial.geotiff._reader import read_to_array
254254
with pytest.raises(NotImplementedError):
255255
read_to_array(str(path))
256+
257+
258+
def _build_tiff_with_tiepoint_only(tiepoint_6: tuple) -> bytes:
259+
"""Build a tiny single-strip TIFF carrying ModelTiepointTag but
260+
no ModelPixelScaleTag or ModelTransformationTag.
261+
262+
The GeoTIFF spec permits this configuration; the tiepoint encodes a
263+
real-world (X, Y) origin for pixel (I, J) and the pixel scale defaults
264+
to (1.0, 1.0).
265+
"""
266+
import struct
267+
268+
bo = '<'
269+
width, height = 2, 2
270+
pixels = np.zeros((height, width), dtype=np.uint8)
271+
272+
tag_list = []
273+
274+
def add_short(tag, val):
275+
tag_list.append((tag, 3, 1, struct.pack(f'{bo}H', val)))
276+
277+
def add_long(tag, val):
278+
tag_list.append((tag, 4, 1, struct.pack(f'{bo}I', val)))
279+
280+
def add_doubles(tag, vals):
281+
tag_list.append(
282+
(tag, 12, len(vals), struct.pack(f'{bo}{len(vals)}d', *vals)))
283+
284+
add_short(256, width) # ImageWidth
285+
add_short(257, height) # ImageLength
286+
add_short(258, 8) # BitsPerSample
287+
add_short(259, 1) # Compression: none
288+
add_short(262, 1) # PhotometricInterpretation
289+
add_short(277, 1) # SamplesPerPixel
290+
add_short(278, height) # RowsPerStrip
291+
add_long(273, 0) # StripOffsets (placeholder)
292+
add_long(279, len(pixels.tobytes())) # StripByteCounts
293+
add_short(339, 1) # SampleFormat
294+
# ModelTiepointTag (33922): 6 doubles (I, J, K, X, Y, Z).
295+
# Deliberately no ModelPixelScaleTag (33550).
296+
add_doubles(33922, list(tiepoint_6))
297+
298+
tag_list.sort(key=lambda t: t[0])
299+
300+
num_entries = len(tag_list)
301+
ifd_start = 8
302+
ifd_size = 2 + 12 * num_entries + 4
303+
304+
overflow = bytearray()
305+
overflow_offsets = {}
306+
for tag, _typ, _count, raw in tag_list:
307+
if len(raw) > 4:
308+
overflow_offsets[tag] = ifd_start + ifd_size + len(overflow)
309+
overflow.extend(raw)
310+
if len(overflow) % 2:
311+
overflow.append(0)
312+
313+
pixel_start = ifd_start + ifd_size + len(overflow)
314+
315+
patched = []
316+
for tag, typ, count, raw in tag_list:
317+
if tag == 273:
318+
patched.append((tag, typ, count, struct.pack(f'{bo}I', pixel_start)))
319+
else:
320+
patched.append((tag, typ, count, raw))
321+
tag_list = patched
322+
323+
out = bytearray()
324+
out.extend(b'II')
325+
out.extend(struct.pack(f'{bo}H', 42))
326+
out.extend(struct.pack(f'{bo}I', ifd_start))
327+
out.extend(struct.pack(f'{bo}H', num_entries))
328+
for tag, typ, count, raw in tag_list:
329+
out.extend(struct.pack(f'{bo}HHI', tag, typ, count))
330+
if len(raw) <= 4:
331+
out.extend(raw.ljust(4, b'\x00'))
332+
else:
333+
out.extend(struct.pack(f'{bo}I', overflow_offsets[tag]))
334+
out.extend(struct.pack(f'{bo}I', 0))
335+
out.extend(overflow)
336+
out.extend(pixels.tobytes())
337+
return bytes(out)
338+
339+
340+
class TestTiepointWithoutScale_1750:
341+
"""Issue #1750: ModelTiepointTag present, ModelPixelScaleTag absent.
342+
343+
Previously the reader returned a default GeoTransform with origin (0, 0)
344+
while still flagging the raster as georeferenced, silently relocating it.
345+
The fix honours the tiepoint's X/Y and falls back to unit pixel scale
346+
(the GeoTIFF spec convention when ModelPixelScale is absent).
347+
"""
348+
349+
def test_tiepoint_origin_preserved(self, tmp_path):
350+
# I=0, J=0 maps to (X=500000, Y=4500000)
351+
tiepoint = (0.0, 0.0, 0.0, 500000.0, 4500000.0, 0.0)
352+
data = _build_tiff_with_tiepoint_only(tiepoint)
353+
path = tmp_path / 'tiepoint_no_scale_1750.tif'
354+
path.write_bytes(data)
355+
356+
header = parse_header(data)
357+
ifds = parse_all_ifds(data, header)
358+
from xrspatial.geotiff._geotags import _extract_transform
359+
transform, has_georef = _extract_transform(ifds[0])
360+
361+
assert has_georef is True
362+
assert transform.origin_x == pytest.approx(500000.0)
363+
assert transform.origin_y == pytest.approx(4500000.0)
364+
# Unit pixel scale fallback per GeoTIFF spec
365+
assert transform.pixel_width == pytest.approx(1.0)
366+
assert transform.pixel_height == pytest.approx(-1.0)
367+
368+
def test_tiepoint_with_nonzero_ij(self, tmp_path):
369+
# I=2, J=3 maps to (X=100, Y=200) -- origin shifts to (98, 203)
370+
tiepoint = (2.0, 3.0, 0.0, 100.0, 200.0, 0.0)
371+
data = _build_tiff_with_tiepoint_only(tiepoint)
372+
path = tmp_path / 'tiepoint_no_scale_ij_1750.tif'
373+
path.write_bytes(data)
374+
375+
header = parse_header(data)
376+
ifds = parse_all_ifds(data, header)
377+
from xrspatial.geotiff._geotags import _extract_transform
378+
transform, has_georef = _extract_transform(ifds[0])
379+
380+
assert has_georef is True
381+
# origin_x = tp_x - tp_i * 1.0 = 100 - 2 = 98
382+
# origin_y = tp_y + tp_j * 1.0 = 200 + 3 = 203
383+
assert transform.origin_x == pytest.approx(98.0)
384+
assert transform.origin_y == pytest.approx(203.0)
385+
assert transform.pixel_width == pytest.approx(1.0)
386+
assert transform.pixel_height == pytest.approx(-1.0)

0 commit comments

Comments
 (0)