Skip to content

Commit 77e8bfb

Browse files
committed
Read and write resolution/DPI tags (282, 283, 296)
Adds support for TIFF resolution metadata used in print and cartographic workflows: - Tag 282 (XResolution): pixels per unit, stored as RATIONAL - Tag 283 (YResolution): pixels per unit, stored as RATIONAL - Tag 296 (ResolutionUnit): 1=none, 2=inch, 3=centimeter Read: resolution values are stored in DataArray attrs as x_resolution, y_resolution (float), and resolution_unit (string: 'none', 'inch', or 'centimeter'). Write: accepted as keyword args on write() and write_geotiff(), or extracted automatically from DataArray attrs. Written as RATIONAL tags (numerator/denominator pairs). Also adds RATIONAL type serialization to the writer's tag encoder. 5 new tests: DPI round-trip, centimeter unit, no-resolution check, DataArray attrs preservation, unit='none'.
1 parent 61178c3 commit 77e8bfb

File tree

5 files changed

+158
-2
lines changed

5 files changed

+158
-2
lines changed

xrspatial/geotiff/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ def read_geotiff(source: str, *, window=None,
135135
if geo_info.raster_type == RASTER_PIXEL_IS_POINT:
136136
attrs['raster_type'] = 'point'
137137

138+
# Resolution / DPI metadata
139+
if geo_info.x_resolution is not None:
140+
attrs['x_resolution'] = geo_info.x_resolution
141+
if geo_info.y_resolution is not None:
142+
attrs['y_resolution'] = geo_info.y_resolution
143+
if geo_info.resolution_unit is not None:
144+
_unit_names = {1: 'none', 2: 'inch', 3: 'centimeter'}
145+
attrs['resolution_unit'] = _unit_names.get(
146+
geo_info.resolution_unit, str(geo_info.resolution_unit))
147+
138148
# Attach palette colormap for indexed-color TIFFs
139149
if geo_info.colormap is not None:
140150
try:
@@ -219,6 +229,9 @@ def write_geotiff(data: xr.DataArray | np.ndarray, path: str, *,
219229
geo_transform = None
220230
epsg = crs
221231
raster_type = RASTER_PIXEL_IS_AREA
232+
x_res = None
233+
y_res = None
234+
res_unit = None
222235

223236
if isinstance(data, xr.DataArray):
224237
arr = data.values
@@ -230,6 +243,13 @@ def write_geotiff(data: xr.DataArray | np.ndarray, path: str, *,
230243
nodata = data.attrs.get('nodata')
231244
if data.attrs.get('raster_type') == 'point':
232245
raster_type = RASTER_PIXEL_IS_POINT
246+
# Resolution / DPI from attrs
247+
x_res = data.attrs.get('x_resolution')
248+
y_res = data.attrs.get('y_resolution')
249+
unit_str = data.attrs.get('resolution_unit')
250+
if unit_str is not None:
251+
_unit_ids = {'none': 1, 'inch': 2, 'centimeter': 3}
252+
res_unit = _unit_ids.get(str(unit_str), None)
233253
else:
234254
arr = np.asarray(data)
235255

@@ -249,6 +269,9 @@ def write_geotiff(data: xr.DataArray | np.ndarray, path: str, *,
249269
overview_levels=overview_levels,
250270
overview_resampling=overview_resampling,
251271
raster_type=raster_type,
272+
x_resolution=x_res,
273+
y_resolution=y_res,
274+
resolution_unit=res_unit,
252275
)
253276

254277

xrspatial/geotiff/_geotags.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ class GeoInfo:
6363
raster_type: int = RASTER_PIXEL_IS_AREA
6464
nodata: float | None = None
6565
colormap: list | None = None # list of (R, G, B, A) float tuples, or None
66+
x_resolution: float | None = None
67+
y_resolution: float | None = None
68+
resolution_unit: int | None = None # 1=none, 2=inch, 3=cm
6669
geokeys: dict[int, int | float | str] = field(default_factory=dict)
6770

6871

@@ -267,6 +270,9 @@ def extract_geo_info(ifd: IFD, data: bytes | memoryview,
267270
raster_type=int(raster_type) if isinstance(raster_type, (int, float)) else RASTER_PIXEL_IS_AREA,
268271
nodata=nodata,
269272
colormap=colormap,
273+
x_resolution=ifd.x_resolution,
274+
y_resolution=ifd.y_resolution,
275+
resolution_unit=ifd.resolution_unit,
270276
geokeys=geokeys,
271277
)
272278

xrspatial/geotiff/_header.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
TAG_SAMPLES_PER_PIXEL = 277
2525
TAG_ROWS_PER_STRIP = 278
2626
TAG_STRIP_BYTE_COUNTS = 279
27+
TAG_X_RESOLUTION = 282
28+
TAG_Y_RESOLUTION = 283
2729
TAG_PLANAR_CONFIG = 284
30+
TAG_RESOLUTION_UNIT = 296
2831
TAG_PREDICTOR = 317
2932
TAG_TILE_WIDTH = 322
3033
TAG_TILE_LENGTH = 323
@@ -159,6 +162,23 @@ def photometric(self) -> int:
159162
def planar_config(self) -> int:
160163
return self.get_value(TAG_PLANAR_CONFIG, 1)
161164

165+
@property
166+
def x_resolution(self) -> float | None:
167+
"""XResolution tag (282), or None if absent."""
168+
v = self.get_value(TAG_X_RESOLUTION)
169+
return float(v) if v is not None else None
170+
171+
@property
172+
def y_resolution(self) -> float | None:
173+
"""YResolution tag (283), or None if absent."""
174+
v = self.get_value(TAG_Y_RESOLUTION)
175+
return float(v) if v is not None else None
176+
177+
@property
178+
def resolution_unit(self) -> int | None:
179+
"""ResolutionUnit tag (296): 1=none, 2=inch, 3=cm. None if absent."""
180+
return self.get_value(TAG_RESOLUTION_UNIT)
181+
162182
@property
163183
def colormap(self) -> tuple | None:
164184
"""ColorMap tag (320) values, or None if absent."""

xrspatial/geotiff/_writer.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
)
1818
from ._dtypes import (
1919
DOUBLE,
20+
RATIONAL,
2021
SHORT,
2122
LONG,
2223
ASCII,
@@ -42,6 +43,9 @@
4243
TAG_STRIP_OFFSETS,
4344
TAG_ROWS_PER_STRIP,
4445
TAG_STRIP_BYTE_COUNTS,
46+
TAG_X_RESOLUTION,
47+
TAG_Y_RESOLUTION,
48+
TAG_RESOLUTION_UNIT,
4549
TAG_TILE_WIDTH,
4650
TAG_TILE_LENGTH,
4751
TAG_TILE_OFFSETS,
@@ -154,6 +158,16 @@ def _make_overview(arr: np.ndarray, method: str = 'mean') -> np.ndarray:
154158
# Tag serialization
155159
# ---------------------------------------------------------------------------
156160

161+
def _float_to_rational(val):
162+
"""Convert a float to a TIFF RATIONAL (numerator, denominator) pair."""
163+
if val == int(val):
164+
return (int(val), 1)
165+
# Use a denominator of 10000 for reasonable precision
166+
den = 10000
167+
num = int(round(val * den))
168+
return (num, den)
169+
170+
157171
def _serialize_tag_value(type_id, count, values):
158172
"""Serialize tag values to bytes."""
159173
if type_id == ASCII:
@@ -168,6 +182,16 @@ def _serialize_tag_value(type_id, count, values):
168182
if isinstance(values, (list, tuple)):
169183
return struct.pack(f'{BO}{count}I', *values)
170184
return struct.pack(f'{BO}I', values)
185+
elif type_id == RATIONAL:
186+
# RATIONAL = two LONGs (numerator, denominator) per value
187+
if isinstance(values, (list, tuple)) and isinstance(values[0], (list, tuple)):
188+
parts = []
189+
for num, den in values:
190+
parts.extend([int(num), int(den)])
191+
return struct.pack(f'{BO}{count * 2}I', *parts)
192+
else:
193+
num, den = _float_to_rational(float(values))
194+
return struct.pack(f'{BO}II', num, den)
171195
elif type_id == DOUBLE:
172196
if isinstance(values, (list, tuple)):
173197
return struct.pack(f'{BO}{count}d', *values)
@@ -387,7 +411,10 @@ def _assemble_tiff(width: int, height: int, dtype: np.dtype,
387411
crs_epsg: int | None,
388412
nodata,
389413
is_cog: bool = False,
390-
raster_type: int = 1) -> bytes:
414+
raster_type: int = 1,
415+
x_resolution: float | None = None,
416+
y_resolution: float | None = None,
417+
resolution_unit: int | None = None) -> bytes:
391418
"""Assemble a complete TIFF file.
392419
393420
Parameters
@@ -455,6 +482,14 @@ def _assemble_tiff(width: int, height: int, dtype: np.dtype,
455482
if pred_val != 1:
456483
tags.append((TAG_PREDICTOR, SHORT, 1, pred_val))
457484

485+
# Resolution / DPI tags
486+
if x_resolution is not None:
487+
tags.append((TAG_X_RESOLUTION, RATIONAL, 1, x_resolution))
488+
if y_resolution is not None:
489+
tags.append((TAG_Y_RESOLUTION, RATIONAL, 1, y_resolution))
490+
if resolution_unit is not None:
491+
tags.append((TAG_RESOLUTION_UNIT, SHORT, 1, resolution_unit))
492+
458493
if tiled:
459494
tags.append((TAG_TILE_WIDTH, SHORT, 1, tile_size))
460495
tags.append((TAG_TILE_LENGTH, SHORT, 1, tile_size))
@@ -665,7 +700,10 @@ def write(data: np.ndarray, path: str, *,
665700
cog: bool = False,
666701
overview_levels: list[int] | None = None,
667702
overview_resampling: str = 'mean',
668-
raster_type: int = 1) -> None:
703+
raster_type: int = 1,
704+
x_resolution: float | None = None,
705+
y_resolution: float | None = None,
706+
resolution_unit: int | None = None) -> None:
669707
"""Write a numpy array as a GeoTIFF or COG.
670708
671709
Parameters
@@ -734,6 +772,8 @@ def write(data: np.ndarray, path: str, *,
734772
w, h, data.dtype, comp_tag, predictor, tiled, tile_size,
735773
parts, geo_transform, crs_epsg, nodata, is_cog=cog,
736774
raster_type=raster_type,
775+
x_resolution=x_resolution, y_resolution=y_resolution,
776+
resolution_unit=resolution_unit,
737777
)
738778

739779
# Write to a temp file then atomically rename, so concurrent writes to

xrspatial/geotiff/tests/test_features.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,73 @@ def test_zstd_public_api(self, tmp_path):
256256
np.testing.assert_array_equal(result.values, arr)
257257

258258

259+
# -----------------------------------------------------------------------
260+
# Resolution / DPI tags
261+
# -----------------------------------------------------------------------
262+
263+
class TestResolution:
264+
265+
def test_write_read_dpi(self, tmp_path):
266+
"""Resolution tags round-trip through write and read."""
267+
arr = np.ones((4, 4), dtype=np.float32)
268+
path = str(tmp_path / 'dpi.tif')
269+
write(arr, path, compression='none', tiled=False,
270+
x_resolution=300.0, y_resolution=300.0, resolution_unit=2)
271+
272+
da = read_geotiff(path)
273+
assert da.attrs['x_resolution'] == pytest.approx(300.0, rel=0.01)
274+
assert da.attrs['y_resolution'] == pytest.approx(300.0, rel=0.01)
275+
assert da.attrs['resolution_unit'] == 'inch'
276+
277+
def test_write_read_cm(self, tmp_path):
278+
"""Centimeter resolution unit."""
279+
arr = np.ones((4, 4), dtype=np.float32)
280+
path = str(tmp_path / 'dpi_cm.tif')
281+
write(arr, path, compression='none', tiled=False,
282+
x_resolution=118.0, y_resolution=118.0, resolution_unit=3)
283+
284+
da = read_geotiff(path)
285+
assert da.attrs['x_resolution'] == pytest.approx(118.0, rel=0.01)
286+
assert da.attrs['resolution_unit'] == 'centimeter'
287+
288+
def test_no_resolution_no_attrs(self, tmp_path):
289+
"""Files without resolution tags don't get resolution attrs."""
290+
arr = np.ones((4, 4), dtype=np.float32)
291+
path = str(tmp_path / 'no_dpi.tif')
292+
write(arr, path, compression='none', tiled=False)
293+
294+
da = read_geotiff(path)
295+
assert 'x_resolution' not in da.attrs
296+
assert 'y_resolution' not in da.attrs
297+
assert 'resolution_unit' not in da.attrs
298+
299+
def test_dataarray_attrs_round_trip(self, tmp_path):
300+
"""Resolution attrs on DataArray are preserved through write/read."""
301+
da = xr.DataArray(
302+
np.ones((4, 4), dtype=np.float32),
303+
dims=['y', 'x'],
304+
attrs={'x_resolution': 72.0, 'y_resolution': 72.0,
305+
'resolution_unit': 'inch'},
306+
)
307+
path = str(tmp_path / 'da_dpi.tif')
308+
write_geotiff(da, path, compression='none')
309+
310+
result = read_geotiff(path)
311+
assert result.attrs['x_resolution'] == pytest.approx(72.0, rel=0.01)
312+
assert result.attrs['y_resolution'] == pytest.approx(72.0, rel=0.01)
313+
assert result.attrs['resolution_unit'] == 'inch'
314+
315+
def test_unit_none(self, tmp_path):
316+
"""ResolutionUnit=1 (no unit) round-trips as 'none'."""
317+
arr = np.ones((4, 4), dtype=np.float32)
318+
path = str(tmp_path / 'no_unit.tif')
319+
write(arr, path, compression='none', tiled=False,
320+
x_resolution=1.0, y_resolution=1.0, resolution_unit=1)
321+
322+
da = read_geotiff(path)
323+
assert da.attrs['resolution_unit'] == 'none'
324+
325+
259326
# -----------------------------------------------------------------------
260327
# Overview resampling methods
261328
# -----------------------------------------------------------------------

0 commit comments

Comments
 (0)