Skip to content

Commit 36a097c

Browse files
authored
Merge pull request #3 from DigitalHolography/port_pipeline_waveform_shape_metrics
Rough implementation of old doppler view pipeline
2 parents eb21f38 + 86baed0 commit 36a097c

40 files changed

Lines changed: 3298 additions & 773 deletions

.gitignore

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# Temporary workspace files
2+
system_design/
3+
AGENTS.md
4+
uv.lock
5+
test/
6+
17
# Byte-compiled / cache
28
__pycache__/
39
*.py[cod]
@@ -21,6 +27,7 @@ dist/
2127
wheelhouse/
2228

2329
# Test / coverage
30+
.tmp/
2431
.pytest_cache/
2532
.tox/
2633
.nox/
@@ -63,4 +70,4 @@ Desktop.ini
6370
process_result.csv
6471
pipelines.txt
6572
requirements-optional.txt
66-
eyeflow_matlab/
73+
eyeflow_matlab/

pyproject.toml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,24 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "EyeFlow"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Cohort-analysis engine for retinal Doppler holography"
99
readme = "README.md"
1010
requires-python = ">=3.10"
1111
license = { text = "GPL-3.0-only" }
1212

1313
# =============== [ DEPENDENCIES ] ===============
1414

15-
dependencies = ["numpy>=1.24", "h5py>=3.9", "sv-ttk>=2.6", "tkinterdnd2"]
15+
dependencies = [
16+
"numpy>=1.24",
17+
"h5py>=3.9",
18+
"pydantic>=2.6",
19+
"scipy>=1.10",
20+
"scikit-image>=0.20",
21+
"joblib>=1.3",
22+
"sv-ttk>=2.6",
23+
"tkinterdnd2",
24+
]
1625

1726
[project.optional-dependencies]
1827
# For specific pipelines

src/app_settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
APP_NAME = "EyeFlow"
1313
SETTINGS_FILENAME = "settings.json"
1414
DEFAULT_SETTINGS_FILENAME = "default_settings.json"
15-
LAST_BATCH_LOG_FILENAME = "last_batch_log.txt"
15+
LAST_BATCH_LOG_FILENAME = "last_EF_log.txt"
1616
VERSION_PATTERN = re.compile(r'^version\s*=\s*"([^"]+)"\s*$')
1717
INVALID_PATH_CHARS_PATTERN = re.compile(r'[<>:"/\\|?*]+')
1818

src/cli.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
from collections.abc import Callable, Sequence
2424
from pathlib import Path
2525

26+
from runtime_limits import configure_numeric_threads
27+
28+
configure_numeric_threads()
29+
2630
import h5py
2731

2832
from pipelines import (
@@ -31,7 +35,7 @@
3135
load_pipeline_catalog,
3236
)
3337
from pipelines.core.errors import format_pipeline_exception
34-
from pipelines.core.utils import write_combined_results_h5
38+
from input_output import write_combined_results_h5
3539

3640

3741
def _build_pipeline_registry() -> dict[str, PipelineDescriptor]:

src/domain/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Pure EyeFlow domain calculations."""
2+
3+
from .steps import (
4+
ArterialWaveformAnalysisStep,
5+
DomainStep,
6+
VesselVelocityEstimatorStep,
7+
)
8+
9+
__all__ = [
10+
"ArterialWaveformAnalysisStep",
11+
"DomainStep",
12+
"VesselVelocityEstimatorStep",
13+
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Pure blood-flow velocity calculations ported from Matlab EyeFlow."""
2+
3+
from .per_beat import PerBeatAnalysisInput, PerBeatAnalysisResult, run_per_beat_analysis
4+
from .per_beat_signal import PerBeatSignalAnalysisResult, per_beat_signal_analysis
5+
6+
__all__ = [
7+
"PerBeatAnalysisInput",
8+
"PerBeatAnalysisResult",
9+
"PerBeatSignalAnalysisResult",
10+
"per_beat_signal_analysis",
11+
"run_per_beat_analysis",
12+
]
13+
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Shared helpers for blood-flow velocity signal analysis."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
7+
8+
def next_power_of_two(value: int) -> int:
9+
if value < 1:
10+
raise ValueError("next_power_of_two expects a strictly positive integer.")
11+
return 1 << (value - 1).bit_length()
12+
13+
14+
def normalize_cycle_boundaries(
15+
cycle_boundaries,
16+
signal_length: int,
17+
*,
18+
index_base: int | None = None,
19+
) -> np.ndarray:
20+
boundaries = np.asarray(cycle_boundaries, dtype=np.int64).reshape(-1)
21+
if boundaries.size < 2:
22+
raise ValueError("At least two cycle boundaries are required.")
23+
if signal_length <= 0:
24+
raise ValueError("signal_length must be positive.")
25+
26+
inferred_index_base = _infer_index_base(boundaries, signal_length, index_base)
27+
normalized = boundaries - int(inferred_index_base)
28+
_validate_cycle_boundaries(normalized, signal_length, inferred_index_base)
29+
return normalized.astype(np.int64, copy=False)
30+
31+
32+
def _infer_index_base(
33+
boundaries: np.ndarray,
34+
signal_length: int,
35+
index_base: int | None,
36+
) -> int:
37+
if index_base is not None:
38+
return int(index_base)
39+
if np.any(boundaries == 0):
40+
return 0
41+
if np.any(boundaries == signal_length):
42+
return 1
43+
return 1
44+
45+
46+
def _validate_cycle_boundaries(
47+
normalized: np.ndarray,
48+
signal_length: int,
49+
index_base: int,
50+
) -> None:
51+
if np.any(np.diff(normalized) <= 0):
52+
raise ValueError("Cycle boundaries must be strictly increasing.")
53+
if normalized[0] >= 0 and normalized[-1] < signal_length:
54+
return
55+
raise ValueError(
56+
"Cycle boundaries fall outside the available signal length after "
57+
f"normalization (index_base={index_base})."
58+
)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Port of BloodFlowVelocity/perBeatAnalysis.m."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
import numpy as np
8+
9+
from .per_beat_signal import PerBeatSignalAnalysisResult, per_beat_signal_analysis
10+
11+
12+
@dataclass(frozen=True)
13+
class VesselPerBeatAnalysisResult:
14+
signal: PerBeatSignalAnalysisResult
15+
vmax_band_limited: np.ndarray
16+
vmin_band_limited: np.ndarray
17+
vti_per_beat: np.ndarray
18+
19+
20+
@dataclass(frozen=True)
21+
class PerBeatAnalysisInput:
22+
arterial_velocity_signal: np.ndarray
23+
venous_velocity_signal: np.ndarray
24+
systolic_acceleration_peak_indexes: np.ndarray
25+
band_limited_signal_harmonic_count: int
26+
dt_seconds: float
27+
beat_period_seconds: np.ndarray | None = None
28+
index_base: int | None = None
29+
30+
31+
@dataclass(frozen=True)
32+
class PerBeatAnalysisResult:
33+
beat_period_idx: np.ndarray
34+
beat_period_seconds: np.ndarray
35+
artery: VesselPerBeatAnalysisResult
36+
vein: VesselPerBeatAnalysisResult
37+
38+
39+
def run_per_beat_analysis(inputs: PerBeatAnalysisInput) -> PerBeatAnalysisResult:
40+
sys_idx_list = np.asarray(
41+
inputs.systolic_acceleration_peak_indexes,
42+
dtype=np.int64,
43+
).reshape(-1)
44+
beat_period_idx = np.diff(sys_idx_list).astype(np.int32, copy=False)
45+
return PerBeatAnalysisResult(
46+
beat_period_idx=beat_period_idx,
47+
beat_period_seconds=_beat_period_seconds(inputs, beat_period_idx),
48+
vein=_run_vessel(inputs.venous_velocity_signal, sys_idx_list, inputs),
49+
artery=_run_vessel(inputs.arterial_velocity_signal, sys_idx_list, inputs),
50+
)
51+
52+
53+
def _beat_period_seconds(
54+
inputs: PerBeatAnalysisInput,
55+
beat_period_idx: np.ndarray,
56+
) -> np.ndarray:
57+
if inputs.beat_period_seconds is not None:
58+
periods = np.asarray(inputs.beat_period_seconds, dtype=np.float64).reshape(-1)
59+
if periods.size == beat_period_idx.size:
60+
return periods
61+
return beat_period_idx.astype(np.float64, copy=False) * float(inputs.dt_seconds)
62+
63+
64+
def _run_vessel(
65+
velocity_signal,
66+
sys_idx_list: np.ndarray,
67+
inputs: PerBeatAnalysisInput,
68+
) -> VesselPerBeatAnalysisResult:
69+
signal = per_beat_signal_analysis(
70+
velocity_signal,
71+
sys_idx_list,
72+
inputs.band_limited_signal_harmonic_count,
73+
index_base=inputs.index_base,
74+
)
75+
return VesselPerBeatAnalysisResult(
76+
signal=signal,
77+
vmax_band_limited=np.max(signal.velocity_signal_per_beat_band_limited, axis=1),
78+
vmin_band_limited=np.min(signal.velocity_signal_per_beat_band_limited, axis=1),
79+
vti_per_beat=(
80+
np.sum(signal.velocity_signal_per_beat, axis=1) * float(inputs.dt_seconds)
81+
),
82+
)
83+
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Port of BloodFlowVelocity/perBeatSignalAnalysis.m."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
import numpy as np
8+
9+
from ._signal_utils import next_power_of_two, normalize_cycle_boundaries
10+
11+
12+
@dataclass(frozen=True)
13+
class PerBeatSignalAnalysisResult:
14+
velocity_signal_per_beat: np.ndarray
15+
velocity_signal_per_beat_fft: np.ndarray
16+
velocity_signal_per_beat_band_limited: np.ndarray
17+
18+
19+
def _interpft_real(signal: np.ndarray, target_length: int) -> np.ndarray:
20+
source = np.asarray(signal, dtype=np.float64).reshape(-1)
21+
source_length = int(source.size)
22+
if source_length == 0:
23+
raise ValueError("interpft requires a non-empty signal.")
24+
if target_length <= 0:
25+
raise ValueError("interpft target_length must be positive.")
26+
if target_length == source_length:
27+
return source.copy()
28+
29+
spectrum = np.fft.fft(source)
30+
resized = np.zeros(int(target_length), dtype=np.complex128)
31+
_copy_resized_spectrum(spectrum, resized, source_length)
32+
interpolated = np.fft.ifft(resized) * (float(target_length) / float(source_length))
33+
return interpolated.real
34+
35+
36+
def _copy_resized_spectrum(
37+
spectrum: np.ndarray,
38+
resized: np.ndarray,
39+
source_length: int,
40+
) -> None:
41+
if source_length % 2 == 0:
42+
half = source_length // 2
43+
resized[:half] = spectrum[:half]
44+
resized[-(source_length - half - 1) :] = spectrum[half + 1 :]
45+
resized[half] = spectrum[half] / 2.0
46+
resized[resized.size - half] = spectrum[half] / 2.0
47+
return
48+
49+
pivot = source_length // 2 + 1
50+
resized[:pivot] = spectrum[:pivot]
51+
resized[-(source_length // 2) :] = spectrum[pivot:]
52+
53+
54+
def per_beat_signal_analysis(
55+
signal,
56+
sys_idx_list,
57+
band_limited_signal_harmonic_count: int,
58+
*,
59+
index_base: int | None = None,
60+
) -> PerBeatSignalAnalysisResult:
61+
signal_array = np.asarray(signal, dtype=np.float64).reshape(-1)
62+
if signal_array.size == 0:
63+
raise ValueError("signal must contain at least one sample.")
64+
if band_limited_signal_harmonic_count < 1:
65+
raise ValueError("band_limited_signal_harmonic_count must be positive.")
66+
67+
cycle_boundaries = normalize_cycle_boundaries(
68+
sys_idx_list,
69+
signal_array.size,
70+
index_base=index_base,
71+
)
72+
return _analyze_cycles(
73+
signal_array,
74+
cycle_boundaries,
75+
int(band_limited_signal_harmonic_count),
76+
)
77+
78+
79+
def _analyze_cycles(
80+
signal_array: np.ndarray,
81+
cycle_boundaries: np.ndarray,
82+
harmonic_count: int,
83+
) -> PerBeatSignalAnalysisResult:
84+
number_of_beats = int(cycle_boundaries.size - 1)
85+
n_fft = next_power_of_two(int(np.max(np.diff(cycle_boundaries))))
86+
per_beat, per_beat_fft, band_limited = _empty_outputs(number_of_beats, n_fft)
87+
88+
for beat_index in range(number_of_beats):
89+
start = int(cycle_boundaries[beat_index])
90+
stop = int(cycle_boundaries[beat_index + 1]) + 1
91+
beat_interp = _interpft_real(signal_array[start:stop], n_fft + 1)[:-1]
92+
beat_fft = np.fft.fft(beat_interp, n=n_fft)
93+
per_beat[beat_index, :] = beat_interp
94+
per_beat_fft[beat_index, :] = beat_fft
95+
band_limited[beat_index, :] = _band_limited_signal(
96+
beat_fft,
97+
n_fft,
98+
harmonic_count,
99+
)
100+
101+
return PerBeatSignalAnalysisResult(per_beat, per_beat_fft, band_limited)
102+
103+
104+
def _empty_outputs(
105+
number_of_beats: int,
106+
n_fft: int,
107+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
108+
per_beat = np.full((number_of_beats, n_fft), np.nan, dtype=np.float64)
109+
per_beat_fft = np.full((number_of_beats, n_fft), np.nan + 0j, dtype=np.complex128)
110+
band_limited = np.full((number_of_beats, n_fft), np.nan, dtype=np.float64)
111+
return per_beat, per_beat_fft, band_limited
112+
113+
114+
def _band_limited_signal(
115+
beat_fft: np.ndarray,
116+
n_fft: int,
117+
harmonic_count: int,
118+
) -> np.ndarray:
119+
count = min(int(harmonic_count), n_fft)
120+
band_limited_spectrum = beat_fft[:count].copy() * 2.0
121+
band_limited_spectrum[0] = beat_fft[0]
122+
return np.abs(np.fft.ifft(band_limited_spectrum, n=n_fft))

src/domain/steps/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Pure domain steps migrated from DopplerView calculation steps."""
2+
3+
from .arterial_waveform_analysis import (
4+
ArterialWaveformAnalysisStep,
5+
)
6+
from .base import DomainStep
7+
from .vessel_velocity_estimator import (
8+
VesselVelocityEstimatorStep,
9+
)
10+
11+
__all__ = [
12+
"ArterialWaveformAnalysisStep",
13+
"DomainStep",
14+
"VesselVelocityEstimatorStep",
15+
]

0 commit comments

Comments
 (0)