"""Ultrasound probe definitions and the base :class:`Probe` class.
A probe describes the physical transducer: element positions, centre frequency,
bandwidth, and properties such as element dimensions and lens geometry.
All probe objects are instances of :class:`Probe`, which inherits validation from
:class:`~zea.data.spec.ProbeSpec`.
Probe parameters
^^^^^^^^^^^^^^^^
A probe is described by the fields below (all optional — record what you have; see
:class:`~zea.data.spec.ProbeSpec` for exact dtypes and shapes):
- ``name`` -- probe model identifier, e.g. ``"verasonics_c5_2v"``.
- ``type`` -- geometry class: ``"linear"``, ``"phased"``, ``"curved"``, ...
- ``probe_geometry`` -- element positions ``(x, y, z)`` in metres, shape
``(n_el, 3)``. The element count ``n_el`` and ``pitch`` are derived from it.
- ``probe_center_frequency`` -- the probe's nominal centre frequency (Hz), a fixed
property of the transducer. For the built-in probes it is defined as the
**midpoint of the -6 dB band** named by the probe (e.g. "L11-4v" -> 4-11 MHz ->
7.5 MHz), so it derives consistently with ``probe_bandwidth_percent`` from the
band edges. This is the *defined band centre*, not a measured peak-response
frequency, and it is distinct from the centre frequency of the transmit pulse
used in an acquisition, which lives in ``ScanSpec.center_frequency`` /
:attr:`~zea.parameters.Parameters.center_frequency`.
- ``probe_bandwidth_percent`` -- fractional bandwidth as a percentage,
``(f_high - f_low) / f_centre * 100``, using the arithmetic band centre above
(the standard fractional-bandwidth reference).
- ``element_width`` -- width of a single element (m), along the array (lateral, x)
direction.
- ``element_height`` -- elevation aperture of a single element (m).
- ``lens_sound_speed`` / ``lens_thickness`` -- acoustic-lens speed of sound (m/s)
and thickness (m), used to correct receive travel times for the lens.
There are three ways to obtain a probe:
Loading a built-in probe
^^^^^^^^^^^^^^^^^^^^^^^^
A small set of probes is pre-defined and can be retrieved by name:
.. doctest::
>>> from zea import Probe
>>> probe = Probe.from_name("verasonics_l11_4v")
>>> float(probe.probe_center_frequency)
7500000.0
>>> probe.n_el
128
See :meth:`Probe.from_name` for the full list of registered names.
Built-in probes
~~~~~~~~~~~~~~~
- :class:`Verasonics_l11_4v` -- Verasonics L11-4V linear array
- :class:`Verasonics_l11_5v` -- Verasonics L11-5V linear array
- :class:`Esaote_sll1543` -- Esaote SLL1543 linear array
- :class:`Verasonics_c5_2v` -- Verasonics C5-2v convex (curved) array
- :class:`Verasonics_p4_2v` -- Verasonics P4-2v phased array
Loading a probe from a data file
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
When you open a :class:`~zea.data.file.File`, the probe stored in that file is
accessible through the :attr:`~zea.data.file.File.probe` property:
.. doctest::
>>> from zea import File
>>> path = (
... "hf://zeahub/picmus/database/experiments/contrast_speckle/"
... "contrast_speckle_expe_dataset_iq/contrast_speckle_expe_dataset_iq.hdf5"
... )
>>> with File(path) as f:
... probe = f.probe
>>> probe.name
'verasonics_l11_4v'
Defining a custom probe
^^^^^^^^^^^^^^^^^^^^^^^^
Pass any combination of fields from :class:`~zea.data.spec.ProbeSpec` directly
to :class:`Probe`. Only the fields you provide are validated; everything else
is left as ``None``:
.. doctest::
>>> import numpy as np
>>> from zea import Probe
>>> from zea.probes import create_probe_geometry
>>> probe = Probe(
... name="my_probe",
... type="linear",
... probe_center_frequency=np.float32(5e6),
... probe_geometry=create_probe_geometry(n_el=64, pitch=0.3e-3),
... )
>>> probe.n_el
64
You can also register a custom probe class with the
:data:`~zea.internal.registry.probe_registry` decorator so it becomes
retrievable by name — see the built-in classes below as examples.
Saving a probe to a data file
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Pass a :class:`Probe` object directly to :meth:`~zea.data.file.File.create`
via the ``probe`` argument, alternatively a simple dictionary of probe
parameters will also work:
.. doctest::
>>> import numpy as np
>>> from zea import File, Probe
>>> n_frames, n_tx, n_el, n_ax = 1, 4, 128, 64
>>> probe = Probe.from_name("verasonics_l11_4v")
>>> raw = np.zeros((n_frames, n_tx, n_ax, n_el, 1), dtype=np.float32)
>>> scan = {
... "sampling_frequency": np.float32(40e6),
... "center_frequency": np.float32(6.25e6),
... "demodulation_frequency": np.float32(6.25e6),
... "initial_times": np.zeros(n_tx, dtype=np.float32),
... "t0_delays": np.zeros((n_tx, n_el), dtype=np.float32),
... "tx_apodizations": np.ones((n_tx, n_el), dtype=np.float32),
... "focus_distances": np.full(n_tx, np.inf, dtype=np.float32),
... "transmit_origins": np.zeros((n_tx, 3), dtype=np.float32),
... "polar_angles": np.zeros(n_tx, dtype=np.float32),
... "time_to_next_transmit": np.ones((n_frames, n_tx), dtype=np.float32) * 1e-4,
... }
>>> File.create(
... "probe_example.hdf5",
... data={"raw_data": raw},
... scan=scan,
... probe=probe, # dictionary or zea.Probe object
... overwrite=True,
... )
.. testcleanup::
import os
os.remove("probe_example.hdf5")
""" # noqa: E501
import numpy as np
from keras import ops
from zea.beamform.geometry import element_pitch
from zea.data.spec import ProbeSpec
from zea.internal.core import dict_to_tensor
from zea.internal.registry import probe_registry
[docs]
def create_probe_geometry(n_el, pitch):
"""Create probe geometry based on number of elements and pitch.
Args:
n_el (int): Number of elements in the probe.
pitch (float): Pitch of the elements in the probe.
Returns:
np.ndarray: Probe geometry with shape (n_el, 3).
"""
aperture = (n_el - 1) * pitch
probe_geometry = np.stack(
[
np.linspace(-aperture / 2, aperture / 2, n_el).T,
np.zeros((n_el,)),
np.zeros((n_el,)),
],
axis=1,
).astype(np.float32)
return probe_geometry
[docs]
def create_curved_probe_geometry(n_el, pitch, radius):
"""Create the geometry of a convex (curved) array.
Elements lie on an arc of the given radius of curvature in the ``x-z`` plane
(``y = 0``), centred on ``(0, 0, -radius)``. The middle of the array sits at the
origin facing ``+z`` and the peripheral elements curve back toward ``-z`` (zea's
convention: the medium is at ``+z``). ``pitch`` is the arc spacing between adjacent
elements, so the per-element angular step is ``pitch / radius``.
Args:
n_el (int): Number of elements in the probe.
pitch (float): Arc distance between adjacent elements in metres.
radius (float): Radius of curvature of the array in metres.
Returns:
np.ndarray: Probe geometry with shape (n_el, 3).
"""
angular_pitch = pitch / radius
angles = (np.arange(n_el) - (n_el - 1) / 2) * angular_pitch
probe_geometry = np.stack(
[
radius * np.sin(angles),
np.zeros((n_el,)),
radius * np.cos(angles) - radius,
],
axis=1,
).astype(np.float32)
return probe_geometry
[docs]
def fit_curved_probe_radius(probe_geometry, tol: float = 0.5) -> float:
"""Recover a curved probe's radius of curvature from its element positions.
Inverse of :func:`create_curved_probe_geometry`, for curved probes whose radius is
only known through ``probe_geometry`` (e.g. read from a converted file) rather than
built from an explicit radius. :attr:`zea.Parameters.radius_of_curvature` uses it, and
the centre of curvature is the default :attr:`zea.Parameters.grid_apex` of such a probe.
Elements are assumed to lie on an arc of radius ``R`` centred at ``(0, 0, -R)``, so
``x^2 + (z + R)^2 = R^2`` => ``x^2 + z^2 + 2 R z = 0``. Solved as a least-squares
fit over all elements rather than per-element, which is ill-conditioned for the
elements nearest the middle of the array (``z -> 0``).
.. note::
The centre of curvature sits at ``(0, 0, -R)``, not at the middle of the array,
which is the origin.
Args:
probe_geometry (np.ndarray): Element positions in metres, shape (n_el, 3), in
zea's curved-probe frame (see :func:`create_curved_probe_geometry`).
tol (float, optional): How far an element may sit from the fitted arc before the
fit is rejected, as a fraction of the element pitch. Defaults to 0.5, which
comfortably admits the irregularity of measured and simulated geometries
while still rejecting an array that is off-frame by a single element.
Returns:
float: Fitted radius of curvature in metres.
Raises:
ValueError: If the elements do not lie on such an arc, either because the array
is not curved or because ``probe_geometry`` uses a different origin -- a
translated arc still fits *a* circle, just not one centred at ``(0, 0, -R)``.
"""
x = probe_geometry[:, 0].astype(np.float64)
z = probe_geometry[:, 2].astype(np.float64)
denominator = 2.0 * np.sum(z**2)
radius = -np.sum(z * (x**2 + z**2)) / denominator if denominator > 0 else np.inf
# Check the fit instead of trusting it: this catches a flat array (nothing to fit) as
# well as a geometry in a different frame, which would otherwise fit the wrong circle.
if np.isfinite(radius):
deviation = np.max(np.abs(np.hypot(x, z + radius) - radius))
if deviation <= tol * element_pitch(probe_geometry):
return float(radius)
raise ValueError(
"Cannot fit a radius of curvature: the elements do not lie on an arc centred at "
"(0, 0, -R). Either the array is not curved, or probe_geometry uses a different "
"origin and needs re-centring onto zea's curved-probe frame "
"(see create_curved_probe_geometry)."
)
[docs]
class Probe(ProbeSpec):
"""Probe class which is a container for ultrasound transducer parameters.
These parameters differentiate from the scan parameters, which might very
per :class:`~zea.data.spec.TrackSpec`. Per definition, probe parameters are constant
for the duration of an acquisition, while scan parameters may vary per frame
or per transmit event. This is the reason they are stored in separate groups
in a :class:`~zea.data.file.File` object. Upon loading parameters, it is recommended
to use the `~zea.parameters.Parameters` class. This allows you to use a single
interface to access both probe and scan parameters.
"""
# These are not converted to Parameters object
_NON_PARAMETERS = ("name", "type")
[docs]
def get_parameters(self):
"""Return a dict of the probe parameters."""
return {
key: getattr(self, key)
for key in self.SCHEMA
if getattr(self, key) is not None and key not in Probe._NON_PARAMETERS
}
def __repr__(self) -> str:
parts = []
if self.name is not None:
parts.append(f"name='{self.name}'")
if self.type is not None:
parts.append(f"type='{self.type}'")
if self.probe_geometry is not None:
n_el = self.probe_geometry.shape[0]
parts.append(f"n_el={n_el}")
if self.probe_center_frequency is not None:
parts.append(f"fc={float(self.probe_center_frequency) / 1e6:.2f} MHz")
if self.probe_bandwidth_percent is not None:
parts.append(f"bw={float(self.probe_bandwidth_percent):.1f}%")
if self.element_width is not None:
parts.append(f"element_width={float(self.element_width) * 1e3:.3f} mm")
return f"Probe({', '.join(parts)})"
[docs]
@classmethod
def from_name(cls, probe_name, **kwargs) -> "Probe":
"""Create a probe from its name.
Args:
probe_name (str): Name of the probe.
Returns:
Probe: Probe object.
"""
try:
probe_class = probe_registry[probe_name]
except KeyError as exc:
raise NotImplementedError(f"Probe {probe_name} not implemented.") from exc
return probe_class(**kwargs)
[docs]
def to_tensor(self, keep_as_is=None):
"""Convert the attributes in the object to tensors."""
# TODO: merge this with Parameters.to_tensor()
return dict_to_tensor(self.get_parameters(), keep_as_is=keep_as_is)
[docs]
@staticmethod
def get_pitch(probe_geometry: np.ndarray) -> float:
"""Compute the pitch (centre-to-centre element spacing) in metres from
the probe geometry.
Raises :class:`ValueError` when the probe has fewer than 2 elements, the
neighbouring elements are not uniformly spaced (which includes matrix arrays),
or the elements coincide.
"""
n_el = probe_geometry.shape[0]
if n_el < 2:
raise ValueError(f"Cannot compute pitch: probe has fewer than 2 elements (n_el={n_el})")
steps = np.diff(np.asarray(probe_geometry, dtype=np.float64), axis=0)
spacings = np.linalg.norm(steps, axis=1)
if not np.allclose(spacings, spacings[0], rtol=1e-3):
raise ValueError(
"Cannot compute pitch: elements are not uniformly spaced. "
f"Min spacing: {spacings.min():.4e} m, max: {spacings.max():.4e} m."
)
pitch = element_pitch(probe_geometry)
if pitch == 0:
raise ValueError("Cannot compute pitch: element positions coincide.")
return pitch
@property
def pitch(self) -> float | None:
"""Centre-to-centre element spacing in metres, derived from :attr:`probe_geometry`.
Raises :class:`ValueError` when:
* :attr:`probe_geometry` is not set,
* the probe has fewer than 2 elements,
* the spacing is non-uniform (which includes matrix arrays), to surface likely
data errors rather than silently returning ``None``, or
* the elements coincide.
"""
if self.probe_geometry is None:
raise ValueError("Cannot compute pitch: probe_geometry is not set")
return self.get_pitch(self.probe_geometry)
@property
def kerf(self) -> float | None:
"""Gap between elements in metres, derived from :attr:`element_width` and :attr:`pitch`."""
if self.element_width is not None and self.pitch is not None:
return float(self.pitch - self.element_width)
return None
def _resolve_element_width(probe_geometry, element_width):
"""Return the element width, inferring it from the probe pitch when not given."""
if element_width is not None:
return element_width
try:
geometry = ops.convert_to_numpy(probe_geometry)
except (RuntimeError, ValueError, TypeError) as exc:
raise ValueError(
"Element width is not provided, and automatic inference is not available for "
"traced/symbolic probe geometry (for example under JAX JIT or TensorFlow graph "
"mode). Please provide `element_width` explicitly in the scan/probe parameters."
) from exc
# Measured along the array, so this also works for curved arrays
pitch = element_pitch(geometry)
if not pitch > 0: # nan for a single element, 0 when the elements coincide
raise ValueError(
"Element width is not provided and automatic estimation failed from probe "
"geometry, which needs at least 2 distinct element positions. Please provide "
"`element_width` explicitly."
)
return pitch * 0.9 # 90% of the pitch
[docs]
@probe_registry(name="verasonics_l11_4v")
class Verasonics_l11_4v(Probe):
"""Verasonics L11-4V linear array.
Elevation focus ~20 mm, sensitivity -64.5 to -59.5 dB.
"""
def __init__(self):
"""Verasonics L11-4V linear ultrasound transducer."""
probe_geometry = create_probe_geometry(n_el=128, pitch=0.3e-3)
# Centre frequency is the midpoint of the -6 dB band from the probe name.
low_freq, high_freq = 4e6, 11e6
center_frequency = (low_freq + high_freq) / 2
probe_bandwidth_percent = (high_freq - low_freq) / center_frequency * 100
super().__init__(
name="verasonics_l11_4v",
type="linear",
probe_center_frequency=center_frequency,
probe_bandwidth_percent=probe_bandwidth_percent,
probe_geometry=probe_geometry,
element_width=0.27e-3,
element_height=5e-3,
)
[docs]
@probe_registry(name="verasonics_l11_5v")
class Verasonics_l11_5v(Probe):
"""Verasonics L11-5V linear array."""
def __init__(self):
"""Verasonics L11-5V linear ultrasound transducer."""
probe_geometry = create_probe_geometry(n_el=128, pitch=0.3e-3)
# Centre frequency is the midpoint of the -6 dB band from the probe name.
low_freq, high_freq = 5e6, 11e6
center_frequency = (low_freq + high_freq) / 2
probe_bandwidth_percent = (high_freq - low_freq) / center_frequency * 100
super().__init__(
name="verasonics_l11_5v",
type="linear",
probe_center_frequency=center_frequency,
probe_bandwidth_percent=probe_bandwidth_percent,
probe_geometry=probe_geometry,
element_width=0.27e-3,
element_height=5e-3,
)
[docs]
@probe_registry(name="esaote_sll1543")
class Esaote_sll1543(Probe):
"""Esaote SLL1543 linear ultrasound transducer.
https://lysis.cc/products/esaote-sl1543
"""
def __init__(self):
"""Set probe parameters"""
probe_geometry = create_probe_geometry(n_el=192, pitch=0.245 / 1e3)
# Centre frequency is the midpoint of the -6 dB band from the probe name.
low_freq, high_freq = 3e6, 13e6
center_frequency = (low_freq + high_freq) / 2
probe_bandwidth_percent = (high_freq - low_freq) / center_frequency * 100
super().__init__(
name="esaote_sll1543",
type="linear",
probe_center_frequency=center_frequency,
probe_bandwidth_percent=probe_bandwidth_percent,
probe_geometry=probe_geometry,
)
[docs]
@probe_registry(name="verasonics_c5_2v")
class Verasonics_c5_2v(Probe):
"""Verasonics C5-2v convex (curved) array.
Elevation focus ~60 mm, sensitivity -55.5 to -50.5 dB.
"""
def __init__(self):
"""Verasonics C5-2v convex (curved) ultrasound transducer."""
probe_geometry = create_curved_probe_geometry(n_el=128, pitch=0.508e-3, radius=49.57e-3)
# Centre frequency is the midpoint of the -6 dB band from the probe name.
low_freq, high_freq = 2e6, 5e6
center_frequency = (low_freq + high_freq) / 2
probe_bandwidth_percent = (high_freq - low_freq) / center_frequency * 100
super().__init__(
name="verasonics_c5_2v",
type="curved",
probe_center_frequency=center_frequency,
probe_bandwidth_percent=probe_bandwidth_percent,
probe_geometry=probe_geometry,
element_width=0.46e-3,
)
[docs]
@probe_registry(name="verasonics_p4_2v")
class Verasonics_p4_2v(Probe):
"""Verasonics P4-2v phased array.
Elevation focus ~60 mm, sensitivity -69 to -65 dB.
"""
def __init__(self):
"""Verasonics P4-2v phased-array ultrasound transducer."""
probe_geometry = create_probe_geometry(n_el=64, pitch=0.3e-3)
# Centre frequency is the midpoint of the -6 dB band from the probe name.
low_freq, high_freq = 2e6, 4e6
center_frequency = (low_freq + high_freq) / 2
probe_bandwidth_percent = (high_freq - low_freq) / center_frequency * 100
super().__init__(
name="verasonics_p4_2v",
type="phased",
probe_center_frequency=center_frequency,
probe_bandwidth_percent=probe_bandwidth_percent,
probe_geometry=probe_geometry,
element_width=0.25e-3,
)