# coding=utf-8
__author__ = "Dimitris Karkalousos"
import contextlib
from typing import List, Optional, Sequence, Tuple, Union
import numba as nb
import numpy as np
import torch
@contextlib.contextmanager
def temp_seed(rng: np.random, seed: Optional[Union[int, Tuple[int, ...]]]):
"""Temporarily set the seed of a numpy random number generator.
Parameters
----------
rng : np.random.Generator
The numpy random number generator to modify.
seed : Optional[Union[int, Tuple[int, ...]]], optional
The seed to set, by default None.
"""
if seed is None:
try:
yield
finally:
pass
else:
state = rng.get_state()
rng.seed(seed)
try:
yield
finally:
rng.set_state(state)
[docs]class MaskFunc:
"""MaskFunc is an abstract base class for creating sub-sampling masks.
This class is used to create a mask for MRI data that can be used to randomly under-sample the k-space data. The
mask is created by retaining a specified fraction of the low-frequency columns and setting the rest to zero. The
fraction of low-frequency columns to retain and the amount of under-sampling can be specified at initialization.
Examples
--------
>>> from atommic.collections.common.data.subsample import MaskFunc
>>> mask_func = MaskFunc(center_fractions=[0.08, 0.04], accelerations=[4, 8])
>>> mask_func.choose_acceleration()
(0.08, 4)
"""
def __init__(self, center_fractions: Sequence[float], accelerations: Sequence[int]):
"""Inits :class:`MaskFunc`.
Parameters
----------
center_fractions : Sequence[float]
Fraction of low-frequency columns to be retained. If multiple values are provided, then one of these
numbers is chosen uniformly each time. For 2D setting this value corresponds to setting the
Full-Width-Half-Maximum.
accelerations : Sequence[int]
Amount of under-sampling. This should have the same length as center_fractions. If multiple values are
provided, then one of these is chosen uniformly each time.
"""
super().__init__()
self.center_fractions = center_fractions
self.accelerations = accelerations
self.rng = np.random.RandomState()
def __call__(
self,
shape: Sequence[int],
seed: Optional[Union[int, Tuple[int, ...]]] = None,
partial_fourier_percentage: Optional[float] = 0.0,
**kwargs,
) -> Tuple[torch.Tensor, int]:
"""Calls :class:`MaskFunc`.
Parameters
----------
shape : Sequence[int]
The shape of the mask to be created. The shape should have at least 3 dimensions. Same as the shape of the
input k-space data.
seed : int or tuple of ints, optional
Seed for the random number generator. Default is ``None``.
partial_fourier_percentage : float, optional
Percentage of the low-frequency columns to be retained. Default is ``0.0``.
"""
raise NotImplementedError
[docs] def choose_acceleration(self) -> Tuple[float, int]:
"""Chooses an acceleration factor and center fractions from a list of multiple values.
Returns
-------
Tuple[float, int]
A tuple of the center fraction and the acceleration factor.
"""
choice = self.rng.randint(0, len(self.accelerations))
center_fraction = self.center_fractions[choice]
acceleration = self.accelerations[choice]
return center_fraction, acceleration
[docs]class Equispaced1DMaskFunc(MaskFunc):
r"""Equispaced1DMaskFunc creates a sub-sampling mask of a given shape.
The mask selects a subset of columns from the input k-space data. If the k-space data has N columns, the mask
picks out:
1. N_low_freqs = (N * center_fraction) columns in the center corresponding to low-frequencies.
2. The other columns are selected with equal spacing at a proportion that reaches the desired acceleration
rate taking into consideration the number of low frequencies. This ensures that the expected number of
columns selected is equal to (N / acceleration).
It is possible to use multiple center_fractions and accelerations, in which case one possible (center_fraction,
acceleration) is chosen uniformly at random each time the Equispaced1DMaskFunc object is called.
Note that this function may not give equispaced samples \
(documented in https://github.com/facebookresearch/fastMRI/issues/54), which will require modifications to
standard GRAPPA approaches. Nonetheless, this aspect of the function has been preserved to match the public
multicoil data.
Examples
--------
>>> import torch
>>> from atommic.collections.common.data.subsample import Equispaced1DMaskFunc
>>> mask_func = Equispaced1DMaskFunc(center_fractions=[0.08, 0.04], accelerations=[4, 8])
>>> mask_func.choose_acceleration()
(0.08, 4)
>>> kspace = torch.randn(1, 1, 640, 368)
>>> mask, acceleration = mask_func(kspace.shape)
>>> mask.shape
torch.Size([1, 1, 1, 368])
>>> acceleration
4
"""
def __call__(
self,
shape: Sequence[int],
seed: Optional[Union[int, Tuple[int, ...]]] = None,
partial_fourier_percentage: Optional[float] = 0.0,
**kwargs,
) -> Tuple[torch.Tensor, int]:
"""Calls :class:`Equispaced1DMaskFunc`.
Parameters
----------
shape : Sequence[int]
The shape of the mask to be created. The shape should have at least 3 dimensions. Same as the shape of the
input k-space data.
seed : int or tuple of ints, optional
Seed for the random number generator. Default is ``None``.
partial_fourier_percentage : float, optional
Percentage of the low-frequency columns to be retained. Default is ``0.0``.
Returns
-------
Tuple[torch.Tensor, int]
A tuple of the generated mask and the acceleration factor.
Raises
------
ValueError
If the `shape` parameter has less than 3 dimensions.
"""
if len(shape) < 3:
raise ValueError("Shape should have 3 or more dimensions")
with temp_seed(self.rng, seed):
center_fraction, acceleration = self.choose_acceleration()
num_cols = shape[-2]
num_low_freqs = int(round(num_cols * center_fraction))
# create the mask
mask = np.zeros(num_cols, dtype=np.float32)
pad = torch.div((num_cols - num_low_freqs + 1), 2, rounding_mode="trunc").item()
mask[pad : pad + num_low_freqs] = True
# determine acceleration rate by adjusting for the number of low frequencies
adjusted_accel = (acceleration * (num_low_freqs - num_cols)) / (num_low_freqs * acceleration - num_cols)
offset = self.rng.randint(0, round(adjusted_accel))
accel_samples = np.arange(offset, num_cols - 1, adjusted_accel)
accel_samples = np.around(accel_samples).astype(np.uint)
mask[accel_samples] = True
# reshape the mask
mask_shape = [1 for _ in shape]
mask_shape[-2] = num_cols
mask = torch.from_numpy(mask.reshape(*mask_shape).astype(np.float32))
if partial_fourier_percentage != 0:
mask[:, : int(np.round(mask.shape[1] * partial_fourier_percentage))] = 0.0
return mask, acceleration
[docs]class Equispaced2DMaskFunc(MaskFunc):
"""Same as Equispaced1DMaskFunc, but for 2D k-space data.
.. note::
See ..class::`atommic.collections.common.data.subsample.Equispaced1DMaskFunc` for more details.
Examples
--------
>>> import torch
>>> from atommic.collections.common.data.subsample import Equispaced2DMaskFunc
>>> mask_func = Equispaced2DMaskFunc(center_fractions=[0.08, 0.04], accelerations=[4, 8])
>>> mask_func.choose_acceleration()
(0.08, 4)
>>> kspace = torch.randn(1, 1, 640, 368)
>>> mask, acceleration = mask_func(kspace.shape)
>>> mask.shape
torch.Size([1, 1, 640, 368])
>>> acceleration
4
"""
def __call__(
self,
shape: Sequence[int],
seed: Optional[Union[int, Tuple[int, ...]]] = None,
partial_fourier_percentage: Optional[float] = 0.0,
**kwargs,
) -> Tuple[torch.Tensor, int]:
"""Calls :class:`Equispaced2DMaskFunc`.
Parameters
----------
shape : Sequence[int]
The shape of the mask to be created. The shape should have at least 3 dimensions. Same as the shape of the
input k-space data.
seed : int or tuple of ints, optional
Seed for the random number generator. Default is ``None``.
partial_fourier_percentage : float, optional
Percentage of the low-frequency columns to be retained. Default is ``0.0``.
Returns
-------
Tuple[torch.Tensor, int]
A tuple containing the mask and the acceleration factor.
The mask is a `torch.Tensor` of the same shape as the input shape.
The acceleration factor is an `int` that represents the number of samples taken in the k-space.
Raises
------
ValueError
If the `shape` parameter has less than 3 dimensions.
"""
if len(shape) < 3:
raise ValueError("Shape should have 3 or more dimensions")
with temp_seed(self.rng, seed):
center_fraction, acceleration = self.choose_acceleration()
acceleration = int(acceleration / 2)
center_fraction = center_fraction / 2
num_cols = shape[-2]
num_low_freqs = int(round(num_cols * center_fraction))
num_rows = shape[-3]
num_high_freqs = int(round(num_rows * center_fraction))
# create the mask
mask = np.zeros([num_rows, num_cols], dtype=np.float32)
pad_cols = torch.div((num_cols - num_low_freqs + 1), 2, rounding_mode="trunc").item()
pad_rows = torch.div((num_rows - num_high_freqs + 1), 2, rounding_mode="trunc").item()
mask[pad_rows : pad_rows + num_high_freqs, pad_cols : pad_cols + num_low_freqs] = True
for i in np.arange(0, num_rows, acceleration):
for j in np.arange(0, num_cols, acceleration):
mask[int(i), int(j)] = True
# reshape the mask
mask_shape = [1 for _ in shape]
mask_shape[-2] = num_cols
mask_shape[-3] = num_rows
mask = torch.from_numpy(mask.reshape(*mask_shape).astype(np.float32))
if partial_fourier_percentage != 0:
mask[:, : int(np.round(mask.shape[1] * partial_fourier_percentage))] = 0.0
return mask, acceleration * 2
[docs]class Gaussian1DMaskFunc(MaskFunc):
"""Same as Gaussian2DMaskFunc, but for 1D k-space data.
.. note::
See ..class::`atommic.collections.common.data.subsample.Gaussian2DMaskFunc` for more details.
Examples
--------
>>> import torch
>>> from atommic.collections.common.data.subsample import Gaussian1DMaskFunc
>>> mask_func = Gaussian1DMaskFunc(center_fractions=[0.7, 0.7], accelerations=[4, 8])
>>> mask_func.choose_acceleration()
(0.7, 4)
>>> kspace = torch.randn(1, 1, 640, 368)
>>> mask, acceleration = mask_func(kspace.shape)
>>> mask.shape
torch.Size([1, 1, 1, 368])
>>> acceleration
4
"""
def __call__(
self,
shape: Union[Sequence[int], np.ndarray],
seed: Optional[Union[int, Tuple[int, ...]]] = None,
partial_fourier_percentage: Optional[float] = 0.0,
center_scale: Optional[float] = 0.02,
**kwargs,
) -> Tuple[torch.Tensor, int]:
"""Calls :class:`Gaussian1DMaskFunc`.
Parameters
----------
shape : Sequence[int]
The shape of the mask to be created. The shape should have at least 3 dimensions. Same as the shape of the
input k-space data.
seed : int or tuple of ints, optional
Seed for the random number generator. Default is ``None``.
partial_fourier_percentage : float, optional
Percentage of the low-frequency columns to be retained. Default is ``0.0``.
center_scale : float, optional
For autocalibration purposes, data points near the k-space center will be fully sampled within an ellipse
of which the half-axes will be set to the given `center_scale` percentage of the fully sampled region.
Default is ``0.02``.
Returns
-------
Tuple[torch.Tensor, int]
A tuple of the generated mask and the acceleration factor.
"""
with temp_seed(self.rng, seed):
dims = [1 for _ in shape]
self.shape = tuple(shape[-3:-1])
self.shape = (self.shape[1], self.shape[0])
dims[-2] = self.shape[-2]
full_width_half_maximum, acceleration = self.choose_acceleration()
self.full_width_half_maximum = full_width_half_maximum
self.acceleration = acceleration
self.center_scale = center_scale
mask = self.gaussian_kspace()
mask[tuple(self.gaussian_coordinates())] = 1.0
mask = np.fft.ifftshift(np.fft.ifftshift(np.fft.ifftshift(mask, axes=0), axes=0), axes=(0, 1))
if partial_fourier_percentage != 0:
mask[:, : int(np.round(mask.shape[1] * partial_fourier_percentage))] = 0.0
mask = torch.from_numpy(np.transpose(mask, (1, 0))[0].reshape(*dims).astype(np.float32))
return mask, acceleration
[docs] def gaussian_kspace(self) -> np.ndarray:
"""Creates a Gaussian sampled k-space center."""
scaled = int(self.shape[0] * self.center_scale)
center = np.ones((scaled, self.shape[1]))
top_scaled = torch.div((self.shape[0] - scaled), 2, rounding_mode="trunc").item()
bottom_scaled = self.shape[0] - scaled - top_scaled
top = np.zeros((top_scaled, self.shape[1]))
btm = np.zeros((bottom_scaled, self.shape[1]))
return np.concatenate((top, center, btm))
[docs] def gaussian_coordinates(self) -> Tuple[np.ndarray, np.ndarray]:
r"""Returns Gaussian sampled k-space coordinates.
Returns
-------
xsamples : np.ndarray
A 1D numpy array of x-coordinates.
ysamples : np.ndarray
A 1D numpy array of y-coordinates.
Notes
-----
The number of samples taken is determined by `n_sample` which is calculated as
`self.shape[0] / self.acceleration`. The selection of the samples is based on the probabilities calculated
from `gaussian_kernel`.
"""
n_sample = int(self.shape[0] / self.acceleration)
idxs = np.random.choice(range(self.shape[0]), size=n_sample, replace=False, p=self.gaussian_kernel())
xsamples = np.concatenate([np.tile(i, self.shape[1]) for i in idxs])
ysamples = np.concatenate([range(self.shape[1]) for _ in idxs])
return xsamples, ysamples
[docs] def gaussian_kernel(self) -> np.ndarray:
r"""Creates a Gaussian sampled k-space kernel.
.. note::
The function calculates the Gaussian kernel by computing the sum of the exponential of the squared \
x-values divided by 2 times the square of the standard deviation. The standard deviation is calculated \
from the full width at half maximum (FWHM) of the Gaussian curve and is defined as the FWHM divided by \
the square root of 8 times the natural logarithm of 2. The FWHM and the kern_len are obtained from the \
`full_width_half_maximum` and `shape` attributes of the class respectively.
Returns
-------
ndarray
The Gaussian kernel.
"""
kernel = 1
for kern_len in self.shape:
sigma = self.full_width_half_maximum / np.sqrt(8 * np.log(2))
x = np.linspace(-1.0, 1.0, kern_len)
g = np.exp(-(x**2 / (2 * sigma**2))) # noqa: F841
kernel = g
break
kernel = kernel / kernel.sum() # type: ignore
return kernel
[docs]class Gaussian2DMaskFunc(MaskFunc):
"""Creates a 2D sub-sampling mask of a given shape.
The sub-sampling mask is generated in k-space, with data points near the k-space center being fully sampled within
an ellipse. The half-axes of the ellipse are set to the `center_scale` percentage of the fully sampled region. The
remaining points are sampled according to a Gaussian distribution.
The center fractions act as Full-Width at Half-Maximum (FWHM) values.
Examples
--------
>>> import torch
>>> from atommic.collections.common.data.subsample import Gaussian2DMaskFunc
>>> mask_func = Gaussian2DMaskFunc(center_fractions=[0.7, 0.7], accelerations=[4, 8])
>>> mask_func.choose_acceleration()
(0.7, 4)
>>> kspace = torch.randn(1, 1, 640, 368)
>>> mask, acceleration = mask_func(kspace.shape)
>>> mask.shape
torch.Size([1, 1, 640, 368])
>>> acceleration
4
"""
def __call__(
self,
shape: Union[Sequence[int], np.ndarray],
seed: Optional[Union[int, Tuple[int, ...]]] = None,
partial_fourier_percentage: Optional[float] = 0.0,
center_scale: Optional[float] = 0.02,
**kwargs,
) -> Tuple[torch.Tensor, int]:
"""Calls :class:`Gaussian2DMaskFunc`.
Parameters
----------
shape : Sequence[int]
The shape of the mask to be created. The shape should have at least 3 dimensions. Same as the shape of the
input k-space data.
seed : int or tuple of ints, optional
Seed for the random number generator. Default is ``None``.
partial_fourier_percentage : float, optional
Percentage of the low-frequency columns to be retained. Default is ``0.0``.
center_scale : float, optional
For autocalibration purposes, data points near the k-space center will be fully sampled within an ellipse
of which the half-axes will be set to the given `center_scale` percentage of the fully sampled region.
Default is ``0.02``.
Returns
-------
Tuple[torch.Tensor, int]
A tuple of the generated mask and the acceleration factor.
Raises
------
ValueError
If the `shape` parameter has less than 3 dimensions.
"""
with temp_seed(self.rng, seed):
dims = [1 for _ in shape]
self.shape = tuple(shape[-3:-1])
dims[-3:-1] = self.shape
full_width_half_maximum, acceleration = self.choose_acceleration()
self.full_width_half_maximum = full_width_half_maximum
self.acceleration = acceleration
self.center_scale = center_scale
mask = self.gaussian_kspace()
mask[tuple(self.gaussian_coordinates())] = 1.0
if partial_fourier_percentage != 0:
mask[: int(np.round(mask.shape[0] * partial_fourier_percentage)), :] = 0.0
mask = torch.from_numpy(mask.reshape(dims).astype(np.float32))
return mask, acceleration
[docs] def gaussian_kspace(self) -> np.ndarray:
"""Creates a Gaussian sampled k-space center."""
a, b = self.center_scale * self.shape[0], self.center_scale * self.shape[1]
afocal, bfocal = self.shape[0] / 2, self.shape[1] / 2
xx, yy = np.mgrid[: self.shape[0], : self.shape[1]]
ellipse = np.power((xx - afocal) / a, 2) + np.power((yy - bfocal) / b, 2)
return (ellipse < 1).astype(float)
[docs] def gaussian_coordinates(self) -> List[Tuple[int, int]]:
r"""Returns Gaussian sampled k-space coordinates.
Returns
-------
xsamples : np.ndarray
A 1D numpy array of x-coordinates.
ysamples : np.ndarray
A 1D numpy array of y-coordinates.
Notes
-----
The number of samples taken is determined by `n_sample` which is calculated as \
`self.shape[0] / self.acceleration`. The selection of the samples is based on the probabilities calculated \
from `gaussian_kernel`.
"""
n_sample = int(self.shape[0] * self.shape[1] / self.acceleration)
cartesian_prod = list(np.ndindex(self.shape))
kernel = self.gaussian_kernel()
idxs = np.random.choice(range(len(cartesian_prod)), size=n_sample, replace=False, p=kernel.flatten())
return list(zip(*list(map(cartesian_prod.__getitem__, idxs))))
[docs] def gaussian_kernel(self) -> np.ndarray:
r"""Creates a Gaussian sampled k-space kernel.
.. note::
The function calculates the Gaussian kernel by computing the sum of the exponential of the squared \
x-values divided by 2 times the square of the standard deviation. The standard deviation is calculated \
from the full width at half maximum (FWHM) of the Gaussian curve and is defined as the FWHM divided by \
the square root of 8 times the natural logarithm of 2. The FWHM and the kern_len are obtained from the \
`full_width_half_maximum` and `shape` attributes of the class respectively.
Returns
-------
ndarray
The Gaussian kernel.
"""
kernels = []
for kern_len in self.shape:
sigma = self.full_width_half_maximum / np.sqrt(8 * np.log(2))
x = np.linspace(-1.0, 1.0, kern_len)
g = np.exp(-(x**2 / (2 * sigma**2)))
kernels.append(g)
kernel = np.sqrt(np.outer(kernels[0], kernels[1]))
kernel = kernel / kernel.sum()
return kernel
[docs]class Poisson2DMaskFunc(MaskFunc):
r"""Generate variable-density Poisson-disc sampling pattern, as described in [1]_.
The function generates a variable density Poisson-disc sampling mask with density proportional to
:math:`1 / (1 + s |r|)`, where :math:`r` represents the k-space radius, and :math:`s` represents the slope. A
binary search is performed on the slope :math:`s` such that the resulting acceleration factor is close to the
prescribed acceleration factor `accel`. The parameter `tol` determines how much they can deviate.
References
----------
.. [1] Bridson, Robert. "Fast Poisson disk sampling in arbitrary dimensions." SIGGRAPH sketches. 2007
.. note::
Taken and adapted from: https://github.com/mikgroup/sigpy/blob/master/sigpy/mri/samp.py
"""
def __call__(
self,
shape: Union[Sequence[int], np.ndarray],
seed: Optional[Union[int, Tuple[int, ...]]] = None,
partial_fourier_percentage: Optional[float] = 0.0,
center_scale: Optional[float] = 0.02,
calib: Optional[Tuple[float, float]] = (0.0, 0.0),
crop_corner: bool = True,
max_attempts: int = 30,
tol: float = 0.3,
**kwargs,
) -> Tuple[torch.Tensor, int]:
"""Calls :class:`Poisson2DMaskFunc`.
Parameters
----------
shape : Sequence[int]
The shape of the mask to be created. The shape should have at least 3 dimensions. Same as the shape of the
input k-space data.
seed : int or tuple of ints, optional
Seed for the random number generator. Default is ``None``.
partial_fourier_percentage : float, optional
Percentage of the low-frequency columns to be retained. Default is ``0.0``.
center_scale : float, optional
For autocalibration purposes, data points near the k-space center will be fully sampled within an ellipse
of which the half-axes will be set to the given `center_scale` percentage of the fully sampled region.
Default is ``0.02``.
calib : Optional[Tuple[float, float]], optional
Defines the size of the calibration region, which is a square region in the center of k-space. The first
value defines the percentage of the center that is sampled, and the second value defines the size of the
calibration region in the center of k-space. Default is ``(0.0, 0.0)``.
crop_corner : bool, optional
If set to True, the center of the mask will be cropped to the size of the calibration region. Default is
``True``.
max_attempts : int, optional
Maximum number of attempts to generate a mask with the desired acceleration factor. Default is ``30``.
tol : float, optional
Tolerance for the acceleration factor. Default is ``0.3``.
Returns
-------
Tuple[torch.Tensor, int]
A tuple containing the mask and the acceleration factor.
The mask is a `torch.Tensor` of the same shape as the input shape.
The acceleration factor is an `int` that represents the number of samples taken in the k-space.
"""
with temp_seed(self.rng, seed):
self.shape = tuple(shape[-3:-1])
self.center_scale = center_scale
_, self.acceleration = self.choose_acceleration()
ny, nx = self.shape
y, x = np.mgrid[:ny, :nx]
x = np.maximum(abs(x - nx / 2) - calib[-1] / 2, 0) # type: ignore
x /= x.max()
y = np.maximum(abs(y - ny / 2) - calib[-2] / 2, 0) # type: ignore
y /= y.max()
r = np.hypot(x, y)
slope_max = 40.0
slope_min = 0.0
d = max(nx, ny)
while slope_min < slope_max:
slope = (slope_max + slope_min) / 2
radius_x = np.clip((1 + r * slope) * nx / d, 1, None)
radius_y = np.clip((1 + r * slope) * ny / d, 1, None)
mask = self.generate_poisson_mask(nx, ny, max_attempts, radius_x, radius_y, calib)
if crop_corner:
mask *= r < 1
with np.errstate(divide="ignore", invalid="ignore"):
actual_acceleration = mask.size / np.sum(mask)
if abs(actual_acceleration - self.acceleration) < tol:
break
if actual_acceleration < self.acceleration:
slope_min = slope
else:
slope_max = slope
pattern1 = mask
pattern2 = self.centered_circle()
mask = np.logical_or(pattern1, pattern2)
if abs(actual_acceleration - self.acceleration) >= tol:
raise ValueError(f"Cannot generate mask to satisfy acceleration factor of {self.acceleration}.")
if partial_fourier_percentage != 0:
mask[:, : int(np.round(mask.shape[1] * partial_fourier_percentage))] = 0.0
mask = torch.from_numpy(mask.reshape(self.shape).astype(np.float32)).unsqueeze(0).unsqueeze(-1)
return mask, self.acceleration
[docs] def centered_circle(self) -> np.ndarray:
"""Creates a boolean centered circle image using the center_scale as a radius.
Returns
-------
np.ndarray
A 2D array of type bool, where True values indicate the points inside the centered circle and False
values indicate the points outside the centered circle. The circle has its center at the center of the
input shape and its radius is determined by the `center_scale` attribute.
"""
center_x = int((self.shape[0] - 1) / 2)
center_y = int((self.shape[1] - 1) / 2)
X, Y = np.indices(self.shape)
radius = int(self.shape[0] * self.center_scale)
radius_squared = radius**2
return ((X - center_x) ** 2 + (Y - center_y) ** 2) < radius_squared
@staticmethod
@nb.jit(nopython=True, cache=True)
def generate_poisson_mask(
nx: int,
ny: int,
max_attempts: int,
radius_x: np.ndarray,
radius_y: np.ndarray,
calib: Tuple[float, float],
) -> np.ndarray:
"""Generates a Poisson mask of shape `(ny, nx)` by placing points on the grid according to a Poisson
distribution.
Parameters
----------
nx : int
The number of columns in the output mask.
ny : int
The number of rows in the output mask.
max_attempts : int
The maximum number of attempts to generate a mask with the desired acceleration factor.
radius_x : np.ndarray
An array of shape `(ny, nx)` representing the radius of the Poisson distribution in the x-direction.
radius_y : np.ndarray
An array of shape `(ny, nx)` representing the radius of the Poisson distribution in the y-direction.
calib : Tuple[float, float]
Defines the size of the calibration region. The calibration region is a square region in the center of
k-space. The first value defines the percentage of the center that is sampled. The second value defines
the size of the calibration region in the center of k-space.
Returns
-------
np.ndarray
A binary mask with points placed according to a Poisson distribution.
"""
mask = np.zeros((ny, nx))
# Add calibration region
mask[
int(ny / 2 - calib[-2] / 2) : int(ny / 2 + calib[-2] / 2),
int(nx / 2 - calib[-1] / 2) : int(nx / 2 + calib[-1] / 2),
] = 1
# initialize active list
pxs = np.empty(nx * ny, np.int32)
pys = np.empty(nx * ny, np.int32)
pxs[0] = np.random.randint(0, nx)
pys[0] = np.random.randint(0, ny)
num_actives = 1
while num_actives > 0:
i = np.random.randint(0, num_actives)
px = pxs[i]
py = pys[i]
rx = radius_x[py, px]
ry = radius_y[py, px]
# Attempt to generate point
done = False
k = 0
while not done and k < max_attempts:
# Generate point randomly from r and 2 * r
v = (np.random.random() * 3 + 1) ** 0.5
t = 2 * np.pi * np.random.random()
qx = px + v * rx * np.cos(t)
qy = py + v * ry * np.sin(t)
# Reject if outside grid or close to other points
if 0 <= qx < nx and 0 <= qy < ny:
startx = max(int(qx - rx), 0)
endx = min(int(qx + rx + 1), nx)
starty = max(int(qy - ry), 0)
endy = min(int(qy + ry + 1), ny)
done = True
for x in range(startx, endx):
for y in range(starty, endy):
if mask[y, x] == 1 and (
((qx - x) / radius_x[y, x]) ** 2 + ((qy - y) / (radius_y[y, x])) ** 2 < 1
):
done = False
break
k += 1
# Add point if done else remove from active list
if done:
pxs[num_actives] = qx
pys[num_actives] = qy
mask[int(qy), int(qx)] = 1
num_actives += 1
else:
pxs[i] = pxs[num_actives - 1]
pys[i] = pys[num_actives - 1]
num_actives -= 1
return mask
[docs]class Random1DMaskFunc(MaskFunc):
r"""Random1DMaskFunc creates a sub-sampling mask of a given shape.
The mask selects a subset of columns from the input k-space data. If the k-space data has N columns, the mask
picks out:
1. N_low_freqs = (N * center_fraction) columns in the center corresponding to low-frequencies.
2. The other columns are selected uniformly at random with a probability equal to:
prob = (N / acceleration - N_low_freqs) / (N - N_low_freqs). This ensures that the expected number of columns
selected is equal to (N / acceleration).
It is possible to use multiple center_fractions and accelerations, in which case one possible (center_fraction,
acceleration) is chosen uniformly at random each time the Random1DMaskFunc object is called.
For example, if accelerations = [4, 8] and center_fractions = [0.08, 0.04], then there is a 50% probability that
4-fold acceleration with 8% center fraction is selected and a 50% probability that 8-fold acceleration with 4%
center fraction is selected.
"""
def __call__(
self,
shape: Sequence[int],
seed: Optional[Union[int, Tuple[int, ...]]] = None,
partial_fourier_percentage: Optional[float] = 0.0,
**kwargs,
) -> Tuple[torch.Tensor, int]:
"""Calls :class:`Random1DMaskFunc`.
Parameters
----------
shape : Sequence[int]
The shape of the mask to be created. The shape should have at least 3 dimensions. Same as the shape of the
input k-space data.
seed : int or tuple of ints, optional
Seed for the random number generator. Default is ``None``.
partial_fourier_percentage : float, optional
Percentage of the low-frequency columns to be retained. Default is ``0.0``.
Returns
-------
Tuple[torch.Tensor, int]
A tuple of the generated mask and the acceleration factor.
Raises
------
ValueError
If the `shape` parameter has less than 3 dimensions.
"""
if len(shape) < 3:
raise ValueError("Shape should have 3 or more dimensions")
with temp_seed(self.rng, seed):
num_cols = shape[-2]
center_fraction, acceleration = self.choose_acceleration()
# create the mask
num_low_freqs = int(round(num_cols * center_fraction))
prob = (num_cols / acceleration - num_low_freqs) / (num_cols - num_low_freqs)
mask = self.rng.uniform(size=num_cols) < prob
pad = torch.div((num_cols - num_low_freqs + 1), 2, rounding_mode="trunc").item()
mask[pad : pad + num_low_freqs] = True
# reshape the mask
mask_shape = [1 for _ in shape]
mask_shape[-2] = num_cols
mask = torch.from_numpy(mask.reshape(*mask_shape).astype(np.float32))
if partial_fourier_percentage != 0:
mask[:, : int(np.round(mask.shape[1] * partial_fourier_percentage))] = 0.0
return mask, acceleration
[docs]def create_masker(
mask_type_str: str, center_fractions: Union[Sequence[float], float], accelerations: Union[Sequence[int], int]
) -> MaskFunc:
"""Creates a MaskFunc object based on the specified mask type.
Parameters
----------
mask_type_str : str
The string representation of the mask type. Must be one of the following:
'equispaced1d', 'equispaced2d', 'gaussian1d', 'gaussian2d', 'poisson2d', 'random1d'.
center_fractions : Sequence[float] or float
The center fractions for the mask.
accelerations : Sequence[int] or int
The accelerations for the mask.
Returns
-------
MaskFunc
A MaskFunc object that corresponds to the specified mask type.
Raises
------
NotImplementedError
If the specified `mask_type_str` is not supported.
Examples
--------
>>> from atommic.collections.common.data.subsample import create_masker
>>> create_masker("random1d", [0.5], [4])
Random1DMaskFunc([0.5], [4])
>>> create_masker("equispaced2d", [0.3, 0.7], [8, 6])
Equispaced2DMaskFunc([0.3, 0.7], [8, 6])
>>> create_masker("poisson2d", [0.3, 0.7], [8, 6])
Poisson2DMaskFunc([0.3, 0.7], [8, 6])
>>> create_masker("gaussian1d", [0.3, 0.7], [8, 6])
Gaussian1DMaskFunc([0.3, 0.7], [8, 6])
>>> create_masker("gaussian2d", [0.3, 0.7], [8, 6])
Gaussian2DMaskFunc([0.3, 0.7], [8, 6])
"""
if isinstance(center_fractions, float):
center_fractions = [center_fractions]
if isinstance(accelerations, int):
accelerations = [accelerations]
if mask_type_str == "random1d":
return Random1DMaskFunc(center_fractions, accelerations)
if mask_type_str == "equispaced1d":
return Equispaced1DMaskFunc(center_fractions, accelerations)
if mask_type_str == "equispaced2d":
return Equispaced2DMaskFunc(center_fractions, accelerations)
if mask_type_str == "gaussian1d":
return Gaussian1DMaskFunc(center_fractions, accelerations)
if mask_type_str == "gaussian2d":
return Gaussian2DMaskFunc(center_fractions, accelerations)
if mask_type_str == "poisson2d":
return Poisson2DMaskFunc(center_fractions, accelerations)
raise NotImplementedError(f"{mask_type_str} not supported")