Skip to content

Commit 7086858

Browse files
authored
Illustrate false "acceleration" of DCF-based "preconditioning" (#18)
* Start work on precon demo * Finish precon demo * Add seed and description
1 parent 4262384 commit 7086858

File tree

1 file changed

+296
-0
lines changed

1 file changed

+296
-0
lines changed

docs/lit/mri/6-precon.jl

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
#=
2+
# [DCF-based "preconditioning" in MRI](@id 6-nufft)
3+
4+
This example illustrates
5+
how "preconditioning" based on sampling
6+
density compensation factors (DCFs)
7+
affects image reconstruction in MRI
8+
using the Julia language.
9+
=#
10+
11+
#srcURL
12+
13+
#=
14+
The bottom line here
15+
is that DCF-based preconditioning
16+
gives an apparent speed-up
17+
when staring from a zero initial image,
18+
but leads to increased noise (worse error).
19+
Choosing a smart starting image,
20+
like an appropriately scaled gridding image,
21+
provides comparable speed-up
22+
without compromising noise.
23+
=#
24+
25+
26+
#=
27+
First we add the Julia packages that are need for these examples.
28+
Change `false` to `true` in the following code block
29+
if you are using any of the following packages for the first time.
30+
=#
31+
32+
if false
33+
import Pkg
34+
Pkg.add([
35+
"ImagePhantoms"
36+
"Unitful"
37+
"Plots"
38+
"LaTeXStrings"
39+
"MIRTjim"
40+
"MIRT"
41+
"InteractiveUtils"
42+
])
43+
end
44+
45+
46+
# Now tell this Julia session to use the following packages.
47+
# Run `Pkg.add()` in the preceding code block first, if needed.
48+
49+
using ImagePhantoms: shepp_logan, SheppLoganEmis, spectrum, phantom #, Gauss2
50+
using LaTeXStrings
51+
using LinearAlgebra: I, norm
52+
using MIRTjim: jim, prompt # jiffy image display
53+
using MIRT: Anufft, diffl_map, ncg
54+
using Plots; default(label="", markerstrokecolor=:auto)
55+
using Random: seed!; seed!(0)
56+
using Unitful: mm # physical units (mm here)
57+
using InteractiveUtils: versioninfo
58+
59+
60+
# The following line is helpful when running this file as a script;
61+
# this way it will prompt user to hit a key after each image is displayed.
62+
63+
isinteractive() && jim(:prompt, true);
64+
65+
66+
#=
67+
## Radial k-space sampling
68+
69+
We consider radial sampling
70+
as a simple representative non-Cartesian case.
71+
Consider imaging a 256mm × 256mm field of FOV
72+
with the goal of reconstructing a 128 × 128 pixel image.
73+
The following radial and angular k-space sampling is reasonable.
74+
=#
75+
76+
N = 128
77+
FOV = 256mm # physical units!
78+
Δx = FOV / N # pixel size
79+
kmax = 1 / 2Δx
80+
kr = ((-N÷2):(N÷2)) / (N÷2) * kmax # radial sampling in k-space
81+
Nr = length(kr) # N+1
82+
= N-2 # theoretically should be about π/2 * N
83+
= (0:-1)/* π # angular samples
84+
νx = kr * cos.(kϕ)' # N × Nϕ k-space sampling in cycles/mm
85+
νy = kr * sin.(kϕ)'
86+
plot(νx, νy,
87+
xaxis = (L"\nu_x", (-1,1) .* (1.1 * kmax), (-1:1)*kmax),
88+
yaxis = (L"\nu_y", (-1,1) .* (1.1 * kmax), (-1:1)*kmax),
89+
aspect_ratio = 1,
90+
title = "Radial k-space sampling",
91+
)
92+
93+
#
94+
isinteractive() && prompt();
95+
96+
#=
97+
For the NUFFT routines considered here,
98+
the sampling arguments must be "Ω" values:
99+
digital frequencies have pseudo-units of radians / pixel.
100+
=#
101+
102+
Ωx = (2π * Δx) * νx # N × Nϕ grid of k-space sample locations
103+
Ωy = (2π * Δx) * νy # in pseudo-units of radians / sample
104+
105+
scatter(Ωx, Ωy,
106+
xaxis = (L"\Omega_x", (-1,1) .* 1.1π, ((-1:1)*π, ["", "0", "π"])),
107+
yaxis = (L"\Omega_y", (-1,1) .* 1.1π, ((-1:1)*π, ["", "0", "π"])),
108+
aspect_ratio = 1, markersize = 0.5,
109+
title = "Radial k-space sampling",
110+
)
111+
112+
#
113+
isinteractive() && prompt();
114+
115+
116+
#=
117+
## Radial k-space data for Shepp-Logan phantom
118+
119+
Get the ellipse parameters for a MRI-suitable version of the Shepp-Logan phantom
120+
and calculate (analytically) the radial k-space data.
121+
Then display in polar coordinates.
122+
=#
123+
124+
object = shepp_logan(SheppLoganEmis(); fovs=(FOV,FOV))
125+
#src object = [Gauss2(18mm, 0mm, 100mm, 70mm, 0, 1)] # useful for validating DCF
126+
data = spectrum(object).(νx,νy)
127+
data = data / oneunit(eltype(data)) # abandon units at this point
128+
dscale = 10000
129+
jimk = (args...; kwargs...) -> jim(kr, kϕ, args...;
130+
xaxis = (L"k_r", (-1,1) .* kmax, (-1:1) .* maximum(abs, kr)),
131+
yaxis = (L"k_{\phi}", (0,π), (0,π)),
132+
aspect_ratio = :none,
133+
kwargs...
134+
)
135+
pk = jimk(abs.(data) / dscale; title="k-space data magnitude / $dscale")
136+
137+
138+
#=
139+
Here is what the phantom should look like ideally.
140+
=#
141+
142+
x = (-(N÷2):(N÷2-1)) * Δx
143+
y = (-(N÷2):(N÷2-1)) * Δx
144+
ideal = phantom(x, y, object, 2)
145+
clim = (0, 9)
146+
p0 = jim(x, y, ideal; title="True Shepp-Logan phantom", clim)
147+
148+
#
149+
isinteractive() && prompt();
150+
151+
#=
152+
## NUFFT operator
153+
with sanity check
154+
=#
155+
156+
A = Anufft([vec(Ωx) vec(Ωy)], (N,N); n_shift = [N/2,N/2]) # todo: odim=(Nr,Nϕ)
157+
dx = FOV / N # pixel size
158+
dx = dx / oneunit(dx) # abandon units for now
159+
Ax_to_y = Ax -> dx^2 * reshape(Ax, Nr, Nϕ) # trick
160+
pj1 = jimk(abs.(Ax_to_y(A * ideal)) / dscale, "|A*x|/$dscale")
161+
pj2 = jimk(abs.(Ax_to_y(A * ideal) - data) / dscale, "|A*x-y|/$dscale")
162+
plot(pk, pj1, pj2)
163+
164+
#
165+
isinteractive() && prompt();
166+
167+
168+
#=
169+
## Density compensation
170+
171+
Radial sampling needs
172+
(N+1) DSF weights along k-space polar coordinate.
173+
174+
We use the improved method of
175+
[Lauzon&Rutt, 1996](https://doi.org/10.1002/mrm.1910360617)
176+
and
177+
[Joseph, 1998](https://doi.org/10.1002/mrm.1910400317).
178+
=#
179+
180+
= 1/FOV # k-space radial sample spacing
181+
dcf = π /** abs.(kr) # see lauzon:96:eop, joseph:98:sei
182+
dcf[kr .== 0/mm] .= π * (dν/2)^2 /# area of center disk
183+
dcf = dcf / oneunit(eltype(dcf)) # kludge: units not working with LinearMap now
184+
gridded4 = A' * vec(dcf .* data)
185+
p4 = jim(x, y, gridded4, title="NUFFT gridding with better ramp-filter DCF"; clim)
186+
187+
#
188+
isinteractive() && prompt();
189+
190+
191+
# A profile shows it is "decent" but not amazing.
192+
193+
pp = plot(x, real(gridded4[:,N÷2]), label="Modified ramp DCF")
194+
plot!(x, real(ideal[:,N÷2]), label="Ideal", xlabel=L"x", ylabel="middle profile")
195+
196+
#
197+
isinteractive() && prompt();
198+
199+
200+
#=
201+
## Unregularized iterative MRI reconstruction
202+
203+
Apply a few iterations of conjugate gradient (CG)
204+
to approach a minimizer of the least-squares cost function:
205+
```math
206+
\arg \min_{x} \frac{1}{2} ‖ A x - y ‖₂².
207+
```
208+
209+
Using a zero-image as the starting point ``x₀``
210+
leads to slow convergence.
211+
212+
Using "preconditioning" by including DCF values
213+
in the cost function
214+
(a kind of weighted LS)
215+
appears to give much faster convergence,
216+
but leads to suboptimal image quality
217+
especially in the presence of noise.
218+
219+
Using a decent initial image
220+
(e.g., a gridded image with appropriate scaling)
221+
is preferable.
222+
223+
There is a subtle point here about `dx`
224+
when converting the Fourier integral to a sum.
225+
Here `yideal` is `data/dx^2`.
226+
=#
227+
228+
nrmse = x -> norm(x - ideal) / norm(ideal) * 100
229+
fun = (x, iter) -> nrmse(x)
230+
231+
snr2sigma(db, y) = 10^(-db/20) * norm(y) / sqrt(length(y))
232+
yideal = vec(data/dx^2)
233+
σnoise = snr2sigma(40, yideal)
234+
ydata = yideal + σnoise * randn(eltype(yideal), size(yideal))
235+
snr = 20 * log10(norm(yideal) / norm(ydata - yideal)) # check
236+
gradf = u -> u - ydata # gradient of f(u) = 1/2 ‖ u - y ‖²
237+
curvf = u -> 1 # curvature of f(u)
238+
239+
x0 = 0 * ideal
240+
niter = 15
241+
xls0, out0 = ncg([A], [gradf], [curvf], x0; niter, fun)
242+
243+
default(linewidth = 2)
244+
ppr = plot(
245+
xaxis = ("iteration", (0, niter)),
246+
yaxis = ("NRMSE", (0, 100), 0:20:100),
247+
widen = true,
248+
)
249+
plot!(ppr, 0:niter, out0,
250+
label = "Non-preconditioned, 0 init", color = :red, marker = :circle,
251+
);
252+
253+
# "Preconditioned" version
254+
precon = vec(repeat(dcf, Nϕ));
255+
256+
# gradient of fp(u) = 1/2 ‖ P^{1/2} (u - y) ‖²
257+
gradfp = u -> precon .* (u - ydata)
258+
curvfp = u -> precon # curvature of fp(u)
259+
260+
xlsp, out2 = ncg([A], [gradfp], [curvfp], x0; niter, fun)
261+
plot!(ppr, 0:niter, out2,
262+
label = "DCF preconditioned, 0 init", color = :blue, marker = :x,
263+
)
264+
#src savefig(ppr, "precon0.pdf")
265+
266+
# Smart start with gridded image
267+
x0 = gridded4 # initial guess: decent gridding reconstruction
268+
xls1, out1 = ncg([A], [gradf], [curvf], x0; niter, fun)
269+
plot!(ppr, 0:niter, out1,
270+
label = "Non-preconditioned, gridding init", color=:green, marker=:+,
271+
)
272+
273+
#src savefig(ppr, "precon1.pdf")
274+
275+
#
276+
isinteractive() && prompt();
277+
278+
# The images look very similar at 15 iterations
279+
280+
elim = (0, 1)
281+
tmp = stack((ideal, xls0, xlsp, xls1))
282+
err = stack((ideal, xls0, xlsp, xls1)) .- ideal
283+
p5 = plot(
284+
jim(x, y, tmp; title = "Ideal | 0 init | precon | grid init",
285+
clim, nrow=1, size = (600, 200)),
286+
jim(x, y, err; title = "Error", color=:cividis,
287+
clim = elim, nrow=1, size = (600, 200)),
288+
layout = (2, 1),
289+
size = (650, 450),
290+
)
291+
292+
#
293+
isinteractive() && prompt();
294+
295+
296+
include("../../inc/reproduce.jl")

0 commit comments

Comments
 (0)