Skip to content

Commit 4979d07

Browse files
committed
to_geotiff: round-trip descending x and ascending y (#1716)
The writer stored abs(pixel_width)/abs(pixel_height) in ModelPixelScaleTag, and the reader hard-coded a north-up reconstruction. DataArrays with descending x or ascending y silently round-tripped with the wrong sign. Emit ModelTransformationTag (34264) for non-standard orientations: that tag carries the full signed 4x4 affine, which ModelPixelScale cannot because the spec requires positive scales. The reader already preferred ModelTransformationTag when present; only the writer side needed work. Also fix write_vrt's dst_y_off / dst_x_off math to use the top-left corner of each source's geographic extent rather than blindly assuming origin_y is the top. Without this, mosaics built from sources with ascending y placed tiles outside the VRT extent.
1 parent 0699aae commit 4979d07

4 files changed

Lines changed: 180 additions & 12 deletions

File tree

xrspatial/geotiff/_geotags.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -888,16 +888,34 @@ def build_geo_tags(transform: GeoTransform, crs_epsg: int | None = None,
888888
"""
889889
tags = {}
890890

891-
# ModelPixelScaleTag (33550): (ScaleX, ScaleY, ScaleZ)
892-
sx = abs(transform.pixel_width)
893-
sy = abs(transform.pixel_height)
894-
tags[TAG_MODEL_PIXEL_SCALE] = (sx, sy, 0.0)
895-
896-
# ModelTiepointTag (33922): (I, J, K, X, Y, Z)
897-
tags[TAG_MODEL_TIEPOINT] = (
898-
0.0, 0.0, 0.0,
899-
transform.origin_x, transform.origin_y, 0.0,
900-
)
891+
# Standard north-up rasters have pixel_width > 0 and pixel_height < 0.
892+
# Anything else (descending x, ascending y) cannot be expressed via
893+
# ModelPixelScale + ModelTiepoint because the spec requires the scales
894+
# to be positive.
895+
north_up = transform.pixel_width > 0 and transform.pixel_height < 0
896+
897+
if north_up:
898+
# ModelPixelScaleTag (33550): (ScaleX, ScaleY, ScaleZ)
899+
sx = transform.pixel_width
900+
sy = -transform.pixel_height
901+
tags[TAG_MODEL_PIXEL_SCALE] = (sx, sy, 0.0)
902+
903+
# ModelTiepointTag (33922): (I, J, K, X, Y, Z)
904+
tags[TAG_MODEL_TIEPOINT] = (
905+
0.0, 0.0, 0.0,
906+
transform.origin_x, transform.origin_y, 0.0,
907+
)
908+
else:
909+
# Why: GeoTIFF spec requires ModelPixelScale entries to be positive,
910+
# so non-standard orientations (descending x, ascending y) must use
911+
# ModelTransformationTag (34264) instead. The 4x4 row-major matrix
912+
# maps (col, row, 0, 1) -> (X, Y, 0, 1).
913+
tags[TAG_MODEL_TRANSFORMATION] = (
914+
transform.pixel_width, 0.0, 0.0, transform.origin_x,
915+
0.0, transform.pixel_height, 0.0, transform.origin_y,
916+
0.0, 0.0, 0.0, 0.0,
917+
0.0, 0.0, 0.0, 1.0,
918+
)
901919

902920
# GeoKeyDirectoryTag (34735)
903921
geokeys = []

xrspatial/geotiff/_vrt.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -908,9 +908,21 @@ def write_vrt(vrt_path: str, source_files: list[str], *,
908908

909909
for m in sources_meta:
910910
t = m['transform']
911+
# Top edge of this source in geo coords -- origin_y is the
912+
# *top* only for north-up rasters (pixel_height < 0). Sources
913+
# with ascending y (pixel_height > 0) place origin_y at the
914+
# bottom, so the top is origin_y + height * pixel_height.
915+
src_top = max(
916+
t.origin_y,
917+
t.origin_y + m['height'] * t.pixel_height,
918+
)
919+
src_left = min(
920+
t.origin_x,
921+
t.origin_x + m['width'] * t.pixel_width,
922+
)
911923
# Pixel offset in the virtual raster
912-
dst_x_off = int(round((t.origin_x - mosaic_x0) / abs(res_x)))
913-
dst_y_off = int(round((mosaic_y_top - t.origin_y) / abs(res_y)))
924+
dst_x_off = int(round((src_left - mosaic_x0) / abs(res_x)))
925+
dst_y_off = int(round((mosaic_y_top - src_top) / abs(res_y)))
914926

915927
fname = m['path']
916928
rel_attr = '0'

xrspatial/geotiff/_writer.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
TAG_GDAL_NODATA,
4141
TAG_MODEL_PIXEL_SCALE,
4242
TAG_MODEL_TIEPOINT,
43+
TAG_MODEL_TRANSFORMATION,
4344
)
4445
from ._header import (
4546
TAG_NEW_SUBFILE_TYPE,
@@ -873,6 +874,8 @@ def _assemble_tiff(width: int, height: int, dtype: np.dtype,
873874
tags.append((gtag, DOUBLE, 3, list(gval)))
874875
elif gtag == TAG_MODEL_TIEPOINT:
875876
tags.append((gtag, DOUBLE, 6, list(gval)))
877+
elif gtag == TAG_MODEL_TRANSFORMATION:
878+
tags.append((gtag, DOUBLE, 16, list(gval)))
876879
elif gtag == TAG_GEO_KEY_DIRECTORY:
877880
tags.append((gtag, SHORT, len(gval), list(gval)))
878881
elif gtag == TAG_GEO_ASCII_PARAMS:
@@ -1516,6 +1519,8 @@ def write_streaming(dask_data, path: str, *,
15161519
tags.append((gtag, DOUBLE, 3, list(gval)))
15171520
elif gtag == TAG_MODEL_TIEPOINT:
15181521
tags.append((gtag, DOUBLE, 6, list(gval)))
1522+
elif gtag == TAG_MODEL_TRANSFORMATION:
1523+
tags.append((gtag, DOUBLE, 16, list(gval)))
15191524
elif gtag == TAG_GEO_KEY_DIRECTORY:
15201525
tags.append((gtag, SHORT, len(gval), list(gval)))
15211526
elif gtag == TAG_GEO_ASCII_PARAMS:
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Regression tests for issue #1716.
2+
3+
``to_geotiff`` previously stored ``abs(pixel_width)`` / ``abs(pixel_height)``
4+
in ModelPixelScaleTag and the reader hard-coded a north-up reconstruction.
5+
DataArrays with descending x or ascending y silently round-tripped with the
6+
wrong georeference. The writer now emits ModelTransformationTag (34264)
7+
for non-standard orientations so the sign survives the round trip.
8+
"""
9+
from __future__ import annotations
10+
11+
import os
12+
13+
import numpy as np
14+
import pytest
15+
import xarray as xr
16+
17+
from xrspatial.geotiff import open_geotiff, to_geotiff
18+
from xrspatial.geotiff._geotags import (
19+
TAG_MODEL_PIXEL_SCALE,
20+
TAG_MODEL_TIEPOINT,
21+
TAG_MODEL_TRANSFORMATION,
22+
)
23+
from xrspatial.geotiff._header import parse_all_ifds, parse_header
24+
25+
26+
def _ifd_tag_ids(path: str) -> set[int]:
27+
with open(path, 'rb') as fh:
28+
data = fh.read()
29+
header = parse_header(data)
30+
ifds = parse_all_ifds(data, header)
31+
return set(ifds[0].entries.keys())
32+
33+
34+
def _make_da(x_coords: np.ndarray, y_coords: np.ndarray) -> xr.DataArray:
35+
arr = np.arange(len(y_coords) * len(x_coords), dtype=np.float32)
36+
arr = arr.reshape(len(y_coords), len(x_coords))
37+
return xr.DataArray(
38+
arr,
39+
dims=('y', 'x'),
40+
coords={'y': y_coords, 'x': x_coords},
41+
)
42+
43+
44+
def test_descending_x_roundtrip(tmp_path):
45+
"""Descending x coords survive the round trip."""
46+
# x decreases left-to-right (unusual but valid)
47+
x = np.array([200.0, 190.0, 180.0, 170.0, 160.0], dtype=np.float64)
48+
y = np.array([50.0, 40.0, 30.0, 20.0], dtype=np.float64) # north-up
49+
da = _make_da(x, y)
50+
51+
out = tmp_path / 'tmp_1716_desc_x.tif'
52+
to_geotiff(da, str(out), crs=4326)
53+
54+
loaded = open_geotiff(str(out))
55+
np.testing.assert_allclose(loaded.coords['x'].values, x)
56+
np.testing.assert_allclose(loaded.coords['y'].values, y)
57+
np.testing.assert_array_equal(loaded.values, da.values)
58+
59+
60+
def test_ascending_y_roundtrip(tmp_path):
61+
"""Ascending y coords survive the round trip."""
62+
x = np.array([160.0, 170.0, 180.0, 190.0, 200.0], dtype=np.float64)
63+
# y increases top-to-bottom (south-up)
64+
y = np.array([20.0, 30.0, 40.0, 50.0], dtype=np.float64)
65+
da = _make_da(x, y)
66+
67+
out = tmp_path / 'tmp_1716_asc_y.tif'
68+
to_geotiff(da, str(out), crs=4326)
69+
70+
loaded = open_geotiff(str(out))
71+
np.testing.assert_allclose(loaded.coords['x'].values, x)
72+
np.testing.assert_allclose(loaded.coords['y'].values, y)
73+
np.testing.assert_array_equal(loaded.values, da.values)
74+
75+
76+
def test_descending_x_and_ascending_y_roundtrip(tmp_path):
77+
"""Both axes flipped relative to north-up."""
78+
x = np.array([200.0, 190.0, 180.0, 170.0, 160.0], dtype=np.float64)
79+
y = np.array([20.0, 30.0, 40.0, 50.0], dtype=np.float64)
80+
da = _make_da(x, y)
81+
82+
out = tmp_path / 'tmp_1716_desc_x_asc_y.tif'
83+
to_geotiff(da, str(out), crs=4326)
84+
85+
loaded = open_geotiff(str(out))
86+
np.testing.assert_allclose(loaded.coords['x'].values, x)
87+
np.testing.assert_allclose(loaded.coords['y'].values, y)
88+
np.testing.assert_array_equal(loaded.values, da.values)
89+
90+
91+
def test_north_up_still_uses_pixel_scale_and_tiepoint(tmp_path):
92+
"""Standard north-up orientation keeps ModelPixelScale + ModelTiepoint."""
93+
x = np.array([160.0, 170.0, 180.0, 190.0, 200.0], dtype=np.float64)
94+
y = np.array([50.0, 40.0, 30.0, 20.0], dtype=np.float64)
95+
da = _make_da(x, y)
96+
97+
out = tmp_path / 'tmp_1716_north_up.tif'
98+
to_geotiff(da, str(out), crs=4326)
99+
100+
tag_ids = _ifd_tag_ids(str(out))
101+
assert TAG_MODEL_PIXEL_SCALE in tag_ids
102+
assert TAG_MODEL_TIEPOINT in tag_ids
103+
assert TAG_MODEL_TRANSFORMATION not in tag_ids
104+
105+
106+
def test_descending_x_uses_transformation_tag(tmp_path):
107+
"""Non-standard orientation emits ModelTransformationTag and skips
108+
the scale/tiepoint pair."""
109+
x = np.array([200.0, 190.0, 180.0, 170.0, 160.0], dtype=np.float64)
110+
y = np.array([50.0, 40.0, 30.0, 20.0], dtype=np.float64)
111+
da = _make_da(x, y)
112+
113+
out = tmp_path / 'tmp_1716_desc_x_tags.tif'
114+
to_geotiff(da, str(out), crs=4326)
115+
116+
tag_ids = _ifd_tag_ids(str(out))
117+
assert TAG_MODEL_TRANSFORMATION in tag_ids
118+
assert TAG_MODEL_PIXEL_SCALE not in tag_ids
119+
assert TAG_MODEL_TIEPOINT not in tag_ids
120+
121+
122+
def test_ascending_y_uses_transformation_tag(tmp_path):
123+
x = np.array([160.0, 170.0, 180.0, 190.0, 200.0], dtype=np.float64)
124+
y = np.array([20.0, 30.0, 40.0, 50.0], dtype=np.float64)
125+
da = _make_da(x, y)
126+
127+
out = tmp_path / 'tmp_1716_asc_y_tags.tif'
128+
to_geotiff(da, str(out), crs=4326)
129+
130+
tag_ids = _ifd_tag_ids(str(out))
131+
assert TAG_MODEL_TRANSFORMATION in tag_ids
132+
assert TAG_MODEL_PIXEL_SCALE not in tag_ids
133+
assert TAG_MODEL_TIEPOINT not in tag_ids

0 commit comments

Comments
 (0)