-
Notifications
You must be signed in to change notification settings - Fork 12
/
edit_volumes.py
2836 lines (2423 loc) · 139 KB
/
edit_volumes.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
This file contains functions to edit/preprocess volumes (i.e. not tensors!).
These functions are sorted in five categories:
1- volume editing: this can be applied to any volume (i.e. images or label maps). It contains:
-mask_volume
-rescale_volume
-crop_volume
-crop_volume_around_region
-crop_volume_with_idx
-pad_volume
-flip_volume
-resample_volume
-resample_volume_like
-get_ras_axes
-align_volume_to_ref
-blur_volume
2- label map editing: can be applied to label maps only. It contains:
-correct_label_map
-mask_label_map
-smooth_label_map
-erode_label_map
-get_largest_connected_component
-compute_hard_volumes
-compute_distance_map
3- editing all volumes in a folder: functions are more or less the same as 1, but they now apply to all the volumes
in a given folder. Thus we provide folder paths rather than numpy arrays as inputs. It contains:
-mask_images_in_dir
-rescale_images_in_dir
-crop_images_in_dir
-crop_images_around_region_in_dir
-pad_images_in_dir
-flip_images_in_dir
-align_images_in_dir
-correct_nans_images_in_dir
-blur_images_in_dir
-create_mutlimodal_images
-convert_images_in_dir_to_nifty
-mri_convert_images_in_dir
-samseg_images_in_dir
-niftyreg_images_in_dir
-upsample_anisotropic_images
-simulate_upsampled_anisotropic_images
-check_images_in_dir
4- label maps in dir: same as 3 but for label map-specific functions. It contains:
-correct_labels_in_dir
-mask_labels_in_dir
-smooth_labels_in_dir
-erode_labels_in_dir
-upsample_labels_in_dir
-compute_hard_volumes_in_dir
-build_atlas
5- dataset editing: functions for editing datasets (i.e. images with corresponding label maps). It contains:
-check_images_and_labels
-crop_dataset_to_minimum_size
-subdivide_dataset_to_patches
If you use this code, please cite the first SynthSeg paper:
https://github.com/BBillot/lab2im/blob/master/bibtex.bib
Copyright 2020 Benjamin Billot
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in
compliance with the License. You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is
distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing permissions and limitations under the
License.
"""
# python imports
import os
import csv
import shutil
import numpy as np
import tensorflow as tf
import keras.layers as KL
from keras.models import Model
from scipy.ndimage.filters import convolve
from scipy.ndimage import label as scipy_label
from scipy.interpolate import RegularGridInterpolator
from scipy.ndimage.morphology import distance_transform_edt, binary_fill_holes
from scipy.ndimage import binary_dilation, binary_erosion, gaussian_filter
# project imports
from . import utils
from .layers import GaussianBlur, ConvertLabels
from .edit_tensors import blurring_sigma_for_downsampling
# ---------------------------------------------------- edit volume -----------------------------------------------------
def mask_volume(volume, mask=None, threshold=0.1, dilate=0, erode=0, fill_holes=False, masking_value=0,
return_mask=False, return_copy=True):
"""Mask a volume, either with a given mask, or by keeping only the values above a threshold.
:param volume: a numpy array, possibly with several channels
:param mask: (optional) a numpy array to mask volume with.
Mask doesn't have to be a 0/1 array, all strictly positive values of mask are considered for masking volume.
Mask should have the same size as volume. If volume has several channels, mask can either be uni- or multi-channel.
In the first case, the same mask is applied to all channels.
:param threshold: (optional) If mask is None, masking is performed by keeping thresholding the input.
:param dilate: (optional) number of voxels by which to dilate the provided or computed mask.
:param erode: (optional) number of voxels by which to erode the provided or computed mask.
:param fill_holes: (optional) whether to fill the holes in the provided or computed mask.
:param masking_value: (optional) masking value
:param return_mask: (optional) whether to return the applied mask
:param return_copy: (optional) whether to return the original volume or a copy. Default is copy.
:return: the masked volume, and the applied mask if return_mask is True.
"""
# get info
new_volume = volume.copy() if return_copy else volume
vol_shape = list(new_volume.shape)
n_dims, n_channels = utils.get_dims(vol_shape)
# get mask and erode/dilate it
if mask is None:
mask = new_volume >= threshold
else:
assert list(mask.shape[:n_dims]) == vol_shape[:n_dims], 'mask should have shape {0}, or {1}, had {2}'.format(
vol_shape[:n_dims], vol_shape[:n_dims] + [n_channels], list(mask.shape))
mask = mask > 0
if dilate > 0:
dilate_struct = utils.build_binary_structure(dilate, n_dims)
mask_to_apply = binary_dilation(mask, dilate_struct)
else:
mask_to_apply = mask
if erode > 0:
erode_struct = utils.build_binary_structure(erode, n_dims)
mask_to_apply = binary_erosion(mask_to_apply, erode_struct)
if fill_holes:
mask_to_apply = binary_fill_holes(mask_to_apply)
# replace values outside of mask by padding_char
if mask_to_apply.shape == new_volume.shape:
new_volume[np.logical_not(mask_to_apply)] = masking_value
else:
new_volume[np.stack([np.logical_not(mask_to_apply)] * n_channels, axis=-1)] = masking_value
if return_mask:
return new_volume, mask_to_apply
else:
return new_volume
def rescale_volume(volume, new_min=0, new_max=255, min_percentile=2, max_percentile=98, use_positive_only=False):
"""This function linearly rescales a volume between new_min and new_max.
:param volume: a numpy array
:param new_min: (optional) minimum value for the rescaled image.
:param new_max: (optional) maximum value for the rescaled image.
:param min_percentile: (optional) percentile for estimating robust minimum of volume (float in [0,...100]),
where 0 = np.min
:param max_percentile: (optional) percentile for estimating robust maximum of volume (float in [0,...100]),
where 100 = np.max
:param use_positive_only: (optional) whether to use only positive values when estimating the min and max percentile
:return: rescaled volume
"""
# select only positive intensities
new_volume = volume.copy()
intensities = new_volume[new_volume > 0] if use_positive_only else new_volume.flatten()
# define min and max intensities in original image for normalisation
robust_min = np.min(intensities) if min_percentile == 0 else np.percentile(intensities, min_percentile)
robust_max = np.max(intensities) if max_percentile == 100 else np.percentile(intensities, max_percentile)
# trim values outside range
new_volume = np.clip(new_volume, robust_min, robust_max)
# rescale image
if robust_min != robust_max:
return new_min + (new_volume - robust_min) / (robust_max - robust_min) * (new_max - new_min)
else: # avoid dividing by zero
return np.zeros_like(new_volume)
def crop_volume(volume, cropping_margin=None, cropping_shape=None, aff=None, return_crop_idx=False, mode='center'):
"""Crop volume by a given margin, or to a given shape.
:param volume: 2d or 3d numpy array (possibly with multiple channels)
:param cropping_margin: (optional) margin by which to crop the volume. The cropping margin is applied on both sides.
Can be an int, sequence or 1d numpy array of size n_dims. Should be given if cropping_shape is None.
:param cropping_shape: (optional) shape to which the volume will be cropped. Can be an int, sequence or 1d numpy
array of size n_dims. Should be given if cropping_margin is None.
:param aff: (optional) affine matrix of the input volume.
If not None, this function also returns an updated version of the affine matrix for the cropped volume.
:param return_crop_idx: (optional) whether to return the cropping indices used to crop the given volume.
:param mode: (optional) if cropping_shape is not None, whether to extract the centre of the image (mode='center'),
or to randomly crop the volume to the provided shape (mode='random'). Default is 'center'.
:return: cropped volume, corresponding affine matrix if aff is not None, and cropping indices if return_crop_idx is
True (in that order).
"""
assert (cropping_margin is not None) | (cropping_shape is not None), \
'cropping_margin or cropping_shape should be provided'
assert not ((cropping_margin is not None) & (cropping_shape is not None)), \
'only one of cropping_margin or cropping_shape should be provided'
# get info
new_volume = volume.copy()
vol_shape = new_volume.shape
n_dims, _ = utils.get_dims(vol_shape)
# find cropping indices
if cropping_margin is not None:
cropping_margin = utils.reformat_to_list(cropping_margin, length=n_dims)
do_cropping = np.array(vol_shape[:n_dims]) > 2 * np.array(cropping_margin)
min_crop_idx = [cropping_margin[i] if do_cropping[i] else 0 for i in range(n_dims)]
max_crop_idx = [vol_shape[i] - cropping_margin[i] if do_cropping[i] else vol_shape[i] for i in range(n_dims)]
else:
cropping_shape = utils.reformat_to_list(cropping_shape, length=n_dims)
if mode == 'center':
min_crop_idx = np.maximum([int((vol_shape[i] - cropping_shape[i]) / 2) for i in range(n_dims)], 0)
max_crop_idx = np.minimum([min_crop_idx[i] + cropping_shape[i] for i in range(n_dims)],
np.array(vol_shape)[:n_dims])
elif mode == 'random':
crop_max_val = np.maximum(np.array([vol_shape[i] - cropping_shape[i] for i in range(n_dims)]), 0)
min_crop_idx = np.random.randint(0, high=crop_max_val + 1)
max_crop_idx = np.minimum(min_crop_idx + np.array(cropping_shape), np.array(vol_shape)[:n_dims])
else:
raise ValueError('mode should be either "center" or "random", had %s' % mode)
crop_idx = np.concatenate([np.array(min_crop_idx), np.array(max_crop_idx)])
# crop volume
if n_dims == 2:
new_volume = new_volume[crop_idx[0]: crop_idx[2], crop_idx[1]: crop_idx[3], ...]
elif n_dims == 3:
new_volume = new_volume[crop_idx[0]: crop_idx[3], crop_idx[1]: crop_idx[4], crop_idx[2]: crop_idx[5], ...]
# sort outputs
output = [new_volume]
if aff is not None:
aff[0:3, -1] = aff[0:3, -1] + aff[:3, :3] @ np.array(min_crop_idx)
output.append(aff)
if return_crop_idx:
output.append(crop_idx)
return output[0] if len(output) == 1 else tuple(output)
def crop_volume_around_region(volume,
mask=None,
masking_labels=None,
threshold=0.1,
margin=0,
cropping_shape=None,
cropping_shape_div_by=None,
aff=None,
overflow='strict'):
"""Crop a volume around a specific region.
This region is defined by a mask obtained by either:
1) directly specifying it as input (see mask)
2) keeping a set of label values if the volume is a label map (see masking_labels).
3) thresholding the input volume (see threshold)
The cropping region is defined by the bounding box of the mask, which we can further modify by either:
1) extending it by a margin (see margin)
2) providing a specific cropping shape, in this case the cropping region will be centered around the bounding box
(see cropping_shape).
3) extending it to a shape that is divisible by a given number. Again, the cropping region will be centered around
the bounding box (see cropping_shape_div_by).
Finally, if the size of the cropping region has been modified, and that this modified size overflows out of the
image (e.g. because the center of the mask is close to the edge), we can either:
1) stick to the valid image space (the size of the modified cropping region won't be respected)
2) shift the cropping region so that it lies on the valid image space, and if it still overflows, then we restrict
to the valid image space.
3) pad the image with zeros, such that the cropping region is not ill-defined anymore.
3) shift the cropping region to the valida image space, and if it still overflows, then we pad with zeros.
:param volume: a 2d or 3d numpy array
:param mask: (optional) mask of region to crop around. Must be same size as volume. Can either be boolean or 0/1.
If no mask is given, it will be computed by either thresholding the input volume or using masking_labels.
:param masking_labels: (optional) if mask is None, and if the volume is a label map, it can be cropped around a
set of labels specified in masking_labels, which can either be a single int, a sequence or a 1d numpy array.
:param threshold: (optional) if mask amd masking_labels are None, lower bound to determine values to crop around.
:param margin: (optional) add margin around mask
:param cropping_shape: (optional) shape to which the input volumes must be cropped. Volumes are padded around the
centre of the above-defined mask is they are too small for the given shape. Can be an integer or sequence.
Cannot be given at the same time as margin or cropping_shape_div_by.
:param cropping_shape_div_by: (optional) makes sure the shape of the cropped region is divisible by the provided
number. If it is not, then we enlarge the cropping area. If the enlarged area is too big for the input volume, we
pad it with 0. Must be an integer. Cannot be given at the same time as margin or cropping_shape.
:param aff: (optional) if specified, this function returns an updated affine matrix of the volume after cropping.
:param overflow: (optional) how to proceed when the cropping region overflows outside the initial image space.
Can either be 'strict' (default), 'shift-strict', 'padding', 'shift-padding.
:return: the cropped volume, the cropping indices (in the order [lower_bound_dim_1, ..., upper_bound_dim_1, ...]),
and the updated affine matrix if aff is not None.
"""
assert not ((margin > 0) & (cropping_shape is not None)), "margin and cropping_shape can't be given together."
assert not ((margin > 0) & (cropping_shape_div_by is not None)), \
"margin and cropping_shape_div_by can't be given together."
assert not ((cropping_shape_div_by is not None) & (cropping_shape is not None)), \
"cropping_shape_div_by and cropping_shape can't be given together."
new_vol = volume.copy()
n_dims, n_channels = utils.get_dims(new_vol.shape)
vol_shape = np.array(new_vol.shape[:n_dims])
# mask ROIs for cropping
if mask is None:
if masking_labels is not None:
_, mask = mask_label_map(new_vol, masking_values=masking_labels, return_mask=True)
else:
mask = new_vol > threshold
# find cropping indices
if np.any(mask):
indices = np.nonzero(mask)
min_idx = np.array([np.min(idx) for idx in indices])
max_idx = np.array([np.max(idx) for idx in indices])
intermediate_vol_shape = max_idx - min_idx
if (margin == 0) & (cropping_shape is None) & (cropping_shape_div_by is None):
cropping_shape = intermediate_vol_shape
if margin:
cropping_shape = intermediate_vol_shape + 2 * margin
elif cropping_shape is not None:
cropping_shape = np.array(utils.reformat_to_list(cropping_shape, length=n_dims))
elif cropping_shape_div_by is not None:
cropping_shape = [utils.find_closest_number_divisible_by_m(s, cropping_shape_div_by, answer_type='higher')
for s in intermediate_vol_shape]
min_idx = min_idx - np.int32(np.ceil((cropping_shape - intermediate_vol_shape) / 2))
max_idx = max_idx + np.int32(np.floor((cropping_shape - intermediate_vol_shape) / 2))
min_overflow = np.abs(np.minimum(min_idx, 0))
max_overflow = np.maximum(max_idx - vol_shape, 0)
if 'strict' in overflow:
min_overflow = np.zeros_like(min_overflow)
max_overflow = np.zeros_like(min_overflow)
if overflow == 'shift-strict':
min_idx -= max_overflow
max_idx += min_overflow
if overflow == 'shift-padding':
for ii in range(n_dims):
# no need to do anything if both min/max_overflow are 0 (no padding/shifting required at all)
# or if both are positive, because in this case we don't shift at all and we pad directly
if (min_overflow[ii] > 0) & (max_overflow[ii] == 0):
max_idx_new = max_idx[ii] + min_overflow[ii]
if max_idx_new <= vol_shape[ii]:
max_idx[ii] = max_idx_new
min_overflow[ii] = 0
else:
min_overflow[ii] = min_overflow[ii] - (vol_shape[ii] - max_idx[ii])
max_idx[ii] = vol_shape[ii]
elif (min_overflow[ii] == 0) & (max_overflow[ii] > 0):
min_idx_new = min_idx[ii] - max_overflow[ii]
if min_idx_new >= 0:
min_idx[ii] = min_idx_new
max_overflow[ii] = 0
else:
max_overflow[ii] = max_overflow[ii] - min_idx[ii]
min_idx[ii] = 0
# crop volume if necessary
min_idx = np.maximum(min_idx, 0)
max_idx = np.minimum(max_idx, vol_shape)
cropping = np.concatenate([min_idx, max_idx])
if np.any(cropping[:3] > 0) or np.any(cropping[3:] != vol_shape):
if n_dims == 3:
new_vol = new_vol[cropping[0]:cropping[3], cropping[1]:cropping[4], cropping[2]:cropping[5], ...]
elif n_dims == 2:
new_vol = new_vol[cropping[0]:cropping[2], cropping[1]:cropping[3], ...]
else:
raise ValueError('cannot crop volumes with more than 3 dimensions')
# pad volume if necessary
if np.any(min_overflow > 0) | np.any(max_overflow > 0):
pad_margins = tuple([(min_overflow[i], max_overflow[i]) for i in range(n_dims)])
pad_margins = tuple(list(pad_margins) + [(0, 0)]) if n_channels > 1 else pad_margins
new_vol = np.pad(new_vol, pad_margins, mode='constant', constant_values=0)
# if there's nothing to crop around, we return the input as is
else:
min_idx = min_overflow = np.zeros(3)
cropping = None
# return results
if aff is not None:
if n_dims == 2:
min_idx = np.append(min_idx, 0)
min_overflow = np.append(min_overflow, 0)
aff[0:3, -1] = aff[0:3, -1] + aff[:3, :3] @ min_idx
aff[:-1, -1] = aff[:-1, -1] - aff[:-1, :-1] @ min_overflow
return new_vol, cropping, aff
else:
return new_vol, cropping
def crop_volume_with_idx(volume, crop_idx, aff=None, n_dims=None, return_copy=True):
"""Crop a volume with given indices.
:param volume: a 2d or 3d numpy array
:param crop_idx: cropping indices, in the order [lower_bound_dim_1, ..., upper_bound_dim_1, ...].
Can be a list or a 1d numpy array.
:param aff: (optional) if aff is specified, this function returns an updated affine matrix of the volume after
cropping.
:param n_dims: (optional) number of dimensions (excluding channels) of the volume. If not provided, n_dims will be
inferred from the input volume.
:param return_copy: (optional) whether to return the original volume or a copy. Default is copy.
:return: the cropped volume, and the updated affine matrix if aff is not None.
"""
# get info
new_volume = volume.copy() if return_copy else volume
n_dims = int(np.array(crop_idx).shape[0] / 2) if n_dims is None else n_dims
# crop image
if n_dims == 2:
new_volume = new_volume[crop_idx[0]:crop_idx[2], crop_idx[1]:crop_idx[3], ...]
elif n_dims == 3:
new_volume = new_volume[crop_idx[0]:crop_idx[3], crop_idx[1]:crop_idx[4], crop_idx[2]:crop_idx[5], ...]
else:
raise Exception('cannot crop volumes with more than 3 dimensions')
if aff is not None:
aff[0:3, -1] = aff[0:3, -1] + aff[:3, :3] @ crop_idx[:3]
return new_volume, aff
else:
return new_volume
def pad_volume(volume, padding_shape, padding_value=0, aff=None, return_pad_idx=False):
"""Pad volume to a given shape
:param volume: volume to be padded
:param padding_shape: shape to pad volume to. Can be a number, a sequence or a 1d numpy array.
:param padding_value: (optional) value used for padding
:param aff: (optional) affine matrix of the volume
:param return_pad_idx: (optional) the pad_idx corresponds to the indices where we should crop the resulting
padded image (ie the output of this function) to go back to the original volume (ie the input of this function).
:return: padded volume, and updated affine matrix if aff is not None.
"""
# get info
new_volume = volume.copy()
vol_shape = new_volume.shape
n_dims, n_channels = utils.get_dims(vol_shape)
padding_shape = utils.reformat_to_list(padding_shape, length=n_dims, dtype='int')
# check if need to pad
if np.any(np.array(padding_shape, dtype='int32') > np.array(vol_shape[:n_dims], dtype='int32')):
# get padding margins
min_margins = np.maximum(np.int32(np.floor((np.array(padding_shape) - np.array(vol_shape)[:n_dims]) / 2)), 0)
max_margins = np.maximum(np.int32(np.ceil((np.array(padding_shape) - np.array(vol_shape)[:n_dims]) / 2)), 0)
pad_idx = np.concatenate([min_margins, min_margins + np.array(vol_shape[:n_dims])])
pad_margins = tuple([(min_margins[i], max_margins[i]) for i in range(n_dims)])
if n_channels > 1:
pad_margins = tuple(list(pad_margins) + [(0, 0)])
# pad volume
new_volume = np.pad(new_volume, pad_margins, mode='constant', constant_values=padding_value)
if aff is not None:
if n_dims == 2:
min_margins = np.append(min_margins, 0)
aff[:-1, -1] = aff[:-1, -1] - aff[:-1, :-1] @ min_margins
else:
pad_idx = np.concatenate([np.array([0] * n_dims), np.array(vol_shape[:n_dims])])
# sort outputs
output = [new_volume]
if aff is not None:
output.append(aff)
if return_pad_idx:
output.append(pad_idx)
return output[0] if len(output) == 1 else tuple(output)
def flip_volume(volume, axis=None, direction=None, aff=None, return_copy=True):
"""Flip volume along a specified axis.
If unknown, this axis can be inferred from an affine matrix with a specified anatomical direction.
:param volume: a numpy array
:param axis: (optional) axis along which to flip the volume. Can either be an int or a tuple.
:param direction: (optional) if axis is None, the volume can be flipped along an anatomical direction:
'rl' (right/left), 'ap' anterior/posterior), 'si' (superior/inferior).
:param aff: (optional) please provide an affine matrix if direction is not None
:param return_copy: (optional) whether to return the original volume or a copy. Default is copy.
:return: flipped volume
"""
new_volume = volume.copy() if return_copy else volume
assert (axis is not None) | ((aff is not None) & (direction is not None)), \
'please provide either axis, or an affine matrix with a direction'
# get flipping axis from aff if axis not provided
if (axis is None) & (aff is not None):
volume_axes = get_ras_axes(aff)
if direction == 'rl':
axis = volume_axes[0]
elif direction == 'ap':
axis = volume_axes[1]
elif direction == 'si':
axis = volume_axes[2]
else:
raise ValueError("direction should be 'rl', 'ap', or 'si', had %s" % direction)
# flip volume
return np.flip(new_volume, axis=axis)
def resample_volume(volume, aff, new_vox_size, interpolation='linear', blur=True):
"""This function resizes the voxels of a volume to a new provided size, while adjusting the header to keep the RAS
:param volume: a numpy array
:param aff: affine matrix of the volume
:param new_vox_size: new voxel size (3 - element numpy vector) in mm
:param interpolation: (optional) type of interpolation. Can be 'linear' or 'nearest'. Default is 'linear'.
:param blur: (optional) whether to blur before resampling to avoid aliasing effects.
Only used if the input volume is downsampled. Default is True.
:return: new volume and affine matrix
"""
pixdim = np.sqrt(np.sum(aff * aff, axis=0))[:-1]
new_vox_size = np.array(new_vox_size)
factor = pixdim / new_vox_size
sigmas = 0.25 / factor
sigmas[factor > 1] = 0 # don't blur if upsampling
volume_filt = gaussian_filter(volume, sigmas) if blur else volume
# volume2 = zoom(volume_filt, factor, order=1, mode='reflect', prefilter=False)
x = np.arange(0, volume_filt.shape[0])
y = np.arange(0, volume_filt.shape[1])
z = np.arange(0, volume_filt.shape[2])
my_interpolating_function = RegularGridInterpolator((x, y, z), volume_filt, method=interpolation)
start = - (factor - 1) / (2 * factor)
step = 1.0 / factor
stop = start + step * np.ceil(volume_filt.shape * factor)
xi = np.arange(start=start[0], stop=stop[0], step=step[0])
yi = np.arange(start=start[1], stop=stop[1], step=step[1])
zi = np.arange(start=start[2], stop=stop[2], step=step[2])
xi[xi < 0] = 0
yi[yi < 0] = 0
zi[zi < 0] = 0
xi[xi > (volume_filt.shape[0] - 1)] = volume_filt.shape[0] - 1
yi[yi > (volume_filt.shape[1] - 1)] = volume_filt.shape[1] - 1
zi[zi > (volume_filt.shape[2] - 1)] = volume_filt.shape[2] - 1
xig, yig, zig = np.meshgrid(xi, yi, zi, indexing='ij', sparse=True)
volume2 = my_interpolating_function((xig, yig, zig))
aff2 = aff.copy()
for c in range(3):
aff2[:-1, c] = aff2[:-1, c] / factor[c]
aff2[:-1, -1] = aff2[:-1, -1] - np.matmul(aff2[:-1, :-1], 0.5 * (factor - 1))
return volume2, aff2
def resample_volume_like(vol_ref, aff_ref, vol_flo, aff_flo, interpolation='linear'):
"""This function reslices a floating image to the space of a reference image
:param vol_ref: a numpy array with the reference volume
:param aff_ref: affine matrix of the reference volume
:param vol_flo: a numpy array with the floating volume
:param aff_flo: affine matrix of the floating volume
:param interpolation: (optional) type of interpolation. Can be 'linear' or 'nearest'. Default is 'linear'.
:return: resliced volume
"""
T = np.matmul(np.linalg.inv(aff_flo), aff_ref)
xf = np.arange(0, vol_flo.shape[0])
yf = np.arange(0, vol_flo.shape[1])
zf = np.arange(0, vol_flo.shape[2])
my_interpolating_function = RegularGridInterpolator((xf, yf, zf), vol_flo, bounds_error=False, fill_value=0.0,
method=interpolation)
xr = np.arange(0, vol_ref.shape[0])
yr = np.arange(0, vol_ref.shape[1])
zr = np.arange(0, vol_ref.shape[2])
xrg, yrg, zrg = np.meshgrid(xr, yr, zr, indexing='ij', sparse=False)
n = xrg.size
xrg = xrg.reshape([n])
yrg = yrg.reshape([n])
zrg = zrg.reshape([n])
bottom = np.ones_like(xrg)
coords = np.stack([xrg, yrg, zrg, bottom])
coords_new = np.matmul(T, coords)[:-1, :]
result = my_interpolating_function((coords_new[0, :], coords_new[1, :], coords_new[2, :]))
return result.reshape(vol_ref.shape)
def get_ras_axes(aff, n_dims=3):
"""This function finds the RAS axes corresponding to each dimension of a volume, based on its affine matrix.
:param aff: affine matrix Can be a 2d numpy array of size n_dims*n_dims, n_dims+1*n_dims+1, or n_dims*n_dims+1.
:param n_dims: number of dimensions (excluding channels) of the volume corresponding to the provided affine matrix.
:return: two numpy 1d arrays of length n_dims, one with the axes corresponding to RAS orientations,
and one with their corresponding direction.
"""
aff_inverted = np.linalg.inv(aff)
img_ras_axes = np.argmax(np.absolute(aff_inverted[0:n_dims, 0:n_dims]), axis=0)
for i in range(n_dims):
if i not in img_ras_axes:
unique, counts = np.unique(img_ras_axes, return_counts=True)
incorrect_value = unique[np.argmax(counts)]
img_ras_axes[np.where(img_ras_axes == incorrect_value)[0][-1]] = i
return img_ras_axes
def align_volume_to_ref(volume, aff, aff_ref=None, return_aff=False, n_dims=None, return_copy=True):
"""This function aligns a volume to a reference orientation (axis and direction) specified by an affine matrix.
:param volume: a numpy array
:param aff: affine matrix of the floating volume
:param aff_ref: (optional) affine matrix of the target orientation. Default is identity matrix.
:param return_aff: (optional) whether to return the affine matrix of the aligned volume
:param n_dims: (optional) number of dimensions (excluding channels) of the volume. If not provided, n_dims will be
inferred from the input volume.
:param return_copy: (optional) whether to return the original volume or a copy. Default is copy.
:return: aligned volume, with corresponding affine matrix if return_aff is True.
"""
# work on copy
new_volume = volume.copy() if return_copy else volume
aff_flo = aff.copy()
# default value for aff_ref
if aff_ref is None:
aff_ref = np.eye(4)
# extract ras axes
if n_dims is None:
n_dims, _ = utils.get_dims(new_volume.shape)
ras_axes_ref = get_ras_axes(aff_ref, n_dims=n_dims)
ras_axes_flo = get_ras_axes(aff_flo, n_dims=n_dims)
# align axes
aff_flo[:, ras_axes_ref] = aff_flo[:, ras_axes_flo]
for i in range(n_dims):
if ras_axes_flo[i] != ras_axes_ref[i]:
new_volume = np.swapaxes(new_volume, ras_axes_flo[i], ras_axes_ref[i])
swapped_axis_idx = np.where(ras_axes_flo == ras_axes_ref[i])
ras_axes_flo[swapped_axis_idx], ras_axes_flo[i] = ras_axes_flo[i], ras_axes_flo[swapped_axis_idx]
# align directions
dot_products = np.sum(aff_flo[:3, :3] * aff_ref[:3, :3], axis=0)
for i in range(n_dims):
if dot_products[i] < 0:
new_volume = np.flip(new_volume, axis=i)
aff_flo[:, i] = - aff_flo[:, i]
aff_flo[:3, 3] = aff_flo[:3, 3] - aff_flo[:3, i] * (new_volume.shape[i] - 1)
if return_aff:
return new_volume, aff_flo
else:
return new_volume
def blur_volume(volume, sigma, mask=None):
"""Blur volume with gaussian masks of given sigma.
:param volume: 2d or 3d numpy array
:param sigma: standard deviation of the gaussian kernels. Can be a number, a sequence or a 1d numpy array
:param mask: (optional) numpy array of the same shape as volume to correct for edge blurring effects.
Mask can be a boolean or numerical array. In the latter, the mask is computed by keeping all values above zero.
:return: blurred volume
"""
# initialisation
new_volume = volume.copy()
n_dims, _ = utils.get_dims(new_volume.shape)
sigma = utils.reformat_to_list(sigma, length=n_dims, dtype='float')
# blur image
new_volume = gaussian_filter(new_volume, sigma=sigma, mode='nearest') # nearest refers to edge padding
# correct edge effect if mask is not None
if mask is not None:
assert new_volume.shape == mask.shape, 'volume and mask should have the same dimensions: ' \
'got {0} and {1}'.format(new_volume.shape, mask.shape)
mask = (mask > 0) * 1.0
blurred_mask = gaussian_filter(mask, sigma=sigma, mode='nearest')
new_volume = new_volume / (blurred_mask + 1e-6)
new_volume[mask == 0] = 0
return new_volume
# --------------------------------------------------- edit label map ---------------------------------------------------
def correct_label_map(labels, list_incorrect_labels, list_correct_labels=None, use_nearest_label=False,
remove_zero=False, smooth=False):
"""This function corrects specified label values in a label map by either a list of given values, or by the nearest
label.
:param labels: a 2d or 3d label map
:param list_incorrect_labels: list of all label values to correct (eg [1, 2, 3]). Can also be a path to such a list.
:param list_correct_labels: (optional) list of correct label values to replace the incorrect ones.
Correct values must have the same order as their corresponding value in list_incorrect_labels.
When several correct values are possible for the same incorrect value, the nearest correct value will be selected at
each voxel to correct. In that case, the different correct values must be specified inside a list within
list_correct_labels (e.g. [10, 20, 30, [40, 50]).
:param use_nearest_label: (optional) whether to correct the incorrect label values with the nearest labels.
:param remove_zero: (optional) if use_nearest_label is True, set to True not to consider zero among the potential
candidates for the nearest neighbour. -1 will be returned when no solution are possible.
:param smooth: (optional) whether to smooth the corrected label map
:return: corrected label map
"""
assert (list_correct_labels is not None) | use_nearest_label, \
'please provide a list of correct labels, or set use_nearest_label to True.'
assert (list_correct_labels is None) | (not use_nearest_label), \
'cannot provide a list of correct values and set use_nearest_label to True'
# initialisation
new_labels = labels.copy()
list_incorrect_labels = utils.reformat_to_list(utils.load_array_if_path(list_incorrect_labels))
volume_labels = np.unique(labels)
n_dims, _ = utils.get_dims(labels.shape)
# use list of correct values
if list_correct_labels is not None:
list_correct_labels = utils.reformat_to_list(utils.load_array_if_path(list_correct_labels))
# loop over label values
for incorrect_label, correct_label in zip(list_incorrect_labels, list_correct_labels):
if incorrect_label in volume_labels:
# only one possible value to replace with
if isinstance(correct_label, (int, float, np.int64, np.int32, np.int16, np.int8)):
incorrect_voxels = np.where(labels == incorrect_label)
new_labels[incorrect_voxels] = correct_label
# several possibilities
elif isinstance(correct_label, (tuple, list)):
# make sure at least one correct label is present
if not any([lab in volume_labels for lab in correct_label]):
print('no correct values found in volume, please adjust: '
'incorrect: {}, correct: {}'.format(incorrect_label, correct_label))
# crop around incorrect label until we find incorrect labels
correct_label_not_found = True
margin_mult = 1
tmp_labels = None
crop = None
while correct_label_not_found:
tmp_labels, crop = crop_volume_around_region(labels,
masking_labels=incorrect_label,
margin=10 * margin_mult)
correct_label_not_found = not any([lab in np.unique(tmp_labels) for lab in correct_label])
margin_mult += 1
# calculate distance maps for all new label candidates
incorrect_voxels = np.where(tmp_labels == incorrect_label)
distance_map_list = [distance_transform_edt(tmp_labels != lab) for lab in correct_label]
distances_correct = np.stack([dist[incorrect_voxels] for dist in distance_map_list])
# select nearest values and use them to correct label map
idx_correct_lab = np.argmin(distances_correct, axis=0)
incorrect_voxels = tuple([incorrect_voxels[i] + crop[i] for i in range(n_dims)])
new_labels[incorrect_voxels] = np.array(correct_label)[idx_correct_lab]
# use nearest label
else:
# loop over label values
for incorrect_label in list_incorrect_labels:
if incorrect_label in volume_labels:
# loop around regions
components, n_components = scipy_label(labels == incorrect_label)
loop_info = utils.LoopInfo(n_components + 1, 100, 'correcting')
for i in range(1, n_components + 1):
loop_info.update(i)
# crop each region
_, crop = crop_volume_around_region(components, masking_labels=i, margin=1)
tmp_labels = crop_volume_with_idx(labels, crop)
tmp_new_labels = crop_volume_with_idx(new_labels, crop)
# list all possible correct labels
correct_labels = np.unique(tmp_labels)
for il in list_incorrect_labels:
correct_labels = np.delete(correct_labels, np.where(correct_labels == il))
if remove_zero:
correct_labels = np.delete(correct_labels, np.where(correct_labels == 0))
# replace incorrect voxels by new value
incorrect_voxels = np.where(tmp_labels == incorrect_label)
if len(correct_labels) == 0:
tmp_new_labels[incorrect_voxels] = -1
else:
if len(correct_labels) == 1:
idx_correct_lab = np.zeros(len(incorrect_voxels[0]), dtype='int32')
else:
distance_map_list = [distance_transform_edt(tmp_labels != lab) for lab in correct_labels]
distances_correct = np.stack([dist[incorrect_voxels] for dist in distance_map_list])
idx_correct_lab = np.argmin(distances_correct, axis=0)
tmp_new_labels[incorrect_voxels] = np.array(correct_labels)[idx_correct_lab]
# paste back
if n_dims == 2:
new_labels[crop[0]:crop[2], crop[1]:crop[3], ...] = tmp_new_labels
else:
new_labels[crop[0]:crop[3], crop[1]:crop[4], crop[2]:crop[5], ...] = tmp_new_labels
# smoothing
if smooth:
kernel = np.ones(tuple([3] * n_dims))
new_labels = smooth_label_map(new_labels, kernel)
return new_labels
def mask_label_map(labels, masking_values, masking_value=0, return_mask=False):
"""
This function masks a label map around a list of specified values.
:param labels: input label map
:param masking_values: list of values to mask around
:param masking_value: (optional) value to mask the label map with
:param return_mask: (optional) whether to return the applied mask
:return: the masked label map, and the applied mask if return_mask is True.
"""
# build mask and mask labels
mask = np.zeros(labels.shape, dtype=bool)
masked_labels = labels.copy()
for value in utils.reformat_to_list(masking_values):
mask = mask | (labels == value)
masked_labels[np.logical_not(mask)] = masking_value
if return_mask:
mask = mask * 1
return masked_labels, mask
else:
return masked_labels
def smooth_label_map(labels, kernel, labels_list=None, print_progress=0):
"""This function smooth an input label map by replacing each voxel by the value of its most numerous neighbour.
:param labels: input label map
:param kernel: kernel when counting neighbours. Must contain only zeros or ones.
:param labels_list: list of label values to smooth. Defaults is None, where all labels are smoothed.
:param print_progress: (optional) If not 0, interval at which to print the number of processed labels.
:return: smoothed label map
"""
# get info
labels_shape = labels.shape
unique_labels = np.unique(labels).astype('int32')
if labels_list is None:
labels_list = unique_labels
new_labels = mask_new_labels = None
else:
labels_to_keep = [lab for lab in unique_labels if lab not in labels_list]
new_labels, mask_new_labels = mask_label_map(labels, labels_to_keep, return_mask=True)
# loop through label values
count = np.zeros(labels_shape)
labels_smoothed = np.zeros(labels_shape, dtype='int')
loop_info = utils.LoopInfo(len(labels_list), print_progress, 'smoothing')
for la, label in enumerate(labels_list):
if print_progress:
loop_info.update(la)
# count neighbours with same value
mask = (labels == label) * 1
n_neighbours = convolve(mask, kernel)
# update label map and maximum neighbour counts
idx = n_neighbours > count
count[idx] = n_neighbours[idx]
labels_smoothed[idx] = label
labels_smoothed = labels_smoothed.astype('int32')
if new_labels is None:
new_labels = labels_smoothed
else:
new_labels = np.where(mask_new_labels, new_labels, labels_smoothed)
return new_labels
def erode_label_map(labels, labels_to_erode, erosion_factors=1., gpu=False, model=None, return_model=False):
"""Erode a given set of label values within a label map.
:param labels: a 2d or 3d label map
:param labels_to_erode: list of label values to erode
:param erosion_factors: (optional) list of erosion factors to use for each label. If values are integers, normal
erosion applies. If float, we first 1) blur a mask of the corresponding label value, and 2) use the erosion factor
as a threshold in the blurred mask.
If erosion_factors is a single value, the same factor will be applied to all labels.
:param gpu: (optional) whether to use a fast gpu model for blurring (if erosion factors are floats)
:param model: (optional) gpu model for blurring masks (if erosion factors are floats)
:param return_model: (optional) whether to return the gpu blurring model
:return: eroded label map, and gpu blurring model is return_model is True.
"""
# reformat labels_to_erode and erode
new_labels = labels.copy()
labels_to_erode = utils.reformat_to_list(labels_to_erode)
erosion_factors = utils.reformat_to_list(erosion_factors, length=len(labels_to_erode))
labels_shape = list(new_labels.shape)
n_dims, _ = utils.get_dims(labels_shape)
# loop over labels to erode
for label_to_erode, erosion_factor in zip(labels_to_erode, erosion_factors):
assert erosion_factor > 0, 'all erosion factors should be strictly positive, had {}'.format(erosion_factor)
# get mask of current label value
mask = (new_labels == label_to_erode)
# erode as usual if erosion factor is int
if int(erosion_factor) == erosion_factor:
erode_struct = utils.build_binary_structure(int(erosion_factor), n_dims)
eroded_mask = binary_erosion(mask, erode_struct)
# blur mask and use erosion factor as a threshold if float
else:
if gpu:
if model is None:
mask_in = KL.Input(shape=labels_shape + [1], dtype='float32')
blurred_mask = GaussianBlur([1] * 3)(mask_in)
model = Model(inputs=mask_in, outputs=blurred_mask)
eroded_mask = model.predict(utils.add_axis(np.float32(mask), axis=[0, -1]))
else:
eroded_mask = blur_volume(np.array(mask, dtype='float32'), 1)
eroded_mask = np.squeeze(eroded_mask) > erosion_factor
# crop label map and mask around values to change
mask = mask & np.logical_not(eroded_mask)
cropped_lab_mask, cropping = crop_volume_around_region(mask, margin=3)
cropped_labels = crop_volume_with_idx(new_labels, cropping)
# calculate distance maps for all labels in cropped_labels
labels_list = np.unique(cropped_labels)
labels_list = labels_list[labels_list != label_to_erode]
list_dist_maps = [distance_transform_edt(np.logical_not(cropped_labels == la)) for la in labels_list]
candidate_distances = np.stack([dist[cropped_lab_mask] for dist in list_dist_maps])
# select nearest value and put cropped labels back to full label map
idx_correct_lab = np.argmin(candidate_distances, axis=0)
cropped_labels[cropped_lab_mask] = np.array(labels_list)[idx_correct_lab]
if n_dims == 2:
new_labels[cropping[0]:cropping[2], cropping[1]:cropping[3], ...] = cropped_labels
elif n_dims == 3:
new_labels[cropping[0]:cropping[3], cropping[1]:cropping[4], cropping[2]:cropping[5], ...] = cropped_labels
if return_model:
return new_labels, model
else:
return new_labels
def get_largest_connected_component(mask, structure=None):
"""Function to get the largest connected component for a given input.
:param mask: a 2d or 3d label map of boolean type.
:param structure: numpy array defining the connectivity.
"""
components, n_components = scipy_label(mask, structure)
return components == np.argmax(np.bincount(components.flat)[1:]) + 1 if n_components > 0 else mask.copy()
def compute_hard_volumes(labels, voxel_volume=1., label_list=None, skip_background=True):
"""Compute hard volumes in a label map.
:param labels: a label map
:param voxel_volume: (optional) volume of voxel. Default is 1 (i.e. returned volumes are voxel counts).
:param label_list: (optional) list of labels to compute volumes for. Can be an int, a sequence, or a numpy array.
If None, the volumes of all label values are computed.
:param skip_background: (optional) whether to skip computing the volume of the background.
If label_list is None, this assumes background value is 0.
If label_list is not None, this assumes the background is the first value in label list.
:return: numpy 1d vector with the volumes of each structure
"""
# initialisation
subject_label_list = utils.reformat_to_list(np.unique(labels), dtype='int')
if label_list is None:
label_list = subject_label_list
else:
label_list = utils.reformat_to_list(label_list)
if skip_background:
label_list = label_list[1:]
volumes = np.zeros(len(label_list))
# loop over label values
for idx, label in enumerate(label_list):
if label in subject_label_list:
mask = (labels == label) * 1
volumes[idx] = np.sum(mask)
else:
volumes[idx] = 0
return volumes * voxel_volume
def compute_distance_map(labels, masking_labels=None, crop_margin=None):
"""Compute distance map for a given list of label values in a label map.
:param labels: a label map
:param masking_labels: (optional) list of label values to mask the label map with. The distances will be computed
for these labels only. Default is None, where all positive values are considered.
:param crop_margin: (optional) margin with which to crop the input label maps around the labels for which we
want to compute the distance maps.
:return: a distance map with positive values inside the considered regions, and negative values outside."""