def test_store_image_as_short_nifti(test_output_dirs: TestOutputDirectories, norm_method: PhotometricNormalizationMethod, image_range: Any, window_level: Any) -> None: window, level = window_level if window_level else (400, 0) image = np.random.random_sample((1, 2, 3)) image_shape = image.shape args = SegmentationModelBase(norm_method=norm_method, window=window, level=level, should_validate=False) # Get integer values that are in the image range image1 = LinearTransform.transform(data=image, input_range=(0, 1), output_range=args.output_range) image = image1.astype(np.short) # type: ignore header = ImageHeader(origin=(1, 1, 1), direction=(1, 0, 0, 0, 1, 0, 0, 0, 1), spacing=(1, 1, 1)) nifti_name = test_output_dirs.create_file_or_folder_path(default_image_name) io_util.store_image_as_short_nifti(image, header, nifti_name, args) if norm_method == PhotometricNormalizationMethod.CtWindow: output_range = get_range_for_window_level(args.level, args.window) image = LinearTransform.transform(data=image, input_range=args.output_range, output_range=output_range) image = image.astype(np.short) else: image = image * 1000 t = np.unique(image) assert_nifti_content(nifti_name, image_shape, header, list(t), np.short)
def to_unique_bytes(a: np.ndarray, input_range: Tuple[float, float]) -> Any: """Returns an array of unique ubytes after applying LinearTransform on the input array.""" ubyte_range = (np.iinfo(np.ubyte).min, np.iinfo(np.ubyte).max) a = LinearTransform.transform(data=a, input_range=input_range, output_range=ubyte_range) return np.unique(a.astype(np.ubyte))
def _load_and_scale_image(name: str) -> ImageWithHeader: image_with_header = load_nifti_image(full_ml_test_data_path(name)) return ImageWithHeader(image=LinearTransform.transform( data=image_with_header.image, input_range=(0, 255), output_range=(0, 1)), header=image_with_header.header)
def store_as_nifti( image: np.ndarray, header: ImageHeader, file_name: PathOrString, image_type: Union[str, type, np.dtype], scale: bool = False, input_range: Optional[Iterable[Union[int, float]]] = None, output_range: Optional[Iterable[Union[int, float]]] = None) -> Path: """ Saves an image in nifti format (uploading to Azure also if an online Run), and performs the following operations: 1) transpose the image back into X,Y,Z from Z,Y,X 2) if scale is true, then performs a linear scaling to either the input/output range or byte range (0,255) as default. 3) cast the image values to the given type before saving :param image: 3D image in shape: Z x Y x X. :param header: The image header :param file_name: The name of the file for this image. :param scale: Should perform linear scaling of the image, to desired output range or byte range by default. :param input_range: The input range the image belongs to. :param output_range: The output range to scale the image to. :param image_type: The type to save the image in. :return: the path to the saved image """ if image.ndim != 3: raise Exception("Image must have 3 dimensions, found: {}".format( len(image.shape))) if image_type is None: raise Exception("You must specify a valid image type.") if scale and ((input_range is not None and output_range is None) or (input_range is None and output_range is not None)): raise Exception( "You must provide both input and output ranges to apply custom linear scaling." ) # rescale image for visualization in the app if scale: if input_range is not None and output_range is not None: # noinspection PyTypeChecker image = LinearTransform.transform( data=image, input_range=input_range, # type: ignore output_range=tuple(output_range) # type: ignore ) else: image = (image + 1) * 255 image = sitk.GetImageFromArray(image.astype(image_type)) image.SetSpacing(sitk.VectorDouble(reverse_tuple_float3( header.spacing))) # Spacing needs to be X Y Z image.SetOrigin(header.origin) image.SetDirection(header.direction) sitk.WriteImage(image, str(file_name)) return Path(file_name)
def test_store_as_scaled_ubyte_nifti(test_output_dirs: TestOutputDirectories, input_range: Any) -> None: image = np.random.random_sample((dim_z, dim_y, dim_x)) header = ImageHeader(origin=(1, 1, 1), direction=(1, 0, 0, 0, 1, 0, 0, 0, 1), spacing=(1, 2, 4)) io_util.store_as_scaled_ubyte_nifti(image, header, test_output_dirs.create_file_or_folder_path(default_image_name), input_range) image = LinearTransform.transform(data=image, input_range=input_range, output_range=(0, 255)) t = np.unique(image.astype(np.ubyte)) assert_nifti_content(test_output_dirs.create_file_or_folder_path(default_image_name), image.shape, header, list(t), np.ubyte)
def test_scale_and_unscale_image( test_output_dirs: TestOutputDirectories) -> None: """ Test if an image in the CT value range can be recovered when we save dataset examples (undoing the effects of CT Windowing) """ image_size = (5, 5, 5) spacing = (1, 2, 3) header = ImageHeader(origin=(0, 1, 0), direction=(-1, 0, 0, 0, -1, 0, 0, 0, -1), spacing=spacing) np.random.seed(0) # Random image values with mean -100, std 100. This will cover a range # from -400 to +200 HU image = np.random.normal(-100, 100, size=image_size) window = 200 level = -100 # Lower and upper bounds of the interval of raw CT values that will be retained. lower = level - window / 2 upper = level + window / 2 # Create a copy of the image with all values outside of the (Window, Level) range set to the boundaries. # When saving and loading back in, we will not be able to recover any values that fell outside those boundaries. image_restricted = image.copy() image_restricted[image < lower] = lower image_restricted[image > upper] = upper # The image will be saved with voxel type short image_restricted = image_restricted.astype(int) # Apply window and level, mapping to the usual CNN input value range cnn_input_range = (-1, +1) image_windowed = LinearTransform.transform(data=image, input_range=(lower, upper), output_range=cnn_input_range) args = SegmentationModelBase( norm_method=PhotometricNormalizationMethod.CtWindow, output_range=cnn_input_range, window=window, level=level, should_validate=False) file_name = test_output_dirs.create_file_or_folder_path( "scale_and_unscale_image.nii.gz") io_util.store_image_as_short_nifti(image_windowed, header, file_name, args) image_from_disk = io_util.load_nifti_image(file_name) # noinspection PyTypeChecker assert_nifti_content(file_name, image_size, header, np.unique(image_restricted).tolist(), np.short) assert np.array_equal(image_from_disk.image, image_restricted)
def test_store_as_nifti(test_output_dirs: TestOutputDirectories, image_type: Any, scale: Any, input_range: Any, output_range: Any) \ -> None: image = np.random.random_sample((dim_z, dim_y, dim_x)) spacingzyx = (1, 2, 3) path_image = test_output_dirs.create_file_or_folder_path(default_image_name) header = ImageHeader(origin=(1, 1, 1), direction=(1, 0, 0, 0, 1, 0, 0, 0, 1), spacing=spacingzyx) io_util.store_as_nifti(image, header, path_image, image_type, scale, input_range, output_range) if scale: linear_transform = LinearTransform.transform(data=image, input_range=input_range, output_range=output_range) image = linear_transform.astype(image_type) # type: ignore assert_nifti_content(test_output_dirs.create_file_or_folder_path(default_image_name), image.shape, header, list(np.unique(image.astype(image_type))), image_type) loaded_image = io_util.load_nifti_image(path_image, image_type) assert loaded_image.header.spacing == spacingzyx
def mri_window(image_in: np.ndarray, mask: Optional[np.ndarray], output_range: Tuple[float, float] = (-1.0, 1.0), sharpen: float = 1.9, tail: Union[List[float], float] = 1.0, debug_mode: bool = False) -> Tuple[np.array, str]: """ This function takes an MRI Image, removes to first peak of values (air). Then a window range is found centered around the mean of the remaining values and with a range controlled by the standard deviation and the sharpen input parameter. The larger sharpen is, the wider the range. The resulting values are the normalised to the given output_range, with values below and above the range being set the the boundary values. :param image_in: The image to normalize. :param mask: Consider only pixel values of the input image for which the mask is non-zero. If None the whole image is considered. :param output_range: The desired value range of the result image. :param sharpen: number of standard deviation either side of mean to include in the window :param tail: Default 1, allow window range to include more of tail of distribution. :param debug_mode: If true, create diagnostic plots. :return: normalized image """ nchannel = image_in.shape[0] imout = np.zeros_like(image_in) if isinstance(tail, int): tail = float(tail) if isinstance(tail, float): tail = [tail] * nchannel status = "" for ichannel in range(nchannel): if ichannel > 0: status += "Channel {}: ".format(ichannel) # Flatten to apply Otsu_thresholding imflat = image_in[ichannel, ...].flatten() if mask is None: maflat = None in_mask = False else: maflat = mask.flatten() in_mask = mask > 0 # Find Otsu's threshold for the values of the input image threshold = threshold_otsu(imflat) # Find window level level, std_i, _, max_foreground = robust_mean_std( imflat[imflat > threshold]) # If lower value of window is below threshold replace lower value with threshold input_range = (max(level - std_i * sharpen, threshold), min(max_foreground, level + tail[ichannel] * std_i * sharpen)) im_thresh = image_in[ichannel, ...] im_thresh[image_in[ichannel, ...] < threshold] = 0 # Use Polynomial transform function to convert data to output range imout[ichannel, ...] = LinearTransform.transform(im_thresh, input_range, output_range) status += f"Otsu {threshold:0.0f}, level {level:0.0f}, range ({input_range[0]:0.0f}, {input_range[1]:0.0f}) " logging.debug(status) if debug_mode: print('Otsu {}, range {}'.format(threshold, input_range)) if mask is None: no_thresh = np.sum(imflat < threshold) no_high = np.sum(imout == output_range[1]) pc_thresh = no_thresh / np.numel(imflat) * 100 pc_high = no_high / np.numel(imflat) * 100 else: no_thresh = np.sum(imflat[maflat == 1] < threshold) no_high = np.sum(imout == output_range[1]) pc_thresh = no_thresh / np.sum(in_mask) * 100 pc_high = no_high / np.sum(in_mask) * 100 print('Percent of values outside window range: low,high', pc_thresh, pc_high, no_high) with open("channels_trim.txt", 'a') as fileout: fileout.write( "Thresholded: {ich:d}, {pl:4.2f}, {ph:4.2f} \n".format( ich=ichannel, pl=pc_thresh, ph=pc_high)) # Plot input distribution fig, axs = plt.subplots(2, 2, figsize=(9, 9)) axs[0, 0].set_title("Original Image") axs[0, 0].imshow(image_in[ichannel, :, :, 70], cmap='gray') # axs[1,0].hist(image.flatten(), 100) axs[1, 0].set_title("Original Image - Histogram with Mask") if mask is None: axs[1, 0].hist(image_in[ichannel, ...].flatten(), 200) else: axs[1, 0].hist(image_in[ichannel, ...][in_mask].flatten(), 200) axs[0, 1].set_title( "Normalised Image, Level= {level:4.1f},\n " "Window range {in1:4.1f} to {in2:4.1f}, \n" "{pt:4.1f} % below threshold, {ph:4.1f} % above window \n" "Threshold= {th:4.1f}".format(level=level, in1=input_range[0], in2=input_range[1], pt=pc_thresh, ph=pc_high, th=threshold)) axs[0, 1].imshow(imout[ichannel, :, :, 70], cmap='gray') axs[1, 1].set_title("Normalised Image - Histogram with Mask") if mask is None: axs[1, 1].hist(imout[ichannel, ...].flatten(), 200) else: axs[1, 1].hist(imout[ichannel, ...][in_mask].flatten(), 200) plt.show() return imout, status
def normalize_trim(image: np.ndarray, mask: np.ndarray, output_range: Tuple[float, float] = (-1.0, 1.0), sharpen: float = 1.9, trim_percentiles: Tuple[float, float] = (2.0, 98.0), debug_mode: bool = False) -> np.array: """ Normalizes a single image to have mean 0 and standard deviation 1 Normalising occurs after percentile thresholds have been applied to strip out extreme values :param image: The image to normalize, size Channels x Z x Y x X :param mask: Consider only pixel values of the input image for which the mask is non-zero. Size Z x Y x X :param output_range: The desired value range of the result image. :param sharpen: number of standard deviation either side of mean to include in the window. :param trim_percentiles: Only consider voxel values between those two percentiles when computing mean and std. :param debug_mode: If true, create a diagnostic plot (interactive) :return: trimmed-normalized image """ image_shape = image.shape imout = np.zeros_like(image) in_mask = mask > 0.5 status = "" for ichannel in range(image_shape[0]): if ichannel > 0: status += "Channel {}: ".format(ichannel) channel_image = image[ichannel, ...] pixels_inside_mask = channel_image[in_mask].flatten().astype(float) # First remove all values that fall outside the trim_percentiles thresholds = np.percentile(pixels_inside_mask, trim_percentiles, interpolation='midpoint') lower_threshold = thresholds[0] upper_threshold = thresholds[1] above_lower = pixels_inside_mask > lower_threshold below_upper = pixels_inside_mask < upper_threshold inside_thresholds = np.logical_and(above_lower, below_upper) # Compute robust statistics off the pixel values that are inside the trim values median, estimated_std, min_value, max_value = robust_mean_std( pixels_inside_mask[inside_thresholds]) # Compute an input value range from median and robust std, going as many standard deviations # as specified by the sharpen parameter input_range = (max(median - estimated_std * sharpen, min_value), min(median + estimated_std * sharpen, max_value)) # Use Polynomial transform function to convert data to output range. This also sets values outside # the input_range to the boundary values. channel_output = LinearTransform.transform(data=channel_image, input_range=input_range, output_range=output_range) channel_output[np.logical_not(in_mask)] = output_range[0] imout[ichannel, ...] = channel_output status += "Range ({0:0.0f}, {1:0.0f}) ".format(input_range[0], input_range[1]) logging.info(status) if debug_mode: print('median, estimated_std', median, estimated_std) # # Normalise values to zero mean and unit variance # fig, axs = plt.subplots(2, 2, figsize=(9, 9)) axs[0, 0].set_title("Original Image") axs[0, 0].imshow(image[0, :, :, 70], cmap='gray') # axs[1,0].hist(image.flatten(), 100) axs[1, 0].set_title("Original Image - Histogram with Mask") axs[1, 0].set_xlim(lower_threshold, upper_threshold) axs[1, 0].hist(channel_image[in_mask].flatten(), 20) axs[0, 1].set_title("Normalised Image, Level= {level:4.1f},\n " "Window range {in1} to {in2}".format( level=median, in1=lower_threshold, in2=upper_threshold)) axs[0, 1].imshow(imout[0, :, :, 70], cmap='gray') axs[1, 1].set_title("Normalised Image - Histogram with Mask") axs[1, 1].hist(channel_image[in_mask], 20) plt.show() return imout, status