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.
@@ -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._read import ReadMixin
10
+ from async_geotiff._transform import TransformMixin
11
+
9
12
  if TYPE_CHECKING:
10
- from async_tiff import GeoKeyDirectory, ImageFileDirectory
13
+ from async_tiff import TIFF, GeoKeyDirectory, ImageFileDirectory
14
+ from pyproj.crs import CRS
11
15
 
12
16
  from async_geotiff import GeoTIFF
13
17
 
18
+ # ruff: noqa: SLF001
19
+
14
20
 
15
21
  @dataclass(init=False, frozen=True, kw_only=True, eq=False, repr=False)
16
- class Overview:
22
+ class Overview(ReadMixin, 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: ImageFileDirectory
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: ImageFileDirectory | 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: ImageFileDirectory,
52
+ mask_ifd: ImageFileDirectory | None,
47
53
  ) -> Overview:
48
54
  instance = cls.__new__(cls)
49
55
 
@@ -55,13 +61,38 @@ 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.image_height
62
78
 
63
- @cached_property
64
- def transform(self) -> Affine:
79
+ @property
80
+ def nodata(self) -> int | float | None:
81
+ """The nodata value for the overview, if any."""
82
+ return self._geotiff.nodata
83
+
84
+ @property
85
+ def tile_height(self) -> int:
86
+ """The height in pixels per tile of the overview."""
87
+ return self._ifd.tile_height or self.height
88
+
89
+ @property
90
+ def tile_width(self) -> int:
91
+ """The width in pixels per tile of the overview."""
92
+ return self._ifd.tile_width or self.width
93
+
94
+ @property
95
+ def transform(self) -> Affine: # type: ignore[override]
65
96
  """The affine transform mapping pixel coordinates to geographic coordinates.
66
97
 
67
98
  Returns:
@@ -70,9 +101,9 @@ class Overview:
70
101
  """
71
102
  full_transform = self._geotiff.transform
72
103
 
73
- overview_width = self._ifd[1].image_width
104
+ overview_width = self._ifd.image_width
74
105
  full_width = self._geotiff.width
75
- overview_height = self._ifd[1].image_height
106
+ overview_height = self._ifd.image_height
76
107
  full_height = self._geotiff.height
77
108
 
78
109
  scale_x = full_width / overview_width
@@ -83,4 +114,4 @@ class Overview:
83
114
  @property
84
115
  def width(self) -> int:
85
116
  """The width of the overview in pixels."""
86
- return self._ifd[1].image_width
117
+ return self._ifd.image_width
async_geotiff/_read.py ADDED
@@ -0,0 +1,185 @@
1
+ """Higher-level read utilities for cross-tile operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol
6
+
7
+ import numpy as np
8
+ from affine import Affine
9
+
10
+ from async_geotiff._array import Array
11
+ from async_geotiff._fetch import HasTiffReference
12
+ from async_geotiff._windows import Window
13
+ from async_geotiff.exceptions import WindowError
14
+
15
+ if TYPE_CHECKING:
16
+ from numpy.typing import NDArray
17
+
18
+ from async_geotiff._tile import Tile
19
+
20
+
21
+ class CanFetchTiles(HasTiffReference, Protocol):
22
+ """Protocol for objects that can fetch tiles."""
23
+
24
+ @property
25
+ def height(self) -> int:
26
+ """The height of the image in pixels."""
27
+ ...
28
+
29
+ @property
30
+ def width(self) -> int:
31
+ """The width of the image in pixels."""
32
+ ...
33
+
34
+ async def fetch_tiles(
35
+ self,
36
+ xs: list[int],
37
+ ys: list[int],
38
+ ) -> list[Tile]: ...
39
+
40
+
41
+ class ReadMixin:
42
+ async def read(
43
+ self: CanFetchTiles,
44
+ *,
45
+ window: Window | None = None,
46
+ ) -> Array:
47
+ """Read pixel data for a window region.
48
+
49
+ This method fetches all tiles that intersect the given window and
50
+ stitches them together, returning only the pixels within the window.
51
+
52
+ Args:
53
+ window: A Window object defining the pixel region to read.
54
+ If None, the entire image is read.
55
+
56
+ Returns:
57
+ An Array containing the pixel data for the requested window.
58
+
59
+ Raises:
60
+ WindowError: If the window extends outside the image bounds.
61
+
62
+ """
63
+ return await read(self, window=window)
64
+
65
+
66
+ async def read(
67
+ self: CanFetchTiles,
68
+ *,
69
+ window: Window | None = None,
70
+ ) -> Array:
71
+ if isinstance(window, Window):
72
+ win = window
73
+ else:
74
+ win = Window(col_off=0, row_off=0, width=self.width, height=self.height)
75
+
76
+ # Most validation occurred in construction of Window; here we just check against
77
+ # image size
78
+ if win.col_off + win.width > self.width or win.row_off + win.height > self.height:
79
+ raise WindowError(
80
+ f"Window extends outside image bounds.\n"
81
+ f"Window: cols={win.col_off}:{win.col_off + win.width}, "
82
+ f"rows={win.row_off}:{win.row_off + win.height}.\n"
83
+ f"Image size: {self.height}x{self.width}",
84
+ )
85
+
86
+ # Calculate which tiles we need to fetch
87
+ tile_x_start = win.col_off // self.tile_width
88
+ tile_x_stop = (win.col_off + win.width - 1) // self.tile_width + 1
89
+ tile_y_start = win.row_off // self.tile_height
90
+ tile_y_stop = (win.row_off + win.height - 1) // self.tile_height + 1
91
+
92
+ # Build list of tile coordinates
93
+ xs: list[int] = []
94
+ ys: list[int] = []
95
+ for tx in range(tile_x_start, tile_x_stop):
96
+ for ty in range(tile_y_start, tile_y_stop):
97
+ xs.append(tx)
98
+ ys.append(ty)
99
+
100
+ tiles = await self.fetch_tiles(xs, ys)
101
+
102
+ num_bands = tiles[0].array.count
103
+ dtype = tiles[0].array.data.dtype
104
+
105
+ # Create output array and mask array
106
+ output_data = np.empty((num_bands, win.height, win.width), dtype=dtype)
107
+ output_mask: NDArray[np.bool_] | None = None
108
+ if self._mask_ifd is not None:
109
+ output_mask = np.ones((win.height, win.width), dtype=np.bool_)
110
+
111
+ assemble_tiles(
112
+ tiles=tiles,
113
+ window=win,
114
+ tile_width=self.tile_width,
115
+ tile_height=self.tile_height,
116
+ output_data=output_data,
117
+ output_mask=output_mask,
118
+ )
119
+
120
+ window_transform = self.transform * Affine.translation(
121
+ win.col_off,
122
+ win.row_off,
123
+ )
124
+
125
+ return Array(
126
+ data=output_data,
127
+ mask=output_mask,
128
+ width=win.width,
129
+ height=win.height,
130
+ count=num_bands,
131
+ transform=window_transform,
132
+ crs=self.crs,
133
+ )
134
+
135
+
136
+ def assemble_tiles( # noqa: PLR0913
137
+ *,
138
+ tiles: list[Tile],
139
+ window: Window,
140
+ tile_width: int,
141
+ tile_height: int,
142
+ output_data: NDArray[np.generic],
143
+ output_mask: NDArray[np.bool_] | None,
144
+ ) -> None:
145
+ """Assemble multiple tiles into output arrays.
146
+
147
+ This function copies data from tiles into the appropriate positions
148
+ in the output arrays, handling partial tiles at window boundaries.
149
+ """
150
+ for tile in tiles:
151
+ # Create a window for this tile's position in image coordinates
152
+ tile_window = Window(
153
+ col_off=tile.x * tile_width,
154
+ row_off=tile.y * tile_height,
155
+ width=tile.array.width,
156
+ height=tile.array.height,
157
+ )
158
+
159
+ # Calculate the intersection between tile and target window
160
+ overlap = window.intersection(tile_window)
161
+
162
+ # Calculate source slice within the tile
163
+ src_col_start = overlap.col_off - tile_window.col_off
164
+ src_col_stop = src_col_start + overlap.width
165
+ src_row_start = overlap.row_off - tile_window.row_off
166
+ src_row_stop = src_row_start + overlap.height
167
+
168
+ # Calculate destination slice within the output
169
+ dst_col_start = overlap.col_off - window.col_off
170
+ dst_col_stop = dst_col_start + overlap.width
171
+ dst_row_start = overlap.row_off - window.row_off
172
+ dst_row_stop = dst_row_start + overlap.height
173
+
174
+ # Copy data and mask if present
175
+ output_data[
176
+ :,
177
+ dst_row_start:dst_row_stop,
178
+ dst_col_start:dst_col_stop,
179
+ ] = tile.array.data[:, src_row_start:src_row_stop, src_col_start:src_col_stop]
180
+
181
+ if output_mask is not None and tile.array.mask is not None:
182
+ output_mask[
183
+ dst_row_start:dst_row_stop,
184
+ dst_col_start:dst_col_stop,
185
+ ] = tile.array.mask[src_row_start:src_row_stop, src_col_start:src_col_stop]
async_geotiff/_tile.py ADDED
@@ -0,0 +1,26 @@
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
+ from async_geotiff._array import Array
10
+
11
+
12
+ @dataclass(frozen=True, kw_only=True, eq=False)
13
+ class Tile:
14
+ """A tile from a GeoTIFF, containing array data and grid position."""
15
+
16
+ x: int
17
+ """The tile column index in the GeoTIFF or overview."""
18
+
19
+ y: int
20
+ """The tile row index in the GeoTIFF or overview."""
21
+
22
+ _ifd: ImageFileDirectory
23
+ """A reference to the IFD this tile belongs to."""
24
+
25
+ array: Array
26
+ """The array data for this tile."""
@@ -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)
@@ -0,0 +1,76 @@
1
+ """Window utilities for defining rectangular subsets of rasters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from async_geotiff.exceptions import WindowError
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Window:
12
+ """A rectangular subset of a raster.
13
+
14
+ Windows define pixel regions using column/row offsets and dimensions.
15
+ This class is similar to rasterio's [Window][rasterio.windows.Window] but supports
16
+ integer offsets and ranges only.
17
+ """
18
+
19
+ col_off: int
20
+ """The column offset (x position of the left edge)."""
21
+
22
+ row_off: int
23
+ """The row offset (y position of the top edge)."""
24
+
25
+ width: int
26
+ """The width in pixels (number of columns)."""
27
+
28
+ height: int
29
+ """The height in pixels (number of rows)."""
30
+
31
+ def __post_init__(self) -> None:
32
+ """Validate window dimensions."""
33
+ if self.col_off < 0 or self.row_off < 0:
34
+ raise WindowError(
35
+ f"Window start indices must be non-negative, "
36
+ f"got col_off={self.col_off}, row_off={self.row_off}",
37
+ )
38
+
39
+ if self.width <= 0:
40
+ raise WindowError(f"Window width must be positive, got {self.width}")
41
+
42
+ if self.height <= 0:
43
+ raise WindowError(f"Window height must be positive, got {self.height}")
44
+
45
+ def __repr__(self) -> str:
46
+ """Return a nicely formatted representation string."""
47
+ return (
48
+ f"async_geotiff.Window(col_off={self.col_off}, row_off={self.row_off}, "
49
+ f"width={self.width}, height={self.height})"
50
+ )
51
+
52
+ def intersection(self, other: Window) -> Window:
53
+ """Compute the intersection with another window.
54
+
55
+ Args:
56
+ other: Another Window object.
57
+
58
+ Returns:
59
+ A new Window representing the overlapping region.
60
+
61
+ Raises:
62
+ WindowError: If windows do not intersect.
63
+
64
+ """
65
+ col_off = max(self.col_off, other.col_off)
66
+ row_off = max(self.row_off, other.row_off)
67
+ col_stop = min(self.col_off + self.width, other.col_off + other.width)
68
+ row_stop = min(self.row_off + self.height, other.row_off + other.height)
69
+
70
+ width = col_stop - col_off
71
+ height = row_stop - row_off
72
+
73
+ if width <= 0 or height <= 0:
74
+ raise WindowError(f"Windows do not intersect: {self} and {other}")
75
+
76
+ return Window(col_off=col_off, row_off=row_off, width=width, height=height)
@@ -0,0 +1,104 @@
1
+ """High-level Colormap class for GeoTIFF colormaps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ import numpy as np
9
+
10
+ if TYPE_CHECKING:
11
+ from async_tiff import Colormap as AsyncTiffColormap
12
+ from numpy.typing import NDArray
13
+
14
+
15
+ @dataclass(frozen=True, kw_only=True, eq=False)
16
+ class Colormap:
17
+ """A representation of a GeoTIFF colormap.
18
+
19
+ GeoTIFF colormaps
20
+ """
21
+
22
+ _cmap: AsyncTiffColormap
23
+ """The colormap data held in Rust, accessible via the buffer protocol.
24
+
25
+ Has shape `(N, 3)` and is of data type uint16.
26
+ """
27
+
28
+ _nodata: int | float | None
29
+ """The nodata value from gdal_nodata, if set."""
30
+
31
+ def as_array(self, *, dtype: type[np.uint8 | np.uint16] = np.uint8) -> NDArray:
32
+ """Return the colormap as a NumPy array with shape (N, 3) and dtype uint16.
33
+
34
+ Each row corresponds to a color entry in the colormap, with columns
35
+ representing the Red, Green, and Blue components respectively.
36
+
37
+ This is the most efficient way to access and apply the colormap data.
38
+
39
+ ```py
40
+ geotiff = await GeoTIFF.open(...)
41
+ array = await geotiff.fetch_tile(0, 0)
42
+
43
+ colormap = geotiff.colormap
44
+ colormap_array = colormap.as_array()
45
+
46
+ rgb_data = colormap_array[array.data[0]]
47
+ # A 3D array with shape (height, width, 3)
48
+ ```
49
+
50
+ Returns:
51
+ A NumPy array representation of the colormap.
52
+
53
+ """
54
+ cmap_array = np.asarray(self._cmap)
55
+ if dtype == np.uint8:
56
+ return (cmap_array >> 8).astype(np.uint8)
57
+ if dtype == np.uint16:
58
+ return cmap_array
59
+ raise ValueError("dtype must be either np.uint8 or np.uint16.")
60
+
61
+ def as_dict(
62
+ self,
63
+ *,
64
+ dtype: type[np.uint8 | np.uint16] = np.uint8,
65
+ ) -> dict[int, tuple[int, int, int]]:
66
+ """Return the colormap as a dictionary mapping indices to RGB tuples.
67
+
68
+ Returns:
69
+ A dictionary where keys are indices and values are tuples of
70
+ (Red, Green, Blue) components.
71
+
72
+ """
73
+ cmap_array = self.as_array(dtype=dtype)
74
+ return {
75
+ int(idx): (int(r), int(g), int(b))
76
+ for idx, (r, g, b) in enumerate(cmap_array)
77
+ }
78
+
79
+ def as_rasterio(self) -> dict[int, tuple[int, int, int, int]]:
80
+ """Return the colormap as a mapping to 8-bit RGBA colors.
81
+
82
+ This returns a colormap in the same format as rasterio's
83
+ [`DatasetReader.colormap`][rasterio.io.DatasetReader.colormap] method.
84
+
85
+ This is the same as
86
+ [`Colormap.as_dict`][async_geotiff.colormap.Colormap.as_dict] with:
87
+
88
+ - `dtype` set to `np.uint8`
89
+ - an added alpha channel set to 255, **except** for the nodata value, if
90
+ defined, which has an alpha of 0.
91
+
92
+ Returns:
93
+ Mapping of color index value (starting at 0) to RGBA color as a 4-element
94
+ tuple.
95
+
96
+ """
97
+ cmap_array = self.as_array(dtype=np.uint8)
98
+ cmap_dict: dict[int, tuple[int, int, int, int]] = {}
99
+
100
+ for idx, (r, g, b) in enumerate(cmap_array):
101
+ alpha = 255 if self._nodata is None or idx != self._nodata else 0
102
+ cmap_dict[int(idx)] = (int(r), int(g), int(b), alpha)
103
+
104
+ return cmap_dict
@@ -0,0 +1,5 @@
1
+ """Exceptions for async_geotiff package."""
2
+
3
+
4
+ class WindowError(Exception):
5
+ """Exception raised for window-related errors."""
async_geotiff/tms.py CHANGED
@@ -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.tile_width # noqa: SLF001
67
+ blockysize = overview._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.")