Source code for mrinufft.trajectories.inits.eccentric

"""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