diff --git a/src/async_geotiff/_geotiff.py b/src/async_geotiff/_geotiff.py index 602fce0..f6b995f 100644 --- a/src/async_geotiff/_geotiff.py +++ b/src/async_geotiff/_geotiff.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from functools import cached_property -from typing import TYPE_CHECKING, Literal, Self +from typing import TYPE_CHECKING, Self from affine import Affine from async_tiff import TIFF @@ -10,18 +10,17 @@ from async_geotiff._crs import crs_from_geo_keys from async_geotiff._overview import Overview +from async_geotiff._transform import TransformMixin from async_geotiff.enums import Compression, Interleaving if TYPE_CHECKING: - from collections.abc import Callable - import pyproj from async_tiff import GeoKeyDirectory, ImageFileDirectory, ObspecInput from async_tiff.store import ObjectStore # type: ignore # noqa: PGH003 @dataclass(frozen=True, init=False, kw_only=True, repr=False) -class GeoTIFF: +class GeoTIFF(TransformMixin): """A class representing a GeoTIFF image.""" _tiff: TIFF @@ -222,27 +221,6 @@ def height(self) -> int: """The height (number of rows) of the full image.""" return self._primary_ifd.image_height - def index( - self, - x: float, - y: float, - op: Callable[[float, float], tuple[int, int]] | None = None, - ) -> tuple[int, int]: - """Get the (row, col) index of the pixel containing (x, y). - - Args: - x: x value in coordinate reference system - y: y value in coordinate reference system - op: function, optional (default: numpy.floor) - Function to convert fractional pixels to whole numbers - (floor, ceiling, round) - - Returns: - (row index, col index) - - """ - raise NotImplementedError - def indexes(self) -> list[int]: """Return the 1-based indexes of each band in the dataset. @@ -283,7 +261,7 @@ def photometric(self) -> PhotometricInterpretation | None: # https://rasterio.readthedocs.io/en/stable/api/rasterio.enums.html#rasterio.enums.PhotometricInterp raise NotImplementedError - @cached_property + @property def res(self) -> tuple[float, float]: """Return the (width, height) of pixels in the units of its CRS.""" transform = self.transform @@ -344,46 +322,6 @@ def width(self) -> int: """The width (number of columns) of the full image.""" return self._primary_ifd.image_width - def xy( - self, - row: int, - col: int, - offset: Literal["center", "ul", "ur", "ll", "lr"] | str = "center", - ) -> tuple[float, float]: - """Get the coordinates x, y of a pixel at row, col. - - The pixel's center is returned by default, but a corner can be returned - by setting `offset` to one of `"ul"`, `"ur"`, `"ll"`, `"lr"`. - - Args: - row: Pixel row. - col: Pixel column. - offset: Determines if the returned coordinates are for the center of the - pixel or for a corner. - - """ - transform = self.transform - - if offset == "center": - c = col + 0.5 - r = row + 0.5 - elif offset == "ul": - c = col - r = row - elif offset == "ur": - c = col + 1 - r = row - elif offset == "ll": - c = col - r = row + 1 - elif offset == "lr": - c = col + 1 - r = row + 1 - else: - raise ValueError(f"Invalid offset value: {offset}") - - return transform * (c, r) - def has_geokeys(ifd: ImageFileDirectory) -> bool: """Check if an IFD has GeoTIFF keys. diff --git a/src/async_geotiff/_overview.py b/src/async_geotiff/_overview.py index 7ae6e1d..feab4d7 100644 --- a/src/async_geotiff/_overview.py +++ b/src/async_geotiff/_overview.py @@ -6,6 +6,8 @@ from affine import Affine +from async_geotiff._transform import TransformMixin + if TYPE_CHECKING: from async_tiff import GeoKeyDirectory, ImageFileDirectory @@ -13,7 +15,7 @@ @dataclass(init=False, frozen=True, kw_only=True, eq=False, repr=False) -class Overview: +class Overview(TransformMixin): """An overview level of a Cloud-Optimized GeoTIFF image.""" _geotiff: GeoTIFF diff --git a/src/async_geotiff/_transform.py b/src/async_geotiff/_transform.py new file mode 100644 index 0000000..08f928d --- /dev/null +++ b/src/async_geotiff/_transform.py @@ -0,0 +1,91 @@ +"""Mixin class for coordinate transformation methods.""" + +from __future__ import annotations + +from math import floor +from typing import TYPE_CHECKING, Literal, Protocol + +if TYPE_CHECKING: + from collections.abc import Callable + + from affine import Affine + + +class HasTransform(Protocol): + """Protocol for objects that have an affine transform.""" + + @property + def transform(self) -> Affine: ... + + +class TransformMixin: + """Mixin providing coordinate transformation methods. + + Classes using this mixin must have a `transform` property that returns + an `Affine` transformation matrix. + """ + + def index( + self: HasTransform, + x: float, + y: float, + op: Callable[[float], int] = floor, + ) -> tuple[int, int]: + """Get the (row, col) index of the pixel containing (x, y). + + Args: + x: x value in coordinate reference system. + y: y value in coordinate reference system. + op: Function to convert fractional pixels to whole numbers + (floor, ceiling, round). Defaults to math.floor. + + Returns: + (row index, col index) + + """ + inv_transform = ~self.transform + # Affine * (x, y) returns tuple[float, float] for 2D coordinates + col_frac, row_frac = inv_transform * (x, y) # type: ignore[misc] + + return (op(row_frac), op(col_frac)) + + def xy( + self: HasTransform, + row: int, + col: int, + offset: Literal["center", "ul", "ur", "ll", "lr"] = "center", + ) -> tuple[float, float]: + """Get the coordinates (x, y) of a pixel at (row, col). + + The pixel's center is returned by default, but a corner can be returned + by setting `offset` to one of `"ul"`, `"ur"`, `"ll"`, `"lr"`. + + Args: + row: Pixel row. + col: Pixel column. + offset: Determines if the returned coordinates are for the center of the + pixel or for a corner. + + Returns: + (x, y) coordinates in the dataset's CRS. + + """ + if offset == "center": + c = col + 0.5 + r = row + 0.5 + elif offset == "ul": + c = col + r = row + elif offset == "ur": + c = col + 1 + r = row + elif offset == "ll": + c = col + r = row + 1 + elif offset == "lr": + c = col + 1 + r = row + 1 + else: + raise ValueError(f"Invalid offset value: {offset}") + + return self.transform * (c, r)