Readout oversampling and apodization

Readout oversampling and apodization#

Open in Colab

Two operations are applied to Cartesian k-space before the image is reconstructed. Readout oversampling doubles the field of view along the frequency-encoding direction, and is removed so that the image has the prescribed matrix. Truncation of k-space at the edge of the acquired matrix convolves the image with a sinc, whose side lobes appear as Gibbs ringing parallel to every sharp edge; an apodization window reduces the ringing at the cost of spatial resolution.

This example removes twofold readout oversampling from the k-space of a Shepp-Logan phantom and compares the result with an acquisition without oversampling, then reconstructs a 64 x 64 acquisition with no window, a Fermi window and a Hann window, and measures the ringing and the resolution of each. The k-space is evaluated analytically at the sample positions, so its truncation and its oversampling are those of a continuous object.

Learning objectives

  • Remove readout oversampling in the image domain with bartorch.remove_readout_oversampling(), and distinguish it from discarding the outer readout samples.

  • Relate the Gibbs ringing of a truncated acquisition to the side lobes of its point spread function.

  • Apodize k-space with bartorch.apodize(), and quantify the trade-off between ringing amplitude and the full width at half maximum.

  • Choose between the radial and the separable extension of a window over k-space.

import math

import torch

import bartorch
import bartorch.tools as bt

Readout oversampling#

The receiver digitises the echo at twice the sampling rate the prescribed matrix requires, so that its anti-aliasing filter can roll off outside the field of view. Twofold oversampling halves the sample spacing \(\Delta k_x\), which doubles the field of view along the readout; the extent of k-space, and so the spatial resolution, is unchanged.

The acquisition has 128 phase-encoding lines and 256 readout samples at half the phase-encoding spacing, in the grid units of a 128 matrix. The first trajectory component is \(k_x\), along the last image axis.

MATRIX = 128
phase_encodes = (torch.arange(MATRIX) - MATRIX // 2).float()
readout = (torch.arange(2 * MATRIX) - MATRIX).float() / 2
ky, kx = torch.meshgrid(phase_encodes, readout, indexing="ij")
trajectory = torch.stack([kx, ky, torch.zeros_like(kx)], dim=-1)

oversampled = bt.phantom(traj=trajectory).reshape(MATRIX, 2 * MATRIX)

bartorch.remove_readout_oversampling() transforms the readout to the image domain, crops the field of view to the prescribed matrix and transforms back. Discarding the outer half of the readout samples instead keeps the doubled field of view and halves the resolution along the readout. The crop is compared with an acquisition without oversampling; the crop uses the unitary transform, whose normalization depends on the number of samples, so the two differ by the factor \(\sqrt{2}\) between the transform lengths.

cropped = bartorch.remove_readout_oversampling(oversampled, MATRIX, axis=-1) / math.sqrt(2)
truncated = bartorch.resize(oversampled, (MATRIX, MATRIX)) / math.sqrt(2)
reference = bt.phantom(MATRIX, kspace=True)


def image_of(kspace):
    return bartorch.fft(kspace, axes=(-2, -1), inverse=True).abs()


def nrmse(estimate, target):
    return float((estimate - target).norm() / target.norm())


print(f"crop against no oversampling: NRMSE {nrmse(image_of(cropped), image_of(reference)):.1e}")
crop against no oversampling: NRMSE 4.8e-03
  • 256 readout samples
  • no oversampling, image-domain crop, crop - no oversampling, k-space truncation

The image-domain crop reproduces the acquisition without oversampling to the part of the truncation ringing that extends past the prescribed field of view along the readout and is cropped with it. Discarding k-space samples instead widens the pixel to twice the prescribed size along \(x\): the object occupies half of the matrix.

Apodization#

An acquisition truncated at the edge of k-space is the object’s spectrum multiplied by a rectangle, and its image the object convolved with a sinc. The first side lobe of the sinc is 22 % of its peak, which appears as an overshoot of about 9 % at a step edge and as ringing that decays over a few pixels. An apodization window rolls the data off towards the edge of k-space, which lowers the side lobes and widens the main lobe of the point spread function [1].

The phantom is acquired on a 64 matrix and reconstructed on a 128 grid by zero-filling, which interpolates the image and makes the ringing visible between the pixels of the acquired grid.

GRID, ACQUIRED = 128, 64
image = bt.phantom(GRID).abs()
measured = bt.phantom(ACQUIRED, kspace=True)


def reconstruct(kspace):
    return bartorch.fft(bartorch.resize(kspace, (GRID, GRID)), axes=(-2, -1), inverse=True).abs()

bartorch.fermi_window() sets the radius of the half height and the width of the transition separately, and keeps a wide passband. bartorch.hann_window() tapers from the centre of k-space to zero at the edge. The point spread functions are evaluated on a grid eight times finer than the acquired one: the side lobes of an unwindowed acquisition have their zeros at the pixels of the acquired grid, so a point spread function read off that grid shows none.

fermi = bartorch.fermi_window((ACQUIRED, ACQUIRED), radius=0.8, width=0.08)
hann = bartorch.hann_window((ACQUIRED, ACQUIRED))
windows = {"no window": torch.ones(ACQUIRED, ACQUIRED), "Fermi": fermi, "Hann": hann}

UPSAMPLE = 8
offset = (torch.arange(UPSAMPLE * ACQUIRED) - UPSAMPLE * ACQUIRED // 2) / UPSAMPLE


def psf(window):
    fine = bartorch.resize(window.to(torch.complex64), (UPSAMPLE * ACQUIRED,) * 2)
    profile = bartorch.fft(fine, axes=(-2, -1), inverse=True).abs()[UPSAMPLE * ACQUIRED // 2]
    return profile / profile.max()


def fwhm(profile):
    above = torch.nonzero(profile > 0.5).flatten()
    return float(offset[above[-1]] - offset[above[0]])

bartorch.apodize() multiplies k-space by either window over the axes it names. The ringing is measured as the standard deviation of the image over the parenchyma within six pixels of the skull, where the object is uniform, and the resolution as the full width at half maximum of the point spread function, in pixels of the acquired grid.

reconstructions = {
    "no window": reconstruct(measured),
    "Fermi": reconstruct(bartorch.apodize(measured, kind="fermi", radius=0.8, width=0.08)),
    "Hann": reconstruct(bartorch.apodize(measured, kind="hann")),
}

skull = (image > 0.9).float()[None, None]
near_skull = torch.nn.functional.max_pool2d(skull, 13, stride=1, padding=6)[0, 0] > 0
flat = torch.nn.functional.max_pool2d(
    (image - 0.2).abs().gt(1e-3).float()[None, None], 5, stride=1, padding=2
)[0, 0].eq(0)
ringing_region = near_skull & flat

print(f"{'window':>9}  {'ringing':>7}  {'side lobe':>9}  {'FWHM':>7}")
metrics = {}
for name, window in windows.items():
    profile = psf(window)
    ringing = float(reconstructions[name][ringing_region].std())
    lobe = float(profile[offset.abs() > 3].max())
    metrics[name] = (ringing, fwhm(profile))
    print(f"{name:>9}  {ringing:7.4f}  {lobe:9.1e}  {fwhm(profile):5.2f} px")
   window  ringing  side lobe     FWHM
no window   0.0404    9.1e-02   1.00 px
    Fermi   0.0325    2.6e-02   1.50 px
     Hann   0.0100    5.6e-03   2.00 px
  • object, no window, FWHM 1.00 px, Fermi, FWHM 1.50 px, Hann, FWHM 2.00 px
  • |PSF|, row 64, across the skull

The zoomed panels show the left edge of the skull, displayed from zero to half the skull intensity. Without a window the ringing is visible as bands parallel to the skull across the adjacent parenchyma. The Fermi window lowers the far side lobes by a factor of three and the ringing next to the skull by about a fifth, and widens the point spread function by half a pixel; the Hann window removes the ringing almost entirely and doubles the full width at half maximum, which blurs the skull into the parenchyma. The Fermi window’s wide passband is the usual compromise for anatomical imaging.

Radial and separable windows#

The one-dimensional kernel is extended over k-space either on the Euclidean norm of the normalized coordinates (geometry="radial", an ellipse) or as a product along each axis (geometry="separable"), which retains more of the corners of k-space. Bernstein et al. [1] give the ratio of the two at the diagonal Nyquist point as 52.4 % in two dimensions and 50.7 % in three, for a Fermi window of transition width 10/128. The radial window is 0.5 there; the separable one is the product of the one-dimensional kernel at \(1/\sqrt{d}\) along each of \(d\) axes. The one-dimensional kernel at \(u\) is the window at the centre of a grid with its radius moved to \(1 - u\).

TRANSITION = 10.0 / 128


def kernel(u):
    return float(bartorch.fermi_window((4, 4), radius=1.0 - u, width=TRANSITION)[2, 2])


for dimensions in (2, 3):
    separable = kernel(1 / math.sqrt(dimensions)) ** dimensions
    print(f"{dimensions}D: radial / separable at the diagonal {100 * 0.5 / separable:.1f} %")
2D: radial / separable at the diagonal 52.4 %
3D: radial / separable at the diagonal 50.7 %
  • radial, separable
  • window along the diagonal

The radial window has the more isotropic point spread function and the higher signal-to-noise ratio; the separable one the better resolution along the diagonals. Both normalize each axis to its own Nyquist edge, so a rectangular matrix receives an ellipse matched to its grid.

References#

Total running time of the script: (0 minutes 0.814 seconds)

Gallery generated by Sphinx-Gallery