a b/src/LFBNet/preprocessing/preprocessing.py
1
""" Script to preprocess a given FDG PET image in .nii.
2
"""
3
# Import libraries
4
import glob
5
import os
6
7
import numpy as np
8
from numpy import ndarray
9
from numpy.random import seed
10
from tqdm import tqdm
11
from typing import List, Tuple
12
import matplotlib.pyplot as plt
13
import warnings
14
15
from skimage.transform import resize
16
import scipy.ndimage
17
import nibabel as nib
18
19
# seed random number generator
20
seed(1)
21
22
23
def directory_exist(dir_check: str = None):
24
    """ Check if the given path exists.
25
26
    Args:
27
        dir_check: directory path to check
28
    Raises:
29
        Raises exception if the given directory path doesn't exist.
30
    """
31
    if os.path.exists(dir_check):
32
        # print("The directory %s does exist \n" % dir_check)
33
        pass
34
    else:
35
        raise Exception("Please provide the correct path to the processed data: %s not found \n" % (dir_check))
36
37
38
def generate_mip_show(pet: int = None, gt: int = None, identifier: str = None):
39
    """ Display the MIP images of a given 3D FDG PET image , and corresponding ground truth gt.
40
41
    Args:
42
        pet: 3D array of PET images
43
        gt: 3D array of reference or ground truth lymphoma segmentation.
44
        identifier: Name (identifier) of the patient.
45
    """
46
    # consider axis 0 for sagittal and axis 1 for coronal views
47
    pet, gt = np.array(pet), np.array(gt)
48
    pet = [np.amax(pet, axis=0), np.amax(pet, axis=1)]
49
    gt = [np.amax(gt, axis=0), np.amax(gt, axis=1)]
50
    try:
51
        pet = np.squeeze(pet)
52
        gt = np.squeeze(gt)
53
    except:
54
        pass
55
56
    img_gt = np.concatenate((pet, gt, pet), axis=0)
57
    display_image(img_gt, identifier=identifier)
58
59
60
def display_image(im_display: ndarray, identifier: str = None):
61
    """ display given array of images.
62
63
    Args:
64
        im_display: array of images to show
65
        identifier: patient name to display as title
66
    """
67
    plt.figure(figsize=(10, 1))
68
    plt.subplots_adjust(hspace=0.015)
69
    plt.suptitle("Showing image: " + str(identifier), fontsize=12, y=0.95)
70
    # loop through the length of tickers and keep track of index
71
    for n, im in enumerate(im_display):
72
        # add a new subplot iteratively
73
        plt.subplot(int(len(im_display) // 2), 2, n + 1)
74
        plt.imshow(np.log(im + 1))
75
    plt.show()
76
77
78
def get_pet_gt(current_dir: str = None):
79
    """ Read pet and corresponding reference images.
80
81
    Args:
82
        current_dir: directory path to the PET and corresponding ground truth image.
83
    Returns:
84
        Returns array of pet and ground truth images.
85
86
    """
87
88
    def get_nii_files(nii_path):
89
        """ Read .nii data in the given nii_path.
90
        if there are more .nii or .nii.gz file inside the nii_path directory, the function will return the
91
        first read image.
92
93
        Args:
94
            nii_path: directory path to the .nii or .nii.gz file.
95
96
        Returns:
97
            Returns loaded nibble format if it exists, else it returns empty array.
98
99
        """
100
        # more than one .nii or .nii.gz is found in the folder the first will be returned
101
        full_path = [path_i for path_i in glob.glob(str(nii_path) + "/*.nii.gz")]
102
103
        if not len(full_path):  # if no file exists that ends wtih .nii.gz
104
            full_path = [path_i for path_i in glob.glob(str(nii_path) + "/*.nii")]
105
106
        if not len(full_path):
107
            get_image = None  # raise Exception("No .nii or .nii.gz found in %s dirctory" % nii_path)
108
109
        else:
110
            print("%d files found in %s \t the first will be read " % (len(full_path), nii_path))
111
            print("reading ... %s" % full_path[0])
112
            get_image = nib.load(full_path[0])
113
114
        return get_image
115
116
    # all folders files
117
    # all_ = [path_i for path_i in glob.glob(str(nii_path) + "/*.nii")]
118
119
    # Get pet image
120
    # if "pet" in str()
121
    pet_path = str(current_dir) + "/pet/"
122
    pet = get_nii_files(pet_path)
123
124
    # Get gt image
125
    try:
126
        # if the ground truth exists
127
        gt_path = str(current_dir) + "/gt/"
128
        gt = get_nii_files(gt_path)
129
    except:
130
        #  if the ground truth does not exist
131
        gt = None
132
133
    return [pet, gt]
134
135
136
def resize_nii_to_desired_spacing(
137
        data: int = None, data_spacing: Tuple[float] = None, desired_spacing: ndarray = None,
138
        interpolation_order_value: int = None
139
        ):
140
    """ resizes a given input data into the desired spacing using the specified interpolation order.
141
142
    Args:
143
        data: array of input data to resize.
144
        data_spacing:  original input data spacing.
145
        desired_spacing: required output data spacing.
146
        interpolation_order_value: interpolation order. E.g., 0 for binary images, and 3 for cubic interpolation.
147
148
    Returns:
149
        resized data.
150
151
    """
152
    if desired_spacing is None:
153
        desired_spacing_x, desired_spacing_y, desired_spacing_z = [4.0, 4.0, 4.0]
154
    else:
155
        desired_spacing_x, desired_spacing_y, desired_spacing_z = desired_spacing
156
157
    print("Given data spacing \t Desired spacing \n")
158
    print(data_spacing, "\t", end="")
159
    print(desired_spacing, "\n")
160
161
    if not isinstance(data, list):
162
        data = np.array(data)
163
164
    # New resolution, consider at least the input image is two-dimensional
165
    new_x_resolution = np.ceil(data.shape[0] * (data_spacing[0] / desired_spacing_x))
166
    new_y_resolution = np.ceil(data.shape[1] * (data_spacing[1] / desired_spacing_y))
167
168
    print('resizing')
169
    print(np.array(data).shape)
170
171
    if len(data_spacing) == 3 and len(np.squeeze(data).shape) == 3:  # 3D input image
172
        new_z_resolution = np.ceil(data.shape[2] * (data_spacing[2] / desired_spacing_z))
173
174
        # resize to new image resolution
175
        image_resized = resize(
176
            data, (new_x_resolution, new_y_resolution, new_z_resolution), order=interpolation_order_value,
177
            preserve_range=True, anti_aliasing=False
178
            )
179
180
    else:  # if the given input image is 2D
181
        image_resized = resize(
182
            data, (new_x_resolution, new_y_resolution), order=interpolation_order_value, preserve_range=True,
183
            anti_aliasing=False
184
            )
185
186
    return image_resized
187
188
189
def z_score(image):
190
    """ z-score operation on the input data.
191
192
    Args:
193
        image: input data to apply the z-score.
194
195
    Returns:
196
        Standardized value of the input using z-score. Z-score = (input - mean(input))/(std(input))
197
198
    """
199
    image = image.copy()
200
    if not isinstance(image, ndarray):
201
        image = np.array(image)
202
203
    image_nan = np.where(image > 0, image, np.nan)
204
    means = np.nanmean(image_nan, axis=(image.shape))
205
    stds = np.nanstd(image_nan, axis=(image.shape))
206
    image_norm = (image - means) / (stds + 1e-8)
207
208
    return image_norm
209
210
211
# cropping from the center based:
212
def crop_nii_to_desired_resolution(data: ndarray = None, cropped_resolution: List[int] = None):
213
    """ Crops the input data in the given cropped resolution.
214
215
    Args:
216
        data: input data to be cropped.
217
        cropped_resolution: desired output resolution.
218
219
    Returns:
220
        Returns cropped data of size cropped resolution.
221
222
    """
223
    #
224
    if not isinstance(data, list):  # if data is not array change it to array
225
        data = np.array(data)
226
227
    try:
228
        data = np.squeeze(data)
229
    except:
230
        pass
231
232
    if cropped_resolution is not None:
233
        cropped_resolution = [128, 128, 256]
234
235
    print("\n Initial data size \t Cropped data size ")
236
    print(data.shape, "\t", end=" ")
237
    print(cropped_resolution, "\n")
238
239
    if len(cropped_resolution) == 3 and len(data.shape) == 3:  # 3D data
240
        x, y, z = data.shape
241
    else:
242
        raise Exception("Input image not !")
243
244
    # start cropping : get middle x, y, z values by dividing by 2 and subtract the desired center of image resolution
245
    start_cropping_at_x = (x // 2 - (cropped_resolution[0] // 2))
246
    start_cropping_at_y = (y // 2 - (cropped_resolution[1] // 2))
247
    start_cropping_at_z = (z // 2 - (cropped_resolution[2] // 2))
248
249
    # check for off sets: mean the new cropping resolution is bigger than the input image's resolution
250
    off_set_x, off_set_y, off_set_z = 0, 0, 0
251
    if start_cropping_at_x < 0:
252
        off_set_x = np.abs(start_cropping_at_x)
253
        # set the first pixel at zero
254
        start_cropping_at_x = 0
255
    if start_cropping_at_y < 0:
256
        off_set_y = np.abs(start_cropping_at_y)
257
        # set the first pixel at zero
258
        start_cropping_at_y = 0
259
    if start_cropping_at_z < 0:
260
        off_set_z = np.abs(start_cropping_at_z)
261
        # set the first pixel at zero
262
        start_cropping_at_z = 0
263
    else:
264
        # take [0:cropped_resolution[0]]
265
        # Patients are given [x, y, z] when we say z it starts at zero (leg) to z (head).
266
        start_cropping_at_z = 2 * (z // 2 - (cropped_resolution[2] // 2))
267
268
    npad = ((off_set_x, off_set_x), (off_set_y, off_set_y), (2 * off_set_z, 0))
269
270
    # set zero value to the off set pixels mode='constant',
271
    data = np.pad(data, pad_width=npad, constant_values=0)
272
273
    # cropping to the given or set cropping resolution
274
    data = data[start_cropping_at_x:start_cropping_at_x + cropped_resolution[0],
275
           start_cropping_at_y:start_cropping_at_y + cropped_resolution[1],
276
           start_cropping_at_z:start_cropping_at_z + cropped_resolution[2]]
277
278
    return data
279
280
281
def save_nii_images(
282
        image: List[ndarray] = None, affine: ndarray = None, path_save: str = None, identifier: str = None,
283
        name: List[str] = None
284
        ):
285
    """ Save given images into the given directory. If no saving directory is given it will save into
286
    ./data/predicted/' directory.
287
288
    Args:
289
        image: data to save.
290
        affine: affine value.
291
        path_save: saving directory path.
292
        identifier: unique name of the case.
293
        name: name of the case to read.
294
    Returns:
295
        Saved image.
296
    """
297
    try:
298
        directory_exist(path_save)
299
    except:
300
        try:
301
            os.mkdir(path_save)
302
        except:
303
            if not os.path.exists("../data"):
304
                os.mkdir("../data")
305
306
            if not os.path.exists('../data/predicted/'):
307
                os.mkdir('../data/predicted/')
308
309
            path_save = '../data/predicted/'
310
    if identifier is not None:  # associate file name e.g. patient name
311
        dir = os.path.join(path_save, str(identifier))
312
    else:
313
        dir = path_save
314
315
    if not os.path.exists(dir):
316
        os.mkdir(dir)  # os.mkdir("./" + str(identifier))
317
318
    # print('saving it to %s\n' % dir)
319
    # .nii name, e.g. pet, gt, but if not given the base name of given directory
320
    if name is None:
321
        name = ['image_' + str(os.path.basename(dir)).split('.')[0]]
322
323
    if affine is None:
324
        affine = np.diag([4, 4, 4, 1])
325
326
    # image = np.flip(image, axis=-1)
327
    for select_image in range(len(image)):
328
        im_ = nib.Nifti1Image(image[select_image], affine)
329
        save_to = str(dir) + "/" + str(name[select_image])
330
        im_.to_filename(save_to)
331
332
333
def generate_mip_from_3D(given_3d_nii: ndarray = None, mip_axis: int = 0):
334
    """ Projects a 3D data into MIP along the selected MIP axis.
335
336
    Args:
337
        given_3d_nii: 3D array to project into MIP.
338
        mip_axis: Projecting axis.
339
340
    Returns:
341
        Returns MIP image.
342
343
    """
344
    if given_3d_nii is None:
345
        raise Exception("3D image not given")
346
347
    if not isinstance(given_3d_nii, list):
348
        given_3d_nii = np.array(given_3d_nii)
349
350
    mip = np.amax(given_3d_nii, axis=mip_axis)
351
352
    mip = np.asarray(mip)
353
354
    return mip
355
356
357
def transform_coordinate_space(modality_1, modality_2, mode='nearest'):
358
    """ Apply affine transformation on given data using affine from the second data.
359
360
    Adapted from: https://gist.github.com/zivy/79d7ee0490faee1156c1277a78e4a4c4
361
   Transfers coordinate space from modality_2 to modality_1
362
   Input images are in nifty/nibabel format (.nii or .nii.gz)
363
364
365
    Args:
366
        modality_1: reference modality.
367
        modality_2:  image modality to apply affine transformation.
368
369
    Returns:
370
        Returns affine transformed form of modality_2 using modality_1 as reference.
371
372
    """
373
    aff_t1 = modality_1.affine
374
    try:
375
        aff_t2 = modality_2.affine
376
    except:
377
        aff_t2 = aff_t1
378
379
    inv_af_2 = np.linalg.inv(aff_t2)
380
    out_shape = modality_1.get_fdata().shape
381
382
    # desired transformation
383
    T = inv_af_2.dot(aff_t1)
384
385
    # apply transformation
386
    transformed_img = scipy.ndimage.affine_transform(modality_2.get_fdata(), T, output_shape=out_shape, mode=mode)
387
    return transformed_img
388
389
390
# read PET and GT images
391
def read_pet_gt_resize_crop_save_as_3d_andor_mip(
392
        data_path: str = None, data_name: str = None, saving_dir: str = None, save_3D: bool = False, crop: bool = True,
393
        output_resolution: List[int] = None, desired_spacing: List[float] = None, generate_mip: bool = False
394
        ):
395
    """ Read pet and ground truth images from teh input data path. It also apply resize, and cropping operations.
396
397
    Args:
398
        data_path: directory to the raw 3D pet and ground truth .nii fies.
399
        data_name: unique the whole data identifier.
400
        saving_dir: directory path to save the processed images.
401
        save_3D: Save the processed 3D data or not.
402
        crop: apply cropping operations.
403
        output_resolution: desired output resolution.
404
        desired_spacing: required to be output spacing.
405
        generate_mip: project the processed 3D data into MIPs.
406
407
    Returns:
408
        Returns processed data.
409
410
    """
411
    if output_resolution is not None:
412
        # output resized and cropped image resolution
413
        rows, columns, depths = output_resolution
414
    else:  # default values
415
        # output resized and cropped image resolution=
416
        output_resolution = [128, 128, 256]
417
418
    if data_name is None:
419
        data_name = "unknown_data"
420
421
    '''
422
    get directory name  or patient id of the PET and gt images
423
    Assuming the file structure is :
424
   ---patient_id
425
         -- PET
426
            -- *.nii or .nii.gz
427
        -- gt
428
            -- *.nii or .nii.gz
429
    '''
430
431
    # check if the directory exist
432
    directory_exist(data_path)
433
434
    # by default the processed 3d and 2D MIP will be saved into the 'data' subdirectory, respectively with name tags
435
    # as '_default_3d_dir' and '_default_MIP_dir'
436
437
    def create_directory(directory_to_create: list):
438
        """
439
440
        :param directory_to_create:
441
        """
442
        for directory in directory_to_create:
443
            if not os.path.exists(directory):
444
                os.mkdir(directory)  # os.mkdir("./" + str(identifier))
445
446
    # if saving_dir is None:
447
    #     if not os.path.exists("../data"):
448
    #         os.mkdir("../data")
449
    #
450
    #     saving_dir_3d = "../data/" + str(data_name) + "_default_3d_dir"
451
    #
452
    #     # create directory in the data with name resized_cropped_MIP_default
453
    #     saving_dir_mip = "../data/" + str(data_name) + "_default_MIP_dir"
454
    #
455
    #     create_directory([saving_dir_3d, saving_dir_mip])
456
    #
457
    # # If the saving directory is given, a sub folder for the processed 3d and 2D MIP will be created
458
    # else:
459
    data_3d = str(data_name) + "_default_3d_dir_"
460
    data_mip = str(data_name) + "_default_MIP_dir"
461
    saving_dir_3d = os.path.join(saving_dir, data_3d)
462
    saving_dir_mip = os.path.join(saving_dir, data_mip)
463
464
    create_directory([saving_dir_3d, saving_dir_mip])
465
466
    # all patient ids
467
    case_ids = os.listdir(data_path)
468
469
    if not len(case_ids):  # reise exception if the directory is empty
470
        raise Exception("Directory %s is empty" % data_path)
471
    else:  # read the pet and gt images
472
        print("Continuing to read %d  cases" % len(case_ids))
473
474
    #  resize, to  given resolution
475
    if desired_spacing is None:
476
        desired_spacing = [4.0, 4.0, 4.0]
477
478
    # print where will be saved the 3d and mip
479
    print("\n")
480
    print(40 * "**==**")
481
    print("3D resized and cropped images will be saved to %s, if set save" % saving_dir_3d)
482
    print("Generated MIPs will be saved to %s, if set to save" % saving_dir_mip)
483
    print(40 * "**==**")
484
    print("\n")
485
486
    for image_name in tqdm(list(case_ids)):
487
        # if image_name in images:
488
        current_id = os.path.join(data_path, image_name)
489
        # read, resize, crop and save as 3D
490
        pet, gt = get_pet_gt(current_id)
491
492
        # resolution
493
        res_pet = pet.header.get_zooms()
494
        res_pet = tuple([float(round(res, 2)) for res in list(res_pet[:3])])
495
        affine = pet.affine
496
        print(f'Size of the PET input image: {np.asanyarray(pet.dataobj).shape}')
497
498
        # if there is a ground truth:
499
        if gt is not None:
500
            res_gt = gt.header.get_zooms()
501
            res_gt = tuple([float(round(res, 2)) for res in list(res_gt[:3])])
502
503
            # check if pet and gt are on the same spacing, otherwise gt could be generated from CT images
504
            if not res_pet[:3] == res_gt[:3]:  # assert (res_pet == res_gt)
505
                print("pet, and gt resolutions, respectively \n")
506
                print(res_pet, "\t", res_gt)
507
                warnings.warn("Pet and gt have different spacing, Alignment continue...")
508
                # apply affine transformation to move the gt to the PET space,
509
                # probably the gt was generated from the CT images
510
                gt = transform_coordinate_space(pet, gt, mode='constant')
511
                # raise Exception('Ground truth and pet images are not on the same spacing')
512
                gt[gt >= 1] = 1
513
                gt[gt < 1] = 0
514
            else:
515
                gt = np.asanyarray(gt.dataobj)
516
517
            """
518
               For remarc data the ground truth is inverted along the z-axis
519
               """
520
            if data_name == "remarc":
521
                gt = np.flip(gt, axis=-1)
522
523
            gt = resize_nii_to_desired_spacing(
524
                gt, data_spacing=res_pet, desired_spacing=desired_spacing, interpolation_order_value=0
525
                )
526
527
        pet = np.asanyarray(pet.dataobj)
528
        # if the given image has stacked as channel example one image in remarc : 175x175x274x2
529
        if pet.shape[-1] == 2:
530
            pet = pet[..., 0]
531
532
        # generate_mip_show(pet, gt, identifier=str(image_name))
533
534
        pet = resize_nii_to_desired_spacing(
535
            pet, data_spacing=res_pet, desired_spacing=desired_spacing, interpolation_order_value=3
536
            )
537
538
        '''
539
        if most data are with brain images at the very top, avoid cropping the brain images,instead crop from bottom
540
        or the leg part
541
        '''
542
        if gt is not None:
543
            if str(data_name).lower() == 'lnh':
544
                # flip left to right to mach lnh data to remarc
545
                gt = np.flip(gt, axis=-1)
546
547
        crop_zero_above_brain = True
548
        if crop_zero_above_brain:
549
            # remove all zero pixels just before the brian image
550
            xs, ys, zs = np.where(pet != 0)
551
            if len(zs):  # use zs for cropping the ground truth data also
552
                pet = pet[:, :, min(zs):]
553
554
        # if gt is None:
555
        #     generate_mip_show(pet, pet, identifier=str(image_name))
556
        # else:
557
        #     generate_mip_show(pet, gt, identifier=str(image_name))
558
559
        if crop:
560
            pet = crop_nii_to_desired_resolution(pet.copy(), cropped_resolution=output_resolution.copy())
561
562
            if gt is not None:
563
                # remove the zero values of pet image above the brain
564
                if len(zs):  # use zs for cropping the ground truth data also
565
                    gt = gt[:, :, min(zs):]
566
567
                gt = crop_nii_to_desired_resolution(gt.copy(), cropped_resolution=output_resolution.copy())
568
569
        # if gt is None:
570
        #     generate_mip_show(pet, pet, identifier=str(image_name))
571
        # else:
572
        #     generate_mip_show(pet, gt, identifier=str(image_name))
573
574
        if save_3D:
575
            # output image affine
576
            affine = np.diag([desired_spacing[0], desired_spacing[1], desired_spacing[2], 1])
577
            if gt is not None:
578
                save_nii_images(
579
                    [pet, gt], affine=affine, path_save=saving_dir_3d, identifier=str(image_name),
580
                    name=['pet', 'ground_truth']
581
                    )
582
            else:
583
                save_nii_images([pet], affine=affine, path_save=saving_dir_3d, identifier=str(image_name), name=['pet'])
584
585
        # generate Sagittal and coronal MIPs
586
        if generate_mip:
587
            for sagittal_coronal in range(2):
588
                pet_mip = generate_mip_from_3D(pet, mip_axis=int(sagittal_coronal))  # pet mip
589
590
                # assuming sagittal is on axis 0, and coronal is on axis 1
591
                if sagittal_coronal == 0:  # sagittal
592
                    naming_ = "sagittal"
593
                elif sagittal_coronal == 1:
594
                    naming_ = "coronal"
595
596
                if gt is not None:
597
                    gt_mip = generate_mip_from_3D(gt, mip_axis=int(sagittal_coronal))  # gt mip
598
                    # save the generated MIP
599
                    save_nii_images(
600
                        [pet_mip, gt_mip], affine, path_save=saving_dir_mip, identifier=str(image_name),
601
                        name=['pet_' + str(naming_), 'ground_truth_' + str(naming_)]
602
                        )
603
                else:
604
                    # save the generated MIP
605
                    save_nii_images(
606
                        [pet_mip], affine, path_save=saving_dir_mip, identifier=str(image_name),
607
                        name=['pet_' + str(naming_)]
608
                        )
609
    return saving_dir_mip
610
611
612
# Read .nii files using itk
613
if __name__ == '__main__':
614
    # how to run examples.
615
    # input_path = r"F:\Data\Remarc\REMARC/"
616
    # data_ = "remarc"
617
    #
618
    input_path = r"F:\Data\Vienna\No_ground_truth/"
619
    data_ = "LNH"
620
    saving_dir_mip = read_pet_gt_resize_crop_save_as_3d_andor_mip(
621
        data_path=input_path, data_name=data_, saving_dir=None, save_3D=True, crop=True,
622
        output_resolution=[128, 128, 256], desired_spacing=None, generate_mip=True
623
        )