Skip to content

Commit 4fffa55

Browse files
authored
Merge branch 'develop' into mypy-type-check-wsicore
2 parents 7c4e12e + 6b4a75a commit 4fffa55

File tree

4 files changed

+106
-9
lines changed

4 files changed

+106
-9
lines changed

tests/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,17 @@ def sample_jp2(remote_sample: Callable) -> Path:
162162
return remote_sample("jp2-omnyx-small")
163163

164164

165+
@pytest.fixture(scope="session")
166+
def sample_dicom(remote_sample: Callable) -> Path:
167+
"""Sample pytest fixture for DICOM images.
168+
169+
This fixture downloads a sample DICOM file in a standard format for testing.
170+
The file represents a single DICOM image and is stored in a temporary directory.
171+
172+
"""
173+
return remote_sample("dicom-1")
174+
175+
165176
@pytest.fixture(scope="session")
166177
def sample_all_wsis(
167178
sample_ndpi: Path,

tests/test_wsireader.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,41 @@
8686
# -------------------------------------------------------------------------------------
8787
# Utility Test Functions
8888
# -------------------------------------------------------------------------------------
89+
def get_tissue_com_tile(reader: WSIReader, size: int) -> IntBounds:
90+
"""Returns bounds of a tile located approximately at COM of the tissue.
91+
92+
Uses reader.tissue_mask() to find the center of mass of the tissue
93+
and returns a tile centered at that point, at requested size. Used
94+
to ensure we are looking at a tissue region when doing level consistency
95+
tests etc.
96+
97+
Args:
98+
reader (WSIReader): WSIReader instance.
99+
size (int): Size at baseline of the tile to return.
100+
101+
Returns:
102+
IntBounds: Baseline bounds of the tile centered at COM of the tissue.
103+
104+
"""
105+
mask = reader.tissue_mask(resolution=8.0, units="mpp").img
106+
107+
# Find the center of mass of the tissue
108+
ys, xs = np.nonzero(mask)
109+
com_y = int(ys.mean())
110+
com_x = int(xs.mean())
111+
# convert to baseline coordinates
112+
com_x = int(com_x * (8.0 / reader.info.mpp[0]))
113+
com_y = int(com_y * (8.0 / reader.info.mpp[1]))
114+
115+
# Calculate bounds for the tile centered at COM
116+
half_size = size // 2
117+
bounds = (
118+
max(0, com_x - half_size),
119+
max(0, com_y - half_size),
120+
min(reader.info.slide_dimensions[0], com_x + half_size),
121+
min(reader.info.slide_dimensions[1], com_y + half_size),
122+
)
123+
return np.array(bounds)
89124

90125

91126
def strictly_increasing(sequence: Iterable) -> bool:
@@ -2702,7 +2737,8 @@ def test_read_rect_level_consistency(wsi: WSIReader) -> None:
27022737
they are aligned.
27032738
27042739
"""
2705-
location = (0, 0)
2740+
bounds = get_tissue_com_tile(wsi, 1024)
2741+
location = bounds[:2]
27062742
size = np.array([1024, 1024])
27072743
# Avoid testing very small levels (e.g. as in Omnyx JP2) because
27082744
# MSE for very small levels is noisy.
@@ -2737,7 +2773,7 @@ def test_read_bounds_level_consistency(wsi: WSIReader) -> None:
27372773
they are aligned.
27382774
27392775
"""
2740-
bounds = (0, 0, 1024, 1024)
2776+
bounds = get_tissue_com_tile(wsi, 1024)
27412777
# This logic can be moved from the helper to here when other
27422778
# reader classes have been parameterised into scenarios also.
27432779
read_bounds_level_consistency(wsi, bounds)
@@ -2782,15 +2818,17 @@ def test_read_rect_coord_space_consistency(wsi: WSIReader) -> None:
27822818
will not be of the same size, but the field of view will match.
27832819
27842820
"""
2821+
bounds = get_tissue_com_tile(wsi, 2000)
2822+
location = (bounds[:2] // 2) * 2 # ensure even coordinates
27852823
roi1 = wsi.read_rect(
2786-
np.array([500, 500]),
2824+
location,
27872825
np.array([2000, 2000]),
27882826
coord_space="baseline",
27892827
resolution=1.00,
27902828
units="baseline",
27912829
)
27922830
roi2 = wsi.read_rect(
2793-
np.array([250, 250]),
2831+
location // 2,
27942832
np.array([1000, 1000]),
27952833
coord_space="resolution",
27962834
resolution=0.5,
@@ -2991,3 +3029,22 @@ def test_fsspec_reader_open_pass_empty_json(tmp_path: Path) -> None:
29913029
json_path.write_text("{}")
29923030

29933031
assert not FsspecJsonWSIReader.is_valid_zarr_fsspec(str(json_path))
3032+
3033+
3034+
def test_oob_read_dicom(sample_dicom: Path) -> None:
3035+
"""Test that out of bounds returns background value.
3036+
3037+
For consistency with openslide, our readers should return a
3038+
background tile when reading out of bounds.
3039+
3040+
"""
3041+
wsi = DICOMWSIReader(sample_dicom)
3042+
# Read a region that is out of bounds
3043+
region = wsi.read_rect(
3044+
location=(200000, 200),
3045+
size=(100, 100),
3046+
)
3047+
# Check that the region is the same size as the requested size
3048+
assert region.shape == (100, 100, 3)
3049+
# Check that the region is white (255)
3050+
assert np.all(region == 255)

tiatoolbox/visualization/bokeh_app/main.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,16 @@ def populate_slide_list(slide_folder: Path, search_txt: str | None = None) -> No
725725
"""Populate the slide list with the available slides."""
726726
file_list = []
727727
len_slidepath = len(slide_folder.parts)
728-
for ext in ["*.svs", "*ndpi", "*.tiff", "*.mrxs", "*.jpg", "*.png", "*.tif"]:
728+
for ext in [
729+
"*.svs",
730+
"*ndpi",
731+
"*.tiff",
732+
"*.mrxs",
733+
"*.jpg",
734+
"*.png",
735+
"*.tif",
736+
"*.dcm",
737+
]:
729738
file_list.extend(list(Path(slide_folder).glob(str(Path("*") / ext))))
730739
file_list.extend(list(Path(slide_folder).glob(ext)))
731740
if search_txt is None:
@@ -2086,7 +2095,16 @@ def setup_doc(self: DocConfig, base_doc: Document) -> tuple[Row, Tabs]:
20862095

20872096
# Set initial slide to first one in base folder
20882097
slide_list = []
2089-
for ext in ["*.svs", "*ndpi", "*.tiff", "*.tif", "*.mrxs", "*.png", "*.jpg"]:
2098+
for ext in [
2099+
"*.svs",
2100+
"*ndpi",
2101+
"*.tiff",
2102+
"*.tif",
2103+
"*.mrxs",
2104+
"*.png",
2105+
"*.jpg",
2106+
"*.dcm",
2107+
]:
20902108
slide_list.extend(list(doc_config["slide_folder"].glob(ext)))
20912109
slide_list.extend(
20922110
list(doc_config["slide_folder"].glob(str(Path("*") / ext))),

tiatoolbox/wsicore/wsireader.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4595,6 +4595,7 @@ def _info(self: DICOMWSIReader) -> WSIMeta:
45954595
mpp=mpp,
45964596
level_count=len(level_dimensions),
45974597
vendor=dataset.Manufacturer,
4598+
file_path=self.input_path,
45984599
)
45994600

46004601
def read_rect(
@@ -4826,8 +4827,19 @@ def read_rect(
48264827
_, constrained_read_size = utils.transforms.bounds2locsize(
48274828
constrained_read_bounds,
48284829
)
4830+
4831+
# if out of bounds, return empty image consistent with openslide
4832+
if np.any(np.array(constrained_read_size) <= 0):
4833+
return (
4834+
np.ones(
4835+
shape=(int(size[1]), int(size[0]), 3),
4836+
dtype=np.uint8,
4837+
)
4838+
* 255
4839+
)
4840+
48294841
dicom_level = wsi.levels[read_level].level
4830-
im_region = wsi.read_region(location, dicom_level, constrained_read_size)
4842+
im_region = wsi.read_region(level_location, dicom_level, constrained_read_size)
48314843
im_region = np.array(im_region)
48324844

48334845
# Apply padding outside the slide area
@@ -5003,7 +5015,6 @@ class docstrings for more information.
50035015
wsi = self.wsi
50045016

50055017
# Read at optimal level and corrected read size
5006-
location_at_baseline = bounds_at_baseline[:2]
50075018
level_location, size_at_read_level = utils.transforms.bounds2locsize(
50085019
bounds_at_read_level,
50095020
)
@@ -5016,7 +5027,7 @@ class docstrings for more information.
50165027
_, read_size = utils.transforms.bounds2locsize(read_bounds)
50175028
dicom_level = wsi.levels[read_level].level
50185029
im_region = wsi.read_region(
5019-
location=location_at_baseline,
5030+
location=level_location,
50205031
level=dicom_level,
50215032
size=read_size,
50225033
)

0 commit comments

Comments
 (0)