Ejemplo n.º 1
0
    def create_mask_from_contours(self,
                                  img_obj,
                                  draw_method="ray_cast",
                                  disconnected_segments="keep_as_is",
                                  settings=None):
        # Creates an image based on provided contours

        # Skip if image object is empty
        if img_obj.is_missing:
            self.roi = None
            return

        if settings is not None:
            disconnected_segments = settings.general.divide_disconnected_roi

        # Create an empty roi volume
        roi_mask = np.zeros(img_obj.size, dtype=np.bool)

        # Iterate over contours to fill out the mask
        for contour in self.contour:
            # Multiple methods are implemented. All methods return a slice_list (containing slice numbers (z)) and a mask list, which contain boolean masks for respective
            # slices. This are then inserted at the specified slice positions, using an OR operation. This operation is required to avoid overwriting different slices.

            # Ray casting method to draw segmentation map based on polygon contour
            if draw_method == "ray_cast":
                slice_list, mask_list = contour.contour_to_grid_ray_cast(
                    img_obj=img_obj)
                for ii in np.arange(len(slice_list)):
                    slice_id = slice_list[ii]
                    roi_mask[slice_id, :, :] = np.logical_or(
                        roi_mask[slice_id, :, :], mask_list[ii])

        if disconnected_segments == "keep_largest":
            # Check if the created roi mask consists of multiple, separate segments, and keep only the largest.
            import skimage.measure

            # Label regions
            roi_label_mask, n_regions = skimage.measure.label(input=roi_mask,
                                                              connectivity=2,
                                                              return_num=True)

            # Determine size of regions
            roi_sizes = np.zeros(n_regions)
            for ii in np.arange(start=0, stop=n_regions):
                roi_sizes[ii] = np.sum(roi_label_mask == ii + 1)

            # Select largest region
            roi_mask = roi_label_mask == np.argmax(roi_sizes) + 1

        # Store roi as image object
        self.roi = ImageClass(voxel_grid=roi_mask,
                              origin=img_obj.origin,
                              spacing=img_obj.spacing,
                              orientation=img_obj.orientation,
                              slice_z_pos=img_obj.slice_z_pos)

        # Remove contour information
        self.contour = None
Ejemplo n.º 2
0
def read_itk_image(image_folder, modality=None, name_contains=None):

    # Identify the ITK (NIfTI or NRRD) file.
    itk_file = _find_itk_images(image_folder=image_folder,
                                name_contains=name_contains,
                                is_mask=False)

    # Load the image
    sitk_img = sitk.ReadImage(os.path.join(image_folder, itk_file))

    # Import the image volume
    voxel_grid = sitk.GetArrayFromImage(sitk_img)

    # Determine origin, spacing, and orientation
    image_origin = np.array(sitk_img.GetOrigin())[::-1]
    image_spacing = np.array(sitk_img.GetSpacing())[::-1]
    image_orientation = np.array(sitk_img.GetDirection())[::-1]

    # Determine z-positions of the slices
    slice_z_pos = image_origin[0] + np.arange(
        voxel_grid.shape[0]) * image_spacing[0]

    # Create an ImageClass object from the input image.
    image_obj = ImageClass(voxel_grid=voxel_grid,
                           origin=image_origin,
                           spacing=image_spacing,
                           slice_z_pos=slice_z_pos,
                           orientation=image_orientation,
                           modality=modality,
                           spat_transform="base",
                           no_image=False)

    return image_obj
Ejemplo n.º 3
0
def merge_roi_objects(roi_list):
    """
    Combine multiple roi objects into one
    :param roi_list:
    :return:
    """

    # If there are no rois to combine, return the only roi object
    if len(roi_list) == 1:
        return roi_list[0]

    # Read basic information concerning the roi mask
    roi_origin = roi_list[0].roi.origin
    roi_spacing = roi_list[0].roi.spacing
    roi_orientation = roi_list[0].roi.orientation
    roi_size = roi_list[0].roi.size

    roi_mask = np.zeros(roi_size, dtype=np.bool)

    # Iterate over rois and perform checks
    for roi in roi_list:

        # Ensure that voxel masks have been created
        if roi.contour is not None:
            raise ValueError("ROI needs to exist as a mask prior to merging.")

        # Ensure that the origin is the same
        if not np.all(np.equal(roi_origin, roi.roi.origin)):
            raise ValueError("Merged ROIs have mismatching origins.")

        # Ensure that spacing is the same
        if not np.all(np.equal(roi_spacing, roi.roi.spacing)):
            raise ValueError("Merged ROIs have mismatching voxel spacing.")

        # Ensure that orientation is the same
        if not np.all(np.equal(roi_orientation, roi.roi.orientation)):
            raise ValueError("Merged ROIs have mismatching orientations.")

        # Ensure that size is the same
        if not np.all(np.equal(roi_size, roi.roi.size)):
            raise ValueError("Merged ROIs do not have the same size.")

        roi_mask = np.logical_or(roi_mask, roi.roi.get_voxel_grid())

    # Create a roi mask object
    roi_mask_obj = ImageClass(voxel_grid=roi_mask,
                              origin=roi_origin,
                              spacing=roi_spacing,
                              orientation=roi_orientation)

    # Set name of the roi
    combined_roi_name = "+".join([roi.name for roi in roi_list])

    # Create a merged roi object
    combined_roi = RoiClass(name=combined_roi_name,
                            contour=None,
                            roi_mask=roi_mask_obj)

    return combined_roi
Ejemplo n.º 4
0
def _load_itk_segmentation(image_folder, roi: str):

    # Deparse roi
    deparsed_roi = parse_roi_name(roi=roi)

    # Create an empty roi_list. This list will be iteratively expanded.
    roi_list = []

    for single_roi in deparsed_roi:
        # Not every roi may be found as a file, e.g. in a shotgun approach. We capture
        # NoITKSegmentationFileFound errors that only occur if the segmentation cannot be found.
        try:
            file_name = _find_itk_images(image_folder=image_folder,
                                         name_contains=single_roi,
                                         is_mask=True)
        except NoITKSegmentationFileFound:
            continue

        # Load the segmentation file
        sitk_img = sitk.ReadImage(os.path.join(image_folder, file_name))

        # Obtain mask
        mask = sitk.GetArrayFromImage(sitk_img).astype(np.bool)

        # Determine origin, spacing, and orientation
        mask_origin = np.array(sitk_img.GetOrigin())[::-1]
        mask_spacing = np.array(sitk_img.GetSpacing())[::-1]
        mask_orientation = np.array(sitk_img.GetDirection())[::-1]

        # Determine z-positions of the slices
        slice_z_pos = mask_origin[0] + np.arange(
            mask.shape[0]) * mask_spacing[0]

        # Create an ImageClass object using the mask.
        roi_mask_obj = ImageClass(voxel_grid=mask,
                                  origin=mask_origin,
                                  spacing=mask_spacing,
                                  slice_z_pos=slice_z_pos,
                                  orientation=mask_orientation,
                                  modality="SEG",
                                  spat_transform="base",
                                  no_image=False)

        roi_list += [
            RoiClass(name=single_roi, contour=None, roi_mask=roi_mask_obj)
        ]

    # Attempt to merge deparsed roi objects.
    if len(roi_list) > 0:
        roi_obj = merge_roi_objects(roi_list=roi_list)

    else:
        roi_obj = None

    return roi_obj
Ejemplo n.º 5
0
class RoiClass:
    # Class for regions of interest

    def __init__(self, name, contour, roi_mask=None, g_range=np.array([np.nan, np.nan]), incl_threshold=0.5, metadata=None):

        self.name = name
        if contour is not None:
            self.contour = contour
        else:
            self.contour = None

        # Fixed parameters
        self.g_range = g_range                  # Range of allowed grey level intensities
        self.incl_threshold = incl_threshold    # Threshold for partial volume effect
        self.adapt_size = 0.0                   # Shrinkage and growth of roi
        self.svx_randomisation_id = -1          # Randomisation id for supervoxel roi randomisation

        # ROI masks
        self.roi: Union[ImageClass, None] = roi_mask             # Union of intensity and morphology masks
        self.roi_intensity: Union[ImageClass, None] = None       # Intensity mask of the ROI
        self.roi_morphology: Union[ImageClass, None] = None      # Morphological mask of the ROI

        # Diagnostics features
        self.diagnostic_list = []
        self.metadata: FileDataset = metadata

    def copy(self, drop_image=False):

        roi_copy = copy.deepcopy(self)

        if drop_image:
            roi_copy.roi.drop_image()
            if roi_copy.roi_intensity is not None:
                roi_copy.roi_intensity.drop_image()
            if roi_copy.roi_morphology is not None:
                roi_copy.roi_morphology.drop_image()

        # Creates a new copy of the roi
        return roi_copy

    def create_mask_from_contours(self, img_obj, draw_method="ray_cast", disconnected_segments="keep_as_is", settings=None):
        # Creates an image based on provided contours

        # Skip if image object is empty
        if img_obj.is_missing:
            self.roi = None
            return

        if settings is not None:
            disconnected_segments = settings.general.divide_disconnected_roi

        # Create an empty roi volume
        roi_mask = np.zeros(img_obj.size, dtype=np.bool)

        # Create empty slice and mask lists.
        slice_list = []
        mask_list = []

        # Iterate over contours to fill out the mask
        for contour in self.contour:
            # Multiple methods are implemented. All methods return a slice_list (containing slice numbers (z)) and a mask list, which contain boolean masks for respective
            # slices. This are then inserted at the specified slice positions, using an OR operation. This operation is required to avoid overwriting different slices.

            # Ray casting method to draw segmentation map based on polygon contour
            if draw_method == "ray_cast":
                contour_slice_list, contour_mask_list = contour.contour_to_grid_ray_cast(img_obj=img_obj)

                slice_list += contour_slice_list
                mask_list += contour_mask_list

        # Check for out-of-range slices.
        if len(slice_list) > 0:

            # Identify if there any both negative or positive values in slice_list.
            if any([slice_id < 0 for slice_id in slice_list]) and not all([slice_id < 0 for slice_id in slice_list]):
                mask_list = [mask_list[ii] for ii, unused in enumerate(mask_list) if slice_list[ii] >= 0]
                slice_list = [slice_id for slice_id in slice_list if slice_id >= 0]

            # Identify any slices that lie outside the negative or positive z-range.
            mask_list = [mask_list[ii] for ii, unused in enumerate(mask_list) if abs(slice_list[ii]) < img_obj.size[0]]
            slice_list = [slice_id for slice_id in slice_list if slice_id < img_obj.size[0]]

        # Set mask.
        if len(slice_list) > 0:

            # Iterate over the elements in the slice list.
            for ii in np.arange(len(slice_list)):
                slice_id = slice_list[ii]
                roi_mask[slice_id, :, :] = np.logical_or(roi_mask[slice_id, :, :], mask_list[ii])

        if disconnected_segments == "keep_largest":
            # Check if the created roi mask consists of multiple, separate segments, and keep only the largest.
            import skimage.measure

            # Label regions
            roi_label_mask, n_regions = skimage.measure.label(input=roi_mask, connectivity=2, return_num=True)

            # Determine size of regions
            roi_sizes = np.zeros(n_regions)
            for ii in np.arange(start=0, stop=n_regions):
                roi_sizes[ii] = np.sum(roi_label_mask == ii + 1)

            # Select largest region
            roi_mask = roi_label_mask == np.argmax(roi_sizes) + 1

        # Store roi as image object
        self.roi = ImageClass(voxel_grid=roi_mask, origin=img_obj.origin, spacing=img_obj.spacing, orientation=img_obj.orientation)

        # Remove contour information
        self.contour = None

    def decimate(self, by_slice):
        """
        Decimates the roi
        :param by_slice: boolean, 2D (True) or 3D (False)
        :return:
        """

        # Resect masks
        if self.roi is not None:
            self.roi.decimate(by_slice=by_slice)
        if self.roi_intensity is not None:
            self.roi_intensity.decimate(by_slice=by_slice)
        if self.roi_morphology is not None:
            self.roi_morphology.decimate(by_slice=by_slice)

    def interpolate(self, img_obj, settings):

        # Skip if image and/or is missing
        if img_obj is None or self.roi is None:
            return

        if settings.img_interpolate.anti_aliasing:
            from mirp.imageProcess import gaussian_preprocess_filter
            self.roi.set_voxel_grid(voxel_grid=gaussian_preprocess_filter(orig_vox=self.roi.get_voxel_grid(), orig_spacing=self.roi.spacing,
                                                                          sample_spacing=img_obj.spacing,
                                                                          param_beta=settings.img_interpolate.smoothing_beta, mode="nearest",
                                                                          by_slice=settings.general.by_slice))

        # Register with image
        self.register(img_obj=img_obj)

        # Binarise
        self.binarise_mask()

    def register(self, img_obj: ImageClass, apply_to_self=True):
        """Register roi with image
        Do not apply threshold until after interpolation"""

        if apply_to_self is False:
            roi_copy = self.copy()
            roi_copy.register(img_obj=img_obj, apply_to_self=True)
            return roi_copy

        from mirp.imageProcess import interpolate_to_new_grid

        # Skip if image and/or is missing
        if img_obj is None or self.roi is None:
            return

        # Check whether registration is required
        registration_required = False

        # Mismatch in grid dimension
        if np.any([np.abs(np.array(self.roi.size) - np.array(img_obj.size)) > 0.0]):
            registration_required = True

        # Mismatch in origin
        if np.any([np.abs(self.roi.origin - img_obj.origin) > 0.0]):
            registration_required = True

        # Mismatch in spacing
        if np.any([np.abs(self.roi.spacing - img_obj.spacing) > 0.0]):
            registration_required = True

        if not np.allclose(self.roi.orientation, img_obj.orientation):
            raise ValueError("Cannot register segmentation and image object due to different alignments. "
                             "Please use an external programme to transfer segmentation to the image.")

        if registration_required:
            # Register roi to image; this transforms the roi grid into
            self.roi.size, sample_spacing, voxel_grid, grid_origin = \
                interpolate_to_new_grid(orig_dim=self.roi.size,
                                        orig_spacing=self.roi.spacing,
                                        orig_vox=self.roi.get_voxel_grid(),
                                        sample_dim=img_obj.size,
                                        sample_spacing=img_obj.spacing,
                                        grid_origin=np.dot(self.roi.m_affine_inv, np.transpose(img_obj.origin - self.roi.origin)),
                                        order=1,
                                        mode="nearest",
                                        align_to_center=False)

            # Update origin before spacing, because computing the origin requires the original affine matrix.
            self.roi.origin = self.roi.origin + np.dot(self.roi.m_affine, np.transpose(grid_origin))

            # Update spacing and affine matrix.
            self.roi.set_spacing(sample_spacing)

            # Update voxel grid
            self.roi.set_voxel_grid(voxel_grid=voxel_grid)

    def binarise_mask(self):

        if self.roi is None:
            return

        if not self.roi.dtype_name == "bool":
            self.roi.set_voxel_grid(voxel_grid=np.around(self.roi.get_voxel_grid(), 6) >= np.around(self.incl_threshold, 6))

    def generate_masks(self):
        """"Generate roi intensity and morphology masks"""

        if self.roi is None:
            self.roi_intensity = None
            self.roi_morphology = None
        else:
            self.roi_intensity = self.roi.copy()
            self.roi_morphology = self.roi.copy()

    def update_roi(self):
        """Update region of interest based on intensity and morphological masks"""

        if self.roi is None or self.roi_intensity is None or self.roi_morphology is None:
            return

        self.roi.set_voxel_grid(voxel_grid=np.logical_or(self.roi_intensity.get_voxel_grid(), self.roi_morphology.get_voxel_grid()))

    def crop(self, ind_ext_z=None, ind_ext_y=None, ind_ext_x=None,
             xy_only=False, z_only=False):
        """"Resects roi"""

        # Resect masks
        if self.roi is not None:
            self.roi.crop(ind_ext_z=ind_ext_z,
                          ind_ext_y=ind_ext_y,
                          ind_ext_x=ind_ext_x,
                          xy_only=xy_only,
                          z_only=z_only)

        if self.roi_intensity is not None:
            self.roi_intensity.crop(ind_ext_z=ind_ext_z,
                                    ind_ext_y=ind_ext_y,
                                    ind_ext_x=ind_ext_x,
                                    xy_only=xy_only,
                                    z_only=z_only)

        if self.roi_morphology is not None:
            self.roi_morphology.crop(ind_ext_z=ind_ext_z,
                                     ind_ext_y=ind_ext_y,
                                     ind_ext_x=ind_ext_x,
                                     xy_only=xy_only,
                                     z_only=z_only)

    def crop_to_size(self, center, crop_size, xy_only=False):
        """"Crops roi to a pre-defined size"""

        # Crop masks to size
        if self.roi is not None:
            self.roi.crop_to_size(center=center, crop_size=crop_size, xy_only=xy_only)
        if self.roi_intensity is not None:
            self.roi_intensity.crop_to_size(center=center, crop_size=crop_size, xy_only=xy_only)
        if self.roi_morphology is not None:
            self.roi_morphology.crop_to_size(center=center, crop_size=crop_size, xy_only=xy_only)

    def resegmentise_mask(self, img_obj, by_slice, method, settings):
        # Resegmentation of the roi map based on grey level values

        from skimage.measure import label
        from skimage.morphology import remove_small_holes

        # Skip if required voxel grids are missing
        if img_obj.is_missing or self.roi_intensity is None or self.roi_morphology is None:
            return

        ################################################################################################################
        # Resegmentation that affects both intensity and morphological maps
        ################################################################################################################

        # Initialise range
        updated_range = np.array([np.nan, np.nan])

        if bool(set(method).intersection(["threshold", "range"])):
            # Filter out voxels with intensity outside prescribed range

            # Local constant
            g_thresh = settings.roi_resegment.g_thresh  # Threshold values

            # Upper threshold
            if not np.isnan(g_thresh[1]):
                updated_range[1] = copy.deepcopy(g_thresh[1])

            # Lower threshold
            if not np.isnan(g_thresh[0]):
                updated_range[0] = copy.deepcopy(g_thresh[0])

            # Set the threshold values as g_range
            self.g_range = g_thresh

        if bool(set(method).intersection(["sigma", "outlier"])):
            # Remove voxels with outlier intensities

            # Local constant
            sigma = settings.roi_resegment.sigma
            img_voxel_grid = img_obj.get_voxel_grid()
            roi_voxel_grid = self.roi_intensity.get_voxel_grid()

            # Check if the voxel grid is not empty
            if np.any(roi_voxel_grid):

                # Calculate mean and standard deviation of intensities in roi
                mean_int = np.mean(img_voxel_grid[roi_voxel_grid])
                sd_int   = np.std(img_voxel_grid[roi_voxel_grid])

                if not np.isnan(updated_range[0]):
                    updated_range[0] = np.max([updated_range[0], mean_int - sigma * sd_int])
                else:
                    updated_range[0] = mean_int - sigma * sd_int

                if not np.isnan(updated_range[1]):
                    updated_range[1] = np.min([updated_range[1], mean_int + sigma * sd_int])
                else:
                    updated_range[1] = mean_int + sigma * sd_int

        if not np.isnan(updated_range[0]) or not np.isnan(updated_range[1]):
            # Update intensity mask
            roi_voxel_grid = self.roi_intensity.get_voxel_grid()

            if not np.isnan(updated_range[0]):
                roi_voxel_grid = np.logical_and((img_obj.get_voxel_grid() >= updated_range[0]), roi_voxel_grid)

            if not np.isnan(updated_range[1]):
                roi_voxel_grid = np.logical_and((img_obj.get_voxel_grid() <= updated_range[1]), roi_voxel_grid)

            # Set roi voxel volume
            self.roi_intensity.set_voxel_grid(voxel_grid=roi_voxel_grid)

        ################################################################################################################
        # Resegmentation that affects only morphological maps
        ################################################################################################################
        if bool(set(method).intersection("close_volume")):
            # Close internal volumes

            from scipy.ndimage import generate_binary_structure, binary_erosion

            # Read minimal volume required
            max_fill_volume = settings.roi_resegment.max_fill_volume

            # Get voxel grid of the roi morphological mask
            roi_voxel_grid = self.roi_morphology.get_voxel_grid()

            # Determine fill volume (in voxels); if max_fill_volume is less than 0.0, fill all holes
            if max_fill_volume < 0.0: fill_volume = np.prod(np.array(self.roi_morphology.size)) + 1.0
            else:                     fill_volume = np.floor(max_fill_volume / np.prod(self.roi_morphology.spacing)) + 1.0

            # If the maximum fill volume is smaller than the minimal size of a hole
            if fill_volume < 1.0: return None

            # Label all non-roi voxels and get label corresponding to voxels outside of the roi
            non_roi_label = label(np.pad(roi_voxel_grid, 1, mode="constant", constant_values=0),
                                  background=1, connectivity=3)
            outside_label = non_roi_label[0, 0, 0]

            # Crop non-roi labels and determine non-roi voxels outside of the mask
            non_roi_label = non_roi_label[1:-1, 1:-1, 1:-1]
            vox_outside = non_roi_label == outside_label

            # Determine mask of voxels which are not internal holes
            vox_not_internal = np.logical_or(roi_voxel_grid, vox_outside)

            # Check if there are any holes, otherwise continue
            if not np.any(~vox_not_internal): return None

            if by_slice:
                # 2D approach to filling holes

                for ii in np.arange(0, self.roi_morphology.size[0]):
                    # Skip operations on slides that do not contain voxels in the mask or no holes in the slice
                    if not np.any(roi_voxel_grid[ii, :, :]): continue
                    if not(np.any(~vox_not_internal[ii, :, :])): continue

                    # Fill holes up to fill_volume in voxel number
                    vox_filled = remove_small_holes(vox_not_internal[ii, :, :], min_size=np.int(fill_volume), connectivity=2)

                    # Update mask by removing outside voxels from the mask
                    roi_voxel_grid[ii, :, :] = np.squeeze(np.logical_and(vox_filled, ~vox_outside[ii, :, :]))
            else:
                # 3D approach to filling holes

                # Fill holes up to fill_volume in voxel number
                vox_filled = remove_small_holes(vox_not_internal, min_size=np.int(fill_volume), connectivity=3)

                # Update mask by removing outside voxels from the mask
                roi_voxel_grid = np.logical_and(vox_filled, ~vox_outside)

            # Update voxel grid
            self.roi_morphology.set_voxel_grid(voxel_grid=roi_voxel_grid)

        if bool(set(method).intersection("remove_disconnected")):
            # Remove disconnected voxels

            # Discover prior disconnected volumes from the roi voxel grid
            vox_disconnected = label(self.roi.get_voxel_grid(), background=0, connectivity=3)
            vox_disconnected_labels = np.unique(vox_disconnected)

            # Set up an empty morphological masks
            upd_vox_mask = np.full(shape=self.roi_morphology.size, fill_value=False, dtype=np.bool)

            # Get the minimum volume fraction for inclusion as voxels
            min_vol_fract = settings.roi_resegment.min_vol_fract

            # Iterate over disconnected labels
            for curr_volume_label in vox_disconnected_labels:

                # Skip background
                if vox_disconnected_labels == 0: continue

                # Mask only current volume, skip if empty
                curr_mask = np.logical_and(self.roi_morphology.get_voxel_grid(), vox_disconnected == curr_volume_label)
                if not np.any(curr_mask): continue

                # Find fully disconnected voxels groups and count them
                vox_mask = label(curr_mask, background=0, connectivity=3)
                vox_mask_labels, vox_label_count = np.unique(vox_mask, return_counts=True)

                # Filter out the background counts
                valid_label_id = np.nonzero(vox_mask_labels)
                vox_mask_labels = vox_mask_labels[valid_label_id]
                vox_label_count = vox_label_count[valid_label_id]

                # Normalise to maximum
                vox_label_count = vox_label_count / np.max(vox_label_count)

                # Select labels fulfilling the minimal size
                vox_mask_labels = vox_mask_labels[vox_label_count >= min_vol_fract]

                for vox_mask_label_id in vox_mask_labels:
                    upd_vox_mask += vox_mask == vox_mask_label_id

                # Update morphological voxel grid
                self.roi_morphology.set_voxel_grid(voxel_grid=upd_vox_mask > 0)

    def is_empty(self):
        """Checks whether the roi or one of its masks is empty"""

        # Original roi object
        if self.roi is not None:
            n_roi_voxels     = np.int(np.sum(self.roi.get_voxel_grid()))
            if n_roi_voxels == 0:
                return True

        # Roi intensity mask
        if self.roi_intensity is not None:
            n_roi_int_voxels = np.int(np.sum(self.roi_intensity.get_voxel_grid()))
            if n_roi_int_voxels == 0:
                return True

        # Roi morphological mask
        if self.roi_morphology is not None:
            n_roi_morph_voxels = np.int(np.sum(self.roi_morphology.get_voxel_grid()))
            if n_roi_morph_voxels == 0:
                return True

        # If none of the above rois was empty, return false
        return False

    def rotate(self, angle, img_obj):
        """ Rotates roi in the y-x plane """

        # Register with image prior to rotation
        self.register(img_obj=img_obj)

        # Rotate roi
        self.roi.rotate(angle)

    def dilate(self, by_slice, dist=None, vox_dist=None):
        from mirp.featureSets.utilities import rep
        import scipy.ndimage as ndi

        # Skip if the roi does not exist
        if self.roi is None:
            return

        # Check dtype of the roi voxel grid and binarise if necessary
        if not self.roi.dtype_name == "bool":
            logging.info("Converting roi to boolean before dilation.")
            self.binarise_mask()

        # Check if any distance is provided for dilation
        if vox_dist is None and dist is None:
            logging.error("No dilation distance provided.")

        # Check whether voxel are isometric
        if by_slice: spacing = self.roi.spacing[[1, 2]]
        else:        spacing = self.roi.spacing

        if np.any(spacing - np.max(spacing) != 0.0):
            logging.warning("Non-uniform voxel spacing was detected. Roi dilation requires uniform voxel spacing.")

        # Derive filter extension and distance
        if dist is not None:
            base_ext: int = np.max([np.floor(dist / np.max(spacing)).astype(np.int), 0])
        else:
            base_ext: int = np.int(vox_dist)
            dist     = vox_dist * np.max(spacing)

        # Check if an actual extension is required.
        if base_ext > 0:

            # Create displacement map
            df_base = pd.DataFrame({"x": rep(x=np.arange(-base_ext, base_ext + 1),
                                             each=(2 * base_ext + 1) * (2 * base_ext + 1),
                                             times=1),
                                    "y": rep(x=np.arange(-base_ext, base_ext + 1),
                                             each=2 * base_ext + 1,
                                             times=2 * base_ext + 1),
                                    "z": rep(x=np.arange(-base_ext, base_ext + 1),
                                             each=1,
                                             times=(2 * base_ext + 1) * (2 * base_ext + 1))})

            # Calculate distances for displacement map
            df_base["dist"] = np.sqrt(np.sum(np.multiply(df_base.loc[:, ("z", "y", "x")].values, self.roi.spacing) ** 2.0, axis=1))

            # Identify elements in range
            if by_slice: df_base["in_range"] = np.logical_and(df_base.dist <= dist, df_base.z == 0)
            else:        df_base["in_range"] = df_base.dist <= dist

            # Update voxel coordinates to start at [0,0,0]
            df_base.loc[:, ["x", "y", "z"]] -= df_base.loc[0, ["x", "y", "z"]]

            # Generate geometric filter structure
            geom_struct = np.zeros(shape=(np.max(df_base.z) + 1, np.max(df_base.y) + 1,
                                          np.max(df_base.x) + 1), dtype=np.bool)
            geom_struct[df_base.z.astype(np.int), df_base.y.astype(np.int), df_base.x.astype(np.int)] = df_base.in_range

            # Dilate roi mask amd store voxel grid
            self.roi.set_voxel_grid(voxel_grid=ndi.binary_dilation(self.roi.get_voxel_grid(), structure=geom_struct, iterations=1))

        else:
            logging.info("No dilation: distance %s is too small compared to voxel spacing %s.", str(dist),
                         str(np.max(spacing)))

    def adapt_volume(self, by_slice, vol_grow_fract=None):
        import scipy.ndimage

        # Skip if the roi does not exist
        if self.roi is None:
            return

        # Check dtype of the roi voxel grid and binarise if necessary
        if not self.roi.dtype_name == "bool":
            logging.info("Converting roi to boolean before dilation.")
            self.binarise_mask()

        # Check if any distance is provided for dilation
        if vol_grow_fract is None:
            logging.error("No dilation volume fraction was provided.")

        # Check whether voxels are isometric
        if by_slice:
            spacing = self.roi.spacing[[1, 2]]
        else:
            spacing = self.roi.spacing

        if np.any(spacing - np.max(spacing) != 0.0):
            logging.warning("Non-uniform voxel spacing was detected. Roi volume adaptation requires uniform voxel spacing.")

        # Set geometrical structure
        geom_struct = scipy.ndimage.generate_binary_structure(3, 1)
        if by_slice: geom_struct[(0, 2), :, :] = False  # Set structures in different slices to 0

        if not vol_grow_fract == 0.0 and not self.is_empty():
            # Determine original volume
            previous_roi = self.roi.get_voxel_grid()
            orig_volume = np.sum(previous_roi)

            # Iteratively grow or shrink the volume. The loop terminates through break statements
            while True:
                if vol_grow_fract > 0.0:
                    updated_roi = scipy.ndimage.binary_dilation(previous_roi, structure=geom_struct, iterations=1)
                else:
                    updated_roi = scipy.ndimage.binary_erosion(previous_roi, structure=geom_struct, iterations=1)

                new_volume = np.sum(updated_roi)
                if new_volume == 0:
                    break

                if vol_grow_fract > 0.0 and new_volume/orig_volume - 1.0 >= vol_grow_fract:
                    break

                if vol_grow_fract < 0.0 and new_volume/orig_volume - 1.0 <= vol_grow_fract:
                    break

                # Replace previous roi by the updated roi
                previous_roi = updated_roi

            # Randomly add/remove border voxels until desired growth/shrinkage is achieved
            if not new_volume/orig_volume - 1.0 == vol_grow_fract:
                additional_vox = np.abs(int(np.floor(orig_volume * (1.0 + vol_grow_fract) - np.sum(previous_roi))))
                if additional_vox > 0:
                    border_voxel_ind = np.array(np.where(np.logical_xor(previous_roi, updated_roi)))
                    select_ind = np.random.choice(a=border_voxel_ind.shape[1], size=additional_vox, replace=False)
                    border_voxel_ind = border_voxel_ind[:, select_ind]
                    if vol_grow_fract > 0.0:
                        previous_roi[border_voxel_ind[0, :], border_voxel_ind[1, :], border_voxel_ind[2, :]] = True
                    else:
                        previous_roi[border_voxel_ind[0, :], border_voxel_ind[1, :], border_voxel_ind[2, :]] = False

            # Set the new roi
            self.roi.set_voxel_grid(voxel_grid=previous_roi)

    def erode(self, by_slice, eroded_vol_fract=0.8, dist=None, vox_dist=None):
        """" Erosion of the roi segment """

        import scipy.ndimage as ndi

        # Skip if the roi does not exist
        if self.roi is None:
            return

        # Check whether voxels are booleans
        if not self.roi.dtype_name == "bool":
            logging.info("Converting roi to boolean before erosion.")
            self.binarise_mask()

        # Check if any distance is provided for dilation
        if vox_dist is None and dist is None:
            logging.error("No erosion distance provided.")

        # Check whether voxel are isometric
        if by_slice: spacing = self.roi.spacing[[1, 2]]
        else:        spacing = self.roi.spacing

        if np.any(spacing - np.max(spacing) != 0.0):
            logging.warning("Non-uniform voxel spacing was detected. Roi erosion requires uniform voxel spacing.")

        # Set geometrical structure
        geom_struct = ndi.generate_binary_structure(3, 1)
        if by_slice: geom_struct[(0, 2), :, :] = False    # Set structures in different slices to 0

        # Set number of erosion steps
        if vox_dist is None:
            erode_steps = np.max([np.round(np.abs(dist) / np.max(spacing)).astype(np.int), 0])
        else:
            erode_steps = np.abs(vox_dist.astype(np.int))
            dist = vox_dist * np.max(spacing)

        if erode_steps > 0:
            # Determine initial volume
            voxels_prev = self.roi.get_voxel_grid()
            vol_init = np.sum(voxels_prev)

            # Iterate over erosion steps
            for step in np.arange(0, erode_steps):

                # Perform erosion
                voxels_upd = ndi.binary_erosion(voxels_prev, structure=geom_struct, iterations=1)

                # Calculate volume of the eroded volume
                vol_curr = np.sum(voxels_upd)

                # Stop erosion if the volume shrinks below 80 percent of the original volume due to erosion and return
                # return voxels from the previous erosion step.
                if vol_curr * 1.0 / vol_init < eroded_vol_fract:
                    voxels_upd = voxels_prev
                    break
                else:
                    voxels_prev = voxels_upd

            # Set updated voxels
            self.roi.set_voxel_grid(voxel_grid=voxels_upd)
        else:
            logging.info("No erosion: distance %s is too small compared to voxel spacing %s.", str(dist), str(np.max(spacing)))

    def decode_voxel_grid(self):
        """Converts run length encoded grids to conventional volumes"""

        # Decode main ROI object
        if self.roi is not None:
            self.roi.decode_voxel_grid()

        # Decode intensity and morphological masks
        if self.roi_intensity is not None:
            self.roi_intensity.decode_voxel_grid()
        if self.roi_morphology is not None:
            self.roi_morphology.decode_voxel_grid()

    def as_pandas_dataframe(self, img_obj, intensity_mask=False, morphology_mask=False, distance_map=False, by_slice=False):
        """Converts the image and roi voxel grids to a pandas dataframe for further processing"""

        # Return None if the image and/or ROI are missing
        if img_obj.is_missing or self.roi is None:
            return None

        # Check if the masks exist and assign if not
        if intensity_mask and self.roi_intensity is None:
            self.roi_intensity = self.roi.copy()
        if (morphology_mask or distance_map) and self.roi_morphology is None:
            self.roi_morphology = self.roi.copy()

        # Create table from test object
        img_dims = img_obj.size
        index_id = np.arange(start=0, stop=np.prod(img_dims))
        coords = np.unravel_index(indices=index_id, dims=img_dims)
        df_img = pd.DataFrame({"index_id": index_id,
                               "g":        np.ravel(img_obj.get_voxel_grid()),
                               "x": coords[2],
                               "y": coords[1],
                               "z": coords[0]})

        if intensity_mask:
            df_img["roi_int_mask"] = np.ravel(self.roi_intensity.get_voxel_grid()).astype(np.bool)
        if morphology_mask:
            df_img["roi_morph_mask"] = np.ravel(self.roi_morphology.get_voxel_grid()).astype(np.bool)
        if distance_map:
            # Calculate distance by sequential border erosion
            from scipy.ndimage import generate_binary_structure, binary_erosion

            # Set up distance map and morphological voxel grid
            dist_map = np.zeros(img_dims)
            morph_voxel_grid = self.roi_morphology.get_voxel_grid()

            if by_slice:
                # Distances are determined in 2D
                binary_struct = generate_binary_structure(rank=2, connectivity=1)

                # Iterate over slices
                for ii in np.arange(0, img_dims[0]):
                    # Calculate distance by sequential border erosion
                    roi_eroded = np.squeeze(morph_voxel_grid[ii, :, :])

                    # Iterate distance from border
                    while np.sum(roi_eroded) > 0:
                        roi_eroded = binary_erosion(roi_eroded, structure=binary_struct)
                        dist_map[ii, :, :] += roi_eroded * 1

            else:
                # Distances are determined in 3D
                binary_struct = generate_binary_structure(rank=3, connectivity=1)

                # Copy of roi morphology mask
                roi_eroded = copy.deepcopy(morph_voxel_grid)

                # Incrementally erode the morphological mask
                while np.sum(roi_eroded) > 0:
                    roi_eroded = binary_erosion(roi_eroded, structure=binary_struct)
                    dist_map += roi_eroded * 1

            # Update distance from border, as minimum distance is 1
            dist_map[morph_voxel_grid] += 1

            # Add distance map to table
            df_img["border_distance"] = np.ravel(dist_map).astype(np.int32)

        return df_img

    def compute_diagnostic_features(self, img_obj, append_str=""):
        """ Creates diagnostic features for the ROI """

        # Set feature names
        feat_names = ["int_map_dim_x", "int_map_dim_y", "int_map_dim_z", "int_bb_dim_x", "int_bb_dim_y", "int_bb_dim_z",
                      "int_vox_dim_x", "int_vox_dim_y", "int_vox_dim_z", "int_vox_count", "int_mean_int", "int_min_int", "int_max_int",
                      "mrp_map_dim_x", "mrp_map_dim_y", "mrp_map_dim_z", "mrp_bb_dim_x", "mrp_bb_dim_y", "mrp_bb_dim_z",
                      "mrp_vox_dim_x", "mrp_vox_dim_y", "mrp_vox_dim_z", "mrp_vox_count", "mrp_mean_int", "mrp_min_int",
                      "mrp_max_int"]

        # Create pandas dataframe with one row and feature columns
        df = pd.DataFrame(np.full(shape=(1, len(feat_names)), fill_value=np.nan))
        df.columns = feat_names

        # Skip further analysis if the image and/or roi are missing
        if img_obj.is_missing or self.roi is None:
            return df

        # Register with image on function call
        roi_copy = self.register(img_obj, apply_to_self=False)

        # Binarise (if required)
        roi_copy.binarise_mask()

        # Make copies of intensity and morphological masks (if required)
        if roi_copy.roi_intensity is None:
            roi_copy.roi_intensity = roi_copy.roi
        if roi_copy.roi_morphology is None:
            roi_copy.roi_morphology = roi_copy.roi

        # Get image and roi voxel grids
        img_voxel_grid = img_obj.get_voxel_grid()
        int_voxel_grid = roi_copy.roi_intensity.get_voxel_grid()
        mrp_voxel_grid = roi_copy.roi_morphology.get_voxel_grid()

        # Compute bounding boxes
        int_bounding_box_dim = np.squeeze(np.diff(roi_copy.get_bounding_box(roi_voxel_grid=int_voxel_grid), axis=0) + 1)
        mrp_bounding_box_dim = np.squeeze(np.diff(roi_copy.get_bounding_box(roi_voxel_grid=mrp_voxel_grid), axis=0) + 1)

        # Set intensity mask features
        df["int_map_dim_x"] = roi_copy.roi_intensity.size[2]
        df["int_map_dim_y"] = roi_copy.roi_intensity.size[1]
        df["int_map_dim_z"] = roi_copy.roi_intensity.size[0]
        df["int_bb_dim_x"] = int_bounding_box_dim[2]
        df["int_bb_dim_y"] = int_bounding_box_dim[1]
        df["int_bb_dim_z"] = int_bounding_box_dim[0]
        df["int_vox_dim_x"] = roi_copy.roi_intensity.spacing[2]
        df["int_vox_dim_y"] = roi_copy.roi_intensity.spacing[1]
        df["int_vox_dim_z"] = roi_copy.roi_intensity.spacing[0]
        df["int_vox_count"] = np.sum(int_voxel_grid)
        df["int_mean_int"] = np.mean(img_voxel_grid[int_voxel_grid])
        df["int_min_int"] = np.min(img_voxel_grid[int_voxel_grid])
        df["int_max_int"] = np.max(img_voxel_grid[int_voxel_grid])

        # Set morphological mask features
        df["mrp_map_dim_x"] = roi_copy.roi_morphology.size[2]
        df["mrp_map_dim_y"] = roi_copy.roi_morphology.size[1]
        df["mrp_map_dim_z"] = roi_copy.roi_morphology.size[0]
        df["mrp_bb_dim_x"] = mrp_bounding_box_dim[2]
        df["mrp_bb_dim_y"] = mrp_bounding_box_dim[1]
        df["mrp_bb_dim_z"] = mrp_bounding_box_dim[0]
        df["mrp_vox_dim_x"] = roi_copy.roi_morphology.spacing[2]
        df["mrp_vox_dim_y"] = roi_copy.roi_morphology.spacing[1]
        df["mrp_vox_dim_z"] = roi_copy.roi_morphology.spacing[0]
        df["mrp_vox_count"] = np.sum(mrp_voxel_grid)
        df["mrp_mean_int"] = np.mean(img_voxel_grid[mrp_voxel_grid])
        df["mrp_min_int"] = np.min(img_voxel_grid[mrp_voxel_grid])
        df["mrp_max_int"] = np.max(img_voxel_grid[mrp_voxel_grid])

        # Update column names
        df.columns = ["_".join(["diag", feature, append_str]).strip("_") for feature in df.columns]

        del roi_copy

        self.diagnostic_list += [df]

    def get_bounding_box(self, roi_voxel_grid):
        # Calculates coordinates of ROI bounding box
        z_ind, y_ind, x_ind = np.where(roi_voxel_grid)
        max_ind = np.array((np.max(z_ind), np.max(y_ind), np.max(x_ind)))
        min_ind = np.array((np.min(z_ind), np.min(y_ind), np.min(x_ind)))
        del z_ind, y_ind, x_ind

        return min_ind, max_ind

    def get_center_slice(self):
        """ Identify location of the central slice in the roi """

        # Return a NaN if no roi is present
        if self.roi is None:
            return np.nan

        # Determine indices of voxels included in the roi
        z_ind, y_ind, x_ind = np.where(self.roi.get_voxel_grid())
        z_center = (np.max(z_ind) + np.min(z_ind)) // 2

        return z_center

    def get_all_slices(self):
        """ Identify location of all slices in the roi """

        # Return NaN in case the roi is missing
        if self.roi is None:
            return np.array([np.nan])

        z_ind, y_ind, x_ind = np.where(self.roi.get_voxel_grid())

        return np.unique(z_ind)

    def export(self, img_obj, file_path):
        """
        Export roi to file
        :param img_obj:
        :param file_path:
        :return:
        """

        roi_str_components = [img_obj.get_export_descriptor()]
        roi_str_components += [self.get_export_descriptor()]

        # Write morphological and intensity roi
        if self.roi_morphology is not None and self.roi_intensity is not None:
            self.roi_morphology.write(file_path=file_path, file_name="_".join(roi_str_components + ["morph.nii.gz"]))
            self.roi_intensity.write(file_path=file_path, file_name="_".join(roi_str_components + ["int.nii.gz"]))

        elif self.roi is not None:
            self.roi.write(file_path=file_path, file_name="_".join(roi_str_components + ["nii.gz"]))

        else:
            return

    def get_export_descriptor(self):
        """
        Generates an export string for identifying a file
        :return: export string
        """
        descr_list = []

        if self.adapt_size != 0.0:
            # Volume adaptation
            descr_list += ["vol",
                           str(self.adapt_size)]
        if self.svx_randomisation_id != -1:
            # Contour randomisation
            descr_list += ["svx",
                           str(self.svx_randomisation_id)]

        descr_list += [self.name]

        return "_".join(descr_list)

    def get_slices(self, slice_number=None):
        # Extract roi objects for each slice

        roi_obj_list = []

        # Create a copy of the current object
        base_roi_obj = self.copy(drop_image=True)

        # Remove attributes that need to be set
        base_roi_obj.roi = None
        base_roi_obj.roi_intensity = None
        base_roi_obj.roi_morphology = None

        if slice_number is None:
            # Extract mask for each slice.  Copy the base roi object.

            if self.roi is not None:
                roi_slices = self.roi.get_slices()

            if self.roi_intensity is not None:
                roi_int_slices = self.roi_intensity.get_slices()
            else:
                roi_int_slices = None

            if self.roi_morphology is not None:
                roi_morph_slices = self.roi_morphology.get_slices()
            else:
                roi_morph_slices = None

            # Add masks to a roi object for each slice
            for ii in np.arange(self.roi.size[0]):
                slice_roi_obj = copy.deepcopy(base_roi_obj)

                if self.roi is not None:
                    slice_roi_obj.roi = roi_slices[ii]
                if self.roi_intensity is not None:
                    slice_roi_obj.roi_intensity = roi_int_slices[ii]
                if self.roi_morphology is not None:
                    slice_roi_obj.roi_morphology = roi_morph_slices[ii]

                # Add to list
                roi_obj_list += [slice_roi_obj]
        else:
            # Extract a single slice. Copy the base roi object.
            slice_roi_obj = copy.deepcopy(base_roi_obj)

            # Add the mask for the requested slice
            if self.roi is not None:
                slice_roi_obj.roi = self.roi.get_slices(slice_number=slice_number)[0]
            if self.roi_intensity is not None:
                slice_roi_obj.roi_intensity = self.roi_intensity.get_slices(slice_number=slice_number)[0]
            if self.roi_morphology is not None:
                slice_roi_obj.roi_morphology = self.roi_morphology.get_slices(slice_number=slice_number)[0]

            # Add to list
            roi_obj_list += [slice_roi_obj]

        return roi_obj_list

    def drop_image(self):
        """Drops image, e.g. to free up memory."""
        if self.roi is not None:
            self.roi.drop_image()

        if self.roi_intensity is not None:
            self.roi_intensity.drop_image()

        if self.roi_morphology is not None:
            self.roi_morphology.drop_image()

    def drop_metadata(self):
        self.metadata = None

        if self.roi is not None:
            self.roi.drop_metadata()

        if self.roi_intensity is not None:
            self.roi_intensity.drop_metadata()

        if self.roi_morphology is not None:
            self.roi_morphology.drop_metadata()

    def write_dicom(self, file_path, file_name="RS.dcm"):
        import os

        if self.metadata is None:
            return None

        # Check if the write folder exists
        if not os.path.isdir(file_path):

            if os.path.isfile(file_path):
                # Check if the write folder is a file.
                raise IOError(f"{file_path} is an existing file, not a directory. No DICOM images were exported.")
            else:
                os.makedirs(file_path, exist_ok=True)

        self.metadata.save_as(filename=os.path.join(file_path, file_name), write_like_original=False)

    def get_metadata(self, tag, tag_type, default=None):
        # Do not attempt to read the metadata if no metadata is present.
        if self.metadata is None:
            return

        return get_pydicom_meta_tag(dcm_seq=self.metadata, tag=tag, tag_type=tag_type, default=default)

    def set_metadata(self, tag, value, force_vr=None):

        # Do not update the metadata if no metadata is present.
        if self.metadata is None:
            return None

        set_pydicom_meta_tag(dcm_seq=self.metadata, tag=tag, value=value, force_vr=force_vr)

    def has_metadata(self, tag):

        if self.metadata is None:
            return None

        else:
            return get_pydicom_meta_tag(dcm_seq=self.metadata, tag=tag, test_tag=True)

    def rename(self, new):

        if self.metadata is not None:
            # Obtain the old name
            old = self.name

            if not self.has_metadata(tag=(0x3006, 0x0020)):
                raise ValueError(f"The DICOM metaheader does not contain a Structure Set ROI sequence.")

            # Iterate over roi elements in the roi sequence
            for ii, roi_element in enumerate(self.metadata[0x3006, 0x0020]):

                # Find ROI name that matches the old name
                if get_pydicom_meta_tag(dcm_seq=roi_element, tag=(0x3006, 0x0026), tag_type="str") == old:
                    set_pydicom_meta_tag(dcm_seq=roi_element, tag=(0x3006, 0x0026), value=new)

            # Assign a new name
            self.name = new
        else:
            # Assign a new name
            self.name = new
Ejemplo n.º 6
0
def read_dicom_image_series(image_folder, modality=None, series_uid=None):

    # Obtain a list with image files
    file_list = _find_dicom_image_series(image_folder=image_folder,
                                         allowed_modalities=["CT", "PT", "MR"],
                                         modality=modality,
                                         series_uid=series_uid)

    # Obtain slice positions for each file
    file_table = pd.DataFrame({
        "file_name": file_list,
        "position_z": 0.0,
        "position_y": 0.0,
        "position_x": 0.0,
        "sop_instance_uid": ""
    })

    image_position_x = []
    image_position_y = []
    image_position_z = []
    sop_instance_uid = []

    for file_name in file_list:
        # Load the dicom header
        dcm = pydicom.dcmread(os.path.join(image_folder, file_name),
                              stop_before_pixels=True,
                              force=True)

        # Find the origin of each slice.
        slice_origin = get_pydicom_meta_tag(dcm_seq=dcm,
                                            tag=(0x0020, 0x0032),
                                            tag_type="mult_float",
                                            default=np.array([0.0, 0.0,
                                                              0.0]))[::-1]

        # Update with slice positions
        image_position_x += [slice_origin[2]]
        image_position_y += [slice_origin[1]]
        image_position_z += [slice_origin[0]]

        # Find the sop instance UID of each slice.
        slice_sop_instance_uid = get_pydicom_meta_tag(dcm_seq=dcm,
                                                      tag=(0x0008, 0x0018),
                                                      tag_type="str")

        # Update with the slice SOP instance UID.
        sop_instance_uid += [slice_sop_instance_uid]

    # Order ascending position (DICOM: z increases from feet to head)
    file_table = pd.DataFrame({
        "file_name": file_list,
        "position_z": image_position_z,
        "position_y": image_position_y,
        "position_x": image_position_x,
        "sop_instance_uid": sop_instance_uid
    }).sort_values(by=["position_z", "position_y", "position_x"])

    # Obtain DICOM metadata from the bottom slice. This will be used to fill most of the different details.
    dcm = pydicom.dcmread(os.path.join(image_folder,
                                       file_table.file_name.values[0]),
                          stop_before_pixels=True,
                          force=True)

    # Find the number of rows (y) and columns (x) in the data set.
    n_x = get_pydicom_meta_tag(dcm_seq=dcm,
                               tag=(0x0028, 0x011),
                               tag_type="int")
    n_y = get_pydicom_meta_tag(dcm_seq=dcm,
                               tag=(0x0028, 0x010),
                               tag_type="int")

    # Create an empty voxel grid. Use z, y, x ordering for consistency within MIRP.
    voxel_grid = np.zeros((len(file_table), n_y, n_x), dtype=np.float32)

    # Read all dicom slices in order.
    slice_dcm_list = [
        pydicom.dcmread(os.path.join(image_folder, file_name),
                        stop_before_pixels=False,
                        force=True)
        for file_name in file_table.file_name.values
    ]

    # Iterate over the different slices to fill out the voxel_grid.
    for ii, file_name in enumerate(file_table.file_name.values):

        # Read the dicom file and extract the slice grid
        slice_dcm = slice_dcm_list[ii]
        slice_grid = slice_dcm.pixel_array.astype(np.float32)

        # Update with scale and intercept. These may change per slice.
        rescale_intercept = get_pydicom_meta_tag(dcm_seq=slice_dcm,
                                                 tag=(0x0028, 0x1052),
                                                 tag_type="float",
                                                 default=0.0)
        rescale_slope = get_pydicom_meta_tag(dcm_seq=slice_dcm,
                                             tag=(0x0028, 0x1053),
                                             tag_type="float",
                                             default=1.0)
        slice_grid = slice_grid * rescale_slope + rescale_intercept

        # Convert all images to SUV at admin
        if get_pydicom_meta_tag(dcm_seq=dcm,
                                tag=(0x0008, 0x0060),
                                tag_type="str") == "PT":
            suv_conversion_object = SUVscalingObj(dcm=slice_dcm)
            scale_factor = suv_conversion_object.get_scale_factor(
                suv_normalisation="bw")

            # Convert to SUV
            slice_grid *= scale_factor

            # Update the DICOM header
            slice_dcm = suv_conversion_object.update_dicom_header(
                dcm=slice_dcm)

        # Store in voxel grid
        voxel_grid[ii, :, :] = slice_grid

    # Obtain the image origin from the dicom header (note: z, y, x order)
    image_origin = get_pydicom_meta_tag(dcm_seq=dcm,
                                        tag=(0x0020, 0x0032),
                                        tag_type="mult_float",
                                        default=np.array([0.0, 0.0,
                                                          0.0]))[::-1]

    # Obtain the image spacing from the dicom header and slice positions.
    image_pixel_spacing = get_pydicom_meta_tag(dcm_seq=dcm,
                                               tag=(0x0028, 0x0030),
                                               tag_type="mult_float")
    image_slice_thickness = get_pydicom_meta_tag(dcm_seq=dcm,
                                                 tag=(0x0018, 0x0050),
                                                 tag_type="float",
                                                 default=None)

    if len(file_table) > 1:
        # Compute the distance between the origins of the slices. This is the slice spacing.
        image_slice_spacing = np.median(
            np.sqrt(
                np.sum(np.array([
                    np.power(np.diff(file_table.position_z.values), 2.0),
                    np.power(np.diff(file_table.position_y.values), 2.0),
                    np.power(np.diff(file_table.position_x.values), 2.0)
                ]),
                       axis=0)))

        if image_slice_thickness is None:
            # TODO: Update slice thickness tag in dcm
            pass
        else:
            # Warn the user if there is a mismatch between slice thickness and the actual slice spacing.
            if not np.around(image_slice_thickness - image_slice_spacing,
                             decimals=3) == 0.0:
                warnings.warn(
                    f"Mismatch between slice thickness ({image_slice_thickness}) and actual slice spacing ({image_slice_spacing}). The actual slice spacing will be "
                    f"used.", UserWarning)

    elif image_slice_thickness is not None:
        # There is only one slice, and we use the slice thickness as parameter.
        image_slice_spacing = image_slice_thickness

    else:
        # There is only one slice and the slice thickness is unknown. In this situation, we use the pixel spacing
        image_slice_spacing = np.max(image_pixel_spacing)

    # Combine pixel spacing and slice spacing into the voxel spacing, using z, y, x order.
    image_spacing = np.array(
        [image_slice_spacing, image_pixel_spacing[1], image_pixel_spacing[0]])

    # Obtain image orientation (Xx, Xy, Xz, Yx, Yy, Yz; see DICOM C.7.6.2 Image Plane Module)
    image_orientation = get_pydicom_meta_tag(dcm_seq=dcm,
                                             tag=(0x0020, 0x0037),
                                             tag_type="mult_float")

    # Add (Zx, Zy, Zz)
    if len(file_table) > 1:

        # Compute distance between subsequent origins.
        slice_origin_distance = np.sqrt(
            np.power(np.diff(file_table.position_x.values), 2.0) +
            np.power(np.diff(file_table.position_y.values), 2.0) +
            np.power(np.diff(file_table.position_z.values), 2.0))

        # Find unique distance values.
        slice_origin_distance = np.unique(np.around(slice_origin_distance, 3))

        # If there is more than one value, this means that there is an unexpected shift in origins.
        if len(slice_origin_distance) > 1:
            raise ValueError(
                f"Inconsistent distance between slice origins of subsequent slices: "
                f"{slice_origin_distance}. Slices cannot be aligned correctly. This is likely due to "
                f"missing slices.")

        z_orientation = np.array([
            np.median(np.diff(file_table.position_x.values)),
            np.median(np.diff(file_table.position_y.values)),
            np.median(np.diff(file_table.position_z.values))
        ]) / image_slice_spacing

        # Append orientation.
        image_orientation += z_orientation.tolist()
    else:
        image_orientation += [0.0, 0.0, 1.0]

    # Revert to z, y, x order
    image_orientation = image_orientation[::-1]

    # Create an ImageClass object and store dicom meta-data
    img_obj = ImageClass(voxel_grid=voxel_grid,
                         origin=image_origin,
                         spacing=image_spacing,
                         orientation=image_orientation,
                         modality=get_pydicom_meta_tag(dcm_seq=dcm,
                                                       tag=(0x0008, 0x0060),
                                                       tag_type="str"),
                         spat_transform="base",
                         no_image=False,
                         metadata=slice_dcm_list[0],
                         slice_table=file_table)

    return img_obj
Ejemplo n.º 7
0
def read_dicom_image_series(image_folder, modality=None, series_uid=None):

    # Obtain a list with image files
    file_list = _find_dicom_image_series(image_folder=image_folder,
                                         allowed_modalities=["CT", "PT", "MR"],
                                         modality=modality,
                                         series_uid=series_uid)

    # Obtain slice positions for each file
    image_position_z = []
    for file_name in file_list:

        # Read DICOM header
        dcm = pydicom.dcmread(os.path.join(image_folder, file_name),
                              stop_before_pixels=True,
                              force=True,
                              specific_tags=[Tag(0x0020, 0x0032)])

        # Obtain the z position
        image_position_z += [
            get_pydicom_meta_tag(dcm_seq=dcm,
                                 tag=(0x0020, 0x0032),
                                 tag_type="mult_float")[2]
        ]

    # Order ascending position (DICOM: z increases from feet to head)
    file_table = pd.DataFrame({
        "file_name": file_list,
        "position_z": image_position_z
    }).sort_values(by="position_z")

    # Obtain DICOM metadata from the bottom slice. This will be used to fill out all the different details.
    dcm = pydicom.dcmread(os.path.join(image_folder,
                                       file_table.file_name.values[0]),
                          stop_before_pixels=True,
                          force=True)

    # Find the number of rows (y) and columns (x) in the data set.
    n_x = get_pydicom_meta_tag(dcm_seq=dcm,
                               tag=(0x0028, 0x011),
                               tag_type="int")
    n_y = get_pydicom_meta_tag(dcm_seq=dcm,
                               tag=(0x0028, 0x010),
                               tag_type="int")

    # Create an empty voxel grid. Use z, y, x ordering for consistency within MIRP.
    voxel_grid = np.zeros((len(file_table), n_y, n_x), dtype=np.float32)

    # Read all dicom slices in order.
    slice_dcm_list = [
        pydicom.dcmread(os.path.join(image_folder, file_name),
                        stop_before_pixels=False,
                        force=True)
        for file_name in file_table.file_name.values
    ]

    # Iterate over the different slices to fill out the voxel_grid.
    for ii, file_name in enumerate(file_table.file_name.values):

        # Read the dicom file and extract the slice grid
        slice_dcm = slice_dcm_list[ii]
        slice_grid = slice_dcm.pixel_array.astype(np.float32)

        # Update with scale and intercept. These may change per slice.
        rescale_intercept = get_pydicom_meta_tag(dcm_seq=slice_dcm,
                                                 tag=(0x0028, 0x1052),
                                                 tag_type="float",
                                                 default=0.0)
        rescale_slope = get_pydicom_meta_tag(dcm_seq=slice_dcm,
                                             tag=(0x0028, 0x1053),
                                             tag_type="float",
                                             default=1.0)
        slice_grid = slice_grid * rescale_slope + rescale_intercept

        # Convert all images to SUV at admin
        if get_pydicom_meta_tag(dcm_seq=dcm,
                                tag=(0x0008, 0x0060),
                                tag_type="str") == "PT":
            suv_conversion_object = SUVscalingObj(dcm=slice_dcm)
            scale_factor = suv_conversion_object.get_scale_factor(
                suv_normalisation="bw")

            # Convert to SUV
            slice_grid *= scale_factor

            # Update the DICOM header
            slice_dcm = suv_conversion_object.update_dicom_header(
                dcm=slice_dcm)

        # Store in voxel grid
        voxel_grid[ii, :, :] = slice_grid

    # Obtain the image origin from the dicom header (note: z, y, x order)
    image_origin = get_pydicom_meta_tag(dcm_seq=dcm,
                                        tag=(0x0020, 0x0032),
                                        tag_type="mult_float",
                                        default=np.array([0.0, 0.0,
                                                          0.0]))[::-1]

    # Obtain the image spacing from the dicom header and slice positions.
    image_pixel_spacing = get_pydicom_meta_tag(dcm_seq=dcm,
                                               tag=(0x0028, 0x0030),
                                               tag_type="mult_float")
    image_slice_thickness = get_pydicom_meta_tag(dcm_seq=dcm,
                                                 tag=(0x0018, 0x0050),
                                                 tag_type="float",
                                                 default=None)

    if len(file_table) > 1:
        # Slice spacing can be determined from the slice positions
        image_slice_spacing = np.median(
            np.abs(np.diff(file_table.position_z.values)))

        if image_slice_thickness is None:
            # TODO: Update slice thickness tag in dcm
            pass
        else:
            # Warn the user if there is a mismatch between slice thickness and the actual slice spacing.
            if not np.around(image_slice_thickness - image_slice_spacing,
                             decimals=5) == 0.0:
                warnings.warn(
                    f"Mismatch between slice thickness ({image_slice_thickness}) and actual slice spacing ({image_slice_spacing}). The actual slice spacing will be "
                    f"used.", UserWarning)

    elif image_slice_thickness is not None:
        # There is only one slice, and we use the slice thickness as parameter.
        image_slice_spacing = image_slice_thickness

    else:
        # There is only one slice and the slice thickness is unknown. In this situation, we use the pixel spacing
        image_slice_spacing = np.max(image_pixel_spacing)

    # Combine pixel spacing and slice spacing into the voxel spacing, using z, y, x order.
    image_spacing = np.array(
        [image_slice_spacing, image_pixel_spacing[1], image_pixel_spacing[0]])

    # Obtain image orientation and add the 3rd dimension
    image_orientation = get_pydicom_meta_tag(dcm_seq=dcm,
                                             tag=(0x0020, 0x0037),
                                             tag_type="mult_float")
    image_orientation += [0.0, 0.0, 1.0]

    # Revert to z, y, x order
    image_orientation = image_orientation[::-1]

    # Create an ImageClass object and store dicom meta-data
    img_obj = ImageClass(voxel_grid=voxel_grid,
                         origin=image_origin,
                         spacing=image_spacing,
                         slice_z_pos=file_table.position_z.values,
                         orientation=image_orientation,
                         modality=get_pydicom_meta_tag(dcm_seq=dcm,
                                                       tag=(0x0008, 0x0060),
                                                       tag_type="str"),
                         spat_transform="base",
                         no_image=False,
                         metadata=slice_dcm_list[0],
                         metadata_sop_instances=[
                             get_pydicom_meta_tag(dcm_seq=slice_dcm,
                                                  tag=(0x0008, 0x0018),
                                                  tag_type="str")
                             for slice_dcm in slice_dcm_list
                         ])

    return img_obj