"""Trajectories based on the ECCENTRIC formulation."""
import numpy as np
from numpy.typing import NDArray
from mrinufft.trajectories.maths import R2D
from mrinufft.trajectories.utils import KMAX
def _initialize_2D_eccentric(
Nc: int,
Ns: int,
radius_ratio: float,
center_ratio: float = 0.0,
nb_revolutions: float = 1.0,
min_distance: float = 0.0,
max_radius: float = 1.0,
seed: int | None = None,
) -> NDArray[np.float64]:
"""Initialize a 2D ECCENTRIC trajectory.
This function offers an additional degree of customization
with `max_radius` used to generate 3D stacks following the authors
specifications.
It is made private to avoid giving users access to
the `max_radius` argument as it is irrelevant for them.
Parameters
----------
Nc : int
Number of shots/circles.
Ns : int
Number of samples per shot/circle.
radius_ratio : float
Radius of each circle relatively to the k-space radius,
between 0 and 0.5.
center_ratio : float, optional
Proportion between 0 and 1 of shots positioned around
the center into a pseudo-rosette pattern. Default to 0.
nb_revolutions : float, optional
Number of revolutions per circle. Defaults to 1.
min_distance : float, optional
Minimum allowed distance between consecutive circles relatively
to the k-space radius between 0 and 0.5. Defaults to 0.
max_radius : float, optional
Maximum radius for circle placement relative to the k-space radius,
between 0 and 1. Defaults to 1.
seed : int | None, optional
Random seed for reproducibility, used only to draw the circle centers.
Defaults to None.
Returns
-------
NDArray[np.float64]
The generated 2D trajectory with shape (Nc, Ns, 2).
"""
# Check arguments validity
if not (0 < radius_ratio <= 0.5):
raise ValueError("The `radius_ratio` should be strictly between 0 and 0.5.")
if not (0 <= center_ratio <= 1):
raise ValueError("The `center_ratio` should be between 0 and 1.")
if not (0 <= min_distance <= 0.5):
raise ValueError("The `min_distance` should be between 0 and 0.5.")
if not (0 <= max_radius <= 1):
raise ValueError("The `max_radius` should be between 0 and 1.")
# Define a single circle
circle_angles = np.linspace(0, 2 * np.pi * nb_revolutions, Ns, endpoint=False)
circle = np.zeros((Ns, 2))
circle[:, 0] = radius_ratio * np.cos(circle_angles)
circle[:, 1] = radius_ratio * np.sin(circle_angles)
# Draw random positions for each circle until consecutive
# circles are not too close
rng = np.random.default_rng(seed=seed)
distances, angles = np.zeros(Nc), np.zeros(Nc)
close_mask = np.ones(Nc).astype(bool)
while close_mask.any():
# Draw new points only where too close
nb_close = np.sum(close_mask)
distances[close_mask] = (
rng.random(size=nb_close) * max_radius * (1 - radius_ratio)
)
angles[close_mask] = rng.random(size=nb_close) * 2 * np.pi
# Update positions
positions = np.zeros((Nc, 2))
positions[:, 0] = distances * np.cos(angles)
positions[:, 1] = distances * np.sin(angles)
# Check again for closeness
close_mask = (
np.linalg.norm(positions - np.roll(positions, shift=1, axis=0), axis=-1)
< min_distance
)
close_mask[0] = False
# Break the rolling closeness and guarantee to find a solution
# Enforce some central positions to be in pseudo-rosette style
Nc_center = round(Nc * center_ratio)
angles[:Nc_center] = np.linspace(0, 2 * np.pi, Nc_center, endpoint=False)
positions[:Nc_center, 0] = radius_ratio * np.cos(angles[:Nc_center])
positions[:Nc_center, 1] = radius_ratio * np.sin(angles[:Nc_center])
# Assemble trajectory
trajectory = np.zeros((Nc, Ns, 2))
for i in range(Nc):
# Apply rotation so the circle's first point is the
# closest to the center
rotation = R2D(np.pi + angles[i]).T
trajectory[i] = positions[i, None, :] + circle[None, :, :] @ rotation
return KMAX * trajectory
[docs]
def initialize_2D_eccentric(
Nc: int,
Ns: int,
radius_ratio: float,
center_ratio: float = 0.0,
nb_revolutions: float = 1.0,
min_distance: float = 0.0,
seed: int | None = None,
) -> NDArray[np.float64]:
"""Initialize a 2D ECCENTRIC trajectory.
This is a reproduction of the proposition from [Kla+24]_.
It creates trajectories as uniformly distributed circles,
with a pseudo rosette-like structure at the center to ensure
its coverage. ECCENTRIC stands for ECcentric Circle ENcoding
TRajectorIes for Compressed sensing.
Notes
-----
This implementation follows the original propositions but
decisions were made about missing details and additional
features are proposed:
- circles are oriented such that their starting points are the closest
to the center. It is chosen to avoid sampling the center at
radically different times, which would cause contrast discrepancies
and signal loss due to dephasing.
- the number of circle revolutions is an input instead of sticking to 1,
to handle multi-echo sequences or simply benefit from a higher duty cycle.
Parameters
----------
Nc : int
Number of shots/circles.
Ns : int
Number of samples per shot/circle.
radius_ratio : float
Radius of each circle relatively to the k-space radius,
between 0 and 0.5.
center_ratio : float, optional
Proportion between 0 and 1 of shots positioned around
the center into a pseudo-rosette pattern. Default to 0.
nb_revolutions : float, optional
Number of revolutions per circle. Defaults to 1.
min_distance : float, optional
Minimum allowed distance between consecutive circles relatively
to the k-space radius between 0 and 0.5. Defaults to 0.
seed : int | None, optional
Random seed for reproducibility, used only to draw the circle centers.
Defaults to None.
Returns
-------
NDArray[np.float64]
The generated 2D trajectory with shape (Nc, Ns, 2).
References
----------
.. [Kla+24] Klauser, Antoine, Bernhard Strasser, Wolfgang Bogner,
Lukas Hingerl, Sebastien Courvoisier, Claudiu Schirda,
Bruce R. Rosen, Francois Lazeyras, and Ovidiu C. Andronesi.
"ECCENTRIC: a fast and unrestrained approach for high-resolution
in vivo metabolic imaging at ultra-high field MR".
Imaging Neuroscience 2 (2024): 1-20.
"""
return _initialize_2D_eccentric(
Nc=Nc,
Ns=Ns,
radius_ratio=radius_ratio,
center_ratio=center_ratio,
nb_revolutions=nb_revolutions,
min_distance=min_distance,
max_radius=1,
seed=seed,
)
[docs]
def initialize_3D_eccentric(
Nc: int,
Ns: int,
nb_stacks: int,
radius_ratio: float,
center_ratio: float = 0.0,
nb_revolutions: float = 1.0,
min_distance: float = 0.0,
seed: int | None = None,
) -> NDArray[np.float64]:
"""Initialize a 3D ECCENTRIC trajectory.
This is a reproduction of the proposition from [Kla+24]_.
It creates trajectories as uniformly distributed circles
stacked spherically over the :math:`k_z`-axis, with a pseudo
rosette-like structure at the center to ensure its coverage.
ECCENTRIC stands for ECcentric Circle ENcoding TRajectorIes
for Compressed sensing.
Notes
-----
This implementation follows the original propositions but
decisions were made about missing details and additional
features are proposed:
- circles are oriented such that their starting points are the closest
to the center. It is chosen to avoid sampling the center at
radically different times, which would cause contrast discrepancies
and signal loss due to dephasing.
- the number of circle revolutions is an input instead of sticking to 1,
to handle multi-echo sequences or simply benefit from a higher duty cycle.
Parameters
----------
Nc : int
Number of shots/circles.
Ns : int
Number of samples per shot/circle.
nb_stacks : int
Number of stack layers along the :math:`k_z`-axis.
radius_ratio : float
Radius of each circle relatively to the k-space radius,
between 0 and 0.5.
center_ratio : float, optional
Proportion between 0 and 1 of shots positioned around
the center into a pseudo-rosette pattern. Default to 0.
nb_revolutions : float, optional
Number of revolutions per circle. Defaults to 1.
min_distance : float, optional
Minimum allowed distance between consecutive circles relatively
to the k-space radius between 0 and 0.5. Defaults to 0.
max_radius : float, optional
Maximum radius for circle placement relative to the k-space radius,
between 0 and 1. Defaults to 1.
seed : int | None, optional
Random seed for reproducibility, used only to draw the circle centers.
Defaults to None.
Returns
-------
NDArray[np.float64]
The generated 3D trajectory with shape (Nc, Ns, 3).
References
----------
.. [Kla+24] Klauser, Antoine, Bernhard Strasser, Wolfgang Bogner,
Lukas Hingerl, Sebastien Courvoisier, Claudiu Schirda,
Bruce R. Rosen, Francois Lazeyras, and Ovidiu C. Andronesi.
"ECCENTRIC: a fast and unrestrained approach for high-resolution
in vivo metabolic imaging at ultra-high field MR".
Imaging Neuroscience 2 (2024): 1-20.
"""
trajectory = np.zeros((Nc, Ns, 3))
# Attribute shots to stacks following a prescribed density
Nc_per_stack = np.ones(nb_stacks).astype(int)
stack_positions = np.linspace(-1, 1, nb_stacks)
density = np.sqrt(
1 - stack_positions**2
) # same as the paper but simpler formulation
for _ in range(Nc - nb_stacks):
idx = np.argmax(density / Nc_per_stack)
Nc_per_stack[idx] += 1
# Generate each stack
counter = 0
for i in range(nb_stacks):
# Set indices and update counter for next round
id_start = counter
id_end = counter + Nc_per_stack[i]
counter += Nc_per_stack[i]
# Generate the stack but change the maximum radius
stack = _initialize_2D_eccentric(
Nc=Nc_per_stack[i],
Ns=Ns,
radius_ratio=radius_ratio,
center_ratio=center_ratio,
nb_revolutions=nb_revolutions,
min_distance=min_distance,
max_radius=density[i],
seed=seed,
)
trajectory[id_start:id_end, :, :2] = stack
trajectory[id_start:id_end, :, 2] = KMAX * stack_positions[i]
return trajectory