fullwave25 1.4.2.dev4__py3-none-any.whl → 1.5.0.dev0__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.
- fullwave/__init__.py +1 -1
- fullwave/beamformer/__init__.py +2 -1
- fullwave/beamformer/mach_beamformer.py +533 -0
- fullwave/grid.py +15 -0
- fullwave/medium.py +43 -21
- fullwave/medium_builder/presets/__init__.py +8 -1
- fullwave/medium_builder/presets/combined_phantom_map.py +146 -0
- fullwave/medium_builder/presets/domain_combined_phantom.py +316 -0
- fullwave/solver/binary_manager.py +1 -1
- fullwave/solver/binary_markers.py +29 -0
- fullwave/solver/bins/database/relaxation_params_database_num_relax=4_20261102_1540.mat +0 -0
- fullwave/solver/bins/database/relaxation_params_database_num_relax=4_20261102_1540_invalid_cells.json +4096 -0
- fullwave/solver/input_file_writer.py +25 -0
- fullwave/solver/pml_builder.py +2 -0
- fullwave/solver/shipped_database.py +1 -1
- fullwave/solver/solver.py +149 -52
- fullwave/solver/source_type.py +27 -0
- fullwave/source.py +39 -1
- fullwave/transducer.py +122 -10
- fullwave/ultra/backend.py +47 -60
- fullwave/ultra/backend_2d.py +23 -12
- fullwave/ultra/binary.py +4 -0
- fullwave/ultra/dat_io.py +15 -0
- fullwave/ultra/writer_2d.py +38 -0
- fullwave/ultra/writer_3d.py +37 -0
- fullwave/utils/plot_utils.py +158 -4
- fullwave/utils/relaxation_parameters.py +193 -21
- fullwave25-1.5.0.dev0.dist-info/METADATA +350 -0
- {fullwave25-1.4.2.dev4.dist-info → fullwave25-1.5.0.dev0.dist-info}/RECORD +31 -26
- fullwave25-1.4.2.dev4.dist-info/METADATA +0 -514
- {fullwave25-1.4.2.dev4.dist-info → fullwave25-1.5.0.dev0.dist-info}/WHEEL +0 -0
- {fullwave25-1.4.2.dev4.dist-info → fullwave25-1.5.0.dev0.dist-info}/licenses/LICENSE +0 -0
fullwave/__init__.py
CHANGED
|
@@ -66,7 +66,7 @@ try:
|
|
|
66
66
|
__version__ = version("fullwave")
|
|
67
67
|
except PackageNotFoundError:
|
|
68
68
|
# Update via bump-my-version, not manually
|
|
69
|
-
__version__ = "1.
|
|
69
|
+
__version__ = "1.5.0-dev0"
|
|
70
70
|
|
|
71
71
|
VERSION = __version__ # for convenience
|
|
72
72
|
logger.info("Fullwave version: %s", __version__)
|
fullwave/beamformer/__init__.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""beamformer package."""
|
|
2
2
|
|
|
3
3
|
from .beamformer import Beamformer
|
|
4
|
+
from .mach_beamformer import MachBeamformer, face_crossing_time_s, receiver_positions_m
|
|
4
5
|
|
|
5
|
-
__all__ = ["Beamformer"]
|
|
6
|
+
__all__ = ["Beamformer", "MachBeamformer", "face_crossing_time_s", "receiver_positions_m"]
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""Delay-and-sum beamformer on the GPU, built on mach.
|
|
2
|
+
|
|
3
|
+
mach (https://github.com/Forest-Neurotech/mach) is an open-source GPU beamformer with an
|
|
4
|
+
optimized delay-and-sum CUDA kernel. mach, pymust and cupy come with the optional
|
|
5
|
+
extra ``fullwave25[mach]``. This module imports without them; ``MachBeamformer.beamform``
|
|
6
|
+
raises an ImportError that names the extra.
|
|
7
|
+
|
|
8
|
+
Conventions
|
|
9
|
+
-----------
|
|
10
|
+
Positions are ``(lateral, elevation, depth)`` in meters. Depth 0 is the probe face
|
|
11
|
+
(``Transducer.face_row_px``), and depth grows into the medium. A flat array has its
|
|
12
|
+
receivers one grid row behind the face, so their depth is ``-dx``.
|
|
13
|
+
|
|
14
|
+
Transmit arrival times are in seconds, measured from the moment the earliest firing
|
|
15
|
+
element has its pulse at the probe face. ``time_zero_s`` is that moment in the clock of
|
|
16
|
+
the recorded traces, whose first sample is the first time step of the simulation.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import functools
|
|
22
|
+
import importlib
|
|
23
|
+
import logging
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
from scipy.signal import hilbert
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from types import ModuleType
|
|
31
|
+
|
|
32
|
+
from numpy.typing import NDArray
|
|
33
|
+
|
|
34
|
+
import fullwave
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("__main__." + __name__)
|
|
37
|
+
|
|
38
|
+
MACH_INSTALL_HINT = (
|
|
39
|
+
"MachBeamformer needs mach-beamform, pymust and cupy. "
|
|
40
|
+
"Install them with: pip install 'fullwave25[mach]'"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@functools.cache
|
|
45
|
+
def _load_mach_modules() -> tuple[ModuleType, ModuleType, ModuleType]:
|
|
46
|
+
"""Import cupy, pymust and mach.experimental, or raise a clear ImportError."""
|
|
47
|
+
try:
|
|
48
|
+
cupy = importlib.import_module("cupy")
|
|
49
|
+
pymust = importlib.import_module("pymust")
|
|
50
|
+
experimental = importlib.import_module("mach.experimental")
|
|
51
|
+
except ImportError as error:
|
|
52
|
+
logger.error(MACH_INSTALL_HINT)
|
|
53
|
+
raise ImportError(MACH_INSTALL_HINT) from error
|
|
54
|
+
return cupy, pymust, experimental
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def plane_wave_arrival_s(
|
|
58
|
+
points_m: NDArray[np.float64],
|
|
59
|
+
element_positions_m: NDArray[np.float64],
|
|
60
|
+
*,
|
|
61
|
+
angle_deg: float,
|
|
62
|
+
sound_speed: float,
|
|
63
|
+
elevation_angle_deg: float = 0.0,
|
|
64
|
+
) -> NDArray[np.float64]:
|
|
65
|
+
"""Return the arrival time of a steered plane wave at each point.
|
|
66
|
+
|
|
67
|
+
Same steering as ``Transducer.plane_wave``: the element delay is the projection of
|
|
68
|
+
its position on the steering direction, and the earliest element fires at zero.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
points_m : NDArray[np.float64]
|
|
73
|
+
Points of shape [n_points, 3] (lateral, elevation, depth) [m].
|
|
74
|
+
element_positions_m : NDArray[np.float64]
|
|
75
|
+
Element positions of shape [n_elements, 3] at the probe face [m].
|
|
76
|
+
angle_deg : float
|
|
77
|
+
Steering angle in the lateral plane [degrees].
|
|
78
|
+
sound_speed : float
|
|
79
|
+
Sound speed [m/s].
|
|
80
|
+
elevation_angle_deg : float, optional
|
|
81
|
+
Steering angle in the elevation plane [degrees].
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
NDArray[np.float64]
|
|
86
|
+
Arrival time at each point [s], shape [n_points].
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
sin_lateral = np.sin(np.radians(angle_deg))
|
|
90
|
+
sin_elevation = np.sin(np.radians(elevation_angle_deg))
|
|
91
|
+
axial = np.sqrt(1.0 - sin_lateral**2 - sin_elevation**2)
|
|
92
|
+
direction = np.array([sin_lateral, sin_elevation, axial])
|
|
93
|
+
points = np.asarray(points_m, dtype=float)
|
|
94
|
+
elements = np.asarray(element_positions_m, dtype=float)
|
|
95
|
+
return (points @ direction - (elements @ direction).min()) / sound_speed
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def focused_arrival_s(
|
|
99
|
+
points_m: NDArray[np.float64],
|
|
100
|
+
element_positions_m: NDArray[np.float64],
|
|
101
|
+
*,
|
|
102
|
+
focus_m: NDArray[np.float64],
|
|
103
|
+
sound_speed: float,
|
|
104
|
+
) -> NDArray[np.float64]:
|
|
105
|
+
"""Return the arrival time of a transmit focused at one point.
|
|
106
|
+
|
|
107
|
+
Same timing as ``Transducer.focus``: the element farthest from the focus fires at
|
|
108
|
+
zero and every element reaches the focus together. Before the focus the wave
|
|
109
|
+
converges, after the focus it diverges.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
points_m : NDArray[np.float64]
|
|
114
|
+
Points of shape [n_points, 3] (lateral, elevation, depth) [m].
|
|
115
|
+
element_positions_m : NDArray[np.float64]
|
|
116
|
+
Element positions of shape [n_elements, 3] at the probe face [m].
|
|
117
|
+
focus_m : NDArray[np.float64]
|
|
118
|
+
Focus (lateral, elevation, depth) [m]. The depth must be positive.
|
|
119
|
+
sound_speed : float
|
|
120
|
+
Sound speed [m/s].
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
NDArray[np.float64]
|
|
125
|
+
Arrival time at each point [s], shape [n_points].
|
|
126
|
+
|
|
127
|
+
Raises
|
|
128
|
+
------
|
|
129
|
+
ValueError
|
|
130
|
+
If the focus is not in front of the probe face.
|
|
131
|
+
|
|
132
|
+
"""
|
|
133
|
+
focus = np.asarray(focus_m, dtype=float)
|
|
134
|
+
if focus[2] <= 0.0:
|
|
135
|
+
error_msg = "The focus must be in front of the probe face (positive depth)."
|
|
136
|
+
logger.error(error_msg)
|
|
137
|
+
raise ValueError(error_msg)
|
|
138
|
+
points = np.asarray(points_m, dtype=float)
|
|
139
|
+
elements = np.asarray(element_positions_m, dtype=float)
|
|
140
|
+
farthest_m = np.linalg.norm(elements - focus, axis=1).max()
|
|
141
|
+
to_focus_m = np.linalg.norm(points - focus, axis=1)
|
|
142
|
+
before_focus = points[:, 2] < focus[2]
|
|
143
|
+
return (farthest_m + np.where(before_focus, -to_focus_m, to_focus_m)) / sound_speed
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def synthetic_aperture_arrival_s(
|
|
147
|
+
points_m: NDArray[np.float64],
|
|
148
|
+
element_positions_m: NDArray[np.float64],
|
|
149
|
+
*,
|
|
150
|
+
element: int,
|
|
151
|
+
sound_speed: float,
|
|
152
|
+
) -> NDArray[np.float64]:
|
|
153
|
+
"""Return the arrival time of a spherical wave from one element.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
points_m : NDArray[np.float64]
|
|
158
|
+
Points of shape [n_points, 3] (lateral, elevation, depth) [m].
|
|
159
|
+
element_positions_m : NDArray[np.float64]
|
|
160
|
+
Element positions of shape [n_elements, 3] at the probe face [m].
|
|
161
|
+
element : int
|
|
162
|
+
The firing element, counted from one as in ``Transducer.synthetic_aperture``.
|
|
163
|
+
sound_speed : float
|
|
164
|
+
Sound speed [m/s].
|
|
165
|
+
|
|
166
|
+
Returns
|
|
167
|
+
-------
|
|
168
|
+
NDArray[np.float64]
|
|
169
|
+
Arrival time at each point [s], shape [n_points].
|
|
170
|
+
|
|
171
|
+
Raises
|
|
172
|
+
------
|
|
173
|
+
ValueError
|
|
174
|
+
If the element is outside the aperture.
|
|
175
|
+
|
|
176
|
+
"""
|
|
177
|
+
elements = np.asarray(element_positions_m, dtype=float)
|
|
178
|
+
if not 1 <= element <= len(elements):
|
|
179
|
+
error_msg = f"element {element} is outside the aperture of {len(elements)} elements"
|
|
180
|
+
logger.error(error_msg)
|
|
181
|
+
raise ValueError(error_msg)
|
|
182
|
+
points = np.asarray(points_m, dtype=float)
|
|
183
|
+
return np.linalg.norm(points - elements[element - 1], axis=1) / sound_speed
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def user_arrival_s(
|
|
187
|
+
arrival_s: NDArray[np.float64],
|
|
188
|
+
*,
|
|
189
|
+
n_points: int,
|
|
190
|
+
) -> NDArray[np.float64]:
|
|
191
|
+
"""Check arrival times a caller computed, and return them as float64.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
arrival_s : NDArray[np.float64]
|
|
196
|
+
Arrival time at each point [s], shape [n_points] or [n_transmit, n_points],
|
|
197
|
+
measured from the moment the earliest firing element is at the probe face.
|
|
198
|
+
n_points : int
|
|
199
|
+
How many image points there are.
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
NDArray[np.float64]
|
|
204
|
+
The same times, shape [n_points] or [n_transmit, n_points].
|
|
205
|
+
|
|
206
|
+
Raises
|
|
207
|
+
------
|
|
208
|
+
ValueError
|
|
209
|
+
If the last axis does not hold ``n_points`` values.
|
|
210
|
+
|
|
211
|
+
"""
|
|
212
|
+
arrival = np.asarray(arrival_s, dtype=float)
|
|
213
|
+
if arrival.ndim not in (1, 2) or arrival.shape[-1] != n_points:
|
|
214
|
+
error_msg = f"arrival_s must have shape [{n_points}] or [n_transmit, {n_points}]"
|
|
215
|
+
logger.error(error_msg)
|
|
216
|
+
raise ValueError(error_msg)
|
|
217
|
+
return arrival
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def receiver_positions_m(transducer: fullwave.Transducer) -> NDArray[np.float64]:
|
|
221
|
+
"""Return the receiver positions of a flat array, shape [n_elements, 3] [m].
|
|
222
|
+
|
|
223
|
+
The columns are lateral (0 at the center of the array), elevation and depth below the
|
|
224
|
+
probe face. The rows follow the element ids.
|
|
225
|
+
|
|
226
|
+
Parameters
|
|
227
|
+
----------
|
|
228
|
+
transducer : fullwave.Transducer
|
|
229
|
+
A flat array.
|
|
230
|
+
|
|
231
|
+
Returns
|
|
232
|
+
-------
|
|
233
|
+
NDArray[np.float64]
|
|
234
|
+
The receiver positions [m].
|
|
235
|
+
|
|
236
|
+
"""
|
|
237
|
+
grid = transducer.grid
|
|
238
|
+
centers = transducer.element_id_to_receiver_center
|
|
239
|
+
element_ids = sorted(centers)
|
|
240
|
+
receivers_px = np.array([centers[i] for i in element_ids], dtype=float)
|
|
241
|
+
lateral_center_px = receivers_px[:, 1].mean()
|
|
242
|
+
receivers_m = np.zeros((len(receivers_px), 3))
|
|
243
|
+
receivers_m[:, 0] = (receivers_px[:, 1] - lateral_center_px) * grid.dx
|
|
244
|
+
if receivers_px.shape[1] == 3:
|
|
245
|
+
receivers_m[:, 1] = (receivers_px[:, 2] - receivers_px[:, 2].mean()) * grid.dx
|
|
246
|
+
receivers_m[:, 2] = (receivers_px[:, 0] - transducer.face_row_px) * grid.dx
|
|
247
|
+
return receivers_m
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def face_crossing_time_s(transducer: fullwave.Transducer) -> float:
|
|
251
|
+
"""Return when the earliest firing element has its pulse at the probe face.
|
|
252
|
+
|
|
253
|
+
The method reads the drive and not the recorded traces. The pulse of the earliest
|
|
254
|
+
firing source pixel peaks (envelope of its Hilbert transform) at the drive time
|
|
255
|
+
``t_peak``. The drive is applied to every source row at once, and the wave seen
|
|
256
|
+
in front of the array leaves the back source row (measured on the 12-row L7-4
|
|
257
|
+
layout: the pulse peak is 29 time steps after the drive peak at the receiver row,
|
|
258
|
+
which is 12 row steps of ``dx / c0``). The pulse is therefore at the face
|
|
259
|
+
``(face_row_px - back_source_row) * dx / c0`` after ``t_peak``.
|
|
260
|
+
The first sample of the recorded traces is time step 0 of the simulation.
|
|
261
|
+
|
|
262
|
+
Parameters
|
|
263
|
+
----------
|
|
264
|
+
transducer : fullwave.Transducer
|
|
265
|
+
A flat array with its excitation set.
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
float
|
|
270
|
+
The time of the pulse peak at the probe face [s].
|
|
271
|
+
|
|
272
|
+
"""
|
|
273
|
+
signal = np.asarray(transducer.signal, dtype=float)
|
|
274
|
+
driven = np.flatnonzero((signal != 0).any(axis=1))
|
|
275
|
+
peak_steps = np.argmax(np.abs(hilbert(signal[driven], axis=1)), axis=1)
|
|
276
|
+
peak_step = int(peak_steps.min())
|
|
277
|
+
grid = transducer.grid
|
|
278
|
+
back_source_row = int(transducer.source_coords[:, 0].min())
|
|
279
|
+
rows_to_face = transducer.face_row_px - back_source_row
|
|
280
|
+
return peak_step * float(grid.dt) + rows_to_face * float(grid.dx) / float(grid.c0)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class MachBeamformer:
|
|
284
|
+
"""Delay-and-sum beamformer that runs on the GPU through mach.
|
|
285
|
+
|
|
286
|
+
The traces are converted to IQ with ``pymust.rf2iq`` and nothing else is applied to
|
|
287
|
+
them. ``f_number`` is the standard F = depth / aperture width: mach sums over the
|
|
288
|
+
receivers within a total aperture of ``depth / f_number`` around the image point,
|
|
289
|
+
with a Tukey window of ``tukey_alpha`` across it (0 is rectangular). ``Beamformer``
|
|
290
|
+
instead takes the half width as ``depth / F``, so ``f_number / 2`` here matches it.
|
|
291
|
+
|
|
292
|
+
Time zero: depth 0 is the probe face. A transmit arrival time is zero when the
|
|
293
|
+
earliest firing element has its pulse at the face, and ``time_zero_s`` is that
|
|
294
|
+
moment in the clock of the traces. mach starts the traces at ``-time_zero_s``.
|
|
295
|
+
``from_transducer`` derives ``time_zero_s`` from the drive and the source-to-face
|
|
296
|
+
rows (``face_crossing_time_s``).
|
|
297
|
+
|
|
298
|
+
References
|
|
299
|
+
----------
|
|
300
|
+
If you use this beamformer, please cite mach:
|
|
301
|
+
C. Guan, A. P. Rockhill, M. Sode and G. Pinton, "mach: ultrafast ultrasound
|
|
302
|
+
beamforming", Journal of Medical Imaging 13(6), 062203 (2026),
|
|
303
|
+
https://doi.org/10.1117/1.JMI.13.6.062203
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
def __init__(
|
|
307
|
+
self,
|
|
308
|
+
*,
|
|
309
|
+
lateral_position_m: NDArray[np.float64],
|
|
310
|
+
axial_position_m: NDArray[np.float64],
|
|
311
|
+
receiver_positions_m: NDArray[np.float64],
|
|
312
|
+
sound_speed: float,
|
|
313
|
+
center_frequency_hz: float,
|
|
314
|
+
sampling_frequency_hz: float,
|
|
315
|
+
f_number: float = 1.0,
|
|
316
|
+
time_zero_s: float = 0.0,
|
|
317
|
+
tukey_alpha: float = 0.5,
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Initialize the beamformer.
|
|
320
|
+
|
|
321
|
+
Parameters
|
|
322
|
+
----------
|
|
323
|
+
lateral_position_m : NDArray[np.float64]
|
|
324
|
+
Lateral positions of the image columns [m].
|
|
325
|
+
axial_position_m : NDArray[np.float64]
|
|
326
|
+
Depths of the image rows below the probe face [m].
|
|
327
|
+
receiver_positions_m : NDArray[np.float64]
|
|
328
|
+
Receiver positions of shape [n_elements, 3] (lateral, elevation, depth) [m].
|
|
329
|
+
sound_speed : float
|
|
330
|
+
Sound speed used for the delays [m/s].
|
|
331
|
+
center_frequency_hz : float
|
|
332
|
+
Center frequency of the IQ demodulation [Hz].
|
|
333
|
+
sampling_frequency_hz : float
|
|
334
|
+
Sampling frequency of the traces [Hz].
|
|
335
|
+
f_number : float, optional
|
|
336
|
+
Receive f-number F = depth / aperture width, by default 1.0.
|
|
337
|
+
time_zero_s : float, optional
|
|
338
|
+
When the earliest firing element has its pulse at the face, in the clock of
|
|
339
|
+
the traces [s], by default 0.0.
|
|
340
|
+
tukey_alpha : float, optional
|
|
341
|
+
Tukey parameter of the receive aperture window, by default 0.5.
|
|
342
|
+
|
|
343
|
+
"""
|
|
344
|
+
self.lateral_position_m = np.asarray(lateral_position_m, dtype=float)
|
|
345
|
+
self.axial_position_m = np.asarray(axial_position_m, dtype=float)
|
|
346
|
+
self.receiver_positions_m = np.asarray(receiver_positions_m, dtype=float)
|
|
347
|
+
self.sound_speed = float(sound_speed)
|
|
348
|
+
self.center_frequency_hz = float(center_frequency_hz)
|
|
349
|
+
self.sampling_frequency_hz = float(sampling_frequency_hz)
|
|
350
|
+
self.f_number = float(f_number)
|
|
351
|
+
self.time_zero_s = float(time_zero_s)
|
|
352
|
+
self.tukey_alpha = float(tukey_alpha)
|
|
353
|
+
|
|
354
|
+
@classmethod
|
|
355
|
+
def from_transducer(
|
|
356
|
+
cls,
|
|
357
|
+
transducer: fullwave.Transducer,
|
|
358
|
+
*,
|
|
359
|
+
lateral_position_m: NDArray[np.float64],
|
|
360
|
+
axial_position_m: NDArray[np.float64],
|
|
361
|
+
f_number: float = 1.0,
|
|
362
|
+
tukey_alpha: float = 0.5,
|
|
363
|
+
) -> MachBeamformer:
|
|
364
|
+
"""Build the beamformer from a flat array whose excitation is set.
|
|
365
|
+
|
|
366
|
+
Lateral 0 is the center of the array. The sound speed is ``grid.c0``, the center
|
|
367
|
+
frequency is the grid frequency, and the sampling follows
|
|
368
|
+
``transducer.sampling_modulus_time``.
|
|
369
|
+
|
|
370
|
+
Parameters
|
|
371
|
+
----------
|
|
372
|
+
transducer : fullwave.Transducer
|
|
373
|
+
A flat array with its excitation set (the transmit of the traces).
|
|
374
|
+
lateral_position_m : NDArray[np.float64]
|
|
375
|
+
Lateral positions of the image columns [m].
|
|
376
|
+
axial_position_m : NDArray[np.float64]
|
|
377
|
+
Depths of the image rows below the probe face [m].
|
|
378
|
+
f_number : float, optional
|
|
379
|
+
Receive f-number F = depth / aperture width, by default 1.0.
|
|
380
|
+
tukey_alpha : float, optional
|
|
381
|
+
Tukey parameter of the receive aperture window, by default 0.5.
|
|
382
|
+
|
|
383
|
+
Returns
|
|
384
|
+
-------
|
|
385
|
+
MachBeamformer
|
|
386
|
+
The beamformer.
|
|
387
|
+
|
|
388
|
+
"""
|
|
389
|
+
grid = transducer.grid
|
|
390
|
+
return cls(
|
|
391
|
+
lateral_position_m=lateral_position_m,
|
|
392
|
+
axial_position_m=axial_position_m,
|
|
393
|
+
receiver_positions_m=receiver_positions_m(transducer),
|
|
394
|
+
sound_speed=float(grid.c0),
|
|
395
|
+
center_frequency_hz=float(grid.f0),
|
|
396
|
+
sampling_frequency_hz=1.0 / (float(grid.dt) * transducer.sampling_modulus_time),
|
|
397
|
+
f_number=f_number,
|
|
398
|
+
time_zero_s=face_crossing_time_s(transducer),
|
|
399
|
+
tukey_alpha=tukey_alpha,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def element_positions_m(self) -> NDArray[np.float64]:
|
|
404
|
+
"""Return the element positions at the probe face, shape [n_elements, 3] [m]."""
|
|
405
|
+
positions = self.receiver_positions_m.copy()
|
|
406
|
+
positions[:, 2] = 0.0
|
|
407
|
+
return positions
|
|
408
|
+
|
|
409
|
+
@property
|
|
410
|
+
def scan_points_m(self) -> NDArray[np.float64]:
|
|
411
|
+
"""Return the image points, shape [n_lateral * n_axial, 3], lateral-major [m]."""
|
|
412
|
+
lateral, axial = np.meshgrid(
|
|
413
|
+
self.lateral_position_m,
|
|
414
|
+
self.axial_position_m,
|
|
415
|
+
indexing="ij",
|
|
416
|
+
)
|
|
417
|
+
points = np.zeros((lateral.size, 3))
|
|
418
|
+
points[:, 0] = lateral.ravel()
|
|
419
|
+
points[:, 2] = axial.ravel()
|
|
420
|
+
return points
|
|
421
|
+
|
|
422
|
+
def plane_wave_arrival_s(
|
|
423
|
+
self,
|
|
424
|
+
*,
|
|
425
|
+
angle_deg: float,
|
|
426
|
+
elevation_angle_deg: float = 0.0,
|
|
427
|
+
) -> NDArray[np.float64]:
|
|
428
|
+
"""Return the arrival time of a steered plane wave at the image points [s]."""
|
|
429
|
+
return plane_wave_arrival_s(
|
|
430
|
+
self.scan_points_m,
|
|
431
|
+
self.element_positions_m,
|
|
432
|
+
angle_deg=angle_deg,
|
|
433
|
+
sound_speed=self.sound_speed,
|
|
434
|
+
elevation_angle_deg=elevation_angle_deg,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def focused_arrival_s(self, *, focus_m: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
438
|
+
"""Return the arrival time of a transmit focused at ``focus_m`` [s]."""
|
|
439
|
+
return focused_arrival_s(
|
|
440
|
+
self.scan_points_m,
|
|
441
|
+
self.element_positions_m,
|
|
442
|
+
focus_m=focus_m,
|
|
443
|
+
sound_speed=self.sound_speed,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def synthetic_aperture_arrival_s(self, *, element: int) -> NDArray[np.float64]:
|
|
447
|
+
"""Return the arrival time of the wave of one element, counted from one [s]."""
|
|
448
|
+
return synthetic_aperture_arrival_s(
|
|
449
|
+
self.scan_points_m,
|
|
450
|
+
self.element_positions_m,
|
|
451
|
+
element=element,
|
|
452
|
+
sound_speed=self.sound_speed,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
def user_arrival_s(self, arrival_s: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
456
|
+
"""Check arrival times given by the caller for the image points [s]."""
|
|
457
|
+
return user_arrival_s(arrival_s, n_points=len(self.scan_points_m))
|
|
458
|
+
|
|
459
|
+
def rf_to_iq(self, rf: NDArray[np.float64]) -> NDArray[np.complex64]:
|
|
460
|
+
"""Convert traces to IQ with ``pymust.rf2iq``, the only processing applied.
|
|
461
|
+
|
|
462
|
+
Parameters
|
|
463
|
+
----------
|
|
464
|
+
rf : NDArray[np.float64]
|
|
465
|
+
Traces of shape [n_elements, n_time] or [n_transmit, n_elements, n_time].
|
|
466
|
+
|
|
467
|
+
Returns
|
|
468
|
+
-------
|
|
469
|
+
NDArray[np.complex64]
|
|
470
|
+
IQ traces of shape [n_transmit, n_elements, n_time].
|
|
471
|
+
|
|
472
|
+
"""
|
|
473
|
+
_, pymust, _ = _load_mach_modules()
|
|
474
|
+
traces = np.asarray(rf)
|
|
475
|
+
if traces.ndim == 2:
|
|
476
|
+
traces = traces[np.newaxis]
|
|
477
|
+
iq = pymust.rf2iq(
|
|
478
|
+
traces.transpose(2, 1, 0),
|
|
479
|
+
Fs=self.sampling_frequency_hz,
|
|
480
|
+
Fc=self.center_frequency_hz,
|
|
481
|
+
)
|
|
482
|
+
return np.ascontiguousarray(iq.transpose(2, 1, 0), dtype=np.complex64)
|
|
483
|
+
|
|
484
|
+
def beamform(
|
|
485
|
+
self,
|
|
486
|
+
rf: NDArray[np.float64],
|
|
487
|
+
*,
|
|
488
|
+
tx_arrival_s: NDArray[np.float64],
|
|
489
|
+
) -> NDArray[np.complex64]:
|
|
490
|
+
"""Beamform the traces into one image, summing the transmits coherently.
|
|
491
|
+
|
|
492
|
+
Parameters
|
|
493
|
+
----------
|
|
494
|
+
rf : NDArray[np.float64]
|
|
495
|
+
Traces of shape [n_elements, n_time] for one transmit, or
|
|
496
|
+
[n_transmit, n_elements, n_time].
|
|
497
|
+
tx_arrival_s : NDArray[np.float64]
|
|
498
|
+
Transmit arrival times from one of the ``*_arrival_s`` helpers, shape
|
|
499
|
+
[n_points] for one transmit or [n_transmit, n_points].
|
|
500
|
+
|
|
501
|
+
Returns
|
|
502
|
+
-------
|
|
503
|
+
NDArray[np.complex64]
|
|
504
|
+
The IQ image of shape [n_axial, n_lateral]. Its magnitude is the envelope.
|
|
505
|
+
|
|
506
|
+
Raises
|
|
507
|
+
------
|
|
508
|
+
ValueError
|
|
509
|
+
If the transmit count of ``rf`` and ``tx_arrival_s`` differ.
|
|
510
|
+
|
|
511
|
+
"""
|
|
512
|
+
cupy, _, experimental = _load_mach_modules()
|
|
513
|
+
arrival = np.atleast_2d(self.user_arrival_s(tx_arrival_s))
|
|
514
|
+
iq = self.rf_to_iq(rf)
|
|
515
|
+
if iq.shape[0] != arrival.shape[0]:
|
|
516
|
+
error_msg = "rf and tx_arrival_s hold different numbers of transmits"
|
|
517
|
+
logger.error(error_msg)
|
|
518
|
+
raise ValueError(error_msg)
|
|
519
|
+
channel_data = np.ascontiguousarray(iq[..., np.newaxis])
|
|
520
|
+
flat_image = experimental.beamform(
|
|
521
|
+
channel_data=cupy.asarray(channel_data),
|
|
522
|
+
rx_coords_m=cupy.asarray(self.receiver_positions_m),
|
|
523
|
+
scan_coords_m=cupy.asarray(self.scan_points_m),
|
|
524
|
+
tx_wave_arrivals_s=cupy.asarray(arrival),
|
|
525
|
+
f_number=self.f_number,
|
|
526
|
+
rx_start_s=-self.time_zero_s,
|
|
527
|
+
sampling_freq_hz=self.sampling_frequency_hz,
|
|
528
|
+
sound_speed_m_s=self.sound_speed,
|
|
529
|
+
modulation_freq_hz=self.center_frequency_hz,
|
|
530
|
+
tukey_alpha=self.tukey_alpha,
|
|
531
|
+
)
|
|
532
|
+
image = cupy.asnumpy(flat_image)[:, 0]
|
|
533
|
+
return image.reshape(len(self.lateral_position_m), len(self.axial_position_m)).T
|
fullwave/grid.py
CHANGED
|
@@ -126,6 +126,21 @@ class Grid:
|
|
|
126
126
|
"""Grid point spacing in z-direction [m]."""
|
|
127
127
|
return self.wavelength / self.ppw if len(self.domain_size) == 3 else 0
|
|
128
128
|
|
|
129
|
+
def extent_mm(self) -> tuple[float, float, float, float]:
|
|
130
|
+
"""Return the 2D plot extent in millimeters.
|
|
131
|
+
|
|
132
|
+
The lateral axis is centered on 0 and the depth axis starts at 0 on the top.
|
|
133
|
+
|
|
134
|
+
Returns
|
|
135
|
+
-------
|
|
136
|
+
tuple[float, float, float, float]
|
|
137
|
+
(left, right, bottom, top) as expected by matplotlib's ``imshow``.
|
|
138
|
+
|
|
139
|
+
"""
|
|
140
|
+
half_width_mm = self.ny * self.dy * 1e3 / 2
|
|
141
|
+
depth_mm = self.nx * self.dx * 1e3
|
|
142
|
+
return (-half_width_mm, half_width_mm, depth_mm, 0.0)
|
|
143
|
+
|
|
129
144
|
@property
|
|
130
145
|
def nt(self) -> int:
|
|
131
146
|
"""Number of time steps."""
|