Source code for tomophantom.artefacts

#!/usr/bin/env python3
"""
Copyright 2023
The University of Manchester & Diamond Light Source
Licensed under the Apache License, Version 2.0.
"""
import numpy as np
import random
from typing import Union, Any


[docs]def artefacts_mix(data: np.ndarray, **artefacts_dict: Any) -> Union[np.ndarray, list]: """A module to generate and apply a mix of various typical imaging artefacts to add to the simulated data. One can build various dictionaries with keywords arguments specified bellow and pass it to the function as: `artefacts_mix(data, **noise_dict, **zingers, **etc)`. DISCLAIMER: Note that most of the features are experimental and do not always reflect the accurate modelling of the inaccuracies. Args: data (np.ndarray): 2D or 3D numpy array (sinogram or 3D projection data). The input data must be of the following shape: 2D sinogram (anglesDim, DetectorsHoriz), 3D projection data (DetectorsVert, anglesDim, DetectorsHoriz). **artefacts_dict (dict): A dictionary with keyword arguments related to different artefact type. Keyword Args: noise_type (str): Define noise type as 'Poisson' or 'Gaussian'. noise_amplitude (int, float): Photon flux for Poisson or variance for Gaussian noise. noise_seed (int): Seeds for noise generator. 'None' defines random generation. noise_prelog (bool): Set to True if prelog (raw) data required. zingers_percentage (float): The amount of zingers (dead pixels, outliers) to be added to the data. zingers_modulus (int): Modulus to control the amount of 4/6 pixel clusters to be added. stripes_percentage (float): The amount of stripes in the data (rings in reconstruction). stripes_maxthickness (int): Maxthickness defines the maximal thickness of a stripe. stripes_intensity (float): Controls the intensity levels of stripes. stripe_type (str): Stripe types can be 'partial' or 'full'. stripes_variability (float): Variability multiplier to incorporate the change of intensity in stripe. datashifts_maxamplitude_pixel (int): Controls pixel-wise (integer) misalignment in data. datashifts_maxamplitude_subpixel (float): Controls subpixel misalignment in data. pve_strength (int): The strength of partial volume effect, responsible for the limited resolution of a scanner. fresnel_dist_observation (int): The distance for obervation for fresnel propagator. fresnel_scale_factor (float): Fresnel propagator sacaling. fresnel_wavelenght (float): Fresnel propagator wavelength. verbose (bool): Make the output of modules verbose. Returns: np.ndarray: 2D/3D numpy array with artefacts applied to input data. [np.ndarray, shifts]: a list of 2D/3D numpy array and simulated shifts. """ ####### VERBOSE ######## if "verbose" not in artefacts_dict: verbose = True else: verbose = artefacts_dict["verbose"] ####### NOISE DICTIONARY######## _noise_: dict = {} if "noise_type" not in artefacts_dict: _noise_["noise_type"] = None else: _noise_["noise_type"] = artefacts_dict["noise_type"] if "noise_amplitude" not in artefacts_dict: _noise_["noise_amplitude"] = 1e5 else: _noise_["noise_amplitude"] = artefacts_dict["noise_amplitude"] if "noise_seed" not in artefacts_dict: _noise_["noise_seed"] = None else: _noise_["noise_seed"] = artefacts_dict["noise_seed"] if "noise_prelog" not in artefacts_dict: _noise_["noise_prelog"] = None else: _noise_["noise_prelog"] = artefacts_dict["noise_prelog"] ####### ZINGERS ######## _zingers_: dict = {} if "zingers_percentage" not in artefacts_dict: _zingers_["zingers_percentage"] = None else: _zingers_["zingers_percentage"] = artefacts_dict["zingers_percentage"] if "zingers_modulus" not in artefacts_dict: _zingers_["zingers_modulus"] = 10 else: _zingers_["zingers_modulus"] = artefacts_dict["zingers_modulus"] ####### STRIPES ######## _stripes_: dict = {} if "stripes_percentage" not in artefacts_dict: _stripes_["stripes_percentage"] = None else: _stripes_["stripes_percentage"] = artefacts_dict["stripes_percentage"] if "stripes_maxthickness" not in artefacts_dict: _stripes_["stripes_maxthickness"] = 1.0 else: _stripes_["stripes_maxthickness"] = artefacts_dict["stripes_maxthickness"] if "stripes_intensity" not in artefacts_dict: _stripes_["stripes_intensity"] = 0.1 else: _stripes_["stripes_intensity"] = artefacts_dict["stripes_intensity"] if "stripes_type" not in artefacts_dict: _stripes_["stripes_type"] = "full" else: _stripes_["stripes_type"] = artefacts_dict["stripes_type"] if "stripes_variability" not in artefacts_dict: _stripes_["stripes_variability"] = 0.0 else: _stripes_["stripes_variability"] = artefacts_dict["stripes_variability"] ####### DATASHIFTS ######## _datashifts_: dict = {} if "datashifts_maxamplitude_pixel" not in artefacts_dict: _datashifts_["datashifts_maxamplitude_pixel"] = None else: _datashifts_["datashifts_maxamplitude_pixel"] = artefacts_dict[ "datashifts_maxamplitude_pixel" ] if "datashifts_maxamplitude_subpixel" not in artefacts_dict: _datashifts_["datashifts_maxamplitude_subpixel"] = None else: _datashifts_["datashifts_maxamplitude_subpixel"] = artefacts_dict[ "datashifts_maxamplitude_subpixel" ] ####### PVE ######## _pve_: dict = {} if "pve_strength" not in artefacts_dict: _pve_["pve_strength"] = None else: _pve_["pve_strength"] = artefacts_dict["pve_strength"] _fresnel_propagator_: dict = {} if "fresnel_dist_observation" not in artefacts_dict: _fresnel_propagator_["fresnel_dist_observation"] = None else: _fresnel_propagator_["fresnel_dist_observation"] = artefacts_dict[ "fresnel_dist_observation" ] if "fresnel_scale_factor" not in artefacts_dict: _fresnel_propagator_["fresnel_scale_factor"] = 10 else: _fresnel_propagator_["fresnel_scale_factor"] = artefacts_dict[ "fresnel_scale_factor" ] if "fresnel_wavelenght" not in artefacts_dict: _fresnel_propagator_["fresnel_wavelenght"] = 0.0001 else: _fresnel_propagator_["fresnel_wavelenght"] = artefacts_dict[ "fresnel_wavelenght" ] ########################################################################### ################Applying artefacts and noise to the data################### ########################################################################### # PARTIAL VOLUME EFFECT if _pve_["pve_strength"] is not None: sino_artifacts = np.float32(pve(data=data, pve_strength=_pve_["pve_strength"])) if verbose is True: print("Partial volume effect (PVE) has been simulated.") else: sino_artifacts = np.float32(data) # FRESNEL PROPAGATOR if _fresnel_propagator_["fresnel_dist_observation"] is not None: sino_artifacts = np.float32( fresnel_propagator( data=sino_artifacts, dist_observation=_fresnel_propagator_["fresnel_dist_observation"], scale_factor=_fresnel_propagator_["fresnel_scale_factor"], wavelenght=_fresnel_propagator_["fresnel_wavelenght"], ) ) if verbose is True: print("Fresnel propagator has been simulated.") # ZINGERS if _zingers_["zingers_percentage"] is not None: sino_artifacts = np.float32( zingers( data=sino_artifacts, percentage=_zingers_["zingers_percentage"], modulus=_zingers_["zingers_modulus"], ) ) if verbose is True: print("Zingers have been added to the data.") # STRIPES if _stripes_["stripes_percentage"] is not None: sino_artifacts = np.float32( stripes( data=sino_artifacts, percentage=_stripes_["stripes_percentage"], maxthickness=_stripes_["stripes_maxthickness"], intensity_thresh=_stripes_["stripes_intensity"], stripe_type=_stripes_["stripes_type"], variability=_stripes_["stripes_variability"], ) ) if verbose is True: print("Stripes leading to ring artefacts have been simulated.") # DATASHIFTS if _datashifts_["datashifts_maxamplitude_pixel"] is not None: [sino_artifacts, shifts] = datashifts( data=sino_artifacts, maxamplitude=_datashifts_["datashifts_maxamplitude_pixel"], ) if verbose is True: print("Data shifts have been simulated.") if _datashifts_["datashifts_maxamplitude_subpixel"] is not None: [sino_artifacts, shifts] = datashifts_subpixel( data=sino_artifacts, maxamplitude=_datashifts_["datashifts_maxamplitude_subpixel"], ) if verbose is True: print("Data shifts (in subpixel precision) have been simulated.") # NOISE if _noise_["noise_type"] is not None: sino_artifacts = noise( data=sino_artifacts, sigma=_noise_["noise_amplitude"], noisetype=_noise_["noise_type"], seed=_noise_["noise_seed"], prelog=_noise_["noise_prelog"], ) if verbose is True: print("{} noise has been added to the data.".format(_noise_["noise_type"])) if (_datashifts_["datashifts_maxamplitude_pixel"]) or ( _datashifts_["datashifts_maxamplitude_subpixel"] ) is not None: return [np.float32(sino_artifacts), shifts] else: return np.float32(sino_artifacts)
[docs]def stripes( data: np.ndarray, percentage: float, maxthickness: int, intensity_thresh: float, stripe_type: str, variability: float, ) -> np.ndarray: """Function to add stripes (constant offsets) to sinograms or 3D projection data which results in rings in the reconstructed image. Args: data (np.ndarray): 2D sinogram (anglesDim, DetectorsHoriz) or 3D projection data (DetectorsVert, anglesDim, DetectorsHoriz). percentage (float): Percentage defines the amount of stripes in the data. maxthickness (int): Defines the maximal thickness of a stripe. intensity_thresh (float): Controls the intensity levels of stripes. stripe_type (str): Choose between 'partial' or 'full'. variability (float): Variability multiplier to incorporate change of intensity in the stripe. Raises: ValueError: Percentage is out of range. ValueError: Thickness is out of range. Returns: np.ndarray: 2D sinogram or 3D projection data with stripes """ if data.ndim == 2: (anglesDim, DetectorsDimH) = np.shape(data) else: (DetectorsDimV, anglesDim, DetectorsDimH) = np.shape(data) if 0 < percentage <= 100: pass else: raise ValueError("percentage must be larger than zero but smaller than 100") if 0 <= maxthickness <= 10: pass else: raise ValueError("maximum thickness must be in [0,10] range") if stripe_type != "partial": stripe_type = "full" sino_stripes = data.copy() max_intensity = np.max(sino_stripes) range_detect = int((np.float32(DetectorsDimH)) * (np.float32(percentage) / 100.0)) if data.ndim == 2: for x in range(range_detect): for mm in range(0, 20): randind = random.randint(0, DetectorsDimH - 1) # generate random index if sino_stripes[0, randind] != 0.0: break if stripe_type == "partial": randind_ang1 = random.randint(0, anglesDim) randind_ang2 = random.randint(0, anglesDim) else: randind_ang1 = 0 randind_ang2 = anglesDim randthickness = random.randint(0, maxthickness) # generate random thickness randintens = random.uniform(-1.0, 0.5) # generate random multiplier intensity = max_intensity * randintens * intensity_thresh if (randind > 0 + randthickness) & ( randind < DetectorsDimH - randthickness ): for x1 in range(-randthickness, randthickness + 1): if variability != 0.0: intensity_off = variability * max_intensity else: intensity_off = 0.0 for ll in range(randind_ang1, randind_ang2): sino_stripes[ll, randind + x1] += intensity + intensity_off intensity_off += ( [-1, 1][random.randrange(2)] * variability * max_intensity ) # sino_stripes[randind_ang1:randind_ang2,randind+x1] += intensity else: for j in range(DetectorsDimV): for x in range(range_detect): for mm in range(0, 20): randind = random.randint( 0, DetectorsDimH - 1 ) # generate random index if sino_stripes[j, 0, randind] != 0.0: break if stripe_type == "partial": randind_ang1 = random.randint(0, anglesDim) randind_ang2 = random.randint(0, anglesDim) else: randind_ang1 = 0 randind_ang2 = anglesDim randthickness = random.randint( 0, maxthickness ) # generate random thickness randintens = random.uniform(-1, 0.5) # generate random multiplier intensity = max_intensity * randintens * intensity_thresh if (randind > 0 + randthickness) & ( randind < DetectorsDimH - randthickness ): for x1 in range(-randthickness, randthickness + 1): if variability != 0.0: intensity_off = variability * max_intensity else: intensity_off = 0.0 for ll in range(randind_ang1, randind_ang2): sino_stripes[j, ll, randind + x1] += ( intensity + intensity_off ) intensity_off += ( [-1, 1][random.randrange(2)] * variability * max_intensity ) return sino_stripes
[docs]def zingers(data: np.ndarray, percentage: float, modulus: int) -> np.ndarray: """Adding zingers (zero single pixels or small 4 pixels clusters) to sinograms or 6 voxels to 3D projection data. Args: data (np.ndarray): 2D sinogram or 3D projection data. The input data must be of the following shape: 2D sinogram (anglesDim, DetectorsHoriz), 3D projection data (DetectorsVert, anglesDim, DetectorsHoriz). percentage (float): The amount of zingers to be added to the data. modulus (int): Modulus to control the amount of 4/6 pixel clusters to be added. Raises: ValueError: Percentage is out of range. ValueError: Modulus must be positive. Returns: np.ndarray: 2D or 3D array with zingers. """ if data.ndim == 2: (anglesDim, DetectorsDimH) = np.shape(data) else: (DetectorsDimV, anglesDim, DetectorsDimH) = np.shape(data) if 0.0 < percentage <= 100.0: pass else: raise ValueError("percentage must be larger than zero but smaller than 100") if modulus > 0: pass else: raise ValueError("Modulus integer must be positive") sino_zingers = data.copy() length_sino = np.size(sino_zingers) num_values = int((length_sino) * (np.float32(percentage) / 100.0)) sino_zingers_fl = sino_zingers.flatten() for x in range(num_values): randind = random.randint(0, length_sino - 1) # generate random index sino_zingers_fl[randind] = 0 if (x % int(modulus)) == 0: if data.ndim == 2: if (randind > DetectorsDimH) & (randind < length_sino - DetectorsDimH): sino_zingers_fl[randind + 1] = 0 sino_zingers_fl[randind - 1] = 0 sino_zingers_fl[randind + DetectorsDimH] = 0 sino_zingers_fl[randind - DetectorsDimH] = 0 else: if (randind > DetectorsDimH * DetectorsDimV) & ( randind < length_sino - DetectorsDimH * DetectorsDimV ): sino_zingers_fl[randind + 1] = 0 sino_zingers_fl[randind - 1] = 0 sino_zingers_fl[randind + DetectorsDimH] = 0 sino_zingers_fl[randind - DetectorsDimH] = 0 sino_zingers_fl[randind + DetectorsDimH * DetectorsDimV] = 0 sino_zingers_fl[randind - DetectorsDimH * DetectorsDimV] = 0 sino_zingers[:] = sino_zingers_fl.reshape(sino_zingers.shape) return sino_zingers
[docs]def noise( data: np.ndarray, sigma: Union[int, float], noisetype: str, seed: bool = True, prelog: bool = False, ) -> Union[list, np.ndarray]: """Adding random noise to data (adapted from LD-CT simulator) Args: data (np.ndarray): N-d array sigma (Union[int, float]): int for Poisson or float for Gaussian noisetype (str): 'Gaussian' or 'Poisson' seed (bool, optional): Initiate random seed with True. Defaults to True. prelog (bool, optional): get raw data if true, returns a list [noisy, raw]!. Defaults to False. Returns: np.ndarray: N-d array """ data_noisy = data.copy() maxData = np.max(data) data_noisy = data / maxData # normalising if seed is True: np.random.seed(int(seed)) if noisetype == "Gaussian": # add normal Gaussian noise data_noisy += np.random.normal(loc=0.0, scale=sigma, size=np.shape(data_noisy)) data_noisy[data_noisy < 0] = 0 data_noisy *= maxData elif noisetype == "Poisson": # add Poisson noise if maxData > 0: ri = 1.0 sig = np.sqrt( 11 ) # standard variance of electronic noise, a characteristic of a CT scanner yb = ( sigma * np.exp(-data_noisy) + ri ) # exponential transform to incident flux data_raw = np.random.poisson(yb) + np.sqrt(sig) * np.reshape( np.random.randn(np.size(yb)), np.shape(yb) ) li_hat = -np.log((data_raw - ri) / sigma) * maxData # log corrected data li_hat[(data_raw - ri) <= 0] = 0.0 data_noisy = li_hat.copy() else: print("Select 'Gaussian' or 'Poisson' for the noise type") if prelog is True: return [data_noisy, data_raw] else: return data_noisy
[docs]def datashifts(data: np.ndarray, maxamplitude: int) -> list: """Function to add random pixel shifts to 3D or 3D data as an offset for each angular position. Args: data (np.ndarray): 2D sinogram or 3D projection data. The input data must be of the following shape: 2D sinogram (anglesDim, DetectorsHoriz), 3D projection data (DetectorsVert, anglesDim, DetectorsHoriz). maxamplitude (int): The misilighnment ammplitude. Defines the maximal amplitude of each shift in pixels. Returns: list: 2D or 3d data with misalignment and shifts vectors [data, shifts]. """ if data.ndim == 2: (anglesDim, DetectorsDimH) = np.shape(data) shifts = np.zeros(anglesDim, dtype="int8") # the vector of shifts else: (DetectorsDimV, anglesDim, DetectorsDimH) = np.shape(data) shifts = np.zeros([anglesDim, 2], dtype="int8") # the 2D vector of shifts sino_shifts = np.zeros(np.shape(data), dtype="float32") non = lambda s: s if s < 0 else None mom = lambda s: max(0, s) for x in range(anglesDim): rand_shift = random.randint( -maxamplitude, maxamplitude ) # generate random shift (int) if data.ndim == 2: shifts[x] = rand_shift projection = data[x, :] # extract 1D projection projection_shift = np.zeros(np.shape(projection), dtype="float32") projection_shift[mom(rand_shift) : non(rand_shift)] = projection[ mom(-rand_shift) : non(-rand_shift) ] sino_shifts[x, :] = projection_shift else: rand_shift2 = random.randint( -maxamplitude, maxamplitude ) # generate random shift (int) shifts[x, 0] = rand_shift2 shifts[x, 1] = rand_shift projection2D = data[:, x, :] # extract 2D projection projection2D_shift = np.zeros(np.shape(projection2D), dtype="float32") projection2D_shift[ mom(rand_shift) : non(rand_shift), mom(rand_shift2) : non(rand_shift2) ] = projection2D[ mom(-rand_shift) : non(-rand_shift), mom(-rand_shift2) : non(-rand_shift2), ] sino_shifts[:, x, :] = projection2D_shift return [sino_shifts, shifts]
[docs]def datashifts_subpixel(data: np.ndarray, maxamplitude: float) -> list: """Function to add random sub-pixel shifts to 3D or 3D data as an offset for each angular position. Args: data (np.ndarray): 2D sinogram or 3D projection data. The input data must be of the following shape: 2D sinogram (anglesDim, DetectorsHoriz), 3D projection data (DetectorsVert, anglesDim, DetectorsHoriz). maxamplitude (float): The misilighnment ammplitude. Defines the maximal amplitude of each shift. Returns: list: 2D or 3d data with misalignment and shifts vectors [data, shifts]. """ from skimage import transform as tf if data.ndim == 2: shifts = np.zeros([1, 2], dtype="float32") random_shift_x = random.uniform( -maxamplitude, maxamplitude ) # generate a random floating point number random_shift_y = random.uniform( -maxamplitude, maxamplitude ) # generate a random floating point number tform = tf.SimilarityTransform(translation=(-random_shift_x, -random_shift_y)) sino_shifts = tf.warp(data, tform, order=5) else: (DetectorsDimV, anglesDim, DetectorsDimH) = np.shape(data) shifts = np.zeros([anglesDim, 2], dtype="float32") # the 2D vector of shifts sino_shifts = np.zeros(np.shape(data), dtype="float32") for x in range(anglesDim): random_shift_x = random.uniform( -maxamplitude, maxamplitude ) # generate a random floating point number random_shift_y = random.uniform( -maxamplitude, maxamplitude ) # generate a random floating point number projection2D = data[:, x, :] # extract 2D projection tform = tf.SimilarityTransform( translation=(-random_shift_x, -random_shift_y) ) projection_shifted = tf.warp(projection2D, tform, order=5) shifts[x, 0] = random_shift_x shifts[x, 1] = random_shift_y sino_shifts[:, x, :] = projection_shifted return [sino_shifts, shifts]
[docs]def pve(data: np.ndarray, pve_strength: int) -> np.ndarray: """Applying Partial Volume effect (smoothing) to data. Args: data (np.ndarray): 2D sinogram or 3D projection data. The input data must be of the following shape: 2D sinogram (anglesDim, DetectorsHoriz), 3D projection data (DetectorsVert, anglesDim, DetectorsHoriz). pve_strength (int): The level of smoothing, defined by kernel size. Raises: ValueError: Smoothing kernel must be positive Returns: np.ndarray: Smoothed 2D or 3D data. """ from scipy.ndimage import gaussian_filter data_pve = data.copy() if pve_strength > 0: pass else: raise ValueError("Smoothing kernel must be positive") if data.ndim == 2: (anglesDim, DetectorsDimH) = np.shape(data) for x in range(anglesDim): data_pve[x, :] = gaussian_filter(data_pve[x, :], pve_strength) else: (DetectorsDimV, anglesDim, DetectorsDimH) = np.shape(data) for x in range(anglesDim): data_pve[:, x, :] = gaussian_filter(data_pve[:, x, :], pve_strength) return data_pve
[docs]def fresnel_propagator( data: np.ndarray, dist_observation: int, scale_factor: float, wavelenght: float ) -> np.ndarray: """Fresnel propagator applied to data. Adapted from the script by Adrián Carbajal-Domínguez, adrian.carbajal@ujat.mx Args: data (np.ndarray): 2D sinogram or 3D projection data. The input data must be of the following shape: 2D sinogram (anglesDim, DetectorsHoriz), 3D projection data (DetectorsVert, anglesDim, DetectorsHoriz). dist_observation (int): The distance for obervation for fresnel propagator. scale_factor (float): Scaling. wavelenght (float): Wavelength. Returns: np.ndarray: 2D or 3D data with propagation. """ data_fresnel = data.copy() if data.ndim == 2: (anglesDim, DetectorsDimH) = np.shape(data) n1 = DetectorsDimH * 0.5 # Define the angular spectrum coordinates u = np.arange(-n1, n1, 1) # Define the propagation matrix propagator = np.exp( 2 * np.pi * 1j * (dist_observation / scale_factor) * np.sqrt((1 / wavelenght) ** 2 - (u / 10) ** 2) ) #### Compute the Fast Fourier Transform of each 1D projection for x in range(anglesDim): f = np.fft.fft(data_fresnel[x, :]) # Correct the low and high frequencies fshift = np.fft.fftshift(f) # multiply both matrices: Fourier transform and the propagator matrices. field = fshift * propagator # Calculate the inverse Fourier transform field2 = np.fft.ifft(field) data_fresnel[x, :] = np.abs(field2) else: (DetectorsDimV, anglesDim, DetectorsDimH) = np.shape(data) ####Define the size of the propagation function p(u,v). It has to be of the same size of the image. n1 = DetectorsDimV * 0.5 n2 = DetectorsDimH * 0.5 # Define the angular spectrum coordinates u = np.arange(-n1, n1, 1) v = np.arange(-n2, n2, 1) U, V = np.meshgrid(u, v) # Define the propagation matrix propagator = np.exp( 2 * np.pi * 1j * (dist_observation / scale_factor) * np.sqrt( (1 / wavelenght) ** 2 - (U / scale_factor) ** 2 - (V / scale_factor) ** 2 ) ) #### Compute the Fast Fourier Transform of each 2D projection for x in range(anglesDim): f = np.fft.fft2(data_fresnel[:, x, :]) # Correct the low and high frequencies fshift = np.fft.fftshift(f) # multiply both matrices: Fourier transform and the propagator matrices. field = fshift * np.transpose(propagator) # Calculate the inverse Fourier transform field2 = np.fft.ifft2(field) data_fresnel[:, x, :] = np.abs(field2) return data_fresnel