fullwave25 1.4.2.dev3__py3-none-any.whl → 1.4.3.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 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.4.2-dev3"
69
+ __version__ = "1.4.3-dev0"
70
70
 
71
71
  VERSION = __version__ # for convenience
72
72
  logger.info("Fullwave version: %s", __version__)
@@ -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"]
@@ -14,12 +14,13 @@ class Beamformer:
14
14
  For faster implementations, consider using libraries such as
15
15
  mach beamformer: https://github.com/Forest-Neurotech/mach
16
16
 
17
- Conventions: ``axial_position_m`` is measured from the top of the grid
18
- (row 0), not from the array. ``transducer_coordinates`` must be the
19
- receiver positions in grid points (for example from
20
- ``Transducer.element_id_to_receiver_center``). The time origin is the
21
- mean receiver row, so the array depth is subtracted from the two-way
22
- travel time.
17
+ Conventions: ``axial_position_m`` is the depth below the probe face, so
18
+ depth 0 is the face row ``face_row_px`` (``Transducer.face_row_px``, the
19
+ first medium row, one row in front of the receiver row).
20
+ ``transducer_coordinates`` must be the receiver positions in grid points
21
+ (for example from ``Transducer.element_id_to_receiver_center``). The time
22
+ origin is the mean receiver row, so the array depth is subtracted from the
23
+ two-way travel time.
23
24
 
24
25
  References
25
26
  ----------
@@ -38,6 +39,7 @@ class Beamformer:
38
39
  axial_position_m: np.ndarray,
39
40
  num_elements: int,
40
41
  transducer_coordinates: np.ndarray,
42
+ face_row_px: int,
41
43
  f_number: float = 1.0,
42
44
  ) -> None:
43
45
  """Initialize the beamformer with simulation and transducer parameters.
@@ -53,11 +55,14 @@ class Beamformer:
53
55
  lateral_position_m : np.ndarray
54
56
  Lateral positions (m) where the beamformed image will be computed.
55
57
  axial_position_m : np.ndarray
56
- Axial positions (m) from the top of the grid where the image is computed.
58
+ Depths (m) below the probe face where the image is computed.
57
59
  num_elements : int
58
60
  Number of transducer elements.
59
61
  transducer_coordinates : np.ndarray
60
62
  Receiver coordinates [n_elements, 2] in grid points (row, column).
63
+ face_row_px : int
64
+ Grid row of the probe face (depth 0), for example
65
+ ``Transducer.face_row_px``.
61
66
  f_number : float, optional
62
67
  F-number for aperture calculation, by default 1.0.
63
68
 
@@ -69,6 +74,7 @@ class Beamformer:
69
74
  self.axial_position_m = axial_position_m
70
75
  self.num_elements = num_elements
71
76
  self.transducer_coordinates = transducer_coordinates
77
+ self.face_row_px = face_row_px
72
78
  self.f_number = f_number
73
79
 
74
80
  def run(self, signals: np.ndarray) -> np.ndarray:
@@ -83,7 +89,9 @@ class Beamformer:
83
89
  Returns
84
90
  -------
85
91
  np.ndarray
86
- Beamformed image. Shape: [n_axial, n_lateral, n_transmit].
92
+ Beamformed image. Shape: [n_axial, n_lateral, n_transmit]. Row 0
93
+ of the first axis is the first depth of ``axial_position_m``, which
94
+ is measured from the probe face.
87
95
 
88
96
  """
89
97
  # signals: [n_elements, n_time, n_transmit]
@@ -110,12 +118,13 @@ class Beamformer:
110
118
  desc="Lateral positions",
111
119
  total=len(self.lateral_position_m),
112
120
  ):
113
- for i_axial, axial in tqdm(
121
+ for i_axial, depth in tqdm(
114
122
  enumerate(self.axial_position_m),
115
123
  desc="Axial positions",
116
124
  total=len(self.axial_position_m),
117
125
  leave=False,
118
126
  ):
127
+ axial = self.face_row_px * self.dx + depth
119
128
  fcen = np.array(
120
129
  [
121
130
  int(axial / self.dx),
@@ -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."""
fullwave/medium.py CHANGED
@@ -852,7 +852,12 @@ class MediumRelaxationMaps:
852
852
  target_map_dict.items(),
853
853
  strict=False,
854
854
  ):
855
- plot_utils.plot_array_on_ax(ax, map_data, title=title)
855
+ plot_utils.plot_array_on_ax(
856
+ ax,
857
+ map_data,
858
+ title=title,
859
+ **plot_utils.mm_axes_kwargs(self.grid),
860
+ )
856
861
  plt.tight_layout()
857
862
 
858
863
  if export_path is not None:
@@ -1129,10 +1134,8 @@ class MediumExponentialAttenuation:
1129
1134
  ax,
1130
1135
  map_data,
1131
1136
  title=title,
1132
- xlim=(0 - 10, self.grid.ny + 10),
1133
- ylim=(0 - 10, self.grid.nx + 10),
1134
- reverse_y_axis=True,
1135
1137
  cmap=cmap,
1138
+ **plot_utils.mm_axes_kwargs(self.grid),
1136
1139
  )
1137
1140
  plt.tight_layout()
1138
1141
 
@@ -1543,10 +1546,8 @@ class Medium:
1543
1546
  ax,
1544
1547
  map_data,
1545
1548
  title=title,
1546
- xlim=(0 - 10, self.grid.ny + 10),
1547
- ylim=(0 - 10, self.grid.nx + 10),
1548
- reverse_y_axis=True,
1549
1549
  cmap=cmap,
1550
+ **plot_utils.mm_axes_kwargs(self.grid),
1550
1551
  )
1551
1552
  ax.title.set_fontsize(fontsize_title)
1552
1553
  plt.tight_layout()