Expand description
iLSQR: LSQR dipole inversion with streaking-artefact removal
ilsqr implements STI Suite 3.0’s QSM_iLSQR (Li et al. 2015), as called with
QSM_iLSQR(phase, mask, 'TE', TE, 'B0', B0, 'H', H, 'padsize', [p p p], 'voxelsize', vs)
with p = IlsqrParams::pad_mm. The defaults are STI_DEFAULT_NITER = 100 iterations,
QSM_iLSQR’s own default, which applies when it is called as a function without 'niter'
(as UK Biobank’s pipeline and STI Suite’s STI_Templates.m call it), and STI_GUI_PAD_MM
= 12 mm of padding, what STI Suite 3.0’s QSM_GUI passes (see IlsqrParams). The GUI
passes STI_GUI_NITER = 40 iterations. Called without 'padsize', QSM_iLSQR pads STI_DEFAULT_PAD_MM = 6;
UK Biobank and STI Suite’s examples pass 64, STI_PAD_MM.
STI Suite ships as protected P-code, so the algorithm was recovered by black-box probing
(call tracing, capturing the operators and right-hand sides it hands to MATLAB’s lsqr, and
fitting every stage until a MATLAB re-implementation reproduced STI to its own
single-precision noise floor):
- Crop field and mask to the mask’s bounding box (odd extents grown by one voxel to make
them even), then zero-pad each axis to the next multiple of 16 strictly above
crop + 2·pad_mm/voxel_size(defaultpad_mm= 12). Everything below runs on that grid. - Laplacian weights
W:L = |F⁻¹(k²·F φ)|withk²in (cycles/mm)² and the DC term set to 10⁶ (STI’s value, which adds10⁶·mean(φ)to every voxel, so the|·|decides which tail of the Laplacian is down-weighted). Ranked inside the mask:W = 1below the 60th percentile, falling linearly to0.2at the 99.9th,0outside. The field itself is not re-masked. - Initial solution: LSQR on
D W D χ = D W φ(D= dipole convolution), tolerance 0.01, at mostmax_iteriterations (MATLABlsqrstopping rules). - FastQSM prior:
sign(D)·Fφ, blended where|D|is small with a copy smoothed in k-space by a radius-3 ball closed with IPT’simclose(·, [1 1 1])(which, on this ball, only fills its centre), masked, blended again, rescaled to a TKD (threshold 1/8) solution by least squares inside the mask. - Gradient weights
G_d(d= x, y, z): forward differences of the FastQSM map (no voxel-size scaling), pooled over the three axes inside the mask;G = 1below the 50th percentile of|∇|,0above the 70th, linear between. - Streaking artefacts: LSQR for
yinmin ‖G ∇ F⁻¹(M_ic y) − G ∇(M·Re χ₁)‖withM_ic = |D| < 0.1(the ill-conditioned cone), tolerancetol(default 1e-3), at mostmax_iteriterations; artefactχ_sa = Re F⁻¹(M_ic y). χ = (Re χ₁ − χ_sa) · mask, cropped back to the input grid.
Percentiles are STI’s: the sorted value at 1-based index round(p·N), not interpolated.
The field may be in any linear unit (rad, Hz, ppm); χ comes out in the unit the field
would have for χ = 1 (QSM.rs passes ppm, so χ is in ppm).
§Performance and precision
Nearly all the time goes into the two LSQR solves, each costing two 3-D FFTs on the padded grid per operator call. The initial solve runs in k-space (2 FFTs per call instead of 4, the same iterates up to rounding), the transforms are pruned to the box holding the mask and carry the solves’ diagonal products in their first and last passes, and the artefact solve stores its vectors on the cone and the mask only. On a UK Biobank SWI field (352×400×96 padded) this takes ~15 s on 16 cores with a 1.9 GB peak. Against the image-space formulation of the initial solve, the k-space one gives the same stop flags and iteration counts in both solves there, and χ within a relative L2 of 5.8e-9 (the artefact solve amplifying rounding-level differences of the initial solution, which agree to 2.7e-15).
By default everything runs in double precision. STI Suite runs its two LSQR solves in single
precision: their operators (single FFTs, single(D), single(W), single(G)), and
MATLAB’s lsqr on single vectors and scalars, with the right-hand sides computed in double
and rounded to single (probed: STI’s initial solve is reproduced to 2e-8 that way, against
7e-4 in double). IlsqrPrecision::Single does the same, for parity with STI; it runs the
two solves on dense vectors over the padded grid (no pruned transforms), so it is slower
and needs more memory. Double precision stays the default (QSM.rs keeps double where the
reference uses single and it does no harm): on whole heads both agree with STI equally
well (QSM-CI’s sim and KKI travelling head: 0.0024 % and 0.017 % NRMSE in either
precision). LSQR stopped after a fixed number of iterations amplifies rounding, though, so
on small noisy volumes only single precision follows STI (below: 0.17 % against 10.4 %).
§Agreement with STI Suite
tests/ilsqr_sti_parity.rs (measured 2026-10-30; re-measured 2026-11-02 after the dipole
kernel’s rounding, below):
- Against the re-implementation of
QSM_iLSQRthe algorithm was recovered with (tests/data/ilsqr_sti_clone.m, double precision, 5 iterations per solve): max |Δ| 5.7e-14 ppm on a 20×20×14 phantom (noise, phase outside the mask). This pins the algorithm. - Against STI’s own output on that phantom (licensed MATLAB R2023b, 100 iterations):
IlsqrPrecision::Singleχ NRMSE 0.17 %, initial LSQR solution 0.010 %; double precision χ 10.4 % (r 0.9950), initial 0.36 %. - Against the verified QSM-CI
ilsqr-sticontainer (QSMxT/QSM-CI#304) at its call (100 iterations,padsize12 and 8) on two noise-free 32×32×24 / 32×28×28 phantoms: double NRMSE 0.12 % and 0.071 % (r 0.9999993, 0.9999997); single 0.032 % and 0.0055 % (0.019 % before the dipole kernel’s rounding), at or below STI’s own spread between CPUs (its χ moves by 3.6e-4 and 1.2e-4 relative between the workstation that made the fixture and a GitHub-hosted runner). That fixture is regenerated in Actions bytests/data/ilsqr_sti_qsmci_small.py. - Inside QSMART (
tests/qsmart_qsmci_parity.rs, 2026-11-02: QSMART’s call, 50 iterations, STI’s default padding, field in rad/s, on the QSMART toolbox’s own SDF local fields of a 36×36×28 vessel phantom): 1.0e-4 % and 1.0e-3 % NRMSE for the two stages, once QSMART’s own γ mismatch is applied (it makes the field with γ = 2.675e8 rad/s/T and STI turns it back with 2π·42.575 MHz/T, so the original’s χ is 2.2e-5 low; QSM.rs keeps one γ and does not reproduce that: 2.5e-3 % without it). That is STI’s single-precision floor (its own χ moves by 1.3e-4 % / 1.3e-3 % between CPUs there).
Dipole kernel rounding (astewartau/QSM.rs#396, 2026-11-02). Until then the kernel’s
k-space coordinates were [crate::fft::fftfreq]’s, i·(1/(n·vs)); STI computes
i/(n·vs). The two round differently, and on the lattice points of the magic angle
(k_x² + k_y² = 2k_z² for H = z) that decides whether D is 0 or −5.6e-17. FastQSM
takes sign(D), so the FastQSM prior and from it the gradient weights moved (at ~1,600
voxels per axis on the QSMART phantom), while the initial solution agreed to 1e-10 %.
Inside QSMART (48³ grid, 64 of its 266 magic-angle points differing in sign) χ was 0.15 %
and 0.094 % NRMSE from STI’s for the two stages, in either precision; found by dumping the
MATLAB re-implementation’s intermediates on QSMART’s call (which reproduces STI there to
2.6e-5 % / 5.7e-4 %; with fftfreq’s coordinates it gives the same 0.15 % / 0.094 %).
Neither sign pattern is better than the other (both are rounding), so the kernel follows
STI’s arithmetic (sti_dipole_kernel).
Until 2026-10-30 the FastQSM kernel was closed without IPT’s padding (−∞ beyond the array
for the dilation, +∞ for the erosion, on the unpadded 7³ ball), which kept 32 dilated
voxels at the ball’s ±y caps (179 non-zeros against imclose’s 147). FastQSM moved by 2 %,
its gradient weights at ~800 voxels per axis, and χ by 3.4 % / 1.6 % NRMSE on the container
phantoms, 0.99 % on QSM-CI’s sim and 1.78 % on the KKI travelling head; with the closing
fixed, 0.0024 % and 0.017 % (QSMxT/QSM-CI parity runs 41019608126 → 41023156365,
astewartau/QSM.rs#329). QSMART, which calls this iLSQR, moved from 16.71 % to 16.64 % on sim
against the original QSMART (4.27 % on KKI). The licensed fixture above had been made with an imclose stand-in
of the same kind, which hid it; it was regenerated with MATLAB’s own imclose.
§Padding
IlsqrParams::pad_mm is STI’s 'padsize': millimetres of zero padding per side, the same
on every axis. It sets the grid that both LSQR solves run on, so for small or thin volumes it
dominates the cost: an 8³ volume at 1 mm is solved on 144³ with 64 mm, on 32³ with 6 mm.
The default, STI_GUI_PAD_MM (12), is what STI Suite’s QSM_GUI passes (and what
STI_Templates.m and the χ-separation toolbox’s Chisep_script.m use).
STI_DEFAULT_PAD_MM (6) is what QSM_iLSQR uses when called without 'padsize' (as
QSMART calls it). STI_PAD_MM (64) is what UK Biobank’s pipeline and STI Suite’s examples
pass (set it to reproduce them exactly). 0 is allowed (the grid is
still rounded up past the crop to a multiple of 16) but wraps thin volumes onto themselves.
The padding changes χ because the dipole convolution is circular on the padded grid and the
Laplacian and gradient weights are ranked over it.
On 150 UK Biobank subjects × 2 visits, the qsm-forward head phantom and thin slabs, comparing 0–128 mm (ukb-qsmxt docs/19), no test-retest, age or longitudinal metric differed from 64 mm. Above 32 mm the result only moves by the floor set by the capped LSQR iterations (32 and 128 mm are each ~2 ppb RMS from 64 mm). On a thin phantom slab 0–6 mm lose ~1.5 NRMSE points against the ground truth and 16 mm ~0.5; on whole heads every pad gave the same accuracy.
QSM.rs ≤ v0.38 shipped a different iLSQR (a port of QSM.m’s formulation: no padding, LSMR
for the artefacts, other weights). It was removed: STI Suite’s algorithm is the reference
iLSQR, and it is also what the original QSMART calls (see crate::pipeline::run_qsmart).
§Reference pipeline
What STI Suite 3.0 runs around QSM_iLSQR. Its GUI and functions are P-code; these facts
were probed in MATLAB R2023b by driving QSM_GUI headless with logging wrappers on the
functions it calls (3-echo synthetic head, TE 4/10/16 ms, 1×1×1.5 mm):
- Chain (the GUI’s “3D GRE data” then “iLSQR” buttons):
MRPhaseUnwrapon the raw, unmasked phase of every echo (defaultpadsize, 12 voxels), the unwrapped echoes summed,V_SHARP(Σφ, mask, 'smvsize', 12)(12 is the GUI’s “Radius” field default), thenQSM_iLSQRon V-SHARP’s eroded mask. Seecrate::unwrap::laplacianandcrate::bgremove::vsharpfor those stages. - TE: the GUI passes
TE= the sum of the echo times (its “sum_TE” field: 30 ms for 4 + 10 + 16), matching the summed phase. For iLSQR this only sets the unit of χ. - Parameters the GUI passes (as a
paramsstruct):niter40,padsize[12 12 12],tol_step10.01,tol_step20.001,Kthreshold0.1. The struct call is bitwise equal toQSM_iLSQR(..., 'padsize', [12 12 12], 'niter', 40). Called without them,QSM_iLSQRusesniter100 (bitwise equal to an explicit 100) andpadsize6 mm (see Padding).IlsqrParams::defaulttakes the function’s 100 iterations (decided 2026-10-29: UK Biobank andSTI_Templates.ml. 115 callQSM_iLSQRwithout'niter', so 100 is what they run; 40 is only what the GUI passes) and the GUI’s 12 mm padding. (STI_Templates.mlistspadsize = [12 12 12]as the default; the function’s own default is 6.) Until 2026-10-29 the default was the GUI’s 40; the QSM-CI parity pair against the verifiedilsqr-sticontainer (which calls it without'niter') differed by 17–18 % NRMSE at 40 and 1–2 % at 100 (astewartau/QSM.rs#329); that 1–2 % was the FastQSM closing (see Agreement with STI Suite). - Masking: the GUI’s “Single button for QSM” path builds the mask by thresholding the
magnitude (
Support_Functions/CreateMask.m: rescale, threshold, largest connected component, fill holes; then the P-codedGetmasking/PolishMask), not with BET. - B0 direction: the GUI’s
Hfield defaults to[0 0 1]and is not filled from the data; the v3.0 manual says to compute it from the DICOM for oblique scans. - Other callers: QSMART (
QSMART.ml. 32–39), like Hongfu Sun’sqsm_r2s_prisma.mwhose call it copies, passes the field in rad/s with'niter', 50, 'TE', 1000and no'padsize'. QSMART used STI Suite v2.2 (paper §3.4); v2.2’sQSM_iLSQRagrees with v3.0’s to 8e-5 relative on two synthetic cases. UK Biobank passes'padsize', [64 64 64].
References: Li, W., Wang, N., Yu, F., Han, H., Cao, W., Romero, R., Tantiwongkosi, B., Duong, T.Q., Liu, C. (2015). “A method for estimating and removing streaking artifacts in quantitative susceptibility mapping.” NeuroImage, 108:111-122. https://doi.org/10.1016/j.neuroimage.2014.12.043
Paige, C.C., Saunders, M.A. (1982). “LSQR: An algorithm for sparse linear equations and sparse least squares.” ACM Trans. Math. Softw. 8(1):43-71.
§Pipeline rules
The facts above that a configuration can depart from are published as rules in
crate::pipeline::rules::RULES (each with its strength, basis, reason and source): sti.unwrap-laplacian, sti.laplacian-solver, sti.echo-combination, sti.echo-weights, sti.bg-vsharp, sti.vsharp-no-deconvolve, sti.vsharp-radius, sti.vsharp-grid, sti.ilsqr-iterations, sti.ilsqr-pad.
Modules§
- timing 🔒
- Wall-clock tracing of iLSQR for native benchmarks and parity checks. Not compiled for
wasm32, which has no clock instd.
Structs§
- Ilsqr
Params - Parameters for the iLSQR algorithm.
- Ilsqr
Trace - Where an
ilsqr_tracedrun spent its time, and how its two LSQR solves ended. - Lsqr
Outcome - How an
lsqr_matlabsolve ended (MATLABlsqrflags).
Enums§
- Ilsqr
Precision - Arithmetic of iLSQR’s two LSQR solves (see the module docs, Performance and precision).
Constants§
- NONE 🔒
- Sentinel of the compact-index maps in the artefact solve.
- NORM_
CHUNK 🔒 - Chunk length of the LSQR norms: fixed, so every sum is independent of the thread count.
- STI_
DEFAULT_ NITER QSM_iLSQR’s own iteration cap, used when it is called without'niter'(output bitwise equal to an explicit 100), as UK Biobank’s pipeline and STI Suite’sSTI_Templates.mcall it, and the default ofIlsqrParams::max_iter.- STI_
DEFAULT_ PAD_ MM - Zero padding STI’s
QSM_iLSQRuses when called without'padsize'(as QSMART calls it), in mm. Probed in STI Suite 3.0: output bitwise equal to an explicitpadsizeof 6 mm (and different at 5.5 and 6.5) on five crop / voxel-size combinations. - STI_
GUI_ NITER - Iteration cap (
niter) STI Suite 3.0’sQSM_GUIpasses toQSM_iLSQR. Not the default ofIlsqrParams::max_iter(that isSTI_DEFAULT_NITER); set it to reproduce the GUI. - STI_
GUI_ PAD_ MM - Zero padding STI Suite 3.0’s
QSM_GUIpasses toQSM_iLSQR(padsize[12 12 12]), in mm, and the default ofIlsqrParams::pad_mm.STI_Templates.mand the χ-separation toolbox’sChisep_script.muse the same value. - STI_
PAD_ MM - Zero padding STI’s
QSM_iLSQRis given by UK Biobank and in STI Suite’s examples, in mm.
Traits§
- Tracer 🔒
- Instrumentation hooks for
ilsqr_with. The library path (ilsqr) uses(), whose hooks do nothing, so it never reads a clock (std::time::Instantpanics onwasm32-unknown-unknown). The hooks only observe; they cannot change the result.
Functions§
- artefact_
solve 🔒 - Step 6 of
ilsqr: the streaking artefacts, by LSQR (tol) over the k-space cone |D| < 0.1 against the weighted gradient (weightsgw, full grid, zero off the mask) of the initial solutionx1. Returns the real part of F⁻¹ of the solution (the artefacts divided by √N) and how LSQR ended. - artefact_
solve_ 🔒single - Step 6 of
ilsqras STI computes it (IlsqrPrecision::Single): the right-hand sideG ∇(M·Re χ₁)in double, rounded to single; the operatory ↦ G ∇(√N F⁻¹(M_ic y))and its adjointr ↦ M_ic F(∇ᴴ(G r)) / √Nin single precision, on dense vectors (the unknown over the whole k-space grid, the residual over three whole image grids); MATLAB’slsqron single data. ReturnsRe F⁻¹(M_ic y)in double (the artefacts divided by √N), asartefact_solvedoes. - axpy 🔒
- circ_
next 🔒 - Circular neighbour
l + e_axison the gridn. - cnorm32 🔒
‖x‖₂of a single-precision vector, accumulated in f64 overdense_partsand rounded to f32 (the value MATLAB’snormof asinglevector rounds to, up to its BLAS’s order).- cnorm_
parts 🔒 - compact_
parts 🔒 - See
dense_parts.posare the (strictly increasing) positions in the long vector. - dense_
parts 🔒 - How an LSQR vector is split for its norms: lengths of consecutive parts, each summed sequentially, the partial sums then added in order.
- div_
inplace 🔒 - grad_
masked 🔒 - Weighted circular forward differences (no voxel-size scaling), as STI’s iLSQR uses, at the
mask voxels only:
r[a·nm + i] = gw[a][i] · (val(l + e_a) − val(l))withl = inside[i],nm = inside.len(),gwthe weights compacted to the mask. - grad_
masked_ 🔒adj - Adjoint of
grad_maskedonto the boxbxof the grid:acc[l] = Σ_a (q_a[l − e_a] − q_a[l])forlinbx(the rest ofaccis left as it is), summed in axis order from zero, withq_a = gw[a]·r_aon the mask and zero elsewhere (mask_of[l]= position oflininside, orNONE). The result is zero outside the mask grown by one voxel along +e_a. - ilsqr
- iLSQR as in STI Suite 3.0 (
QSM_iLSQR), padded byparams.pad_mm. - ilsqr_
traced super::ilsqr, also returning anIlsqrTrace(wall time per stage and per FFT, and how both LSQR solves ended). Native targets only; the result is identical toilsqr’s.- ilsqr_
with 🔒 ilsqrwith instrumentation hooks.- initial_
solve 🔒 - Step 3 of
ilsqr: LSQR (tol 0.01) on D W D χ = D W φ with D = F⁻¹ diag(dk) F. Returns χ and how LSQR ended. - initial_
solve_ 🔒single - Step 3 of
ilsqras STI computes it (IlsqrPrecision::Single): the right-hand sideD W φin double, rounded to single; the operatorD W Devaluated in single precision (single 3-D FFTs,single(D),single(W)) and MATLAB’slsqron single data. Returns the real part of the solution in double, as STI’sdouble(x1). - lsqr
- LSQR iterative solver for Ax = b
- lsqr_
matlab - LSQR (Paige & Saunders 1982) with MATLAB
lsqr’s stopping rules, zero initial guess, no preconditioner: stops when‖r‖ ≤ tol·‖b‖or‖Aᴴr‖ / (‖A‖_F·‖r‖) ≤ tol(both tested on the previous iterate, before it is updated), after three stagnant steps, or on breakdown.apply_acomputesA x,apply_ahcomputesAᴴ u. - lsqr_
matlab_ 🔒f32 - MATLAB
lsqr(zero initial guess, no preconditioner) onsingledata: the iteration and stopping rules oflsqr_matlab, with every vector and scalar in single precision, as MATLAB runs it whenband the operator’s results aresingle(adoublescalar such astolorepsmeeting asingleone is rounded to single first). - lsqr_
matlab_ 🔒into lsqr_matlabwith operators that write into a caller-owned buffer (outholds stale data on entry and must be overwritten), takingbby value as LSQR’s firstu, so a solve allocates its six vectors once.parts= (range parts, domain parts): how the norms ofuandvvectors are split (seedense_parts); the domain parts also give the length of the unknown. Performs exactly the same floating-point operations as the textbook loop (the norms of updated vectors are fused into the update passes, with the same parts).- norm 🔒
- nsq 🔒
|z|²(exactlyz.norm_sqr())- scale_
inplace 🔒 - sign0 🔒
- split_
parts 🔒 - split_
parts_ 🔒mut - sti_
bounding_ 🔒box - STI’s
MaskBoundingBox: tight bounding box (0-based, inclusive); an odd extent grows by one voxel at the high end, or at the low end when the high end is the array edge (and shrinks by one at the high end when both ends are edges). - sti_
dipole_ 🔒kernel - STI’s dipole kernel
D = 1/3 − (k·H)² / |k|²(0 at DC) on the unshifted padded grid, in STI’s arithmetic:kfromsti_freqs,k·H = k_x H_x + k_y H_y + k_z H_zand|k|² = k_x² + k_y² + k_z²in that order, andHused as given when it is a unit vector (normalised otherwise). Seesti_freqsfor why the rounding matters. - sti_
fastqsm 🔒 - STI’s
FastQSMon the padded grid, givenF φ(unshifted k-space). Returns the masked estimate, rescaled to the TKD (threshold 1/8) solution by least squares inside the mask. - sti_
freqs 🔒 - STI’s k-space coordinates (cycles/mm) on the unshifted layout of an even axis of
nvoxels: the signed index divided byn·vs, as STI computes(-n/2:n/2-1)/(n*vs). - sti_
padded_ 🔒dims - STI’s padded grid: per axis the next multiple of 16 strictly above
c + 2·pad_mm/vs. - sti_
percentile_ 🔒index - 0-based index of STI’s percentile: the sorted value at 1-based index
round(p·N). - sti_
percentiles 🔒 - STI’s percentiles
p_lo ≤ p_hiofv(which is reordered), by selection instead of a full sort: the k-th element intotal_cmporder is unique, so this is exactly the sorted lookup. - transform 🔒
- One of [
Fft3dWorkspace]’s pruned / fused transforms (forward, or inverse ifinverse). - update_
and_ 🔒norm fapplied elementwise toy(paired withx), thencnorm_parts(y), in one pass: the same values as the two passes, part by part.- update_
d_ 🔒x_ and_ norms - LSQR’s direction and solution updates
d ← (v − θ d)/ρ,x ← x + φ din one pass, returning(‖d‖, ‖x‖): the same values as twoupdate_and_normpasses, part by part.