Skip to content

Commit cbd657f

Browse files
committed
Add ZarrTIFFWSIReader class.
1 parent 6b214fe commit cbd657f

File tree

5 files changed

+707
-0
lines changed

5 files changed

+707
-0
lines changed

requirements/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# torch installation
22
--extra-index-url https://download.pytorch.org/whl/cu118; sys_platform != "darwin"
3+
aiohttp>=3.8.1
34
albumentations>=1.3.0
45
bokeh>=3.1.1, <3.6.0
56
Click>=8.1.3

tests/zarrtiff/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Test files meant for testing zarr tiff json files."""

tests/zarrtiff/tiff_fsspec.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Module for processing SVS metadata and generating fsspec JSON file."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
from datetime import datetime
8+
from pathlib import Path
9+
from typing import TYPE_CHECKING, Any
10+
11+
from tifffile import TiffFile, TiffPages, tiff2fsspec
12+
13+
if TYPE_CHECKING:
14+
from numbers import Number
15+
16+
# Constants
17+
EXPECTED_KEY_VALUE_PAIRS = 2
18+
EXPECTED_ARG_COUNT = 4
19+
URL_PLACEHOLDER = "https://replace.me/"
20+
21+
22+
def _parse_svs_metadata(pages: TiffPages) -> dict[str, Any]:
23+
"""Extract SVS-specific metadata."""
24+
raw = {}
25+
mpp: list[float] | None = None
26+
objective_power: float | None = None
27+
vendor = "Aperio"
28+
29+
description = pages[0].description
30+
raw["Description"] = description
31+
parts = description.split("|")
32+
description_headers, key_value_pairs = parts[0], parts[1:]
33+
description_headers = description_headers.split(";")
34+
35+
software, photometric_info = description_headers[0].splitlines()
36+
raw["Software"] = software
37+
raw["Photometric Info"] = photometric_info
38+
39+
def parse_svs_tag(string: str) -> tuple[str, Number | str | datetime]:
40+
"""Parse SVS key-value string."""
41+
pair = string.split("=")
42+
if len(pair) != EXPECTED_KEY_VALUE_PAIRS:
43+
msg = "Invalid metadata. Expected string of the format 'key=value'."
44+
raise ValueError(msg)
45+
46+
key, value_string = pair
47+
key = key.strip()
48+
value_string = value_string.strip()
49+
50+
def us_date(string: str) -> datetime:
51+
"""Return datetime parsed according to US date format."""
52+
return datetime.strptime(string, r"%m/%d/%y").astimezone()
53+
54+
def time(string: str) -> datetime:
55+
"""Return datetime parsed according to HMS format."""
56+
return datetime.strptime(string, r"%H:%M:%S").astimezone()
57+
58+
casting_precedence = [us_date, time, int, float]
59+
value: Number | str | datetime = value_string
60+
for cast in casting_precedence:
61+
try:
62+
value = cast(value_string)
63+
break
64+
except ValueError:
65+
continue
66+
67+
return key, value
68+
69+
svs_tags = dict(parse_svs_tag(string) for string in key_value_pairs)
70+
raw["SVS Tags"] = svs_tags
71+
mpp = [svs_tags.get("MPP")] * 2 if svs_tags.get("MPP") is not None else None
72+
objective_power = svs_tags.get("AppMag")
73+
74+
return {
75+
"objective_power": objective_power,
76+
"vendor": vendor,
77+
"mpp": mpp,
78+
"raw": raw,
79+
}
80+
81+
82+
def convert_metadata(metadata: dict) -> dict:
83+
"""Convert metadata to JSON-compatible format."""
84+
if isinstance(metadata, dict):
85+
return {key: convert_metadata(value) for key, value in metadata.items()}
86+
if isinstance(metadata, list):
87+
return [convert_metadata(item) for item in metadata]
88+
if isinstance(metadata, datetime):
89+
return metadata.isoformat() # Convert datetime to ISO 8601 string
90+
return metadata
91+
92+
93+
def replace_url(
94+
data: dict[str, Any], output_path: Path, old_url: str, new_url: str
95+
) -> None:
96+
"""Replace URL in the JSON file."""
97+
for value in data.values():
98+
if isinstance(value, list) and value[0] == old_url:
99+
value[0] = new_url
100+
101+
with output_path.open("w") as json_file:
102+
json.dump(data, json_file, indent=2)
103+
104+
105+
def main(svs_file_path: str, json_file_path: str, final_url: str) -> None:
106+
"""Main function to handle SVS file processing."""
107+
url_to_replace = f"{URL_PLACEHOLDER}{Path(svs_file_path).name}"
108+
109+
tiff_file_pages = TiffFile(svs_file_path).pages
110+
111+
# Generate fsspec JSON
112+
tiff2fsspec(svs_file_path, url=URL_PLACEHOLDER, out=json_file_path)
113+
114+
# Parse SVS metadata
115+
metadata = _parse_svs_metadata(pages=tiff_file_pages)
116+
117+
# Convert metadata to JSON-compatible format
118+
metadata_serializable = convert_metadata(metadata)
119+
120+
# Read the JSON data from the file
121+
json_path = Path(json_file_path)
122+
with json_path.open() as file:
123+
json_data = json.load(file)
124+
125+
# Decode `.zattrs` JSON string into a dictionary
126+
zattrs = json.loads(json_data[".zattrs"])
127+
128+
# Update metadata into `.zattrs`
129+
if "multiscales" in zattrs and isinstance(zattrs["multiscales"], list):
130+
zattrs["multiscales"][0]["metadata"] = metadata_serializable
131+
132+
# Convert back to a JSON string
133+
json_data[".zattrs"] = json.dumps(zattrs)
134+
135+
# Replace URLs in the JSON file
136+
replace_url(json_data, json_path, url_to_replace, final_url)
137+
138+
139+
if __name__ == "__main__":
140+
if len(sys.argv) != EXPECTED_ARG_COUNT:
141+
msg = " Usage: python script.py <svs_file_path> <json_file_path> <final_url>"
142+
raise ValueError(msg)
143+
144+
main(sys.argv[1], sys.argv[2], sys.argv[3])

tests/zarrtiff/tileserver.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from flask_cors import CORS
2+
3+
from tiatoolbox.visualization import TileServer
4+
from tiatoolbox.wsicore import WSIReader
5+
6+
## Before running this script run: pip install aiohttp
7+
8+
svs = "../../samples/fsspec/73c69d24-6f9e-44e2-bfe5-a608d4cf5c27_fsspec.json"
9+
10+
reader = WSIReader.open(svs)
11+
12+
# Initialize and run the TileServer
13+
tile_server = TileServer(
14+
title="Tiatoolbox TileServer",
15+
layers={"layer": reader},
16+
)
17+
CORS(tile_server, send_wildcard=True)
18+
19+
20+
tile_server.run(host="127.0.0.1", port=5000)

0 commit comments

Comments
 (0)