From e3a2d9549ae155d061ff7c15f6779d9dfdf5f75c Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 26 Jan 2026 20:27:48 -0500 Subject: [PATCH 1/6] feat: Sketching overview dataclass --- src/async_geotiff/_overview.py | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/async_geotiff/_overview.py diff --git a/src/async_geotiff/_overview.py b/src/async_geotiff/_overview.py new file mode 100644 index 0000000..8b10987 --- /dev/null +++ b/src/async_geotiff/_overview.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from functools import cached_property +from typing import TYPE_CHECKING, Literal, Self + +from affine import Affine +from async_tiff import TIFF + +from async_geotiff.enums import Compression, Interleaving, PhotometricInterp + +if TYPE_CHECKING: + from async_tiff import GeoKeyDirectory, ImageFileDirectory + + from async_geotiff import GeoTIFF + + +class Overview: + """An overview level of a Cloud-Optimized GeoTIFF image.""" + + _geotiff: GeoTIFF + """A reference to the parent GeoTIFF object. + """ + + _gkd: GeoKeyDirectory + """The GeoKeyDirectory of the primary IFD. + """ + + _ifd: ImageFileDirectory + """The IFD for this overview level. + """ + + _mask_ifd: ImageFileDirectory | None + """The IFD for the mask associated with this overview level, if any. + """ + + _overview_idx: int + """The overview level (0 is the full resolution image, 1 is the first overview, etc). + """ + + def __init__( + self, + geotiff: GeoTIFF, + gkd: GeoKeyDirectory, + ifd: ImageFileDirectory, + mask_ifd: ImageFileDirectory | None, + overview_idx: int, + ) -> None: + self._geotiff = geotiff + self._gkd = gkd + self._ifd = ifd + self._mask_ifd = mask_ifd + self._overview_idx = overview_idx + + @cached_property + def transform(self) -> Affine: + """The affine transform mapping pixel coordinates to geographic coordinates. + + Returns: + Affine: The affine transform. + """ + full_transform = self._geotiff.transform + + overview_width = self._ifd.image_width + full_width = self._geotiff.width + overview_height = self._ifd.image_height + full_height = self._geotiff.height + + scale_x = full_width / overview_width + scale_y = full_height / overview_height + + return full_transform * Affine.scale(scale_x, scale_y) From 9fd24fb3564e270bfdb9f4c08911784083456aa0 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 26 Jan 2026 20:35:19 -0500 Subject: [PATCH 2/6] Add `overviews` accessor to GeoTIFF class --- src/async_geotiff/__init__.py | 3 ++- src/async_geotiff/_geotiff.py | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/async_geotiff/__init__.py b/src/async_geotiff/__init__.py index b2ad35e..2c8e2cb 100644 --- a/src/async_geotiff/__init__.py +++ b/src/async_geotiff/__init__.py @@ -1,4 +1,5 @@ from ._geotiff import GeoTIFF +from ._overview import Overview from ._version import __version__ -__all__ = ["GeoTIFF", "__version__"] +__all__ = ["GeoTIFF", "Overview", "__version__"] diff --git a/src/async_geotiff/_geotiff.py b/src/async_geotiff/_geotiff.py index ec3fbe2..2b27bcb 100644 --- a/src/async_geotiff/_geotiff.py +++ b/src/async_geotiff/_geotiff.py @@ -6,6 +6,7 @@ from affine import Affine from async_tiff import TIFF +from async_geotiff._overview import Overview from async_geotiff.enums import Compression, Interleaving, PhotometricInterp if TYPE_CHECKING: @@ -15,7 +16,7 @@ class GeoTIFF: - """A class representing a GeoTIFF dataset.""" + """A class representing a GeoTIFF image.""" _tiff: TIFF """The underlying async-tiff TIFF instance that we wrap. @@ -31,6 +32,10 @@ class GeoTIFF: """The GeoKeyDirectory of the primary IFD. """ + _overviews: list[Overview] + """A list of overviews for the GeoTIFF. + """ + def __init__(self, tiff: TIFF) -> None: """Create a GeoTIFF from an existing TIFF instance.""" @@ -48,6 +53,9 @@ def __init__(self, tiff: TIFF) -> None: self._primary_ifd = first_ifd self._gkd = gkd + # TODO: populate overviews + self._overviews = [] + @classmethod async def open( cls, @@ -214,6 +222,11 @@ def nodata(self) -> float | None: return float(nodata) + @property + def overviews(self) -> list[Overview]: + """A list of overview levels for the dataset.""" + return self._overviews + @property def photometric(self) -> PhotometricInterp | None: """The photometric interpretation of the dataset.""" From 343e5d42f36405398b38f7474ea7e14ced22e726 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 27 Jan 2026 13:06:52 -0500 Subject: [PATCH 3/6] Create overviews --- src/async_geotiff/_geotiff.py | 59 ++++++++++++++++++++++++++++------ src/async_geotiff/_overview.py | 35 ++++++-------------- 2 files changed, 60 insertions(+), 34 deletions(-) diff --git a/src/async_geotiff/_geotiff.py b/src/async_geotiff/_geotiff.py index 2b27bcb..4e48004 100644 --- a/src/async_geotiff/_geotiff.py +++ b/src/async_geotiff/_geotiff.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass, field from functools import cached_property from typing import TYPE_CHECKING, Literal, Self @@ -15,6 +16,7 @@ from async_tiff.store import ObjectStore +@dataclass(frozen=True, init=False, kw_only=True, slots=True, repr=False) class GeoTIFF: """A class representing a GeoTIFF image.""" @@ -22,17 +24,17 @@ class GeoTIFF: """The underlying async-tiff TIFF instance that we wrap. """ - _primary_ifd: ImageFileDirectory + _primary_ifd: ImageFileDirectory = field(init=False) """The primary (first) IFD of the GeoTIFF. Some tags, like most geo tags, only exist on the primary IFD. """ - _gkd: GeoKeyDirectory + _gkd: GeoKeyDirectory = field(init=False) """The GeoKeyDirectory of the primary IFD. """ - _overviews: list[Overview] + _overviews: list[Overview] = field(init=False) """A list of overviews for the GeoTIFF. """ @@ -49,12 +51,39 @@ def __init__(self, tiff: TIFF) -> None: if len(tiff.ifds) == 0: raise ValueError("TIFF does not contain any IFDs") - self._tiff = tiff - self._primary_ifd = first_ifd - self._gkd = gkd - - # TODO: populate overviews - self._overviews = [] + # We use object.__setattr__ because the dataclass is frozen + object.__setattr__(self, "_tiff", tiff) + object.__setattr__(self, "_primary_ifd", first_ifd) + object.__setattr__(self, "_gkd", gkd) + + # Skip the first IFD, since it's the primary image + ifd_idx = 1 + overviews: list[Overview] = [] + while True: + try: + data_ifd = (ifd_idx, tiff.ifds[ifd_idx]) + except IndexError: + # No more IFDs + break + + ifd_idx += 1 + + mask_ifd = None + next_ifd = None + try: + next_ifd = tiff.ifds[ifd_idx] + except IndexError: + # No more IFDs + pass + finally: + if next_ifd is not None and is_mask_ifd(next_ifd): + mask_ifd = (ifd_idx, next_ifd) + ifd_idx += 1 + + ovr = Overview(_geotiff=self, _gkd=gkd, _ifd=data_ifd, _mask_ifd=mask_ifd) + overviews.append(ovr) + + object.__setattr__(self, "_overviews", overviews) @classmethod async def open( @@ -343,3 +372,15 @@ def has_geokeys(ifd: ImageFileDirectory) -> bool: """ return ifd.geo_key_directory is not None + + +def is_mask_ifd(ifd: ImageFileDirectory) -> bool: + """Check if an IFD is a mask IFD.""" + if ( + ifd.compression == Compression.deflate + and ifd.new_subfile_type + and ifd.photometric_interpretation == 4 + ): + return True + + return False diff --git a/src/async_geotiff/_overview.py b/src/async_geotiff/_overview.py index 8b10987..0189434 100644 --- a/src/async_geotiff/_overview.py +++ b/src/async_geotiff/_overview.py @@ -1,12 +1,10 @@ from __future__ import annotations +from dataclasses import dataclass from functools import cached_property -from typing import TYPE_CHECKING, Literal, Self +from typing import TYPE_CHECKING from affine import Affine -from async_tiff import TIFF - -from async_geotiff.enums import Compression, Interleaving, PhotometricInterp if TYPE_CHECKING: from async_tiff import GeoKeyDirectory, ImageFileDirectory @@ -14,6 +12,7 @@ from async_geotiff import GeoTIFF +@dataclass(frozen=True, kw_only=True, slots=True, eq=False) class Overview: """An overview level of a Cloud-Optimized GeoTIFF image.""" @@ -25,32 +24,18 @@ class Overview: """The GeoKeyDirectory of the primary IFD. """ - _ifd: ImageFileDirectory + _ifd: tuple[int, ImageFileDirectory] """The IFD for this overview level. + + (positional index of the IFD in the TIFF file, IFD object) """ - _mask_ifd: ImageFileDirectory | None + _mask_ifd: tuple[int, ImageFileDirectory] | None """The IFD for the mask associated with this overview level, if any. - """ - _overview_idx: int - """The overview level (0 is the full resolution image, 1 is the first overview, etc). + (positional index of the IFD in the TIFF file, IFD object) """ - def __init__( - self, - geotiff: GeoTIFF, - gkd: GeoKeyDirectory, - ifd: ImageFileDirectory, - mask_ifd: ImageFileDirectory | None, - overview_idx: int, - ) -> None: - self._geotiff = geotiff - self._gkd = gkd - self._ifd = ifd - self._mask_ifd = mask_ifd - self._overview_idx = overview_idx - @cached_property def transform(self) -> Affine: """The affine transform mapping pixel coordinates to geographic coordinates. @@ -60,9 +45,9 @@ def transform(self) -> Affine: """ full_transform = self._geotiff.transform - overview_width = self._ifd.image_width + overview_width = self._ifd[1].image_width full_width = self._geotiff.width - overview_height = self._ifd.image_height + overview_height = self._ifd[1].image_height full_height = self._geotiff.height scale_x = full_width / overview_width From abbdca44c8f3f52843b9971a7c93874dd73dabc9 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 27 Jan 2026 13:16:30 -0500 Subject: [PATCH 4/6] Test overview transform --- src/async_geotiff/_geotiff.py | 2 +- src/async_geotiff/_overview.py | 2 +- tests/test_overview.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tests/test_overview.py diff --git a/src/async_geotiff/_geotiff.py b/src/async_geotiff/_geotiff.py index 4e48004..8b6b488 100644 --- a/src/async_geotiff/_geotiff.py +++ b/src/async_geotiff/_geotiff.py @@ -16,7 +16,7 @@ from async_tiff.store import ObjectStore -@dataclass(frozen=True, init=False, kw_only=True, slots=True, repr=False) +@dataclass(frozen=True, init=False, kw_only=True, repr=False) class GeoTIFF: """A class representing a GeoTIFF image.""" diff --git a/src/async_geotiff/_overview.py b/src/async_geotiff/_overview.py index 0189434..f3c38d7 100644 --- a/src/async_geotiff/_overview.py +++ b/src/async_geotiff/_overview.py @@ -12,7 +12,7 @@ from async_geotiff import GeoTIFF -@dataclass(frozen=True, kw_only=True, slots=True, eq=False) +@dataclass(frozen=True, kw_only=True, eq=False, repr=False) class Overview: """An overview level of a Cloud-Optimized GeoTIFF image.""" diff --git a/tests/test_overview.py b/tests/test_overview.py new file mode 100644 index 0000000..559456a --- /dev/null +++ b/tests/test_overview.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Awaitable, Callable + +import pytest +from affine import Affine + +from async_geotiff import GeoTIFF + +if TYPE_CHECKING: + from rasterio.io import DatasetReader + + LoadGeoTIFF = Callable[[str], Awaitable[GeoTIFF]] + LoadRasterio = Callable[[str], DatasetReader] + + +@pytest.mark.asyncio +async def test_overview_transform( + load_geotiff: LoadGeoTIFF, load_rasterio: LoadRasterio +) -> None: + name = "uint8_rgb_deflate_block64_cog" + + geotiff = await load_geotiff(name) + ovr = geotiff.overviews[0] + + with load_rasterio(name) as rasterio_ds: + overviews = rasterio_ds.overviews(1) + overview_level = overviews[0] + decimated_transform = rasterio_ds.transform * Affine.scale(overview_level) + + assert ovr.transform == decimated_transform From 738f47d7603e10ac1986eb008c8548aebb9e4765 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 27 Jan 2026 13:17:55 -0500 Subject: [PATCH 5/6] assert overviews length --- tests/test_overview.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_overview.py b/tests/test_overview.py index 559456a..2a1eb46 100644 --- a/tests/test_overview.py +++ b/tests/test_overview.py @@ -24,6 +24,8 @@ async def test_overview_transform( ovr = geotiff.overviews[0] with load_rasterio(name) as rasterio_ds: + assert len(geotiff.overviews) == len(rasterio_ds.overviews(1)) + overviews = rasterio_ds.overviews(1) overview_level = overviews[0] decimated_transform = rasterio_ds.transform * Affine.scale(overview_level) From 9c5b790360214e54eb69f5cc499950c5e33228c1 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Tue, 27 Jan 2026 13:20:45 -0500 Subject: [PATCH 6/6] Remove Overview __init__ method --- src/async_geotiff/_geotiff.py | 7 ++++++- src/async_geotiff/_overview.py | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/async_geotiff/_geotiff.py b/src/async_geotiff/_geotiff.py index 8b6b488..341a595 100644 --- a/src/async_geotiff/_geotiff.py +++ b/src/async_geotiff/_geotiff.py @@ -80,7 +80,12 @@ def __init__(self, tiff: TIFF) -> None: mask_ifd = (ifd_idx, next_ifd) ifd_idx += 1 - ovr = Overview(_geotiff=self, _gkd=gkd, _ifd=data_ifd, _mask_ifd=mask_ifd) + ovr = Overview._create( + geotiff=self, + gkd=gkd, + ifd=data_ifd, + mask_ifd=mask_ifd, + ) overviews.append(ovr) object.__setattr__(self, "_overviews", overviews) diff --git a/src/async_geotiff/_overview.py b/src/async_geotiff/_overview.py index f3c38d7..f96dade 100644 --- a/src/async_geotiff/_overview.py +++ b/src/async_geotiff/_overview.py @@ -12,7 +12,7 @@ from async_geotiff import GeoTIFF -@dataclass(frozen=True, kw_only=True, eq=False, repr=False) +@dataclass(init=False, frozen=True, kw_only=True, eq=False, repr=False) class Overview: """An overview level of a Cloud-Optimized GeoTIFF image.""" @@ -36,6 +36,25 @@ class Overview: (positional index of the IFD in the TIFF file, IFD object) """ + @classmethod + def _create( + cls, + *, + geotiff: GeoTIFF, + gkd: GeoKeyDirectory, + ifd: tuple[int, ImageFileDirectory], + mask_ifd: tuple[int, ImageFileDirectory] | None, + ) -> Overview: + instance = cls.__new__(cls) + + # We use object.__setattr__ because the dataclass is frozen + object.__setattr__(instance, "_geotiff", geotiff) + object.__setattr__(instance, "_gkd", gkd) + object.__setattr__(instance, "_ifd", ifd) + object.__setattr__(instance, "_mask_ifd", mask_ifd) + + return instance + @cached_property def transform(self) -> Affine: """The affine transform mapping pixel coordinates to geographic coordinates.