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`.

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")
    >>> probe.probe_center_frequency
    np.float32(6250000.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

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,
    ... }
    >>> f = File.create(
    ...     "probe_example.hdf5",
    ...     data={"raw_data": raw},
    ...     scan=scan,
    ...     probe=probe, # dictionary or zea.Probe object
    ...     overwrite=True,
    ... )
    >>> f.probe.name
    'verasonics_l11_4v'
    >>> f.close()

.. testcleanup::

    import os
    os.remove("probe_example.hdf5")

"""  # noqa: E501

import numpy as np

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] class Probe(ProbeSpec): # 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"pitch={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 | None: """Compute the pitch (centre-to-centre element spacing) in metres from the probe geometry. Returns ``None`` when the pitch is not defined for the geometry (fewer than 2 elements, or not a 1-D / linear array). Raises :class:`ValueError` when the array looks like a ULA but the element positions are not uniformly spaced, to surface likely data errors rather than silently returning ``None``. """ 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})") # Only valid for 1-D (linear) arrangements – all elements must lie on the x-axis # (y == 0 and z == 0 for every element). if not (np.allclose(probe_geometry[:, 1], 0) and np.allclose(probe_geometry[:, 2], 0)): raise ValueError( "Cannot compute pitch: probe geometry is not 1-D (linear array). " "Element positions must have y=0 and z=0 for all elements." ) spacings = np.diff(probe_geometry[:, 0]) if not np.allclose(spacings, spacings[0], rtol=1e-3): raise ValueError( "Cannot compute pitch: element x-positions are not uniformly spaced. " f"Min spacing: {spacings.min():.4e} m, max: {spacings.max():.4e} m." ) return float(spacings[0])
@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, or * the elements are not arranged along a single axis (not a 1-D / linear array). * the spacing is non-uniform (elements are present but clearly not a ULA), to surface likely data errors rather than silently returning ``None``. """ 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 self.pitch - self.element_width return None
[docs] @probe_registry(name="verasonics_l11_4v") class Verasonics_l11_4v(Probe): """Verasonics L11-4V linear ultrasound transducer.""" def __init__(self): """Verasonics L11-4V linear ultrasound transducer.""" probe_geometry = create_probe_geometry(n_el=128, pitch=0.3e-3) center_frequency = 6.25e6 probe_bandwidth_percent = (11 - 4) * 100 / (center_frequency / 1e6) super().__init__( name="verasonics_l11_4v", type="linear", probe_center_frequency=center_frequency, probe_bandwidth_percent=probe_bandwidth_percent, probe_geometry=probe_geometry, )
[docs] @probe_registry(name="verasonics_l11_5v") class Verasonics_l11_5v(Probe): """Verasonics L11-5V linear ultrasound transducer.""" def __init__(self): """Verasonics L11-5V linear ultrasound transducer.""" probe_geometry = create_probe_geometry(n_el=128, pitch=0.3e-3) center_frequency = 6.25e6 probe_bandwidth_percent = (11 - 5) * 100 / (center_frequency / 1e6) # elevation_focus = 18e-3 # sensitivity = -52 +/- 3 dB super().__init__( name="verasonics_l11_5v", type="linear", probe_center_frequency=center_frequency, probe_bandwidth_percent=probe_bandwidth_percent, probe_geometry=probe_geometry, )
[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) center_frequency = 8e6 probe_bandwidth_percent = (13 - 3) * 100 / (center_frequency / 1e6) super().__init__( name="esaote_sll1543", type="linear", probe_center_frequency=center_frequency, probe_bandwidth_percent=probe_bandwidth_percent, probe_geometry=probe_geometry, )