diff --git a/CHANGELOG.md b/CHANGELOG.md index 744356f2..05962a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support for opening DICOMDIR files using `open_dicomdir()`. + ## [0.13.0] - 2023-11-11 ### Added diff --git a/README.md b/README.md index b2cdd4bb..929f8574 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,14 @@ from wsidicom import WsiDicom slide = WsiDicom.open([file_stream_1, file_stream_2, ... ]) ``` +***Or load a WSI dataset from a DICOMDIR.*** + +```python +from wsidicom import WsiDicom + +slide = WsiDicom.open_dicomdir(path_to_dicom_dir) +``` + ***Or load a WSI dataset from DICOMWeb.*** ```python diff --git a/wsidicom/file/wsidicom_file_source.py b/wsidicom/file/wsidicom_file_source.py index be1b6039..a80023ce 100644 --- a/wsidicom/file/wsidicom_file_source.py +++ b/wsidicom/file/wsidicom_file_source.py @@ -14,13 +14,17 @@ """A source for reading WSI DICOM files.""" -from collections import defaultdict import io import logging +from collections import defaultdict from pathlib import Path from typing import BinaryIO, Dict, Iterable, List, Optional, Tuple, Union -from pydicom.uid import UID +from pydicom import dcmread +from pydicom.errors import InvalidDicomError +from pydicom.filereader import _read_file_meta_info, read_preamble +from pydicom.fileset import FileSet +from pydicom.uid import UID, MediaStorageDirectoryStorage from wsidicom.errors import ( WsiDicomNotFoundError, @@ -33,8 +37,6 @@ from wsidicom.instance import ImageType, TileType, WsiDataset, WsiInstance from wsidicom.source import Source from wsidicom.uid import ANN_SOP_CLASS_UID, WSI_SOP_CLASS_UID, SlideUids -from pydicom.filereader import read_preamble, _read_file_meta_info -from pydicom.errors import InvalidDicomError class WsiDicomFileSource(Source): @@ -160,6 +162,27 @@ def contains_levels(self) -> bool: """Returns true source has one level that can be read with WsiDicom.""" return len(self.image_files) > 0 + @classmethod + def open_dicomdir(cls, path: Union[str, Path]): + """Open a DICOMDIR file and return a WsiDicomFileSource for contained files. + + Parameters + ---------- + path: Union[str, Path] + Path to DICOMDIR file. + + Returns + ---------- + WsiDicomFileSource + Source for files in DICOMDIR. + """ + dicomdir = dcmread(path) + if dicomdir.file_meta.MediaStorageSOPClassUID != MediaStorageDirectoryStorage: + raise ValueError() + fileset = FileSet(dicomdir) + files = [file.path for file in fileset] + return cls(files) + @staticmethod def _open_file(file: Union[Path, BinaryIO]) -> Tuple[BinaryIO, Optional[Path]]: """Open stream if file is path. Return stream and optional filepath.""" diff --git a/wsidicom/wsidicom.py b/wsidicom/wsidicom.py index f3bc7f07..81688eca 100644 --- a/wsidicom/wsidicom.py +++ b/wsidicom/wsidicom.py @@ -151,6 +151,33 @@ def open_web( ) return cls(source, label, True) + @classmethod + def open_dicomdir( + cls, path: Union[str, Path], label: Optional[Union[PILImage, str, Path]] = None + ) -> "WsiDicom": + """Open WSI DICOM files in DICOMDIR and return a WsiDicom object. + + Parameters + ---------- + path: Union[str, Path] + Path to DICOMDIR file or directory with a DICOMDIR file. + label: Optional[Union[PILImage, str, Path]] = None + Optional label image to use instead of label found in path. + + Returns + ---------- + WsiDicom + WsiDicom created from WSI DICOM files in DICOMDIR. + """ + if isinstance(path, str): + path = Path(path) + if path.is_dir(): + path = path.joinpath("DICOMDIR") + if not path.is_file() or not path.exists(): + raise FileNotFoundError(f"DICOMDIR file {path} not found.") + source = WsiDicomFileSource.open_dicomdir(path) + return cls(source, label, True) + def __enter__(self): return self