Skip to content

Commit 1013db7

Browse files
authored
feat: Define TransformMixin and add transform methods to Overview (#30)
1 parent 5666eb0 commit 1013db7

File tree

3 files changed

+98
-67
lines changed

3 files changed

+98
-67
lines changed

src/async_geotiff/_geotiff.py

Lines changed: 4 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,25 @@
22

33
from dataclasses import dataclass, field
44
from functools import cached_property
5-
from typing import TYPE_CHECKING, Literal, Self
5+
from typing import TYPE_CHECKING, Self
66

77
from affine import Affine
88
from async_tiff import TIFF
99
from async_tiff.enums import PhotometricInterpretation
1010

1111
from async_geotiff._crs import crs_from_geo_keys
1212
from async_geotiff._overview import Overview
13+
from async_geotiff._transform import TransformMixin
1314
from async_geotiff.enums import Compression, Interleaving
1415

1516
if TYPE_CHECKING:
16-
from collections.abc import Callable
17-
1817
import pyproj
1918
from async_tiff import GeoKeyDirectory, ImageFileDirectory, ObspecInput
2019
from async_tiff.store import ObjectStore # type: ignore # noqa: PGH003
2120

2221

2322
@dataclass(frozen=True, init=False, kw_only=True, repr=False)
24-
class GeoTIFF:
23+
class GeoTIFF(TransformMixin):
2524
"""A class representing a GeoTIFF image."""
2625

2726
_tiff: TIFF
@@ -222,27 +221,6 @@ def height(self) -> int:
222221
"""The height (number of rows) of the full image."""
223222
return self._primary_ifd.image_height
224223

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-
246224
def indexes(self) -> list[int]:
247225
"""Return the 1-based indexes of each band in the dataset.
248226
@@ -283,7 +261,7 @@ def photometric(self) -> PhotometricInterpretation | None:
283261
# https://rasterio.readthedocs.io/en/stable/api/rasterio.enums.html#rasterio.enums.PhotometricInterp
284262
raise NotImplementedError
285263

286-
@cached_property
264+
@property
287265
def res(self) -> tuple[float, float]:
288266
"""Return the (width, height) of pixels in the units of its CRS."""
289267
transform = self.transform
@@ -344,46 +322,6 @@ def width(self) -> int:
344322
"""The width (number of columns) of the full image."""
345323
return self._primary_ifd.image_width
346324

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-
387325

388326
def has_geokeys(ifd: ImageFileDirectory) -> bool:
389327
"""Check if an IFD has GeoTIFF keys.

src/async_geotiff/_overview.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@
66

77
from affine import Affine
88

9+
from async_geotiff._transform import TransformMixin
10+
911
if TYPE_CHECKING:
1012
from async_tiff import GeoKeyDirectory, ImageFileDirectory
1113

1214
from async_geotiff import GeoTIFF
1315

1416

1517
@dataclass(init=False, frozen=True, kw_only=True, eq=False, repr=False)
16-
class Overview:
18+
class Overview(TransformMixin):
1719
"""An overview level of a Cloud-Optimized GeoTIFF image."""
1820

1921
_geotiff: GeoTIFF

src/async_geotiff/_transform.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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 have a `transform` property that returns
25+
an `Affine` transformation matrix.
26+
"""
27+
28+
def index(
29+
self: HasTransform,
30+
x: float,
31+
y: float,
32+
op: Callable[[float], int] = floor,
33+
) -> tuple[int, int]:
34+
"""Get the (row, col) index of the pixel containing (x, y).
35+
36+
Args:
37+
x: x value in coordinate reference system.
38+
y: y value in coordinate reference system.
39+
op: Function to convert fractional pixels to whole numbers
40+
(floor, ceiling, round). Defaults to math.floor.
41+
42+
Returns:
43+
(row index, col index)
44+
45+
"""
46+
inv_transform = ~self.transform
47+
# Affine * (x, y) returns tuple[float, float] for 2D coordinates
48+
col_frac, row_frac = inv_transform * (x, y) # type: ignore[misc]
49+
50+
return (op(row_frac), op(col_frac))
51+
52+
def xy(
53+
self: HasTransform,
54+
row: int,
55+
col: int,
56+
offset: Literal["center", "ul", "ur", "ll", "lr"] = "center",
57+
) -> tuple[float, float]:
58+
"""Get the coordinates (x, y) of a pixel at (row, col).
59+
60+
The pixel's center is returned by default, but a corner can be returned
61+
by setting `offset` to one of `"ul"`, `"ur"`, `"ll"`, `"lr"`.
62+
63+
Args:
64+
row: Pixel row.
65+
col: Pixel column.
66+
offset: Determines if the returned coordinates are for the center of the
67+
pixel or for a corner.
68+
69+
Returns:
70+
(x, y) coordinates in the dataset's CRS.
71+
72+
"""
73+
if offset == "center":
74+
c = col + 0.5
75+
r = row + 0.5
76+
elif offset == "ul":
77+
c = col
78+
r = row
79+
elif offset == "ur":
80+
c = col + 1
81+
r = row
82+
elif offset == "ll":
83+
c = col
84+
r = row + 1
85+
elif offset == "lr":
86+
c = col + 1
87+
r = row + 1
88+
else:
89+
raise ValueError(f"Invalid offset value: {offset}")
90+
91+
return self.transform * (c, r)

0 commit comments

Comments
 (0)