"""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")
>>> float(probe.probe_center_frequency)
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,
... }
>>> 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 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):
"""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"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:
"""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 geometry is not a 1-D (linear) array, or the element positions are
not uniformly spaced.
"""
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 float(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,
)