Skip to content

Commit 922d509

Browse files
authored
feat: Implement transform, height, width (#8)
* Initial pytest setup * feat: Implement `transform`, `height`, `width` * add python test ci * don't apply ruff on fixtures
1 parent 1113c61 commit 922d509

File tree

7 files changed

+326
-14
lines changed

7 files changed

+326
-14
lines changed

.github/workflows/test.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Test Python
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
jobs:
10+
lint-python:
11+
name: Lint
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
with:
16+
submodules: "recursive"
17+
18+
- name: Install a specific version of uv
19+
uses: astral-sh/setup-uv@v6
20+
with:
21+
version: "latest"
22+
23+
# # Ensure docs build without warnings
24+
# - name: Check docs
25+
# if: "${{ matrix.python-version == 3.11 }}"
26+
# run: uv run --group docs mkdocs build --strict
27+
28+
# Use ruff-action so we get annotations in the Github UI
29+
- uses: astral-sh/ruff-action@v3
30+
31+
pytest:
32+
name: Pytest
33+
runs-on: ubuntu-latest
34+
strategy:
35+
matrix:
36+
python-version: ["3.11", "3.12", "3.13", "3.14"]
37+
steps:
38+
- uses: actions/checkout@v4
39+
with:
40+
submodules: "recursive"
41+
42+
- name: Install a specific version of uv
43+
uses: astral-sh/setup-uv@v6
44+
with:
45+
version: "latest"
46+
47+
- name: Run pytest
48+
run: uv run pytest

pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,20 @@ dependencies = [
1616
morecantile = ["morecantile>=7.0,<8.0"]
1717

1818
[dependency-groups]
19-
dev = ["ipykernel>=7.1.0", "rasterio>=1.4.4"]
19+
dev = [
20+
"ipykernel>=7.1.0",
21+
"obstore>=0.8.2",
22+
"pytest>=9.0.2",
23+
"pytest-asyncio>=1.3.0",
24+
"rasterio>=1.4.4",
25+
]
2026

2127
[build-system]
2228
requires = ["uv_build>=0.8.8,<0.9.0"]
2329
build-backend = "uv_build"
2430

31+
[tool.ruff]
32+
extend-exclude = ["fixtures"]
33+
2534
[tool.ruff.lint]
2635
select = ["I"]

src/async_geotiff/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from ._version import __version__
21
from ._geotiff import GeoTIFF
2+
from ._version import __version__
33

44
__all__ = ["GeoTIFF", "__version__"]

src/async_geotiff/_geotiff.py

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
from typing import TYPE_CHECKING, Literal, Self
44

5-
from async_tiff import TIFF, ImageFileDirectory, ObspecInput
6-
from async_tiff.store import ObjectStore
5+
from affine import Affine
6+
from async_tiff import TIFF
77

88
from async_geotiff.enums import Compression, Interleaving, PhotometricInterp
99

1010
if TYPE_CHECKING:
1111
import pyproj
12-
from affine import Affine
12+
from async_tiff import GeoKeyDirectory, ImageFileDirectory, ObspecInput
13+
from async_tiff.store import ObjectStore
1314

1415

1516
class GeoTIFF:
@@ -19,13 +20,32 @@ class GeoTIFF:
1920
"""The underlying async-tiff TIFF instance that we wrap.
2021
"""
2122

23+
_primary_ifd: ImageFileDirectory
24+
"""The primary (first) IFD of the GeoTIFF.
25+
26+
Some tags, like most geo tags, only exist on the primary IFD.
27+
"""
28+
29+
_gkd: GeoKeyDirectory
30+
"""The GeoKeyDirectory of the primary IFD.
31+
"""
32+
2233
def __init__(self, tiff: TIFF) -> None:
2334
"""Create a GeoTIFF from an existing TIFF instance."""
35+
36+
first_ifd = tiff.ifds[0]
37+
gkd = first_ifd.geo_key_directory
38+
2439
# Validate that this is indeed a GeoTIFF
25-
if not has_geokeys(tiff.ifds[0]):
40+
if gkd is None:
2641
raise ValueError("TIFF does not contain GeoTIFF keys")
2742

43+
if len(tiff.ifds) == 0:
44+
raise ValueError("TIFF does not contain any IFDs")
45+
2846
self._tiff = tiff
47+
self._primary_ifd = first_ifd
48+
self._gkd = gkd
2949

3050
@classmethod
3151
async def open(
@@ -36,7 +56,7 @@ async def open(
3656
prefetch: int = 32768,
3757
multiplier: int | float = 2.0,
3858
) -> Self:
39-
"""Open a new TIFF.
59+
"""Open a new GeoTIFF.
4060
4161
Args:
4262
path: The path within the store to read from.
@@ -121,12 +141,12 @@ def compression(self) -> Compression:
121141

122142
@property
123143
def count(self) -> int:
124-
"""The number of raster bands in the dataset."""
144+
"""The number of raster bands in the full image."""
125145
raise NotImplementedError()
126146

127147
@property
128148
def crs(self) -> pyproj.CRS:
129-
"""The datasets coordinate reference system."""
149+
"""The dataset's coordinate reference system."""
130150
raise NotImplementedError()
131151

132152
@property
@@ -138,8 +158,8 @@ def dtypes(self) -> list[str]:
138158

139159
@property
140160
def height(self) -> int:
141-
"""The height (number of rows) of the dataset."""
142-
raise NotImplementedError()
161+
"""The height (number of rows) of the full image."""
162+
return self._primary_ifd.image_height
143163

144164
def index(
145165
self,
@@ -208,12 +228,48 @@ def transform(self) -> Affine:
208228
209229
This transform maps pixel row/column coordinates to coordinates in the dataset's coordinate reference system.
210230
"""
211-
raise NotImplementedError()
231+
if (tie_points := self._primary_ifd.model_tiepoint) and (
232+
model_scale := self._primary_ifd.model_pixel_scale
233+
):
234+
x_origin = tie_points[3]
235+
y_origin = tie_points[4]
236+
x_resolution = model_scale[0]
237+
y_resolution = -model_scale[1]
238+
239+
return Affine(x_resolution, 0, x_origin, 0, y_resolution, y_origin)
240+
241+
if model_transformation := self._primary_ifd.model_transformation:
242+
# ModelTransformation is a 4x4 matrix in row-major order
243+
# [0 1 2 3 ] [a b 0 c]
244+
# [4 5 6 7 ] = [d e 0 f]
245+
# [8 9 10 11] [0 0 1 0]
246+
# [12 13 14 15] [0 0 0 1]
247+
x_origin = model_transformation[3]
248+
y_origin = model_transformation[7]
249+
row_rotation = model_transformation[1]
250+
col_rotation = model_transformation[4]
251+
252+
# TODO: confirm these are correct
253+
# Why does geotiff.js square and then square-root them?
254+
# https://github.com/developmentseed/async-geotiff/issues/7
255+
x_resolution = model_transformation[0]
256+
y_resolution = -model_transformation[5]
257+
258+
return Affine(
259+
model_transformation[0],
260+
row_rotation,
261+
x_origin,
262+
col_rotation,
263+
model_transformation[5],
264+
y_origin,
265+
)
266+
267+
raise ValueError("The image does not have an affine transformation.")
212268

213269
@property
214270
def width(self) -> int:
215-
"""The width (number of columns) of the dataset."""
216-
raise NotImplementedError()
271+
"""The width (number of columns) of the full image."""
272+
return self._primary_ifd.image_width
217273

218274
def xy(
219275
self,

tests/conftest.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from __future__ import annotations
2+
3+
from contextlib import contextmanager
4+
from pathlib import Path
5+
from typing import TYPE_CHECKING, Generator
6+
7+
import pytest
8+
import rasterio
9+
from async_tiff.store import LocalStore
10+
11+
from async_geotiff import GeoTIFF
12+
13+
if TYPE_CHECKING:
14+
from rasterio.io import DatasetReader
15+
16+
17+
@pytest.fixture(scope="session")
18+
def root_dir() -> Path:
19+
root_dir = Path(__file__).parent.resolve()
20+
21+
if root_dir.name != "async-geotiff":
22+
root_dir = root_dir.parent
23+
24+
return root_dir
25+
26+
27+
@pytest.fixture(scope="session")
28+
def fixture_store(root_dir) -> LocalStore:
29+
return LocalStore(root_dir / "fixtures")
30+
31+
32+
@pytest.fixture
33+
def load_geotiff(fixture_store):
34+
async def _load(name: str) -> GeoTIFF:
35+
path = f"geotiff-test-data/rasterio_generated/fixtures/{name}.tif"
36+
return await GeoTIFF.open(path=path, store=fixture_store)
37+
38+
return _load
39+
40+
41+
@pytest.fixture
42+
def load_rasterio(root_dir):
43+
@contextmanager
44+
def _load(name: str) -> Generator[DatasetReader, None, None]:
45+
path = f"{root_dir}/fixtures/geotiff-test-data/rasterio_generated/fixtures/{name}.tif"
46+
with rasterio.open(path) as ds:
47+
yield ds
48+
49+
return _load

tests/test_geotiff.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Awaitable, Callable
4+
5+
import pytest
6+
7+
from async_geotiff import GeoTIFF
8+
9+
if TYPE_CHECKING:
10+
from rasterio.io import DatasetReader
11+
12+
13+
@pytest.mark.asyncio
14+
async def test_height_width(
15+
load_geotiff: Callable[[str], Awaitable[GeoTIFF]],
16+
load_rasterio: Callable[[str], DatasetReader],
17+
) -> None:
18+
name = "uint8_rgb_deflate_block64_cog"
19+
20+
geotiff = await load_geotiff(name)
21+
with load_rasterio(name) as rasterio_ds:
22+
assert rasterio_ds.height == geotiff.height
23+
assert rasterio_ds.width == geotiff.width
24+
25+
26+
@pytest.mark.asyncio
27+
async def test_transform(
28+
load_geotiff: Callable[[str], Awaitable[GeoTIFF]],
29+
load_rasterio: Callable[[str], DatasetReader],
30+
) -> None:
31+
name = "uint8_rgb_deflate_block64_cog"
32+
33+
geotiff = await load_geotiff(name)
34+
with load_rasterio(name) as rasterio_ds:
35+
assert rasterio_ds.transform == geotiff.transform

0 commit comments

Comments
 (0)