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 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)