Skip to content

Commit bc5ee15

Browse files
John-Ppre-commit-ci[bot]shaneahmed
authored
BUG: Read MPP Metadata From NGFF v0.4 Zattrs (#486)
This PR adds support for reading MPP metadata from v0.4 NGFF which was missing. It currently only supports units in micrometers and will use the first scale transform in the axis `coordainteTransformations` list in `.zattrs`. ## To-Do - [x] Unit test coverage Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Shan E Ahmed Raza <[email protected]>
1 parent 00b6e9c commit bc5ee15

File tree

4 files changed

+126
-4
lines changed

4 files changed

+126
-4
lines changed

tests/test_wsireader.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1903,6 +1903,69 @@ def test_is_ngff_regular_zarr(tmp_path):
19031903
assert not is_ngff(zarr_path)
19041904

19051905

1906+
def test_ngff_zattrs_non_micrometer_scale_mpp(tmp_path):
1907+
"""Test that mpp is None if scale is not in micrometers."""
1908+
sample = _fetch_remote_sample("ngff-1")
1909+
# Create a copy of the sample with a non-micrometer scale
1910+
sample_copy = tmp_path / "ngff-1"
1911+
shutil.copytree(sample, sample_copy)
1912+
with open(sample_copy / ".zattrs", "r") as fh:
1913+
zattrs = json.load(fh)
1914+
zattrs["multiscales"][0]["axes"][0]["unit"] = "foo"
1915+
with open(sample_copy / ".zattrs", "w") as fh:
1916+
json.dump(zattrs, fh, indent=2)
1917+
with pytest.warns(UserWarning, match="micrometer"):
1918+
wsi = wsireader.NGFFWSIReader(sample_copy)
1919+
assert wsi.info.mpp is None
1920+
1921+
1922+
def test_ngff_zattrs_missing_axes_mpp(tmp_path):
1923+
"""Test that mpp is None if axes are missing."""
1924+
sample = _fetch_remote_sample("ngff-1")
1925+
# Create a copy of the sample with no axes
1926+
sample_copy = tmp_path / "ngff-1"
1927+
shutil.copytree(sample, sample_copy)
1928+
with open(sample_copy / ".zattrs", "r") as fh:
1929+
zattrs = json.load(fh)
1930+
zattrs["multiscales"][0]["axes"] = []
1931+
with open(sample_copy / ".zattrs", "w") as fh:
1932+
json.dump(zattrs, fh, indent=2)
1933+
wsi = wsireader.NGFFWSIReader(sample_copy)
1934+
assert wsi.info.mpp is None
1935+
1936+
1937+
def test_ngff_empty_datasets_mpp(tmp_path):
1938+
"""Test that mpp is None if there are no datasets."""
1939+
sample = _fetch_remote_sample("ngff-1")
1940+
# Create a copy of the sample with no axes
1941+
sample_copy = tmp_path / "ngff-1"
1942+
shutil.copytree(sample, sample_copy)
1943+
with open(sample_copy / ".zattrs", "r") as fh:
1944+
zattrs = json.load(fh)
1945+
zattrs["multiscales"][0]["datasets"] = []
1946+
with open(sample_copy / ".zattrs", "w") as fh:
1947+
json.dump(zattrs, fh, indent=2)
1948+
wsi = wsireader.NGFFWSIReader(sample_copy)
1949+
assert wsi.info.mpp is None
1950+
1951+
1952+
def test_nff_no_scale_transforms_mpp(tmp_path):
1953+
"""Test that mpp is None if no scale transforms are present."""
1954+
sample = _fetch_remote_sample("ngff-1")
1955+
# Create a copy of the sample with no axes
1956+
sample_copy = tmp_path / "ngff-1"
1957+
shutil.copytree(sample, sample_copy)
1958+
with open(sample_copy / ".zattrs", "r") as fh:
1959+
zattrs = json.load(fh)
1960+
for i, _ in enumerate(zattrs["multiscales"][0]["datasets"]):
1961+
datasets = zattrs["multiscales"][0]["datasets"][i]
1962+
datasets["coordinateTransformations"][0]["type"] = "identity"
1963+
with open(sample_copy / ".zattrs", "w") as fh:
1964+
json.dump(zattrs, fh, indent=2)
1965+
wsi = wsireader.NGFFWSIReader(sample_copy)
1966+
assert wsi.info.mpp is None
1967+
1968+
19061969
class TestReader:
19071970
scenarios = [
19081971
(
@@ -2084,3 +2147,10 @@ def test_file_path_does_not_exist(sample_key, reader_class):
20842147
"""Test that FileNotFoundError is raised when file does not exist."""
20852148
with pytest.raises(FileNotFoundError):
20862149
_ = reader_class("./foo.bar")
2150+
2151+
@staticmethod
2152+
def test_read_mpp(sample_key, reader_class):
2153+
"""Test that the mpp is read correctly."""
2154+
sample = _fetch_remote_sample(sample_key)
2155+
wsi = reader_class(sample)
2156+
assert wsi.info.mpp == pytest.approx(0.25, 1)

tiatoolbox/wsicore/metadata/ngff.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ class CoordinateTransform:
9696
The scale factors. Must be one for each axis.
9797
"""
9898

99-
type: str = "scale" # noqa: A003
100-
scale: List[float] = field(default_factory=lambda: [1.0, 0.5, 0.5])
99+
type: str = "identity" # noqa: A003
100+
scale: Optional[List[float]] = None
101101

102102

103103
@dataclass

tiatoolbox/wsicore/wsireader.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4200,7 +4200,15 @@ def __init__(self, path, **kwargs):
42004200
multiscales=ngff.Multiscales(
42014201
version=multiscales.get("version"),
42024202
axes=[ngff.Axis(**axis) for axis in axes],
4203-
datasets=[ngff.Dataset(**dataset) for dataset in datasets],
4203+
datasets=[
4204+
ngff.Dataset(
4205+
path=dataset["path"],
4206+
coordinateTransformations=dataset.get(
4207+
"coordinateTransformations",
4208+
),
4209+
)
4210+
for dataset in datasets
4211+
],
42044212
),
42054213
omero=ngff.Omero(
42064214
name=omero.get("name"),
@@ -4217,17 +4225,60 @@ def __init__(self, path, **kwargs):
42174225
}
42184226

42194227
def _info(self):
4228+
multiscales = self.zattrs.multiscales
42204229
return WSIMeta(
4221-
axes="".join(axis.name.upper() for axis in self.zattrs.multiscales.axes),
4230+
axes="".join(axis.name.upper() for axis in multiscales.axes),
42224231
level_dimensions=[
42234232
array.shape[:2][::-1]
42244233
for _, array in sorted(self._zarr_group.arrays(), key=lambda x: x[0])
42254234
],
42264235
slide_dimensions=self._zarr_group[0].shape[:2][::-1],
42274236
vendor=self.zattrs._creator.name, # skipcq
42284237
raw=self._zarr_group.attrs,
4238+
mpp=self._get_mpp(),
42294239
)
42304240

4241+
def _get_mpp(self) -> Optional[Tuple[float, float]]:
4242+
"""Get the microns-per-pixel (MPP) of the slide.
4243+
4244+
Returns:
4245+
Tuple[float, float]:
4246+
The mpp of the slide an x,y tuple. None if not available.
4247+
4248+
"""
4249+
# Check that the required axes are present
4250+
multiscales = self.zattrs.multiscales
4251+
axes_dict = {a.name.lower(): a for a in multiscales.axes}
4252+
if "x" not in axes_dict or "y" not in axes_dict:
4253+
return None
4254+
x = axes_dict["x"]
4255+
y = axes_dict["y"]
4256+
4257+
# Check the units,
4258+
# Currently only handle micrometer units
4259+
if x.unit != y.unit != "micrometer":
4260+
warnings.warn(
4261+
f"Expected units of micrometer, got {x.unit} and {y.unit}",
4262+
UserWarning,
4263+
)
4264+
return None
4265+
4266+
# Check that datasets is non-empty and has at least one coordinateTransformation
4267+
if (
4268+
not multiscales.datasets
4269+
or not multiscales.datasets[0].coordinateTransformations
4270+
):
4271+
return None
4272+
4273+
# Currently simply using the first scale transform
4274+
transforms = multiscales.datasets[0].coordinateTransformations
4275+
for t in transforms:
4276+
if "scale" in t and t.get("type") == "scale":
4277+
x_index = multiscales.axes.index(x)
4278+
y_index = multiscales.axes.index(y)
4279+
return (t["scale"][x_index], t["scale"][y_index])
4280+
return None
4281+
42314282
def read_rect(
42324283
self,
42334284
location,

whitelist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ DICOM
1818
DNS
1919
Dataloader
2020
Dataset
21+
Datasets
2122
Delaunay
2223
E1120
2324
FCN

0 commit comments

Comments
 (0)