From 95e70fa628fbac67aacb2bef716a95b60661276e Mon Sep 17 00:00:00 2001 From: Jiaqi-Lv <60471431+Jiaqi-Lv@users.noreply.github.com> Date: Mon, 3 Mar 2025 12:39:39 +0000 Subject: [PATCH 1/3] :bug: Fix `MapDe` `dist_filter` Shape (#914) - Fix `dist_filter` in `MapDe` model for multi-class output. Explanation: Previously, if we set `num_class` to more than 1, the model would still output 1 channel. This was because the `dist_filter` always had size of 1 in its first dimension, however the first dimension determines the number of output channels in the tensor produced by `torch.functional.F.conv2d`. This PR changes this by repeating the filters the match the number of output classes. --- tests/models/test_arch_mapde.py | 9 +++++++++ tiatoolbox/models/architecture/mapde.py | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/models/test_arch_mapde.py b/tests/models/test_arch_mapde.py index 2a65583c6..4ec404826 100644 --- a/tests/models/test_arch_mapde.py +++ b/tests/models/test_arch_mapde.py @@ -48,3 +48,12 @@ def test_functionality(remote_sample: Callable) -> None: output = model.infer_batch(model, batch, device=select_device(on_gpu=ON_GPU)) output = model.postproc(output[0]) assert np.all(output[0:2] == [[19, 171], [53, 89]]) + + +def test_multiclass_output() -> None: + """Test the architecture for multi-class output.""" + multiclass_model = MapDe(num_input_channels=3, num_classes=3) + test_input = torch.rand((1, 3, 252, 252)) + + output = multiclass_model(test_input) + assert output.shape == (1, 3, 252, 252) diff --git a/tiatoolbox/models/architecture/mapde.py b/tiatoolbox/models/architecture/mapde.py index bbb468bb8..0900aa6fd 100644 --- a/tiatoolbox/models/architecture/mapde.py +++ b/tiatoolbox/models/architecture/mapde.py @@ -199,8 +199,11 @@ def __init__( dtype=np.float32, ) - dist_filter = np.expand_dims(dist_filter, axis=(0, 1)) # NCHW + # For conv2d, filter shape = (out_channels, in_channels//groups, H, W) + dist_filter = np.expand_dims(dist_filter, axis=(0, 1)) dist_filter = np.repeat(dist_filter, repeats=num_classes * 2, axis=1) + # Need to repeat for out_channels + dist_filter = np.repeat(dist_filter, repeats=num_classes, axis=0) self.min_distance = min_distance self.threshold_abs = threshold_abs From 2416ba913761cf7ab690af56b9753eddfb4424fc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Mar 2025 11:31:09 +0000 Subject: [PATCH 2/3] :technologist: pre-commit autoupdate (#916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :technologist: pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.9.4 → v0.9.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.4...v0.9.9) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * :hammer: Update `ruff` version * :hammer: Update noqa for Unused static method argument --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> --- .github/workflows/python-package.yml | 2 +- .pre-commit-config.yaml | 2 +- requirements/requirements_dev.txt | 2 +- tiatoolbox/annotation/storage.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index afccd6512..a6bf3b6c1 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -30,7 +30,7 @@ jobs: sudo apt update sudo apt-get install -y libopenslide-dev openslide-tools libopenjp2-7 libopenjp2-tools python -m pip install --upgrade pip - python -m pip install ruff==0.9.4 pytest pytest-cov pytest-runner + python -m pip install ruff==0.9.9 pytest pytest-cov pytest-runner pip install -r requirements/requirements.txt - name: Cache tiatoolbox static assets uses: actions/cache@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 687d3fef9..7691af305 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,7 +60,7 @@ repos: - id: rst-inline-touching-normal # Detect mistake of inline code touching normal text in rst. - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.9.4 + rev: v0.9.9 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/requirements/requirements_dev.txt b/requirements/requirements_dev.txt index 5396121a4..2e89d1c26 100644 --- a/requirements/requirements_dev.txt +++ b/requirements/requirements_dev.txt @@ -10,7 +10,7 @@ pytest>=7.2.0 pytest-cov>=4.0.0 pytest-runner>=6.0 pytest-xdist[psutil] -ruff==0.9.4 # This will be updated by pre-commit bot to latest version +ruff==0.9.9 # This will be updated by pre-commit bot to latest version toml>=0.10.2 twine>=4.0.1 wheel>=0.37.1 diff --git a/tiatoolbox/annotation/storage.py b/tiatoolbox/annotation/storage.py index 5834e2640..c129842e1 100644 --- a/tiatoolbox/annotation/storage.py +++ b/tiatoolbox/annotation/storage.py @@ -442,8 +442,8 @@ class AnnotationStore(ABC, MutableMapping[str, Annotation]): def __new__( cls: type[StoreInstanceType], - *args: str, # noqa: ARG003 - **kwargs: int, # noqa: ARG003 + *args: str, # noqa: ARG004 + **kwargs: int, # noqa: ARG004 ) -> StoreInstanceType: """Return an instance of a subclass of AnnotationStore.""" if cls is AnnotationStore: From 5a90a269948c44ff065fa43445f008d5fcbf676f Mon Sep 17 00:00:00 2001 From: Aleksandar Acic <32873451+aacic@users.noreply.github.com> Date: Fri, 7 Mar 2025 03:16:14 -0600 Subject: [PATCH 3/3] Add FsspecJsonWSIReader class. (#897) The `FsspecJsonWSIReader` reads fsspec json file which represents SVS or TIFF whole slide image. The images are accessible by HTTP range requests, eg: `https://api.gdc.cancer.gov/data/73c69d24-6f9e-44e2-bfe5-a608d4cf5c27` The whole image can be downloaded like: `curl -C - -o TCGA-22-1017-01Z-00-DX1.9562FE79-A261-42D3-B394-F3E0E2FF7DDA.svs https://api.gdc.cancer.gov/data/73c69d24-6f9e-44e2-bfe5-a608d4cf5c27` The `FsspecJsonWSIReader` class has a `_zarr_store` field which is created by reading json file using `fsspec`: ``` mapper = fsspec.get_mapper( "reference://", fo=str(input_img), target_protocol="file" ) self._zarr_array = zarr.open(mapper, mode="r") self._zarr_store = self._zarr_array.store self._zarr_lru_cache = zarr.LRUStoreCache(self._zarr_store, max_size=cache_size) self._zarr_group = zarr.open(self._zarr_lru_cache) ``` This is equivalent to `TIFFWSIReader` code: ``` self._zarr_store = tifffile.imread( self.input_path, series=self.series_n, aszarr=True, ) self._zarr_lru_cache = zarr.LRUStoreCache(self._zarr_store, max_size=cache_size) self._zarr_group = zarr.open(self._zarr_lru_cache) ``` Both FsspecJsonWSIReader and TIFFWSIReader forward calls to `read_bounds` and `read_rect` methods of the`TIFFWSIReaderDelegate` delegate instance. The method `_info` of the`TIFFWSIReaderDelegate` reads SVS metadata which is stored in the root group metadata like: ``` { ".zattrs": { "multiscales": [ { "metadata": { "objective_power": 40, "vendor": "Aperio", "mpp": [0.2525, 0.2525] } } ] } } ``` To test, execute from the root dir: ``` pip install -r requirements/requirements_dev.txt mkdir -p samples/slides mkdir -p samples/fsspec cd samples/slides curl -C - -o TCGA-22-1017-01Z-00-DX1.9562FE79-A261-42D3-B394-F3E0E2FF7DDA.svs https://api.gdc.cancer.gov/data/73c69d24-6f9e-44e2-bfe5-a608d4cf5c27 cd ../../ cp tiatoolbox/utils/tiff_to_fsspec.py . python tiff_to_fsspec.py "samples/slides/TCGA-22-1017-01Z-00-DX1.9562FE79-A261-42D3-B394-F3E0E2FF7DDA.svs" "samples/fsspec/73c69d24-6f9e-44e2-bfe5-a608d4cf5c27_fsspec.json" "https://api.gdc.cancer.gov/data/73c69d24-6f9e-44e2-bfe5-a608d4cf5c27" ``` Create `tileserver.py` inside of the project root: ``` from flask_cors import CORS from tiatoolbox.visualization import TileServer from tiatoolbox.wsicore.wsireader import FsspecJsonWSIReader wsi = FsspecJsonWSIReader.open( "./samples/fsspec/73c69d24-6f9e-44e2-bfe5-a608d4cf5c27_fsspec.json" ) # Initialize and run the TileServer tile_server = TileServer( title="Tiatoolbox TileServer", layers={"layer": wsi}, ) CORS(tile_server, send_wildcard=True) tile_server.run(host="127.0.0.1", port=5000) ``` Open `http://127.0.0.1:5000/` and verify that it works. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> --- requirements/requirements.txt | 1 + tests/test_wsireader.py | 173 +++++++- tiatoolbox/utils/tiff_to_fsspec.py | 110 +++++ tiatoolbox/wsicore/wsireader.py | 668 +++++++++++++++++++++-------- 4 files changed, 772 insertions(+), 180 deletions(-) create mode 100644 tiatoolbox/utils/tiff_to_fsspec.py diff --git a/requirements/requirements.txt b/requirements/requirements.txt index a803eb698..c29a54620 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,5 +1,6 @@ # torch installation --extra-index-url https://download.pytorch.org/whl/cu118; sys_platform != "darwin" +aiohttp>=3.8.1 albumentations>=1.3.0 bokeh>=3.1.1, <3.6.0 Click>=8.1.3 diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index e156f8990..2b4411c4f 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -9,9 +9,8 @@ import shutil from copy import deepcopy from pathlib import Path - -# When no longer supporting Python <3.9 this should be collections.abc.Iterable from typing import TYPE_CHECKING, Callable +from unittest.mock import patch import cv2 import glymur @@ -27,7 +26,7 @@ from tiatoolbox import cli, utils from tiatoolbox.annotation import SQLiteStore -from tiatoolbox.utils import imread +from tiatoolbox.utils import imread, tiff_to_fsspec from tiatoolbox.utils.exceptions import FileNotSupportedError from tiatoolbox.utils.magic import is_sqlite3 from tiatoolbox.utils.transforms import imresize, locsize2bounds @@ -37,6 +36,7 @@ AnnotationStoreReader, ArrayView, DICOMWSIReader, + FsspecJsonWSIReader, JP2WSIReader, NGFFWSIReader, OpenSlideWSIReader, @@ -221,6 +221,43 @@ def read_bounds_level_consistency(wsi: WSIReader, bounds: IntBounds) -> None: # Utility Test Classes & Functions # ------------------------------------------------------------------------------------- +_FSSPEC_WSI_CACHE = {} + + +def fsspec_wsi(sample_svs: Path, tmp_path: Path) -> FsspecJsonWSIReader: + """Returns cached FsspecJsonWSIReader instance. + + The reader instance opens CMU-1-Small-Region.svs image. + + It's cached so the reader can be reused, + + since loading the whole image using HTTP range requests from: + + https://tiatoolbox.dcs.warwick.ac.uk/sample_wsis/CMU-1-Small-Region.svs + + takes about 20 seconds. + + """ + cache_key = "sample_svs" + + if cache_key in _FSSPEC_WSI_CACHE: + return _FSSPEC_WSI_CACHE[cache_key] # Return cached instance + + file_types = ("*.svs",) + files_all = utils.misc.grab_files_from_dir( + input_path=Path(sample_svs).parent, + file_types=file_types, + ) + svs_file_path = str(files_all[0]) + json_file_path = str(tmp_path / "fsspec.json") + final_url = ( + "https://tiatoolbox.dcs.warwick.ac.uk/sample_wsis/CMU-1-Small-Region.svs" + ) + tiff_to_fsspec.main(svs_file_path, json_file_path, final_url) + + _FSSPEC_WSI_CACHE[cache_key] = wsireader.FsspecJsonWSIReader(json_file_path) + return _FSSPEC_WSI_CACHE[cache_key] + class DummyMutableOpenSlideObject: """Dummy OpenSlide object with mutable properties.""" @@ -2812,3 +2849,133 @@ def test_read_multi_channel(source_image: Path) -> None: assert region.shape == (100, 50, (new_img_array.shape[-1])) assert np.abs(np.median(region.astype(int) - target.astype(int))) == 0 assert np.abs(np.mean(region.astype(int) - target.astype(int))) < 0.2 + + +def test_fsspec_json_wsi_reader_instantiation() -> None: + """Test if FsspecJsonWSIReader is instantiated. + + In case json is passed to WSIReader.open, FsspecJsonWSIReader + should be instantiated. + """ + input_path = "mock_path.json" + mpp = None + power = None + + with ( + patch( + "tiatoolbox.wsicore.wsireader.FsspecJsonWSIReader.is_valid_zarr_fsspec", + return_value=True, + ), + patch("tiatoolbox.wsicore.wsireader.FsspecJsonWSIReader") as mock_reader, + ): + WSIReader.open(input_path, mpp, power) + mock_reader.assert_called_once_with(input_path, mpp=mpp, power=power) + + +def test_generate_fsspec_json_file_and_validate( + sample_svs: Path, tmp_path: Path +) -> None: + """Test generate fsspec json file and validate it.""" + file_types = ("*.svs",) + + files_all = utils.misc.grab_files_from_dir( + input_path=Path(sample_svs).parent, + file_types=file_types, + ) + + svs_file_path = str(files_all[0]) + json_file_path = str(tmp_path / "fsspec.json") + final_url = "https://example.com/some_id" + + tiff_to_fsspec.main(svs_file_path, json_file_path, final_url) + + assert Path(json_file_path).exists(), "Output JSON file was not created." + + assert FsspecJsonWSIReader.is_valid_zarr_fsspec(json_file_path), ( + "FSSPEC JSON file is invalid." + ) + + +def test_fsspec_wsireader_info_read(sample_svs: Path, tmp_path: Path) -> None: + """Test info read of the FsspecJsonWSIReader. + + Generate fsspec json file and load image from: + + https://tiatoolbox.dcs.warwick.ac.uk/sample_wsis/CMU-1-Small-Region.svs + + """ + wsi = fsspec_wsi(sample_svs, tmp_path) + info = wsi.info + + assert info is not None, "info should not be None." + + +def test_read_bounds_fsspec_reader_baseline(sample_svs: Path, tmp_path: Path) -> None: + """Test FsspecJsonWSIReader read bounds at baseline. + + Location coordinate is in baseline (level 0) reference frame. + + """ + wsi = fsspec_wsi(sample_svs, tmp_path) + + bounds = SVS_TEST_TISSUE_BOUNDS + size = SVS_TEST_TISSUE_SIZE + im_region = wsi.read_bounds(bounds, resolution=0, units="level") + + assert isinstance(im_region, np.ndarray) + assert im_region.dtype == "uint8" + assert im_region.shape == (*size[::-1], 3) + + +def test_read_rect_fsspec_reader_baseline(sample_svs: Path, tmp_path: Path) -> None: + """Test FsspecJsonWSIReader read rect at baseline. + + Location coordinate is in baseline (level 0) reference frame. + + """ + wsi = fsspec_wsi(sample_svs, tmp_path) + + location = SVS_TEST_TISSUE_LOCATION + size = SVS_TEST_TISSUE_SIZE + im_region = wsi.read_rect(location, size, resolution=0, units="level") + + assert isinstance(im_region, np.ndarray) + assert im_region.dtype == "uint8" + assert im_region.shape == (*size[::-1], 3) + + +def test_fsspec_reader_open_invalid_json_file(tmp_path: Path) -> None: + """Ensure JSONDecodeError is handled properly. + + Pass invalid JSON to FsspecJsonWSIReader.is_valid_zarr_fsspec. + """ + json_path = tmp_path / "invalid.json" + json_path.write_text("{invalid json}") # Corrupt JSON + + assert not FsspecJsonWSIReader.is_valid_zarr_fsspec(str(json_path)) + + +def test_fsspec_reader_open_oserror_handling() -> None: + """Ensure OSError is handled properly. + + Pass non existent JSON to FsspecJsonWSIReader.is_valid_zarr_fsspec. + + """ + with patch("builtins.open", side_effect=OSError("File not found")): + result = FsspecJsonWSIReader.is_valid_zarr_fsspec("non_existent.json") + + assert result is False, "Function should return False for OSError" + + +def test_fsspec_reader_open_pass_empty_json(tmp_path: Path) -> None: + """Ensure empty JSON is handled properly. + + Pass empty JSON to FsspecJsonWSIReader.is_valid_zarr_fsspec and + + verify that it's not valid. + + """ + json_path = tmp_path / "empty.json" + json_path.write_text("{}") + + assert not FsspecJsonWSIReader.is_valid_zarr_fsspec(str(json_path)) diff --git a/tiatoolbox/utils/tiff_to_fsspec.py b/tiatoolbox/utils/tiff_to_fsspec.py new file mode 100644 index 000000000..fdd9c11ba --- /dev/null +++ b/tiatoolbox/utils/tiff_to_fsspec.py @@ -0,0 +1,110 @@ +"""Module for processing SVS metadata and generating fsspec zarr JSON file. + +The fsspec zarr json file is meant to be used in case SVS or TIFF files +can be accessed using byte range HTTP API. + +The fsspec zarr json file can be opened using FsspecJsonWSIReader. + +""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +from tifffile import TiffFile, tiff2fsspec + +from tiatoolbox.wsicore.wsireader import TIFFWSIReaderDelegate + +# Constants +EXPECTED_KEY_VALUE_PAIRS = 2 +EXPECTED_ARG_COUNT = 4 +URL_PLACEHOLDER = "https://replace.me/" + + +def convert_metadata(metadata: dict) -> dict: + """Convert metadata to JSON-compatible format.""" + if isinstance(metadata, dict): + return {key: convert_metadata(value) for key, value in metadata.items()} + if isinstance(metadata, list): + return [convert_metadata(item) for item in metadata] + if isinstance(metadata, datetime): + return metadata.isoformat() # Convert datetime to ISO 8601 string + return metadata + + +def replace_url( + data: dict[str, Any], output_path: Path, old_url: str, new_url: str +) -> None: + """Replace URL in the JSON file.""" + for value in data.values(): + if isinstance(value, list) and value[0] == old_url: + value[0] = new_url + + with output_path.open("w") as json_file: + json.dump(data, json_file, indent=2) + + +def main(svs_file_path: str, json_file_path: str, final_url: str) -> None: + """Main function to process an SVS file. + + Args: + svs_file_path (str): The local file path of the SVS file to be processed. + json_file_path (str): The file path where the output JSON will be saved. + final_url (str): The URL where the SVS file is stored online + and can be accessed via HTTP byte range API. + + Example: + main('/path/to/CMU-1-Small-Region.svs', '/path/to/CMU-1-Small-Region.json', 'https://tiatoolbox.dcs.warwick.ac.uk/sample_wsis/CMU-1-Small-Region.svs') + + """ + url_to_replace = f"{URL_PLACEHOLDER}{Path(svs_file_path).name}" + + tiff = TiffFile(svs_file_path) + + tiff_file_pages = tiff.pages + + # Generate fsspec JSON + tiff2fsspec(svs_file_path, url=URL_PLACEHOLDER, out=json_file_path) + + if tiff.is_svs: + metadata = TIFFWSIReaderDelegate.parse_svs_metadata(tiff_file_pages) + else: # pragma: no cover + metadata = TIFFWSIReaderDelegate.parse_generic_tiff_metadata(tiff_file_pages) + + # Convert metadata to JSON-compatible format + metadata_serializable = convert_metadata(metadata) + + # Read the JSON data from the file + json_path = Path(json_file_path) + with json_path.open() as file: + json_data = json.load(file) + + # Decode `.zattrs` JSON string into a dictionary + zattrs = json.loads(json_data[".zattrs"]) + + # Ensure "multiscales" exists and is a list + if "multiscales" not in zattrs or not isinstance( + zattrs["multiscales"], list + ): # pragma: no cover + zattrs["multiscales"] = [{}] # Initialize as a list with an empty dictionary + + # Update metadata into `.zattrs` + zattrs["multiscales"][0]["metadata"] = metadata_serializable + + # Convert back to a JSON string + json_data[".zattrs"] = json.dumps(zattrs) + + # Replace URLs in the JSON file + replace_url(json_data, json_path, url_to_replace, final_url) + + +if __name__ == "__main__": + if len(sys.argv) != EXPECTED_ARG_COUNT: + msg = " Usage: python script.py " + raise ValueError(msg) + + main(sys.argv[1], sys.argv[2], sys.argv[3]) diff --git a/tiatoolbox/wsicore/wsireader.py b/tiatoolbox/wsicore/wsireader.py index efcaaa710..4557b7226 100644 --- a/tiatoolbox/wsicore/wsireader.py +++ b/tiatoolbox/wsicore/wsireader.py @@ -12,14 +12,18 @@ from pathlib import Path from typing import TYPE_CHECKING +import fsspec import numpy as np import openslide import pandas as pd import tifffile import zarr from defusedxml import ElementTree +from imagecodecs.numcodecs import Delta, Jpeg, Jpeg2k, Lzw +from numcodecs import register_codec from packaging.version import Version from PIL import Image +from tifffile import TiffPages from tiatoolbox import logger, utils from tiatoolbox.annotation import AnnotationStore, SQLiteStore @@ -375,6 +379,9 @@ def open( # noqa: PLR0911 _, _, suffixes = utils.misc.split_path_name_ext(input_path) last_suffix = suffixes[-1] + if FsspecJsonWSIReader.is_valid_zarr_fsspec(input_img): + return FsspecJsonWSIReader(input_img, mpp=mpp, power=power) + if last_suffix == ".db": return AnnotationStoreReader(input_path, **kwargs) @@ -437,6 +444,7 @@ def verify_supported_wsi(input_path: Path) -> None: ".jpeg", ".zarr", ".db", + ".json", ]: msg = f"File {input_path} is not a supported file format." raise FileNotSupportedError( @@ -750,7 +758,7 @@ def _find_read_params_at_resolution( output = tuple(np.ceil(v).astype(np.int64) for v in output) return (read_level, read_level_to_resolution_scale_factor, *output) - def _bounds_at_resolution_to_baseline( + def bounds_at_resolution_to_baseline( self: WSIReader, bounds: Bounds, resolution: Resolution, @@ -819,7 +827,7 @@ def slide_dimensions( _, wsi_shape_at_resolution, _, - ) = self._find_read_bounds_params( + ) = self.find_read_bounds_params( [0, 0, *list(wsi_shape_at_baseline)], resolution, units, @@ -827,7 +835,7 @@ def slide_dimensions( ) return wsi_shape_at_resolution - def _find_read_bounds_params( + def find_read_bounds_params( self: WSIReader, bounds: Bounds, resolution: Resolution, @@ -1103,7 +1111,7 @@ def _find_tile_params( return level, slide_dimension, rescale, tile_objective_value - def _read_rect_at_resolution( + def read_rect_at_resolution( self: WSIReader, location: NumPair, size: NumPair, @@ -1114,7 +1122,7 @@ def _read_rect_at_resolution( pad_constant_values: Number | Iterable[NumPair] = 0, **kwargs: dict, ) -> np.ndarray: - """Internal helper to perform `read_rect` at resolution. + """Helper to perform `read_rect` at resolution. In actuality, `read_rect` at resolution is synonymous with calling `read_bound` at resolution because `size` has always @@ -1930,7 +1938,7 @@ def read_rect( """ if coord_space == "resolution": - return self._read_rect_at_resolution( + return self.read_rect_at_resolution( location, size, resolution=resolution, @@ -2093,7 +2101,7 @@ class docstrings for more information. # convert from requested to `baseline` bounds_at_baseline = bounds if coord_space == "resolution": - bounds_at_baseline = self._bounds_at_resolution_to_baseline( + bounds_at_baseline = self.bounds_at_resolution_to_baseline( bounds, resolution, units, @@ -2108,7 +2116,7 @@ class docstrings for more information. bounds_at_read_level, _, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -2120,7 +2128,7 @@ class docstrings for more information. bounds_at_read_level, size_at_requested, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -2229,7 +2237,7 @@ def _info(self: OpenSlideWSIReader) -> WSIMeta: # Fallback to calculating objective power from mpp if objective_power is None: - if mpp is not None: + if mpp is not None: # pragma: no cover objective_power = utils.misc.mpp2common_objective_power( float(np.mean(mpp)), ) @@ -2471,7 +2479,7 @@ def read_rect( """ if coord_space == "resolution": - return self._read_rect_at_resolution( + return self.read_rect_at_resolution( location, size, resolution=resolution, @@ -2631,7 +2639,7 @@ class docstrings for more information. """ bounds_at_baseline = bounds if coord_space == "resolution": - bounds_at_baseline = self._bounds_at_resolution_to_baseline( + bounds_at_baseline = self.bounds_at_resolution_to_baseline( bounds, resolution, units, @@ -2646,7 +2654,7 @@ class docstrings for more information. _, _, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -2658,7 +2666,7 @@ class docstrings for more information. _, # bounds_at_read_level, size_at_requested, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -3177,7 +3185,7 @@ def read_rect( """ if coord_space == "resolution": - return self._read_rect_at_resolution( + return self.read_rect_at_resolution( location, size, resolution=resolution, @@ -3341,7 +3349,7 @@ class docstrings for more information. # convert from requested to `baseline` bounds_at_baseline = bounds if coord_space == "resolution": - bounds_at_baseline = self._bounds_at_resolution_to_baseline( + bounds_at_baseline = self.bounds_at_resolution_to_baseline( bounds, resolution, units, @@ -3352,14 +3360,14 @@ class docstrings for more information. # because the rounding error at `bounds_at_baseline` leads to # different `size_at_requested` (keeping same read resolution # but base image is of different scale) - _, _, _, post_read_scale = self._find_read_bounds_params( + _, _, _, post_read_scale = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, ) else: # * Find parameters for optimal read - _, _, size_at_requested, post_read_scale = self._find_read_bounds_params( + _, _, size_at_requested, post_read_scale = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -3501,7 +3509,9 @@ def __init__( def page_area(page: tifffile.TiffPage) -> float: """Calculate the area of a page.""" - return np.prod(self._canonical_shape(page.shape)[:2]) + return np.prod( + TIFFWSIReaderDelegate.canonical_shape(self._axes, page.shape)[:2] + ) series_areas = [page_area(s.pages[0]) for s in all_series] # skipcq self.series_n = np.argmax(series_areas) @@ -3513,7 +3523,7 @@ def page_area(page: tifffile.TiffPage) -> float: ) self._zarr_lru_cache = zarr.LRUStoreCache(self._zarr_store, max_size=cache_size) self._zarr_group = zarr.open(self._zarr_lru_cache) - if not isinstance(self._zarr_group, zarr.hierarchy.Group): + if not isinstance(self._zarr_group, zarr.hierarchy.Group): # pragma: no cover group = zarr.hierarchy.group() group[0] = self._zarr_group self._zarr_group = group @@ -3525,110 +3535,14 @@ def page_area(page: tifffile.TiffPage) -> float: self.level_arrays = dict( sorted( self.level_arrays.items(), - key=lambda x: -np.prod(self._canonical_shape(x[1].array.shape[:2])), + key=lambda x: -np.prod( + TIFFWSIReaderDelegate.canonical_shape( + self._axes, x[1].array.shape[:2] + ) + ), ) ) - - def _canonical_shape(self: TIFFWSIReader, shape: IntPair) -> tuple: - """Make a level shape tuple in YXS order. - - Args: - shape (IntPair): - Input shape tuple. - - Returns: - tuple: - Shape in YXS order. - - """ - if self._axes == "YXS": - return shape - if self._axes == "SYX": - return np.roll(shape, -1) - msg = f"Unsupported axes `{self._axes}`." - raise ValueError(msg) - - def _parse_svs_metadata(self: TIFFWSIReader) -> dict: - """Extract SVS specific metadata. - - Returns: - dict: - Dictionary of kwargs for WSIMeta. - - """ - raw = {} - mpp = None - objective_power = None - vendor = "Aperio" - - description = self.tiff.pages[0].description - raw["Description"] = description - parts = description.split("|") - description_headers, key_value_pairs = parts[0], parts[1:] - description_headers = description_headers.split(";") - - software, photometric_info = description_headers[0].splitlines() - raw["Software"] = software - raw["Photometric Info"] = photometric_info - - def parse_svs_tag(string: str) -> tuple[str, Number | str]: - """Parse SVS key-value string. - - Infers type(s) of data by trial and error with a fallback to - the original string type. - - Args: - string (str): - Key-value string in SVS format: "key=value". - - Returns: - tuple: - Key-value pair. - - """ - pair = string.split("=") - if len(pair) != 2: # noqa: PLR2004 - msg = "Invalid metadata. Expected string of the format 'key=value'." - raise ValueError( - msg, - ) - key, value_string = pair - key = key.strip() - value_string = value_string.strip() - - def us_date(string: str) -> datetime: - """Return datetime parsed according to US date format.""" - return datetime.strptime(string, r"%m/%d/%y").astimezone() - - def time(string: str) -> datetime: - """Return datetime parsed according to HMS format.""" - return datetime.strptime(string, r"%H:%M:%S").astimezone() - - casting_precedence = [us_date, time, int, float] - value = value_string - for cast in casting_precedence: - try: - value = cast(value_string) - except ValueError: # noqa: PERF203 - continue - else: - return key, value - - return key, value - - svs_tags = dict(parse_svs_tag(string) for string in key_value_pairs) - raw["SVS Tags"] = svs_tags - mpp = svs_tags.get("MPP") - if mpp is not None: - mpp = [mpp] * 2 - objective_power = svs_tags.get("AppMag") - - return { - "objective_power": objective_power, - "vendor": vendor, - "mpp": mpp, - "raw": raw, - } + self.tiff_reader_delegate = TIFFWSIReaderDelegate(self, self.level_arrays) def _get_ome_xml(self: TIFFWSIReader) -> ElementTree.Element: """Parse OME-XML from the description of the first IFD (page). @@ -3741,40 +3655,6 @@ def _get_ome_mpp( return None - def _parse_generic_tiff_metadata(self: TIFFWSIReader) -> dict: - """Extract generic tiled metadata. - - Returns: - dict: Dictionary of kwargs for WSIMeta. - - """ - mpp = None - objective_power = None - vendor = "Generic" - - description = self.tiff.pages[0].description - raw = {"Description": description} - # Check for MPP in the tiff resolution tags - # res_units: 1 = undefined, 2 = inch, 3 = centimeter - res_units = self.tiff.pages[0].tags.get("ResolutionUnit") - res_x = self.tiff.pages[0].tags.get("XResolution") - res_y = self.tiff.pages[0].tags.get("YResolution") - if ( - all(x is not None for x in [res_units, res_x, res_y]) - and res_units.value != 1 - ): - mpp = [ - utils.misc.ppu2mpp(res_x.value[0] / res_x.value[1], res_units.value), - utils.misc.ppu2mpp(res_y.value[0] / res_y.value[1], res_units.value), - ] - - return { - "objective_power": objective_power, - "vendor": vendor, - "mpp": mpp, - "raw": raw, - } - def _info(self: TIFFWSIReader) -> WSIMeta: """TIFF metadata constructor. @@ -3785,7 +3665,11 @@ def _info(self: TIFFWSIReader) -> WSIMeta: """ level_count = len(self.level_arrays) level_dimensions = [ - np.array(self._canonical_shape(p.array.shape)[:2][::-1]) + np.array( + TIFFWSIReaderDelegate.canonical_shape(self._axes, p.array.shape)[:2][ + ::-1 + ] + ) for p in self.level_arrays.values() ] slide_dimensions = level_dimensions[0] @@ -3805,11 +3689,13 @@ def _info(self: TIFFWSIReader) -> WSIMeta: } if self.tiff.is_svs: - filetype_params = self._parse_svs_metadata() + filetype_params = TIFFWSIReaderDelegate.parse_svs_metadata(self.tiff.pages) elif self.tiff.is_ome: filetype_params = self._parse_ome_metadata() else: - filetype_params = self._parse_generic_tiff_metadata() + filetype_params = TIFFWSIReaderDelegate.parse_generic_tiff_metadata( + self.tiff.pages + ) filetype_params["raw"]["TIFF Tags"] = tiff_tags return WSIMeta( @@ -3832,6 +3718,399 @@ def read_rect( pad_mode: str = "constant", pad_constant_values: int | IntPair = 0, coord_space: str = "baseline", + **kwargs: dict, + ) -> np.ndarray: + """See TIFFWSIReaderDelegate.read_rect docs for details.""" + return self.tiff_reader_delegate.read_rect( + location, + size, + resolution, + units, + interpolation, + pad_mode, + pad_constant_values, + coord_space, + **kwargs, + ) + + def read_bounds( + self: TIFFWSIReader, + bounds: IntBounds, + resolution: Resolution = 0, + units: Units = "level", + interpolation: str = "optimise", + pad_mode: str = "constant", + pad_constant_values: int | IntPair = 0, + coord_space: str = "baseline", + **kwargs: dict, + ) -> np.ndarray: + """See TIFFWSIReaderDelegate.read_bounds docs for details.""" + return self.tiff_reader_delegate.read_bounds( + bounds, + resolution, + units, + interpolation, + pad_mode, + pad_constant_values, + coord_space, + **kwargs, + ) + + +class FsspecJsonWSIReader(WSIReader): + """Reader for fsspec zarr json generated by: tiatoolbox/utils/tiff_to_fsspec.py. + + The fsspec zarr json file represents a SVS or TIFF file + that be accessed using byte range HTTP API. + + All the information on the chunk locations in the SVS or TIFF file + is outlined as byte-ranges in the JSON, + so the reader requests only chunks that are needed to display requested tiles, + rather than the entire SVS or TIFF file. + + """ + + def __init__( + self: FsspecJsonWSIReader, + input_img: str | Path | np.ndarray, + mpp: tuple[Number, Number] | None = None, + power: Number | None = None, + cache_size: int = 2**28, + ) -> None: + """Initialize :class:`FsspecJsonWSIReader`.""" + super().__init__(input_img=input_img, mpp=mpp, power=power) + jpeg_codec = Jpeg() + register_codec(jpeg_codec, "imagecodecs_jpeg") + + jpeg2k_codec = Jpeg2k() + register_codec(jpeg2k_codec, "imagecodecs_jpeg2k") + + lzw_codec = Lzw() + register_codec(lzw_codec, "imagecodecs_lzw") + + delta_codec = Delta() + register_codec(delta_codec, "imagecodecs_delta") + + mapper = fsspec.get_mapper( + "reference://", fo=str(input_img), target_protocol="file" + ) + + self._zarr_array = zarr.open(mapper, mode="r") + + self.__set_axes() + + self._zarr_store = self._zarr_array.store + + self._zarr_lru_cache = zarr.LRUStoreCache(self._zarr_store, max_size=cache_size) + self._zarr_group = zarr.open(self._zarr_lru_cache) + if not isinstance(self._zarr_group, zarr.hierarchy.Group): # pragma: no cover + group = zarr.hierarchy.group() + group[0] = self._zarr_group + self._zarr_group = group + self.level_arrays = { + int(key): ArrayView(array, axes=self._axes) + for key, array in self._zarr_group.items() + } + # ensure level arrays are sorted by descending area + self.level_arrays = dict( + sorted( + self.level_arrays.items(), + key=lambda x: -np.prod( + TIFFWSIReaderDelegate.canonical_shape( + self._axes, x[1].array.shape[:2] + ) + ), + ) + ) + self.tiff_reader_delegate = TIFFWSIReaderDelegate(self, self.level_arrays) + + def __set_axes(self) -> None: # pragma: no cover + """Loads axes from the json file. + + In case zarr array has a group 0 at root, + loads axes from the layer 0. + + In case the zarr array doesn't have a group 0 at + root, loads axes from attrs at root. + + """ + if isinstance(self._zarr_array, zarr.hierarchy.Group): + if "0" in self._zarr_array: + zattrs = self._zarr_array["0"].attrs + if "_ARRAY_DIMENSIONS" in zattrs: + self._axes = "".join( + zattrs["_ARRAY_DIMENSIONS"] + ) # Concatenate dimensions + else: + msg = "'_ARRAY_DIMENSIONS' does not exist in the group '0'." + raise ValueError(msg) + else: + msg = "The group '0' does not exist in the zarr_array." + raise ValueError(msg) + else: + zattrs_path = self._zarr_array.attrs + if "_ARRAY_DIMENSIONS" in zattrs_path: + self._axes = "".join( + zattrs_path["_ARRAY_DIMENSIONS"] + ) # Concatenate dimensions + else: + msg = "'_ARRAY_DIMENSIONS' does not exist in the root .zattrs." + raise ValueError(msg) + + @staticmethod + def is_valid_zarr_fsspec(file_path: str) -> bool: + """Check if the input path is a valid Zarr fsspec JSON file. + + Checks if the file_path is a valid Zarr fsspec JSON file generated by: + tiatoolbox/utils/tiff_to_fsspec.py + + Args: + file_path: str Path to the file to check. + + Returns: + bool: True if the file is a valid Zarr fsspec JSON file + """ + path = Path(file_path) + + if path.suffix.lower() != ".json": + logger.error("File does not have a .json extension.") + return False + + try: + with fsspec.open(file_path, "r") as file: + data = json.load(file) + + # Basic validation for fsspec Zarr JSON structure + if ".zattrs" not in data: + logger.error("Field .zattrs missing in '%s'.", file_path) + return False + + return True # noqa: TRY300 + + except json.JSONDecodeError as e: + logger.error("Invalid JSON file: %s", e) + return False + except (OSError, ValueError) as e: + logger.error("An error occurred: %s", e) + return False + + def _info(self: FsspecJsonWSIReader) -> WSIMeta: + """TIFF metadata constructor. + + Returns: + WSIMeta: + Containing metadata. + + """ + level_count = len(self.level_arrays) + level_dimensions = [ + np.array( + TIFFWSIReaderDelegate.canonical_shape(self._axes, p.array.shape)[:2][ + ::-1 + ] + ) + for p in self.level_arrays.values() + ] + slide_dimensions = level_dimensions[0] + level_downsamples = [(level_dimensions[0] / x)[0] for x in level_dimensions] + + zarr_attrs = self._zarr_array.attrs + + filetype_params = {} + # Check for "multiscales" and extract metadata + if "multiscales" in zarr_attrs: # pragma: no cover + multiscales = zarr_attrs[ + "multiscales" + ] # List of multiscale metadata entries + for entry in multiscales: + filetype_params = entry.get("metadata", {}) + + return WSIMeta( + file_path=self.input_path, + slide_dimensions=slide_dimensions, + axes=self._axes, + level_count=level_count, + level_dimensions=level_dimensions, + level_downsamples=level_downsamples, + **filetype_params, + ) + + def read_rect( + self: FsspecJsonWSIReader, + location: IntPair, + size: IntPair, + resolution: Resolution = 0, + units: Units = "level", + interpolation: str = "optimise", + pad_mode: str = "constant", + pad_constant_values: int | IntPair = 0, + coord_space: str = "baseline", + **kwargs: dict, + ) -> np.ndarray: + """See TIFFWSIReaderDelegate.read_rect docs for details.""" + return self.tiff_reader_delegate.read_rect( + location, + size, + resolution, + units, + interpolation, + pad_mode, + pad_constant_values, + coord_space, + **kwargs, + ) + + def read_bounds( + self: FsspecJsonWSIReader, + bounds: IntBounds, + resolution: Resolution = 0, + units: Units = "level", + interpolation: str = "optimise", + pad_mode: str = "constant", + pad_constant_values: int | IntPair = 0, + coord_space: str = "baseline", + **kwargs: dict, + ) -> np.ndarray: + """See TIFFWSIReaderDelegate.read_bounds docs for details.""" + return self.tiff_reader_delegate.read_bounds( + bounds, + resolution, + units, + interpolation, + pad_mode, + pad_constant_values, + coord_space, + **kwargs, + ) + + +class TIFFWSIReaderDelegate: + """Delegate class to handle image reading operations. + + Currently used in FsspecJsonWSIReader and TIFFWSIReader. + """ + + def __init__(self, reader: WSIReader, level_arrays: dict[int, ArrayView]) -> None: + """Initialize the delegate with a reader and level arrays. + + Args: + reader (WSIReader): An instance of FsspecJsonWSIReader or TIFFWSIReader. + level_arrays (dict[int, ArrayView]): Dictionary of level arrays. + """ + self.reader = reader + self.level_arrays = level_arrays + + @staticmethod + def parse_svs_metadata(pages: TiffPages) -> dict: + """Extract SVS specific metadata. + + Returns: + dict: + Dictionary of kwargs for WSIMeta. + + """ + raw = {} + mpp = None + objective_power = None + vendor = "Aperio" + + description = pages[0].description + raw["Description"] = description + parts = description.split("|") + description_headers, key_value_pairs = parts[0], parts[1:] + description_headers = description_headers.split(";") + + software, photometric_info = description_headers[0].splitlines() + raw["Software"] = software + raw["Photometric Info"] = photometric_info + + def parse_svs_tag(string: str) -> tuple[str, Number | str]: + """Parse SVS key-value string. + + Infers type(s) of data by trial and error with a fallback to + the original string type. + + Args: + string (str): + Key-value string in SVS format: "key=value". + + Returns: + tuple: + Key-value pair. + + """ + pair = string.split("=") + if len(pair) != 2: # noqa: PLR2004 + msg = "Invalid metadata. Expected string of the format 'key=value'." + raise ValueError( + msg, + ) + key, value_string = pair + key = key.strip() + value_string = value_string.strip() + + def us_date(string: str) -> datetime: + """Return datetime parsed according to US date format.""" + return datetime.strptime(string, r"%m/%d/%y").astimezone() + + def time(string: str) -> datetime: + """Return datetime parsed according to HMS format.""" + return datetime.strptime(string, r"%H:%M:%S").astimezone() + + casting_precedence = [us_date, time, int, float] + value = value_string + for cast in casting_precedence: + try: + value = cast(value_string) + except ValueError: # noqa: PERF203 + continue + else: + return key, value + + return key, value + + svs_tags = dict(parse_svs_tag(string) for string in key_value_pairs) + raw["SVS Tags"] = svs_tags + mpp = svs_tags.get("MPP") + if mpp is not None: # pragma: no cover + mpp = [mpp] * 2 + objective_power = svs_tags.get("AppMag") + + return { + "objective_power": objective_power, + "vendor": vendor, + "mpp": mpp, + "raw": raw, + } + + @staticmethod + def canonical_shape(axes: str, shape: tuple[int, int]) -> tuple[int, int]: + """Make a level shape tuple in YXS order. + + Args: + axes (str): The axes format. + shape (tuple[int, int]): Input shape tuple. + + Returns: + tuple[int, int]: Shape in YXS order. + """ + if axes == "YXS": + return shape + if axes == "SYX": + return np.roll(shape, -1) + msg = f"Unsupported axes `{axes}`." + raise ValueError(msg) + + def read_rect( + self, + location: IntPair, + size: IntPair, + resolution: Resolution = 0, + units: Units = "level", + interpolation: str = "optimise", + pad_mode: str = "constant", + pad_constant_values: int | IntPair = 0, + coord_space: str = "baseline", **kwargs: dict, # noqa: ARG002 ) -> np.ndarray: """Read a region of the whole slide image at a location and size. @@ -4015,7 +4294,7 @@ def read_rect( """ if coord_space == "resolution": - im_region = self._read_rect_at_resolution( + im_region = self.reader.read_rect_at_resolution( location, size, resolution=resolution, @@ -4033,7 +4312,7 @@ def read_rect( level_read_size, post_read_scale, _, - ) = self.find_read_rect_params( + ) = self.reader.find_read_rect_params( location=location, size=size, resolution=resolution, @@ -4060,7 +4339,7 @@ def read_rect( return utils.transforms.background_composite(image=im_region, alpha=False) def read_bounds( - self: TIFFWSIReader, + self: TIFFWSIReaderDelegate, bounds: IntBounds, resolution: Resolution = 0, units: Units = "level", @@ -4172,7 +4451,7 @@ class docstrings for more information. """ bounds_at_baseline = bounds if coord_space == "resolution": - bounds_at_baseline = self._bounds_at_resolution_to_baseline( + bounds_at_baseline = self.reader.bounds_at_resolution_to_baseline( bounds, resolution, units, @@ -4187,7 +4466,7 @@ class docstrings for more information. bounds_at_read_level, _, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.reader.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -4199,7 +4478,7 @@ class docstrings for more information. bounds_at_read_level, size_at_requested, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.reader.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -4231,6 +4510,41 @@ class docstrings for more information. return im_region + @staticmethod + def parse_generic_tiff_metadata(pages: TiffPages) -> dict: + """Extract generic tiled metadata. + + Returns: + dict: Dictionary of kwargs for WSIMeta. + + """ + mpp = None + objective_power = None + vendor = "Generic" + + description = pages[0].description + raw = {"Description": description} + # Check for MPP in the tiff resolution tags + # res_units: 1 = undefined, 2 = inch, 3 = centimeter + res_units = pages[0].tags.get("ResolutionUnit") + res_x = pages[0].tags.get("XResolution") + res_y = pages[0].tags.get("YResolution") + if ( # pragma: no cover + all(x is not None for x in [res_units, res_x, res_y]) + and res_units.value != 1 + ): + mpp = [ + utils.misc.ppu2mpp(res_x.value[0] / res_x.value[1], res_units.value), + utils.misc.ppu2mpp(res_y.value[0] / res_y.value[1], res_units.value), + ] + + return { + "objective_power": objective_power, + "vendor": vendor, + "mpp": mpp, + "raw": raw, + } + class DICOMWSIReader(WSIReader): """Define DICOM WSI Reader.""" @@ -4477,7 +4791,7 @@ def read_rect( """ if coord_space == "resolution": - return self._read_rect_at_resolution( + return self.read_rect_at_resolution( location, size, resolution=resolution, @@ -4654,7 +4968,7 @@ class docstrings for more information. # convert from requested to `baseline` bounds_at_baseline = bounds if coord_space == "resolution": - bounds_at_baseline = self._bounds_at_resolution_to_baseline( + bounds_at_baseline = self.bounds_at_resolution_to_baseline( bounds, resolution, units, @@ -4669,7 +4983,7 @@ class docstrings for more information. bounds_at_read_level, _, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -4681,7 +4995,7 @@ class docstrings for more information. bounds_at_read_level, size_at_requested, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -5047,7 +5361,7 @@ def read_rect( """ if coord_space == "resolution": - im_region = self._read_rect_at_resolution( + im_region = self.read_rect_at_resolution( location, size, resolution=resolution, @@ -5205,7 +5519,7 @@ class docstrings for more information. """ bounds_at_baseline = bounds if coord_space == "resolution": - bounds_at_baseline = self._bounds_at_resolution_to_baseline( + bounds_at_baseline = self.bounds_at_resolution_to_baseline( bounds, resolution, units, @@ -5220,7 +5534,7 @@ class docstrings for more information. _, _, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -5232,7 +5546,7 @@ class docstrings for more information. _, size_at_requested, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -5573,7 +5887,7 @@ def read_rect( """ if coord_space == "resolution": - return self._read_rect_at_resolution( + return self.read_rect_at_resolution( location, size, resolution=resolution, @@ -5757,7 +6071,7 @@ class docstrings for more information. """ bounds_at_baseline = bounds if coord_space == "resolution": - bounds_at_baseline = self._bounds_at_resolution_to_baseline( + bounds_at_baseline = self.bounds_at_resolution_to_baseline( bounds, resolution, units, @@ -5772,7 +6086,7 @@ class docstrings for more information. _, _, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units, @@ -5784,7 +6098,7 @@ class docstrings for more information. _, size_at_requested, post_read_scale, - ) = self._find_read_bounds_params( + ) = self.find_read_bounds_params( bounds_at_baseline, resolution=resolution, units=units,