├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── requirements.txt ├── setup.py ├── tests ├── data │ └── testdata.md ├── probes │ └── test_probes.py ├── test_annotations.py ├── test_aperture.py ├── test_kwave.py ├── test_meta.py ├── test_position.py ├── test_probe.py ├── test_transforms.py ├── test_uff.py └── waves │ ├── test_scans.py │ └── test_wave_geometry.py ├── tox.ini └── uff ├── __init__.py ├── aperture.py ├── channel_data.py ├── curviliear_array.py ├── cylindrical_wave_origin.py ├── element.py ├── element_geometry.py ├── event.py ├── excitation.py ├── impulse_response.py ├── linear_array.py ├── matrix_array.py ├── origin.py ├── perimeter.py ├── plane_wave_origin.py ├── position.py ├── probe.py ├── receive_setup.py ├── rotation.py ├── spherical_wave_origin.py ├── time_zero_reference_point.py ├── timed_event.py ├── transform.py ├── translation.py ├── transmit_event.py ├── transmit_setup.py ├── transmit_wave.py ├── uff.py ├── uff_io.py ├── utils.py ├── version.py ├── wave.py └── wave_type.py /.gitattributes: -------------------------------------------------------------------------------- 1 | tests/data/*.uff filter=lfs diff=lfs merge=lfs -text 2 | tests/data/*.h5 filter=lfs diff=lfs merge=lfs -text 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Python version [e.g. 3.9] 29 | - Version/Commit [e.g. 0.3.0] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Test 4 | 5 | on: [push, pull_request] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.7', '3.8', '3.9', '3.10'] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | python -m pip install tox tox-gh-actions 24 | - name: Test with tox 25 | run: tox 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__ 3 | .tox 4 | MANIFEST 5 | .idea 6 | *.h5 7 | *.uff 8 | /venv/ 9 | /build/ 10 | /src/UFF.py.egg-info/ 11 | /src/uff_reader.egg-info/ 12 | /dist/* 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Walter Simson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uff-reader 2 | 3 | Python Reader for the Ultrasound File Format 4 | 5 | This Python package is based on the standard defined in v0.3.0 of 6 | the [Ultrasound File Format](https://bitbucket.org/ultrasound_file_format/uff/wiki/Home). 7 | 8 | **NOTE**: A Python reader for the Ultrasound toolbox ([USTB](https://bitbucket.org/ustb/ustb)) version of uff can be found [here](https://github.com/magnusdk/pyuff_ustb) 9 | 10 | ## Instalation 11 | 12 | to install run: 13 | 14 | ```pip install uff-reader``` 15 | 16 | ## Getting Started 17 | 18 | ``` python3 19 | from uff import UFF 20 | 21 | # load uff object from uff file 22 | uff_object = UFF.load('') 23 | 24 | # print uff summary 25 | uff_object.summary 26 | ``` 27 | ## Cite 28 | ```bibtex 29 | @inproceedings{bernard2018ultrasound, 30 | title={The ultrasound file format (UFF)-first draft}, 31 | author={Bernard, Olivier and Bradway, David and Hansen, Hendrik HG and Kruizinga, Pieter and Nair, Arun and Perdios, Dimitris and Ricci, Stefano and Rindal, Ole Marius Hoel and Rodriguez-Molares, Alfonso and Stuart, Matthias Bo and others}, 32 | booktitle={2018 IEEE International Ultrasonics Symposium (IUS)}, 33 | pages={1--4}, 34 | year={2018}, 35 | organization={IEEE} 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "uff-reader" 7 | description = "Python interface for the Ultrasound File Format (UFF)" 8 | readme = "README.md" 9 | license = "MIT" 10 | version = "0.0.2" 11 | authors = [ 12 | { name = "Walter Simson", email = "walter.simson@tum.de" }, 13 | ] 14 | dependencies = [ 15 | "h5py>=3.5.0", 16 | "numpy>=1.20", 17 | "pytest>=6.2.4", 18 | "requests>=2.26.0", 19 | "scipy>=1.7.0", 20 | ] 21 | 22 | [tool.hatch.build.targets.sdist] 23 | include = [ 24 | "uff", 25 | ] 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==1.20.1 2 | pytest==6.2.4 3 | h5py==3.5.0 4 | requests==2.26.0 5 | scipy>=1.7.0 6 | 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='uff-reader', 5 | version='0.0.2', 6 | description='Python interface for the Ultrasound File Format (UFF)', 7 | author='Walter Simson', 8 | author_email='walter.simson@tum.de', 9 | packages=['uff'], 10 | package_dir={'': 'uff'}, 11 | install_requires=['numpy>=1.20', 12 | 'pytest>=6.2.4', 13 | 'requests>=2.26.0', 14 | 'h5py>=3.5.0', 15 | 'scipy>=1.7.0']) 16 | -------------------------------------------------------------------------------- /tests/data/testdata.md: -------------------------------------------------------------------------------- 1 | ## Test Data 2 | 3 | The following files are required to be located here for testing: 4 | 5 | * [File 1](http://ustb.no/datasets/uff/fieldII_converging_wave_grid.uff) 6 | * [File 2](http://ustb.no/datasets/uff/fieldII_single_element_transmit_grid.uff) 7 | * [File 3](http://ustb.no/datasets/uff/fieldII_converging_wave_mlt_sector.uff) 8 | * [File 4](http://ustb.no/datasets/uff/fieldII_diverging_wave_grid.uff) 9 | * [File 5](http://ustb.no/datasets/uff/fieldII_plane_wave_grid.uff) 10 | 11 | The files are currently hosted by [USTB](https://www.ustb.no/) and are automatically downloaded by the test suite. 12 | -------------------------------------------------------------------------------- /tests/probes/test_probes.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waltsims/uff-reader/b17bb7832f8d9d51a49557301051a8827adace93/tests/probes/test_probes.py -------------------------------------------------------------------------------- /tests/test_annotations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from uff import TimeZeroReferencePoint 3 | 4 | 5 | def test_annotations(): 6 | assert TimeZeroReferencePoint.__annotations__ != {}, "Time zero reference point annotation dict is empty" 7 | -------------------------------------------------------------------------------- /tests/test_aperture.py: -------------------------------------------------------------------------------- 1 | from uff import Aperture 2 | from uff import Position 3 | 4 | 5 | def test_instantiation(): 6 | p1 = Position(0, 0, 0) 7 | Aperture(origin=p1, window='Hamming', f_number=1, fixed_size=1) 8 | pass 9 | -------------------------------------------------------------------------------- /tests/test_kwave.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import numpy as np 4 | 5 | from uff import ElementGeometry, Perimeter, Position, Translation, Rotation, Transform, Probe, WaveType, Aperture, \ 6 | Origin, Wave, TransmitWave, TimeZeroReferencePoint, TransmitSetup, ReceiveSetup, ChannelData, UFF, Excitation, \ 7 | Element, TimedEvent, Event 8 | from uff import verify_correctness, load_uff_dict, is_version_compatible 9 | 10 | 11 | def create_uff(data_type='real'): 12 | n_elem = 32 13 | Nx, Ny, Nz = 128, 128, 16 14 | elem_width = 4 15 | elem_spacing = 0 16 | elem_length = 12 17 | elem_pitch = elem_width + elem_spacing 18 | transducer_width = n_elem * elem_width + (n_elem - 1) * elem_spacing 19 | dt = 1.9481e-08 # in seconds 20 | 21 | elem_x = 1e-3 * elem_pitch * np.linspace(-n_elem // 2, n_elem // 2) - (elem_width // 2) 22 | elem_y = np.zeros(n_elem) 23 | elem_z = np.zeros(n_elem) 24 | 25 | source_strength = 1e6 26 | tone_burst_freq = 1.5e6 27 | tone_burst_cycles = 1 28 | 29 | eg = ElementGeometry(Perimeter([Position(1.0, 1.0, 1.0)])) 30 | # ir = ImpulseResponse(0, 10, [1, 1, 1], '[m]') 31 | 32 | elements = [] 33 | for elem_idx in range(n_elem): 34 | T = Translation(float(elem_x[elem_idx]), float(elem_y[elem_idx]), float(elem_z[elem_idx])) 35 | R = Rotation(0.0, 0.0, 0.0) 36 | elem_transform = Transform(T, R) 37 | elem = Element(elem_transform, eg) 38 | elements.append(elem) 39 | 40 | probe_T = Translation(1.0, float(Ny // 2 - transducer_width // 2), float(Nz // 2 - elem_length // 2)) 41 | probe_R = Rotation(0.0, 0.0, 0.0) 42 | probe_transform = Transform(probe_T, probe_R) 43 | 44 | probe = Probe(number_elements=np.int32(n_elem), 45 | pitch=1.0 + 1e-8, 46 | element_height=12.0, 47 | element_width=1.0, 48 | element=elements, 49 | transform=probe_transform, 50 | element_geometry=[eg]) 51 | 52 | dt_string = datetime.now().isoformat() 53 | 54 | transmit_waves = [ 55 | TransmitWave(wave=1, 56 | time_zero_reference_point=TimeZeroReferencePoint( 57 | 0.0, 0.0, 0.0)) 58 | ] 59 | 60 | transmit_setup = TransmitSetup(probe=1, 61 | transmit_waves=transmit_waves, 62 | channel_mapping=np.array([[i + 1 for i in range(n_elem)]]), 63 | sampling_frequency=1 / dt) 64 | 65 | receive_setup = ReceiveSetup(probe=1, 66 | time_offset=100 * dt, 67 | channel_mapping=np.array([[i + 1 for i in range(n_elem)]]), 68 | sampling_frequency=1 / dt) 69 | 70 | us_event = Event(transmit_setup=transmit_setup, 71 | receive_setup=receive_setup) 72 | 73 | seq = [TimedEvent( 74 | event=1, 75 | time_offset=0.0, 76 | )] 77 | 78 | unique_waves = [ 79 | Wave(origin=Origin(position=Position(), rotation=Rotation()), 80 | wave_type=WaveType.PLANE, 81 | aperture=Aperture(origin=Position(), 82 | fixed_size=0.0, 83 | f_number=1.0, 84 | window='rectwin'), 85 | excitation=1) 86 | ] 87 | 88 | unique_events = [us_event] 89 | 90 | unique_excitations = [ 91 | Excitation( 92 | pulse_shape='sinusoidal', 93 | waveform=np.ones(10), # => np.delay_mask 94 | sampling_frequency=1 / dt) 95 | ] 96 | 97 | if data_type == 'complex': 98 | data = np.random.random((Nx, Ny, Nz)) + np.random.random((Nx, Ny, Nz)) * 1j 99 | data_new = np.empty((data.shape[:1]) + (n_elem,), dtype=complex) 100 | elif data_type == 'real': 101 | data = np.random.random((Nx, Ny, Nz)) 102 | data_new = np.empty((data.shape[:1]) + (n_elem,)) 103 | else: 104 | raise ValueError(f'Invalid argument {data_type} passed') 105 | 106 | for elem in range(n_elem): 107 | s_index, e_index = elem * elem_width, (elem + 1) * elem_width 108 | data_new[:, elem] = data[s_index:e_index, :, :].sum(axis=(0, 2)) 109 | 110 | assert data_new.shape[-1] == n_elem 111 | 112 | channel_data = ChannelData( 113 | probes=[probe], 114 | sound_speed=1540.0, 115 | local_time=dt_string, 116 | country_code='DE', 117 | repetition_rate=(1 / tone_burst_freq), 118 | authors="Some random guy", 119 | description="Lorem ipsum si amet ... as always ;)", 120 | system="Whatever system it is ...", 121 | data=data_new, 122 | sequence=seq, 123 | unique_waves=unique_waves, 124 | unique_events=unique_events, 125 | unique_excitations=unique_excitations) 126 | 127 | uff = UFF() 128 | uff.channel_data = channel_data 129 | version = {'major': 0, 'minor': 3, 'patch': 0} 130 | return uff, version 131 | 132 | 133 | def test_kwave_complex_io(): 134 | # Create kWave output 135 | kwave_uff, version = create_uff('complex') 136 | 137 | # Save kWave output in .uff format 138 | kwave_uff.save('kwave_out.uff', version) 139 | 140 | # Load save .uff file 141 | uff_dict = load_uff_dict('kwave_out.uff') 142 | 143 | # Check version correctness 144 | version = uff_dict['version'] 145 | assert is_version_compatible(version, (0, 3, 0)) 146 | 147 | # Save back loaded .uff file 148 | uff_loaded = UFF.deserialize(uff_dict) 149 | 150 | uff_loaded.save('kwave_out_duplicate.uff', version) 151 | 152 | # Check equality of both files 153 | verify_correctness('kwave_out.uff', 'kwave_out_duplicate.uff') 154 | 155 | 156 | def test_kwave_real_io(): 157 | # Create kWave output 158 | kwave_uff, version = create_uff('real') 159 | 160 | # Save kWave output in .uff format 161 | kwave_uff.save('kwave_out.uff', version) 162 | 163 | # Load save .uff file 164 | uff_dict = load_uff_dict('kwave_out.uff') 165 | 166 | # Check version correctness 167 | version = uff_dict['version'] 168 | assert is_version_compatible(version, (0, 3, 0)) 169 | 170 | # Save back loaded .uff file 171 | uff_loaded = UFF.deserialize(uff_dict) 172 | 173 | uff_loaded.save('kwave_out_duplicate.uff', version) 174 | 175 | # Check equality of both files 176 | verify_correctness('kwave_out.uff', 'kwave_out_duplicate.uff') 177 | -------------------------------------------------------------------------------- /tests/test_meta.py: -------------------------------------------------------------------------------- 1 | from uff import __version__ 2 | 3 | 4 | def test_version(): 5 | if type(__version__) is not str: 6 | raise TypeError("Version string incorrect") 7 | 8 | if __version__ != '0.3.0': 9 | raise AssertionError(f"Version {__version__} string incorrect") 10 | 11 | pass 12 | -------------------------------------------------------------------------------- /tests/test_position.py: -------------------------------------------------------------------------------- 1 | from uff import Position 2 | import unittest 3 | 4 | 5 | class TestPosition: 6 | def test_position_instantiation(self): 7 | p1 = Position(1, 2, 3) 8 | 9 | def test_position_compare(self): 10 | p1 = Position(1, 2, 3) 11 | p2 = Position(1, 2, 3) 12 | assert p1 == p2 13 | 14 | def test_position_itterator(self): 15 | p1 = Position(1, 2, 3) 16 | p1_iter = iter(p1) 17 | 18 | assert 1 == next(p1_iter) 19 | assert 2 == next(p1_iter) 20 | assert 3 == next(p1_iter) 21 | with unittest.TestCase.assertRaises(self, StopIteration): 22 | next(p1_iter) 23 | -------------------------------------------------------------------------------- /tests/test_probe.py: -------------------------------------------------------------------------------- 1 | from uff import Element 2 | from uff import ElementGeometry 3 | from uff import ImpulseResponse 4 | from uff import Perimeter 5 | from uff import Position 6 | from uff import Probe 7 | from uff import Rotation 8 | from uff import Transform 9 | from uff import Translation 10 | 11 | 12 | def test_instantiation(): 13 | t = Translation(1, 1, 1) 14 | r = Rotation(0, 0, 0) 15 | tt = Transform(t, r) 16 | eg = ElementGeometry(Perimeter([Position(1, 1, 1)])) 17 | ir = ImpulseResponse(0, 10, [1, 1, 1], '[m]') 18 | e = Element(tt, eg, ir) 19 | p = Probe(transform=tt, 20 | element=e, 21 | element_geometry=[eg], 22 | element_impulse_response=[ir], 23 | number_elements=128, 24 | pitch=0, 25 | element_height=0.15, 26 | element_width=0.3) 27 | 28 | 29 | def test_serialization(): 30 | t = Translation(1, 1, 1) 31 | r = Rotation(0, 0, 0) 32 | tt = Transform(t, r) 33 | eg = ElementGeometry(Perimeter([Position(1, 1, 1)])) 34 | ir = ImpulseResponse(0, 10, [1, 1, 1], '[m]') 35 | e = Element(tt, eg, ir) 36 | p = Probe(transform=tt, 37 | element=e, 38 | element_geometry=[eg], 39 | element_impulse_response=[ir], 40 | number_elements=128, 41 | pitch=0, 42 | element_height=0.15, 43 | element_width=0.3) 44 | 45 | p_ser = p.serialize() 46 | pass 47 | 48 | 49 | def test_serialization_with_only_required_args(): 50 | t = Translation(1, 1, 1) 51 | r = Rotation(0, 0, 0) 52 | tt = Transform(t, r) 53 | ir = ImpulseResponse(0, 10, [1, 1, 1], '[m]') 54 | eg = ElementGeometry(Perimeter([Position(1, 1, 1)])) 55 | e = Element(tt, eg, ir) 56 | p = Probe(transform=tt, 57 | element=e, 58 | pitch=0) 59 | 60 | p_ser = p.serialize() 61 | pass 62 | -------------------------------------------------------------------------------- /tests/test_transforms.py: -------------------------------------------------------------------------------- 1 | from uff import Translation 2 | from uff import Rotation 3 | from uff import Transform 4 | from uff import Position 5 | import numpy as np 6 | from scipy.spatial.transform.rotation import Rotation as R 7 | 8 | 9 | def test_translate_position(): 10 | point = Position(x=1, y=2, z=3) 11 | trans = Translation(x=-5, y=10, z=-17) 12 | new_point = trans(point) 13 | assert new_point == Position(x=-4, y=12, z=-14) 14 | 15 | 16 | def test_rotation_position(): 17 | rot1 = R.from_euler('xyz', [50, 60, 90], degrees=False) 18 | point = Position(x=1, y=2, z=3) 19 | point1 = Position(*rot1.apply(np.array(point))) 20 | rot2 = Rotation(50, 60, 90) 21 | point2 = rot2(point) 22 | assert point1 == point2 23 | 24 | 25 | def test_transform_position(): 26 | point = Position(x=1, y=2, z=3) 27 | rot = Rotation(50, 60, 90) 28 | trans = Translation(x=-5, y=10, z=-17) 29 | tr = Transform(rotation=rot, translation=trans) 30 | x = tr(point) 31 | point1 = Position(*rot(point)) 32 | y = trans(point1) 33 | assert x == y 34 | -------------------------------------------------------------------------------- /tests/test_uff.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from uff.uff import UFF 5 | from uff import verify_correctness, is_version_compatible, load_uff_dict, download_test_data 6 | 7 | FIXTURE_DIR = os.path.join( 8 | os.path.dirname(os.path.realpath(__file__)), 9 | 'data', 10 | ) 11 | 12 | 13 | def test_uff_save_load(): 14 | ref_files = [ 15 | 'fieldII_converging_wave_mlt_sector.uff', 16 | 'fieldII_converging_wave_grid.uff', 17 | 'fieldII_diverging_wave_grid.uff', 18 | 'fieldII_plane_wave_grid.uff', 19 | 'fieldII_single_element_transmit_grid.uff', 20 | ] 21 | 22 | # check all files exist in data/ 23 | missing_files = [file for file in ref_files if not os.path.isfile(Path(FIXTURE_DIR) / file)] 24 | if missing_files: 25 | print("Downloading test files...") 26 | # if they do not download them with utils. 27 | base_url = 'http://ustb.no/datasets/uff/' 28 | urls = [base_url + file for file in missing_files] 29 | download_test_data(rel_path=FIXTURE_DIR, file_urls=urls) 30 | 31 | for ref_file in ref_files: 32 | ref_uff_path = os.path.join(FIXTURE_DIR, ref_file) 33 | uff_dict = load_uff_dict(ref_uff_path) 34 | 35 | version = uff_dict['version'] 36 | assert is_version_compatible(version, (0, 3, 0)) 37 | print("good version") 38 | 39 | uff_new = UFF.load(ref_uff_path) 40 | 41 | uff_new_save_path = 'new.uff' 42 | uff_new.save(uff_new_save_path, version) 43 | 44 | verify_correctness(uff_new_save_path, ref_uff_path) 45 | -------------------------------------------------------------------------------- /tests/waves/test_scans.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from uff import Aperture, Position, PlaneWaveOrigin, Wave, WaveType, SphericalWaveOrigin, TransmitWave, TransmitSetup, \ 4 | ReceiveSetup, Event 5 | from uff import Rotation 6 | 7 | 8 | def test_linear_scan_focused_beam(): 9 | N_waves = 16 10 | x0 = np.linspace(-20e-3, 20e-3, N_waves) 11 | focal_depth = 20e-3 12 | 13 | waves = [] 14 | 15 | for wave_idx in range(N_waves): 16 | p = Position(x=x0[wave_idx], y=focal_depth) 17 | a = Aperture(f_number=1, fixed_size=[40e-3, 12e-3], origin=Position()) 18 | waves.append(Wave(origin=p, aperture=a, wave_type=WaveType.CONVERGING)) 19 | 20 | 21 | def test_plane_wave_sequence(): 22 | n_waves = 11 23 | angle_span = 30 * np.pi / 180 24 | 25 | angles = np.linspace(-0.5 * angle_span, 0.5 * angle_span, n_waves) 26 | 27 | waves = [] 28 | 29 | for wave_idx in range(n_waves): 30 | a = Aperture(fixed_size=[40e-3, 12e-3], 31 | origin=Position(), 32 | window='hamming') 33 | origin = PlaneWaveOrigin(rotation=Rotation(y=angles[wave_idx])) 34 | waves.append(Wave(origin=origin, aperture=a, wave_type=WaveType.PLANE)) 35 | 36 | 37 | def test_sector_scan_diverging_beams(): 38 | n_waves = 5 39 | azimuth = np.linspace(-np.pi / 6, np.pi / 6, n_waves) 40 | virtual_source_distance = 20e-3 41 | 42 | waves = [] 43 | 44 | for angle in azimuth: 45 | w = Wave(origin=SphericalWaveOrigin( 46 | position=Position(x=virtual_source_distance * np.sin(angle), 47 | z=-virtual_source_distance * np.cos(angle))), 48 | wave_type=WaveType.DIVERGING, 49 | aperture=Aperture(window='rectwin', 50 | origin=Position(), 51 | fixed_size=[18e-3, 12e-3])) 52 | 53 | waves.append(w) 54 | 55 | 56 | def test_sector_scan_focus_beams(): 57 | n_waves = 16 58 | azimuth = np.linspace(-np.pi / 6, np.pi / 6, n_waves) 59 | focal_depth = 70e-3 60 | 61 | waves = [] 62 | 63 | for angle in azimuth: 64 | w = Wave(origin=SphericalWaveOrigin(position=Position( 65 | x=focal_depth * np.sin(angle), z=focal_depth * np.cos(angle))), 66 | wave_type=WaveType.CONVERGING, 67 | aperture=Aperture(window='rectwin', 68 | origin=Position(), 69 | fixed_size=[18e-3, 12e-3])) 70 | 71 | waves.append(w) 72 | 73 | 74 | def test_scan_mlt(): 75 | n_waves = 70 # Number of unique beams in the sequence 76 | n_mlt = 2 # Number of Multi-Line Transmots (i.e. beams in the same transmit event) 77 | focal_depth = 0.06 # Transmit focal depth [m] 78 | angle_span = 70 * np.pi / 180 # Sector opening [rad] 79 | angles = np.linspace(-0.5 * angle_span, 0.5 * angle_span, n_waves) 80 | 81 | waves = [] 82 | for angle in angles: 83 | x_pos = focal_depth * np.cos(angle) 84 | y_pos = focal_depth * np.sin(angle) 85 | p: Position = Position(y=y_pos, x=x_pos) 86 | origin = SphericalWaveOrigin(position=p) 87 | a: Aperture = Aperture(fixed_size=[16e-3, 12e-3], 88 | origin=Position(), 89 | window='Tukey(0.5)') 90 | waves.append( 91 | Wave(origin=origin, aperture=a, wave_type=WaveType.CONVERGING)) 92 | 93 | events = [] 94 | # Merge two beams into each event 95 | for i in range(int(n_waves / n_mlt)): 96 | for wave_n in range(n_mlt): 97 | tw = TransmitWave(time_zero_reference_point=0, 98 | wave=waves[int(i + wave_n * n_waves / n_mlt)]) 99 | # TODO: currently many Nones passed since all arguments are required. Fix by setting default parameters. 100 | ts = TransmitSetup('transmit_waves', [tw], 101 | channel_mapping=None, 102 | sampled_delays=None, 103 | sampled_excitations=None, 104 | sampling_frequency=None, 105 | transmit_voltage=None) 106 | # TODO: currently many Nones passed since all arguments are required. Fix by setting default parameters. 107 | rs = ReceiveSetup(probe=None, 108 | time_offset=None, 109 | channel_mapping=None, 110 | sampling_frequency=None, 111 | tgc_profile=None, 112 | tgc_sampling_frequency=None, 113 | modulation_frequency=None) 114 | events.append(Event(transmit_setup=ts, receive_setup=rs)) 115 | -------------------------------------------------------------------------------- /tests/waves/test_wave_geometry.py: -------------------------------------------------------------------------------- 1 | from uff import Aperture, Position, WaveType 2 | from uff import PlaneWaveOrigin 3 | from uff import SphericalWaveOrigin 4 | from uff import Wave 5 | 6 | 7 | def test_converging_wave(): 8 | # Single convering wave 9 | 10 | # Geometric origin 11 | aperture = Aperture(window='hanning', 12 | origin=Position(), 13 | f_number=2.1, 14 | fixed_size=[12e-3]) 15 | 16 | wo = SphericalWaveOrigin() 17 | p = Position(x=20e-3, y=0, z=50e-3) 18 | wt = WaveType.CONVERGING 19 | wave = Wave(aperture=aperture, origin=wo, wave_type=wt) 20 | 21 | 22 | def test_diverging_wave(): 23 | # Single divergin wave 24 | 25 | # Geometric origin 26 | aperture = Aperture(window='hanning', 27 | origin=Position(), 28 | f_number=2.1, 29 | fixed_size=12e-3) 30 | 31 | wo = SphericalWaveOrigin() 32 | pos = Position(x=20e-3, y=0, z=50e-3) 33 | wt = WaveType.DIVERGING 34 | wave = Wave(aperture=aperture, origin=wo, wave_type=wt) 35 | 36 | 37 | def test_plane_wave(): 38 | # Single divergin wave 39 | 40 | # Geometric origin 41 | aperture = Aperture(window='hanning', 42 | origin=Position(), 43 | f_number=2.1, 44 | fixed_size=12e-3) 45 | 46 | wo = PlaneWaveOrigin() 47 | p = Position(x=20e-3, y=0, z=50e-3) 48 | wt = WaveType.DIVERGING 49 | wave = Wave(aperture=aperture, origin=wo, wave_type=wt) 50 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37,py38,py39,py310 3 | 4 | [gh-actions] 5 | python = 6 | 3.7: py37 7 | 3.8: py38 8 | 3.9: py39 9 | 3.10: py310 10 | 11 | [testenv] 12 | changedir = tests 13 | deps = pytest 14 | commands = pytest --basetemp="{envtmpdir}" {posargs} 15 | 16 | -------------------------------------------------------------------------------- /uff/__init__.py: -------------------------------------------------------------------------------- 1 | from uff.transform import Transform 2 | from uff.translation import Translation 3 | from uff.rotation import Rotation 4 | from uff.element import Element 5 | from uff.element_geometry import ElementGeometry 6 | from uff.impulse_response import ImpulseResponse 7 | from uff.perimeter import Perimeter 8 | from uff.position import Position 9 | from uff.aperture import Aperture 10 | from uff.wave import Wave 11 | from uff.wave_type import WaveType 12 | from uff.origin import Origin 13 | from uff.plane_wave_origin import PlaneWaveOrigin 14 | from uff.spherical_wave_origin import SphericalWaveOrigin 15 | from uff.cylindrical_wave_origin import CylindricalWaveOrigin 16 | from uff.event import Event 17 | from uff.excitation import Excitation 18 | from uff.transmit_setup import TransmitSetup 19 | from uff.timed_event import TimedEvent 20 | from uff.receive_setup import ReceiveSetup 21 | from uff.probe import Probe 22 | from uff.time_zero_reference_point import TimeZeroReferencePoint 23 | from uff.channel_data import ChannelData 24 | from uff.transmit_wave import TransmitWave 25 | from uff.uff import UFF 26 | from uff.version import __version__, __version_info__ 27 | -------------------------------------------------------------------------------- /uff/aperture.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.position import Position 4 | from uff.uff_io import Serializable 5 | 6 | 7 | @dataclass 8 | class Aperture(Serializable): 9 | """ 10 | UFF class to define analytically the aperture use in an ultrasound wave. 11 | 12 | Notes: 13 | The class aperture defines the transmit apodization profile. In this case 14 | origin defines the center of the aperture. The size of the aperture can 15 | be described with fixed_size, or with f_number in which case the aperture 16 | size is d/f_number where d is the distance between uff.wave.origin and 17 | uff.wave.aperture.origin. The parameter window is a string describing 18 | the apodization window. 19 | 20 | Attributes: 21 | origin (Position): Location of the aperture center in space. 22 | window (str): String defining the apodization window type and 23 | parameter (e.g., 'Hamming', 'Gauss(8)', 'Tukey(0.5)') 24 | f_number list[float]: Desired F-number of the aperture [Az, El] 25 | fixed_size list[float]: If non-zero, this overwrites the size of the aperture 26 | in [m] [Az, El] 27 | minimun_size (float): (Optional) If non-zero, this sets a limit for the minimum 28 | dynamic aperture in m [Az, El] 29 | maximum_size (float): (Optional) If non-zero, this sets a limit for the maximum 30 | dynamic aperture in m [Az, El] 31 | """ 32 | 33 | @staticmethod 34 | def str_name(): 35 | return 'aperture' 36 | 37 | # @classmethod 38 | # def deserialize(cls: object, data: dict): 39 | # data['position'] = data.pop('origin') 40 | # return super().deserialize(data) 41 | 42 | # TODO: standard has this named aperture but defined as position 43 | origin: Position 44 | # TODO: what should fixed size type be? list? float? how do you reproduce the same functionality 45 | fixed_size: float 46 | f_number: float = 1.0 47 | window: str = 'rectwin' 48 | minimum_size: float = None 49 | maximum_size: float = None 50 | -------------------------------------------------------------------------------- /uff/channel_data.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | import numpy as np 5 | 6 | from uff import Probe, Wave, Event, TimedEvent 7 | from uff.excitation import Excitation 8 | from uff.uff_io import Serializable 9 | 10 | 11 | @dataclass 12 | class ChannelData(Serializable): 13 | """ 14 | UFF class that contains all the information needed to store and later process channel data. 15 | 16 | Notes: 17 | 18 | The parameter authors identifies the authors of the data; description describes the acquisition scheme, 19 | motivation and application; local_time and country_code identify the time and place the data were acquired; 20 | system describes the hardware used in the acquisition; sound_speed contains the reference speed of sound that was 21 | used in the system to produce the transmitted waves; and repetition_rate is the sequence repetition rate, 22 | also referred to as framerate in some scenarios. 23 | 24 | The object uff.channel_data contains all the probes used in the acquisition, a list of the unique_waves that have 25 | been transmitted, and a list of the unique_events that form the sequence. The sequence is specified as an array 26 | of uff.timed_events, each member containing a reference to an event, and the time_offset since the beginning of 27 | the current repetition, also known as frame. 28 | 29 | The HDF5 dataset data contains the channel data, organized as a matrix of dimensions (HDF5 notation), : 30 | 31 | [frames x events x channels x samples] 32 | 33 | where samples is the number of temporal samples acquired by the system, channels is the number of active 34 | channels, unique_events is the number of events in the sequence (not unique events), and repetitions is the 35 | number of times the described sequence was repeated. Notice that "HDF5 uses C storage conventions, assuming that 36 | the last listed dimension is the fastest-changing dimension and the first-listed dimension is the slowest 37 | changing." (https://support.hdfgroup.org/HDF5/doc1.6/UG/12_Dataspaces.html). This means that by accessing the 38 | data with MATLAB's HDF5 API or Python's h5py the dimension order will be: 39 | 40 | [samples x channels x events x frames] 41 | 42 | This proposal has the limitation of requiring that all event acquisitions have the same number of time samples 43 | and active channels 44 | 45 | Attributes: 46 | authors (str): (Optional) string with the authors of the data 47 | description (str): (Optional) string describing the data 48 | local_time (str): (Optional) string defining the time the dataset was acquired following ISO 8601 49 | country_code (str): (Optional) string defining the country, following ISO 3166-1 50 | system (str): (Optional) string defining the system used to acquired the dataset 51 | repetition_rate (float): (Optional) Inverse of the time delay between consecutive repetitions of the 52 | whole sequence, often known as framerate 53 | data (float): dataset of dimensions [frames x events x channels x samples] in HDF5 54 | probes (Probe): List of the probes used to transmit/recive the sequence 55 | unique_waves (Wave): List of the unique waves (or beams) used in the sequence 56 | unique_events (Event): List of the unique transmit/receive events used in the sequence 57 | unique_excitations (Excitation): List of the unique excitations used in the sequence 58 | sequence (TimedEvent): List of the times_events that describe the sequence 59 | sound_speed (float): Reference sound speed for Tx and Rx events [m/s] 60 | """ 61 | 62 | @staticmethod 63 | def str_name(): 64 | return 'channel_data' 65 | 66 | def serialize(self): 67 | serialized = super().serialize() 68 | 69 | data = serialized.pop('data') 70 | if data.dtype == complex: 71 | serialized['data_imag'] = data.imag 72 | serialized['data_real'] = data.real 73 | 74 | return serialized 75 | 76 | @classmethod 77 | def deserialize(cls: object, data: dict): 78 | if 'data_imag' in data: 79 | assert 'data_real' in data 80 | 81 | if 'data_imag' in data and 'data_real' in data: 82 | data['data'] = data.pop('data_real') + 1j * data.pop('data_imag') 83 | elif 'data_real' in data: 84 | data['data'] = data.pop('data_real') 85 | else: 86 | raise KeyError(f'Channel data not found in {object}') 87 | 88 | return super().deserialize(data) 89 | 90 | # @staticmethod 91 | # def deserialize(data: dict): 92 | # set_attrs, remaining_attrs = self.assign_primitives(data) 93 | # print(set_attrs) 94 | # print('=' * 20) 95 | # print(remaining_attrs) 96 | 97 | probes: List[Probe] 98 | unique_waves: List[Wave] 99 | unique_events: List[Event] 100 | unique_excitations: List[Excitation] 101 | sequence: TimedEvent 102 | sound_speed: float 103 | authors: str = "" 104 | description: str = "" 105 | local_time: str = "" 106 | country_code: str = "" 107 | system: str = "" 108 | repetition_rate: float = None 109 | data: np.ndarray = 0.0 # could be complex or real 110 | -------------------------------------------------------------------------------- /uff/curviliear_array.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.probe import Probe 4 | 5 | 6 | @dataclass 7 | class CurvilinearArray(Probe): 8 | """ 9 | Describes a linear array, made of identical elements, uniformly distributed on a line. 10 | 11 | Attributes: 12 | number_elements (int): Number of elements in the array 13 | pitch (float): Distance between the acoustic ceneter of adyacent elements [m] 14 | radius (float): 15 | element_width (float): (Optional) Element size in the x-axis [m] 16 | element_height (float): (Optional) Element size in the y-axis [m] 17 | """ 18 | 19 | def str_name(self): 20 | return 'probe.curvilinear_array' 21 | 22 | number_elements: int 23 | pitch: float 24 | radius: float 25 | element_width: float = None 26 | element_height: float = None 27 | -------------------------------------------------------------------------------- /uff/cylindrical_wave_origin.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.origin import Origin 4 | from uff.position import Position 5 | 6 | 7 | @dataclass 8 | class CylindricalWaveOrigin(Origin): 9 | position: Position = Position() 10 | 11 | @staticmethod 12 | def str_name(): 13 | return 'cylindrical_wave_origin' 14 | -------------------------------------------------------------------------------- /uff/element.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.element_geometry import ElementGeometry 4 | from uff.impulse_response import ImpulseResponse 5 | from uff.transform import Transform 6 | from uff.uff_io import Serializable 7 | 8 | 9 | @dataclass 10 | class Element(Serializable): 11 | """UFF class to define an ultrasonic element 12 | 13 | Notes: 14 | 15 | The class describe an ultrasonic element with a given geometry 16 | and impulse response, located at a given location in space. 17 | The element_geometry defines the geometry of the elements that 18 | have unique geometry (i.e. in a linear array element_geometry 19 | will have size 1) and unique impulse response. 20 | """ 21 | 22 | @staticmethod 23 | def str_name(): 24 | return 'element' 25 | 26 | transform: Transform 27 | element_geometry: ElementGeometry 28 | impulse_response: ImpulseResponse = None # farid 29 | -------------------------------------------------------------------------------- /uff/element_geometry.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.perimeter import Perimeter 4 | from uff.uff_io import Serializable 5 | 6 | 7 | @dataclass 8 | class ElementGeometry(Serializable): 9 | """Describes the geometry of an ultrasonic element. 10 | 11 | Notes: Here we assume that the acoustic center of the element is at origin O = (0; 0; 0) pointing towards Z = (0; 12 | 0; 1). The element shape is defined by a closed perimeter contained within the XY -plane, that is in turn 13 | composed of an ordered set of uff.position instances. 14 | """ 15 | perimeter: Perimeter 16 | 17 | @staticmethod 18 | def str_name(): 19 | return 'element_geometry' 20 | -------------------------------------------------------------------------------- /uff/event.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.receive_setup import ReceiveSetup 4 | from uff.transmit_setup import TransmitSetup 5 | from uff.uff_io import Serializable 6 | 7 | 8 | @dataclass 9 | class Event(Serializable): 10 | """ 11 | UFF class to describe an unique ultrasound event, composed by a single transmit and receive setup 12 | 13 | Attributes: 14 | transmit_setup (TransmitSetup): Description of the transmit event (probe/channels, waves, excitations, etc.). 15 | If more than one probe is used in reception, this is a list of setups. 16 | receive_setup (ReceiveSetup): Description of the transmit event (probe/channels, waves, excitations, etc.). 17 | If more than one probe is used in reception, this is a list of setups. 18 | 19 | """ 20 | transmit_setup: TransmitSetup 21 | receive_setup: ReceiveSetup 22 | 23 | @staticmethod 24 | def str_name(): 25 | return 'unique_events' 26 | -------------------------------------------------------------------------------- /uff/excitation.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import numpy as np 4 | 5 | from uff.uff_io import Serializable 6 | 7 | 8 | @dataclass 9 | class Excitation(Serializable): 10 | """ 11 | Describes the excitation applied to an element. 12 | 13 | Attributes: 14 | pulse_shape (str): String describing the pulse shape (e.g., sinusoidal, square wave, chirp), 15 | including necessary parameters 16 | waveform (float): Vector containing the sampled excitation waveform [normalized units] 17 | sampling_frequency (float): Scalar containing the sampling frequency of the excitation waveform [Hz] 18 | """ 19 | 20 | @staticmethod 21 | def str_name(): 22 | return 'unique_excitations' 23 | 24 | def serialize(self): 25 | assert isinstance( 26 | self.waveform, 27 | np.ndarray), 'Excitation.waveform should be an np.ndarray' 28 | return super().serialize() 29 | 30 | pulse_shape: str 31 | waveform: np.ndarray 32 | sampling_frequency: float 33 | -------------------------------------------------------------------------------- /uff/impulse_response.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from uff.uff_io import Serializable 5 | 6 | 7 | @dataclass 8 | class ImpulseResponse(Serializable): 9 | """Specifies a temporal impulse response""" 10 | initial_time: float 11 | sampling_frequency: int 12 | data: List[float] 13 | units: str 14 | 15 | @staticmethod 16 | def str_name(): 17 | return 'element_impulse_response' 18 | -------------------------------------------------------------------------------- /uff/linear_array.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.probe import Probe 4 | 5 | 6 | @dataclass 7 | class LinearArray(Probe): 8 | """ 9 | Describes a linear array, made of identical elements, uniformly distributed on a line. 10 | 11 | Attributes: 12 | number_elements (int): Number of elements in the array 13 | pitch (float): Distance between the acoustic ceneter of adyacent elements [m] 14 | element_width (float): (Optional) Element size in the x-axis [m] 15 | element_height (float): (Optional) Element size in the y-axis [m] 16 | """ 17 | 18 | def str_name(self): 19 | return 'probe.linear_array' 20 | 21 | number_elements: int 22 | pitch: float 23 | element_width: float 24 | element_height: float 25 | -------------------------------------------------------------------------------- /uff/matrix_array.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.probe import Probe 4 | 5 | 6 | @dataclass 7 | class LinearArray(Probe): 8 | """ 9 | Describes a matrix array, made of identical elements, uniformly distributed on a 2D grid. 10 | 11 | Attributes: 12 | number_elements_x (int):Number of elements in the x-axis 13 | pitch_x (float): Distance between the acoustic center of adjacent elements along the x-axis [m] 14 | number_elements_y (int):Number of elements in the y-axis 15 | pitch_y (float): Distance between the acoustic center of adjacent elements along the y-axis [m] 16 | element_width (float): (Optional) Element size in the x-axis [m] 17 | element_height (float): (Optional) Element size in the y-axis [m] 18 | """ 19 | 20 | number_elements_x: int 21 | number_elements_y: int 22 | pitch_x: float 23 | pitch_y: float 24 | element_width: float 25 | element_height: float 26 | -------------------------------------------------------------------------------- /uff/origin.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.position import Position 4 | from uff.rotation import Rotation 5 | from uff.uff_io import Serializable 6 | 7 | 8 | @dataclass 9 | # TODO: saved as origin but defined as wave origin in spec 10 | # TODO: when loading origin, how to know what type of origin is being loaded? 11 | class Origin(Serializable): 12 | @staticmethod 13 | def str_name(): 14 | return 'origin' 15 | 16 | rotation: Rotation = Rotation() 17 | position: Position = Position() 18 | -------------------------------------------------------------------------------- /uff/perimeter.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from uff.position import Position 5 | from uff.uff_io import Serializable 6 | 7 | 8 | @dataclass 9 | class Perimeter(Serializable): 10 | """Describes the geometry of an ultrasonic element.""" 11 | position: List[Position] 12 | 13 | @staticmethod 14 | def str_name(): 15 | return 'perimeter' 16 | -------------------------------------------------------------------------------- /uff/plane_wave_origin.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.origin import Origin 4 | from uff.rotation import Rotation 5 | 6 | 7 | @dataclass 8 | class PlaneWaveOrigin(Origin): 9 | rotation: Rotation = Rotation() 10 | -------------------------------------------------------------------------------- /uff/position.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from uff.uff_io import Serializable 4 | 5 | 6 | @dataclass 7 | class Position(Serializable): 8 | """Contains a location in space in Cartesian coordinates and SI units.""" 9 | x: float = 0.0 10 | y: float = 0.0 11 | z: float = 0.0 12 | _index: int = field(repr=False, init=False, default=0) 13 | 14 | def __add__(self, p2): 15 | self.x += p2.x 16 | self.y += p2.y 17 | self.z += p2.z 18 | return self 19 | 20 | @staticmethod 21 | def str_name(): 22 | return 'position' 23 | 24 | def __len__(self): 25 | return 3 26 | 27 | def __add__(self, p2): 28 | if type(p2) == Position: 29 | return Position(x=p2.x + self.x, y=p2.y + self.y, z=p2.z + self.z) 30 | else: 31 | return Position(x=p2 + self.x, y=p2 + self.y, z=p2 + self.z) 32 | 33 | def __sub__(self, p2): 34 | if type(p2) is Position: 35 | return Position(x=p2.x - self.x, y=p2.y - self.y, z=p2.z - self.z) 36 | else: 37 | return Position(x=self.x - p2, y=self.y - p2, z=self.z - p2) 38 | 39 | def __truediv__(self, p2): 40 | if type(p2) in [Position, Position]: 41 | return Position(x=self.x / p2.x, y=self.y / p2.y, z=self.z / p2.z) 42 | else: 43 | return Position(x=self.x / p2, y=self.y / p2, z=self.z / p2) 44 | 45 | def __mul__(self, p2): 46 | if type(p2) in [Position, Position]: 47 | return Position(x=p2.x * self.x, y=p2.y * self.y, z=p2.z * self.z) 48 | else: 49 | return Position(x=p2 * self.x, y=p2 * self.y, z=p2 * self.z) 50 | 51 | def __floordiv__(self, p2): 52 | if type(p2) in [Position, Position]: 53 | return Position(x=self.x // p2.x, y=self.y // p2.y, z=self.z // p2.z) 54 | else: 55 | return Position(x=self.x // p2, y=self.y // p2, z=self.z // p2) 56 | 57 | def __pow__(self, other): 58 | return Position(x=self.x ** other, y=self.y ** other, z=self.z ** other) 59 | 60 | def __iter__(self): 61 | return PositionIterator(self) 62 | 63 | def __getitem__(self, item): 64 | if item == 0: 65 | return self.x 66 | elif item == 1: 67 | return self.y 68 | elif item == 2: 69 | return self.z 70 | else: 71 | raise IndexError(f"Index {item} out of bounds for type {__name__}") 72 | 73 | 74 | class PositionIterator: 75 | 76 | def __init__(self, position): 77 | self._position = position 78 | self._index = 0 79 | 80 | def __iter__(self): 81 | return self 82 | 83 | def __next__(self): 84 | if self._index < len(self._position): 85 | if self._index == 0: 86 | result = self._position.x 87 | elif self._index == 1: 88 | result = self._position.y 89 | elif self._index == 2: 90 | result = self._position.z 91 | self._index += 1 92 | return result 93 | else: 94 | raise StopIteration 95 | -------------------------------------------------------------------------------- /uff/probe.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from uff.element import Element 5 | from uff.element_geometry import ElementGeometry 6 | from uff.impulse_response import ImpulseResponse 7 | from uff.transform import Transform 8 | from uff.uff_io import Serializable 9 | 10 | 11 | @dataclass 12 | class Probe(Serializable): 13 | """ 14 | Describes an generic ultrsound probe formed by a collection of elements. 15 | 16 | Note: 17 | 18 | Where focal_length specifies the lens focusing distance. Note that the 19 | elements in element_geometry and impulse_response are referred by the 20 | fields inside each member in element, avoiding unnecessary replication 21 | of information. 22 | 23 | More compact, although less general, descriptions are available for: 24 | 25 | uff.probe.linear_array, 26 | uff.probe.curvilinear_array, and 27 | uff.probe.matrix_array. 28 | """ 29 | 30 | @staticmethod 31 | def str_name(): 32 | return 'probes' 33 | 34 | def serialize(self): 35 | assert self.element_geometry is None or isinstance(self.element_geometry, list), \ 36 | 'Probe.element_geometry should be a list of element geometries!' 37 | return super().serialize() 38 | 39 | # @classmethod 40 | # def deserialize(cls, data: dict): 41 | # pass 42 | 43 | # >> TODO: These parameters are not defined in the standard 44 | number_elements: int 45 | pitch: float 46 | element_height: float 47 | element_width: float 48 | ## << 49 | transform: Transform = None 50 | # TODO for conformity call `elements` 51 | element: List[Element] = None 52 | 53 | element_impulse_response: List[ImpulseResponse] = None 54 | focal_length: float = None 55 | element_geometry: List[ElementGeometry] = None 56 | # # >> TODO: These parameters are not defined in the standard 57 | number_elements: int = 0 58 | pitch: float = 0 59 | element_height: float = 0 60 | element_width: float = 0 61 | ## << 62 | ## TODO: add element list factory to generate element list from these parameters, 63 | # and getters to generate properties from these properties. 64 | # OPEN QUESTION: what to do when file contains extra properties that do not conform to standard? 65 | # TODO idea for getters 66 | # @property 67 | # def number_elements(self): 68 | # return len(self.element) 69 | # @property 70 | # def pitch(self): 71 | # # check if all element spacings are the same 72 | # # return pitch 73 | # # else: 74 | # # catch and warn of non-standard spacing 75 | # return len(self.element) 76 | # 77 | # @property 78 | # def element_width(self): 79 | # # check for conformal element width 80 | # # return element_width 81 | # # else: 82 | # # catch and warn of non-conformal element width 83 | # pass 84 | # 85 | # @property 86 | # def element_height(self): 87 | # # check for conformal element height 88 | # # return element_height 89 | # # else: 90 | # # catch and warn of non-conformal element height 91 | # pass 92 | -------------------------------------------------------------------------------- /uff/receive_setup.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from uff.uff_io import Serializable 5 | 6 | 7 | @dataclass 8 | class ReceiveSetup(Serializable): 9 | """ 10 | Describes the setup used to receive and sample data. If more than one probe is used in reception, this is a list 11 | of setups. 12 | 13 | Notes: The channel_mapping specifies the connection of system channels to transducer elements. The map is a 14 | M-by-N matrix (HDF5 dimension ordering), where N is the number of system channels and M is the maximum number of 15 | elements connected to a single channel. In most common cases, M=1. An unconnected state is marked by the value 0. 16 | 17 | Attributes: 18 | probe (int): Index of the uff.probe used in reception within the list of probes in the 19 | uff.channel_data structure 20 | time_offset (float): Time delay between the event start and the acquisition of the first sample [s] 21 | channel_mapping (list(list(int)): Map of receive channels to transducer elements 22 | sampling_frequency (float): Sampling frequency of the recorded channel data [Hz] 23 | tgc_profile (list(float)): (Optional) Analog TGC profile sampled at tgc_sampling_frequency [dB] 24 | tgc_sampling_frequency (float): (Optional) Sampling frequency of the TGC profile [Hz] 25 | modulation_frequency (float): (Optional) Modulation frequency used in case of IQ-data [Hz] 26 | 27 | """ 28 | 29 | @staticmethod 30 | def str_name(): 31 | return 'receive_setup' 32 | 33 | probe: int 34 | time_offset: float 35 | channel_mapping: List[List[int]] 36 | sampling_frequency: float 37 | tgc_profile: List[float] = None 38 | tgc_sampling_frequency: float = None 39 | modulation_frequency: float = None 40 | 41 | def __eq__(self, other): 42 | return super().__eq__(other) 43 | -------------------------------------------------------------------------------- /uff/rotation.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from scipy.spatial.transform import Rotation as R 3 | import numpy as np 4 | 5 | from uff.uff_io import Serializable 6 | from uff.position import Position 7 | 8 | 9 | @dataclass 10 | class Rotation(Serializable): 11 | """Contains a rotation in space in spherical coordinates and SI units. 12 | The rotation is specified using Euler angles that are applied in the order ZYX.""" 13 | x: float = 0.0 14 | y: float = 0.0 15 | z: float = 0.0 16 | 17 | @staticmethod 18 | def str_name(): 19 | return 'rotation' 20 | 21 | def __call__(self, pos): 22 | return Position(*self.rotation_object.apply(np.array(pos))) 23 | 24 | @property 25 | def matrix(self): 26 | return R.from_euler('xyz', [self.x, self.y, self.z], degrees=False).matrix() 27 | 28 | @property 29 | def rotation_object(self): 30 | return R.from_euler('xyz', [self.x, self.y, self.z], degrees=False) 31 | -------------------------------------------------------------------------------- /uff/spherical_wave_origin.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.origin import Origin 4 | from uff.position import Position 5 | 6 | 7 | @dataclass 8 | class SphericalWaveOrigin(Origin): 9 | position: Position = Position() 10 | -------------------------------------------------------------------------------- /uff/time_zero_reference_point.py: -------------------------------------------------------------------------------- 1 | from uff.position import Position 2 | 3 | 4 | class TimeZeroReferencePoint(Position): 5 | """Contains a location in space in Cartesian coordinates and SI units for t0.""" 6 | 7 | @staticmethod 8 | def str_name(): 9 | return 'time_zero_reference_point' 10 | -------------------------------------------------------------------------------- /uff/timed_event.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.uff_io import Serializable 4 | from uff.utils import PRIMITIVE_INTS 5 | 6 | 7 | @dataclass 8 | class TimedEvent(Serializable): 9 | """ 10 | UFF class to describe a TR/RX event transmitted at a given moment in time. 11 | 12 | Attributes: 13 | event (int): Index of the uff.event within the list of unique_events in the uff.channel_data structure 14 | time_offset (float): Time offset relative to start of the sequence repetition (frame) [s] 15 | 16 | """ 17 | event: int 18 | time_offset: float 19 | 20 | def serialize(self): 21 | assert isinstance( 22 | self.event, PRIMITIVE_INTS 23 | ), 'TimedEvent.event should be index of the uff.event.' 24 | return super().serialize() 25 | 26 | @staticmethod 27 | def str_name(): 28 | return 'sequence' 29 | -------------------------------------------------------------------------------- /uff/transform.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.rotation import Rotation 4 | from uff.position import Position 5 | from uff.translation import Translation 6 | from uff.uff_io import Serializable 7 | 8 | 9 | @dataclass 10 | class Transform(Serializable): 11 | """Specifies a 3D affine transformation of rotation plus translation, 12 | IN THAT ORDER, where the translation is done on the unrotated 13 | coordinate system. The direction is given by local coordinate 14 | system of the object that owns this object. 15 | 16 | Attributes: 17 | translation (Translation): change in position in meters 18 | rotation (Rotation): change in rotation in radians 19 | """ 20 | translation: Translation 21 | rotation: Rotation 22 | 23 | @staticmethod 24 | def str_name(): 25 | return 'transform' 26 | 27 | def __call__(self, point): 28 | if type(point) is Position: 29 | return self.translation(self.rotation(point)) 30 | else: 31 | raise TypeError(f"Type {type(point)} not recognized.") 32 | pass 33 | -------------------------------------------------------------------------------- /uff/translation.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.uff_io import Serializable 4 | from uff.position import Position 5 | 6 | 7 | @dataclass 8 | class Translation(Serializable): 9 | """Define a translation operation in a 3D Cartesian system""" 10 | x: float 11 | y: float 12 | z: float 13 | 14 | @staticmethod 15 | def str_name(): 16 | return 'translation' 17 | 18 | def __call__(self, pos): 19 | return Position(x=pos.x + self.x, y=pos.y + self.y, z=pos.z + self.z) 20 | -------------------------------------------------------------------------------- /uff/transmit_event.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class TransmitEvent: 6 | """ 7 | UFF class to describe a TR/RX event transmitted at a given moment in time. 8 | 9 | Attributes: 10 | event (int): Index of the uff.event within the list of unique_events in the uff.channel_data 11 | structure 12 | time_offset (float): Time offset relative to start of the sequence repetition (frame) [s] 13 | """ 14 | event: int 15 | time_offset: float 16 | -------------------------------------------------------------------------------- /uff/transmit_setup.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from uff.transmit_wave import TransmitWave 5 | from uff.uff_io import Serializable 6 | 7 | 8 | @dataclass 9 | class TransmitSetup(Serializable): 10 | """ 11 | UFF class to describe the transmit event (probe/channels, waves, excitations, etc.). 12 | 13 | Attributes: 14 | probe (int): Index of the uff.probe used for transmit within the list of probes in 15 | the uff.channel_data structure 16 | transmit_waves (list(TransmitWave)): List of transmit waves used in this event with their respective time 17 | offset and weight 18 | channel_mapping (list(list(int))): Map of transmit channels to transducer elements 19 | sampled_delays (float): (Optional) Transmit delay as set in the system for active channels [s]. 20 | sampled_excitations (float): (Optional) Matrix of sampled excitation waveforms [normalized units]. 21 | sampling_frequency (float): (Optional) Sampling frequency of the excitation waveforms [Hz] 22 | transmit_voltage (float): (Optional) Peak amplitude of the pulse generator [V] 23 | """ 24 | 25 | @staticmethod 26 | def str_name(): 27 | return 'transmit_setup' 28 | 29 | probe: int 30 | transmit_waves: List[TransmitWave] 31 | channel_mapping: List[List[int]] 32 | sampled_delays: float = None 33 | sampled_excitations: float = None 34 | sampling_frequency: float = None 35 | transmit_voltage: float = None 36 | -------------------------------------------------------------------------------- /uff/transmit_wave.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.time_zero_reference_point import TimeZeroReferencePoint 4 | from uff.uff_io import Serializable 5 | 6 | 7 | @dataclass 8 | class TransmitWave(Serializable): 9 | """ 10 | UFF class to describe a transmitted wave as used in an event. 11 | 12 | Attributes: 13 | TODO: clarify weather type int or type wave is correct! 14 | wave now (Wave) was (int): Index of the uff.wave within the list of unique_waves 15 | in the uff.channel_data structure 16 | time_zero_reference_point (Position): Point in space that the waveform passes through at time zero. 17 | time_offset (float): (Optional) Time delay between the start of the event and the 18 | moment this wave reaches the time_zero_reference_point in the 19 | corresponding transmit setup [s]. [Default = 0s] 20 | weight (float): (Optional) Weight applied to the wave within the event 21 | [unitless between -1 and +1]. This may be used to describe 22 | pulse inversion sequences. [Default = 1] 23 | """ 24 | wave: int 25 | # TODO: should be of type position but current dynamic instantiation does not allow for that. 26 | time_zero_reference_point: TimeZeroReferencePoint # Position 27 | time_offset: float = 0.0 28 | weight: float = 1.0 29 | 30 | @staticmethod 31 | def str_name(): 32 | return 'transmit_waves' 33 | -------------------------------------------------------------------------------- /uff/uff.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import numpy as np 4 | 5 | import uff 6 | from uff import ChannelData 7 | from uff.uff_io import Serializable 8 | from uff.utils import is_keys_str_decimals, snake_to_camel_case, save_dict_to_hdf5, load_uff_dict 9 | 10 | 11 | class UFF(Serializable): 12 | def __init__(self): 13 | self.channel_data: ChannelData = None 14 | self.event = None 15 | self.timed_event = None 16 | self.transmit_setup = None 17 | self.receive_setup = None 18 | self.transmit_wave = None 19 | self.excitation = None 20 | self.wave = None 21 | self.aperture = None 22 | self.probe = None 23 | self.element = None 24 | self.element_geometry = None 25 | self.impulse_response = None 26 | self.perimeter = None 27 | self.transform = None 28 | self.rotation = None 29 | self.translation = None 30 | self.version = None 31 | 32 | def __copy__(self): 33 | obj = type(self).__new__(self.__class__) 34 | obj.__dict__.update(self.__dict__) 35 | return obj 36 | 37 | def __deepcopy__(self, memodict={}): 38 | pass 39 | 40 | @staticmethod 41 | def str_name(): 42 | return "UFF" 43 | 44 | @classmethod 45 | def deserialize(cls: object, data: dict): 46 | obj = UFF() 47 | primitives = (np.ndarray, np.int64, np.float64, str, bytes, int, float) 48 | 49 | for k, v in data.items(): 50 | if isinstance(v, primitives): 51 | setattr(obj, k, v) 52 | continue 53 | assert isinstance(v, 54 | dict), f'{type(v)} did not pass type-assertion' 55 | 56 | if k != 'channel_data': 57 | warnings.warn( 58 | f'\nWarning: The UFF standard specifies how objects of class uff.channel_data are ' 59 | f'written.\n Although other properties can be saved to the file, they very likely ' 60 | f'cannot be read back.\n In order to avoid unnecessary crashes, property `uff.{k}` will ' 61 | f'not be deserialized.') 62 | continue 63 | else: 64 | property_cls = ChannelData 65 | 66 | if not is_keys_str_decimals(v): 67 | setattr(obj, k, property_cls.deserialize(v)) 68 | else: 69 | # TODO: assert keys are correct => ascending order starting from 000001 70 | list_of_objs = list(v.values()) 71 | list_of_objs = [ 72 | property_cls.deserialize(item) for item in list_of_objs 73 | ] 74 | setattr(obj, k, list_of_objs) 75 | 76 | return obj 77 | 78 | @classmethod 79 | def load(cls: object, file_name: str): 80 | uff_dict = load_uff_dict(file_name) 81 | return cls.deserialize(uff_dict) 82 | 83 | @staticmethod 84 | def check_version(uff_h5): 85 | if uff_h5['version']: 86 | # read uff version 87 | file_version = uff_h5['version/major'], uff_h5[ 88 | 'version/minor'], uff_h5['version/patch'] 89 | package_version = uff.__version_info__ 90 | if file_version == package_version: 91 | raise ValueError( 92 | f"The file version given ({'.'.join(str(i) for i in file_version)})does not have a matching " 93 | f"version. Version must be {uff.__version__} " 94 | ) 95 | else: 96 | raise Exception( 97 | "Not a valid uff file. UFF version field is missing") 98 | 99 | @property 100 | def summary(self): 101 | print(f'Summary:\n' 102 | f'\tSystem:\t{self.channel_data.system}\n' 103 | f'\tAuthors:\t{self.channel_data.authors}\n' 104 | f'\tCountry:\t{self.channel_data.country_code}\n' 105 | f'\tLocal Time:\t{self.channel_data.local_time}\n' 106 | f'\tDescription:\t{self.channel_data.description}\n') 107 | 108 | def _init_obj_from_param_list(self, class_name: str, params: dict): 109 | class_ = globals()[class_name] 110 | obj_params = list(class_.__annotations__) 111 | return class_(**self._instantiate_args(obj_params, params)) 112 | 113 | def _instantiate_list(self, real_list, class_name): 114 | # List of type title 115 | for idx, l in enumerate(real_list): 116 | assert isinstance(l, dict), f"Unrecognized list entry {l}" 117 | real_list[idx] = self._init_obj_from_param_list(class_name, l) 118 | return real_list 119 | 120 | def _str_indexed_list2regular_list(self, parameter, object_dictionary): 121 | # list of values in fake dictionary if key equals index - 1 because keys start with one 122 | real_list = [ 123 | value for ind, (key, value) in enumerate(object_dictionary.items()) 124 | if int(key) - 1 == ind 125 | ] 126 | if snake_to_camel_case(parameter) in globals().keys(): 127 | title = snake_to_camel_case(parameter) 128 | # TODO: Aperture doesn't define origin type. 129 | else: 130 | # TODO: when parameter == 'probes' => this is not conform to the standard 131 | 132 | param_title_map = { 133 | 'probes': 'Probe', 134 | 'sequence': 'TimedEvent', 135 | 'unique_events': 'Event', 136 | 'element_impulse_response': 'ImpulseResponse', 137 | 'unique_excitations': 'Excitation', 138 | 'unique_waves': 'Wave', 139 | 'transmit_waves': 'TransmitWave', 140 | } 141 | assert parameter in param_title_map, f"What am I? {parameter}" 142 | 143 | title = param_title_map[parameter] 144 | 145 | return self._instantiate_list(real_list, title) 146 | 147 | def save(self, output_uff_path: str, version=None): 148 | if not output_uff_path.endswith('.uff'): 149 | output_uff_path += '.uff' 150 | 151 | serialized = self.serialize() 152 | if version is not None: 153 | serialized['version'] = version 154 | if 'channel_data' in serialized: 155 | serialized['uff.channel_data'] = serialized.pop('channel_data') 156 | save_dict_to_hdf5(serialized, output_uff_path) 157 | -------------------------------------------------------------------------------- /uff/uff_io.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import typing 5 | from typing import List, Type 6 | 7 | import numpy as np 8 | 9 | from uff.utils import PRIMITIVES, is_keys_str_decimals 10 | 11 | 12 | class Serializable(metaclass=abc.ABCMeta): 13 | @staticmethod 14 | @abc.abstractmethod 15 | def str_name(): 16 | return 17 | 18 | def serialize(self): 19 | serialized = {} 20 | # TODO: fix import cycle 21 | from uff import WaveType 22 | 23 | for k, value in vars(self).items(): 24 | if value is None: 25 | continue 26 | 27 | if isinstance(value, PRIMITIVES): 28 | serialized[k] = value 29 | continue 30 | elif isinstance(value, list): 31 | keys = [f'{i:08d}' for i in range(1, len(value) + 1)] 32 | values = [] 33 | for val in value: 34 | if isinstance(val, PRIMITIVES): 35 | values.append(val) 36 | elif isinstance(val, Serializable): 37 | values.append(val.serialize()) 38 | elif isinstance(val, list): 39 | keys2 = [f'{i:08d}' for i in range(1, len(val) + 1)] 40 | values.append(dict(zip(keys2, val))) 41 | else: 42 | raise NotImplementedError 43 | 44 | serialized[k] = dict(zip(keys, values)) 45 | elif isinstance( value, Serializable): # cls in Serializable.__subclasses__(): 46 | serialized[k] = value.serialize() 47 | elif isinstance(value, WaveType): 48 | serialized[k] = value.value 49 | else: 50 | raise TypeError(f'Unknown type [{type(value)}] for serialization!') 51 | return serialized 52 | 53 | @classmethod 54 | def deserialize(cls: object, data: dict): 55 | fields = cls.__annotations__ 56 | 57 | for k, v in data.items(): 58 | assert k in fields, f'Class {cls} does not have property named {k}.' 59 | if isinstance(v, PRIMITIVES): 60 | continue 61 | assert isinstance(v, dict), f'{type(v)} did not pass type-assertion' 62 | 63 | # property_cls = Serializable.get_subcls_with_name(k) 64 | property_cls = fields[k] 65 | if isinstance(property_cls, typing._GenericAlias): # TODO explain this 66 | property_cls = property_cls.__args__[0] 67 | assert property_cls is not None, f'Class {k} is not Serializable!' 68 | 69 | if not is_keys_str_decimals(v): 70 | data[k] = property_cls.deserialize(v) 71 | else: 72 | # TODO: assert keys are correct => ascending order starting from 000001 73 | list_of_objs = list(v.values()) 74 | list_of_objs = [property_cls.deserialize(item) for item in list_of_objs] 75 | data[k] = list_of_objs 76 | 77 | return cls(**data) 78 | 79 | @staticmethod 80 | def all_subclasses(cls=None) -> List[Type[Serializable]]: 81 | if cls is None: 82 | cls = Serializable 83 | subclasses = set(cls.__subclasses__()).union([ 84 | s for c in cls.__subclasses__() 85 | for s in Serializable.all_subclasses(c) 86 | ]) 87 | return list(subclasses) 88 | 89 | @staticmethod 90 | def get_subcls_with_name(name) -> Type[Serializable]: 91 | all_subclasses = Serializable.all_subclasses() 92 | for subcls in all_subclasses: 93 | if subcls.str_name() == name: 94 | return subcls 95 | return None 96 | 97 | def assign_primitives(self, dictionary: dict): 98 | primitives = (np.ndarray, np.int64, np.float64, str, bytes) 99 | set_list = [] 100 | obj_attrs = list(self.__annotations__) 101 | for k, v in dictionary.items(): 102 | assert k in obj_attrs 103 | if isinstance(v, primitives): 104 | setattr(self, k, primitives) 105 | set_list.append(k) 106 | 107 | remaining_list = list(set(obj_attrs) - set(set_list)) 108 | return set_list, remaining_list 109 | 110 | def __eq__(self, other): 111 | if not isinstance(other, self.__class__): 112 | return False 113 | 114 | equal = True 115 | last_attr = None 116 | 117 | for k in self.__annotations__.keys(): 118 | if not equal: 119 | break 120 | last_attr = k 121 | 122 | my_attr = getattr(self, k) 123 | other_attr = getattr(other, k) 124 | if isinstance(my_attr, np.ndarray): 125 | if not isinstance(other_attr, np.ndarray): 126 | equal = False 127 | elif not np.allclose(my_attr, other_attr): 128 | equal = False 129 | else: 130 | try: 131 | if getattr(self, k) != getattr(other, k): 132 | equal = False 133 | except ValueError as err: 134 | print( 135 | 'Ooops! Something went wrong! Probably unsupported comparision. ' 136 | 'Try to override the __eq__ method & handle custom data structures properly. ' 137 | 'Error message is given below:') 138 | raise err 139 | 140 | if not equal: 141 | print('Non-matching attributes detected!') 142 | print(f'Class name: {self.__class__}') 143 | print(f'Attrb name: {last_attr}') 144 | 145 | return equal 146 | -------------------------------------------------------------------------------- /uff/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import multiprocessing as mp 3 | 4 | import h5py 5 | import numpy as np 6 | import requests 7 | from pathlib import Path 8 | 9 | PRIMITIVE_INTS = (int, np.int32, np.int64) 10 | PRIMITIVE_FLOATS = (float, np.float32, np.float64) 11 | PRIMITIVES = (np.ndarray, bytes, str) + PRIMITIVE_INTS + PRIMITIVE_FLOATS 12 | 13 | 14 | def download_file(rel_path, url): 15 | """ 16 | 17 | Args: 18 | rel_path (str): download path 19 | url (str): file_url 20 | 21 | """ 22 | 23 | r = requests.get(url) 24 | with open(Path(rel_path) / url.split('/')[-1], 'wb') as file: 25 | file.write(r.content) 26 | 27 | 28 | def download_test_data(rel_path, file_urls): 29 | """ 30 | 31 | Args: 32 | rel_path (str): download path 33 | file_urls (list(str)): test file urls 34 | 35 | """ 36 | 37 | with mp.Pool(mp.cpu_count()) as pool: 38 | pool.starmap(download_file, [(rel_path, url) for url in file_urls]) 39 | 40 | 41 | def strip_prefix_from_keys(old_dict: dict, prefix: str): 42 | new_dict = {} 43 | for key in old_dict.keys(): 44 | old_key = key 45 | if prefix in key: 46 | new_key = old_key.lstrip(prefix) 47 | else: 48 | new_key = old_key 49 | new_dict[new_key] = old_dict[old_key] 50 | return new_dict 51 | 52 | 53 | def snake_to_camel_case(snake_str: str) -> str: 54 | """ 55 | Convert `snake` pattern to `camel` pattern 56 | Args: 57 | snake_str: String in `snake` pattern 58 | 59 | Returns: 60 | String in `camel` pattern 61 | """ 62 | components = snake_str.split('_') 63 | # Capitalize the first letter of each component with the 'title' method 64 | components = [x.title() for x in components] 65 | return ''.join(components) 66 | 67 | 68 | def save_dict_to_hdf5(dic, filename): 69 | """ 70 | .... 71 | """ 72 | with h5py.File(filename, 'w') as h5file: 73 | _recursively_save_dict_contents_to_group(h5file, '/', dic) 74 | 75 | 76 | def _recursively_save_dict_contents_to_group(h5file, path, dic): 77 | """ 78 | argument => Tuple(data: dict, attrs: dict) 79 | .... 80 | """ 81 | 82 | for key, item in dic.items(): 83 | if isinstance(item, str): 84 | # Strings will be stored as list of lists where each element is a byte character 85 | # TODO: This should save as a string, but the comparison files have lists of chars from matlab. 86 | h5file.create_dataset(path + key, data=[list(c) for c in item], dtype='|S1') 87 | elif isinstance(item, PRIMITIVES): 88 | # Primitive types stored directly 89 | h5file[path + key] = item 90 | elif isinstance(item, dict): 91 | _recursively_save_dict_contents_to_group(h5file, path + key + '/', item) 92 | if is_keys_str_decimals(item): 93 | h5file[path + key].attrs['array_size'] = len(item.keys()) 94 | if path + key == '/uff.channel_data/probes/00000001': 95 | h5file[path + key].attrs['probe_type'] = 'uff.probe.linear_array' 96 | else: 97 | raise ValueError(f'Cannot save {type(item)} type') 98 | 99 | 100 | def load_dict_from_hdf5(filename): 101 | """ 102 | .... 103 | """ 104 | with h5py.File(filename, 'r') as h5file: 105 | return _recursively_load_dict_contents_from_group(h5file, '/') 106 | 107 | 108 | def _recursively_load_dict_contents_from_group(h5file, path): 109 | """ 110 | .... 111 | """ 112 | ans = {} 113 | for key, item in h5file[path].items(): 114 | if isinstance(item, h5py._hl.dataset.Dataset): 115 | ans[key] = _decode_from_hdf5(item[()]) 116 | 117 | elif isinstance(item, h5py._hl.group.Group): 118 | ans[key] = _recursively_load_dict_contents_from_group(h5file, path + key + '/') 119 | return ans 120 | 121 | 122 | def _decode_from_hdf5(item): 123 | """ 124 | Decode an item from HDF5 format to python type. 125 | 126 | This currently just converts __none__ to None and some arrays to lists 127 | 128 | .. versionadded:: 1.0.0 129 | 130 | Parameters 131 | ---------- 132 | item: object 133 | Item to be decoded 134 | 135 | Returns 136 | ------- 137 | output: object 138 | Converted input item 139 | """ 140 | is_none_str = isinstance(item, str) and item == "__none__" 141 | is_none_byte = isinstance(item, bytes) and item == b"__none__" 142 | is_byte_arr = isinstance(item, (bytes, bytearray)) 143 | is_ndarray = isinstance(item, np.ndarray) 144 | is_bool = isinstance(item, np.bool_) 145 | 146 | if is_none_str or is_none_byte: 147 | output = None 148 | elif is_byte_arr: 149 | output = item.decode() 150 | elif is_ndarray: 151 | if item.size == 0: 152 | output = item 153 | elif item.size == 1: 154 | output = item.item() 155 | elif str(item.dtype).startswith('|S') or isinstance(item[0], bytes): 156 | output = "".join(np.char.decode([i[0] for i in item])) 157 | else: 158 | output = item 159 | elif is_bool: 160 | output = bool(item) 161 | else: 162 | output = item 163 | return output 164 | 165 | 166 | def is_keys_str_decimals(dictionary: dict): 167 | """ 168 | Checks if the keys are string decimals 169 | Args: 170 | dictionary: Dictionary object to check 171 | 172 | Returns: 173 | True if keys are numerical strings 174 | """ 175 | keys = dictionary.keys() 176 | are_decimals = [isinstance(k, str) and k.isdecimal() for k in keys] 177 | return all(are_decimals) 178 | 179 | 180 | def is_keys_contain(dictionary: dict, substr: str = 'sequence'): 181 | keys = dictionary.keys() 182 | are_containing = [isinstance(k, str) and substr in k for k in keys] 183 | return all(are_containing) 184 | 185 | 186 | def is_version_compatible(version: dict, expected_version: tuple) -> bool: 187 | """ 188 | 189 | Args: 190 | version: Dictionary containing version info. 191 | Should have following fields -> 'major', 'minor', 'patch' 192 | expected_version: Tuple of 3 elements for ('major', 'minor', 'patch') 193 | 194 | Returns: 195 | Whether the version is the same as the expected version 196 | """ 197 | # return bool(version['major'] == float(UFF.__version_info__[0]) and 198 | # version['minor'] == float(uff.__version_info__[1]) and 199 | # version['patch'] == float(uff.__version_info__[2])) 200 | major, minor, patch = expected_version 201 | return bool(version['major'] == major 202 | and version['minor'] == minor 203 | and version['patch'] == patch) 204 | 205 | 206 | def load_uff_dict(path): 207 | test_dict = load_dict_from_hdf5(path) 208 | uff_dict = strip_prefix_from_keys(old_dict=test_dict, prefix="uff.") 209 | return uff_dict 210 | 211 | 212 | def h5dump(uff_path): 213 | output_fields = os.popen(f'h5dump -n 1 {uff_path}').read() 214 | output_fields = output_fields.split('\n') 215 | 216 | attributes = [l for l in output_fields if l.startswith(' attribute')] 217 | attributes = [a.replace('attribute', '').strip() for a in attributes] 218 | 219 | groups = [l for l in output_fields if l.startswith(' group')] 220 | groups = [g.replace('group', '').strip() for g in groups] 221 | 222 | datasets = [l for l in output_fields if l.startswith(' dataset')] 223 | datasets = [d.replace('dataset', '').strip() for d in datasets] 224 | 225 | return groups, datasets, attributes 226 | 227 | 228 | def verify_correctness(output_path, ref_path): 229 | print('Correcness check will start now ...') 230 | out_groups, out_datasets, out_attrs = h5dump(output_path) 231 | ref_groups, ref_datasets, ref_attrs = h5dump(ref_path) 232 | 233 | if len(set(ref_groups) - set(out_groups)): 234 | print('Some groups are not present in the output UFF file!') 235 | print(set(ref_groups) - set(out_groups)) 236 | raise AssertionError 237 | 238 | if len(set(ref_datasets) - set(out_datasets)): 239 | print('Some datasets are not present in the output UFF file!') 240 | print(set(ref_datasets) - set(out_datasets)) 241 | raise AssertionError 242 | 243 | if len(set(ref_attrs) - set(out_attrs)): 244 | print('Some attrs are not present in the output UFF file!') 245 | print(set(ref_attrs) - set(out_attrs)) 246 | raise AssertionError 247 | 248 | print('Passed structure correctness checks!') 249 | 250 | with h5py.File(output_path, 'r') as out_h5: 251 | with h5py.File(ref_path, 'r') as ref_h5: 252 | 253 | for ds in ref_datasets: 254 | out_val = out_h5[ds][()] 255 | ref_val = ref_h5[ds][()] 256 | if isinstance(out_val, np.ndarray) and isinstance(ref_val, np.ndarray): 257 | if out_val.dtype == '|S1' and ref_val.dtype == '|S1': 258 | assert np.all(out_val == ref_val), f'Dataset [{ds}] does not match!' 259 | else: 260 | assert np.allclose(out_val, ref_val), f'Dataset [{ds}] does not match!' 261 | else: 262 | assert out_val == ref_val, f'Dataset [{ds}] does not match!' 263 | print('Passed value-wise correctness checks!') 264 | -------------------------------------------------------------------------------- /uff/version.py: -------------------------------------------------------------------------------- 1 | __version_info__ = ('0', '3', '0') 2 | __version__ = ".".join(__version_info__) 3 | -------------------------------------------------------------------------------- /uff/wave.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from uff.aperture import Aperture 4 | from uff.origin import Origin 5 | from uff.uff_io import Serializable 6 | from uff.wave_type import WaveType 7 | 8 | 9 | @dataclass 10 | class Wave(Serializable): 11 | """ 12 | UFF class to describe the geometry of a transmitted wave or beam. 13 | 14 | Attributes: 15 | origin (WaveOrigin): Geometric origin of the wave. 16 | type (WaveType): enumerated type (int) 17 | (converging = 0, 18 | diverging = 1, 19 | plane = 2, 20 | cylindrical = 3, 21 | photoacoustic = 4, 22 | default = 0) 23 | aperture (Aperture): Description of the aperture used to produce the wave 24 | excitation (int): (Optional) index to the unique excitation in the parent group 25 | """ 26 | 27 | @staticmethod 28 | def str_name(): 29 | return 'unique_waves' 30 | 31 | @classmethod 32 | def deserialize(cls: object, data: dict): 33 | data['wave_type'] = data.pop('type') 34 | return super().deserialize(data) 35 | 36 | def serialize(self): 37 | data = super().serialize() 38 | data['type'] = data.pop('wave_type') 39 | return data 40 | 41 | origin: Origin 42 | wave_type: WaveType 43 | aperture: Aperture 44 | excitation: int = None 45 | -------------------------------------------------------------------------------- /uff/wave_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class WaveType(Enum): 5 | """ 6 | enumerated type (int) 7 | converging = 0 8 | diverging = 1 9 | plane = 2 10 | cylindrical = 3 11 | photoacoustic = 4 12 | default = 0 13 | """ 14 | 15 | CONVERGING = 0 16 | DIVERGING = 1 17 | PLANE = 2 18 | CYLINDRICAL = 3 19 | PHOTOACOUSTIC = 4 20 | DEFAULT = 0 21 | --------------------------------------------------------------------------------