Skip to content
Merged
Show file tree
Hide file tree
Changes from 43 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
1bc2356
Add ZarrTIFFWSIReader class.
aacic Dec 6, 2024
adb3574
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 16, 2024
1ce17b2
Merge branch 'develop' into zarr-tiff-wsi-reader
shaneahmed Jan 3, 2025
dc7c77c
Merge branch 'develop' into zarr-tiff-wsi-reader
shaneahmed Jan 14, 2025
9276647
Merge branch 'develop' into zarr-tiff-wsi-reader
shaneahmed Jan 23, 2025
0cf8c32
Rename ZarrTIFFWSIReader to FsspecJsonReader
aacic Jan 31, 2025
e750f2a
Rename ZarrTIFFWSIReader to FsspecJsonReader
aacic Jan 31, 2025
0209100
Rename ZarrTIFFWSIReader to FsspecJsonWSIReader.
aacic Jan 31, 2025
7b6a7b1
Rename ZarrTIFFWSIReader to FsspecJsonWSIReader.
aacic Jan 31, 2025
2034262
Migrate tiff_fsspec.py.
aacic Feb 3, 2025
91d5911
Migrate is_valid_zarr_fsspec.
aacic Feb 3, 2025
224de85
Rename ZarrTIFFWSIReader to FsspecJsonWSIReader.
aacic Feb 3, 2025
1bf88bc
Migrate is_valid_zarr_fsspec.
aacic Feb 4, 2025
e3d6129
Fix loggin issue.
aacic Feb 4, 2025
ad8ed13
Add Jpeg2k codec.
aacic Feb 4, 2025
ad559aa
WIP: Add DelegateWSIReader.
aacic Feb 4, 2025
afdf912
Merge branch 'develop' into zarr-tiff-wsi-reader
shaneahmed Feb 5, 2025
ebd41da
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 5, 2025
06b20f2
Update DelegateWSIReader
aacic Feb 5, 2025
c7b99bb
Update DelegateWSIReader
aacic Feb 5, 2025
50f3267
Update DelegateWSIReader.
aacic Feb 5, 2025
5c61c95
Update DelegateWSIReader.
aacic Feb 5, 2025
f320120
Update DelegateWSIReader.
aacic Feb 5, 2025
7c92794
Remane DelegateWSIReader to TIFFWSIReaderDelegate.
aacic Feb 5, 2025
cea7c60
Add docs.
aacic Feb 5, 2025
51d98e5
Add docs.
aacic Feb 5, 2025
5e870a2
Extract parse_svs_metadata to Delagate class.
aacic Feb 5, 2025
5923737
Fix test.
aacic Feb 6, 2025
1f7d6bc
Fix tests.
aacic Feb 6, 2025
1ce4610
Fix tests.
aacic Feb 6, 2025
61c00e2
Register codecs
aacic Feb 6, 2025
4b7860e
Add tests.
aacic Feb 7, 2025
b8dd291
Fix tests.
aacic Feb 7, 2025
63aae0d
Fix metadata issue.
aacic Feb 7, 2025
d691939
Add test_fsspec_json_wsi_reader_instantiation test.
aacic Feb 10, 2025
553cafa
Add no cover else branch.
aacic Feb 10, 2025
cdaa3ed
Add more tests.
aacic Feb 10, 2025
6d5a372
Add more tests.
aacic Feb 10, 2025
3bb9257
Add more tests.
aacic Feb 11, 2025
fd85e9a
Merge branch 'develop' into zarr-tiff-wsi-reader
aacic Feb 11, 2025
6008f4c
Clean up tests.
aacic Feb 11, 2025
4f3c1ad
Update docs.
aacic Feb 11, 2025
e902766
Update docs.
aacic Feb 11, 2025
13b33ce
Update tiatoolbox/utils/tiff_to_fsspec.py
aacic Feb 17, 2025
c06c27e
Update tiatoolbox/wsicore/wsireader.py
aacic Feb 17, 2025
57ac66d
Update tests/test_wsireader.py
aacic Feb 17, 2025
ee240bd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 17, 2025
6317782
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 17, 2025
7e3ce79
Update docs.
aacic Feb 17, 2025
4762d09
Merge branch 'develop' into zarr-tiff-wsi-reader
shaneahmed Mar 4, 2025
3a78386
Merge branch 'develop' into zarr-tiff-wsi-reader
shaneahmed Mar 4, 2025
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
1 change: 1 addition & 0 deletions requirements/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
171 changes: 170 additions & 1 deletion tests/test_wsireader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# 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
Expand All @@ -27,7 +28,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
Expand All @@ -37,6 +38,7 @@
AnnotationStoreReader,
ArrayView,
DICOMWSIReader,
FsspecJsonWSIReader,
JP2WSIReader,
NGFFWSIReader,
OpenSlideWSIReader,
Expand Down Expand Up @@ -221,6 +223,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."""
Expand Down Expand Up @@ -2812,3 +2851,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))
98 changes: 98 additions & 0 deletions tiatoolbox/utils/tiff_to_fsspec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""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 handle SVS file processing."""
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 <svs_file_path> <json_file_path> <final_url>"
raise ValueError(msg)

main(sys.argv[1], sys.argv[2], sys.argv[3])
Loading