Source code for zea.probes

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