ImagePhantoms overview

This page explains the Julia package ImagePhantoms.

This page comes from a single Julia file: 01-overview.jl.

You can access the source code for such Julia documentation using the 'Edit on GitHub' link in the top right. You can view the corresponding notebook in nbviewer here: 01-overview.ipynb, or open it in binder here: 01-overview.ipynb.

Setup

Packages needed here.

using ImagePhantoms
using ImageGeoms: ImageGeom, axesf
using MIRTjim: jim, prompt, mid3
using Plots; default(markerstrokecolor=:auto, label="")
using Unitful: mm
using InteractiveUtils: versioninfo

The following line is helpful when running this file as a script; this way it will prompt user to hit a key after each figure is displayed.

isinteractive() ? jim(:prompt, true) : prompt(:draw);

Overview

When developing image reconstruction methods, it can be helpful to simulate data (e.g., sinograms) using software-defined images called phantoms.

The simplest method here is to make a Shepp-Logan phantom image similar its use in other packages.

image = shepp_logan(256) # CT version by default
jim(image, "SheppLogan"; clim=(0.9, 1.1))
Example block output

Sinograms

Often for image reconstruction algorithm development, we need not only the phantom image, but also its sinogram, i.e., Radon transform and spectrum.

The 2D sinogram coordinate system used here is

\[p(r, ϕ) = \int_{-∞}^{∞} f(r \cos ϕ - ℓ \sin ϕ, r \sin ϕ + ℓ \cos ϕ) \, \mathrm{d} ℓ.\]

We start with the vector of ellipses that defines the phantom, using a typical field of view (FOV) of 200mm for a head:

objects = shepp_logan(SheppLoganToft(); fovs=(200mm,200mm))
10-element Vector{Object2d{Ellipse, Float64, Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(mm,), 𝐋, nothing}}, Float64, 1, Float64}}:
 Object2d{Ellipse, Float64, Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(mm,), 𝐋, nothing}}, Float64, 1, Float64}((0.0 mm, 0.0 mm), (92.0 mm, 69.0 mm), (1.5707963267948966,), 1.0, (1.0,), (6.123233995736766e-17,))
 Object2d{Ellipse, Float64, Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(mm,), 𝐋, nothing}}, Float64, 1, Float64}((0.0 mm, -1.8399999999999999 mm), (87.4 mm, 66.24 mm), (1.5707963267948966,), -0.8, (1.0,), (6.123233995736766e-17,))
 Object2d{Ellipse, Float64, Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(mm,), 𝐋, nothing}}, Float64, 1, Float64}((22.0 mm, 0.0 mm), (31.0 mm, 11.0 mm), (1.2566370614359172,), -0.2, (0.9510565162951535,), (0.30901699437494745,))
 Object2d{Ellipse, Float64, Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(mm,), 𝐋, nothing}}, Float64, 1, Float64}((-22.0 mm, 0.0 mm), (41.0 mm, 16.0 mm), (1.8849555921538759,), -0.2, (0.9510565162951536,), (-0.30901699437494734,))
 Object2d{Ellipse, Float64, Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(mm,), 𝐋, nothing}}, Float64, 1, Float64}((0.0 mm, 35.0 mm), (25.0 mm, 21.0 mm), (1.5707963267948966,), 0.1, (1.0,), (6.123233995736766e-17,))
 Object2d{Ellipse, Float64, Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(mm,), 𝐋, nothing}}, Float64, 1, Float64}((0.0 mm, 10.0 mm), (4.6 mm, 4.6 mm), (0.0,), 0.1, (0.0,), (1.0,))
 Object2d{Ellipse, Float64, Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(mm,), 𝐋, nothing}}, Float64, 1, Float64}((0.0 mm, -10.0 mm), (4.6 mm, 4.6 mm), (0.0,), 0.1, (0.0,), (1.0,))
 Object2d{Ellipse, Float64, Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(mm,), 𝐋, nothing}}, Float64, 1, Float64}((-8.0 mm, -60.5 mm), (4.6 mm, 2.3 mm), (0.0,), 0.1, (0.0,), (1.0,))
 Object2d{Ellipse, Float64, Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(mm,), 𝐋, nothing}}, Float64, 1, Float64}((0.0 mm, -60.5 mm), (2.3 mm, 2.3 mm), (0.0,), 0.1, (0.0,), (1.0,))
 Object2d{Ellipse, Float64, Unitful.Quantity{Float64, 𝐋, Unitful.FreeUnits{(mm,), 𝐋, nothing}}, Float64, 1, Float64}((6.0 mm, -60.5 mm), (4.6 mm, 2.3 mm), (1.5707963267948966,), 0.1, (1.0,), (6.123233995736766e-17,))

From that collection we can compute images, sinograms and spectra. Use phantom to make digital images. It is convenient (but not required) to use ImageGeoms to help with the sampling.

ig = ImageGeom(dims=(200,256), deltas=(1mm,1mm))
image = phantom(axes(ig)..., objects)
jim(axes(ig), image; xlabel="x", ylabel="y", title="SheppLoganToft")
Example block output

Here is the sinogram corresponding to this phantom, computed analytically from the ellipse parameters using radon:

r = range(-100mm,100mm,401)
ϕ = deg2rad.(0:180)
sino = radon(r, ϕ, objects)
jim(r, ϕ, sino; title="Sinogram", xlabel="r", ylabel="ϕ")
Example block output

Spectra

Here is the 2D spectrum (Fourier transform) of this phantom, computed analytically from the ellipse parameters using spectrum:

kspace = spectrum(axesf(ig)..., objects)
jim(axesf(ig), log10.(abs.(kspace/(1mm)^2)); xlabel="ν₁", ylabel="ν₂", title="log10|Spectrum|")
Example block output

The 2D Fourier transform formula used here is:

\[F(ν_1, ν_2) = ∫ ∫ f(x,y) \, \mathrm{e}^{-ı 2π (ν_1 x + ν_2 y)} \, \mathrm{d} x \, \mathrm{d} y,\]

so if $x$ and $y$ have units mm (for example), then the units of the spatial frequency variables $ν_1$ and $ν_2$ are cycles/mm.

2D Rotation

All of the 2D objects (ellipses etc.) in this package can be rotated by an angle $ϕ$.

This package treats the rotation angle $ϕ$ as defining a rotation of the object. Be aware that a rotation of the axes has the opposite sign convention.

After rotation by $ϕ$, any point $(x,y)$ in the original ellipse becomes the point

\[\left[ \begin{matrix} x' \\ y' \end{matrix} \right] = \left[ \begin{matrix} \cos(ϕ) & -\sin(ϕ) \\ \sin(ϕ) & \cos(ϕ) \end{matrix} \right] \left[ \begin{matrix} x \\ y \end{matrix} \right],\]

as illustrated by the blue star below when rotating an ellipse by $ϕ = π/6$.

ellipse0 = ellipse(0, 0, 8, 4, 0, 1)
ϕ1s = :(π/6)
ϕ1 = eval(ϕ1s)
ellipse1 = ellipse(0, 0, 8, 4, ϕ1, 1)

x = range(-9, 9, 181)
y = range(-8, 8, 161)
pic0 = phantom(x, y, [ellipse0])
pic1 = phantom(x, y, [ellipse1])

marker = :star
p0 = jim(x, y, pic0, "Original ellipse";
    xlabel="x", ylabel="y", size=(700,300), prompt=:false)
x0,y0 = 7,0
scatter!([x0], [y0], color=:blue; marker)
point1 = [cos(ϕ1) -sin(ϕ1); sin(ϕ1) cos(ϕ1)] * [x0; y0] # rotate point
x1,y1 = point1[1], point1[2]
p1 = jim(x, y, pic1, "Rotated by ϕ = $ϕ1s";
    xlabel="x", ylabel="y", size=(700,300), prompt=:false)
scatter!([x1], [y1], color=:blue; marker)
jim(p0, p1)
Example block output

3D Rotation

For a 3D object, there are three rotation angles, often called Euler angles, and there are many possible conventions for the names and ordering of these angles.

This package denotes the three angles as $ϕ,θ,ψ.$

Rotation about $z$ by $ϕ$

For consistency with the 2D case, the first of the three angles, $ϕ$, denotes rotation in the $(x,y)$ plane, i.e., around the $z$-axis. In wikipedia's notation this is $R_z(ϕ)$, defined as

\[\left[ \begin{matrix} x' \\ y' \\ z' \end{matrix} \right] = \left[ \begin{matrix} \cos(θ) & -\sin(θ) & 0 \\ \sin(θ) & \cos(θ) & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] \left[ \begin{matrix} x \\ y \\ z \end{matrix} \right]\]

as illustrated by the blue star below.

Here is an illustration for $ϕ = π/6$.

ellipsoid0 = ellipsoid((0, 0, 0), (8, 4, 2), (0, 0, 0), 1)
ϕ1s = :(π/6)
ϕ1 = eval(ϕ1s)
ellipsoid1 = ellipsoid((0, 0, 0), (8, 4, 2), (ϕ1, 0, 0), 1)

x = range(-9, 9, 181)
y = range(-8, 8, 161)
z = [0]
pic0 = phantom(x, y, z, [ellipsoid0])
pic1 = phantom(x, y, z, [ellipsoid1])

p0z = jim(x, y, pic0,
    "Original ellipsoid:\n(x,y) slice";
    xlabel="x", ylabel="y", size=(700,300), prompt=:false)
x0,y0 = 7,0
scatter!([x0], [y0], color=:blue; marker)
Rz(ϕ) = [cos(ϕ) -sin(ϕ) 0; sin(ϕ) cos(ϕ) 0; 0 0 1]
point1 = Rz(ϕ1) * [x0; y0; 0] # rotate point
x1,y1 = point1[1], point1[2]
p1z = jim(x, y, pic1,
    "Rotated about z\nby ϕ = $ϕ1s\n(z out of 'board')";
    xlabel="x", ylabel="y", size=(700,350), prompt=:false)
scatter!([x1], [y1], color=:blue; marker)
jim(p0z, p1z)
Example block output

Rotation about $y$ by $θ$

The 2nd of the three angles, $θ$, corresponds to rotation around the $y$-axis, which has the opposite sign when using the right hand rule:

\[\left[ \begin{matrix} x' \\ y' \\ z' \end{matrix} \right] = \left[ \begin{matrix} \cos(θ) & 0 & \sin(θ) \\ 0 & 1 & 0 \\ -\sin(θ) & 0 & \cos(θ) \\ \end{matrix} \right] \left[ \begin{matrix} x \\ y \\ z \end{matrix} \right],\]

as illustrated by the green star below.

In wikipedia notation this is $R_y(θ)$.

Here is an illustration of rotating an ellipsoid for $θ = π/6$.

ellipsoid0 = ellipsoid((0, 0, 0), (8, 4, 2), (0, 0, 0), 1)
θ1s = :(π/6)
θ1 = eval(θ1s)
ellipsoid1 = ellipsoid((0, 0, 0), (8, 4, 2), (0, θ1, 0), 1)

x = range(-9, 9, 181)
y = [0]
z = range(-8, 8, 161)
pic0 = phantom(x, y, z, [ellipsoid0]); pic0 = selectdim(pic0, 2, 1)
pic1 = phantom(x, y, z, [ellipsoid1]); pic1 = selectdim(pic1, 2, 1)

p0y = jim(x, z, pic0,
    "Original ellipsoid:\n (x,z) slice";
    xlabel="x", ylabel="z", size=(700,350), prompt=false)
x0,z0 = 7,0
scatter!([x0], [z0], color=:green; marker)
Ry(θ) = [cos(θ) 0 sin(θ); 0 1 0; -sin(θ) 0 cos(θ)]
point1 = Ry(θ1) * [x0; 0; z0] # rotate point
x1,z1 = point1[1], point1[3]
p1y = jim(x, z, pic1,
    "Rotated about y\nby θ = $θ1s\n(y into 'board')";
    xlabel="x", ylabel="z", size=(700,350), prompt=false)
scatter!([x1], [z1], color=:green; marker)
jim(p0y, p1y)
Example block output

Rotation about $x$ by $ψ$

The 3rd of the three angles, $ψ$, corresponds to rotation around the $x$-axis:

\[\left[ \begin{matrix} x' \\ y' \\ z' \end{matrix} \right] = \left[ \begin{matrix} 1 & 0 & 0 \\ 0 & \cos(ψ) & -\sin(ψ) \\ 0 & \sin(ψ) & \cos(ψ) \\ \end{matrix} \right] \left[ \begin{matrix} x \\ y \\ z \end{matrix} \right],\]

as illustrated by the red star below.

In wikipedia notation this is $R_x(ψ)$.

Here is an illustration of rotating an ellipsoid for $ψ = π/6$.

ellipsoid0 = ellipsoid((0, 0, 0), (8, 4, 2), (0, 0, 0), 1)
ψ1s = :(π/6)
ψ1 = eval(ψ1s)
ellipsoid1 = ellipsoid((0, 0, 0), (8, 4, 2), (0, 0, ψ1), 1)

x = [0]
y = range(-9, 9, 181)
z = range(-8, 8, 161)
pic0 = phantom(x, y, z, [ellipsoid0]); pic0 = selectdim(pic0, 1, 1)
pic1 = phantom(x, y, z, [ellipsoid1]); pic1 = selectdim(pic1, 1, 1)

p0x = jim(y, z, pic0,
    "Original ellipsoid:\n (y,z) slice)";
    xlabel="y", ylabel="z", size=(700,350), prompt=false)
y0,z0 = 3,0
scatter!([y0], [z0], color=:red; marker)
Rx(ψ) = [1 0 0 ; 0 cos(ψ) -sin(ψ); 0 sin(ψ) cos(ψ)]
point1 = Rx(ψ1) * [0; y0; z0] # rotate point
y1,z1 = point1[2], point1[3]
p1x = jim(y, z, pic1,
    "Rotated about x\nby ψ = $ψ1s\n(x out of 'board')";
    xlabel="y", ylabel="z", size=(700,350), prompt=false)
scatter!([y1], [z1], color=:red; marker)
jim(p0x, p1x)
Example block output

The remaining issue is the multiplication order for multiple rotations.

To address that, we first describe how phantoms are generated in this package. Every phantom shape starts with a base function that is translated, rotated, and scaled to make the final object. For example, an ellipsoid is a transformed sphere. Specifically, if $e(r)$ is the ellipsoid function, and $s(r)$ is the unit sphere function, where $r = (x,y,z)$, then

\[e(r) = s( (R_{xyz}^T (r - c)) ⊘ w )\]

where $c$ is the center of the ellipsoid, and $r$ is the width parameter (actually radii), $⊘$ denotes element-wise division, and $R_{xyz}^T$ is the inverse of the 3D rotation matrix $R_{xyz}$ defined by $R_{xyz} = R_x(ψ) R_y(θ) R_z(ϕ)$.

Note that $r$ and $c$ and $w$ all must have identical units, so the argument $(R_{xyz}^T (r - c)) ⊘ w$ passed the unit-sphere function is unitless, as it must be. The non-exported phantom1 function for each shape defines the base shape function. For the unit sphere it is simply sum(abs2, r) ≤ 1.

Rearranging the equation $r' = (R_{xyz}^T (r - c)) ⊘ w$ yields $r = R_{xyz} (w ⊙ r') + c$. So the process of transforming a unit sphere to an ellipsoid starts with scaling, then rotation by $R_{xyz}$, which rotates first around the $z$-axis, and then finally translating.

Here is an illustration where one can see that all three axes were rotated.

ellipsoid0 = ellipsoid((0, 0, 0), (8, 4, 2), (0, 0, 0), 1)
ϕ1s = :(π/6)
ϕ1 = eval(ϕ1s)
θ1s = :(π/7)
θ1 = eval(θ1s)
ψ1s = :(π/8)
ψ1 = eval(ψ1s)
ellipsoid1 = ellipsoid((0, 0, 0), (8, 4, 2), (ϕ1, θ1, ψ1), 1)

x = range(-9, 9, 181)
y = range(-8, 8, 161)
z = range(-7, 7, 71)
pic0 = phantom(x, y, z, [ellipsoid0])
pic1 = phantom(x, y, z, [ellipsoid1])

p0a = jim(mid3(pic0),
    "Original ellipsoid\n(central slices)";
    xlabel="x", ylabel="y", size=(700,320), prompt=:false)
p1a = jim(mid3(pic1),
    "Rotated\nϕ = $ϕ1s, θ = $θ1s, ψ = $ψ1s";
    xlabel="x", ylabel="y", size=(700,320), prompt=:false)
jim(p0a, p1a)
Example block output

Spectra rotation

The spectrum method accounts for the translation, rotation, and scaling of the base shape function using elementary Fourier transform properties.

The following code first shows the spectra of an ellipsoid before and after rotating it.

ig = ImageGeom(length.((x,y,z)), map(x -> x[2]-x[1], (x,y,z)))
kspace0 = spectrum(axesf(ig)..., [ellipsoid0])
kspace1 = spectrum(axesf(ig)..., [ellipsoid1])
@assert ImagePhantoms.volume(ellipsoid0) == ImagePhantoms.volume(ellipsoid1)

clim = (-6, 0)
p0s = jim(axesf(ig), log10.(abs.(kspace0 / ImagePhantoms.volume(ellipsoid0)));
    clim, xlabel="ν₁", ylabel="ν₂", title="log10|Spectrum original ellipsoid|")
Example block output
p1s = jim(axesf(ig), log10.(abs.(kspace1 / ImagePhantoms.volume(ellipsoid1)));
    clim, xlabel="ν₁", ylabel="ν₂", title="log10|Spectrum rotated ellipsoid|")
Example block output

The following code verifies that the rotated spectrum matches

k = Iterators.product(axesf(ig)...) # tuples of (kx,ky,kz) values
R = Rx(ψ1) * Ry(θ1) * Rz(ϕ1) # Rxyz rotation matrix
kr = (tuple((R' * collect(k))...) for k in k) # rotate each k-space tuple
kspace0r = spectrum(kr, [ellipsoid0]) # evaluate spectrum at rotated tuples
@assert kspace0r ≈ kspace1

Reproducibility

This page was generated with the following version of Julia:

using InteractiveUtils: versioninfo
io = IOBuffer(); versioninfo(io); split(String(take!(io)), '\n')
12-element Vector{SubString{String}}:
 "Julia Version 1.10.3"
 "Commit 0b4590a5507 (2024-04-30 10:59 UTC)"
 "Build Info:"
 "  Official https://julialang.org/ release"
 "Platform Info:"
 "  OS: Linux (x86_64-linux-gnu)"
 "  CPU: 4 × AMD EPYC 7763 64-Core Processor"
 "  WORD_SIZE: 64"
 "  LIBM: libopenlibm"
 "  LLVM: libLLVM-15.0.7 (ORCJIT, znver3)"
 "Threads: 1 default, 0 interactive, 1 GC (on 4 virtual cores)"
 ""

And with the following package versions

import Pkg; Pkg.status()
Status `~/work/ImagePhantoms.jl/ImagePhantoms.jl/docs/Project.toml`
  [e30172f5] Documenter v1.4.1
  [7a1cc6ca] FFTW v1.8.0
⌃ [9ee76f2b] ImageGeoms v0.10.0
  [71a99df6] ImagePhantoms v0.8.0 `~/work/ImagePhantoms.jl/ImagePhantoms.jl`
⌅ [7031d0ef] LazyGrids v0.5.0
  [98b081ad] Literate v2.18.0
  [170b2178] MIRTjim v0.24.0
  [91a5bcdd] Plots v1.40.4
  [1986cc42] Unitful v1.19.1
  [b77e0a4c] InteractiveUtils
  [9a3f8284] Random
Info Packages marked with ⌃ and ⌅ have new versions available. Those with ⌃ may be upgradable, but those with ⌅ are restricted by compatibility constraints from upgrading. To see why use `status --outdated`

This page was generated using Literate.jl.