Skip to content

Commit 1bc2356

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

File tree

5 files changed

+712
-0
lines changed

5 files changed

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