Note
Go to the end to download the full example code.
EPI Nyquist ghost and ramp sampling#
An echo-planar readout acquires k-space in a train of lines of alternating readout gradient polarity, and two corrections are applied before the lines form a Cartesian k-space. A timing error between the readout gradient and the ADC, and eddy currents, displace the echoes of the reversed lines relative to the forward ones; the resulting odd/even phase produces a Nyquist ghost, a copy of the object displaced by half the field of view along the phase-encoding direction. Sampling during the ramps of the readout gradient shortens the echo spacing, and the samples, uniform in time, are not uniform in \(k_x\).
This example simulates an eight-channel EPI acquisition of a Shepp-Logan phantom with a known ADC delay and constant phase error, estimates the odd/even phase from a three-line navigator, and compares the ghosted and the corrected image with the delay-free image. It then resamples a ramp-sampled readout onto a uniform \(k_x\) grid and compares the result with linear interpolation.
Learning objectives
Relate an ADC delay to a linear phase in hybrid space and to the Nyquist ghost at half the field of view.
Estimate the odd/even phase from a three-line navigator with
estimate_epi_phase(), and apply it to the reversed lines withcorrect_lines().Quantify the ghost by the ghost-to-signal ratio.
Resample a ramp-sampled readout with
epi_ramp_operator(), and check the sampling condition under which the resampling is exact.
import math
import numpy as np
import torch
import bartorch
import bartorch.tools as bt
The Nyquist ghost#
A delay \(\delta\) of the ADC relative to the readout gradient shifts the echo of every line by the same time: towards positive \(k_x\) on a forward line and towards negative \(k_x\) on a reversed one. In hybrid space – after the inverse Fourier transform along the readout – a shift of \(\delta\) dwell times is a linear phase \(\pi \delta u\) over the readout coordinate \(u \in [-1, 1]\). A constant phase \(\phi_0\), from eddy currents or a \(B_0\) offset during the readout, adds to it with the same alternating sign. The phase difference between odd and even lines modulates k-space at the Nyquist frequency of the phase-encoding direction, and the image acquires a ghost at half the field of view.
Forward lines carry \(+(\pi \delta u + \phi_0)\) and reversed lines \(-(\pi \delta u + \phi_0)\). A reversed line is stored in the order it was digitised, that is flipped along the readout.
SIZE = 128
DELAY = 0.2 # dwell times
PHASE_0 = 0.25 # rad
SLOPE = math.pi * DELAY * (SIZE - 1) / SIZE # rad over u in [-1, 1]
kspace = bt.phantom(SIZE, kspace=True, coils=8)[:, 0] # (coils, ky, kx)
hybrid = bartorch.fft(kspace, axes=(-1,), inverse=True, unitary=True)
u = torch.linspace(-1.0, 1.0, SIZE)
def acquire(row, polarity):
"""One readout of polarity +1 or -1, digitised in the order it was played."""
delayed = row * torch.polar(torch.ones(SIZE), polarity * (SLOPE * u + PHASE_0))
line = bartorch.fft(delayed, axes=(-1,), unitary=True)
return torch.flip(line, [-1]) if polarity < 0 else line
train = [(acquire(hybrid[:, ky], 1 - 2 * (ky % 2)), ky % 2 == 1) for ky in range(SIZE)]
Ramp sampling#
Sampling during the ramps of the trapezoidal readout gradient places the
samples at \(k_x(t) = \gamma \int_0^t G_x(\tau)\, d\tau\), which is
denser on the ramps than on the plateau. The readout is the Fourier
transform of an object that spans support pixels, so samples at any
positions determine it, provided no two neighbouring samples are further
apart than one over the support, the Nyquist spacing of that object.
epi_ramp_operator() [1] is the regularized
least-squares inverse of the transform at the sampled positions followed by
the transform at the uniform ones.
The readout gradient is a trapezoid whose ramps each take 30 % of the ADC window, sampled with 160 samples, for a one-dimensional object of 64 pixels; positions are in cycles per pixel.
SUPPORT, SAMPLES = 64, 160
profile = torch.zeros(SUPPORT, dtype=torch.complex128)
profile[16:48] = torch.linspace(0.3, 1.0, 32) * torch.exp(1j * torch.linspace(0.0, 2.0, 32))
pixels = (torch.arange(SUPPORT) - SUPPORT // 2).double()
time = torch.linspace(0.0, 1.0, SAMPLES, dtype=torch.float64)
RAMP = 0.3
gradient = torch.clamp(torch.minimum(time / RAMP, (1 - time) / RAMP), 0, 1)
sampled_at = torch.cumsum(gradient, 0)
sampled_at = sampled_at - sampled_at.mean()
sampled_at = 0.5 * sampled_at / sampled_at.abs().max()
uniform_at = torch.arange(SAMPLES, dtype=torch.float64) / SAMPLES - 0.5
def encode(positions):
return torch.exp(-2j * math.pi * torch.outer(positions, pixels)) @ profile
measured, truth = encode(sampled_at), encode(uniform_at)
operator = bt.epi_ramp_operator(sampled_at, uniform_at, SUPPORT)
resampled = (measured.to(torch.complex64)[None] @ operator.T)[0].to(torch.complex128)
Linear interpolation between neighbouring samples is the comparison. Both are assessed on the image profile, the inverse transform of the uniform samples.
linear = torch.complex(
torch.from_numpy(np.interp(uniform_at, sampled_at, measured.real)),
torch.from_numpy(np.interp(uniform_at, sampled_at, measured.imag)),
)
def image_profile(samples):
return torch.fft.fftshift(torch.fft.ifft(torch.fft.ifftshift(samples))).abs()
step = float(torch.diff(sampled_at).max()) * SUPPORT
print(f"largest step x support: {step:.2f} (the samples determine the object below 1)")
for name, estimate in (("band-limited resampling", resampled), ("linear interpolation", linear)):
print(f"{name:>24}: image NRMSE {nrmse(image_profile(estimate), image_profile(truth)):.1e}")
largest step x support: 0.57 (the samples determine the object below 1)
band-limited resampling: image NRMSE 1.4e-06
linear interpolation: image NRMSE 3.2e-02
The band-limited resampling reproduces the profile of a uniformly sampled readout to the precision of the operator, which is returned in single precision. Linear interpolation errs most on the plateau, where the samples are furthest apart; in the image its error is spread over the whole field of view, inside and outside the object, at up to a few per cent of the peak. The ratio printed above is the condition to check on a measured trajectory: where a step exceeds one over the support, the readout is aliased and no resampling recovers it.
References#
Total running time of the script: (0 minutes 1.761 seconds)



