Skip to content

Commit 55fb090

Browse files
committed
Merge branch 'main' into band-interleaved
2 parents b23f40d + 4b5360e commit 55fb090

File tree

10 files changed

+88
-73
lines changed

10 files changed

+88
-73
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Fast, async GeoTIFF and [Cloud-Optimized GeoTIFF][cogeo] (COG) reader for Python
2828
- Lightweight with **no GDAL dependency**.
2929
- Access data from AWS S3, Google Cloud Storage, and Azure Storage via **integration with [obstore]**.
3030
- **Full type hinting** for all operations.
31-
- **Broad decompression support**: Deflate, LZMA, LZW, JPEG, JPEG2000, WebP, ZSTD.
31+
- **Broad decompression support**: Deflate, JPEG, JPEG2000, LERC, LERC_DEFLATE, LERC_ZSTD, LZMA, LZW, WebP, ZSTD.
3232
- Support for **any arbitrary backend** via [obspec] protocols.
3333

3434
[Affine]: https://affine.readthedocs.io/en/latest/

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ classifiers = [
2222
keywords = ["geotiff", "tiff", "async", "cog", "raster", "gis"]
2323
dependencies = [
2424
"affine>=2.4.0",
25-
"async-tiff>=0.5.0",
25+
"async-tiff>=0.6.0-beta.1",
2626
"numpy>=2.0",
2727
"pyproj>=3.3.0",
2828
]

src/async_geotiff/_geotiff.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

3+
import math
34
from dataclasses import dataclass, field
45
from functools import cached_property
5-
from typing import TYPE_CHECKING, Self, cast
6+
from typing import TYPE_CHECKING, Self
67

78
import numpy as np
89
from affine import Affine
@@ -161,17 +162,25 @@ def bounds(self) -> tuple[float, float, float, float]:
161162
lower left x, lower left y, upper right x, upper right y
162163
163164
"""
164-
transform = self.transform
165-
166-
# TODO: Remove type casts once affine supports typing overloads for matmul
167-
# https://github.com/rasterio/affine/pull/137
168-
(left, top) = cast("tuple[float, float]", transform * (0, 0))
169-
(right, bottom) = cast(
170-
"tuple[float, float]",
171-
transform * (self.width, self.height),
165+
# Transform all four corners to handle rotated images correctly
166+
# Pixel coordinates of corners: (x, y, 1) for affine transform
167+
corners_pixel = np.array(
168+
[
169+
[0, 0, 1],
170+
[self.width, 0, 1],
171+
[0, self.height, 1],
172+
[self.width, self.height, 1],
173+
],
172174
)
173175

174-
return (left, bottom, right, top)
176+
# Apply affine transform: transform @ corners_pixel.T
177+
transform_matrix = np.array(self.transform).reshape(3, 3)
178+
corners_geo = (transform_matrix @ corners_pixel.T)[:2].T
179+
180+
min_x, min_y = corners_geo.min(axis=0)
181+
max_x, max_y = corners_geo.max(axis=0)
182+
183+
return (float(min_x), float(min_y), float(max_x), float(max_y))
175184

176185
# @property
177186
# def colorinterp(self) -> list[str]:
@@ -346,7 +355,11 @@ def photometric(self) -> PhotometricInterpretation | None: # noqa: PLR0911
346355
def res(self) -> tuple[float, float]:
347356
"""Return the (width, height) of pixels in the units of its CRS."""
348357
transform = self.transform
349-
return (transform.a, -transform.e)
358+
# For rotated images, resolution is the magnitude of the pixel size
359+
# calculated from the transform matrix components
360+
res_x = math.sqrt(transform.a**2 + transform.d**2)
361+
res_y = math.sqrt(transform.b**2 + transform.e**2)
362+
return (res_x, res_y)
350363

351364
@property
352365
def shape(self) -> tuple[int, int]:

tests/image_list.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,30 @@
11
from __future__ import annotations
22

33
ALL_DATA_IMAGES: list[tuple[str, str]] = [
4-
("eox_cloudless", "eox"),
5-
("nlcd_landcover", "nlcd"),
6-
("uint16_1band_lzw_block128_predictor2", "rasterio"),
7-
("uint8_1band_deflate_block128_unaligned", "rasterio"),
8-
("uint8_1band_lzma_block64", "rasterio"),
9-
("uint8_rgb_deflate_block64_cog", "rasterio"),
10-
("uint8_rgb_webp_block64_cog", "rasterio"),
11-
("uint8_rgba_webp_block64_cog", "rasterio"),
4+
("eox", "eox_cloudless"),
5+
("nlcd", "nlcd_landcover"),
6+
("rasterio", "float32_1band_lerc_block32"),
7+
("rasterio", "float32_1band_lerc_deflate_block32"),
8+
("rasterio", "float32_1band_lerc_zstd_block32"),
9+
("rasterio", "uint16_1band_lzw_block128_predictor2"),
10+
("rasterio", "uint8_1band_deflate_block128_unaligned"),
11+
("rasterio", "uint8_1band_lzma_block64"),
12+
("rasterio", "uint8_rgb_deflate_block64_cog"),
13+
("rasterio", "uint8_rgb_webp_block64_cog"),
14+
("rasterio", "uint8_rgba_webp_block64_cog"),
15+
("umbra", "sydney_airport_GEC"),
1216
]
1317
"""All fixtures where the data can be compared with rasterio."""
1418

1519

1620
ALL_TEST_IMAGES: list[tuple[str, str]] = [
1721
*ALL_DATA_IMAGES,
1822
# YCbCr is auto-decompressed by rasterio
19-
("maxar_opendata_yellowstone_visual", "vantor"),
20-
# we don't support LERC yet
21-
("float32_1band_lerc_block32", "rasterio"),
23+
("vantor", "maxar_opendata_yellowstone_visual"),
2224
]
2325
"""All fixtures where we test metadata parsing."""
2426

2527
ALL_MASKED_IMAGES: list[tuple[str, str]] = [
26-
("maxar_opendata_yellowstone_visual", "vantor"),
28+
("vantor", "maxar_opendata_yellowstone_visual"),
2729
]
2830
"""All fixtures that have a nodata mask."""

tests/test_colormap.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,16 @@
1313

1414
@pytest.mark.asyncio
1515
@pytest.mark.parametrize(
16-
("file_name", "variant"),
16+
("variant", "file_name"),
1717
[
18-
("nlcd_landcover", "nlcd"),
18+
("nlcd", "nlcd_landcover"),
1919
],
2020
)
2121
async def test_colormap(
2222
load_geotiff: LoadGeoTIFF,
2323
load_rasterio: LoadRasterio,
24-
file_name: str,
2524
variant: str,
25+
file_name: str,
2626
) -> None:
2727
geotiff = await load_geotiff(file_name, variant=variant)
2828
colormap = geotiff.colormap

tests/test_crs.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ def projjson_schema() -> dict:
2626

2727
@pytest.mark.asyncio
2828
@pytest.mark.parametrize(
29-
("file_name", "variant"),
29+
("variant", "file_name"),
3030
ALL_TEST_IMAGES,
3131
)
3232
async def test_crs(
3333
load_geotiff: LoadGeoTIFF,
3434
load_rasterio: LoadRasterio,
35-
file_name: str,
3635
variant: str,
36+
file_name: str,
3737
) -> None:
3838
geotiff = await load_geotiff(file_name, variant=variant)
3939
with load_rasterio(file_name, variant=variant) as rasterio_ds:
@@ -42,14 +42,14 @@ async def test_crs(
4242

4343
@pytest.mark.asyncio
4444
@pytest.mark.parametrize(
45-
("file_name", "variant"),
46-
[("nlcd_landcover", "nlcd")],
45+
("variant", "file_name"),
46+
[("nlcd", "nlcd_landcover")],
4747
)
4848
async def test_crs_custom_projjson_schema(
4949
load_geotiff: LoadGeoTIFF,
5050
projjson_schema: dict,
51-
file_name: str,
5251
variant: str,
52+
file_name: str,
5353
) -> None:
5454
"""Validate that a user-defined CRS produces valid PROJJSON."""
5555
geotiff = await load_geotiff(file_name, variant=variant)

tests/test_fetch.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@
1616

1717
@pytest.mark.asyncio
1818
@pytest.mark.parametrize(
19-
("file_name", "variant"),
19+
("variant", "file_name"),
2020
ALL_DATA_IMAGES,
2121
)
2222
async def test_fetch(
2323
load_geotiff: LoadGeoTIFF,
2424
load_rasterio: LoadRasterio,
25-
file_name: str,
2625
variant: str,
26+
file_name: str,
2727
) -> None:
2828
geotiff = await load_geotiff(file_name, variant=variant)
2929

@@ -39,14 +39,14 @@ async def test_fetch(
3939

4040
@pytest.mark.asyncio
4141
@pytest.mark.parametrize(
42-
("file_name", "variant"),
42+
("variant", "file_name"),
4343
ALL_DATA_IMAGES,
4444
)
4545
async def test_fetch_overview(
4646
load_geotiff: LoadGeoTIFF,
4747
load_rasterio: LoadRasterio,
48-
file_name: str,
4948
variant: str,
49+
file_name: str,
5050
) -> None:
5151
geotiff = await load_geotiff(file_name, variant=variant)
5252
overview = geotiff.overviews[0]
@@ -55,22 +55,22 @@ async def test_fetch_overview(
5555

5656
window = Window(0, 0, overview.tile_width, overview.tile_height)
5757
with load_rasterio(file_name, variant=variant, OVERVIEW_LEVEL=0) as rasterio_ds:
58-
rasterio_data = rasterio_ds.read(window=window)
58+
rasterio_data = rasterio_ds.read(window=window, boundless=True)
5959

6060
np.testing.assert_array_equal(tile.array.data, rasterio_data)
6161
assert tile.array.crs == geotiff.crs
6262

6363

6464
@pytest.mark.asyncio
6565
@pytest.mark.parametrize(
66-
("file_name", "variant"),
66+
("variant", "file_name"),
6767
ALL_MASKED_IMAGES,
6868
)
6969
async def test_mask(
7070
load_geotiff: LoadGeoTIFF,
7171
load_rasterio: LoadRasterio,
72-
file_name: str,
7372
variant: str,
73+
file_name: str,
7474
) -> None:
7575
geotiff = await load_geotiff(file_name, variant=variant)
7676

@@ -90,14 +90,14 @@ async def test_mask(
9090

9191
@pytest.mark.asyncio
9292
@pytest.mark.parametrize(
93-
("file_name", "variant"),
93+
("variant", "file_name"),
9494
ALL_MASKED_IMAGES,
9595
)
9696
async def test_mask_overview(
9797
load_geotiff: LoadGeoTIFF,
9898
load_rasterio: LoadRasterio,
99-
file_name: str,
10099
variant: str,
100+
file_name: str,
101101
) -> None:
102102
geotiff = await load_geotiff(file_name, variant=variant)
103103
overview = geotiff.overviews[0]
@@ -118,14 +118,14 @@ async def test_mask_overview(
118118

119119
@pytest.mark.asyncio
120120
@pytest.mark.parametrize(
121-
("file_name", "variant"),
121+
("variant", "file_name"),
122122
ALL_DATA_IMAGES,
123123
)
124124
async def test_fetch_as_masked(
125125
load_geotiff: LoadGeoTIFF,
126126
load_rasterio: LoadRasterio,
127-
file_name: str,
128127
variant: str,
128+
file_name: str,
129129
) -> None:
130130
geotiff = await load_geotiff(file_name, variant=variant)
131131

tests/test_geotiff.py

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

1313
@pytest.mark.asyncio
1414
@pytest.mark.parametrize(
15-
("file_name", "variant"),
15+
("variant", "file_name"),
1616
ALL_TEST_IMAGES,
1717
)
1818
async def test_ifd_info(
1919
load_geotiff: LoadGeoTIFF,
2020
load_rasterio: LoadRasterio,
21-
file_name: str,
2221
variant: str,
22+
file_name: str,
2323
) -> None:
2424
geotiff = await load_geotiff(file_name, variant=variant)
2525

tests/test_read.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@
1919

2020
@pytest.mark.asyncio
2121
@pytest.mark.parametrize(
22-
("file_name", "variant"),
22+
("variant", "file_name"),
2323
ALL_DATA_IMAGES,
2424
)
2525
async def test_read_single_tile(
2626
load_geotiff: LoadGeoTIFF,
2727
load_rasterio: LoadRasterio,
28-
file_name: str,
2928
variant: str,
29+
file_name: str,
3030
) -> None:
3131
"""Test reading a window that fits within a single tile."""
3232
geotiff = await load_geotiff(file_name, variant=variant)
@@ -47,14 +47,14 @@ async def test_read_single_tile(
4747

4848
@pytest.mark.asyncio
4949
@pytest.mark.parametrize(
50-
("file_name", "variant"),
50+
("variant", "file_name"),
5151
ALL_DATA_IMAGES,
5252
)
5353
async def test_read_spanning_tiles(
5454
load_geotiff: LoadGeoTIFF,
5555
load_rasterio: LoadRasterio,
56-
file_name: str,
5756
variant: str,
57+
file_name: str,
5858
) -> None:
5959
"""Test reading a window that spans multiple tiles."""
6060
geotiff = await load_geotiff(file_name, variant=variant)
@@ -93,14 +93,14 @@ async def test_read_spanning_tiles(
9393

9494
@pytest.mark.asyncio
9595
@pytest.mark.parametrize(
96-
("file_name", "variant"),
96+
("variant", "file_name"),
9797
ALL_DATA_IMAGES,
9898
)
9999
async def test_read_overview(
100100
load_geotiff: LoadGeoTIFF,
101101
load_rasterio: LoadRasterio,
102-
file_name: str,
103102
variant: str,
103+
file_name: str,
104104
) -> None:
105105
"""Test reading from an overview level."""
106106
geotiff = await load_geotiff(file_name, variant=variant)
@@ -151,14 +151,14 @@ async def test_read_bounds_validation(
151151

152152
@pytest.mark.asyncio
153153
@pytest.mark.parametrize(
154-
("file_name", "variant"),
154+
("variant", "file_name"),
155155
ALL_DATA_IMAGES,
156156
)
157157
async def test_read_full(
158158
load_geotiff: LoadGeoTIFF,
159159
load_rasterio: LoadRasterio,
160-
file_name: str,
161160
variant: str,
161+
file_name: str,
162162
) -> None:
163163
geotiff = await load_geotiff(file_name, variant=variant)
164164
array = await geotiff.read()

0 commit comments

Comments
 (0)