Examples

Each figure in this guide comes from a script in the examples folder of the repository. Run a script to see the figure and the printed numbers:

pip install "sonosk[plot]"
python examples/bmode_phantom.py

Material values in the examples are generic numbers chosen to show the method. They are not measured tissue data.

B-mode image of a test phantom

Background speckle, an anechoic cyst, a -10 dB and a +6 dB lesion and a row of wire targets, imaged by a 128-element linear array at 5 MHz with nine compounded plane waves.

Phantom label map, B-mode image and quality measures
"""B-mode image of a test phantom with plane-wave compounding.

The phantom has a background of fully developed speckle, an anechoic cyst,
a -10 dB lesion, a +6 dB lesion and a row of wire targets. A 128-element
linear array at 5 MHz sends 9 plane waves between -12 and +12 degrees.
The table lists the quality measures of the image.

The material values are generic soft-tissue numbers for the example.
"""

import matplotlib.pyplot as plt
import numpy as np

import sonosk as sk
import sonosk.plot as splt
from sonosk import imaging, phantom, quality, transducer

F0 = 5e6
C = 1540.0
CYST = (-5e-3, 0, 16e-3)
DARK = (5e-3, 0, 16e-3)
BRIGHT = (0.0, 0, 29e-3)
R = 2.5e-3


def compute(seed=0):
    g = sk.Grid.centered((241, 401), 0.1e-3, depth_start=4e-3)
    mats = [sk.Material("background", C, 1000, alpha0=0.5, echo=0.0),
            sk.Material("cyst", C, 1000, alpha0=0.5, echo=-np.inf),
            sk.Material("hypoechoic", C, 1000, alpha0=0.5, echo=-10.0),
            sk.Material("hyperechoic", C, 1000, alpha0=0.5, echo=6.0)]
    lab = np.zeros(g.shape, int)
    lab = phantom.paint(lab, phantom.sphere(g, CYST, R), 1)
    lab = phantom.paint(lab, phantom.sphere(g, DARK, R), 2)
    lab = phantom.paint(lab, phantom.sphere(g, BRIGHT, R), 3)
    med = sk.Medium(g, lab, mats)

    pos, amp = phantom.scatterers(med, density=60, rng=seed)
    wires, wamp = phantom.point_targets([(x, 0, 38e-3) for x in (-6e-3, -3e-3, 0, 3e-3, 6e-3)],
                                        amplitude=30.0)
    pos = np.vstack([pos, wires])
    amp = np.concatenate([amp, wamp])

    arr = transducer.linear_array(128, 0.3e-3, height=5e-3, dx=0.15e-3)
    pulse = imaging.Pulse(F0, 0.6)
    txs = [imaging.Transmit.plane(a) for a in imaging.plane_wave_angles(9, np.deg2rad(24))]
    rfs = [imaging.simulate(arr, tx, pos, amp, pulse, C, alpha0=0.5)[0] for tx in txs]
    x = np.arange(-11.5e-3, 11.5e-3, 0.075e-3)
    z = np.arange(5e-3, 43e-3, 0.075e-3)
    img = imaging.compound(rfs, 8 * F0, arr, txs, x, z, C, f_number=1.5)

    # depth gain for display: undo the mean attenuation of the round trip
    a = sk.medium.attenuation_np(0.5, F0)
    img = img * np.exp(2 * a * z)[:, None]

    env = np.abs(img)
    zz, xx = np.meshgrid(z, x, indexing="ij")

    def disc(c, r):
        return np.hypot(xx - c[0], zz - c[2]) < r

    near = (np.abs(zz - 16e-3) < 3.5e-3) & ~disc(CYST, R + 1e-3) & ~disc(DARK, R + 1e-3) \
        & (np.abs(xx) < 9e-3)
    stats = {
        "speckle SNR": quality.speckle_snr(env[near]),
        "cyst contrast [dB]": quality.contrast_db(env[disc(CYST, 0.8 * R)], env[near]),
        "cyst gCNR": quality.gcnr(env[disc(CYST, 0.8 * R)], env[near]),
        "-10 dB lesion [dB]": quality.contrast_db(env[disc(DARK, 0.8 * R)], env[near]),
        "-10 dB lesion gCNR": quality.gcnr(env[disc(DARK, 0.8 * R)], env[near]),
    }
    iz = np.argmin(np.abs(z - 38e-3))
    win = slice(max(iz - 40, 0), iz + 40)
    k = np.argmin(np.abs(x))
    sub = env[win, :]
    jz = np.argmax(sub[:, k])
    stats["wire lateral -6 dB [mm]"] = 1e3 * quality.width(sub[jz, k - 25:k + 26], x[k - 25:k + 26])
    stats["wire axial -6 dB [mm]"] = 1e3 * quality.width(sub[:, k], z[win])
    return g, med, x, z, img, stats


def figure():
    g, med, x, z, img, stats = compute()
    fig, ax = plt.subplots(1, 3, figsize=(12, 5.0), constrained_layout=True,
                           gridspec_kw={"width_ratios": [1, 1, 0.8]})
    xg, zg = g.axes()
    splt.labels_map(med.labels, xg, zg, names=[m.name for m in med.materials], ax=ax[0],
                    title="Phantom")
    splt.bmode(img, x, z, dynamic_range=55, ax=ax[1], title="B-mode, 9 plane waves")
    ax[2].axis("off")
    rows = [f"{k:<26s}{v:7.2f}" for k, v in stats.items()]
    ax[2].text(0.0, 0.98, "\n".join(rows), family="monospace", va="top", fontsize=9)
    return fig


if __name__ == "__main__":
    *_, stats = compute()
    for k, v in stats.items():
        print(f"{k:<26s}{v:8.3f}")
    figure()
    plt.show()

HIFU ablation of a tumour

A 1 MHz bowl heats a tumour of 4 mm radius for 8 s. The single exposure leaves most of the tumour untreated and a small lesion in front of and behind it, which is what a treatment plan with several points has to fix.

Peak temperature with lesion and tumour outlines, temperature at the focus, treatment report
"""Thermal ablation of a spherical tumour with a focused bowl.

A 1 MHz bowl (20 mm aperture radius, 50 mm radius of curvature) heats a
tumour of 4 mm radius centred at the focus. The pressure field is the
Rayleigh integral in a homogeneous attenuating medium, scaled to a focal
amplitude of 3 MPa and treated as linear. The source is on for 8 s and the
tissue then cools for 20 s. The Pennes equation is solved in 3-D and the
lesion is the region with CEM43 of 240 min or more.

The tissue and tumour values below are generic numbers for the example and
not measured data.
"""

import matplotlib.pyplot as plt
import numpy as np

import sonosk as sk
import sonosk.plot as splt
from sonosk import field, phantom, thermal, transducer

F = 1e6
A = 20e-3
ROC = 50e-3
P_FOCUS = 3e6
TISSUE = sk.Material("tissue", 1540, 1050, alpha0=0.5, k=0.5, cp=3600, perfusion=0.5)
TUMOUR = sk.Material("tumour", 1540, 1050, alpha0=0.7, k=0.5, cp=3600, perfusion=0.3)


def _axisymmetric_field(r_max, z0, z1, h):
    """|p| of the bowl on an (r, z) grid; the bowl field is symmetric about z."""
    src = transducer.bowl(A, ROC, dx=TISSUE.c / F / 4)
    r = np.arange(0, r_max + h, h)
    z = np.arange(z0, z1 + h, h)
    rr, zz = np.meshgrid(r, z, indexing="ij")
    pts = np.stack([rr.ravel(), 0 * rr.ravel(), zz.ravel()], 1)
    p = np.abs(field.rayleigh(src, pts, F, TISSUE.c, alpha0=TISSUE.alpha0)).reshape(rr.shape)
    return r, z, p


def _interp_rz(r, z, p, R, Z):
    # bilinear interpolation of p(r, z) at the points (R, Z)
    fr = np.clip((R - r[0]) / (r[1] - r[0]), 0, r.size - 1.001)
    fz = np.clip((Z - z[0]) / (z[1] - z[0]), 0, z.size - 1.001)
    i, j = fr.astype(int), fz.astype(int)
    a, b = fr - i, fz - j
    return ((1 - a) * (1 - b) * p[i, j] + a * (1 - b) * p[i + 1, j]
            + (1 - a) * b * p[i, j + 1] + a * b * p[i + 1, j + 1])


def compute():
    g = sk.Grid.centered((41, 41, 66), 0.4e-3, depth_start=36e-3)
    x, y, z = g.mesh()
    lab = phantom.paint(np.zeros(g.shape, int), phantom.sphere(g, (0, 0, ROC), 4e-3), 1)
    med = sk.Medium(g, lab, [TISSUE, TUMOUR])

    r, zr, pr = _axisymmetric_field(np.hypot(*g.extent[:2]) / 2 + 1e-3, 35e-3, 63e-3, 0.2e-3)
    p = _interp_rz(r, zr, pr, np.hypot(x, y), z)
    p *= P_FOCUS / p.max()
    q = thermal.heat_source(p, med.attenuation(F), med.rho, med.c)

    on = 8.0
    res = thermal.bioheat(med, q, duration=28.0, schedule=lambda t: 1.0 if t < on else 0.0,
                          probe=[tuple(np.array(g.shape) // 2)])
    rep = thermal.treatment_report(res.lesion(), med, "tumour")
    return g, med, res, rep


def figure():
    g, med, res, rep = compute()
    xg, yg, zg = g.axes()
    j = g.shape[1] // 2
    fig, ax = plt.subplots(1, 3, figsize=(12, 4.4), constrained_layout=True,
                           gridspec_kw={"width_ratios": [1, 1.2, 0.9]})
    h = splt.temperature_map(res.T_max[:, j, :], xg, zg, ax=ax[0], lesion=res.lesion()[:, j, :],
                             title="Peak temperature, lesion in cyan")
    ax[0].contour(xg * 1e3, zg * 1e3, med.mask("tumour")[:, j, :].T.astype(float), levels=[0.5],
                  colors="white", linestyles="--", linewidths=1.0)
    fig.colorbar(h, ax=ax[0], shrink=0.8, label="C")

    t = np.arange(res.probe.shape[0]) * res.dt
    ax[1].plot(t, res.probe[:, 0])
    ax[1].axhline(43, color="k", lw=0.8, ls="--")
    ax[1].set_xlabel("Time [s]")
    ax[1].set_ylabel("Temperature at the focus [C]")
    ax[1].set_title("Heating 8 s, cooling 20 s")
    ax[1].grid(alpha=0.3)

    mm3 = 1e9
    rows = [f"{'tumour volume':<22s}{rep['target_volume'] * mm3:7.1f} mm3",
            f"{'ablated':<22s}{rep['ablated'] * mm3:7.1f} mm3",
            f"{'residual':<22s}{rep['residual'] * mm3:7.1f} mm3",
            f"{'fraction ablated':<22s}{100 * rep['fraction_ablated']:7.1f} %",
            f"{'healthy tissue lesion':<22s}{rep['healthy_damaged'] * mm3:7.1f} mm3",
            f"{'peak temperature':<22s}{res.T_max.max():7.1f} C"]
    ax[2].axis("off")
    ax[2].text(0.0, 0.98, "\n".join(rows), family="monospace", va="top", fontsize=9)
    return fig


if __name__ == "__main__":
    g, med, res, rep = compute()
    print(f"peak temperature {res.T_max.max():.1f} C, time step {res.dt:.3f} s")
    for k, v in rep.items():
        print(k, v)
    figure()
    plt.show()

Field of a focused bowl

Field of a focused bowl and its on-axis amplitude against O'Neil's solution
"""Pressure field of a focused bowl and comparison with O'Neil's solution.

A 1 MHz bowl with 20 mm aperture radius and 50 mm radius of curvature in
water. Left: field amplitude in the x-z plane from the Rayleigh integral.
Right: on-axis amplitude against the closed-form result.
"""

import matplotlib.pyplot as plt
import numpy as np

import sonosk as sk
import sonosk.plot as splt
from sonosk import analytic, field, transducer

F = 1e6
C = 1500.0
A = 20e-3
ROC = 50e-3


def compute():
    src = transducer.bowl(A, ROC, dx=C / F / 6)
    g = sk.Grid.centered((121, 181), 0.25e-3, depth_start=20e-3)
    p = field.rayleigh_grid(src, g, F, C)
    z = np.linspace(10e-3, 90e-3, 600)
    pz = np.abs(field.rayleigh(src, np.stack([0 * z, 0 * z, z], 1), F, C))
    return g, p, z, pz, analytic.bowl_axial(z, A, ROC, F, C)


def figure():
    g, p, z, pz, pa = compute()
    x, zz = g.axes()
    fig, ax = plt.subplots(1, 2, figsize=(10, 4.2), constrained_layout=True,
                           gridspec_kw={"width_ratios": [1, 1.4]})
    h = splt.field_map(p, x, zz, ax=ax[0], title="|p| [dB]")
    fig.colorbar(h, ax=ax[0], shrink=0.8)
    ax[1].plot(z * 1e3, pz, label="Rayleigh integral")
    ax[1].plot(z * 1e3, pa, "--", label="O'Neil (1949)")
    ax[1].set_xlabel("z [mm]")
    ax[1].set_ylabel("|p| / (rho c u0)")
    ax[1].set_title(f"On axis, focal gain kh = {analytic.focal_gain(A, ROC, F, C):.1f}")
    ax[1].legend()
    ax[1].grid(alpha=0.3)
    return fig


if __name__ == "__main__":
    figure()
    plt.show()

Linear array beams

Focused, steered and apodized beams of a linear array
"""CW beams of a linear array: focused, focused and steered, and with a
Hann apodization.

128 elements, 0.3 mm pitch, 5 mm high with a 20 mm elevation lens, at
5 MHz in a medium with 1540 m/s and 0.5 dB/(cm MHz). Field amplitude in
the x-z plane, normalised in each panel.
"""

import matplotlib.pyplot as plt
import numpy as np

import sonosk as sk
import sonosk.plot as splt
from sonosk import field, transducer

F = 5e6
C = 1540.0


def compute():
    arr = transducer.linear_array(128, 0.3e-3, height=5e-3, elev_focus=20e-3, dx=0.15e-3)
    g = sk.Grid.centered((121, 151), 0.2e-3, depth_start=2e-3)
    out = []
    arr.focus((0, 0, 20e-3), C)
    out.append(("focus 20 mm", field.rayleigh_grid(arr, g, F, C, alpha0=0.5)))
    fp = 20e-3 * np.array([np.sin(np.deg2rad(20)), 0, np.cos(np.deg2rad(20))])
    arr.focus(fp, C)
    out.append(("focus 20 mm, steered 20 deg", field.rayleigh_grid(arr, g, F, C, alpha0=0.5)))
    arr.focus((0, 0, 20e-3), C).set_apodization(transducer.apodization(128, "hann"))
    out.append(("focus 20 mm, Hann apodization", field.rayleigh_grid(arr, g, F, C, alpha0=0.5)))
    return g, out


def figure():
    g, out = compute()
    x, z = g.axes()
    fig, ax = plt.subplots(1, 3, figsize=(11, 4.2), constrained_layout=True)
    for a, (title, p) in zip(ax, out):
        h = splt.field_map(p, x, z, ax=a, title=title, dynamic_range=40)
    fig.colorbar(h, ax=ax, shrink=0.8, label="dB")
    return fig


if __name__ == "__main__":
    figure()
    plt.show()

Line-focus source

Field of a cylindrically focused source in two planes
"""Field of a cylindrically focused source, the geometry of line-focused
aesthetic HIFU transducers.

A 4 MHz source with 8 mm chord, 12 mm length and 6 mm radius of curvature
focuses to a line 6 mm under the face. The values are an example, not the
data of a commercial device. Left: x-z plane through the middle of the
line. Right: y-z plane through the focus line.
"""

import matplotlib.pyplot as plt
import numpy as np

import sonosk as sk
import sonosk.plot as splt
from sonosk import field, transducer

F = 4e6
C = 1540.0


def compute():
    src = transducer.cylindrical(8e-3, 12e-3, 6e-3, dx=0.08e-3)
    gx = sk.Grid.centered((121, 121), 0.06e-3, depth_start=1e-3)
    pxz = field.rayleigh_grid(src, gx, F, C, alpha0=0.5)
    y = np.linspace(-9e-3, 9e-3, 121)
    z = gx.axis(1)
    yy, zz = np.meshgrid(y, z, indexing="ij")
    pyz = field.rayleigh(src, np.stack([0 * yy.ravel(), yy.ravel(), zz.ravel()], 1),
                         F, C, alpha0=0.5).reshape(yy.shape)
    return gx, pxz, y, pyz


def figure():
    gx, pxz, y, pyz = compute()
    x, z = gx.axes()
    fig, ax = plt.subplots(1, 2, figsize=(10, 4.4), constrained_layout=True)
    splt.field_map(pxz, x, z, ax=ax[0], title="x-z plane", dynamic_range=30)
    h = splt.field_map(pyz, y, z, ax=ax[1], title="y-z plane", dynamic_range=30)
    ax[1].set_xlabel("y [mm]")
    fig.colorbar(h, ax=ax, shrink=0.8, label="dB")
    return fig


if __name__ == "__main__":
    figure()
    plt.show()

Element design with the Mason model

Electrical impedance and pulse-echo response with and without a matching layer
"""Design of a 5.7 MHz thickness-mode element with the Mason model.

A 0.4 mm piezoceramic plate (10 mm x 10 mm) on a 3 MRayl backing radiates
into water, without and with a quarter-wave matching layer whose
impedance follows Desilets et al. (1978). The plate constants are typical
of a soft PZT and are only an example; use the data sheet of the material
you build with.
"""

import matplotlib.pyplot as plt
import numpy as np

from sonosk import stack

PLATE = stack.Piezo(thickness=0.4e-3, c=4560.0, rho=7500.0, kt=0.5, eps_r=1470.0,
                    area=1e-4)
F = np.linspace(1e6, 10e6, 2000)


def compute():
    plain = stack.Stack(PLATE, backing=3e6, load=1.5e6)
    zm = stack.matching_impedance(PLATE.impedance, 1.5e6)
    cm = 2500.0
    lay = stack.Layer(stack.quarter_wave(cm, PLATE.f_half_wave), cm, zm / cm)
    matched = stack.Stack(PLATE, backing=3e6, layers=[lay], load=1.5e6)
    return {"no matching layer": plain, f"one layer, {zm / 1e6:.1f} MRayl": matched}


def figure():
    stacks = compute()
    fig, ax = plt.subplots(1, 2, figsize=(10, 3.8), constrained_layout=True)
    for name, s in stacks.items():
        z = s.impedance(F)
        ax[0].semilogy(F / 1e6, np.abs(z), label=name)
        h = s.pulse_echo(F)
        fc, bw = stack.bandwidth(F, h)
        ax[1].plot(F / 1e6, 20 * np.log10(np.abs(h)),
                   label=f"{name}: {fc / 1e6:.2f} MHz, {100 * bw:.0f} %")
    ax[0].set_xlabel("Frequency [MHz]")
    ax[0].set_ylabel("|Z| [ohm]")
    ax[0].set_title("Electrical impedance")
    ax[1].set_xlabel("Frequency [MHz]")
    ax[1].set_ylabel("Pulse-echo response [dB]")
    ax[1].set_ylim(-40, 2)
    ax[1].set_title("Two-way response, -6 dB bandwidth")
    for a in ax:
        a.grid(alpha=0.3)
        a.legend(fontsize=8)
    return fig


if __name__ == "__main__":
    for name, s in compute().items():
        fc, bw = stack.bandwidth(F, s.pulse_echo(F))
        print(f"{name}: centre {fc / 1e6:.2f} MHz, bandwidth {100 * bw:.0f} %")
    figure()
    plt.show()

Mechanical index

On-axis pressure in water and derated, with the depth used for the MI
"""Mechanical index of a focused bowl from its on-axis pressure.

A 3 MHz bowl, 15 mm aperture radius and 40 mm radius of curvature, is
driven so that the peak pressure in water is 2 MPa. The on-axis field is
the O'Neil solution, derated at 0.3 dB/(cm MHz).
"""

import matplotlib.pyplot as plt
import numpy as np

from sonosk import analytic, safety

F = 3e6
A = 15e-3
ROC = 40e-3
P_PEAK = 2.0  # MPa in water


def compute():
    z = np.linspace(2e-3, 80e-3, 2000)
    p = analytic.bowl_axial(z, A, ROC, F)
    pr = P_PEAK * p / p.max()
    z_cm = z * 100.0
    mi, z_sp = safety.mechanical_index(z_cm, pr, F / 1e6)
    pr3 = safety.derate(pr, F / 1e6, z_cm)
    return z_cm, pr, pr3, mi, z_sp


def figure():
    z_cm, pr, pr3, mi, z_sp = compute()

    fig, ax = plt.subplots(figsize=(7, 3.6), constrained_layout=True)
    ax.plot(z_cm, pr, label="water")
    ax.plot(z_cm, pr3, label="derated 0.3 dB/(cm MHz)")
    ax.axvline(z_sp, color="k", lw=0.8, ls="--")
    ax.text(0.03, 0.95, f"MI = {mi:.2f} at z = {z_sp:.2f} cm", transform=ax.transAxes, va="top")
    ax.set_xlabel("Depth [cm]")
    ax.set_ylabel("Peak rarefactional pressure [MPa]")
    ax.set_title("3 MHz focused bowl, a = 15 mm, R = 40 mm")
    ax.legend(loc="upper right")
    ax.grid(alpha=0.3)
    return fig


if __name__ == "__main__":
    z_cm, pr, pr3, mi, z_sp = compute()
    print(f"MI = {mi:.3f} at z = {z_sp:.2f} cm")
    print(safety.check_limits(mi=mi, ispta3=300.0))
    figure()
    plt.show()

Water properties

Sound speed and density of water against temperature
"""Sound speed and density of water between 0 and 95 C."""

import matplotlib.pyplot as plt
import numpy as np

from sonosk import water


def figure():
    t = np.linspace(0.0, 95.0, 400)

    fig, ax = plt.subplots(1, 2, figsize=(9, 3.4), constrained_layout=True)
    ax[0].plot(t, water.sound_speed(t), color="C0")
    ax[0].set_xlabel("Temperature [C]")
    ax[0].set_ylabel("Sound speed [m/s]")
    ax[0].set_title("Marczak (1997)")

    ax[1].plot(t, water.density(t), color="C1")
    ax[1].set_xlabel("Temperature [C]")
    ax[1].set_ylabel("Density [kg/m$^3$]")
    ax[1].set_title("Kell (1975)")

    for a in ax:
        a.grid(alpha=0.3)
    return fig


if __name__ == "__main__":
    figure()
    plt.show()