sonosk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sonosk/__init__.py +32 -0
- sonosk/analytic.py +66 -0
- sonosk/field.py +140 -0
- sonosk/grid.py +82 -0
- sonosk/imaging.py +279 -0
- sonosk/medium.py +115 -0
- sonosk/phantom.py +140 -0
- sonosk/plot.py +106 -0
- sonosk/quality.py +80 -0
- sonosk/safety.py +152 -0
- sonosk/stack.py +193 -0
- sonosk/thermal.py +206 -0
- sonosk/transducer.py +302 -0
- sonosk/water.py +43 -0
- sonosk-0.1.0.dist-info/METADATA +141 -0
- sonosk-0.1.0.dist-info/RECORD +18 -0
- sonosk-0.1.0.dist-info/WHEEL +4 -0
- sonosk-0.1.0.dist-info/licenses/LICENSE +674 -0
sonosk/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""SonoSK: simulation of medical ultrasound imaging and therapy.
|
|
2
|
+
|
|
3
|
+
Modules
|
|
4
|
+
-------
|
|
5
|
+
grid regular grids in 1, 2 and 3 dimensions
|
|
6
|
+
transducer single elements, annular, linear, phased, convex, matrix and
|
|
7
|
+
cylindrical sources
|
|
8
|
+
field CW pressure fields (Rayleigh integral, angular spectrum)
|
|
9
|
+
medium materials and property maps
|
|
10
|
+
phantom shapes, label maps and point scatterers
|
|
11
|
+
imaging pulse-echo simulation, beamforming and B-mode images
|
|
12
|
+
thermal heat source, Pennes bioheat equation, thermal dose, lesions
|
|
13
|
+
stack piezoelectric stack model (Mason) for probe design
|
|
14
|
+
safety acoustic output quantities and regulatory limits
|
|
15
|
+
quality resolution, contrast, CNR, gCNR and speckle measures
|
|
16
|
+
analytic closed-form reference fields
|
|
17
|
+
water properties of water
|
|
18
|
+
plot figures (needs matplotlib)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
from . import (analytic, field, imaging, medium, phantom, quality, safety, stack,
|
|
24
|
+
thermal, transducer, water)
|
|
25
|
+
from .grid import Grid
|
|
26
|
+
from .medium import Material, Medium
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Grid", "Material", "Medium",
|
|
30
|
+
"analytic", "field", "imaging", "medium", "phantom", "quality", "safety",
|
|
31
|
+
"stack", "thermal", "transducer", "water", "__version__",
|
|
32
|
+
]
|
sonosk/analytic.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Closed-form on-axis pressure of circular sources in a lossless fluid.
|
|
2
|
+
|
|
3
|
+
These solutions are used as references for the numerical field code.
|
|
4
|
+
Results are pressure amplitudes normalised by p0 = rho c u0, where u0 is
|
|
5
|
+
the normal velocity amplitude of the source surface.
|
|
6
|
+
|
|
7
|
+
References
|
|
8
|
+
----------
|
|
9
|
+
Kinsler L.E., Frey A.R., Coppens A.B., Sanders J.V. Fundamentals of
|
|
10
|
+
Acoustics, 4th ed. Wiley, 2000.
|
|
11
|
+
|
|
12
|
+
O'Neil H.T. Theory of focusing radiators. J. Acoust. Soc. Am.
|
|
13
|
+
21(5):516-526, 1949.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def wavenumber(f, c=1500.0):
|
|
20
|
+
return 2.0 * np.pi * f / c
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def piston_axial(z, a, f, c=1500.0):
|
|
24
|
+
"""Pressure amplitude p/p0 on the axis of a baffled flat piston.
|
|
25
|
+
|
|
26
|
+
z distance from the piston face [m], a piston radius [m],
|
|
27
|
+
f frequency [Hz], c sound speed [m/s].
|
|
28
|
+
"""
|
|
29
|
+
z = np.asarray(z, dtype=float)
|
|
30
|
+
k = wavenumber(f, c)
|
|
31
|
+
return 2.0 * np.abs(np.sin(0.5 * k * (np.sqrt(z * z + a * a) - z)))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def piston_last_maximum(a, f, c=1500.0):
|
|
35
|
+
"""Position of the last on-axis maximum of a flat piston [m]."""
|
|
36
|
+
lam = c / f
|
|
37
|
+
return a * a / lam - lam / 4.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def bowl_depth(a, roc):
|
|
41
|
+
"""Depth h of a spherical bowl of aperture radius a and curvature roc."""
|
|
42
|
+
if a > roc:
|
|
43
|
+
raise ValueError("aperture radius larger than radius of curvature")
|
|
44
|
+
return roc - np.sqrt(roc * roc - a * a)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def focal_gain(a, roc, f, c=1500.0):
|
|
48
|
+
"""Linear pressure gain k h at the geometric focus of a bowl."""
|
|
49
|
+
return wavenumber(f, c) * bowl_depth(a, roc)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bowl_axial(z, a, roc, f, c=1500.0):
|
|
53
|
+
"""Pressure amplitude p/p0 on the axis of a focused bowl (O'Neil 1949).
|
|
54
|
+
|
|
55
|
+
z is measured from the apex of the bowl [m], a is the aperture radius
|
|
56
|
+
and roc the radius of curvature [m]. The geometric focus is at z = roc.
|
|
57
|
+
"""
|
|
58
|
+
z = np.asarray(z, dtype=float)
|
|
59
|
+
k = wavenumber(f, c)
|
|
60
|
+
h = bowl_depth(a, roc)
|
|
61
|
+
r = np.sqrt((z - h) ** 2 + a * a)
|
|
62
|
+
q = 1.0 - z / roc
|
|
63
|
+
near = np.abs(q) < 1e-9
|
|
64
|
+
qs = np.where(near, 1.0, q)
|
|
65
|
+
p = np.abs(2.0 / qs * np.sin(0.5 * k * (r - z)))
|
|
66
|
+
return np.where(near, k * h, p)
|
sonosk/field.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Continuous-wave pressure fields.
|
|
2
|
+
|
|
3
|
+
rayleigh() sums the Rayleigh integral over the patches of a transducer,
|
|
4
|
+
|
|
5
|
+
p(r) / p0 = (j k / 2 pi) sum_n w_n A_n exp(-(j k + a) R_n) / R_n,
|
|
6
|
+
|
|
7
|
+
where p0 = rho c u0, w_n is the complex patch weight (apodization and
|
|
8
|
+
delay), A_n the patch area, R_n the distance and a the amplitude
|
|
9
|
+
attenuation of a homogeneous medium. For curved sources this is the usual
|
|
10
|
+
approximation that treats each patch as part of a baffled plane.
|
|
11
|
+
|
|
12
|
+
angular_spectrum() propagates a field given on a plane by the angular
|
|
13
|
+
spectrum method.
|
|
14
|
+
|
|
15
|
+
Time convention: exp(j omega t).
|
|
16
|
+
|
|
17
|
+
References
|
|
18
|
+
----------
|
|
19
|
+
O'Neil H.T. Theory of focusing radiators. J. Acoust. Soc. Am.
|
|
20
|
+
21(5):516-526, 1949.
|
|
21
|
+
|
|
22
|
+
Zeng X., McGough R.J. Evaluation of the angular spectrum approach for
|
|
23
|
+
simulations of near-field pressures. J. Acoust. Soc. Am. 123(1):68-76, 2008.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from .medium import attenuation_np
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rayleigh(transducer, points, f, c=1540.0, alpha0=0.0, y=1.0, max_pairs=4_000_000):
|
|
32
|
+
"""Complex pressure p/p0 at points.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
transducer : Transducer
|
|
37
|
+
points : (M, 3) array
|
|
38
|
+
Field points [m].
|
|
39
|
+
f : float
|
|
40
|
+
Frequency [Hz].
|
|
41
|
+
c : float
|
|
42
|
+
Sound speed [m/s].
|
|
43
|
+
alpha0, y : float
|
|
44
|
+
Power-law attenuation of the medium, alpha0 in dB/(cm MHz^y).
|
|
45
|
+
max_pairs : int
|
|
46
|
+
Number of point-patch pairs handled at once; lower it if memory is short.
|
|
47
|
+
"""
|
|
48
|
+
pts = np.asarray(points, dtype=float).reshape(-1, 3)
|
|
49
|
+
k = 2 * np.pi * f / c
|
|
50
|
+
a = float(attenuation_np(alpha0, f, y))
|
|
51
|
+
src = transducer.pos
|
|
52
|
+
w = transducer.weights(f) * transducer.area
|
|
53
|
+
g = 1j * k / (2 * np.pi)
|
|
54
|
+
|
|
55
|
+
out = np.empty(pts.shape[0], dtype=complex)
|
|
56
|
+
step = max(1, max_pairs // src.shape[0])
|
|
57
|
+
for i in range(0, pts.shape[0], step):
|
|
58
|
+
d = pts[i:i + step, None, :] - src[None, :, :]
|
|
59
|
+
r = np.sqrt(np.einsum("ijk,ijk->ij", d, d))
|
|
60
|
+
r = np.maximum(r, 1e-12)
|
|
61
|
+
out[i:i + step] = (np.exp(-(1j * k + a) * r) / r) @ w
|
|
62
|
+
return g * out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def grid_points(grid, y=0.0):
|
|
66
|
+
"""Field points for a grid: 1-D on the z axis, 2-D in the x-z plane at y,
|
|
67
|
+
3-D everywhere. Returns an (N, 3) array."""
|
|
68
|
+
if grid.ndim == 1:
|
|
69
|
+
z = grid.axis(0)
|
|
70
|
+
return np.stack([np.zeros_like(z), np.zeros_like(z), z], 1)
|
|
71
|
+
if grid.ndim == 2:
|
|
72
|
+
x, z = grid.mesh()
|
|
73
|
+
return np.stack([x.ravel(), np.full(x.size, y), z.ravel()], 1)
|
|
74
|
+
x, yy, z = grid.mesh()
|
|
75
|
+
return np.stack([x.ravel(), yy.ravel(), z.ravel()], 1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def rayleigh_grid(transducer, grid, f, c=1540.0, alpha0=0.0, y=1.0, plane_y=0.0, **kw):
|
|
79
|
+
"""rayleigh() evaluated on a Grid; result has the grid shape."""
|
|
80
|
+
p = rayleigh(transducer, grid_points(grid, plane_y), f, c, alpha0, y, **kw)
|
|
81
|
+
return p.reshape(grid.shape)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def angular_spectrum(p0, dx, f, z, c=1540.0, alpha0=0.0, y=1.0, pad=2):
|
|
85
|
+
"""Propagate a complex field from the plane z = 0 to the depths z.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
p0 : 1-D or 2-D complex array
|
|
90
|
+
Field on a line (x) or a plane (x, y). A 1-D input is a field that
|
|
91
|
+
does not change along y, so the result is a 2-D (x, z) problem.
|
|
92
|
+
dx : float
|
|
93
|
+
Sample spacing of p0 [m], the same along both axes.
|
|
94
|
+
f : float
|
|
95
|
+
Frequency [Hz].
|
|
96
|
+
z : array
|
|
97
|
+
Depths [m], measured from the input plane.
|
|
98
|
+
pad : int
|
|
99
|
+
Zero padding factor against wrap-around.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
array of shape (len(z),) + p0.shape
|
|
104
|
+
"""
|
|
105
|
+
p0 = np.asarray(p0, dtype=complex)
|
|
106
|
+
z = np.atleast_1d(np.asarray(z, dtype=float))
|
|
107
|
+
k = 2 * np.pi * f / c
|
|
108
|
+
a = float(attenuation_np(alpha0, f, y))
|
|
109
|
+
|
|
110
|
+
shape = tuple(pad * n for n in p0.shape)
|
|
111
|
+
axes = tuple(range(p0.ndim))
|
|
112
|
+
spec = np.fft.fftn(p0, shape, axes=axes)
|
|
113
|
+
ks = [2 * np.pi * np.fft.fftfreq(n, dx) for n in shape]
|
|
114
|
+
kk = np.meshgrid(*ks, indexing="ij")
|
|
115
|
+
kt2 = sum(q * q for q in kk)
|
|
116
|
+
kz = np.sqrt((k * k - kt2).astype(complex))
|
|
117
|
+
kz = np.where(kz.imag > 0, np.conj(kz), kz) # evanescent waves decay
|
|
118
|
+
|
|
119
|
+
out = np.empty((z.size,) + p0.shape, dtype=complex)
|
|
120
|
+
sl = tuple(slice(0, n) for n in p0.shape)
|
|
121
|
+
for i, zi in enumerate(z):
|
|
122
|
+
q = np.fft.ifftn(spec * np.exp(-1j * kz * zi), axes=axes)
|
|
123
|
+
out[i] = q[sl] * np.exp(-a * zi)
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def scale_to_pressure(p_rel, rho=1000.0, c=1540.0, u0=None, power=None, area=None):
|
|
128
|
+
"""Convert p/p0 to pascals from the surface velocity u0 [m/s] or from the
|
|
129
|
+
radiated acoustic power [W] of a uniform plane source of given area [m^2]."""
|
|
130
|
+
if u0 is None:
|
|
131
|
+
if power is None or area is None:
|
|
132
|
+
raise ValueError("give u0, or power and area")
|
|
133
|
+
u0 = np.sqrt(2 * power / (rho * c * area))
|
|
134
|
+
return np.asarray(p_rel) * rho * c * u0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def intensity(p, rho=1000.0, c=1540.0):
|
|
138
|
+
"""Time-average intensity [W/m^2] of a CW pressure amplitude p [Pa]
|
|
139
|
+
in the plane-wave approximation."""
|
|
140
|
+
return np.abs(p) ** 2 / (2 * rho * c)
|
sonosk/grid.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Regular computational grids in 1, 2 and 3 dimensions.
|
|
2
|
+
|
|
3
|
+
Units are SI (metres). The last axis is depth (z), so a 2-D grid is
|
|
4
|
+
(x, z) and a 3-D grid is (x, y, z).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Grid:
|
|
11
|
+
"""Regular grid.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
shape : int or tuple of int
|
|
16
|
+
Number of points along each axis.
|
|
17
|
+
spacing : float or sequence of float
|
|
18
|
+
Point spacing along each axis [m].
|
|
19
|
+
origin : float or sequence of float, optional
|
|
20
|
+
Coordinate of the first point on each axis [m]. Default 0.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, shape, spacing, origin=0.0):
|
|
24
|
+
shape = tuple(int(n) for n in np.atleast_1d(shape))
|
|
25
|
+
nd = len(shape)
|
|
26
|
+
if nd < 1 or nd > 3:
|
|
27
|
+
raise ValueError("grid must have 1, 2 or 3 dimensions")
|
|
28
|
+
if min(shape) < 1:
|
|
29
|
+
raise ValueError("shape must be positive")
|
|
30
|
+
|
|
31
|
+
spacing = np.broadcast_to(np.asarray(spacing, dtype=float), (nd,)).copy()
|
|
32
|
+
if np.any(spacing <= 0):
|
|
33
|
+
raise ValueError("spacing must be positive")
|
|
34
|
+
origin = np.broadcast_to(np.asarray(origin, dtype=float), (nd,)).copy()
|
|
35
|
+
|
|
36
|
+
self.shape = shape
|
|
37
|
+
self.spacing = spacing
|
|
38
|
+
self.origin = origin
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def centered(cls, shape, spacing, depth_start=0.0):
|
|
42
|
+
"""Grid centred on the depth axis; depth starts at depth_start."""
|
|
43
|
+
g = cls(shape, spacing)
|
|
44
|
+
n = np.array(g.shape)
|
|
45
|
+
origin = -0.5 * g.spacing * (n - 1)
|
|
46
|
+
origin[-1] = depth_start
|
|
47
|
+
g.origin = origin
|
|
48
|
+
return g
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def ndim(self):
|
|
52
|
+
return len(self.shape)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def size(self):
|
|
56
|
+
return int(np.prod(self.shape))
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def extent(self):
|
|
60
|
+
"""Length covered along each axis [m]."""
|
|
61
|
+
return self.spacing * (np.array(self.shape) - 1)
|
|
62
|
+
|
|
63
|
+
def axis(self, i):
|
|
64
|
+
return self.origin[i] + self.spacing[i] * np.arange(self.shape[i])
|
|
65
|
+
|
|
66
|
+
def axes(self):
|
|
67
|
+
return [self.axis(i) for i in range(self.ndim)]
|
|
68
|
+
|
|
69
|
+
def mesh(self):
|
|
70
|
+
return np.meshgrid(*self.axes(), indexing="ij")
|
|
71
|
+
|
|
72
|
+
def points(self):
|
|
73
|
+
"""All grid points as an (N, ndim) array."""
|
|
74
|
+
return np.stack([m.ravel() for m in self.mesh()], axis=1)
|
|
75
|
+
|
|
76
|
+
def points_per_wavelength(self, c, f):
|
|
77
|
+
"""Smallest number of points per wavelength for speed c and frequency f."""
|
|
78
|
+
return float(np.min(c) / f / self.spacing.max())
|
|
79
|
+
|
|
80
|
+
def __repr__(self):
|
|
81
|
+
sp = ", ".join(f"{s * 1e3:g}" for s in self.spacing)
|
|
82
|
+
return f"Grid(shape={self.shape}, spacing=({sp}) mm)"
|
sonosk/imaging.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Pulse-echo simulation and image formation.
|
|
2
|
+
|
|
3
|
+
The forward model is linear (first-order Born scattering) in a homogeneous
|
|
4
|
+
medium with sound speed c:
|
|
5
|
+
|
|
6
|
+
- the transmit wave is described by a virtual source: a plane wave with a
|
|
7
|
+
steering angle, a diverging wave from a point behind the array, or a
|
|
8
|
+
wave focused on a point in front of it;
|
|
9
|
+
- each receive element is a point receiver with the directivity of a
|
|
10
|
+
rectangular element in a soft baffle, sinc(w sin(theta) / lambda) cos(theta);
|
|
11
|
+
- spreading is 1/R on the receive path and 1/R from a diverging source;
|
|
12
|
+
attenuation is applied at the centre frequency along both paths.
|
|
13
|
+
|
|
14
|
+
The echo of every scatterer is a copy of the pulse delayed by its travel
|
|
15
|
+
time. Signals are kept in analytic (complex) form, so the envelope is the
|
|
16
|
+
modulus of the beamformed image.
|
|
17
|
+
|
|
18
|
+
Beamforming is delay-and-sum with a receive f-number. Images from several
|
|
19
|
+
transmits can be summed (coherent compounding).
|
|
20
|
+
|
|
21
|
+
References
|
|
22
|
+
----------
|
|
23
|
+
Montaldo G., Tanter M., Bercoff J., Benech N., Fink M. Coherent plane-wave
|
|
24
|
+
compounding for very high frame rate ultrasonography and transient
|
|
25
|
+
elastography. IEEE Trans. Ultrason. Ferroelectr. Freq. Control
|
|
26
|
+
56(3):489-506, 2009.
|
|
27
|
+
|
|
28
|
+
Garcia D. SIMUS: an open-source simulator for medical ultrasound imaging.
|
|
29
|
+
Part I: theory & examples. Comput. Methods Programs Biomed. 218:106726, 2022.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
from .medium import attenuation_np
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Pulse:
|
|
41
|
+
"""Gaussian-modulated pulse.
|
|
42
|
+
|
|
43
|
+
f0 centre frequency [Hz], bandwidth fractional -6 dB bandwidth of the
|
|
44
|
+
spectrum (0.6 means 60 %).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
f0: float
|
|
48
|
+
bandwidth: float = 0.6
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def sigma(self):
|
|
52
|
+
# spectrum exp(-(2 pi df sigma)^2 / 2) is 0.5 at df = bandwidth f0 / 2
|
|
53
|
+
return np.sqrt(2 * np.log(2)) / (np.pi * self.bandwidth * self.f0)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def duration(self):
|
|
57
|
+
"""Support used for the simulation, 8 sigma [s]."""
|
|
58
|
+
return 8 * self.sigma
|
|
59
|
+
|
|
60
|
+
def analytic(self, t):
|
|
61
|
+
t = np.asarray(t, dtype=float)
|
|
62
|
+
return np.exp(-0.5 * (t / self.sigma) ** 2 + 2j * np.pi * self.f0 * t)
|
|
63
|
+
|
|
64
|
+
def __call__(self, t):
|
|
65
|
+
return self.analytic(t).real
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Transmit:
|
|
69
|
+
"""Transmit wave described by a virtual source.
|
|
70
|
+
|
|
71
|
+
Use the constructors plane(), diverging() and focused().
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, kind, angle=0.0, source=None):
|
|
75
|
+
self.kind = kind
|
|
76
|
+
self.angle = float(angle)
|
|
77
|
+
self.source = None if source is None else np.asarray(source, dtype=float)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def plane(cls, angle=0.0):
|
|
81
|
+
"""Plane wave with steering angle [rad] in the x-z plane."""
|
|
82
|
+
return cls("plane", angle=angle)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def diverging(cls, source):
|
|
86
|
+
"""Diverging wave from a virtual point source behind the array (z < 0)."""
|
|
87
|
+
return cls("diverging", source=source)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def focused(cls, point):
|
|
91
|
+
"""Wave focused on point (z > 0)."""
|
|
92
|
+
return cls("focused", source=point)
|
|
93
|
+
|
|
94
|
+
def element_delays(self, elements, c):
|
|
95
|
+
"""Firing delays [s] of the elements, the first element at 0."""
|
|
96
|
+
e = np.asarray(elements, dtype=float)
|
|
97
|
+
if self.kind == "plane":
|
|
98
|
+
s = e @ self._dir()
|
|
99
|
+
return (s - s.min()) / c
|
|
100
|
+
d = np.linalg.norm(e - self.source, axis=1)
|
|
101
|
+
if self.kind == "diverging":
|
|
102
|
+
return (d - d.min()) / c
|
|
103
|
+
return (d.max() - d) / c
|
|
104
|
+
|
|
105
|
+
def _dir(self):
|
|
106
|
+
return np.array([np.sin(self.angle), 0.0, np.cos(self.angle)])
|
|
107
|
+
|
|
108
|
+
def travel(self, points, elements, c):
|
|
109
|
+
"""Arrival time [s] of the transmit wave and its path length [m]
|
|
110
|
+
and amplitude at points, with time zero at the first firing."""
|
|
111
|
+
p = np.asarray(points, dtype=float).reshape(-1, 3)
|
|
112
|
+
e = np.asarray(elements, dtype=float)
|
|
113
|
+
if self.kind == "plane":
|
|
114
|
+
n = self._dir()
|
|
115
|
+
s0 = (e @ n).min()
|
|
116
|
+
path = p @ n - s0
|
|
117
|
+
# insonified strip: trace the point back to the array along n
|
|
118
|
+
x0 = p[:, 0] - p[:, 2] * np.tan(self.angle)
|
|
119
|
+
amp = ((x0 >= e[:, 0].min()) & (x0 <= e[:, 0].max())).astype(float)
|
|
120
|
+
return path / c, path, amp
|
|
121
|
+
d_e = np.linalg.norm(e - self.source, axis=1)
|
|
122
|
+
r = np.linalg.norm(p - self.source, axis=1)
|
|
123
|
+
if self.kind == "diverging":
|
|
124
|
+
path = r - d_e.min()
|
|
125
|
+
amp = d_e.min() / np.maximum(r, 1e-9)
|
|
126
|
+
return path / c, path, amp
|
|
127
|
+
beyond = p[:, 2] > self.source[2]
|
|
128
|
+
path = d_e.max() + np.where(beyond, r, -r)
|
|
129
|
+
amp = np.ones(p.shape[0])
|
|
130
|
+
return path / c, path, amp
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _directivity(sin_t, cos_t, width, f0, c):
|
|
134
|
+
return np.sinc(width * sin_t * f0 / c) * cos_t
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _element_frame(array):
|
|
138
|
+
e = array.centers
|
|
139
|
+
n = array.element_normals
|
|
140
|
+
return e, n
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def simulate(array, transmit, pos, amp, pulse, c=1540.0, fs=None, alpha0=0.0,
|
|
144
|
+
depth=None, oversample=4, element_chunk=16):
|
|
145
|
+
"""Simulate the received signals of one transmit.
|
|
146
|
+
|
|
147
|
+
Every echo is placed as an impulse on a time axis oversample times
|
|
148
|
+
finer than fs, split between the two nearest samples, then convolved
|
|
149
|
+
with the pulse and decimated to fs.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
array : Transducer
|
|
154
|
+
Linear, phased, convex or matrix array.
|
|
155
|
+
transmit : Transmit
|
|
156
|
+
pos, amp : arrays
|
|
157
|
+
Scatterer positions (N, 3) [m] and amplitudes (N,).
|
|
158
|
+
pulse : Pulse
|
|
159
|
+
Two-way pulse (the electrical excitation convolved with both
|
|
160
|
+
transducer responses).
|
|
161
|
+
c : float
|
|
162
|
+
Sound speed [m/s].
|
|
163
|
+
fs : float, optional
|
|
164
|
+
Sampling frequency of the output [Hz], default 8 f0.
|
|
165
|
+
alpha0 : float
|
|
166
|
+
Attenuation in dB/(cm MHz), applied at f0.
|
|
167
|
+
depth : float, optional
|
|
168
|
+
Record at least down to this depth [m].
|
|
169
|
+
oversample : int
|
|
170
|
+
Ratio of the internal sampling rate to fs.
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
rf : complex array (n_elements, n_samples)
|
|
175
|
+
Analytic received signals; rf.real is the RF signal.
|
|
176
|
+
fs : float
|
|
177
|
+
"""
|
|
178
|
+
pos = np.asarray(pos, dtype=float).reshape(-1, 3)
|
|
179
|
+
amp = np.asarray(amp, dtype=float).ravel()
|
|
180
|
+
fs = 8 * pulse.f0 if fs is None else float(fs)
|
|
181
|
+
fh = fs * oversample
|
|
182
|
+
e, n = _element_frame(array)
|
|
183
|
+
a = float(attenuation_np(alpha0, pulse.f0, 1.0))
|
|
184
|
+
|
|
185
|
+
t_tx, path_tx, amp_tx = transmit.travel(pos, e, c)
|
|
186
|
+
em = e.mean(axis=0)
|
|
187
|
+
rmax = np.linalg.norm(pos - em, axis=1).max() + np.linalg.norm(e - em, axis=1).max()
|
|
188
|
+
t_end = t_tx.max() + rmax / c + pulse.duration
|
|
189
|
+
if depth is not None:
|
|
190
|
+
t_end = max(t_end, 2 * depth / c + pulse.duration)
|
|
191
|
+
ns = int(np.ceil(t_end * fs)) + 1
|
|
192
|
+
nh = ns * oversample
|
|
193
|
+
|
|
194
|
+
base = amp * amp_tx * np.exp(-a * path_tx)
|
|
195
|
+
imp = np.zeros((array.n_elements, nh))
|
|
196
|
+
for i0 in range(0, array.n_elements, element_chunk):
|
|
197
|
+
ee = e[i0:i0 + element_chunk]
|
|
198
|
+
nn = n[i0:i0 + element_chunk]
|
|
199
|
+
d = pos[None, :, :] - ee[:, None, :]
|
|
200
|
+
r = np.linalg.norm(d, axis=2)
|
|
201
|
+
cos_t = np.einsum("eij,ej->ei", d, nn) / r
|
|
202
|
+
sin_t = np.sqrt(np.clip(1 - cos_t ** 2, 0, 1))
|
|
203
|
+
g = base * _directivity(sin_t, cos_t, array.width, pulse.f0, c) * np.exp(-a * r) / r
|
|
204
|
+
g = np.where(cos_t > 0, g, 0.0)
|
|
205
|
+
s = (t_tx[None, :] + r / c) * fh
|
|
206
|
+
k0 = np.floor(s).astype(int)
|
|
207
|
+
fr = s - k0
|
|
208
|
+
for j in range(ee.shape[0]):
|
|
209
|
+
ok = (k0[j] >= 0) & (k0[j] < nh - 1)
|
|
210
|
+
k = k0[j][ok]
|
|
211
|
+
w = g[j][ok]
|
|
212
|
+
imp[i0 + j] = (np.bincount(k, w * (1 - fr[j][ok]), nh)
|
|
213
|
+
+ np.bincount(k + 1, w * fr[j][ok], nh))
|
|
214
|
+
|
|
215
|
+
half = int(np.ceil(0.5 * pulse.duration * fh))
|
|
216
|
+
kern = pulse.analytic(np.arange(-half, half + 1) / fh)
|
|
217
|
+
nfft = 1 << int(np.ceil(np.log2(nh + kern.size)))
|
|
218
|
+
spec = np.fft.fft(imp, nfft, axis=1) * np.fft.fft(kern, nfft)[None, :]
|
|
219
|
+
full = np.fft.ifft(spec, axis=1)[:, half:half + nh]
|
|
220
|
+
return full[:, ::oversample].copy(), fs
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def beamform(rf, fs, array, transmit, x, z, c=1540.0, f_number=1.5, window="hann", y=0.0):
|
|
224
|
+
"""Delay-and-sum image on the grid x by z (2-D, at elevation y).
|
|
225
|
+
|
|
226
|
+
Returns the complex image of shape (len(z), len(x)); its modulus is the
|
|
227
|
+
envelope.
|
|
228
|
+
"""
|
|
229
|
+
x = np.asarray(x, dtype=float)
|
|
230
|
+
z = np.asarray(z, dtype=float)
|
|
231
|
+
zz, xx = np.meshgrid(z, x, indexing="ij")
|
|
232
|
+
pts = np.stack([xx.ravel(), np.full(xx.size, y), zz.ravel()], 1)
|
|
233
|
+
e, n = _element_frame(array)
|
|
234
|
+
t_tx, _, _ = transmit.travel(pts, e, c)
|
|
235
|
+
|
|
236
|
+
img = np.zeros(pts.shape[0], dtype=complex)
|
|
237
|
+
ns = rf.shape[1]
|
|
238
|
+
for i in range(array.n_elements):
|
|
239
|
+
d = pts - e[i]
|
|
240
|
+
r = np.linalg.norm(d, axis=1)
|
|
241
|
+
ax = d @ n[i]
|
|
242
|
+
lat = np.sqrt(np.maximum(r * r - ax * ax, 0))
|
|
243
|
+
u = np.where(ax > 0, lat * f_number / np.maximum(ax, 1e-12), np.inf)
|
|
244
|
+
if window == "hann":
|
|
245
|
+
w = np.where(u <= 0.5, np.cos(np.pi * u) ** 2, 0.0)
|
|
246
|
+
else:
|
|
247
|
+
w = (u <= 0.5).astype(float)
|
|
248
|
+
if not w.any():
|
|
249
|
+
continue
|
|
250
|
+
s = (t_tx + r / c) * fs
|
|
251
|
+
k0 = np.floor(s).astype(int)
|
|
252
|
+
fr = s - k0
|
|
253
|
+
ok = (k0 >= 0) & (k0 < ns - 1) & (w > 0)
|
|
254
|
+
k = k0[ok]
|
|
255
|
+
v = rf[i, k] * (1 - fr[ok]) + rf[i, k + 1] * fr[ok]
|
|
256
|
+
img[ok] += w[ok] * v
|
|
257
|
+
return img.reshape(zz.shape)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def compound(rfs, fs, array, transmits, x, z, c=1540.0, **kw):
|
|
261
|
+
"""Sum of beamformed images over several transmits."""
|
|
262
|
+
out = 0
|
|
263
|
+
for rf, tx in zip(rfs, transmits):
|
|
264
|
+
out = out + beamform(rf, fs, array, tx, x, z, c, **kw)
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def log_compress(image, dynamic_range=60.0):
|
|
269
|
+
"""Envelope in dB relative to its maximum, clipped at -dynamic_range."""
|
|
270
|
+
env = np.abs(image)
|
|
271
|
+
db = 20 * np.log10(np.maximum(env / env.max(), 1e-12))
|
|
272
|
+
return np.maximum(db, -dynamic_range)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def plane_wave_angles(n, span):
|
|
276
|
+
"""n steering angles [rad] spread evenly over +-span/2."""
|
|
277
|
+
if n == 1:
|
|
278
|
+
return np.array([0.0])
|
|
279
|
+
return np.linspace(-span / 2, span / 2, n)
|