Skip to content

Commit

Permalink
Merge branch 'main' into imagecodecs-update
Browse files Browse the repository at this point in the history
  • Loading branch information
erikogabrielsson authored Jan 12, 2024
2 parents d84cb51 + eee90a9 commit e4f8645
Show file tree
Hide file tree
Showing 49 changed files with 964 additions and 581 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Support for multiple pyramids within the same slide. A pyramid must have the same image coordinate system and extended depth of field (if any). Use the `pyramid`-parameter to set the pyramid in for example `read_region()`, or use `set_selected_pyramid()` to set the pyramid to use. By default the first detected pyramid is used.
- RLE encoding using image codecs.
- JPEG 2000 encoding of lossless YBR using image codecs.

### Changed

- Levels with different extended depth of fields are no longer considered to be the same pyramid.

## [0.17.0] - 2023-12-10

### Added
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,14 @@ poetry run pytest -m integration

A WSI DICOM pyramid is in *wsidicom* represented by a hierarchy of objects of different classes, starting from bottom:

- *WsiDicomFile*, represents a WSI DICOM file, used for accessing WsiDicomFileImageData and WsiDataset.
- *WsiDicomFileImageData*, represents the image data in one or several WSI DICOM files.
- *WsiDataset*, represents the image metadata in one or several WSI DICOM files.
- *WsiDicomReader*, represents a WSI DICOM file reader, used for accessing WsiDicomFileImageData and WsiDataset.
- *WsiDicomFileImageData*, represents the image data in one or several (in case of concatenation) WSI DICOM files.
- *WsiDataset*, represents the image metadata in one or several (in case of concatenation) WSI DICOM files.
- *WsiInstance*, represents image data and image metadata.
- *Level*, represents a group of instances with the same image size, i.e. of the same level.
- *Levels*, represents a group of levels, i.e. the pyrimidal structure.
- *WsiDicom*, represents a collection of levels, labels and overviews.
- *Pyramid*, represents a group of levels, i.e. the pyrimidal structure.
- *Pyramids*, represents a collection of pyramids, each with different image coordate system or extended depth of field.
- *WsiDicom*, represents a collection of pyramids, labels and overviews.

Labels and overviews are structured similarly to levels, but with somewhat different properties and restrictions. For DICOMWeb the WsiDicomFile\* classes are replaced with WsiDicomWeb\* classes.

Expand Down
1 change: 0 additions & 1 deletion tests/file/io/test_wsidicom_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,6 @@ def test_update_dataset(
io = WsiDicomIO(buffer)
dataset = Dataset()
dataset.add(DataElement(LossyImageCompressionRatioTag, "DS", original_values))
print(dataset.LossyImageCompressionRatio)
io.write_dataset(dataset, datetime.datetime.now())

# Act
Expand Down
64 changes: 39 additions & 25 deletions tests/file/test_wsidicom_file_target_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.

from pathlib import Path
from typing import Callable, List, cast
from typing import Callable, List

import pytest
from PIL import ImageChops, ImageFilter, ImageStat
Expand All @@ -24,8 +24,7 @@
from wsidicom.codec import Jpeg2kSettings, JpegSettings, Settings
from wsidicom.codec.encoder import Encoder
from wsidicom.file import OffsetTableType, WsiDicomFileTarget
from wsidicom.group.level import Level
from wsidicom.series.levels import Levels
from wsidicom.series import Pyramid, Pyramids


@pytest.mark.integration
Expand All @@ -36,17 +35,17 @@ def test_save_levels(
):
# Arrange
wsi = wsi_factory(wsi_name)
expected_levels_count = len(wsi.levels)
expected_levels_count = len(wsi.pyramids[0])

# Act
with WsiDicomFileTarget(
tmp_path, generate_uid, 1, 16, OffsetTableType.BASIC, None, False
tmp_path, generate_uid, 1, 16, OffsetTableType.BASIC, None, None, False
) as target:
target.save_levels(wsi.levels)
target.save_pyramids(wsi.pyramids)

# Assert
with WsiDicom.open(tmp_path) as saved_wsi:
assert expected_levels_count == len(saved_wsi.levels)
assert expected_levels_count == len(saved_wsi.pyramids[0])

@pytest.mark.parametrize("include_levels", [[-1], [-1, -2]])
@pytest.mark.parametrize("wsi_name", WsiTestDefinitions.wsi_names("full"))
Expand All @@ -62,13 +61,20 @@ def test_save_limited_levels(

# Act
with WsiDicomFileTarget(
tmp_path, generate_uid, 1, 16, OffsetTableType.BASIC, include_levels, False
tmp_path,
generate_uid,
1,
16,
OffsetTableType.BASIC,
None,
include_levels,
False,
) as target:
target.save_levels(wsi.levels)
target.save_pyramids(wsi.pyramids)

# Assert
with WsiDicom.open(tmp_path) as saved_wsi:
assert len(saved_wsi.levels) == len(include_levels)
assert len(saved_wsi.pyramids[0]) == len(include_levels)

@pytest.mark.parametrize("settings", [Jpeg2kSettings(), JpegSettings()])
@pytest.mark.parametrize("wsi_name", WsiTestDefinitions.wsi_names("full"))
Expand All @@ -82,7 +88,6 @@ def test_transcode(
# Arrange
wsi = wsi_factory(wsi_name)
transcoder = Encoder.create_for_settings(settings)
print(wsi.levels.base_level.default_instance.dataset.LossyImageCompressionRatio)

# Act
with WsiDicomFileTarget(
Expand All @@ -91,16 +96,17 @@ def test_transcode(
1,
16,
OffsetTableType.BASIC,
None,
[-1],
False,
transcoder,
) as target:
target.save_levels(wsi.levels)
target.save_pyramids(wsi.pyramids)

# Assert
with WsiDicom.open(tmp_path) as saved_wsi:
original_level = wsi.levels[-1]
level = saved_wsi.levels.base_level
original_level = wsi.pyramids[0][-1]
level = saved_wsi.pyramids[0].base_level
assert (
level.default_instance.image_data.transfer_syntax
== transcoder.transfer_syntax
Expand Down Expand Up @@ -141,22 +147,29 @@ def test_save_levels_add_missing(
# Arrange
wsi = wsi_factory(wsi_name)
levels_larger_than_tile_size = [
level for level in wsi.levels if level.size.any_greater_than(wsi.tile_size)
level
for level in wsi.pyramids[0]
if level.size.any_greater_than(wsi.pyramids[0].tile_size)
]
expected_levels_count = len(levels_larger_than_tile_size) + 1
levels_missing_smallest_levels = Levels(levels_larger_than_tile_size)
pyramid_missing_smallest_levels = Pyramid(levels_larger_than_tile_size)
pyramids = Pyramids([pyramid_missing_smallest_levels])

# Act
with WsiDicomFileTarget(
tmp_path, generate_uid, 1, 16, OffsetTableType.BASIC, None, True
tmp_path, generate_uid, 1, 16, OffsetTableType.BASIC, None, None, True
) as target:
target.save_levels(levels_missing_smallest_levels)
target.save_pyramids(pyramids)

# Assert
with WsiDicom.open(tmp_path) as saved_wsi:
assert expected_levels_count == len(saved_wsi.levels)
assert saved_wsi.levels[-1].size.all_less_than_or_equal(saved_wsi.tile_size)
assert saved_wsi.levels[-2].size.any_greater_than(saved_wsi.tile_size)
assert expected_levels_count == len(saved_wsi.pyramids[0])
assert saved_wsi.pyramids[0][-1].size.all_less_than_or_equal(
saved_wsi.pyramids[0].tile_size
)
assert saved_wsi.pyramids[0][-2].size.any_greater_than(
saved_wsi.pyramids[0].tile_size
)

@pytest.mark.parametrize("wsi_name", WsiTestDefinitions.wsi_names("full"))
def test_create_child(
Expand All @@ -167,8 +180,8 @@ def test_create_child(
):
# Arrange
wsi = wsi_factory(wsi_name)
target_level = cast(Level, wsi.levels[-2])
source_level = cast(Level, wsi.levels[-3])
target_level = wsi.pyramids[0][-2]
source_level = wsi.pyramids[0][-3]

# Act
with WsiDicomFileTarget(
Expand All @@ -178,13 +191,14 @@ def test_create_child(
100,
OffsetTableType.BASIC,
None,
None,
False,
) as target:
target._save_and_open_level(source_level, wsi.pixel_spacing, 2)
target._save_and_open_level(source_level, wsi.pyramids[0].pixel_spacing, 2)

# Assert
with WsiDicom.open(tmp_path) as created_wsi:
created_size = created_wsi.levels[0].size.to_tuple()
created_size = created_wsi.pyramids[0][0].size.to_tuple()
target_size = target_level.size.to_tuple()
assert created_size == target_size

Expand Down
2 changes: 1 addition & 1 deletion tests/test_annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ def dicom_round_trip(cls, dicom: AnnotationInstance) -> AnnotationInstance:
Collection of annotations to save.
Returns
----------
-------
AnnotationInstance
Read back annotation collection.
Expand Down
182 changes: 182 additions & 0 deletions tests/test_pyramids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
from typing import List, Optional, Tuple
from pydicom import Dataset
import pytest
from wsidicom.geometry import Orientation, PointMm, Size, SizeMm
from wsidicom.instance.dataset import ImageType, WsiDataset
from wsidicom.instance.image_coordinate_system import ImageCoordinateSystem

from wsidicom.instance.instance import WsiInstance
from wsidicom.series import Pyramids
from pydicom.uid import generate_uid, UID

from wsidicom.uid import SlideUids


class WsiTestInstance(WsiInstance):
def __init__(
self,
size: Size,
tile_size: Size,
pixel_spacing: SizeMm,
study_instance_uid: UID,
series_instance_uid: UID,
frame_of_reference_uid: UID,
image_coordinate_system: ImageCoordinateSystem,
ext_depth_of_field: Optional[Tuple[int, float]],
):
self._size = size
self._tile_size = tile_size
self._pixel_spacing = pixel_spacing
self._image_coordinate_system = image_coordinate_system
if ext_depth_of_field is not None:
self._ext_depth_of_field_planes = ext_depth_of_field[0]
self._ext_depth_of_field_plane_distance = ext_depth_of_field[1]
self._ext_depth_of_field = True
else:
self._ext_depth_of_field_planes = None
self._ext_depth_of_field_plane_distance = None
self._ext_depth_of_field = False
dataset = Dataset()
dataset.SOPInstanceUID = generate_uid()
dataset.ImagedVolumeWidth = 10
dataset.ImagedVolumeHeight = 10
self._datasets = [WsiDataset(dataset)]
self._image_data = None
self._identifier = generate_uid()
self._uids = SlideUids(
study_instance_uid,
series_instance_uid,
frame_of_reference_uid,
)
self._image_type = ImageType.VOLUME

@property
def size(self) -> Size:
return self._size

@property
def tile_size(self) -> Size:
return self._tile_size

@property
def pixel_spacing(self) -> SizeMm:
return self._pixel_spacing

@property
def image_coordinate_system(self) -> ImageCoordinateSystem:
return self._image_coordinate_system

@property
def ext_depth_of_field(self) -> bool:
return self._ext_depth_of_field

@property
def ext_depth_of_field_planes(self) -> Optional[int]:
return self._ext_depth_of_field_planes

@property
def ext_depth_of_field_plane_distance(self) -> Optional[float]:
return self._ext_depth_of_field_plane_distance


def create_pyramid_instance(
image_coordinate_system: ImageCoordinateSystem,
ext_depth_of_field: Optional[Tuple[int, float]],
study_instance_uid: UID,
series_instance_uid: UID,
frame_of_reference_uid: UID,
):
size = Size(100, 100)
tile_size = Size(10, 10)
pixel_spacing = SizeMm(0.5, 0.5)
return WsiTestInstance(
size,
tile_size,
pixel_spacing,
study_instance_uid,
series_instance_uid,
frame_of_reference_uid,
image_coordinate_system,
ext_depth_of_field,
)


@pytest.fixture()
def study_instance_uid():
return generate_uid()


@pytest.fixture()
def series_instance_uid():
return generate_uid()


@pytest.fixture()
def frame_of_reference_uid():
return generate_uid()


class TestPyramids:
@pytest.mark.parametrize(
[
"instance_definitions",
"expected_pyramid_count",
],
[
[
[
(PointMm(0, 0), Orientation((0, -1, 0, 1, 0, 0)), None),
(PointMm(0, 0), Orientation((0, -1, 0, 1, 0, 0)), None),
],
1,
],
[
[
(PointMm(0, 0), Orientation((0, -1, 0, 1, 0, 0)), (5, 0.5)),
(PointMm(0, 0), Orientation((0, -1, 0, 1, 0, 0)), (5, 0.5)),
],
1,
],
[
[
(PointMm(0, 0), Orientation((0, -1, 0, 1, 0, 0)), None),
(PointMm(0, 0), Orientation((0, -1, 0, 1, 0, 0)), (5, 0.5)),
],
2,
],
[
[
(PointMm(0, 0), Orientation((0, -1, 0, 1, 0, 0)), None),
(PointMm(2, 2), Orientation((0, -1, 0, 1, 0, 0)), None),
],
2,
],
],
)
def test_open_number_of_created_pyramids(
self,
instance_definitions: List[
Tuple[PointMm, Orientation, Optional[Tuple[int, float]]]
],
study_instance_uid: UID,
series_instance_uid: UID,
frame_of_reference_uid: UID,
expected_pyramid_count: int,
):
# Arrange
instances = [
create_pyramid_instance(
ImageCoordinateSystem(instance_definition[0], instance_definition[1]),
instance_definition[2],
study_instance_uid,
series_instance_uid,
frame_of_reference_uid,
)
for instance_definition in instance_definitions
]

# Act
pyramids = Pyramids.open(instances)

# Assert
assert len(pyramids) == expected_pyramid_count
Loading

0 comments on commit e4f8645

Please sign in to comment.