async-geotiff 0.1.0b3__py3-none-any.whl → 0.1.0b5__py3-none-any.whl

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.
async_geotiff/__init__.py CHANGED
@@ -3,8 +3,20 @@
3
3
  [cogeo]: https://cogeo.org/
4
4
  """
5
5
 
6
+ from . import exceptions
7
+ from ._array import Array
6
8
  from ._geotiff import GeoTIFF
7
9
  from ._overview import Overview
10
+ from ._tile import Tile
8
11
  from ._version import __version__
12
+ from ._windows import Window
9
13
 
10
- __all__ = ["GeoTIFF", "Overview", "__version__"]
14
+ __all__ = [
15
+ "Array",
16
+ "GeoTIFF",
17
+ "Overview",
18
+ "Tile",
19
+ "Window",
20
+ "__version__",
21
+ "exceptions",
22
+ ]
@@ -0,0 +1,121 @@
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
+ from numpy.ma import MaskedArray
9
+
10
+ from async_geotiff._transform import TransformMixin
11
+
12
+ if TYPE_CHECKING:
13
+ from affine import Affine
14
+ from async_tiff import Array as AsyncTiffArray
15
+ from numpy.typing import NDArray
16
+ from pyproj.crs import CRS
17
+
18
+
19
+ @dataclass(frozen=True, kw_only=True, eq=False)
20
+ class Array(TransformMixin):
21
+ """An array representation of data from a GeoTIFF."""
22
+
23
+ data: NDArray
24
+ """The array data with shape (bands, height, width)."""
25
+
26
+ mask: NDArray[np.bool_] | None
27
+ """The mask array with shape (height, width), if any.
28
+
29
+ Values of True indicate valid data; False indicates no data.
30
+ """
31
+
32
+ width: int
33
+ """The width of the array in pixels."""
34
+
35
+ height: int
36
+ """The height of the array in pixels."""
37
+
38
+ count: int
39
+ """The number of bands in the array."""
40
+
41
+ transform: Affine
42
+ """The affine transform mapping pixel coordinates to geographic coordinates."""
43
+
44
+ crs: CRS
45
+ """The coordinate reference system of the array."""
46
+
47
+ nodata: float | None = None
48
+ """The nodata value for the array, if any."""
49
+
50
+ @classmethod
51
+ def _create( # noqa: PLR0913
52
+ cls,
53
+ *,
54
+ data: AsyncTiffArray,
55
+ mask: AsyncTiffArray | None,
56
+ planar_configuration: PlanarConfiguration,
57
+ transform: Affine,
58
+ crs: CRS,
59
+ nodata: float | None = None,
60
+ ) -> Self:
61
+ """Create an Array from async_tiff data.
62
+
63
+ Handles axis reordering to ensure data is always in (bands, height, width)
64
+ order, matching rasterio's convention.
65
+ """
66
+ data_arr = np.asarray(data, copy=False)
67
+ if mask is not None:
68
+ mask_arr = np.asarray(mask, copy=False).astype(np.bool_, copy=False)
69
+ assert mask_arr.ndim == 3 # noqa: S101, PLR2004
70
+ assert mask_arr.shape[2] == 1 # noqa: S101
71
+ # This assumes it's always (height, width, 1)
72
+ mask_arr = np.squeeze(mask_arr, axis=2)
73
+ else:
74
+ mask_arr = None
75
+
76
+ assert data_arr.ndim == 3, f"Expected 3D array, got {data_arr.ndim}D" # noqa: S101, PLR2004
77
+
78
+ # async_tiff returns data in the native TIFF order:
79
+ # - Chunky (pixel interleaved): (height, width, bands)
80
+ # - Planar (band interleaved): (bands, height, width)
81
+ # We always want (bands, height, width) to match rasterio.
82
+ if planar_configuration == PlanarConfiguration.Chunky:
83
+ # Transpose from (H, W, C) to (C, H, W)
84
+ data_arr = np.moveaxis(data_arr, -1, 0)
85
+
86
+ count, height, width = data_arr.shape
87
+
88
+ return cls(
89
+ data=data_arr,
90
+ mask=mask_arr,
91
+ width=width,
92
+ height=height,
93
+ count=count,
94
+ transform=transform,
95
+ crs=crs,
96
+ nodata=nodata,
97
+ )
98
+
99
+ def as_masked(self) -> MaskedArray:
100
+ """Return the data as a masked array using the Array mask or nodata value.
101
+
102
+ !!! warning
103
+ In a numpy [`MaskedArray`][numpy.ma.MaskedArray], `True`
104
+ indicates invalid (masked) data and `False` indicates valid data.
105
+
106
+ This is the inverse convention of a GeoTIFF's mask. The boolean array
107
+ [`Array.mask`][async_geotiff.Array.mask] uses `True` for valid data and
108
+ `False` for invalid data.
109
+
110
+ Returns:
111
+ A masked array with the same shape as `data`, where invalid data
112
+ (as indicated by the mask) is masked out.
113
+
114
+ """
115
+ if self.mask is not None:
116
+ return MaskedArray(self.data, mask=~self.mask)
117
+
118
+ if self.nodata is not None:
119
+ return np.ma.masked_equal(self.data, self.nodata)
120
+
121
+ return MaskedArray(self.data)
@@ -0,0 +1,154 @@
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._array import Array
9
+ from async_geotiff._tile import Tile
10
+ from async_geotiff._transform import HasTransform
11
+
12
+ if TYPE_CHECKING:
13
+ from async_tiff import TIFF, ImageFileDirectory
14
+ from async_tiff import Array as AsyncTiffArray
15
+ from pyproj import CRS
16
+
17
+
18
+ class HasTiffReference(HasTransform, Protocol):
19
+ """Protocol for objects that hold a TIFF reference and can request tiles."""
20
+
21
+ @property
22
+ def _ifd(self) -> ImageFileDirectory:
23
+ """The data IFD for this image (index, IFD)."""
24
+ ...
25
+
26
+ @property
27
+ def _mask_ifd(self) -> ImageFileDirectory | None:
28
+ """The mask IFD for this image (index, IFD), if any."""
29
+ ...
30
+
31
+ @property
32
+ def _tiff(self) -> TIFF:
33
+ """A reference to the underlying TIFF object."""
34
+ ...
35
+
36
+ @property
37
+ def crs(self) -> CRS:
38
+ """The coordinate reference system."""
39
+ ...
40
+
41
+ @property
42
+ def tile_height(self) -> int:
43
+ """The height of tiles in pixels."""
44
+ ...
45
+
46
+ @property
47
+ def tile_width(self) -> int:
48
+ """The width of tiles in pixels."""
49
+ ...
50
+
51
+ @property
52
+ def nodata(self) -> int | float | None:
53
+ """The nodata value for the image, if any."""
54
+ ...
55
+
56
+
57
+ class FetchTileMixin:
58
+ """Mixin for fetching tiles from a GeoTIFF.
59
+
60
+ Classes using this mixin must implement HasTiffReference.
61
+ """
62
+
63
+ async def fetch_tile(
64
+ self: HasTiffReference,
65
+ x: int,
66
+ y: int,
67
+ ) -> Tile:
68
+ tile_fut = self._ifd.fetch_tile(x, y)
69
+
70
+ mask_data: AsyncTiffArray | None = None
71
+ if self._mask_ifd is not None:
72
+ mask_fut = self._mask_ifd.fetch_tile(x, y)
73
+ tile, mask = await asyncio.gather(tile_fut, mask_fut)
74
+ tile_data, mask_data = await asyncio.gather(tile.decode(), mask.decode())
75
+ else:
76
+ tile = await tile_fut
77
+ tile_data = await tile.decode()
78
+
79
+ tile_transform = self.transform * Affine.translation(
80
+ x * self.tile_width,
81
+ y * self.tile_height,
82
+ )
83
+
84
+ array = Array._create( # noqa: SLF001
85
+ data=tile_data,
86
+ mask=mask_data,
87
+ planar_configuration=self._ifd.planar_configuration,
88
+ crs=self.crs,
89
+ transform=tile_transform,
90
+ nodata=self.nodata,
91
+ )
92
+ return Tile(
93
+ x=x,
94
+ y=y,
95
+ _ifd=self._ifd,
96
+ array=array,
97
+ )
98
+
99
+ async def fetch_tiles(
100
+ self: HasTiffReference,
101
+ xs: list[int],
102
+ ys: list[int],
103
+ ) -> list[Tile]:
104
+ """Fetch multiple tiles from this overview.
105
+
106
+ Args:
107
+ xs: The x coordinates of the tiles.
108
+ ys: The y coordinates of the tiles.
109
+
110
+ """
111
+ tiles_fut = self._ifd.fetch_tiles(xs, ys)
112
+
113
+ decoded_masks: list[AsyncTiffArray | None] = [None] * len(xs)
114
+ if self._mask_ifd is not None:
115
+ masks_fut = self._mask_ifd.fetch_tiles(xs, ys)
116
+ tiles, masks = await asyncio.gather(tiles_fut, masks_fut)
117
+
118
+ decoded_tile_futs = [tile.decode() for tile in tiles]
119
+ decoded_mask_futs = [mask.decode() for mask in masks]
120
+ decoded_tiles = await asyncio.gather(*decoded_tile_futs)
121
+ decoded_masks = await asyncio.gather(*decoded_mask_futs)
122
+ else:
123
+ tiles = await tiles_fut
124
+ decoded_tiles = await asyncio.gather(*[tile.decode() for tile in tiles])
125
+
126
+ final_tiles: list[Tile] = []
127
+ for x, y, tile_data, mask_data in zip(
128
+ xs,
129
+ ys,
130
+ decoded_tiles,
131
+ decoded_masks,
132
+ strict=True,
133
+ ):
134
+ tile_transform = self.transform * Affine.translation(
135
+ x * self.tile_width,
136
+ y * self.tile_height,
137
+ )
138
+ array = Array._create( # noqa: SLF001
139
+ data=tile_data,
140
+ mask=mask_data,
141
+ planar_configuration=self._ifd.planar_configuration,
142
+ crs=self.crs,
143
+ transform=tile_transform,
144
+ nodata=self.nodata,
145
+ )
146
+ tile = Tile(
147
+ x=x,
148
+ y=y,
149
+ _ifd=self._ifd,
150
+ array=array,
151
+ )
152
+ final_tiles.append(tile)
153
+
154
+ return final_tiles
async_geotiff/_geotiff.py CHANGED
@@ -2,28 +2,38 @@ 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
12
13
  from async_geotiff._overview import Overview
13
- from async_geotiff.enums import Compression, Interleaving
14
+ from async_geotiff._read import ReadMixin
15
+ from async_geotiff._transform import TransformMixin
16
+ from async_geotiff.colormap import Colormap
14
17
 
15
18
  if TYPE_CHECKING:
16
- from collections.abc import Callable
17
-
18
- import pyproj
19
19
  from async_tiff import GeoKeyDirectory, ImageFileDirectory, ObspecInput
20
20
  from async_tiff.store import ObjectStore # type: ignore # noqa: PGH003
21
+ from pyproj.crs import CRS
22
+
23
+ from async_geotiff.enums import Compression, Interleaving
21
24
 
22
25
 
23
26
  @dataclass(frozen=True, init=False, kw_only=True, repr=False)
24
- class GeoTIFF:
27
+ class GeoTIFF(ReadMixin, FetchTileMixin, TransformMixin):
25
28
  """A class representing a GeoTIFF image."""
26
29
 
30
+ _crs: CRS | None = None
31
+ """A cached CRS instance.
32
+
33
+ We don't use functools.cached_property on the `crs` attribute because of typing
34
+ issues.
35
+ """
36
+
27
37
  _tiff: TIFF
28
38
  """The underlying async-tiff TIFF instance that we wrap.
29
39
  """
@@ -34,6 +44,10 @@ class GeoTIFF:
34
44
  Some tags, like most geo tags, only exist on the primary IFD.
35
45
  """
36
46
 
47
+ _mask_ifd: ImageFileDirectory | None = None
48
+ """The mask IFD of the full-resolution GeoTIFF, if any.
49
+ """
50
+
37
51
  _gkd: GeoKeyDirectory = field(init=False)
38
52
  """The GeoKeyDirectory of the primary IFD.
39
53
  """
@@ -42,6 +56,11 @@ class GeoTIFF:
42
56
  """A list of overviews for the GeoTIFF.
43
57
  """
44
58
 
59
+ @property
60
+ def _ifd(self) -> ImageFileDirectory:
61
+ """An alias for the primary IFD to satisfy _fetch protocol."""
62
+ return self._primary_ifd
63
+
45
64
  def __init__(self, tiff: TIFF) -> None:
46
65
  """Create a GeoTIFF from an existing TIFF instance."""
47
66
  first_ifd = tiff.ifds[0]
@@ -59,29 +78,32 @@ class GeoTIFF:
59
78
  object.__setattr__(self, "_primary_ifd", first_ifd)
60
79
  object.__setattr__(self, "_gkd", gkd)
61
80
 
62
- # Skip the first IFD, since it's the primary image
63
- ifd_idx = 1
81
+ # Separate data IFDs and mask IFDs (skip the primary IFD at index 0)
82
+ # Data IFDs are indexed by (width, height) for matching with masks
83
+ data_ifds: dict[tuple[int, int], ImageFileDirectory] = {}
84
+ mask_ifds: dict[tuple[int, int], ImageFileDirectory] = {}
85
+
86
+ for ifd in tiff.ifds[1:]:
87
+ dims = (ifd.image_width, ifd.image_height)
88
+ if is_mask_ifd(ifd):
89
+ mask_ifds[dims] = ifd
90
+ else:
91
+ data_ifds[dims] = ifd
92
+
93
+ # Find and set the mask for the primary IFD (matches primary dimensions)
94
+ if primary_mask_ifd := mask_ifds.get(
95
+ (first_ifd.image_width, first_ifd.image_height),
96
+ ):
97
+ object.__setattr__(self, "_mask_ifd", primary_mask_ifd)
98
+
99
+ # Build overviews, sorted by resolution (highest to lowest, i.e., largest first)
100
+ # Sort by width * height descending
101
+ sorted_dims = sorted(data_ifds.keys(), key=lambda d: d[0] * d[1], reverse=True)
102
+
64
103
  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
104
+ for dims in sorted_dims:
105
+ data_ifd = data_ifds[dims]
106
+ mask_ifd = mask_ifds.get(dims)
85
107
 
86
108
  ovr = Overview._create( # noqa: SLF001
87
109
  geotiff=self,
@@ -169,24 +191,18 @@ class GeoTIFF:
169
191
  # https://github.com/developmentseed/async-geotiff/issues/12
170
192
  raise NotImplementedError
171
193
 
172
- def colormap(self, bidx: int) -> dict[int, tuple[int, int, int]]:
173
- """Return a dict containing the colormap for a band.
174
-
175
- Args:
176
- bidx: The 1-based index of the band whose colormap will be returned.
194
+ @property
195
+ def colormap(self) -> Colormap | None:
196
+ """Return the Colormap stored in the file, if any.
177
197
 
178
198
  Returns:
179
- Mapping of color index value (starting at 0) to RGBA color as a
180
- 4-element tuple.
181
-
182
- 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.
199
+ A Colormap instance if the dataset has a colormap, else None.
187
200
 
188
201
  """
189
- raise NotImplementedError
202
+ if upstream_colormap := self._primary_ifd.colormap:
203
+ return Colormap(_cmap=upstream_colormap, _nodata=self.nodata)
204
+
205
+ return None
190
206
 
191
207
  @property
192
208
  def compression(self) -> Compression:
@@ -204,10 +220,15 @@ class GeoTIFF:
204
220
  """The number of raster bands in the full image."""
205
221
  raise NotImplementedError
206
222
 
207
- @cached_property
208
- def crs(self) -> pyproj.CRS:
223
+ @property
224
+ def crs(self) -> CRS:
209
225
  """The dataset's coordinate reference system."""
210
- return crs_from_geo_keys(self._gkd)
226
+ if self._crs is not None:
227
+ return self._crs
228
+
229
+ crs = crs_from_geo_keys(self._gkd)
230
+ object.__setattr__(self, "_crs", crs)
231
+ return crs
211
232
 
212
233
  @property
213
234
  def dtypes(self) -> list[str]:
@@ -222,27 +243,6 @@ class GeoTIFF:
222
243
  """The height (number of rows) of the full image."""
223
244
  return self._primary_ifd.image_height
224
245
 
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
245
-
246
246
  def indexes(self) -> list[int]:
247
247
  """Return the 1-based indexes of each band in the dataset.
248
248
 
@@ -273,7 +273,14 @@ class GeoTIFF:
273
273
 
274
274
  @property
275
275
  def overviews(self) -> list[Overview]:
276
- """A list of overview levels for the dataset."""
276
+ """A list of overview levels for the dataset.
277
+
278
+ Overviews are reduced-resolution versions of the main image used for faster
279
+ rendering at lower zoom levels.
280
+
281
+ This list of overviews is ordered from finest to coarsest resolution. The first
282
+ element of the list is the highest-resolution after the base image.
283
+ """
277
284
  return self._overviews
278
285
 
279
286
  @property
@@ -283,7 +290,7 @@ class GeoTIFF:
283
290
  # https://rasterio.readthedocs.io/en/stable/api/rasterio.enums.html#rasterio.enums.PhotometricInterp
284
291
  raise NotImplementedError
285
292
 
286
- @cached_property
293
+ @property
287
294
  def res(self) -> tuple[float, float]:
288
295
  """Return the (width, height) of pixels in the units of its CRS."""
289
296
  transform = self.transform
@@ -294,7 +301,17 @@ class GeoTIFF:
294
301
  """Get the shape (height, width) of the full image."""
295
302
  return (self.height, self.width)
296
303
 
297
- @cached_property
304
+ @property
305
+ def tile_height(self) -> int:
306
+ """The height in pixels per tile of the image."""
307
+ return self._primary_ifd.tile_height or self.height
308
+
309
+ @property
310
+ def tile_width(self) -> int:
311
+ """The width in pixels per tile of the image."""
312
+ return self._primary_ifd.tile_width or self.width
313
+
314
+ @property
298
315
  def transform(self) -> Affine:
299
316
  """Return the dataset's georeferencing transformation matrix.
300
317
 
@@ -344,46 +361,6 @@ class GeoTIFF:
344
361
  """The width (number of columns) of the full image."""
345
362
  return self._primary_ifd.image_width
346
363
 
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)
386
-
387
364
 
388
365
  def has_geokeys(ifd: ImageFileDirectory) -> bool:
389
366
  """Check if an IFD has GeoTIFF keys.
@@ -398,8 +375,7 @@ def has_geokeys(ifd: ImageFileDirectory) -> bool:
398
375
  def is_mask_ifd(ifd: ImageFileDirectory) -> bool:
399
376
  """Check if an IFD is a mask IFD."""
400
377
  return (
401
- ifd.compression == Compression.deflate
402
- and ifd.new_subfile_type is not None
378
+ ifd.new_subfile_type is not None
403
379
  and ifd.new_subfile_type & 4 != 0
404
380
  and ifd.photometric_interpretation == PhotometricInterpretation.TransparencyMask
405
381
  )