Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/async_geotiff/_colorinterp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from async_tiff.enums import ExtraSamples

from .enums import ColorInterp, PhotometricInterpretation
from async_geotiff.enums import ColorInterp, PhotometricInterpretation

if TYPE_CHECKING:
from collections.abc import Sequence
Expand All @@ -23,7 +23,7 @@ def infer_color_interpretation( # noqa: PLR0911
case None:
return (ColorInterp.UNDEFINED,) * count
case PhotometricInterpretation.BLACK_IS_ZERO:
return (ColorInterp.GRAY,) * count
return (ColorInterp.GRAY,) + (ColorInterp.UNDEFINED,) * (count - 1)
case PhotometricInterpretation.RGB:
if count <= 2:
raise NotImplementedError(
Expand Down
44 changes: 29 additions & 15 deletions src/async_geotiff/_gdal_metadata.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

from collections import defaultdict
from dataclasses import dataclass
from dataclasses import dataclass, field

import defusedxml.ElementTree as ET # noqa: N817

from async_geotiff.enums import ColorInterp


@dataclass
class BandStatistics:
Expand Down Expand Up @@ -36,6 +38,18 @@ class GDALMetadata:

scales: tuple[float, ...]

colorinterp: dict[int, ColorInterp] = field(default_factory=dict)
"""A mapping of 1-based band index to overridden ColorInterp.

When present, these values override what would otherwise be inferred
from the photometric interpretation tag.
"""


LOWER_CASE_COLORINTERP_MAPPING: dict[str, ColorInterp] = {}
for color in ColorInterp:
LOWER_CASE_COLORINTERP_MAPPING[color.name.lower()] = color


def parse_gdal_metadata( # noqa: C901
gdal_metadata: str | None,
Expand All @@ -53,37 +67,37 @@ def parse_gdal_metadata( # noqa: C901
band_statistics: defaultdict[int, BandStatistics] = defaultdict(BandStatistics)
offsets: list[float] = [0.0] * count
scales: list[float] = [1.0] * count
colorinterp: dict[int, ColorInterp] = {}

for elem in root.findall("Item"):
name = elem.attrib.get("name")
sample = elem.attrib.get("sample")
role = elem.attrib.get("role")
text = elem.text or ""
match name:
# Add 1 to get a 1-based band index to match GDAL.
case "STATISTICS_MAXIMUM":
assert sample is not None # noqa: S101
case "STATISTICS_MAXIMUM" if sample is not None:
band_statistics[int(sample) + 1].max = float(text)
case "STATISTICS_MEAN":
assert sample is not None # noqa: S101
case "STATISTICS_MEAN" if sample is not None:
band_statistics[int(sample) + 1].mean = float(text)
case "STATISTICS_MINIMUM":
assert sample is not None # noqa: S101
case "STATISTICS_MINIMUM" if sample is not None:
band_statistics[int(sample) + 1].min = float(text)
case "STATISTICS_STDDEV":
assert sample is not None # noqa: S101
case "STATISTICS_STDDEV" if sample is not None:
band_statistics[int(sample) + 1].std = float(text)
case "STATISTICS_VALID_PERCENT":
assert sample is not None # noqa: S101
case "STATISTICS_VALID_PERCENT" if sample is not None:
band_statistics[int(sample) + 1].valid_percent = float(text)
case "OFFSET":
assert sample is not None # noqa: S101
case "OFFSET" if sample is not None:
offsets[int(sample)] = float(text)
case "SCALE":
assert sample is not None # noqa: S101
case "SCALE" if sample is not None:
scales[int(sample)] = float(text)
case "COLORINTERP" if role == "colorinterp" and sample is not None:
colorinterp[int(sample) + 1] = LOWER_CASE_COLORINTERP_MAPPING[
text.lower()
]

return GDALMetadata(
band_statistics=dict(band_statistics),
offsets=tuple(offsets),
scales=tuple(scales),
colorinterp=colorinterp,
)
16 changes: 12 additions & 4 deletions src/async_geotiff/_geotiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,20 @@ async def open(
@property
def colorinterp(self) -> tuple[ColorInterp, ...]:
"""The color interpretation of each band in index order."""
return infer_color_interpretation(
count=self.count,
photometric=self.photometric,
extra_samples=self._primary_ifd.extra_samples or [],
interps = list(
infer_color_interpretation(
count=self.count,
photometric=self.photometric,
extra_samples=self._primary_ifd.extra_samples or [],
),
)

if gdal_metadata := self._gdal_metadata:
for band_index, color_interp in gdal_metadata.colorinterp.items():
interps[band_index - 1] = color_interp

return tuple(interps)

@property
def colormap(self) -> Colormap | None:
"""Return the Colormap stored in the file, if any.
Expand Down
6 changes: 6 additions & 0 deletions tests/image_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
("rasterio", "float32_1band_lerc_zstd_block32"),
("rasterio", "uint16_1band_lzw_block128_predictor2"),
("rasterio", "uint16_1band_scale_offset"),
("rasterio", "uint8_1band_and_alpha_deflate_block64_cog"),
("rasterio", "uint8_1band_deflate_block128_unaligned_mask"),
("rasterio", "uint8_1band_deflate_block128_unaligned_predictor2"),
("rasterio", "uint8_1band_deflate_block128_unaligned"),
("rasterio", "uint8_1band_lzma_block64"),
("rasterio", "uint8_1band_lzw_block64_predictor2"),
("rasterio", "uint8_1band_zstd_level1_block64"),
("rasterio", "uint8_nonrgb_deflate_block64_cog"),
("rasterio", "uint8_rgb_deflate_block64_cog"),
("rasterio", "uint8_rgb_webp_block64_cog"),
("rasterio", "uint8_rgba_webp_block64_cog"),
Expand All @@ -35,6 +40,7 @@

ALL_TEST_IMAGES: list[tuple[str, str]] = [
*ALL_DATA_IMAGES,
("rio-tiler", "cog_rgb_with_stats"),
# YCbCr is auto-decompressed by rasterio
("vantor", "maxar_opendata_yellowstone_visual"),
("source-coop-alpha-earth", "xjejfvrbm1fbu1ecw-0000000000-0000008192"),
Expand Down
9 changes: 7 additions & 2 deletions tests/test_colorinterp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import pytest

from .image_list import ALL_DATA_IMAGES
from .image_list import ALL_TEST_IMAGES

if TYPE_CHECKING:
from .conftest import LoadGeoTIFF, LoadRasterio
Expand All @@ -15,14 +15,19 @@
@pytest.mark.asyncio
@pytest.mark.parametrize(
("variant", "file_name"),
ALL_DATA_IMAGES,
ALL_TEST_IMAGES,
)
async def test_colorinterp(
load_geotiff: LoadGeoTIFF,
load_rasterio: LoadRasterio,
variant: str,
file_name: str,
) -> None:
if (variant == "vantor" and file_name == "maxar_opendata_yellowstone_visual") or (
variant == "rio-tiler" and file_name == "cog_rgb_with_stats"
):
pytest.skip("Should our colorinterp map YCbCr to RGB?")

geotiff = await load_geotiff(file_name, variant=variant)
colorinterp = geotiff.colorinterp
assert colorinterp is not None, "Expected color interpretation to be present."
Expand Down