Skip to content

Commit ad46d6c

Browse files
authored
Fractional and Multisegment Pyramids (#297)
* Allow fractional and multisegment pyramids; add tests * Update docstring * Fix for labelmap style masks * Lint fix
1 parent 7a771d9 commit ad46d6c

2 files changed

Lines changed: 243 additions & 13 deletions

File tree

src/highdicom/seg/pyramid.py

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ def create_segmentation_pyramid(
7777
List of source images. If there are multiple source images, they should
7878
be from the same series and pyramid.
7979
pixel_arrays: Sequence[numpy.ndarray]
80-
List of segmentation pixel arrays. Each should be a total pixel matrix.
80+
List of segmentation pixel arrays. Each should be a total pixel matrix,
81+
i.e. have shape (rows, columns), (1, rows, columns), or (1, rows,
82+
columns, segments). Otherwise all options supported by the constructor
83+
of :class:`highdicom.seg.Segmentation` are permitted.
8184
segmentation_type: Union[str, highdicom.seg.SegmentationTypeValues]
8285
Type of segmentation, either ``"BINARY"`` or ``"FRACTIONAL"``
8386
segment_descriptions: Sequence[highdicom.seg.SegmentDescription]
@@ -123,16 +126,17 @@ def create_segmentation_pyramid(
123126
A human readable label for the output pyramid.
124127
**kwargs: Any
125128
Any further parameters are passed directly to the constructor of the
126-
:class:highdicom.seg.Segmentation object. However the following
129+
:class:`highdicom.seg.Segmentation` object. However the following
127130
parameters are disallowed: ``instance_number``, ``sop_instance_uid``,
128131
``plane_orientation``, ``plane_positions``, ``pixel_measures``,
129132
``pixel_array``, ``tile_pixel_array``.
130133
131134
Note
132135
----
133-
Downsampling is performed via simple nearest neighbor interpolation. If
134-
more control is needed over the downsampling process (for example
135-
anti-aliasing), explicitly pass the downsampled arrays.
136+
Downsampling is performed via simple nearest neighbor interpolation (for
137+
``BINARY`` segmentations) or bi-linear interpolation (for ``FRACTIONAL``
138+
segmentations). If more control is needed over the downsampling process
139+
(for example anti-aliasing), explicitly pass the downsampled arrays.
136140
137141
"""
138142
# Disallow duplicate items in kwargs
@@ -149,9 +153,11 @@ def create_segmentation_pyramid(
149153
if len(error_keys) > 0:
150154
raise TypeError(
151155
f'kwargs supplied to the create_segmentation_pyramid function '
152-
f'should not contain a value for parameter {error_keys[0]}.'
156+
f'should not contain a value for parameter {list(error_keys)[0]}.'
153157
)
154158

159+
segmentation_type = SegmentationTypeValues(segmentation_type)
160+
155161
if pyramid_uid is None:
156162
pyramid_uid = UID()
157163
if series_instance_uid is None:
@@ -263,6 +269,30 @@ def create_segmentation_pyramid(
263269
)
264270

265271
# Check that pixel arrays have an appropriate shape
272+
if len(set(p.ndim for p in pixel_arrays)) != 1:
273+
raise ValueError(
274+
'Each item of argument "pixel_arrays" must have the same number of '
275+
'dimensions.'
276+
)
277+
if pixel_arrays[0].ndim == 4:
278+
n_segment_channels = pixel_arrays[0].shape[3]
279+
else:
280+
n_segment_channels = None
281+
282+
dtype = pixel_arrays[0].dtype
283+
if dtype in (np.bool_, np.uint8, np.uint16):
284+
resampler = Image.Resampling.NEAREST
285+
elif dtype in (np.float32, np.float64):
286+
if segmentation_type == SegmentationTypeValues.FRACTIONAL:
287+
resampler = Image.Resampling.BILINEAR
288+
else:
289+
# This is a floating point image that will ultimately be treated as
290+
# binary
291+
resampler = Image.Resampling.NEAREST
292+
else:
293+
raise TypeError('Pixel array has an invalid data type.')
294+
295+
# Checks on consistency of the pixel arrays
266296
for pixel_array in pixel_arrays:
267297
if pixel_array.ndim not in (2, 3, 4):
268298
raise ValueError(
@@ -274,6 +304,17 @@ def create_segmentation_pyramid(
274304
'Each item of argument "pixel_arrays" must contain a single '
275305
'frame, with a size of 1 along dimension 0.'
276306
)
307+
if pixel_array.dtype != dtype:
308+
raise TypeError(
309+
'Each item of argument "pixel_arrays" must have '
310+
'the same datatype.'
311+
)
312+
if pixel_array.ndim == 4:
313+
if pixel_array.shape[3] != n_segment_channels:
314+
raise ValueError(
315+
'Each item of argument "pixel_arrays" must have '
316+
'the same shape down axis 3.'
317+
)
277318

278319
# Check the pixel arrays are appropriately ordered
279320
for index in range(1, len(pixel_arrays)):
@@ -322,7 +363,17 @@ def create_segmentation_pyramid(
322363

323364
if n_pix_arrays == 1:
324365
# Create a pillow image for use later with resizing
325-
mask_image = Image.fromarray(pixel_arrays[0])
366+
if pixel_arrays[0].ndim == 2:
367+
mask_images = [Image.fromarray(pixel_arrays[0])]
368+
elif pixel_arrays[0].ndim == 3:
369+
# Remove frame dimension before casting
370+
mask_images = [Image.fromarray(pixel_arrays[0][0])]
371+
else: # ndim = 4
372+
# One "Image" for each segment
373+
mask_images = [
374+
Image.fromarray(pixel_arrays[0][0, :, :, i])
375+
for i in range(pixel_arrays[0].shape[3])
376+
]
326377

327378
all_segs = []
328379

@@ -338,9 +389,7 @@ def create_segmentation_pyramid(
338389
else:
339390
if output_level == 0:
340391
pixel_array = pixel_arrays[0]
341-
need_resize = False
342392
else:
343-
need_resize = True
344393
if n_sources > 1:
345394
output_size = (
346395
source_image.TotalPixelMatrixColumns,
@@ -353,10 +402,14 @@ def create_segmentation_pyramid(
353402
int(source_images[0].TotalPixelMatrixRows / f)
354403
)
355404

356-
if need_resize:
357-
pixel_array = np.array(
358-
mask_image.resize(output_size, Image.Resampling.NEAREST)
359-
)
405+
resized_masks = [
406+
np.array(im.resize(output_size, resampler))
407+
for im in mask_images
408+
]
409+
if len(resized_masks) > 1:
410+
pixel_array = np.stack(resized_masks, axis=-1)[None]
411+
else:
412+
pixel_array = resized_masks[0][None]
360413

361414
if n_sources == 1:
362415
source_pixel_measures = (

tests/test_seg.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3761,9 +3761,31 @@ def setUp(self):
37613761
)
37623762
self._seg_pix[5:15, 3:8] = 1
37633763

3764+
self._seg_pix_multisegment = np.zeros(
3765+
(1, *tpm_size, 3),
3766+
dtype=np.uint8,
3767+
)
3768+
self._seg_pix_multisegment[0, 5:15, 3:8, 0] = 1
3769+
self._seg_pix_multisegment[0, 23:42, 13:18, 1] = 1
3770+
self._seg_pix_multisegment[0, 45:48, 25:30, 1] = 1
3771+
3772+
self._seg_pix_fractional = np.zeros(
3773+
tpm_size,
3774+
dtype=np.float32,
3775+
)
3776+
self._seg_pix_fractional[5:15, 3:8] = 0.5
3777+
self._seg_pix_fractional[3:5, 10:12] = 1.0
3778+
37643779
self._n_downsamples = 3
37653780
self._downsampled_pix_arrays = [self._seg_pix]
3781+
self._downsampled_pix_arrays_multisegment = [self._seg_pix_multisegment]
3782+
self._downsampled_pix_arrays_fractional = [self._seg_pix_fractional]
37663783
seg_pil = Image.fromarray(self._seg_pix)
3784+
seg_pil_fractional = Image.fromarray(self._seg_pix_fractional)
3785+
seg_pil_multisegment = [
3786+
Image.fromarray(self._seg_pix_multisegment[0, :, :, i])
3787+
for i in range(self._seg_pix_multisegment.shape[3])
3788+
]
37673789
pyramid_uid = UID()
37683790
self._source_pyramid = [deepcopy(self._sm_image)]
37693791
self._source_pyramid[0].PyramidUID = pyramid_uid
@@ -3780,6 +3802,19 @@ def setUp(self):
37803802
)
37813803
self._downsampled_pix_arrays.append(resized)
37823804

3805+
resized_fractional = np.array(
3806+
seg_pil_fractional.resize(out_size, Image.Resampling.BILINEAR)
3807+
)
3808+
self._downsampled_pix_arrays_fractional.append(resized_fractional)
3809+
3810+
resized_multisegment = np.stack(
3811+
[
3812+
im.resize(out_size, Image.Resampling.NEAREST) for im in seg_pil_multisegment
3813+
],
3814+
axis=-1
3815+
)[None]
3816+
self._downsampled_pix_arrays_multisegment.append(resized_multisegment)
3817+
37833818
# Mock lower-resolution source images. No need to have their pixel
37843819
# data correctly set as it isn't used. Just update the relevant
37853820
# metadata
@@ -3820,6 +3855,21 @@ def setUp(self):
38203855
)
38213856
),
38223857
]
3858+
self._segment_descriptions_multi = [
3859+
SegmentDescription(
3860+
segment_number=i,
3861+
segment_label=f'Segment #{i}',
3862+
segmented_property_category=self._segmented_property_category,
3863+
segmented_property_type=self._segmented_property_type,
3864+
algorithm_type=SegmentAlgorithmTypeValues.AUTOMATIC.value,
3865+
algorithm_identification=AlgorithmIdentificationSequence(
3866+
name='bla',
3867+
family=codes.DCM.ArtificialIntelligence,
3868+
version='v1'
3869+
)
3870+
)
3871+
for i in range(1, 4)
3872+
]
38233873

38243874
def test_pyramid_factors(self):
38253875
downsample_factors = [2.0, 5.0]
@@ -3925,6 +3975,83 @@ def test_multiple_source_single_pixel_array(self):
39253975
pix
39263976
)
39273977

3978+
def test_multiple_source_single_pixel_array_multisegment(self):
3979+
# Test construction when given multiple source images and a single
3980+
# segmentation image
3981+
segs = create_segmentation_pyramid(
3982+
source_images=self._source_pyramid,
3983+
pixel_arrays=[self._seg_pix_multisegment],
3984+
segmentation_type=SegmentationTypeValues.BINARY,
3985+
segment_descriptions=self._segment_descriptions_multi,
3986+
series_instance_uid=UID(),
3987+
series_number=1,
3988+
manufacturer='Foo',
3989+
manufacturer_model_name='Bar',
3990+
software_versions='1',
3991+
device_serial_number='123',
3992+
)
3993+
3994+
assert len(segs) == len(self._source_pyramid)
3995+
for pix, seg in zip(self._downsampled_pix_arrays_multisegment, segs):
3996+
assert hasattr(seg, 'PyramidUID')
3997+
seg_pix = seg.get_total_pixel_matrix()
3998+
print("pixel array", pix.shape, seg_pix.shape)
3999+
print("pixel array", pix.max(), seg_pix.max())
4000+
assert np.array_equal(
4001+
seg.get_total_pixel_matrix(),
4002+
pix[0]
4003+
)
4004+
4005+
def test_multiple_source_single_pixel_array_float(self):
4006+
# Test construction when given multiple source images and a single
4007+
# segmentation image
4008+
segs = create_segmentation_pyramid(
4009+
source_images=self._source_pyramid,
4010+
pixel_arrays=[self._seg_pix.astype(np.float32)],
4011+
segmentation_type=SegmentationTypeValues.BINARY,
4012+
segment_descriptions=self._segment_descriptions,
4013+
series_instance_uid=UID(),
4014+
series_number=1,
4015+
manufacturer='Foo',
4016+
manufacturer_model_name='Bar',
4017+
software_versions='1',
4018+
device_serial_number='123',
4019+
)
4020+
4021+
assert len(segs) == len(self._source_pyramid)
4022+
for pix, seg in zip(self._downsampled_pix_arrays, segs):
4023+
assert hasattr(seg, 'PyramidUID')
4024+
assert np.array_equal(
4025+
seg.get_total_pixel_matrix(combine_segments=True),
4026+
pix
4027+
)
4028+
4029+
def test_multiple_source_single_pixel_array_fractional(self):
4030+
# Test construction when given multiple source images and a single
4031+
# segmentation image
4032+
segs = create_segmentation_pyramid(
4033+
source_images=self._source_pyramid,
4034+
pixel_arrays=[self._seg_pix_fractional],
4035+
segmentation_type=SegmentationTypeValues.FRACTIONAL,
4036+
segment_descriptions=self._segment_descriptions,
4037+
series_instance_uid=UID(),
4038+
series_number=1,
4039+
manufacturer='Foo',
4040+
manufacturer_model_name='Bar',
4041+
software_versions='1',
4042+
device_serial_number='123',
4043+
max_fractional_value=200,
4044+
)
4045+
4046+
assert len(segs) == len(self._source_pyramid)
4047+
for pix, seg in zip(self._downsampled_pix_arrays_fractional, segs):
4048+
assert hasattr(seg, 'PyramidUID')
4049+
assert np.allclose(
4050+
seg.get_total_pixel_matrix(combine_segments=False)[:, :, 0],
4051+
pix,
4052+
atol=0.005, # 1/200
4053+
)
4054+
39284055
def test_multiple_source_multiple_pixel_arrays(self):
39294056
# Test construction when given multiple source images and multiple
39304057
# segmentation images
@@ -3948,3 +4075,53 @@ def test_multiple_source_multiple_pixel_arrays(self):
39484075
seg.get_total_pixel_matrix(combine_segments=True),
39494076
pix
39504077
)
4078+
4079+
def test_multiple_source_multiple_pixel_arrays_multisegment(self):
4080+
# Test construction when given multiple source images and multiple
4081+
# segmentation images
4082+
segs = create_segmentation_pyramid(
4083+
source_images=self._source_pyramid,
4084+
pixel_arrays=self._downsampled_pix_arrays_multisegment,
4085+
segmentation_type=SegmentationTypeValues.BINARY,
4086+
segment_descriptions=self._segment_descriptions_multi,
4087+
series_instance_uid=UID(),
4088+
series_number=1,
4089+
manufacturer='Foo',
4090+
manufacturer_model_name='Bar',
4091+
software_versions='1',
4092+
device_serial_number='123',
4093+
)
4094+
4095+
assert len(segs) == len(self._source_pyramid)
4096+
for pix, seg in zip(self._downsampled_pix_arrays_multisegment, segs):
4097+
assert hasattr(seg, 'PyramidUID')
4098+
assert np.array_equal(
4099+
seg.get_total_pixel_matrix(),
4100+
pix[0]
4101+
)
4102+
4103+
def test_multiple_source_multiple_pixel_arrays_multisegment_labelmap(self):
4104+
# Test construction when given multiple source images and multiple
4105+
# segmentation images
4106+
mask = np.argmax(self._seg_pix_multisegment, axis=3).astype(np.uint8)
4107+
segs = create_segmentation_pyramid(
4108+
source_images=self._source_pyramid,
4109+
pixel_arrays=mask,
4110+
segmentation_type=SegmentationTypeValues.BINARY,
4111+
segment_descriptions=self._segment_descriptions_multi,
4112+
series_instance_uid=UID(),
4113+
series_number=1,
4114+
manufacturer='Foo',
4115+
manufacturer_model_name='Bar',
4116+
software_versions='1',
4117+
device_serial_number='123',
4118+
)
4119+
4120+
assert len(segs) == len(self._source_pyramid)
4121+
for pix, seg in zip(self._downsampled_pix_arrays_multisegment, segs):
4122+
mask = np.argmax(pix, axis=3).astype(np.uint8)
4123+
assert hasattr(seg, 'PyramidUID')
4124+
assert np.array_equal(
4125+
seg.get_total_pixel_matrix(combine_segments=True),
4126+
mask[0]
4127+
)

0 commit comments

Comments
 (0)