From 09dda65eb6cea6929a50bd32968e9efc9dcf4e2c Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Thu, 7 May 2026 19:24:24 -0700 Subject: [PATCH 1/2] Skip mask IFDs when resolving overview_level (#1504) open_geotiff(path, overview_level=N) was indexing the IFD list directly. When a TIFF puts a transparency-mask IFD (NewSubfileType bit 2) between the full-res IFD and the overviews, as some GDAL COGs do, overview_level=1 returned the 1-bit mask instead of the first overview. The reader now filters out IFDs whose NewSubfileType has bit 2 set before indexing, matching what GDAL and rasterio do. Out-of-range overview_level raises ValueError with the actual count of non-mask IFDs (and any masks) instead of silently clamping to the last IFD. - IFD.subfile_type and IFD.is_mask properties on parsed IFDs - select_overview_ifd helper in _header.py - routed the four overview_level call sites (CPU read, COG-over-HTTP, GPU read, _read_geo_info) through the helper --- xrspatial/geotiff/__init__.py | 19 +- xrspatial/geotiff/_header.py | 75 +++++++ xrspatial/geotiff/_reader.py | 23 +- .../geotiff/tests/test_overview_filter.py | 200 ++++++++++++++++++ 4 files changed, 296 insertions(+), 21 deletions(-) create mode 100644 xrspatial/geotiff/tests/test_overview_filter.py diff --git a/xrspatial/geotiff/__init__.py b/xrspatial/geotiff/__init__.py index 0cfdddd9..244c31c6 100644 --- a/xrspatial/geotiff/__init__.py +++ b/xrspatial/geotiff/__init__.py @@ -196,7 +196,7 @@ def _read_geo_info(source, *, overview_level: int | None = None): """ from ._dtypes import tiff_dtype_to_numpy from ._geotags import extract_geo_info - from ._header import parse_all_ifds, parse_header + from ._header import parse_all_ifds, parse_header, select_overview_ifd from ._reader import _coerce_path, _is_file_like source = _coerce_path(source) @@ -226,10 +226,9 @@ def _read_geo_info(source, *, overview_level: int | None = None): try: header = parse_header(data) ifds = parse_all_ifds(data, header) - ifd_idx = 0 - if overview_level is not None: - ifd_idx = min(overview_level, len(ifds) - 1) - ifd = ifds[ifd_idx] + if not ifds: + raise ValueError("No IFDs found in TIFF file") + ifd = select_overview_ifd(ifds, overview_level) geo_info = extract_geo_info(ifd, data, header.byte_order) bps = ifd.bits_per_sample if isinstance(bps, tuple): @@ -1444,7 +1443,9 @@ def read_geotiff_gpu(source: str, *, from ._reader import ( _FileSource, _check_dimensions, MAX_PIXELS_DEFAULT, _coerce_path, ) - from ._header import parse_header, parse_all_ifds, validate_tile_layout + from ._header import ( + parse_header, parse_all_ifds, select_overview_ifd, validate_tile_layout, + ) from ._dtypes import tiff_dtype_to_numpy from ._geotags import extract_geo_info from ._gpu_decode import gpu_decode_tiles @@ -1465,10 +1466,8 @@ def read_geotiff_gpu(source: str, *, if len(ifds) == 0: raise ValueError("No IFDs found in TIFF file") - ifd_idx = 0 - if overview_level is not None: - ifd_idx = min(overview_level, len(ifds) - 1) - ifd = ifds[ifd_idx] + # Skip mask IFDs (NewSubfileType bit 2) + ifd = select_overview_ifd(ifds, overview_level) bps = ifd.bits_per_sample if isinstance(bps, tuple): diff --git a/xrspatial/geotiff/_header.py b/xrspatial/geotiff/_header.py index 5f6f6a1b..44eeba96 100644 --- a/xrspatial/geotiff/_header.py +++ b/xrspatial/geotiff/_header.py @@ -91,6 +91,25 @@ def get_values(self, tag: int) -> tuple | None: return (v,) # Convenience properties + @property + def subfile_type(self) -> int: + """NewSubfileType (tag 254) bit flags. 0 if absent. + + Bit flags (TIFF 6.0 spec): + bit 0 (& 1) - reduced-resolution overview + bit 1 (& 2) - page of multi-page document + bit 2 (& 4) - transparency mask + """ + v = self.get_value(TAG_NEW_SUBFILE_TYPE, 0) + if isinstance(v, tuple): + v = v[0] if v else 0 + return int(v) + + @property + def is_mask(self) -> bool: + """True if this IFD's NewSubfileType marks it as a transparency mask.""" + return bool(self.subfile_type & 4) + @property def width(self) -> int: return self.get_value(TAG_IMAGE_WIDTH, 0) @@ -426,6 +445,62 @@ def parse_ifd(data: bytes | memoryview, offset: int, return IFD(entries=entries, next_ifd_offset=next_ifd) +def select_overview_ifd(ifds: list[IFD], overview_level: int | None) -> IFD: + """Pick the IFD for a requested overview level, skipping mask IFDs. + + Some COG variants (notably GDAL with internal masks) interleave + transparency-mask IFDs (NewSubfileType bit 2 set) with overview IFDs. + Indexing the raw IFD list by ``overview_level`` then returns a binary + mask instead of a reduced-resolution overview. This helper builds a + filtered list of full-resolution + overview IFDs (mask-bit clear) and + indexes into that. + + ``overview_level=0`` (or ``None``) returns the full-resolution IFD; + ``overview_level=1`` returns the first overview, and so on. + + Parameters + ---------- + ifds : list[IFD] + All IFDs as parsed from the file. + overview_level : int or None + Which overview to return. ``None`` is treated as ``0``. + + Returns + ------- + IFD + + Raises + ------ + ValueError + If ``ifds`` is empty, or if ``overview_level`` exceeds the number + of non-mask IFDs in the file. + """ + if not ifds: + raise ValueError("No IFDs found in TIFF file") + + filtered = [ifd for ifd in ifds if not ifd.is_mask] + if not filtered: + raise ValueError( + "TIFF file contains no full-resolution or overview IFDs " + "(all IFDs are transparency masks)") + + level = 0 if overview_level is None else overview_level + if level < 0: + raise ValueError(f"overview_level must be >= 0, got {level}") + if level >= len(filtered): + n_overviews = len(filtered) - 1 + n_masks = len(ifds) - len(filtered) + raise ValueError( + f"overview_level={level} is out of range: TIFF has " + f"{len(filtered)} non-mask IFDs (1 full-resolution + " + f"{n_overviews} overview{'s' if n_overviews != 1 else ''}" + f"{f', plus {n_masks} mask IFD' if n_masks else ''}" + f"{'s' if n_masks > 1 else ''}). Valid overview_level values " + f"are 0..{len(filtered) - 1}.") + + return filtered[level] + + def parse_all_ifds(data: bytes | memoryview, header: TIFFHeader) -> list[IFD]: """Parse all IFDs in a TIFF file. diff --git a/xrspatial/geotiff/_reader.py b/xrspatial/geotiff/_reader.py index e7fec46e..92daca0b 100644 --- a/xrspatial/geotiff/_reader.py +++ b/xrspatial/geotiff/_reader.py @@ -20,7 +20,14 @@ ) from ._dtypes import SUB_BYTE_BPS, tiff_dtype_to_numpy from ._geotags import GeoInfo, GeoTransform, extract_geo_info -from ._header import IFD, TIFFHeader, parse_all_ifds, parse_header, validate_tile_layout +from ._header import ( + IFD, + TIFFHeader, + parse_all_ifds, + parse_header, + select_overview_ifd, + validate_tile_layout, +) # --------------------------------------------------------------------------- # Allocation guard: reject TIFF dimensions that would exhaust memory @@ -972,11 +979,8 @@ def _read_cog_http(url: str, overview_level: int | None = None, if len(ifds) == 0: raise ValueError("No IFDs found in COG") - # Select IFD based on overview level - ifd_idx = 0 - if overview_level is not None: - ifd_idx = min(overview_level, len(ifds) - 1) - ifd = ifds[ifd_idx] + # Select IFD based on overview level, skipping any mask IFDs + ifd = select_overview_ifd(ifds, overview_level) bps = ifd.bits_per_sample if isinstance(bps, tuple): @@ -1131,11 +1135,8 @@ def read_to_array(source, *, window=None, overview_level: int | None = None, if len(ifds) == 0: raise ValueError("No IFDs found in TIFF file") - # Select IFD - ifd_idx = 0 - if overview_level is not None: - ifd_idx = min(overview_level, len(ifds) - 1) - ifd = ifds[ifd_idx] + # Select IFD, skipping any mask IFDs + ifd = select_overview_ifd(ifds, overview_level) bps = ifd.bits_per_sample if isinstance(bps, tuple): diff --git a/xrspatial/geotiff/tests/test_overview_filter.py b/xrspatial/geotiff/tests/test_overview_filter.py new file mode 100644 index 00000000..6f0220f9 --- /dev/null +++ b/xrspatial/geotiff/tests/test_overview_filter.py @@ -0,0 +1,200 @@ +"""Regression tests for issue #1504: overview_level must skip mask IFDs. + +GDAL COG variants can interleave NewSubfileType=4 (transparency mask) IFDs +with the overview pyramid. Indexing the raw IFD list by overview_level then +returns a 1-bit mask instead of a reduced-resolution overview. The reader +should filter out mask IFDs before resolving overview_level, and raise a +clear ValueError when the requested level is out of range. +""" +from __future__ import annotations + +import numpy as np +import pytest +import tifffile + +from xrspatial.geotiff import open_geotiff +from xrspatial.geotiff._header import ( + IFD, + parse_all_ifds, + parse_header, + select_overview_ifd, +) + + +def _write_tiff_with_mask(path, full_res, mask, overview): + """Write a 3-IFD TIFF: full-res, mask (subfiletype=4), overview. + + All IFDs are tiled so that the xrspatial reader exercises its tiled-COG + path. Tiles are 16x16 to keep the test files small. + """ + with tifffile.TiffWriter(str(path)) as tw: + # IFD 0: full resolution (subfiletype=0 implicit). + tw.write(full_res, tile=(16, 16), photometric='minisblack') + # IFD 1: transparency mask (subfiletype=4). + tw.write( + mask, + tile=(16, 16), + photometric='minisblack', + subfiletype=4, + ) + # IFD 2: reduced-resolution overview (subfiletype=1). + tw.write( + overview, + tile=(16, 16), + photometric='minisblack', + subfiletype=1, + ) + + +def _write_normal_cog(path, full_res, overviews): + """Write a typical COG: full-res then a chain of overviews (subfiletype=1).""" + with tifffile.TiffWriter(str(path)) as tw: + tw.write(full_res, tile=(16, 16), photometric='minisblack') + for ov in overviews: + tw.write( + ov, + tile=(16, 16), + photometric='minisblack', + subfiletype=1, + ) + + +# --------------------------------------------------------------------------- +# select_overview_ifd unit tests (operate on parsed IFD lists directly) +# --------------------------------------------------------------------------- + +class TestSelectOverviewIFD: + def _ifds_for(self, path): + with open(path, 'rb') as f: + data = f.read() + return parse_all_ifds(data, parse_header(data)) + + def test_skips_mask_ifd(self, tmp_path): + path = tmp_path / 'with_mask.tif' + full = (np.arange(64 * 64, dtype=np.uint16).reshape(64, 64)) + mask = np.zeros((64, 64), dtype=bool) + ov = (np.arange(32 * 32, dtype=np.uint16).reshape(32, 32)) + _write_tiff_with_mask(path, full, mask, ov) + + ifds = self._ifds_for(path) + assert len(ifds) == 3 + # Sanity: middle IFD really is the mask. + assert ifds[1].subfile_type & 4 == 4 + assert ifds[1].is_mask + + # Level 0 is full-res (NOT the mask). + sel0 = select_overview_ifd(ifds, 0) + assert sel0.width == 64 and sel0.height == 64 + assert not sel0.is_mask + + # Level 1 is the overview, jumping over the mask IFD. + sel1 = select_overview_ifd(ifds, 1) + assert sel1.width == 32 and sel1.height == 32 + assert not sel1.is_mask + + def test_none_returns_full_res(self, tmp_path): + path = tmp_path / 'plain.tif' + full = np.zeros((32, 32), dtype=np.uint8) + _write_normal_cog(path, full, []) + ifds = self._ifds_for(path) + assert select_overview_ifd(ifds, None).width == 32 + + def test_out_of_range_raises(self, tmp_path): + path = tmp_path / 'with_mask.tif' + full = np.zeros((64, 64), dtype=np.uint16) + mask = np.zeros((64, 64), dtype=bool) + ov = np.zeros((32, 32), dtype=np.uint16) + _write_tiff_with_mask(path, full, mask, ov) + ifds = self._ifds_for(path) + + with pytest.raises(ValueError) as excinfo: + select_overview_ifd(ifds, 99) + msg = str(excinfo.value) + assert 'overview_level=99' in msg + # Useful diagnostic: tells the user how many real IFDs there are. + assert '2 non-mask IFDs' in msg + assert 'mask' in msg.lower() + + def test_negative_raises(self, tmp_path): + path = tmp_path / 'plain.tif' + full = np.zeros((32, 32), dtype=np.uint8) + _write_normal_cog(path, full, []) + ifds = self._ifds_for(path) + with pytest.raises(ValueError, match='must be >= 0'): + select_overview_ifd(ifds, -1) + + def test_normal_cog_works(self, tmp_path): + path = tmp_path / 'normal_cog.tif' + full = np.full((128, 128), 42, dtype=np.uint16) + ovs = [ + np.full((64, 64), 43, dtype=np.uint16), + np.full((32, 32), 44, dtype=np.uint16), + np.full((16, 16), 45, dtype=np.uint16), + ] + _write_normal_cog(path, full, ovs) + ifds = self._ifds_for(path) + assert len(ifds) == 4 + + for level, expected_w in [(0, 128), (1, 64), (2, 32), (3, 16)]: + sel = select_overview_ifd(ifds, level) + assert sel.width == expected_w + + +# --------------------------------------------------------------------------- +# End-to-end: open_geotiff(overview_level=...) on a file with a mask IFD +# --------------------------------------------------------------------------- + +class TestOpenGeotiffSkipsMask: + def test_overview_level_1_returns_overview_not_mask(self, tmp_path): + path = tmp_path / 'gdal_style_cog.tif' + # Distinct fill values per IFD so the test cannot be fooled by shape. + full = np.full((64, 64), 100, dtype=np.uint16) + mask = np.zeros((64, 64), dtype=bool) + overview = np.full((32, 32), 200, dtype=np.uint16) + _write_tiff_with_mask(path, full, mask, overview) + + # Sanity: full-res still works. + da_full = open_geotiff(str(path), overview_level=0) + assert da_full.shape == (64, 64) + assert int(da_full.values[0, 0]) == 100 + assert da_full.dtype == np.uint16 + + # The bug: overview_level=1 used to land on the mask IFD. + da_ov = open_geotiff(str(path), overview_level=1) + assert da_ov.shape == (32, 32), ( + 'overview_level=1 returned wrong shape; likely picked the mask IFD') + assert int(da_ov.values[0, 0]) == 200 + assert da_ov.dtype == np.uint16 + + def test_out_of_range_raises_value_error(self, tmp_path): + path = tmp_path / 'gdal_style_cog.tif' + full = np.zeros((64, 64), dtype=np.uint16) + mask = np.zeros((64, 64), dtype=bool) + overview = np.zeros((32, 32), dtype=np.uint16) + _write_tiff_with_mask(path, full, mask, overview) + + with pytest.raises(ValueError) as excinfo: + open_geotiff(str(path), overview_level=99) + msg = str(excinfo.value) + assert 'overview_level=99' in msg + assert '2 non-mask IFDs' in msg + + def test_normal_cog_unchanged(self, tmp_path): + path = tmp_path / 'normal_cog.tif' + full = np.full((128, 128), 1, dtype=np.uint16) + ovs = [ + np.full((64, 64), 2, dtype=np.uint16), + np.full((32, 32), 3, dtype=np.uint16), + np.full((16, 16), 4, dtype=np.uint16), + ] + _write_normal_cog(path, full, ovs) + + for level, expected_shape, expected_val in [ + (0, (128, 128), 1), + (1, (64, 64), 2), + (2, (32, 32), 3), + (3, (16, 16), 4), + ]: + da = open_geotiff(str(path), overview_level=level) + assert da.shape == expected_shape + assert int(da.values[0, 0]) == expected_val From 077ed60da5597b3cdb8b0d275dcd944a1555be3b Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Fri, 8 May 2026 07:41:31 -0700 Subject: [PATCH 2/2] Address PR #1518 review: tighter pyramid filter, importorskip tifffile - ``select_overview_ifd`` previously kept any non-mask IFD, which let multi-page-document IFDs (NewSubfileType bit 1) through. Tighten the filter to keep only the full-res IFD (subfile_type=0) plus genuine overview IFDs (bit 0 set, bit 2 clear). Pages and any other future flag combinations are now excluded so ``overview_level`` indexes the pyramid only. Diagnostic text updated to "pyramid IFDs" / "non-pyramid IFD". - Test module had ``import tifffile`` at the top; switched to ``pytest.importorskip("tifffile")`` so the file doesn't fail at collection in environments without tifffile. - Removed unused ``IFD`` import from the test module. - Added two new test cases: page-IFD (subfile_type=2) is filtered, and overview-of-mask (subfile_type=5) is filtered (mask bit dominates). --- xrspatial/geotiff/_header.py | 44 ++++++++--- .../geotiff/tests/test_overview_filter.py | 75 +++++++++++++++++-- 2 files changed, 100 insertions(+), 19 deletions(-) diff --git a/xrspatial/geotiff/_header.py b/xrspatial/geotiff/_header.py index 44eeba96..42627807 100644 --- a/xrspatial/geotiff/_header.py +++ b/xrspatial/geotiff/_header.py @@ -445,15 +445,35 @@ def parse_ifd(data: bytes | memoryview, offset: int, return IFD(entries=entries, next_ifd_offset=next_ifd) +def _is_overview_or_full_res(ifd: IFD) -> bool: + """Return True if *ifd* is the full-resolution image or an overview. + + NewSubfileType (tag 254) is a bit field per TIFF 6.0: + + * bit 0 (value 1) -- reduced-resolution version of another image (overview) + * bit 1 (value 2) -- single page of a multi-page document + * bit 2 (value 4) -- transparency mask + + The full-resolution IFD has ``NewSubfileType=0``. We accept it plus + any IFD that is an overview *and* not a mask. Pages and any future + flag combinations get filtered out so ``overview_level`` indexes the + pyramid only. + """ + st = ifd.subfile_type + if st & 4: + return False # transparency mask (or overview-of-mask, st=5) + return st == 0 or (st & 1) != 0 + + def select_overview_ifd(ifds: list[IFD], overview_level: int | None) -> IFD: - """Pick the IFD for a requested overview level, skipping mask IFDs. + """Pick the IFD for a requested overview level, skipping non-pyramid IFDs. Some COG variants (notably GDAL with internal masks) interleave transparency-mask IFDs (NewSubfileType bit 2 set) with overview IFDs. - Indexing the raw IFD list by ``overview_level`` then returns a binary - mask instead of a reduced-resolution overview. This helper builds a - filtered list of full-resolution + overview IFDs (mask-bit clear) and - indexes into that. + Multi-page TIFFs additionally carry page IFDs (bit 1 set). Indexing the + raw IFD list by ``overview_level`` returns the wrong layer in either + case. This helper builds a filtered list of full-resolution and + overview IFDs only, and indexes into that. ``overview_level=0`` (or ``None``) returns the full-resolution IFD; ``overview_level=1`` returns the first overview, and so on. @@ -473,29 +493,29 @@ def select_overview_ifd(ifds: list[IFD], overview_level: int | None) -> IFD: ------ ValueError If ``ifds`` is empty, or if ``overview_level`` exceeds the number - of non-mask IFDs in the file. + of pyramid IFDs in the file. """ if not ifds: raise ValueError("No IFDs found in TIFF file") - filtered = [ifd for ifd in ifds if not ifd.is_mask] + filtered = [ifd for ifd in ifds if _is_overview_or_full_res(ifd)] if not filtered: raise ValueError( "TIFF file contains no full-resolution or overview IFDs " - "(all IFDs are transparency masks)") + "(every IFD is a mask, page, or other non-pyramid layer)") level = 0 if overview_level is None else overview_level if level < 0: raise ValueError(f"overview_level must be >= 0, got {level}") if level >= len(filtered): n_overviews = len(filtered) - 1 - n_masks = len(ifds) - len(filtered) + n_skipped = len(ifds) - len(filtered) raise ValueError( f"overview_level={level} is out of range: TIFF has " - f"{len(filtered)} non-mask IFDs (1 full-resolution + " + f"{len(filtered)} pyramid IFDs (1 full-resolution + " f"{n_overviews} overview{'s' if n_overviews != 1 else ''}" - f"{f', plus {n_masks} mask IFD' if n_masks else ''}" - f"{'s' if n_masks > 1 else ''}). Valid overview_level values " + f"{f', plus {n_skipped} non-pyramid IFD' if n_skipped else ''}" + f"{'s' if n_skipped > 1 else ''}). Valid overview_level values " f"are 0..{len(filtered) - 1}.") return filtered[level] diff --git a/xrspatial/geotiff/tests/test_overview_filter.py b/xrspatial/geotiff/tests/test_overview_filter.py index 6f0220f9..f015762b 100644 --- a/xrspatial/geotiff/tests/test_overview_filter.py +++ b/xrspatial/geotiff/tests/test_overview_filter.py @@ -10,11 +10,11 @@ import numpy as np import pytest -import tifffile -from xrspatial.geotiff import open_geotiff -from xrspatial.geotiff._header import ( - IFD, +tifffile = pytest.importorskip("tifffile") + +from xrspatial.geotiff import open_geotiff # noqa: E402 +from xrspatial.geotiff._header import ( # noqa: E402 parse_all_ifds, parse_header, select_overview_ifd, @@ -112,8 +112,8 @@ def test_out_of_range_raises(self, tmp_path): msg = str(excinfo.value) assert 'overview_level=99' in msg # Useful diagnostic: tells the user how many real IFDs there are. - assert '2 non-mask IFDs' in msg - assert 'mask' in msg.lower() + assert '2 pyramid IFDs' in msg + assert 'non-pyramid' in msg.lower() or 'mask' in msg.lower() def test_negative_raises(self, tmp_path): path = tmp_path / 'plain.tif' @@ -123,6 +123,67 @@ def test_negative_raises(self, tmp_path): with pytest.raises(ValueError, match='must be >= 0'): select_overview_ifd(ifds, -1) + def test_skips_page_ifd(self, tmp_path): + """NewSubfileType bit 1 (multi-page document page) is also filtered. + + Even though geotiff usage rarely sets bit 1, the spec lets it + coexist with overviews. ``overview_level`` should index the + pyramid only and ignore page IFDs the same way it ignores masks. + """ + path = tmp_path / 'with_page.tif' + full = np.arange(64 * 64, dtype=np.uint16).reshape(64, 64) + page = np.zeros((64, 64), dtype=np.uint16) + ov = np.arange(32 * 32, dtype=np.uint16).reshape(32, 32) + + with tifffile.TiffWriter(str(path)) as tw: + tw.write(full, tile=(16, 16), photometric='minisblack') + # subfiletype=2 -> bit 1 set, page-of-multi-page-doc. + tw.write(page, tile=(16, 16), photometric='minisblack', + subfiletype=2) + tw.write(ov, tile=(16, 16), photometric='minisblack', + subfiletype=1) + + ifds = self._ifds_for(path) + assert len(ifds) == 3 + assert ifds[1].subfile_type == 2 # page + + sel0 = select_overview_ifd(ifds, 0) + assert sel0.width == 64 and sel0.height == 64 + sel1 = select_overview_ifd(ifds, 1) + # Must skip the page IFD and land on the 32x32 overview. + assert sel1.width == 32 and sel1.height == 32 + + def test_skips_overview_of_mask(self, tmp_path): + """An overview-of-mask IFD (subfile_type=5: bits 0+2) is excluded. + + The presence of the mask bit dominates -- this is a mask, even if + it happens to be a reduced-resolution one. + """ + path = tmp_path / 'with_overview_mask.tif' + full = np.arange(64 * 64, dtype=np.uint16).reshape(64, 64) + ov = np.arange(32 * 32, dtype=np.uint16).reshape(32, 32) + ov_mask = np.zeros((32, 32), dtype=bool) + + with tifffile.TiffWriter(str(path)) as tw: + tw.write(full, tile=(16, 16), photometric='minisblack') + tw.write(ov, tile=(16, 16), photometric='minisblack', + subfiletype=1) + # subfiletype=5 -> bits 0+2: reduced-resolution mask. + tw.write(ov_mask, tile=(16, 16), photometric='minisblack', + subfiletype=5) + + ifds = self._ifds_for(path) + assert ifds[2].subfile_type == 5 + assert ifds[2].is_mask # mask-bit dominates + + sel1 = select_overview_ifd(ifds, 1) + assert sel1.width == 32 # the real overview, not the masked one + assert not sel1.is_mask + + # No level 2 -- only 2 pyramid IFDs. + with pytest.raises(ValueError, match='2 pyramid IFDs'): + select_overview_ifd(ifds, 2) + def test_normal_cog_works(self, tmp_path): path = tmp_path / 'normal_cog.tif' full = np.full((128, 128), 42, dtype=np.uint16) @@ -177,7 +238,7 @@ def test_out_of_range_raises_value_error(self, tmp_path): open_geotiff(str(path), overview_level=99) msg = str(excinfo.value) assert 'overview_level=99' in msg - assert '2 non-mask IFDs' in msg + assert '2 pyramid IFDs' in msg def test_normal_cog_unchanged(self, tmp_path): path = tmp_path / 'normal_cog.tif'