Skip to content

Fit nlinv maps on a low-resolution grid, with optional phase-pole correction - #150

Merged
mcencini merged 5 commits into
mainfrom
nlinv-phase-pole
Oct 14, 2026
Merged

mcencini merged 5 commits into
mainfrom
nlinv-phase-pole

Conversation

@mcencini

@mcencini mcencini commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

What failed

  • Shared phase poles. One set of nlinv maps (apps.nlinv_maps, nlinv -m 1) can share a phase vortex. The maps are smooth, so every coil's sensitivity is driven to zero there, and any combination over them goes dark: the adjoint, or a pics solve.
  • Full-resolution Cartesian fit. The Cartesian fit zero-filled the central size lines into the full matrix, so nlinv and the pole detection ran at full resolution: 14 s for a 220x220 slice with 32 coils. BART's pole circle is a twentieth of the grid's longest axis, rounded up, so on that grid it refused any slab of up to 4 partitions on a 64-point plane.
  • Full-resolution non-Cartesian fit. The non-Cartesian fit zeroed the samples beyond radius but kept their trajectory. nlinv -t therefore sized its grid from the whole trajectory, ran at full resolution, and took the zeroed samples for measured zeros, which pulls the maps away from the coils. Three-dimensional trajectories were not supported at all: the reshape assumed a plane.

Change

  • Phase-pole option. apps.nlinv_maps(..., phase_pole=None): None keeps BART's behaviour; an int d is nlinv --phase-pole d, detection after Gauss-Newton step d, with 0 meaning every step. apps.nlinv_pics forwards it.
  • Cartesian. The central min(size, n) lines of every axis are cropped (centre at n // 2), and nlinv runs on that grid.
  • Non-Cartesian.
    • Only the samples within radius are kept, and nlinv -t runs on an explicit grid of 2 ceil(radius) points per axis (-x, which BART honours).
    • Two- and three-dimensional trajectories are both supported.
    • New shape argument: the image grid of the maps, (y, x) or (z, y, x). By default it is the grid BART estimates from the whole trajectory, as before.
  • Common to both paths.
    • The maps are zero-filled in k-space to the image grid and normalised to unit root sum of squares. Output shape and normalisation are unchanged.
    • BART's Sobolev weight (1 + a |k/n|^2)^(-b/2) is relative to the grid, so a is scaled by (m/n)^2 along the shortest cropped axis (m points kept of the axis's n).
    • With BART's own a, small-grid Cartesian maps came out much smoother than before: 0.38 from parallel to the full-grid maps at the worst voxel of a 64-point phantom.
  • Thin grids. BART still refuses the detection on a grid with an axis no longer than a twentieth of its longest, rounded up. On the calibration grid at the default size=24, that means a slab of fewer than 3 partitions. This is documented, under Raises too.
  • Tests:
    • The option reaches BART (its pole-detection log line appears with it and never without), for a slice, a volume and a trajectory.
    • The thin-volume edge, 2 partitions refused and 3 accepted, on the 24 central lines of a 64-point plane.
    • Cartesian calibration-grid maps match the full-grid fit: within 2e-2 of parallel at every object voxel, 1e-3 at the median.
    • Radial-plane small-grid maps match the whole-grid fit: within 2e-2 of parallel everywhere, 1e-3 at the median.
    • On a 32-cube kooshball, small-grid maps agree with the whole-grid fit to 0.99 at the median, and are closer to the true coils at the worst voxel (0.77 vs 0.74 from parallel).
    • The nlinv_pics trajectory test now runs on one thread. Its 32 spokes on a 16-point grid fit loosely enough that the order of a threaded sum moved the maps by 2e-2. It failed once at 4.4e-3 against a 1e-3 tolerance, and is bit-exact on one thread.
    • None of the synthetic coil sets I tried gave maps with a shared pole, so the correction itself was checked only on real data (below).

Measurements (CPU)

Old means the full-grid fit; new means the small-grid fit. |<old,new>| is the per-voxel |sum_c conj(old_c) new_c| (1 = parallel). "vs. true coils" is the worst voxel of the same quantity against the sensitivities that made the data.

Cartesian

Case Fit time, old / new |<old,new>| min / median adj/rss min, old / new
64x64 phantom, 8 coils 0.28 s / 0.06 s 0.986 / 0.9991 0.907 / 0.946
16x64x64 slab, 8 coils 1.44 s / 0.21 s 0.984 / 0.995 0.681 / 0.697
7 T phantom slice, 220x220, 32 coils 13.7 s / 0.14 s 0.10 / 0.9995 0.105 / 0.769
same slice, phase_pole=0 29.0 s / 0.36 s 0.944 / 0.9995 0.938 / 0.982

Non-Cartesian (synthetic, radius=12)

Case Fit time, old / new |<old,new>| min / median vs. true coils, old / new
radial plane 128, 8 coils, 201 spokes 2.16 s / 0.12 s 0.985 / 0.9996 0.988 / 0.990
radial plane 256, 8 coils, 403 spokes 7.64 s / 0.23 s 0.991 / 0.9997 0.994 / 0.993
same, phase_pole=0 25.2 s / 0.18 s 0.993 / 0.9996 0.995 / 0.997
kooshball 48³, 8 coils, 2000 spokes 13.2 s / 1.19 s 0.852 / 0.968 0.639 / 0.816
kooshball 96³, 8 coils, 9000 spokes 96.9 s / 2.20 s 0.607 / 0.897 0.531 / 0.929
same, phase_pole=0 171.8 s / 3.57 s 0.605 / 0.896 0.529 / 0.929

In three dimensions the two fits disagree because the old one is the worse of the two: it fitted the zeroed samples as data.

Phase-pole detection on the Cartesian path

Volume phase_pole=None phase_pole=0
2x64x64 0.09 s refused
3x64x64 0.19 s 0.30 s
16x64x64 0.19 s 0.85 s
32x128x128 0.31 s 1.30 s

The default stays off, which keeps BART's behaviour, avoids the cost where the maps share no pole, and avoids the refusal on thin grids. Callers whose data show the spots ask for it; pulserver/pulserver#282 does, and needs this in a release.

Tests

These ran against the v0.0.15 library (no C changed):

  • tests/test_apps.py, tests/test_docstrings.py (with numpydoc), tests/test_docs.py, tests/test_namespace.py, tests/test_options.py, tests/test_tools.py: 293 passed, 3 skipped (2 need CUDA, 1 in test_tools). The nlinv tests passed 3 runs in a row.
  • scripts/lint.sh (ruff, format, codespell): clean.

I did not run the docs build.

🤖 Generated with Claude Code

One set of nlinv maps can share a phase vortex. The maps are smooth, so at
the vortex every coil's sensitivity is driven towards zero, and any coil
combination over them, the adjoint or a pics solve, shows a dark spot there.
On a 32-channel 7 T phantom slice the adjoint combination over the maps fell
to 0.105 of the root sum of squares inside the object. BART's
`nlinv --phase-pole` detects the shared poles and moves them into the image;
after every Gauss-Newton step it lifts that minimum to 0.938, after the fifth
it leaves it at 0.105. nlinv_maps and nlinv_pics now take `phase_pole` and
forward it on the Cartesian and the trajectory path. It stays off by
default, as in BART: it doubles the time of the fit on the CPU (14 s to 29 s
on that slice) and changes nothing where the maps share no pole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Asked for phase-pole correction, nlinv on a volume with an axis no longer
than its detection circle, a twentieth of the longest axis rounded up, fails
on an assertion in BART's pole.c (compute_curl_map_normal): a 3D slab of four
partitions on a 64-point plane is refused, one of five is not. nlinv_maps
documents the limit and the BartError it raises, and a test pins the edge on
a 24-point plane (two partitions refused, three accepted), so that a caller
asking for the correction knows which volumes it can ask it for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nlinv_maps zero-filled the central `size` lines into the full matrix and ran
nlinv there, so the fit and its phase-pole detection worked at full
resolution: 14 s for one 220x220, 32-coil slice (29 s with the detection),
and the detection circle, a twentieth of the longest axis of that grid,
refused any slab of up to 4 partitions on a 64-point plane. The central
lines are now cropped and fitted on their own grid, and the maps are
zero-filled in k-space back to the grid of the data. BART's Sobolev
weighting is relative to the grid, so `a` is scaled by the square of the
crop ratio along the shortest cropped axis; with BART's own `a` the maps
came out smoother than before (0.38 from parallel at the worst voxel of a
64-point phantom), and with the scaled one they agree with the full-grid
maps to 1.4e-2 at worst and 1e-3 at the median. On the 7 T slice the fit
takes 0.14 s (0.36 s with the detection), and on a 24-line grid the
detection needs 3 partitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mcencini mcencini changed the title Let nlinv_maps ask BART for phase-pole correction Fit Cartesian nlinv maps on the calibration grid, with optional phase-pole correction Oct 14, 2026
nlinv_maps zeroed the samples beyond `radius` but kept their trajectory, so
`nlinv -t` sized its grid from the whole trajectory and fitted at full
resolution, and took the zeroed samples for measurements of zero, which
pulls the maps towards a band-limited product. It was slow (7.6 s for a
256-point radial plane of 8 coils, 97 s for a 96-cube kooshball) and in
three dimensions far from the coils (0.53 from parallel at the worst voxel
of that cube). The samples within `radius` are now kept alone and fitted on
a grid of 2 ceil(radius) points an axis, with the Sobolev `a` scaled as the
Cartesian path scales it, and the maps zero-filled in k-space to the image
grid: 0.23 s and 2.2 s, and 0.93 at the worst voxel of the cube; in a plane
the maps agree with the whole-grid fit to 1e-2 at worst and 3e-4 at the
median. A three-dimensional trajectory now works, with `shape` naming the
image grid where BART's estimate from the trajectory is not the one wanted.
The trajectory test of nlinv_pics runs on one thread: 32 spokes on a
16-point grid leave the fit loose enough that the order of a threaded sum
moved it past the tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mcencini mcencini changed the title Fit Cartesian nlinv maps on the calibration grid, with optional phase-pole correction Fit nlinv maps on a low-resolution grid, with optional phase-pole correction Oct 14, 2026
…ntime prints

The thin-volume test matched the refusal by the function name in BART's
assertion message, which glibc prints and the Windows C runtime does not
("Assertion '0 < odims[i]' failed in .../noir/pole.c:123"), so it failed on
both Windows jobs. It now matches the asserted condition, which both print.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mcencini
mcencini merged commit d9be3d7 into main Oct 14, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant