From 41e07f79c0094a14a90f31342df0c1f60f4f26e5 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Thu, 9 Nov 2023 05:34:29 -0500 Subject: [PATCH] Add property to count number of tile frames (#116) * Add property to count number of tile frames The "NumberOfFrames" attribute on a dataset takes into account not just the number of frames corresponding to tiles, but also the number of focal planes and optical paths (for example, see [here](https://github.com/imi-bigpicture/wsidicom/blob/774fe3ca00096e3fd2556fc956520dbfabc81311/wsidicom/instance/dataset.py#L651-L655)). When I was trying to view a dataset with multiple optical paths, I would encounter errors in the `image_size` property since it did not distinguish frames that came from optical paths and tiles. This fixes viewing [this example](https://imagingdatacommons.github.io/slim/studies/2.25.23897195960526781231486877255455994829/series/2.25.83282858720704132758110891374375550907) via DICOMweb in [large_image](https://github.com/girder/large_image). I am new to the field, so please feel free to correct any naming issues or misconceptions... Signed-off-by: Patrick Avery * Add option to provide a session to the web client (#117) * Add option to provide web client a session We would like to create the `requests.Session` object on our own and pass it into `WsiDicomWebClient`, since we are not using an object derived from `AuthBase` to create it. This PR adds support to pass a session to `WsiDicomWebClient` directly. Signed-off-by: Patrick Avery * Refactor __init__ method of WsiDicomWebClient The `__init__` method now accepts a `DICOMwebClient` object. The previous `__init__` method was moved into a `create_client()` class method. Signed-off-by: Patrick Avery * Remove `WsiDicomFileClient` and update README.md Signed-off-by: Patrick Avery --------- Signed-off-by: Patrick Avery * Dicomweb get multiple frames (#121) * Get multiple frames from dicom web * Allow loading multiple series with DICOMweb (#122) * Allow loading multiple series with DICOMweb `WsiDicom.open_web()` was modified to be able to either take a single series uid (as before) or a list of series uids. If a list of series uids is passed, their instances are all loaded together. This can be useful for cases where, for instance, different optical paths are located in different series. This also loosens the UID matching to only check the frame of references. This also adds `TotalPixelMatrixOriginSequence` matching when comparing datasets. Fixes: #118 Signed-off-by: Patrick Avery * Fix `isinstance()` check for series uids Signed-off-by: Patrick Avery * Fix logic in `SlideUids.matches()` Signed-off-by: Patrick Avery * Remove unused import Signed-off-by: Patrick Avery --------- Signed-off-by: Patrick Avery * Remove unsafe number_of_focal_planes and number_of_optical_paths properties * FIx if * Skip frame count check for concatenated instances --------- Signed-off-by: Patrick Avery Co-authored-by: Erik O Gabrielsson <83275777+erikogabrielsson@users.noreply.github.com> Co-authored-by: Erik O Gabrielsson --- wsidicom/instance/dataset.py | 52 +++++++++++++------ .../instance/tile_index/full_tile_index.py | 2 +- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/wsidicom/instance/dataset.py b/wsidicom/instance/dataset.py index 8a05da30..6c4092dc 100644 --- a/wsidicom/instance/dataset.py +++ b/wsidicom/instance/dataset.py @@ -17,6 +17,7 @@ from dataclasses import dataclass from enum import Enum, IntEnum, auto from functools import cached_property +from re import U from typing import Any, List, Optional, Sequence, Tuple, Union, cast from pydicom.dataset import Dataset @@ -256,10 +257,18 @@ def tile_type(self) -> TileType: """ tile_type = self._get_dicom_attribute("DimensionOrganizationType") if tile_type == "TILED_FULL": + # By the standard it should be tiled full. return TileType.FULL - elif "PerFrameFunctionalGroupsSequence" in self: + if "PerFrameFunctionalGroupsSequence" in self: + # If no per frame functional sequence we cant make a sparse tile index. return TileType.SPARSE - elif self.frame_count == 1: + if self.image_type == ImageType.LABEL: + # Labels are expected to only have one frame and can be treated as tiled full. + return TileType.FULL + number_of_focal_planes = getattr(self, "TotalPixelMatrixFocalPlanes", 1) + number_of_optical_paths = getattr(self, "NumberOfOpticalPaths", 1) + if self.frame_count == number_of_focal_planes * number_of_optical_paths: + # One frame per focal plane and optical path, treat as tiled full. return TileType.FULL raise WsiDicomError("Undetermined tile type.") @@ -315,14 +324,6 @@ def spacing_between_slices(self) -> Optional[float]: return None return getattr(self.pixel_measure, "SpacingBetweenSlices", None) - @cached_property - def number_of_focal_planes(self) -> int: - """Return number of focal planes.""" - number_of_focal_planes = self._get_dicom_attribute( - "TotalPixelMatrixFocalPlanes" - ) - return cast(int, number_of_focal_planes) - @cached_property def frame_sequence(self) -> DicomSequence: """Return per frame functional group sequene if present, otherwise @@ -376,16 +377,30 @@ def image_size(self) -> Size: image_size = Size(self.TotalPixelMatrixColumns, self.TotalPixelMatrixRows) if image_size.width <= 0 or image_size.height <= 0: raise WsiDicomError("Image size is zero") - if self.tile_type == TileType.FULL: + if self.tile_type == TileType.FULL and self.uids.concatenation is None: + # Check that the number of frames match the image size and tile size. + # Dont check concantenated instances as the frame count is ambiguous. expected_tiled_size = image_size.ceil_div(self.tile_size) - if expected_tiled_size.area != self.frame_count: + number_of_focal_planes = getattr(self, "TotalPixelMatrixFocalPlanes", 1) + number_of_optical_paths = getattr(self, "NumberOfOpticalPaths", 1) + expected_frame_count = ( + expected_tiled_size.area + * number_of_focal_planes + * number_of_optical_paths + ) + if expected_frame_count != self.frame_count: error = ( f"Image size {image_size} does not match tile size " f"{self.tile_size} and number of frames {self.frame_count} " f"for tile type {TileType.FULL}." ) - if self.image_type == ImageType.VOLUME or self.frame_count != 1: - # Be strict on volume images. + if ( + self.image_type == ImageType.VOLUME + and self.frame_count + != number_of_focal_planes * number_of_optical_paths + ): + # Be strict on volume images if more than one frame per focal plane + # and optical path. raise WsiDicomError(error) # Labels and overviews are likely to have only one tile. error += " Overriding image size to tile size." @@ -454,7 +469,8 @@ def slice_thickness(self) -> Optional[float]: return self._get_dicom_attribute("SliceThickness", self.pixel_measure) except AttributeError: if self.mm_depth is not None: - return self.mm_depth / self.number_of_focal_planes + number_of_focal_planes = getattr(self, "TotalPixelMatrixFocalPlanes", 1) + return self.mm_depth / number_of_focal_planes return None @cached_property @@ -556,8 +572,10 @@ def matches_instance(self, other_dataset: "WsiDataset") -> bool: and self.image_size == other_dataset.image_size and self.tile_size == other_dataset.tile_size and self.tile_type == other_dataset.tile_type - and (getattr(self, 'TotalPixelMatrixOriginSequence', None) == - getattr(other_dataset, 'TotalPixelMatrixOriginSequence', None)) + and ( + getattr(self, "TotalPixelMatrixOriginSequence", None) + == getattr(other_dataset, "TotalPixelMatrixOriginSequence", None) + ) ) def matches_series(self, uids: SlideUids, tile_size: Optional[Size] = None) -> bool: diff --git a/wsidicom/instance/tile_index/full_tile_index.py b/wsidicom/instance/tile_index/full_tile_index.py index 137c949b..6eaebeba 100644 --- a/wsidicom/instance/tile_index/full_tile_index.py +++ b/wsidicom/instance/tile_index/full_tile_index.py @@ -101,7 +101,7 @@ def _read_focal_planes_from_datasets( focal_planes: Set[float] = set() for dataset in self._datasets: slice_spacing = dataset.spacing_between_slices - number_of_focal_planes = dataset.number_of_focal_planes + number_of_focal_planes = getattr(dataset, "TotalPixelMatrixFocalPlanes", 1) if slice_spacing is None: if number_of_focal_planes == 1: slice_spacing = 0.0