zea 0.0.7__py3-none-any.whl → 0.0.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. zea/__init__.py +3 -3
  2. zea/agent/masks.py +2 -2
  3. zea/agent/selection.py +3 -3
  4. zea/backend/__init__.py +1 -1
  5. zea/backend/tensorflow/dataloader.py +1 -5
  6. zea/beamform/beamformer.py +4 -2
  7. zea/beamform/pfield.py +2 -2
  8. zea/beamform/pixelgrid.py +1 -1
  9. zea/data/__init__.py +0 -9
  10. zea/data/augmentations.py +222 -29
  11. zea/data/convert/__init__.py +1 -6
  12. zea/data/convert/__main__.py +164 -0
  13. zea/data/convert/camus.py +106 -40
  14. zea/data/convert/echonet.py +184 -83
  15. zea/data/convert/echonetlvh/README.md +2 -3
  16. zea/data/convert/echonetlvh/{convert_raw_to_usbmd.py → __init__.py} +174 -103
  17. zea/data/convert/echonetlvh/manual_rejections.txt +73 -0
  18. zea/data/convert/echonetlvh/precompute_crop.py +43 -64
  19. zea/data/convert/picmus.py +37 -40
  20. zea/data/convert/utils.py +86 -0
  21. zea/data/convert/verasonics.py +1247 -0
  22. zea/data/data_format.py +124 -6
  23. zea/data/dataloader.py +12 -7
  24. zea/data/datasets.py +109 -70
  25. zea/data/file.py +119 -82
  26. zea/data/file_operations.py +496 -0
  27. zea/data/preset_utils.py +2 -2
  28. zea/display.py +8 -9
  29. zea/doppler.py +5 -5
  30. zea/func/__init__.py +109 -0
  31. zea/{tensor_ops.py → func/tensor.py} +113 -69
  32. zea/func/ultrasound.py +500 -0
  33. zea/internal/_generate_keras_ops.py +5 -5
  34. zea/internal/checks.py +6 -12
  35. zea/internal/operators.py +4 -0
  36. zea/io_lib.py +108 -160
  37. zea/metrics.py +6 -5
  38. zea/models/__init__.py +1 -1
  39. zea/models/diffusion.py +63 -12
  40. zea/models/echonetlvh.py +1 -1
  41. zea/models/gmm.py +1 -1
  42. zea/models/lv_segmentation.py +2 -0
  43. zea/ops/__init__.py +188 -0
  44. zea/ops/base.py +442 -0
  45. zea/{keras_ops.py → ops/keras_ops.py} +2 -2
  46. zea/ops/pipeline.py +1472 -0
  47. zea/ops/tensor.py +356 -0
  48. zea/ops/ultrasound.py +890 -0
  49. zea/probes.py +2 -10
  50. zea/scan.py +35 -28
  51. zea/tools/fit_scan_cone.py +90 -160
  52. zea/tools/selection_tool.py +1 -1
  53. zea/tracking/__init__.py +16 -0
  54. zea/tracking/base.py +94 -0
  55. zea/tracking/lucas_kanade.py +474 -0
  56. zea/tracking/segmentation.py +110 -0
  57. zea/utils.py +11 -2
  58. {zea-0.0.7.dist-info → zea-0.0.9.dist-info}/METADATA +5 -1
  59. {zea-0.0.7.dist-info → zea-0.0.9.dist-info}/RECORD +62 -48
  60. zea/data/convert/matlab.py +0 -1237
  61. zea/ops.py +0 -3294
  62. {zea-0.0.7.dist-info → zea-0.0.9.dist-info}/WHEEL +0 -0
  63. {zea-0.0.7.dist-info → zea-0.0.9.dist-info}/entry_points.txt +0 -0
  64. {zea-0.0.7.dist-info → zea-0.0.9.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1247 @@
1
+ """Functionality to convert Verasonics MATLAB workspace to the zea format.
2
+
3
+ Example of saving the entire workspace to a .mat file (MATLAB):
4
+
5
+ .. code-block:: matlab
6
+
7
+ >> setup_script;
8
+ >> VSX;
9
+ >> save_raw('C:/path/to/raw_data.mat');
10
+
11
+ Then convert the saved `raw_data.mat` file to zea format using the following code (Python):
12
+
13
+ .. code-block:: python
14
+
15
+ from zea.data.convert.verasonics import VerasonicsFile
16
+
17
+ VerasonicsFile("C:/path/to/raw_data.mat").to_zea("C:/path/to/output.hdf5")
18
+
19
+ Or alternatively, use the script below to convert all .mat files in a directory:
20
+
21
+ .. code-block:: bash
22
+
23
+ python zea/data/convert/verasonics.py "C:/path/to/directory"
24
+
25
+ or without the directory argument, the script will prompt you to select a directory
26
+ using a file dialog.
27
+
28
+ Event structure
29
+ ---------------
30
+
31
+ By default the zea dataformat saves all the data to an hdf5 file with the following structure:
32
+
33
+ .. code-block:: text
34
+
35
+ regular_zea_dataset.hdf5
36
+ ├── data
37
+ └── scan
38
+ └── center_frequency: 1MHz
39
+
40
+ The data is stored in the ``data`` group and the scan parameters are stored in the ``scan``.
41
+ However, when we do an adaptive acquisition, some scanning parameters might change. These
42
+ blocks of data with consistent scanning parameters we call events. In the case we have multiple
43
+ events, we store the data in the following structure:
44
+
45
+ .. code-block:: text
46
+
47
+ zea_dataset.hdf5
48
+ ├── event_0
49
+ │ ├── data
50
+ │ └── scan
51
+ │ └── center_frequency: 1MHz
52
+ ├── event_1
53
+ │ ├── data
54
+ │ └── scan
55
+ │ └── center_frequency: 2MHz
56
+ ├── event_2
57
+ │ ├── data
58
+ │ └── scan
59
+ └── event_3
60
+ ├── data
61
+ └── scan
62
+
63
+ This structure is supported by the zea toolbox. The way we can save the data in this structure
64
+ from the Verasonics, is by changing the setup script to keep track of the TX struct at each event.
65
+
66
+ The way this is done is still in development, an example of such an acquisition script that is
67
+ compatible with saving event structures is found here:
68
+ `setup_agent.m <https://github.com/tue-bmd/needle-tracking/blob/ius2024-demo-nc/verasonics/setup_agent.m>`_
69
+
70
+ Adding additional elements
71
+ --------------------------
72
+
73
+ You can add additional elements to the dataset by defining a function that reads the
74
+ data from the file and returns a ``DatasetElement``. Then pass the function to the
75
+ ``to_zea`` method as a list.
76
+
77
+ .. code-block:: python
78
+
79
+ def read_max_high_voltage(file):
80
+ lens_correction = file["Trans"]["lensCorrection"][0, 0].item()
81
+ return lens_correction
82
+
83
+
84
+ def read_high_voltage_func(file):
85
+ return DatasetElement(
86
+ group_name="scan",
87
+ dataset_name="max_high_voltage",
88
+ data=read_max_high_voltage(file),
89
+ description="The maximum high voltage used by the Verasonics system.",
90
+ unit="V",
91
+ )
92
+
93
+
94
+ VerasonicsFile("C:/path/to/raw_data.mat").to_zea(
95
+ "C:/path/to/output.hdf5",
96
+ [read_high_voltage_func],
97
+ )
98
+ """ # noqa: E501
99
+
100
+ import os
101
+ import sys
102
+ import traceback
103
+ from pathlib import Path
104
+
105
+ import h5py
106
+ import numpy as np
107
+ from keras import ops
108
+
109
+ from zea import log
110
+ from zea.data.data_format import DatasetElement, generate_zea_dataset
111
+ from zea.func import log_compress, normalize
112
+ from zea.internal.device import init_device
113
+ from zea.utils import strtobool
114
+
115
+ _VERASONICS_TO_ZEA_PROBE_NAMES = {
116
+ "L11-4v": "verasonics_l11_4v",
117
+ "L11-5v": "verasonics_l11_5v",
118
+ }
119
+
120
+
121
+ class VerasonicsFile(h5py.File):
122
+ """HDF5 File class for Verasonics MATLAB workspace files.
123
+
124
+ This class extends the h5py.File class to handle Verasonics-specific
125
+ data structures and conventions.
126
+ """
127
+
128
+ def dereference_index(self, dataset, index, event=None, subindex=None):
129
+ """Get the element at the given index from the dataset, dereferencing it if
130
+ necessary.
131
+
132
+ MATLAB stores items in struct array differently depending on the size. If the size
133
+ is 1, the item is stored as a regular dataset. If the size is larger, the item is
134
+ stored as a dataset of references to the actual data.
135
+
136
+ This function dereferences the dataset if it is a reference. Otherwise, it returns
137
+ the dataset.
138
+
139
+ Args:
140
+ dataset (h5py.Dataset): The dataset to read the element from.
141
+ index (int): The index of the element to read.
142
+ event (int, optional): The event index. Usually we store each event in the
143
+ second dimension of the dataset. Defaults to None in this case we assume
144
+ that there is only a single event.
145
+ subindex (slice, optional): The subindex of the element to read after
146
+ referencing the actual data. Defaults to None. In this case, all the data
147
+ is returned.
148
+ """
149
+ if isinstance(dataset.fillvalue, h5py.h5r.Reference):
150
+ if event is not None:
151
+ reference = dataset[index, event]
152
+ else:
153
+ reference = dataset[index, 0]
154
+ if subindex is None:
155
+ return self[reference][:]
156
+ else:
157
+ return self[reference][subindex]
158
+ else:
159
+ return dataset
160
+
161
+ @staticmethod
162
+ def get_reference_size(dataset):
163
+ """Get the size of a reference dataset."""
164
+ if isinstance(dataset.fillvalue, h5py.h5r.Reference):
165
+ return len(dataset)
166
+ else:
167
+ return 1
168
+
169
+ @staticmethod
170
+ def decode_string(dataset):
171
+ """Decode a string dataset."""
172
+ return "".join([chr(c) for c in dataset.squeeze()])
173
+
174
+ @property
175
+ def probe_unit(self):
176
+ """The unit the probe dimensions are defined in."""
177
+ _ALLOWED_UNITS = {"wavelengths", "mm"}
178
+ unit = self.decode_string(self["Trans"]["units"][:])
179
+ assert unit in {"wavelengths", "mm"}, (
180
+ f"Unexpected unit '{unit}' in file, must be one of {_ALLOWED_UNITS}"
181
+ )
182
+ return unit
183
+
184
+ @property
185
+ def probe_geometry(self):
186
+ """The probe geometry of shape (n_el, 3)."""
187
+ # Read the probe geometry from the file
188
+ probe_geometry = self["Trans"]["ElementPos"][:3, :]
189
+
190
+ # Transpose the probe geometry to have the shape (n_el, 3)
191
+ probe_geometry = probe_geometry.T
192
+
193
+ # Convert the probe geometry to meters
194
+ if self.probe_unit == "mm":
195
+ probe_geometry = probe_geometry / 1000
196
+ else:
197
+ probe_geometry = probe_geometry * self.wavelength
198
+
199
+ return probe_geometry
200
+
201
+ @property
202
+ def wavelength(self):
203
+ """Wavelength of the probe from the file in meters."""
204
+
205
+ return self.sound_speed / self.center_frequency
206
+
207
+ def read_transmit_events(self, event=None, frames="all", allow_accumulate=False):
208
+ """Read the events from the file and finds the order in which transmits and receives
209
+ appear in the events.
210
+
211
+ Args:
212
+ event (int, optional): The event index. Defaults to None.
213
+ frames (str or list, optional): The frames to read. Defaults to "all".
214
+ allow_accumulate (bool, optional): Sometimes, some transmits are already accumulated
215
+ on the Verasonics system (e.g. harmonic imaging through pulse inversion).
216
+ In this case, the mode in the Receive structure is set to 1 (accumulate).
217
+ If this flag is set to False, an error is raised when such a mode is detected.
218
+
219
+ Returns:
220
+ tuple: (tx_order, rcv_order, time_to_next_acq)
221
+ tx_order (list): The order in which the transmits appear in the events.
222
+ rcv_order (list): The order in which the receives appear in the events.
223
+ time_to_next_acq (np.ndarray): The time to next acquisition of shape (n_acq, n_tx).
224
+ """
225
+
226
+ num_events = self["Event"]["info"].shape[0]
227
+
228
+ # In the Verasonics the transmits may not be in order in the TX structure and a
229
+ # transmit might be reused. Therefore, we need to keep track of the order in which
230
+ # the transmits appear in the Events.
231
+ tx_order = []
232
+ rcv_order = []
233
+ time_to_next_acq = []
234
+ modes = []
235
+
236
+ frame_indices = self.get_frame_indices(frames)
237
+
238
+ for i in range(num_events):
239
+ # Get the tx
240
+ event_tx = self.dereference_index(self["Event"]["tx"], i)
241
+ event_tx = int(event_tx.item())
242
+
243
+ # Get the rcv
244
+ event_rcv = self.dereference_index(self["Event"]["rcv"], i)
245
+ event_rcv = int(event_rcv.item())
246
+
247
+ if not bool(event_tx) == bool(event_rcv):
248
+ log.warning(
249
+ "Events should have both a transmit and a receive or neither. "
250
+ f"Event {i} has a transmit but no receive or vice versa."
251
+ )
252
+
253
+ if not event_tx:
254
+ continue
255
+
256
+ # Subtract one to make the indices 0-based
257
+ event_tx -= 1
258
+ event_rcv -= 1
259
+
260
+ # Read mode
261
+ mode = self.dereference_index(self["Receive"]["mode"], event_rcv)
262
+ mode = int(mode.item())
263
+
264
+ # Check in the Receive structure if this is still the first frame
265
+ framenum_ref = self["Receive"]["framenum"][event_rcv, 0]
266
+ framenum = self[framenum_ref][:].item()
267
+
268
+ # Only add the event to the list if it is the first frame since we assume
269
+ # that all frames have the same transmits and receives
270
+ if framenum == 1:
271
+ # Add the event to the list
272
+ tx_order.append(event_tx)
273
+ rcv_order.append(event_rcv)
274
+ modes.append(mode)
275
+
276
+ # Read the time_to_next_acq
277
+ seq_control_indices = self.dereference_index(self["Event"]["seqControl"], i)
278
+
279
+ for seq_control_index in seq_control_indices:
280
+ seq_control_index = int(seq_control_index.item() - 1)
281
+ seq_control = self.dereference_index(
282
+ self["SeqControl"]["command"], seq_control_index
283
+ )
284
+ # Decode the seq_control int array into a string
285
+ seq_control = self.decode_string(seq_control)
286
+ if seq_control == "timeToNextAcq":
287
+ value = self.dereference_index(
288
+ self["SeqControl"]["argument"], seq_control_index
289
+ ).item()
290
+ value = value * 1e-6
291
+ time_to_next_acq.append(value)
292
+
293
+ modes = np.array(modes)
294
+ tx_order = np.array(tx_order)
295
+ rcv_order = np.array(rcv_order)
296
+ time_to_next_acq = np.array(time_to_next_acq)
297
+ time_to_next_acq = np.reshape(time_to_next_acq, (-1, tx_order.size))
298
+
299
+ if np.any(modes == 1) and not allow_accumulate:
300
+ raise ValueError(
301
+ "Some receive events are in accumulate mode (mode=1). "
302
+ "This indicates that the data is already accumulated on the Verasonics system. "
303
+ "Set allow_accumulate=True to allow this."
304
+ )
305
+ elif np.any(modes == 1) and allow_accumulate:
306
+ # We only keep the transmits that are in mode 0 (normal acquisition)
307
+ log.info(
308
+ "Data contains both receives in accumulate mode and replace mode.\n"
309
+ "Discarding transmits in accumulate mode (mode=1). "
310
+ "Keeping transmits in replace mode (mode=0)."
311
+ )
312
+ tx_order = tx_order[modes == 0]
313
+ rcv_order = rcv_order[modes == 0]
314
+ time_to_next_acq = time_to_next_acq[:, modes == 0]
315
+
316
+ if event is not None:
317
+ time_to_next_acq = time_to_next_acq[event]
318
+ time_to_next_acq = np.expand_dims(time_to_next_acq, axis=0)
319
+
320
+ time_to_next_acq = time_to_next_acq[frame_indices]
321
+
322
+ return tx_order, rcv_order, time_to_next_acq
323
+
324
+ def read_t0_delays_apod(self, tx_order, event=None):
325
+ """
326
+ Read the t0 delays and apodization from the file.
327
+
328
+ Returns:
329
+ t0_delays (np.ndarray): The t0 delays of shape (n_tx, n_el).
330
+ apod (np.ndarray): The apodization of shape (n_el,).
331
+ """
332
+
333
+ t0_delays_list = []
334
+ tx_apodizations_list = []
335
+
336
+ for n in tx_order:
337
+ # Get column vector of t0_delays
338
+ if event is None:
339
+ t0_delays = self.dereference_index(self["TX"]["Delay"], n)
340
+ else:
341
+ t0_delays = self.dereference_index(self["TX_Agent"]["Delay"], n, event)
342
+ # Turn into 1d array
343
+ t0_delays = t0_delays[:, 0]
344
+
345
+ t0_delays_list.append(t0_delays)
346
+
347
+ # Get column vector of apodizations
348
+ if event is None:
349
+ tx_apodizations = self.dereference_index(self["TX"]["Apod"], n)
350
+ else:
351
+ tx_apodizations = self.dereference_index(self["TX_Agent"]["Apod"], n, event)
352
+ # Turn into 1d array
353
+ tx_apodizations = tx_apodizations[:, 0]
354
+ tx_apodizations_list.append(tx_apodizations)
355
+
356
+ t0_delays = np.stack(t0_delays_list, axis=0)
357
+ apodizations = np.stack(tx_apodizations_list, axis=0)
358
+
359
+ # Convert the t0_delays to meters
360
+ t0_delays = t0_delays * self.wavelength / self.sound_speed
361
+
362
+ return t0_delays, apodizations
363
+
364
+ @property
365
+ def sampling_frequency(self):
366
+ """The sampling frequency in Hz from the file."""
367
+ # Read the sampling frequency from the file
368
+ adc_rate = self.dereference_index(self["Receive"]["decimSampleRate"], 0)
369
+
370
+ if "quadDecim" in self["Receive"]:
371
+ quaddecim = self.dereference_index(self["Receive"]["quadDecim"], 0)
372
+ else:
373
+ # TODO: Verify if this is correct.
374
+ # On the Vantage NXT the quadDecim field is missing. It seems that it should be
375
+ # set to 1.0 (that decimSampleRate is the actual sampling frequency).
376
+ quaddecim = 1.0
377
+
378
+ sampling_frequency = adc_rate / quaddecim * 1e6
379
+ sampling_frequency = sampling_frequency.item()
380
+
381
+ if self.is_baseband_mode:
382
+ # Two sequential samples are interpreted as a single complex sample
383
+ # Therefore, we need to halve the sampling frequency
384
+ sampling_frequency = sampling_frequency / 2
385
+
386
+ return sampling_frequency
387
+
388
+ def read_waveforms(self, tx_order, event=None):
389
+ """
390
+ Read the waveforms from the file.
391
+
392
+ Returns:
393
+ waveforms (np.ndarray): The waveforms of shape (n_tx, n_samples).
394
+ """
395
+ waveforms_one_way_list = []
396
+ waveforms_two_way_list = []
397
+
398
+ # Read all the waveforms from the file
399
+ n_waveforms = self.get_reference_size(self["TW"]["Wvfm1Wy"])
400
+ for n in range(n_waveforms):
401
+ # Get the row vector of the 1-way waveform
402
+ waveform_one_way = self.dereference_index(self["TW"]["Wvfm1Wy"], n)[:]
403
+ # Turn into 1d array
404
+ waveform_one_way = waveform_one_way[0, :]
405
+
406
+ # Get the row vector of the 2-way waveform
407
+ waveform_two_way = self.dereference_index(self["TW"]["Wvfm2Wy"], n)[:]
408
+ # Turn into 1d array
409
+ waveform_two_way = waveform_two_way[0, :]
410
+
411
+ waveforms_one_way_list.append(waveform_one_way)
412
+ waveforms_two_way_list.append(waveform_two_way)
413
+
414
+ tx_waveform_indices = []
415
+
416
+ for n in tx_order:
417
+ # Read the waveform
418
+ if event is None:
419
+ waveform_index = self.dereference_index(self["TX"]["waveform"], n)[:]
420
+ else:
421
+ waveform_index = self.dereference_index(self["TX_Agent"]["waveform"], n, event)[:]
422
+ # Subtract one to make the indices 0-based
423
+ waveform_index -= 1
424
+ # Turn into integer
425
+ waveform_index = int(waveform_index.item())
426
+ tx_waveform_indices.append(waveform_index)
427
+
428
+ return tx_waveform_indices, waveforms_one_way_list, waveforms_two_way_list
429
+
430
+ def read_beamsteering_angles(self, tx_order, event=None):
431
+ """Beam steering angles in radians (theta, alpha) for each transmit.
432
+
433
+ Returns:
434
+ angles (np.ndarray): The beam steering angles of shape (n_tx, 2).
435
+ """
436
+ angles_list = []
437
+
438
+ for n in tx_order:
439
+ # Read the polar angle
440
+ if event is None:
441
+ angle = self.dereference_index(self["TX"]["Steer"], n)[:]
442
+ else:
443
+ angle = self.dereference_index(self["TX_Agent"]["Steer"], n, event)[:]
444
+
445
+ angles_list.append(angle)
446
+ angles = np.stack(angles_list, axis=0)
447
+ angles = np.squeeze(angles, axis=-1)
448
+
449
+ assert angles.shape == (len(tx_order), 2), (
450
+ f"Expected angles shape to be {(len(tx_order), 2)}, but got {angles.shape}"
451
+ )
452
+ return angles
453
+
454
+ def read_polar_angles(self, tx_order, event=None):
455
+ """Read the polar angles of shape (n_tx,) from the file."""
456
+ return self.read_beamsteering_angles(tx_order, event)[:, 0]
457
+
458
+ def read_azimuth_angles(self, tx_order, event=None):
459
+ """Read the azimuth angles of shape (n_tx,) from the file."""
460
+ return self.read_beamsteering_angles(tx_order, event)[:, 1]
461
+
462
+ @property
463
+ def end_samples(self):
464
+ """The index of the last sample for each receive event."""
465
+ length = self["Receive"]["endSample"].shape[0]
466
+ return np.concatenate(
467
+ [self.dereference_index(self["Receive"]["endSample"], i) for i in range(length)]
468
+ )
469
+
470
+ @property
471
+ def start_samples(self):
472
+ """The index of the first sample for each receive event."""
473
+ length = self["Receive"]["startSample"].shape[0]
474
+ return np.concatenate(
475
+ [self.dereference_index(self["Receive"]["startSample"], i) for i in range(length)]
476
+ )
477
+
478
+ @property
479
+ def n_ax(self):
480
+ """Number of axial samples."""
481
+ n_ax = (self.end_samples - self.start_samples + 1).astype(np.int32)
482
+ n_ax = np.unique(n_ax)
483
+ if n_ax.size != 1:
484
+ raise ValueError(
485
+ "The number of axial samples is not the same for all receive events."
486
+ "We do not support this case yet."
487
+ )
488
+ return n_ax.item()
489
+
490
+ @property
491
+ def probe_connector(self):
492
+ """Probe connector indices."""
493
+ probe_connector = self["Trans"]["ConnectorES"][:]
494
+ probe_connector = np.squeeze(probe_connector, axis=0)
495
+ probe_connector = probe_connector.astype(np.int32)
496
+ probe_connector = probe_connector - 1 # make 0-based
497
+ return probe_connector
498
+
499
+ @property
500
+ def is_new_save_raw_format(self):
501
+ return "save_raw_version" in self.keys()
502
+
503
+ def get_image_raw_data_order(self, raw_data: np.ndarray):
504
+ """The order of frames in the RcvBuffer buffer.
505
+
506
+ Because of the circular buffer used in Verasonics, the frames in the RcvBuffer
507
+ buffer are not necessarily in the correct order. This function computes the
508
+ correct order of frames.
509
+ """
510
+ n_frames = raw_data.shape[0]
511
+ try:
512
+ last_frame = int(
513
+ self.dereference_index(self["Resource"]["RcvBuffer"]["lastFrame"], 0)[()].item() - 1
514
+ )
515
+ except KeyError:
516
+ log.warning(
517
+ "Could not find 'lastFrame' in 'Resource/RcvBuffer'. "
518
+ "Assuming data is already in correct order."
519
+ )
520
+ return np.arange(n_frames)
521
+ first_frame = (last_frame + 1) % n_frames
522
+ indices = np.arange(first_frame, first_frame + n_frames) % n_frames
523
+ return indices
524
+
525
+ def read_raw_data(self, event=None, frames="all"):
526
+ """
527
+ Read the raw data from the file.
528
+
529
+ Returns:
530
+ raw_data (np.ndarray): The raw data of shape (n_rcv, n_samples).
531
+ """
532
+
533
+ # Read the raw data from the file
534
+ if event is None:
535
+ raw_data = self.dereference_index(self["RcvData"], 0)
536
+ else:
537
+ # for now we only index frames as events
538
+ raw_data = self.dereference_index(self["RcvData"], 0, subindex=event)
539
+ raw_data = np.expand_dims(raw_data, axis=0)
540
+
541
+ # Convert the raw data to a numpy array to allow out-of-order indexing later
542
+ raw_data = np.array(raw_data, dtype=np.int16)
543
+
544
+ # Reorder and select channels based on probe elements
545
+ if self.is_new_save_raw_format:
546
+ raw_data = raw_data[:, self.probe_connector, :]
547
+ else:
548
+ log.warning(
549
+ "Data was not saved using the updated `save_raw` function (version >= 1.0). "
550
+ "In that case, we assume that the channel order in the data matches the "
551
+ "probe element order. Please verify that this is correct!"
552
+ )
553
+
554
+ # Re-order frames such that sequence is correct
555
+ indices = self.get_image_raw_data_order(raw_data)
556
+ raw_data = raw_data[indices]
557
+
558
+ # Select only the requested frames
559
+ frame_indices = self.get_frame_indices(frames)
560
+ raw_data = raw_data[frame_indices]
561
+
562
+ # Trim the raw data to the final sample in the buffer
563
+ final_sample_in_buffer = int(self.end_samples.max())
564
+ raw_data = raw_data[:, :, :final_sample_in_buffer]
565
+
566
+ # Determine n_tx based on the final sample in buffer and n_ax
567
+ # For some sequences, transmits are already aggregated in the raw data
568
+ # (e.g. harmonic imaging through pulse inversion)
569
+ n_tx = final_sample_in_buffer // self.n_ax
570
+
571
+ # Reshape the raw data to (n_frames, n_el, n_tx, n_ax)
572
+ raw_data = raw_data.reshape((raw_data.shape[0], raw_data.shape[1], n_tx, self.n_ax))
573
+
574
+ # Transpose the raw data to (n_frames, n_tx, n_ax, n_el)
575
+ raw_data = np.transpose(raw_data, (0, 2, 3, 1))
576
+
577
+ # Add channel dimension
578
+ raw_data = raw_data[..., None]
579
+
580
+ # If the data is captured in BS100BW mode or BS50BW mode, the data is stored in
581
+ # as complex IQ data.
582
+ if self.is_baseband_mode:
583
+ raw_data = np.concatenate(
584
+ (
585
+ raw_data[:, :, 0::2, :, :],
586
+ -raw_data[:, :, 1::2, :, :],
587
+ ),
588
+ axis=-1,
589
+ )
590
+
591
+ return raw_data
592
+
593
+ @property
594
+ def center_frequency(self):
595
+ """Center frequency of the probe from the file in Hz."""
596
+
597
+ return self["Trans"]["frequency"][0, 0] * 1e6
598
+
599
+ @property
600
+ def sound_speed(self):
601
+ """Speed of sound in the medium in m/s."""
602
+
603
+ return self["Resource"]["Parameters"]["speedOfSound"][0, 0].item()
604
+
605
+ def read_initial_times(self, rcv_order):
606
+ """Reads the initial times from the file.
607
+
608
+ Args:
609
+ rcv_order (list): The order in which the receives appear in the events.
610
+ wavelength (float): The wavelength of the probe.
611
+
612
+ Returns:
613
+ initial_times (np.ndarray): The initial times of shape (n_rcv,).
614
+ """
615
+ initial_times = []
616
+ for n in rcv_order:
617
+ start_depth = self.dereference_index(self["Receive"]["startDepth"], n).item()
618
+
619
+ initial_times.append(2 * start_depth * self.wavelength / self.sound_speed)
620
+
621
+ return np.array(initial_times).astype(np.float32)
622
+
623
+ @property
624
+ def probe_name(self):
625
+ """The name of the probe from the file."""
626
+ probe_name = self["Trans"]["name"][:]
627
+ probe_name = self.decode_string(probe_name)
628
+ # Translates between verasonics probe names and zea probe names
629
+ if probe_name in _VERASONICS_TO_ZEA_PROBE_NAMES:
630
+ probe_name = _VERASONICS_TO_ZEA_PROBE_NAMES[probe_name]
631
+ else:
632
+ log.warning(
633
+ f"Probe name '{probe_name}' is not in the list of known probes. "
634
+ "Please add it to the _VERASONICS_TO_ZEA_PROBE_NAMES dictionary. "
635
+ "Falling back to generic probe."
636
+ )
637
+ probe_name = "generic"
638
+
639
+ return probe_name
640
+
641
+ def read_focus_distances(self, tx_order, event=None):
642
+ """Reads the focus distances from the file.
643
+
644
+ Args:
645
+ tx_order (list): The order in which the transmits appear in the events.
646
+
647
+ Returns:
648
+ focus_distances (list): The focus distances of shape (n_tx,) in meters.
649
+ """
650
+ focus_distances = []
651
+ for n in tx_order:
652
+ if event is None:
653
+ focus_distance = self.dereference_index(self["TX"]["focus"], n)[0, 0]
654
+ else:
655
+ focus_distance = self.dereference_index(self["TX_Agent"]["focus"], n, event)[0, 0]
656
+ focus_distances.append(focus_distance)
657
+
658
+ # Convert focus distances from wavelengths to meters
659
+ focus_distances = np.array(focus_distances) * self.wavelength
660
+
661
+ return focus_distances
662
+
663
+ @property
664
+ def _probe_geometry_is_ordered_ula(self):
665
+ """Checks if the probe geometry is ordered as a uniform linear array (ULA)."""
666
+ diff_vec = self.probe_geometry[1:] - self.probe_geometry[:-1]
667
+ return np.isclose(diff_vec, diff_vec[0]).all()
668
+
669
+ def planewave_focal_distance_to_inf(self, focus_distances, t0_delays, tx_apodizations):
670
+ """Detects plane wave transmits and sets the focus distance to infinity.
671
+
672
+ Args:
673
+ focus_distances (np.ndarray): The focus distances of shape (n_tx,).
674
+ t0_delays (np.ndarray): The t0 delays of shape (n_tx, n_el).
675
+ tx_apodizations (np.ndarray): The apodization of shape (n_tx, n_el).
676
+
677
+ Returns:
678
+ focus_distances (np.ndarray): The focus distances of shape (n_tx,).
679
+
680
+ Note:
681
+ This function assumes that the probe_geometry is a 1d uniform linear array.
682
+ If not it will warn and return.
683
+ """
684
+ if not self._probe_geometry_is_ordered_ula:
685
+ log.warning(
686
+ "The probe geometry is not ordered as a uniform linear array. "
687
+ "Focal distances are not set to infinity for plane waves."
688
+ )
689
+ return focus_distances
690
+
691
+ for tx in range(focus_distances.size):
692
+ mask_active = np.abs(tx_apodizations[tx]) > 0
693
+ if np.sum(mask_active) < 2:
694
+ continue
695
+ t0_delays_active = t0_delays[tx][mask_active]
696
+
697
+ # If the t0_delays all have the same offset, we assume it is a plane wave
698
+ if np.std(np.diff(t0_delays_active)) < 1e-16:
699
+ focus_distances[tx] = np.inf
700
+
701
+ return focus_distances
702
+
703
+ @property
704
+ def bandwidth_percent(self):
705
+ """Receive bandwidth as a percentage of center frequency."""
706
+ bandwidth_percent = self.dereference_index(self["Receive"]["sampleMode"], 0)
707
+ bandwidth_percent = self.decode_string(bandwidth_percent)
708
+ bandwidth_percent = int(bandwidth_percent[2:-2])
709
+ return bandwidth_percent
710
+
711
+ @property
712
+ def is_baseband_mode(self):
713
+ """If the data is captured in 'BS100BW' mode or 'BS50BW' mode.
714
+
715
+ - The data is stored as complex IQ data.
716
+ - The sampling frequency is halved.
717
+ - Two sequential samples are interpreted as a single complex sample.
718
+ Therefore, we need to halve the sampling frequency.
719
+ """
720
+ return self.bandwidth_percent in (50, 100)
721
+
722
+ @property
723
+ def lens_correction(self):
724
+ """The lens correction: 1 way delay in wavelengths thru lens"""
725
+ lens_correction = self["Trans"]["lensCorrection"][0, 0].item()
726
+ return lens_correction
727
+
728
+ @property
729
+ def tgc_gain_curve(self):
730
+ """The TGC gain curve from the file interpolated to the number of axial samples (n_ax,)."""
731
+
732
+ gain_curve = self["TGC"]["Waveform"][:][:, 0]
733
+
734
+ # Normalize the gain_curve to [0, 40]dB
735
+ gain_curve = gain_curve / 1023 * 40
736
+
737
+ # The gain curve is sampled at 800ns (See Verasonics documentation for details.
738
+ # Specifically the tutorial sequence programming)
739
+ gain_curve_sampling_period = 800e-9
740
+
741
+ # Define the time axis for the gain curve
742
+ t_gain_curve = np.arange(gain_curve.size) * gain_curve_sampling_period
743
+
744
+ # Define the time axis for the axial samples
745
+ t_samples = np.arange(self.n_ax) / self.sampling_frequency
746
+
747
+ # Interpolate the gain_curve to the number of axial samples
748
+ gain_curve = np.interp(t_samples, t_gain_curve, gain_curve)
749
+
750
+ # The gain_curve gains are in dB, so we need to convert them to linear scale
751
+ gain_curve = 10 ** (gain_curve / 20)
752
+
753
+ return gain_curve
754
+
755
+ def get_image_data_p_frame_order(self, image_data: np.ndarray):
756
+ """The order of frames in the ImgDataP buffer.
757
+
758
+ Because of the circular buffer used in Verasonics, the frames in the ImgDataP
759
+ buffer are not necessarily in the correct order. This function computes the
760
+ correct order of frames.
761
+
762
+ Uses the first buffer, so does not support multiple buffers.
763
+ """
764
+ FIRST_BUFFER = 0
765
+ n_frames = image_data.shape[0]
766
+ try:
767
+ first_frame = self.dereference_index(
768
+ self["Resource"]["ImageBuffer"]["firstFrame"], FIRST_BUFFER
769
+ )[()].item()
770
+ last_frame = self.dereference_index(
771
+ self["Resource"]["ImageBuffer"]["lastFrame"], FIRST_BUFFER
772
+ )[()].item()
773
+ first_frame -= 1 # make 0-based
774
+ last_frame -= 1 # make 0-based
775
+ indices = np.arange(first_frame, first_frame + n_frames) % n_frames
776
+ assert indices[-1] == last_frame, (
777
+ "The last frame index does not match the expected last frame index."
778
+ )
779
+ return indices
780
+ except KeyError:
781
+ log.warning(
782
+ "Could not find 'firstFrame' or 'lastFrame' in 'Resource/ImageBuffer'. "
783
+ "Assuming data is already in correct order."
784
+ )
785
+ return np.arange(n_frames)
786
+
787
+ def read_image_data_p(self, event=None, frames="all"):
788
+ """Reads the image data from the file.
789
+
790
+ Uses the ``ImgDataP`` buffer, which is used for spatial filtering
791
+ and persistence processing. Generally, this buffer does not contain the same frames
792
+ as the raw data buffer. This happens because the Verasonics often does not reconstruct
793
+ every acquired frame. This means that the images in this buffer often skip frames, and
794
+ span a longer time period than the raw data buffer.
795
+
796
+ Returns:
797
+ `image_data` (`np.ndarray`): The image data.
798
+ """
799
+ # Check if the file contains image data
800
+ if "ImgDataP" not in self:
801
+ return None
802
+
803
+ # Get the dataset reference
804
+ image_data_ref = self["ImgDataP"][0, 0]
805
+ # Dereference the dataset
806
+ if event is None:
807
+ image_data = self[image_data_ref][:]
808
+ else:
809
+ image_data = self[image_data_ref][event]
810
+ image_data = np.expand_dims(image_data, axis=0)
811
+
812
+ # Re-order images such that sequence is correct
813
+ indices = self.get_image_data_p_frame_order(image_data)
814
+ image_data = image_data[indices, :, :]
815
+
816
+ # Normalize and log-compress the image data
817
+ image_data = normalize(image_data, output_range=(0, 1), input_range=(0, None))
818
+ image_data = log_compress(image_data)
819
+ image_data = ops.convert_to_numpy(image_data)
820
+
821
+ # Select only the requested frames
822
+ frame_indices = self.get_frame_indices(frames)
823
+ image_data = image_data[frame_indices]
824
+
825
+ return image_data
826
+
827
+ @property
828
+ def element_width(self):
829
+ """The element width in meters from the file."""
830
+ element_width = self["Trans"]["elementWidth"][:][0, 0]
831
+
832
+ # Convert the probe element width to meters
833
+ if self.probe_unit == "mm":
834
+ element_width = element_width / 1000
835
+ else:
836
+ element_width = element_width * self.wavelength
837
+
838
+ return element_width
839
+
840
+ def read_verasonics_file(
841
+ self, event=None, additional_functions=None, frames="all", allow_accumulate=False
842
+ ):
843
+ """Reads data from a .mat Verasonics output file.
844
+
845
+ Args:
846
+ event (int, optional): The event index. Defaults to None in this case we assume
847
+ the data file is stored without event structure.
848
+ additional_functions (list, optional): A list of functions that read additional
849
+ data from the file. Each function should take the file as input and return a
850
+ `DatasetElement`. Defaults to None.
851
+ """
852
+
853
+ if additional_functions is None:
854
+ additional_functions = []
855
+
856
+ tx_order, rcv_order, time_to_next_transmit = self.read_transmit_events(
857
+ frames=frames, allow_accumulate=allow_accumulate
858
+ )
859
+ initial_times = self.read_initial_times(rcv_order)
860
+
861
+ # these are capable of handling multiple events
862
+ raw_data = self.read_raw_data(event, frames=frames)
863
+
864
+ verasonics_image_buffer = self.read_image_data_p(event, frames=frames)
865
+
866
+ polar_angles = self.read_polar_angles(tx_order, event)
867
+ azimuth_angles = self.read_azimuth_angles(tx_order, event)
868
+ t0_delays, tx_apodizations = self.read_t0_delays_apod(tx_order, event)
869
+ focus_distances = self.read_focus_distances(tx_order, event)
870
+
871
+ tx_waveform_indices, waveforms_one_way_list, waveforms_two_way_list = self.read_waveforms(
872
+ tx_order, event
873
+ )
874
+ focus_distances = self.planewave_focal_distance_to_inf(
875
+ focus_distances, t0_delays, tx_apodizations
876
+ )
877
+
878
+ if event is None:
879
+ group_name = "scan"
880
+ else:
881
+ group_name = f"event_{event}/scan"
882
+
883
+ el_lens_correction = DatasetElement(
884
+ group_name=group_name,
885
+ dataset_name="lens_correction",
886
+ data=self.lens_correction,
887
+ description=(
888
+ "The lens correction value used by Verasonics. This value is the "
889
+ "additional path length in wavelength that the lens introduces. "
890
+ "(This disregards refraction.)"
891
+ ),
892
+ unit="wavelengths",
893
+ )
894
+
895
+ verasonics_image_buffer = DatasetElement(
896
+ dataset_name="verasonics_image_buffer",
897
+ data=verasonics_image_buffer,
898
+ description=(
899
+ "The Verasonics ImgDataP buffer. "
900
+ "WARNING: This buffer may skip frames compared to the raw data! "
901
+ "Use only for reference."
902
+ ),
903
+ unit="unitless",
904
+ )
905
+
906
+ additional_elements = []
907
+ for additional_function in additional_functions:
908
+ additional_elements.append(additional_function(self))
909
+
910
+ data = {
911
+ "probe_geometry": self.probe_geometry,
912
+ "time_to_next_transmit": time_to_next_transmit,
913
+ "t0_delays": t0_delays,
914
+ "tx_apodizations": tx_apodizations,
915
+ "sampling_frequency": self.sampling_frequency,
916
+ "polar_angles": polar_angles,
917
+ "azimuth_angles": azimuth_angles,
918
+ "bandwidth_percent": self.bandwidth_percent,
919
+ "raw_data": raw_data,
920
+ "center_frequency": self.center_frequency,
921
+ "sound_speed": self.sound_speed,
922
+ "initial_times": initial_times,
923
+ "probe_name": self.probe_name,
924
+ "focus_distances": focus_distances,
925
+ "tx_waveform_indices": tx_waveform_indices,
926
+ "waveforms_one_way": waveforms_one_way_list,
927
+ "waveforms_two_way": waveforms_two_way_list,
928
+ "tgc_gain_curve": self.tgc_gain_curve,
929
+ "element_width": self.element_width,
930
+ "additional_elements": [
931
+ el_lens_correction,
932
+ verasonics_image_buffer,
933
+ *additional_elements,
934
+ ],
935
+ }
936
+
937
+ return data
938
+
939
+ def get_frame_indices(self, frames):
940
+ """Creates a numpy array of frame indices from the file and the frames argument.
941
+
942
+ Args:
943
+ frames (str): The frames argument. This can be "all" or a list of frame indices.
944
+
945
+ Returns:
946
+ frame_indices (np.ndarray): The frame indices.
947
+ """
948
+ # Read the number of frames from the file
949
+ n_frames = int(self.dereference_index(self["Resource"]["RcvBuffer"]["numFrames"], 0)[0][0])
950
+
951
+ if isinstance(frames, str) and frames == "all":
952
+ # Create an array of all frame-indices
953
+ frame_indices = np.arange(n_frames)
954
+ else:
955
+ frame_indices = np.array(frames)
956
+ frame_indices.sort()
957
+
958
+ if np.any(frame_indices >= n_frames):
959
+ log.error(
960
+ f"Frame indices {frame_indices} are out of bounds. "
961
+ f"The file contains {n_frames} frames. "
962
+ f"Using only the indices that are within bounds."
963
+ )
964
+ # Remove out of bounds indices
965
+ frame_indices = frame_indices[frame_indices < n_frames]
966
+
967
+ return frame_indices
968
+
969
+ def to_zea(self, output_path, additional_functions=None, frames="all", allow_accumulate=False):
970
+ """Converts the Verasonics file to the zea format.
971
+
972
+ Args:
973
+ output_path (str): The path to the output file (.hdf5 file).
974
+ additional_functions (list, optional): A list of functions that read additional
975
+ data from the file. Each function should take the file as input and return a
976
+ `DatasetElement`. Defaults to None.
977
+ frames (str or list of int, optional): The frames to add to the file. This can be
978
+ a list of integers, a range of integers (e.g. 4-8), or 'all'. Defaults to
979
+ 'all'.
980
+ allow_accumulate (bool, optional): Sometimes, some transmits are already accumulated
981
+ on the Verasonics system (e.g. harmonic imaging through pulse inversion).
982
+ In this case, the mode in the Receive structure is set to 1 (accumulate).
983
+ If this flag is set to False, an error is raised when such a mode is detected.
984
+ """
985
+ if "TX_Agent" in self:
986
+ active_keys = self["TX_Agent"].keys()
987
+ log.info(
988
+ f"Found active imaging data with {len(active_keys)} events. "
989
+ "Will convert and save all parameters for each event separately."
990
+ )
991
+ num_events = set(self["TX_Agent"][key].shape[-1] for key in active_keys)
992
+ assert len(num_events) == 1, (
993
+ "All TX_Agent entries should have the same number of events."
994
+ )
995
+ num_events = num_events.pop()
996
+
997
+ # loop over TX_Agent entries and overwrite TX each time
998
+ data = {}
999
+ for event in range(num_events):
1000
+ data[event] = self.read_verasonics_file(
1001
+ event=event,
1002
+ additional_functions=additional_functions,
1003
+ allow_accumulate=allow_accumulate,
1004
+ )
1005
+
1006
+ # convert dict of events to dict of lists
1007
+ data = {key: [data[event][key] for event in data] for key in data[0]}
1008
+ description = ["Verasonics data with multiple events"] * num_events
1009
+
1010
+ # Generate the zea dataset
1011
+ generate_zea_dataset(
1012
+ path=output_path,
1013
+ **data,
1014
+ event_structure=True,
1015
+ description=description,
1016
+ )
1017
+
1018
+ else:
1019
+ # Here we call al the functions to read the data from the file
1020
+ log.info("Reading Verasonics file...")
1021
+ data = self.read_verasonics_file(
1022
+ additional_functions=additional_functions,
1023
+ frames=frames,
1024
+ allow_accumulate=allow_accumulate,
1025
+ )
1026
+
1027
+ # Generate the zea dataset
1028
+ log.info("Generating zea dataset...")
1029
+ generate_zea_dataset(path=output_path, **data, description="Verasonics data")
1030
+
1031
+
1032
+ def _zea_from_verasonics_workspace(input_path, output_path, **kwargs):
1033
+ """Helper function around ``VerasonicsFile.to_zea``"""
1034
+
1035
+ # Create the output directory if it does not exist
1036
+ input_path = Path(input_path)
1037
+ output_path = Path(output_path)
1038
+
1039
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1040
+
1041
+ assert input_path.is_file(), log.error(f"Input file {log.yellow(input_path)} does not exist.")
1042
+
1043
+ # Load the data
1044
+ with VerasonicsFile(input_path, "r") as file:
1045
+ file.to_zea(output_path, **kwargs)
1046
+
1047
+ log.success(f"Converted {log.yellow(input_path)} to {log.yellow(output_path)}")
1048
+
1049
+
1050
+ def get_answer(prompt, additional_options=None):
1051
+ """Get a yes or no answer from the user. There is also the option to provide
1052
+ additional options. In case yes or no is selected, the function returns a boolean.
1053
+ In case an additional option is selected, the function returns the selected option
1054
+ as a string.
1055
+
1056
+ Args:
1057
+ prompt (str): The prompt to show the user.
1058
+ additional_options (list, optional): Additional options to show the user.
1059
+ Defaults to None.
1060
+
1061
+ Returns:
1062
+ str: The user's answer.
1063
+ """
1064
+ while True:
1065
+ answer = input(prompt)
1066
+ try:
1067
+ bool_answer = strtobool(answer)
1068
+ return bool_answer
1069
+ except ValueError:
1070
+ if additional_options is not None and answer in additional_options:
1071
+ return answer
1072
+ log.warning("Invalid input.")
1073
+
1074
+
1075
+ def convert_verasonics(args):
1076
+ """
1077
+ Converts a Verasonics MATLAB workspace file (.mat) or a directory containing multiple
1078
+ such files to the zea format.
1079
+
1080
+ Args:
1081
+ args (argparse.Namespace): An object with attributes:
1082
+
1083
+ - src (str): Source folder path.
1084
+ - dst (str): Destination folder path.
1085
+ - frames (list[str]): MATLAB frames spec (e.g., ["all"], integers, or ranges like "4-8")
1086
+ - allow_accumulate (bool): Whether to allow accumulate mode.
1087
+ - device (str): Device to use for processing.
1088
+ """
1089
+
1090
+ init_device(args.device)
1091
+
1092
+ # Variable to indicate what to do with existing files.
1093
+ # Is set by the user in case these are found.
1094
+ existing_file_policy = None
1095
+
1096
+ if args.src is None:
1097
+ log.info("Select a directory containing Verasonics MATLAB workspace files.")
1098
+ # Create a Tkinter root window
1099
+ try:
1100
+ import tkinter as tk
1101
+ from tkinter import filedialog
1102
+
1103
+ root = tk.Tk()
1104
+ root.withdraw()
1105
+ # Prompt the user to select a file or directory
1106
+ selected_path = filedialog.askdirectory()
1107
+ except ImportError as e:
1108
+ raise ImportError(
1109
+ log.error(
1110
+ "tkinter is not installed. Please install it with 'apt install python3-tk'."
1111
+ )
1112
+ ) from e
1113
+ except Exception as e:
1114
+ raise ValueError(
1115
+ log.error(
1116
+ "Failed to open a file dialog (possibly in headless state). "
1117
+ "Please provide a path as an argument. "
1118
+ )
1119
+ ) from e
1120
+ else:
1121
+ selected_path = args.src
1122
+
1123
+ # Exit when no path is selected
1124
+ if not selected_path:
1125
+ log.error("No path selected.")
1126
+ sys.exit()
1127
+ else:
1128
+ selected_path = Path(selected_path)
1129
+
1130
+ selected_path_is_directory = os.path.isdir(selected_path)
1131
+
1132
+ # Set the output path to be next to the input directory with _zea appended
1133
+ # to the name
1134
+ if args.dst is None:
1135
+ if selected_path_is_directory:
1136
+ output_path = selected_path.parent / (Path(selected_path).name + "_zea")
1137
+ else:
1138
+ output_path = str(selected_path.with_suffix("")) + "_zea.hdf5"
1139
+ output_path = Path(output_path)
1140
+ else:
1141
+ output_path = Path(args.dst)
1142
+ if selected_path.is_file() and output_path.suffix not in (".hdf5", ".h5"):
1143
+ log.error(
1144
+ "When converting a single file, the output path should have the .hdf5 "
1145
+ "or .h5 extension."
1146
+ )
1147
+ sys.exit()
1148
+ elif selected_path.is_dir() and output_path.is_file():
1149
+ log.error("When converting a directory, the output path should be a directory.")
1150
+ sys.exit()
1151
+
1152
+ if output_path.is_dir() and not selected_path_is_directory:
1153
+ output_path = output_path / (selected_path.name + "_zea.hdf5")
1154
+
1155
+ log.info(f"Selected path: {log.yellow(selected_path)}")
1156
+
1157
+ # Parse frames
1158
+ frames = args.frames
1159
+ if frames[0] == "all":
1160
+ frames = "all"
1161
+ else:
1162
+ indices = set()
1163
+ for frame in frames:
1164
+ if "-" in frame:
1165
+ start, end = frame.split("-")
1166
+ indices.update(range(int(start), int(end) + 1))
1167
+ else:
1168
+ indices.add(int(frame))
1169
+ frames = list(indices)
1170
+ frames.sort()
1171
+ # Do the conversion of a single file
1172
+ if not selected_path_is_directory:
1173
+ if output_path.is_file():
1174
+ answer = get_answer(
1175
+ f"File {log.yellow(output_path)} exists. Overwrite?"
1176
+ "\n\ty\t - Overwrite"
1177
+ "\n\tn\t - Skip"
1178
+ "\nAnswer: "
1179
+ )
1180
+ if answer is True:
1181
+ log.warning(f"{selected_path} exists. Deleting...")
1182
+ output_path.unlink(missing_ok=False)
1183
+ else:
1184
+ log.info("Aborting...")
1185
+ sys.exit()
1186
+ _zea_from_verasonics_workspace(
1187
+ selected_path, output_path, frames=frames, allow_accumulate=args.allow_accumulate
1188
+ )
1189
+ else:
1190
+ # Continue with the rest of your code...
1191
+ for root, dirs, files in os.walk(selected_path):
1192
+ for mat_file in files:
1193
+ # Skip non-mat files
1194
+ if not mat_file.endswith(".mat"):
1195
+ continue
1196
+
1197
+ log.info(f"Found raw data file {log.yellow(mat_file)}")
1198
+
1199
+ # Convert the file to a Path object
1200
+ mat_file = Path(mat_file)
1201
+
1202
+ # Construct the output path
1203
+ relative_path = (Path(root) / Path(mat_file)).relative_to(selected_path)
1204
+ file_output_path = output_path / (relative_path.with_suffix(".hdf5"))
1205
+
1206
+ full_path = selected_path / relative_path
1207
+
1208
+ # Handle existing files
1209
+ if file_output_path.is_file():
1210
+ if existing_file_policy is None:
1211
+ answer = get_answer(
1212
+ f"File {log.yellow(file_output_path)} exists. Overwrite?"
1213
+ "\n\ty\t - Overwrite"
1214
+ "\n\tn\t - Skip"
1215
+ "\n\tya\t - Overwrite all existing files"
1216
+ "\n\tna\t - Skip all existing files"
1217
+ "\nAnswer: ",
1218
+ additional_options=("ya", "na"),
1219
+ )
1220
+ if answer == "ya":
1221
+ existing_file_policy = "overwrite"
1222
+ elif answer == "na":
1223
+ existing_file_policy = "skip"
1224
+ continue
1225
+
1226
+ if existing_file_policy == "skip" or answer is False:
1227
+ log.info("Skipping...")
1228
+ continue
1229
+
1230
+ if existing_file_policy == "overwrite" or answer is True:
1231
+ log.warning(f"{log.yellow(file_output_path)} exists. Deleting...")
1232
+ file_output_path.unlink(missing_ok=False)
1233
+
1234
+ try:
1235
+ _zea_from_verasonics_workspace(
1236
+ full_path,
1237
+ file_output_path,
1238
+ frames=frames,
1239
+ allow_accumulate=args.allow_accumulate,
1240
+ )
1241
+ except Exception:
1242
+ # Print error message without raising it
1243
+ log.error(f"Failed to convert {mat_file}")
1244
+ # Print stacktrace
1245
+ traceback.print_exc()
1246
+
1247
+ continue