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.
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/PKG-INFO +4 -4
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/README.md +2 -2
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/pyproject.toml +13 -2
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/src/async_geotiff/__init__.py +2 -1
- async_geotiff-0.1.0b4/src/async_geotiff/_array.py +102 -0
- async_geotiff-0.1.0b4/src/async_geotiff/_fetch.py +138 -0
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/src/async_geotiff/_geotiff.py +84 -109
- async_geotiff-0.1.0b4/src/async_geotiff/_ifd.py +18 -0
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/src/async_geotiff/_overview.py +39 -13
- async_geotiff-0.1.0b4/src/async_geotiff/_transform.py +90 -0
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/src/async_geotiff/tms.py +4 -4
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/LICENSE +0 -0
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/src/async_geotiff/_crs.py +0 -0
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/src/async_geotiff/_version.py +0 -0
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/src/async_geotiff/enums.py +0 -0
- {async_geotiff-0.1.0b3 → async_geotiff-0.1.0b4}/src/async_geotiff/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: async-geotiff
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
+
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.
|
|
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,
|
|
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.
|
|
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:
|
|
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
|
-
#
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
208
|
-
def crs(self) ->
|
|
229
|
+
@property
|
|
230
|
+
def crs(self) -> CRS:
|
|
209
231
|
"""The dataset's coordinate reference system."""
|
|
210
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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.
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
46
|
-
mask_ifd:
|
|
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
|
|
77
|
+
return self._ifd.ifd.image_height
|
|
62
78
|
|
|
63
|
-
@
|
|
64
|
-
def
|
|
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
|
|
99
|
+
overview_width = self._ifd.ifd.image_width
|
|
74
100
|
full_width = self._geotiff.width
|
|
75
|
-
overview_height = self._ifd
|
|
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
|
|
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
|
|
67
|
-
blockysize = overview._ifd
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|