async-geotiff 0.1.0b3__tar.gz → 0.1.0b4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: async-geotiff
3
- Version: 0.1.0b3
3
+ Version: 0.1.0b4
4
4
  Summary: Async GeoTIFF reader for Python
5
5
  Keywords: geotiff,tiff,async,cog,raster,gis
6
6
  Author: Kyle Barron
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.14
18
18
  Classifier: Topic :: Scientific/Engineering :: GIS
19
19
  Classifier: Typing :: Typed
20
20
  Requires-Dist: affine>=2.4.0
21
- Requires-Dist: async-tiff>=0.4.0
21
+ Requires-Dist: async-tiff>=0.5.0b1
22
22
  Requires-Dist: numpy>=2.0
23
23
  Requires-Dist: pyproj>=3.3.0
24
24
  Requires-Dist: morecantile>=7.0,<8.0 ; extra == 'morecantile'
@@ -33,9 +33,9 @@ Description-Content-Type: text/markdown
33
33
 
34
34
  # async-geotiff
35
35
 
36
- Async GeoTIFF and [Cloud-Optimized GeoTIFF][cogeo] (COG) reader for Python, wrapping [`async-tiff`].
36
+ Async GeoTIFF and [Cloud-Optimized GeoTIFF][cogeo] (COG) reader for Python, wrapping [`async-tiff`][async-tiff].
37
37
 
38
- [`async-tiff`]: https://github.com/developmentseed/async-tiff
38
+ [async-tiff]: https://github.com/developmentseed/async-tiff
39
39
  [cogeo]: https://cogeo.org/
40
40
 
41
41
  ## Project Goals:
@@ -1,8 +1,8 @@
1
1
  # async-geotiff
2
2
 
3
- Async GeoTIFF and [Cloud-Optimized GeoTIFF][cogeo] (COG) reader for Python, wrapping [`async-tiff`].
3
+ Async GeoTIFF and [Cloud-Optimized GeoTIFF][cogeo] (COG) reader for Python, wrapping [`async-tiff`][async-tiff].
4
4
 
5
- [`async-tiff`]: https://github.com/developmentseed/async-tiff
5
+ [async-tiff]: https://github.com/developmentseed/async-tiff
6
6
  [cogeo]: https://cogeo.org/
7
7
 
8
8
  ## Project Goals:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "async-geotiff"
3
- version = "0.1.0-beta.3"
3
+ version = "0.1.0-beta.4"
4
4
  description = "Async GeoTIFF reader for Python"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Kyle Barron", email = "kyle@developmentseed.org" }]
@@ -22,7 +22,7 @@ classifiers = [
22
22
  keywords = ["geotiff", "tiff", "async", "cog", "raster", "gis"]
23
23
  dependencies = [
24
24
  "affine>=2.4.0",
25
- "async-tiff>=0.4.0",
25
+ "async-tiff>=0.5.0-beta.1",
26
26
  "numpy>=2.0",
27
27
  "pyproj>=3.3.0",
28
28
  ]
@@ -51,6 +51,17 @@ dev = [
51
51
  "rasterio>=1.4.4",
52
52
  "types-jsonschema>=4.26.0.20260109",
53
53
  ]
54
+ docs = [
55
+ "mkdocs-material[imaging]>=9.5.49",
56
+ "mkdocs>=1.6.1",
57
+ "mkdocstrings[python]>=1.0",
58
+ "mike>=2.1.3",
59
+ "griffe-inherited-docstrings>=1.0.1",
60
+ # We use ruff format ourselves, but mkdocstrings requires black to be
61
+ # installed to format signatures in the docs
62
+ "black>=26",
63
+ ]
64
+
54
65
 
55
66
  [build-system]
56
67
  requires = ["uv_build>=0.8.8,<0.9.0"]
@@ -3,8 +3,9 @@
3
3
  [cogeo]: https://cogeo.org/
4
4
  """
5
5
 
6
+ from ._array import Array
6
7
  from ._geotiff import GeoTIFF
7
8
  from ._overview import Overview
8
9
  from ._version import __version__
9
10
 
10
- __all__ = ["GeoTIFF", "Overview", "__version__"]
11
+ __all__ = ["Array", "GeoTIFF", "Overview", "__version__"]
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Self
5
+
6
+ import numpy as np
7
+ from async_tiff.enums import PlanarConfiguration
8
+
9
+ from async_geotiff._transform import TransformMixin
10
+
11
+ if TYPE_CHECKING:
12
+ from affine import Affine
13
+ from async_tiff import Array as AsyncTiffArray
14
+ from numpy.typing import NDArray
15
+ from pyproj.crs import CRS
16
+
17
+
18
+ @dataclass(frozen=True, kw_only=True, eq=False)
19
+ class Array(TransformMixin):
20
+ """An array representation of data from a GeoTIFF."""
21
+
22
+ data: NDArray
23
+ """The array data with shape (bands, height, width)."""
24
+
25
+ mask: NDArray[np.bool_] | None
26
+ """The mask array with shape (height, width), if any.
27
+
28
+ Values of True indicate valid data; False indicates no data.
29
+ """
30
+
31
+ width: int
32
+ """The width of the array in pixels."""
33
+
34
+ height: int
35
+ """The height of the array in pixels."""
36
+
37
+ count: int
38
+ """The number of bands in the array."""
39
+
40
+ transform: Affine
41
+ """The affine transform mapping pixel coordinates to geographic coordinates."""
42
+
43
+ crs: CRS
44
+ """The coordinate reference system of the array."""
45
+
46
+ @classmethod
47
+ def _create(
48
+ cls,
49
+ *,
50
+ data: AsyncTiffArray,
51
+ mask: AsyncTiffArray | None,
52
+ planar_configuration: PlanarConfiguration,
53
+ transform: Affine,
54
+ crs: CRS,
55
+ ) -> Self:
56
+ """Create an Array from async_tiff data.
57
+
58
+ Handles axis reordering to ensure data is always in (bands, height, width)
59
+ order, matching rasterio's convention.
60
+
61
+ Args:
62
+ data: The decoded tile data from async_tiff.
63
+ mask: The decoded mask data from async_tiff, if any.
64
+ planar_configuration: The planar configuration of the source IFD.
65
+ transform: The affine transform for this tile.
66
+ crs: The coordinate reference system.
67
+
68
+ Returns:
69
+ An Array with data in (bands, height, width) order.
70
+
71
+ """
72
+ data_arr = np.asarray(data, copy=False)
73
+ if mask is not None:
74
+ mask_arr = np.asarray(mask, copy=False).astype(np.bool_, copy=False)
75
+ assert mask_arr.ndim == 3 # noqa: S101, PLR2004
76
+ assert mask_arr.shape[2] == 1 # noqa: S101
77
+ # This assumes it's always (height, width, 1)
78
+ mask_arr = np.squeeze(mask_arr, axis=2)
79
+ else:
80
+ mask_arr = None
81
+
82
+ assert data_arr.ndim == 3, f"Expected 3D array, got {data_arr.ndim}D" # noqa: S101, PLR2004
83
+
84
+ # async_tiff returns data in the native TIFF order:
85
+ # - Chunky (pixel interleaved): (height, width, bands)
86
+ # - Planar (band interleaved): (bands, height, width)
87
+ # We always want (bands, height, width) to match rasterio.
88
+ if planar_configuration == PlanarConfiguration.Chunky:
89
+ # Transpose from (H, W, C) to (C, H, W)
90
+ data_arr = np.moveaxis(data_arr, -1, 0)
91
+
92
+ count, height, width = data_arr.shape
93
+
94
+ return cls(
95
+ data=data_arr,
96
+ mask=mask_arr,
97
+ width=width,
98
+ height=height,
99
+ count=count,
100
+ transform=transform,
101
+ crs=crs,
102
+ )
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING, Protocol
5
+
6
+ from affine import Affine
7
+
8
+ from async_geotiff import Array
9
+ from async_geotiff._transform import HasTransform
10
+
11
+ if TYPE_CHECKING:
12
+ from async_tiff import TIFF
13
+ from async_tiff import Array as AsyncTiffArray
14
+ from pyproj import CRS
15
+
16
+ from async_geotiff._ifd import IFDReference
17
+
18
+
19
+ class HasTiffReference(HasTransform, Protocol):
20
+ """Protocol for objects that hold a TIFF reference and can request tiles."""
21
+
22
+ @property
23
+ def _ifd(self) -> IFDReference:
24
+ """The data IFD for this image (index, IFD)."""
25
+ ...
26
+
27
+ @property
28
+ def _mask_ifd(self) -> IFDReference | None:
29
+ """The mask IFD for this image (index, IFD), if any."""
30
+ ...
31
+
32
+ @property
33
+ def _tiff(self) -> TIFF:
34
+ """A reference to the underlying TIFF object."""
35
+ ...
36
+
37
+ @property
38
+ def crs(self) -> CRS:
39
+ """The coordinate reference system."""
40
+ ...
41
+
42
+ @property
43
+ def tile_height(self) -> int:
44
+ """The height of tiles in pixels."""
45
+ ...
46
+
47
+ @property
48
+ def tile_width(self) -> int:
49
+ """The width of tiles in pixels."""
50
+ ...
51
+
52
+
53
+ class FetchTileMixin:
54
+ """Mixin for fetching tiles from a GeoTIFF.
55
+
56
+ Classes using this mixin must implement HasTiffReference.
57
+ """
58
+
59
+ async def fetch_tile(
60
+ self: HasTiffReference,
61
+ x: int,
62
+ y: int,
63
+ ) -> Array:
64
+ tile_fut = self._tiff.fetch_tile(x, y, self._ifd.index)
65
+
66
+ mask_data: AsyncTiffArray | None = None
67
+ if self._mask_ifd is not None:
68
+ mask_ifd_index = self._mask_ifd.index
69
+ mask_fut = self._tiff.fetch_tile(x, y, mask_ifd_index)
70
+ tile, mask = await asyncio.gather(tile_fut, mask_fut)
71
+ tile_data, mask_data = await asyncio.gather(tile.decode(), mask.decode())
72
+ else:
73
+ tile = await tile_fut
74
+ tile_data = await tile.decode()
75
+
76
+ tile_transform = self.transform * Affine.translation(
77
+ x * self.tile_width,
78
+ y * self.tile_height,
79
+ )
80
+
81
+ return Array._create( # noqa: SLF001
82
+ data=tile_data,
83
+ mask=mask_data,
84
+ planar_configuration=self._ifd.ifd.planar_configuration,
85
+ crs=self.crs,
86
+ transform=tile_transform,
87
+ )
88
+
89
+ async def fetch_tiles(
90
+ self: HasTiffReference,
91
+ xs: list[int],
92
+ ys: list[int],
93
+ ) -> list[Array]:
94
+ """Fetch multiple tiles from this overview.
95
+
96
+ Args:
97
+ xs: The x coordinates of the tiles.
98
+ ys: The y coordinates of the tiles.
99
+
100
+ """
101
+ tiles_fut = self._tiff.fetch_tiles(xs, ys, self._ifd.index)
102
+
103
+ decoded_masks: list[AsyncTiffArray | None] = [None] * len(xs)
104
+ if self._mask_ifd is not None:
105
+ mask_ifd_index = self._mask_ifd.index
106
+ masks_fut = self._tiff.fetch_tiles(xs, ys, mask_ifd_index)
107
+ tiles, masks = await asyncio.gather(tiles_fut, masks_fut)
108
+
109
+ decoded_tile_futs = [tile.decode() for tile in tiles]
110
+ decoded_mask_futs = [mask.decode() for mask in masks]
111
+ decoded_tiles = await asyncio.gather(*decoded_tile_futs)
112
+ decoded_masks = await asyncio.gather(*decoded_mask_futs)
113
+ else:
114
+ tiles = await tiles_fut
115
+ decoded_tiles = await asyncio.gather(*[tile.decode() for tile in tiles])
116
+
117
+ arrays: list[Array] = []
118
+ for x, y, tile_data, mask_data in zip(
119
+ xs,
120
+ ys,
121
+ decoded_tiles,
122
+ decoded_masks,
123
+ strict=True,
124
+ ):
125
+ tile_transform = self.transform * Affine.translation(
126
+ x * self.tile_width,
127
+ y * self.tile_height,
128
+ )
129
+ array = Array._create( # noqa: SLF001
130
+ data=tile_data,
131
+ mask=mask_data,
132
+ planar_configuration=self._ifd.ifd.planar_configuration,
133
+ crs=self.crs,
134
+ transform=tile_transform,
135
+ )
136
+ arrays.append(array)
137
+
138
+ return arrays
@@ -2,38 +2,53 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
4
  from functools import cached_property
5
- from typing import TYPE_CHECKING, Literal, Self
5
+ from typing import TYPE_CHECKING, Self
6
6
 
7
7
  from affine import Affine
8
8
  from async_tiff import TIFF
9
9
  from async_tiff.enums import PhotometricInterpretation
10
10
 
11
11
  from async_geotiff._crs import crs_from_geo_keys
12
+ from async_geotiff._fetch import FetchTileMixin
13
+ from async_geotiff._ifd import IFDReference
12
14
  from async_geotiff._overview import Overview
13
- from async_geotiff.enums import Compression, Interleaving
15
+ from async_geotiff._transform import TransformMixin
14
16
 
15
17
  if TYPE_CHECKING:
16
- from collections.abc import Callable
17
-
18
- import pyproj
19
18
  from async_tiff import GeoKeyDirectory, ImageFileDirectory, ObspecInput
20
19
  from async_tiff.store import ObjectStore # type: ignore # noqa: PGH003
20
+ from pyproj.crs import CRS
21
+
22
+ from async_geotiff.enums import Compression, Interleaving
21
23
 
22
24
 
23
25
  @dataclass(frozen=True, init=False, kw_only=True, repr=False)
24
- class GeoTIFF:
26
+ class GeoTIFF(FetchTileMixin, TransformMixin):
25
27
  """A class representing a GeoTIFF image."""
26
28
 
29
+ _crs: CRS | None = None
30
+ """A cached CRS instance.
31
+
32
+ We don't use functools.cached_property on the `crs` attribute because of typing
33
+ issues.
34
+ """
35
+
27
36
  _tiff: TIFF
28
37
  """The underlying async-tiff TIFF instance that we wrap.
29
38
  """
30
39
 
31
- _primary_ifd: ImageFileDirectory = field(init=False)
40
+ _primary_ifd: IFDReference = field(init=False)
32
41
  """The primary (first) IFD of the GeoTIFF.
33
42
 
34
43
  Some tags, like most geo tags, only exist on the primary IFD.
35
44
  """
36
45
 
46
+ _mask_ifd: IFDReference | None = None
47
+ """The mask IFD of the full-resolution GeoTIFF, if any.
48
+
49
+ (positional index of the IFD in the TIFF file, IFD object)
50
+ """
51
+
37
52
  _gkd: GeoKeyDirectory = field(init=False)
38
53
  """The GeoKeyDirectory of the primary IFD.
39
54
  """
@@ -42,6 +57,11 @@ class GeoTIFF:
42
57
  """A list of overviews for the GeoTIFF.
43
58
  """
44
59
 
60
+ @property
61
+ def _ifd(self) -> IFDReference:
62
+ """An alias for the primary IFD to satisfy _fetch protocol."""
63
+ return self._primary_ifd
64
+
45
65
  def __init__(self, tiff: TIFF) -> None:
46
66
  """Create a GeoTIFF from an existing TIFF instance."""
47
67
  first_ifd = tiff.ifds[0]
@@ -56,32 +76,35 @@ class GeoTIFF:
56
76
 
57
77
  # We use object.__setattr__ because the dataclass is frozen
58
78
  object.__setattr__(self, "_tiff", tiff)
59
- object.__setattr__(self, "_primary_ifd", first_ifd)
79
+ object.__setattr__(self, "_primary_ifd", IFDReference(index=0, ifd=first_ifd))
60
80
  object.__setattr__(self, "_gkd", gkd)
61
81
 
62
- # Skip the first IFD, since it's the primary image
63
- ifd_idx = 1
82
+ # Separate data IFDs and mask IFDs (skip the primary IFD at index 0)
83
+ # Data IFDs are indexed by (width, height) for matching with masks
84
+ data_ifds: dict[tuple[int, int], IFDReference] = {}
85
+ mask_ifds: dict[tuple[int, int], IFDReference] = {}
86
+
87
+ for idx, ifd in enumerate(tiff.ifds[1:], start=1):
88
+ dims = (ifd.image_width, ifd.image_height)
89
+ if is_mask_ifd(ifd):
90
+ mask_ifds[dims] = IFDReference(index=idx, ifd=ifd)
91
+ else:
92
+ data_ifds[dims] = IFDReference(index=idx, ifd=ifd)
93
+
94
+ # Find and set the mask for the primary IFD (matches primary dimensions)
95
+ if primary_mask_ifd := mask_ifds.get(
96
+ (first_ifd.image_width, first_ifd.image_height),
97
+ ):
98
+ object.__setattr__(self, "_mask_ifd", primary_mask_ifd)
99
+
100
+ # Build overviews, sorted by resolution (highest to lowest, i.e., largest first)
101
+ # Sort by width * height descending
102
+ sorted_dims = sorted(data_ifds.keys(), key=lambda d: d[0] * d[1], reverse=True)
103
+
64
104
  overviews: list[Overview] = []
65
- while True:
66
- try:
67
- data_ifd = (ifd_idx, tiff.ifds[ifd_idx])
68
- except IndexError:
69
- # No more IFDs
70
- break
71
-
72
- ifd_idx += 1
73
-
74
- mask_ifd = None
75
- next_ifd = None
76
- try:
77
- next_ifd = tiff.ifds[ifd_idx]
78
- except IndexError:
79
- # No more IFDs
80
- pass
81
- finally:
82
- if next_ifd is not None and is_mask_ifd(next_ifd):
83
- mask_ifd = (ifd_idx, next_ifd)
84
- ifd_idx += 1
105
+ for dims in sorted_dims:
106
+ data_ifd = data_ifds[dims]
107
+ mask_ifd = mask_ifds.get(dims)
85
108
 
86
109
  ovr = Overview._create( # noqa: SLF001
87
110
  geotiff=self,
@@ -180,10 +203,9 @@ class GeoTIFF:
180
203
  4-element tuple.
181
204
 
182
205
  Raises:
183
- ValueError
184
- If no colormap is found for the specified band (NULL color table).
185
- IndexError
186
- If no band exists for the provided index.
206
+ ValueError: If no colormap is found for the specified band (NULL color
207
+ table).
208
+ IndexError: If no band exists for the provided index.
187
209
 
188
210
  """
189
211
  raise NotImplementedError
@@ -204,10 +226,15 @@ class GeoTIFF:
204
226
  """The number of raster bands in the full image."""
205
227
  raise NotImplementedError
206
228
 
207
- @cached_property
208
- def crs(self) -> pyproj.CRS:
229
+ @property
230
+ def crs(self) -> CRS:
209
231
  """The dataset's coordinate reference system."""
210
- return crs_from_geo_keys(self._gkd)
232
+ if self._crs is not None:
233
+ return self._crs
234
+
235
+ crs = crs_from_geo_keys(self._gkd)
236
+ object.__setattr__(self, "_crs", crs)
237
+ return crs
211
238
 
212
239
  @property
213
240
  def dtypes(self) -> list[str]:
@@ -220,35 +247,14 @@ class GeoTIFF:
220
247
  @property
221
248
  def height(self) -> int:
222
249
  """The height (number of rows) of the full image."""
223
- return self._primary_ifd.image_height
224
-
225
- def index(
226
- self,
227
- x: float,
228
- y: float,
229
- op: Callable[[float, float], tuple[int, int]] | None = None,
230
- ) -> tuple[int, int]:
231
- """Get the (row, col) index of the pixel containing (x, y).
232
-
233
- Args:
234
- x: x value in coordinate reference system
235
- y: y value in coordinate reference system
236
- op: function, optional (default: numpy.floor)
237
- Function to convert fractional pixels to whole numbers
238
- (floor, ceiling, round)
239
-
240
- Returns:
241
- (row index, col index)
242
-
243
- """
244
- raise NotImplementedError
250
+ return self._primary_ifd.ifd.image_height
245
251
 
246
252
  def indexes(self) -> list[int]:
247
253
  """Return the 1-based indexes of each band in the dataset.
248
254
 
249
255
  For a 3-band dataset, this property will be [1, 2, 3].
250
256
  """
251
- return list(range(1, self._primary_ifd.samples_per_pixel + 1))
257
+ return list(range(1, self._primary_ifd.ifd.samples_per_pixel + 1))
252
258
 
253
259
  @property
254
260
  def interleaving(self) -> Interleaving:
@@ -265,7 +271,7 @@ class GeoTIFF:
265
271
  @property
266
272
  def nodata(self) -> float | None:
267
273
  """The dataset's single nodata value."""
268
- nodata = self._primary_ifd.gdal_nodata
274
+ nodata = self._primary_ifd.ifd.gdal_nodata
269
275
  if nodata is None:
270
276
  return None
271
277
 
@@ -283,7 +289,7 @@ class GeoTIFF:
283
289
  # https://rasterio.readthedocs.io/en/stable/api/rasterio.enums.html#rasterio.enums.PhotometricInterp
284
290
  raise NotImplementedError
285
291
 
286
- @cached_property
292
+ @property
287
293
  def res(self) -> tuple[float, float]:
288
294
  """Return the (width, height) of pixels in the units of its CRS."""
289
295
  transform = self.transform
@@ -294,15 +300,25 @@ class GeoTIFF:
294
300
  """Get the shape (height, width) of the full image."""
295
301
  return (self.height, self.width)
296
302
 
297
- @cached_property
303
+ @property
304
+ def tile_height(self) -> int:
305
+ """The height in pixels per tile of the image."""
306
+ return self._primary_ifd.ifd.tile_height or self.height
307
+
308
+ @property
309
+ def tile_width(self) -> int:
310
+ """The width in pixels per tile of the image."""
311
+ return self._primary_ifd.ifd.tile_width or self.width
312
+
313
+ @property
298
314
  def transform(self) -> Affine:
299
315
  """Return the dataset's georeferencing transformation matrix.
300
316
 
301
317
  This transform maps pixel row/column coordinates to coordinates in the dataset's
302
318
  CRS.
303
319
  """
304
- if (tie_points := self._primary_ifd.model_tiepoint) and (
305
- model_scale := self._primary_ifd.model_pixel_scale
320
+ if (tie_points := self._primary_ifd.ifd.model_tiepoint) and (
321
+ model_scale := self._primary_ifd.ifd.model_pixel_scale
306
322
  ):
307
323
  x_origin = tie_points[3]
308
324
  y_origin = tie_points[4]
@@ -311,7 +327,7 @@ class GeoTIFF:
311
327
 
312
328
  return Affine(x_resolution, 0, x_origin, 0, y_resolution, y_origin)
313
329
 
314
- if model_transformation := self._primary_ifd.model_transformation:
330
+ if model_transformation := self._primary_ifd.ifd.model_transformation:
315
331
  # ModelTransformation is a 4x4 matrix in row-major order
316
332
  # [0 1 2 3 ] [a b 0 c]
317
333
  # [4 5 6 7 ] = [d e 0 f]
@@ -342,47 +358,7 @@ class GeoTIFF:
342
358
  @property
343
359
  def width(self) -> int:
344
360
  """The width (number of columns) of the full image."""
345
- return self._primary_ifd.image_width
346
-
347
- def xy(
348
- self,
349
- row: int,
350
- col: int,
351
- offset: Literal["center", "ul", "ur", "ll", "lr"] | str = "center",
352
- ) -> tuple[float, float]:
353
- """Get the coordinates x, y of a pixel at row, col.
354
-
355
- The pixel's center is returned by default, but a corner can be returned
356
- by setting `offset` to one of `"ul"`, `"ur"`, `"ll"`, `"lr"`.
357
-
358
- Args:
359
- row: Pixel row.
360
- col: Pixel column.
361
- offset: Determines if the returned coordinates are for the center of the
362
- pixel or for a corner.
363
-
364
- """
365
- transform = self.transform
366
-
367
- if offset == "center":
368
- c = col + 0.5
369
- r = row + 0.5
370
- elif offset == "ul":
371
- c = col
372
- r = row
373
- elif offset == "ur":
374
- c = col + 1
375
- r = row
376
- elif offset == "ll":
377
- c = col
378
- r = row + 1
379
- elif offset == "lr":
380
- c = col + 1
381
- r = row + 1
382
- else:
383
- raise ValueError(f"Invalid offset value: {offset}")
384
-
385
- return transform * (c, r)
361
+ return self._primary_ifd.ifd.image_width
386
362
 
387
363
 
388
364
  def has_geokeys(ifd: ImageFileDirectory) -> bool:
@@ -398,8 +374,7 @@ def has_geokeys(ifd: ImageFileDirectory) -> bool:
398
374
  def is_mask_ifd(ifd: ImageFileDirectory) -> bool:
399
375
  """Check if an IFD is a mask IFD."""
400
376
  return (
401
- ifd.compression == Compression.deflate
402
- and ifd.new_subfile_type is not None
377
+ ifd.new_subfile_type is not None
403
378
  and ifd.new_subfile_type & 4 != 0
404
379
  and ifd.photometric_interpretation == PhotometricInterpretation.TransparencyMask
405
380
  )
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from async_tiff import ImageFileDirectory
8
+
9
+
10
+ @dataclass(frozen=True, kw_only=True, repr=False)
11
+ class IFDReference:
12
+ """A reference to an Image File Directory (IFD) in a TIFF file."""
13
+
14
+ index: int
15
+ """The positional index of the IFD in the TIFF file."""
16
+
17
+ ifd: ImageFileDirectory
18
+ """The IFD object itself."""
@@ -1,19 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from functools import cached_property
5
4
  from typing import TYPE_CHECKING
6
5
 
7
6
  from affine import Affine
8
7
 
8
+ from async_geotiff._fetch import FetchTileMixin
9
+ from async_geotiff._transform import TransformMixin
10
+
9
11
  if TYPE_CHECKING:
10
- from async_tiff import GeoKeyDirectory, ImageFileDirectory
12
+ from async_tiff import TIFF, GeoKeyDirectory
13
+ from pyproj.crs import CRS
11
14
 
12
15
  from async_geotiff import GeoTIFF
16
+ from async_geotiff._ifd import IFDReference
17
+
18
+ # ruff: noqa: SLF001
13
19
 
14
20
 
15
21
  @dataclass(init=False, frozen=True, kw_only=True, eq=False, repr=False)
16
- class Overview:
22
+ class Overview(FetchTileMixin, TransformMixin):
17
23
  """An overview level of a Cloud-Optimized GeoTIFF image."""
18
24
 
19
25
  _geotiff: GeoTIFF
@@ -24,13 +30,13 @@ class Overview:
24
30
  """The GeoKeyDirectory of the primary IFD.
25
31
  """
26
32
 
27
- _ifd: tuple[int, ImageFileDirectory]
33
+ _ifd: IFDReference
28
34
  """The IFD for this overview level.
29
35
 
30
36
  (positional index of the IFD in the TIFF file, IFD object)
31
37
  """
32
38
 
33
- _mask_ifd: tuple[int, ImageFileDirectory] | None
39
+ _mask_ifd: IFDReference | None
34
40
  """The IFD for the mask associated with this overview level, if any.
35
41
 
36
42
  (positional index of the IFD in the TIFF file, IFD object)
@@ -42,8 +48,8 @@ class Overview:
42
48
  *,
43
49
  geotiff: GeoTIFF,
44
50
  gkd: GeoKeyDirectory,
45
- ifd: tuple[int, ImageFileDirectory],
46
- mask_ifd: tuple[int, ImageFileDirectory] | None,
51
+ ifd: IFDReference,
52
+ mask_ifd: IFDReference | None,
47
53
  ) -> Overview:
48
54
  instance = cls.__new__(cls)
49
55
 
@@ -55,13 +61,33 @@ class Overview:
55
61
 
56
62
  return instance
57
63
 
64
+ @property
65
+ def _tiff(self) -> TIFF:
66
+ """A reference to the underlying TIFF object."""
67
+ return self._geotiff._tiff
68
+
69
+ @property
70
+ def crs(self) -> CRS:
71
+ """The coordinate reference system of the overview."""
72
+ return self._geotiff.crs
73
+
58
74
  @property
59
75
  def height(self) -> int:
60
76
  """The height of the overview in pixels."""
61
- return self._ifd[1].image_height
77
+ return self._ifd.ifd.image_height
62
78
 
63
- @cached_property
64
- def transform(self) -> Affine:
79
+ @property
80
+ def tile_height(self) -> int:
81
+ """The height in pixels per tile of the overview."""
82
+ return self._ifd.ifd.tile_height or self.height
83
+
84
+ @property
85
+ def tile_width(self) -> int:
86
+ """The width in pixels per tile of the overview."""
87
+ return self._ifd.ifd.tile_width or self.width
88
+
89
+ @property
90
+ def transform(self) -> Affine: # type: ignore[override]
65
91
  """The affine transform mapping pixel coordinates to geographic coordinates.
66
92
 
67
93
  Returns:
@@ -70,9 +96,9 @@ class Overview:
70
96
  """
71
97
  full_transform = self._geotiff.transform
72
98
 
73
- overview_width = self._ifd[1].image_width
99
+ overview_width = self._ifd.ifd.image_width
74
100
  full_width = self._geotiff.width
75
- overview_height = self._ifd[1].image_height
101
+ overview_height = self._ifd.ifd.image_height
76
102
  full_height = self._geotiff.height
77
103
 
78
104
  scale_x = full_width / overview_width
@@ -83,4 +109,4 @@ class Overview:
83
109
  @property
84
110
  def width(self) -> int:
85
111
  """The width of the overview in pixels."""
86
- return self._ifd[1].image_width
112
+ return self._ifd.ifd.image_width
@@ -0,0 +1,90 @@
1
+ """Mixin class for coordinate transformation methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from math import floor
6
+ from typing import TYPE_CHECKING, Literal, Protocol
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Callable
10
+
11
+ from affine import Affine
12
+
13
+
14
+ class HasTransform(Protocol):
15
+ """Protocol for objects that have an affine transform."""
16
+
17
+ @property
18
+ def transform(self) -> Affine: ...
19
+
20
+
21
+ class TransformMixin:
22
+ """Mixin providing coordinate transformation methods.
23
+
24
+ Classes using this mixin must implement HasTransform.
25
+ """
26
+
27
+ def index(
28
+ self: HasTransform,
29
+ x: float,
30
+ y: float,
31
+ op: Callable[[float], int] = floor,
32
+ ) -> tuple[int, int]:
33
+ """Get the (row, col) index of the pixel containing (x, y).
34
+
35
+ Args:
36
+ x: x value in coordinate reference system.
37
+ y: y value in coordinate reference system.
38
+ op: Function to convert fractional pixels to whole numbers
39
+ (floor, ceiling, round). Defaults to math.floor.
40
+
41
+ Returns:
42
+ (row index, col index)
43
+
44
+ """
45
+ inv_transform = ~self.transform
46
+ # Affine * (x, y) returns tuple[float, float] for 2D coordinates
47
+ col_frac, row_frac = inv_transform * (x, y) # type: ignore[misc]
48
+
49
+ return (op(row_frac), op(col_frac))
50
+
51
+ def xy(
52
+ self: HasTransform,
53
+ row: int,
54
+ col: int,
55
+ offset: Literal["center", "ul", "ur", "ll", "lr"] = "center",
56
+ ) -> tuple[float, float]:
57
+ """Get the coordinates (x, y) of a pixel at (row, col).
58
+
59
+ The pixel's center is returned by default, but a corner can be returned
60
+ by setting `offset` to one of `"ul"`, `"ur"`, `"ll"`, `"lr"`.
61
+
62
+ Args:
63
+ row: Pixel row.
64
+ col: Pixel column.
65
+ offset: Determines if the returned coordinates are for the center of the
66
+ pixel or for a corner.
67
+
68
+ Returns:
69
+ (x, y) coordinates in the dataset's CRS.
70
+
71
+ """
72
+ if offset == "center":
73
+ c = col + 0.5
74
+ r = row + 0.5
75
+ elif offset == "ul":
76
+ c = col
77
+ r = row
78
+ elif offset == "ur":
79
+ c = col + 1
80
+ r = row
81
+ elif offset == "ll":
82
+ c = col
83
+ r = row + 1
84
+ elif offset == "lr":
85
+ c = col + 1
86
+ r = row + 1
87
+ else:
88
+ raise ValueError(f"Invalid offset value: {offset}")
89
+
90
+ return self.transform * (c, r)
@@ -47,8 +47,8 @@ def generate_tms(
47
47
  bounds = geotiff.bounds
48
48
  crs = geotiff.crs
49
49
  tr = geotiff.transform
50
- blockxsize = geotiff._primary_ifd.tile_width # noqa: SLF001
51
- blockysize = geotiff._primary_ifd.tile_height # noqa: SLF001
50
+ blockxsize = geotiff._primary_ifd.ifd.tile_width # noqa: SLF001
51
+ blockysize = geotiff._primary_ifd.ifd.tile_height # noqa: SLF001
52
52
 
53
53
  if blockxsize is None or blockysize is None:
54
54
  raise ValueError("GeoTIFF must be tiled to generate a TMS.")
@@ -63,8 +63,8 @@ def generate_tms(
63
63
 
64
64
  for idx, overview in enumerate(reversed(geotiff.overviews)):
65
65
  overview_tr = overview.transform
66
- blockxsize = overview._ifd[1].tile_width # noqa: SLF001
67
- blockysize = overview._ifd[1].tile_height # noqa: SLF001
66
+ blockxsize = overview._ifd.ifd.tile_width # noqa: SLF001
67
+ blockysize = overview._ifd.ifd.tile_height # noqa: SLF001
68
68
 
69
69
  if blockxsize is None or blockysize is None:
70
70
  raise ValueError("GeoTIFF overviews must be tiled to generate a TMS.")
File without changes