3. Coil sensitivities#

Open in Colab

In lesson 2 you estimated the coil sensitivities with ESPIRiT from a block of 24 central lines and passed them to pics without examining them. SENSE [1] solves

\[y_c = P F (S_c \, x)\]

for the image \(x\) with the sensitivities \(S_c\) taken as known. Whatever is wrong in \(S_c\) ends up in the image, as residual aliasing or as shading, however good the solver.

On a scanner the sensitivities come either from a separate low-resolution prescan or, as here, from the scan itself: a fully sampled block of lines at the centre of k-space, the autocalibration (ACS) region. In this lesson you estimate them in three ways, reconstruct with each, and then shrink the ACS region to see which of the three still works.

Learning objectives

Previous: 2. From k-space to an image. Next: 4. Joint reconstruction of image and sensitivity maps, where you reconstruct the image and the sensitivities together and write that reconstruction out as an operator and a solver.

Simulated acquisition#

The object is BART’s Shepp-Logan phantom, seen through BART’s analytical eight-channel head coil. As in lesson 1, you ask for its k-space directly (kspace=True) rather than transforming a sampled image, so the data do not share the discretization of the reconstructions below. Then you add complex Gaussian noise of variance 2 to every sample.

import torch

import bartorch
import bartorch.tools as bt

from bartorch import apps

SIZE = 128
COILS = 8
ACCELERATION = 3

clean = bt.phantom(SIZE, coils=COILS, kspace=True)
kspace = bt.noise(clean, n=2.0, s=3)

You acquire every third phase encode, plus a central block of calibration lines in full. Regular undersampling by \(R\) replicates the point spread function \(R\) times across the field of view, so each voxel of the zero-filled image is the sum of three voxels a third of the field of view apart. Only the sensitivities, which differ between those three voxels, can separate them.

encodes = torch.arange(SIZE) - SIZE // 2


def acquire(calibration):
    """Every third line, and a fully sampled block at the centre."""
    acs = encodes.abs() < calibration // 2
    lines = (encodes % ACCELERATION == 0) | acs
    return kspace * lines.to(torch.complex64).reshape(SIZE, 1)


measured = acquire(24)
print(f"{float(bt.pattern(measured).real.mean()):.0%} of k-space acquired")

channel_images = bartorch.ifft(measured, axes=(-2, -1))
zero_filled = bartorch.rss(channel_images, axes=(0,))[0]
reference, zero-filled
46% of k-space acquired

The images are windowed at half the intensity of the skull, which saturates. In the zero-filled image you see the three overlapping copies of the phantom that regular undersampling makes. The reference on the left is the root sum of squares of the fully sampled, noise-free data; every error below is the NRMSE of a magnitude image against it, inside the phantom, after fitting one global scale, because the three estimators normalize the maps, and so the image, differently.

Sensitivity estimation#

Directly from the ACS lines. bartorch.tools.caldir() reconstructs a low-resolution image per channel from the ACS region alone and divides each by their root sum of squares. It is the simplest estimate, and its resolution is the resolution of the ACS block:

direct = bt.caldir(measured, 24)

ESPIRiT [2] slides a small kernel over the ACS region, builds a calibration matrix from every position of it, and takes the sensitivities as the eigenvectors of an operator derived from that matrix. crop sets the maps to zero where the eigenvalue falls below it, which keeps the background out of the reconstruction:

espirit = bt.ecalib(measured, maps=1, calib_size=24, crop=0.8)

Nonlinear inversion [3] estimates the sensitivities and the image together, from every acquired sample; the ACS region is just part of the data. maxiter counts Gauss-Newton steps, and because the regularization shrinks with every step, the count acts as a regularization parameter; twelve suit this noise level. You divide its maps by their root sum of squares, which is the normalization the other two use. Lesson 4 is about this method.

joint, estimated = bt.nlinv(measured, maxiter=12, return_sensitivities=True)

rss_maps = bartorch.rss(estimated, axes=(0,), keepdim=True)
nonlinear = estimated / rss_maps.abs().clamp(min=1e-6)

print(f"caldir {tuple(direct.shape)}")
print(f"ecalib {tuple(espirit.shape)}")
print(f"nlinv  {tuple(nonlinear.shape)}")
caldir, channel 2, ESPIRiT, channel 2, nlinv, channel 2
caldir (8, 1, 128, 128)
ecalib (8, 1, 128, 128)
nlinv  (8, 1, 128, 128)

Here is channel 2 from each: brightness is the magnitude and colour the phase. The phase of a set of maps is only defined up to a phase common to all channels, which each estimator fixes its own way; that common phase passes into the phase of the image and leaves its magnitude alone. Up to it, the three agree inside the object. Outside it the data say nothing about a sensitivity, and the estimators return different values there: caldir divides noise by noise and returns an arbitrary value of unit magnitude, ESPIRiT sets the maps to zero, and nlinv continues the smooth function its regularization favours.

SENSE and map quality#

Now you give each set to bartorch.apps.pics() with the same Tikhonov weight and the same number of conjugate-gradient iterations, so the only thing that differs is the maps. The image nlinv returned with its maps is a fourth estimate.

reconstructions = {
    name: apps.pics(measured, maps, l2=0.001, maxiter=40)
    for name, maps in (
        ("caldir", direct),
        ("ESPIRiT", espirit),
        ("nlinv", nonlinear),
    )
}

for name, estimate in reconstructions.items():
    print(f"{name:>12}  NRMSE {error(estimate):.3f}")
print(f"{'nlinv image':>12}  NRMSE {error(joint):.3f}")
  • reference, zero-filled, nlinv image, SENSE, caldir, SENSE, ESPIRiT, SENSE, nlinv
  • SENSE, caldir, SENSE, ESPIRiT, SENSE, nlinv
     caldir  NRMSE 0.025
    ESPIRiT  NRMSE 0.015
      nlinv  NRMSE 0.012
nlinv image  NRMSE 0.011

All three sets of maps unfold the three copies, and so does nlinv on its own. The error maps, at 3 % of the image peak, show what is left. The direct estimate leaves a faint residual fold at the edges of the phantom, where its low-resolution maps are least accurate; ESPIRiT and nonlinear inversion leave mostly noise.

ACS size#

Now you keep only eight central lines. ESPIRiT’s kernel is six samples wide by default, so eight lines leave it three positions along the phase-encoding axis, and its calibration matrix has too few rows. caldir still runs, on an image of eight lines’ resolution. nlinv uses every acquired sample, so the ACS region affects it only through its first Gauss-Newton steps.

scarce = acquire(8)

espirit = bt.ecalib(scarce, maps=1, calib_size=8, crop=0.8)
covered = float((espirit.abs() > 0).float().mean())
print(f"ESPIRiT: {covered:.0%} of voxels with a sensitivity")

direct = bt.caldir(scarce, 8)
joint, estimated = bt.nlinv(scarce, maxiter=12, return_sensitivities=True)
rss_maps = bartorch.rss(estimated, axes=(0,), keepdim=True)
nonlinear = estimated / rss_maps.abs().clamp(min=1e-6)

scarce_reconstructions = {
    "caldir": apps.pics(scarce, direct, l2=0.001, maxiter=40),
    "nlinv": apps.pics(scarce, nonlinear, l2=0.001, maxiter=40),
    "nlinv image": joint,
}
for name, estimate in scarce_reconstructions.items():
    print(f"{name:>12}  NRMSE {error(estimate):.3f}")
  • reference, SENSE, caldir, SENSE, nlinv
  • SENSE, caldir, SENSE, nlinv
ESPIRiT: 0% of voxels with a sensitivity
      caldir  NRMSE 0.173
       nlinv  NRMSE 0.040
 nlinv image  NRMSE 0.033

ESPIRiT returns maps that are zero in every voxel, which no reconstruction can use. With the direct estimate you see residual aliasing: maps at the resolution of eight lines do not follow the coil profiles closely enough to unfold three copies, and the folded edges of the skull reappear inside the phantom (the error maps are now at 10 % of the peak). The maps from nonlinear inversion still unfold the image, because they are fitted to all the acquired samples. The same estimator works on non-Cartesian data, where there is no Cartesian ACS region at all, as bartorch.tools.ncalib(), which you use in lesson 8 (8. Radial SENSE).

These errors are for one phantom, one noise level and one sampling pattern, and they depend on how each reconstruction is regularized; they do not rank the estimators in general.

Exercise: ACS size#

Between eight and 24 lines the outcome changes. Sweep the size of the ACS region for the two estimators that use only the ACS block, and compare how their errors depend on it:

sizes = (12, 16, 24, 32)
scores = {"caldir": [], "ESPIRiT": []}
for lines in sizes:
    data = acquire(lines)
    maps = {
        "caldir": bt.caldir(data, lines),
        "ESPIRiT": bt.ecalib(data, maps=1, calib_size=lines, crop=0.8),
    }
    for name, estimate in maps.items():
        image = apps.pics(data, estimate, l2=0.001, maxiter=40)
        scores[name].append(error(image))
01 coil calibration
12 lines: caldir 0.071, ESPIRiT 0.019
16 lines: caldir 0.053, ESPIRiT 0.014
24 lines: caldir 0.025, ESPIRiT 0.015
32 lines: caldir 0.020, ESPIRiT 0.013

The ESPIRiT error is close to its 24-line value from twelve lines on: once the kernel has enough positions in the ACS region, more lines add little. The caldir error keeps falling as the region grows, because its maps are only as sharp as the ACS image they are computed from, and it stays above ESPIRiT’s at every size here. Try crop values between 0.5 and 0.95 for ESPIRiT, or another maxiter for nlinv at eight lines, and see how the errors move.

As a spec#

What this lesson built, stated the way you would ask an agent for it:

With bartorch, simulate 8-channel analytical k-space of a 128x128
Shepp-Logan phantom (bt.phantom(coils=8, kspace=True)) with noise of
variance 2. Keep every third phase encode plus a fully sampled central
ACS block. Estimate sensitivities three ways: bt.caldir, bt.ecalib
(one map, crop 0.8) and bt.nlinv (12 Gauss-Newton steps, maps divided
by their root sum of squares). Reconstruct with apps.pics (l2=0.001,
40 CG iterations) using each set, and report the NRMSE of the magnitude
inside the phantom against the noise-free RSS image, after a global
scale. Repeat with 24 and with 8 ACS lines.

References#

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

Gallery generated by Sphinx-Gallery