From 9aee523172a1e6cb47a7e0a7d1d11dbd0ef47a06 Mon Sep 17 00:00:00 2001 From: D Date: Wed, 28 Jan 2026 01:24:35 +0100 Subject: [PATCH 01/71] feat(imports): [WIP] Added a PIPELINE_REGISTRY + Added a decorator to register a pipeline + Added a `PIPELINE_REGISTRY` * Some small linting on some files * Fixed some small override variable naming (_h5file -> h5file) NOTE: Seems to work but some logic has been removed and need to be looked at --- src/angio_eye.py | 4 +- src/pipelines/__init__.py | 137 ++++++++++++++++++++++---------- src/pipelines/core/base.py | 33 ++++++++ src/pipelines/dummy_heavy.py | 13 +-- src/pipelines/static_example.py | 16 +++- 5 files changed, 150 insertions(+), 53 deletions(-) diff --git a/src/angio_eye.py b/src/angio_eye.py index 311cf69..dd84fd7 100644 --- a/src/angio_eye.py +++ b/src/angio_eye.py @@ -338,7 +338,9 @@ def _populate_pipeline_checks( ) check.grid(row=idx, column=0, sticky="w", padx=(0, 8), pady=(0, 6)) tip_text = pipeline.description or "" - missing_deps = getattr(pipeline, "missing_deps", []) or getattr(pipeline, "requires", []) + missing_deps = getattr(pipeline, "missing_deps", []) or getattr( + pipeline, "requires", [] + ) if missing_deps: tip_suffix = f"\nInstall: {', '.join(missing_deps)}" tip_text = (tip_text + tip_suffix) if tip_text else tip_suffix diff --git a/src/pipelines/__init__.py b/src/pipelines/__init__.py index 74e0c22..effa97e 100644 --- a/src/pipelines/__init__.py +++ b/src/pipelines/__init__.py @@ -5,7 +5,7 @@ import pkgutil from typing import List, Tuple -from .core.base import ProcessPipeline, ProcessResult +from .core.base import ProcessPipeline, ProcessResult, PIPELINE_REGISTRY from .core.utils import write_combined_results_h5, write_result_h5 @@ -16,16 +16,26 @@ class MissingPipeline(ProcessPipeline): missing_deps: List[str] requires: List[str] - def __init__(self, name: str, description: str, missing_deps: List[str], requires: List[str]) -> None: + def __init__( + self, + name: str, + description: str, + missing_deps: List[str], + requires: List[str], + ) -> None: super().__init__() self.name = name self.description = description or "Pipeline unavailable (missing dependencies)." self.missing_deps = missing_deps self.requires = requires - def run(self, _h5file): - missing = ", ".join(self.missing_deps or self.requires or ["unknown dependency"]) - raise ImportError(f"Pipeline '{self.name}' unavailable. Missing dependencies: {missing}") + def run(self, h5file): + missing = ", ".join( + self.missing_deps or self.requires or ["unknown dependency"] + ) + raise ImportError( + f"Pipeline '{self.name}' unavailable. Missing dependencies: {missing}" + ) def _module_docstring(module_name: str) -> str: @@ -64,7 +74,9 @@ def _parse_requires_from_source(module_name: str) -> List[str]: if isinstance(node.value, (ast.List, ast.Tuple)): vals = [] for elt in node.value.elts: - if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + if isinstance(elt, ast.Constant) and isinstance( + elt.value, str + ): vals.append(elt.value) return vals return [] @@ -97,49 +109,88 @@ def _discover_pipelines() -> Tuple[List[ProcessPipeline], List[MissingPipeline]] for module_info in pkgutil.iter_modules(__path__): if module_info.name in {"core"} or module_info.name.startswith("_"): continue + module_name = f"{__name__}.{module_info.name}" - requires = _parse_requires_from_source(module_name) - doc = _module_docstring(module_name) + + # requires = _parse_requires_from_source(module_name) # First, check for missing requirements before importing heavy modules. - pre_missing = _missing_requirements(requires) - if pre_missing: - missing.append(MissingPipeline(module_info.name, doc, pre_missing, requires)) - continue + # pre_missing = _missing_requirements(requires) + + # if pre_missing: + # doc = _module_docstring(module_name) + # missing.append( + # MissingPipeline(module_info.name, doc, pre_missing, requires) + # ) + # continue try: module = importlib.import_module(module_name) - except ImportError as exc: - # Capture missing dependency if ModuleNotFoundError has a name. - missing_deps = [] - if isinstance(exc, ModuleNotFoundError) and exc.name and exc.name not in {module_name, module_info.name}: - missing_deps = [exc.name] - if not missing_deps: - missing_deps = requires - missing.append(MissingPipeline(module_info.name, doc, missing_deps, requires)) - continue - - module_requires = getattr(module, "REQUIRES", requires) - post_missing = _missing_requirements(module_requires) - if post_missing: - missing.append(MissingPipeline(module_info.name, doc, post_missing, module_requires)) - continue - for _, cls in inspect.getmembers(module, inspect.isclass): - if not issubclass(cls, ProcessPipeline) or cls is ProcessPipeline: - continue - if cls.__module__ != module.__name__: - continue - if cls in seen_classes: - continue - seen_classes.add(cls) - try: - inst = cls() - inst.available = True # type: ignore[attr-defined] - inst.requires = module_requires # type: ignore[attr-defined] - available.append(inst) - except TypeError: - # Skip classes requiring constructor args. - continue + except Exception as e: + # Fallback for unknown failures (SyntaxError, etc.) + missing.append( + MissingPipeline( + module_info.name, f"Error: {e}", ["Unknown"], ["Unknown"] + ) + ) + + for cls in PIPELINE_REGISTRY: + if getattr(cls, "is_available", True): + inst = cls() + # The GUI needs thoses values + inst.name = cls.name + inst.available = True + inst.requires = cls.required_deps + available.append(inst) + else: + missing.append( + MissingPipeline( + name=getattr(cls, "name", cls.__name__), + description=getattr(cls, "description", ""), + missing_deps=getattr(cls, "missing_deps", []), + requires=getattr(cls, "required_deps", []), + ) + ) + + # except ImportError as exc: + # # Capture missing dependency if ModuleNotFoundError has a name. + # missing_deps = [] + # if ( + # isinstance(exc, ModuleNotFoundError) + # and exc.name + # and exc.name not in {module_name, module_info.name} + # ): + # missing_deps = [exc.name] + # if not missing_deps: + # missing_deps = requires + # missing.append( + # MissingPipeline(module_info.name, doc, missing_deps, requires) + # ) + # continue + + # module_requires = getattr(module, "REQUIRES", requires) + # post_missing = _missing_requirements(module_requires) + # if post_missing: + # missing.append( + # MissingPipeline(module_info.name, doc, post_missing, module_requires) + # ) + # continue + # for _, cls in inspect.getmembers(module, inspect.isclass): + # if not issubclass(cls, ProcessPipeline) or cls is ProcessPipeline: + # continue + # if cls.__module__ != module.__name__: + # continue + # if cls in seen_classes: + # continue + # seen_classes.add(cls) + # try: + # inst = cls() + # inst.available = True # type: ignore[attr-defined] + # inst.requires = module_requires # type: ignore[attr-defined] + # available.append(inst) + # except TypeError: + # # Skip classes requiring constructor args. + # continue available.sort(key=lambda p: p.name.lower()) missing.sort(key=lambda p: p.name.lower()) diff --git a/src/pipelines/core/base.py b/src/pipelines/core/base.py index 22201fa..66e18d8 100644 --- a/src/pipelines/core/base.py +++ b/src/pipelines/core/base.py @@ -1,9 +1,42 @@ import csv from dataclasses import dataclass from typing import Any, Dict, Optional +import importlib.util import h5py +# Global Registry of all imports needed by the pipelines +PIPELINE_REGISTRY = [] + + +# Decorator to register all neede pipelines +def register_pipeline(name: str, description: str = "", required_deps: list[str] = []): + def decorator(cls): + # metadata for the class + cls.name = name + cls.description = description or getattr(cls, "description", "") + cls.required_deps = required_deps or [] + + # Check if requirements are missing in the current environment + missing = [] + for req in cls.required_deps: + # TODO: We should maybe include the version check + # RM the version "torch>=2.0" -> "torch" + pkg = req.split(">=")[0].split("==")[0].strip() + + if importlib.util.find_spec(pkg) is None: + missing.append(pkg) + + cls.missing_deps = missing + cls.is_available = len(missing) == 0 + + # Add to registry + if cls not in PIPELINE_REGISTRY: + PIPELINE_REGISTRY.append(cls) + return cls + + return decorator + @dataclass class ProcessResult: diff --git a/src/pipelines/dummy_heavy.py b/src/pipelines/dummy_heavy.py index 3b0111d..6ed6ab2 100644 --- a/src/pipelines/dummy_heavy.py +++ b/src/pipelines/dummy_heavy.py @@ -6,15 +6,18 @@ showing the missing deps. """ -REQUIRES = ["torch>=2.2", "pandas>=2.1"] +# REQUIRES = ["torch>=2.2", "pandas>=2.1"] -from .core.base import ProcessPipeline, ProcessResult +from .core.base import ProcessPipeline, ProcessResult, register_pipeline +@register_pipeline( + name="Dummy Heavy", + description="Demo pipeline that requires torch+pandas; computes a trivial metric.", + required_deps=["torch>=2.2", "pandas>=2.1"], +) class DummyHeavy(ProcessPipeline): - description = "Demo pipeline that requires torch+pandas; computes a trivial metric." - - def run(self, _h5file) -> ProcessResult: + def run(self, h5file) -> ProcessResult: import torch # noqa: F401 import pandas as pd # noqa: F401 diff --git a/src/pipelines/static_example.py b/src/pipelines/static_example.py index 72d2806..1c6ec76 100644 --- a/src/pipelines/static_example.py +++ b/src/pipelines/static_example.py @@ -1,8 +1,9 @@ import numpy as np -from .core.base import ProcessPipeline, ProcessResult, with_attrs +from .core.base import ProcessPipeline, ProcessResult, with_attrs, register_pipeline +@register_pipeline(name="Static Example") class StaticExample(ProcessPipeline): """ Tutorial pipeline showing the full surface area of a pipeline: @@ -16,7 +17,7 @@ class StaticExample(ProcessPipeline): description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." - def run(self, _h5file) -> ProcessResult: + def run(self, h5file) -> ProcessResult: # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. metrics = { "scalar_example": 42.0, @@ -24,7 +25,12 @@ def run(self, _h5file) -> ProcessResult: # Attach dataset-level attributes (min/max/name/unit) using with_attrs. "matrix_example": with_attrs( [[1, 2], [3, 4]], - {"minimum": [1], "maximum": [4], "nameID": ["matrix_example"], "unit": ["a.u."]}, + { + "minimum": [1], + "maximum": [4], + "nameID": ["matrix_example"], + "unit": ["a.u."], + }, ), "cube_example": with_attrs( np.arange(8).reshape(2, 2, 2), @@ -39,4 +45,6 @@ def run(self, _h5file) -> ProcessResult: attrs = {"pipeline_version": "1.0", "author": "StaticExample"} file_attrs = {"example_generated": True} - return ProcessResult(metrics=metrics, artifacts=artifacts, attrs=attrs, file_attrs=file_attrs) + return ProcessResult( + metrics=metrics, artifacts=artifacts, attrs=attrs, file_attrs=file_attrs + ) From 0983a01b89aaaff10d5b5406a14fac7b0dac1790 Mon Sep 17 00:00:00 2001 From: D Date: Wed, 28 Jan 2026 01:29:31 +0100 Subject: [PATCH 02/71] feat(imports): [WIP] Added the decorator to all pipelines + Added the decorators * Small linting --- src/pipelines/basic_stats.py | 11 +++++-- src/pipelines/tauh_n10.py | 23 +++++++++++---- src/pipelines/tauh_n10_per_beat.py | 43 +++++++++++++++++++++------- src/pipelines/velocity_comparison.py | 3 +- 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/pipelines/basic_stats.py b/src/pipelines/basic_stats.py index 934e69a..5f65a96 100644 --- a/src/pipelines/basic_stats.py +++ b/src/pipelines/basic_stats.py @@ -3,9 +3,10 @@ import h5py import numpy as np -from .core.base import ProcessPipeline, ProcessResult +from .core.base import ProcessPipeline, ProcessResult, register_pipeline +@register_pipeline(name="Basic Stats") class BasicStats(ProcessPipeline): description = "Min / Max / Mean / Std over the first dataset found in the file." @@ -28,7 +29,13 @@ def run(self, h5file: h5py.File) -> ProcessResult: finite = data[np.isfinite(data)] arr = finite if finite.size > 0 else data if arr.size == 0: - metrics = {"count": 0, "min": float("nan"), "max": float("nan"), "mean": float("nan"), "std": float("nan")} + metrics = { + "count": 0, + "min": float("nan"), + "max": float("nan"), + "mean": float("nan"), + "std": float("nan"), + } else: metrics = { "count": float(arr.size), diff --git a/src/pipelines/tauh_n10.py b/src/pipelines/tauh_n10.py index 4484207..967cb38 100644 --- a/src/pipelines/tauh_n10.py +++ b/src/pipelines/tauh_n10.py @@ -5,7 +5,7 @@ import h5py import numpy as np -from .core.base import ProcessPipeline, ProcessResult +from .core.base import ProcessPipeline, ProcessResult, register_pipeline @dataclass @@ -28,6 +28,7 @@ def _freq_unit(h5file: h5py.File, path: str) -> str: return "hz" +@register_pipeline(name="TauhN10") class TauhN10(ProcessPipeline): """ Acquisition-level τ|H|,10 using synthetic spectral amplitudes. @@ -78,12 +79,16 @@ def _compute_for_vessel(self, h5file: h5py.File, vessel: str) -> TauHResult: freq_n_hz = freq_n_raw if is_hz else freq_n_raw / (2 * math.pi) if fundamental_hz <= 0: - raise ValueError(f"Invalid fundamental frequency for {vessel}: {fundamental_hz}") + raise ValueError( + f"Invalid fundamental frequency for {vessel}: {fundamental_hz}" + ) # Reconstruct the band-limited waveform (0..n) to get Vmax. vmax = self._estimate_vmax(amplitudes, phases, freqs, is_hz, n) if not np.isfinite(vmax) or vmax <= 0: - return TauHResult(tau=math.nan, x_abs=math.nan, vmax=float(vmax), freq_hz=freq_n_hz) + return TauHResult( + tau=math.nan, x_abs=math.nan, vmax=float(vmax), freq_hz=freq_n_hz + ) v_n = amplitudes[n] x_abs = float(abs(v_n) / vmax) @@ -97,7 +102,9 @@ def _compute_for_vessel(self, h5file: h5py.File, vessel: str) -> TauHResult: tau = math.nan else: tau = float(math.sqrt(denom) / omega_n) - return TauHResult(tau=tau, x_abs=x_abs, vmax=float(vmax), freq_hz=float(freq_n_hz)) + return TauHResult( + tau=tau, x_abs=x_abs, vmax=float(vmax), freq_hz=float(freq_n_hz) + ) def _estimate_vmax( self, @@ -112,7 +119,13 @@ def _estimate_vmax( if fundamental_hz <= 0: return math.nan omega_factor = 2 * math.pi if is_hz else 1.0 - t = np.linspace(0.0, 1.0 / fundamental_hz, num=self.synthesis_points, endpoint=False, dtype=np.float64) + t = np.linspace( + 0.0, + 1.0 / fundamental_hz, + num=self.synthesis_points, + endpoint=False, + dtype=np.float64, + ) waveform = np.full_like(t, fill_value=amplitudes[0], dtype=np.float64) for k in range(1, n_max + 1): # cosine synthesis over one cardiac period diff --git a/src/pipelines/tauh_n10_per_beat.py b/src/pipelines/tauh_n10_per_beat.py index 8b7cbeb..1b3aa5f 100644 --- a/src/pipelines/tauh_n10_per_beat.py +++ b/src/pipelines/tauh_n10_per_beat.py @@ -4,10 +4,11 @@ import h5py import numpy as np -from .core.base import ProcessPipeline, ProcessResult +from .core.base import ProcessPipeline, ProcessResult, register_pipeline from .tauh_n10 import _freq_unit +@register_pipeline(name="TauhN10PerBeat") class TauhN10PerBeat(ProcessPipeline): """ Per-beat τ|H|,10 using per-beat FFT amplitudes and VmaxPerBeatBandLimited. @@ -26,7 +27,9 @@ def run(self, h5file: h5py.File) -> ProcessResult: artifacts.update(vessel_artifacts) return ProcessResult(metrics=metrics, artifacts=artifacts) - def _compute_per_beat(self, h5file: h5py.File, vessel: str) -> Tuple[Dict[str, float], Dict[str, float]]: + def _compute_per_beat( + self, h5file: h5py.File, vessel: str + ) -> Tuple[Dict[str, float], Dict[str, float]]: n = self.harmonic_index prefix = vessel.lower() # Per-beat FFT amplitudes/phases and per-beat Vmax for the band-limited signal. @@ -44,14 +47,22 @@ def _compute_per_beat(self, h5file: h5py.File, vessel: str) -> Tuple[Dict[str, f vmax = np.asarray(h5file[vmax_path]).astype(np.float64) freqs = np.asarray(h5file[freq_path]).astype(np.float64).ravel() except KeyError as exc: # noqa: BLE001 - raise ValueError(f"Missing per-beat spectral data for {vessel}: {exc}") from exc + raise ValueError( + f"Missing per-beat spectral data for {vessel}: {exc}" + ) from exc if amps.shape[0] <= n or phases.shape[0] <= n: - raise ValueError(f"Not enough harmonics in per-beat FFT for {vessel}: need index {n}") + raise ValueError( + f"Not enough harmonics in per-beat FFT for {vessel}: need index {n}" + ) if freqs.shape[0] <= n: - raise ValueError(f"Not enough frequency samples for {vessel}: need index {n}") + raise ValueError( + f"Not enough frequency samples for {vessel}: need index {n}" + ) if vmax.ndim != 2 or vmax.shape[1] != amps.shape[1]: - raise ValueError(f"Mismatch in beat count for {vessel}: vmax {vmax.shape}, amps {amps.shape}") + raise ValueError( + f"Mismatch in beat count for {vessel}: vmax {vmax.shape}, amps {amps.shape}" + ) # Frequency handling mirrors the acquisition-level pipeline. freq_unit = _freq_unit(h5file, freq_path) @@ -70,11 +81,19 @@ def _compute_per_beat(self, h5file: h5py.File, vessel: str) -> Tuple[Dict[str, f v_n = float(amps[n, beat_idx]) x_abs = math.nan if v_max <= 0 else abs(v_n) / v_max x_values.append(x_abs) - if v_max <= 0 or not np.isfinite(x_abs) or x_abs <= 0 or x_abs > 1 or omega_n <= 0: + if ( + v_max <= 0 + or not np.isfinite(x_abs) + or x_abs <= 0 + or x_abs > 1 + or omega_n <= 0 + ): tau_values.append(math.nan) continue denom = (1.0 / (x_abs * x_abs)) - 1.0 - tau_values.append(float(math.sqrt(denom) / omega_n) if denom > 0 else math.nan) + tau_values.append( + float(math.sqrt(denom) / omega_n) if denom > 0 else math.nan + ) metrics: Dict[str, float] = {} artifacts: Dict[str, float] = {f"{prefix}_freq_hz_{n}": freq_n_hz} @@ -82,6 +101,10 @@ def _compute_per_beat(self, h5file: h5py.File, vessel: str) -> Tuple[Dict[str, f metrics[f"{prefix}_tauH_{n}_beat{i}"] = tau artifacts[f"{prefix}_vmax_beat{i}"] = vmax_values[i] artifacts[f"{prefix}_X_abs_{n}_beat{i}"] = x_values[i] - metrics[f"{prefix}_tauH_{n}_median"] = float(np.nanmedian(tau_values)) if tau_values else math.nan - metrics[f"{prefix}_tauH_{n}_mean"] = float(np.nanmean(tau_values)) if tau_values else math.nan + metrics[f"{prefix}_tauH_{n}_median"] = ( + float(np.nanmedian(tau_values)) if tau_values else math.nan + ) + metrics[f"{prefix}_tauH_{n}_mean"] = ( + float(np.nanmean(tau_values)) if tau_values else math.nan + ) return metrics, artifacts diff --git a/src/pipelines/velocity_comparison.py b/src/pipelines/velocity_comparison.py index 9c0094f..ac94291 100644 --- a/src/pipelines/velocity_comparison.py +++ b/src/pipelines/velocity_comparison.py @@ -1,9 +1,10 @@ import h5py import numpy as np -from .core.base import ProcessPipeline, ProcessResult +from .core.base import ProcessPipeline, ProcessResult, register_pipeline +@register_pipeline(name="VelocityComparison") class VelocityComparison(ProcessPipeline): description = ( "Mean of /Artery/CrossSections/velocity_whole_seg_mean and " From 367c04b28571d13d88300f425e5105a5a702d4a4 Mon Sep 17 00:00:00 2001 From: D Date: Wed, 28 Jan 2026 18:50:38 +0100 Subject: [PATCH 03/71] feat: Added a first draft for the pyproject.toml --- pyproject.toml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8bc20d9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools >= 77.0.3"] +build-backend = "setuptools.build_meta" + +[project] +name = "AngioEye" +version = "0.1.0" +description = "Cohort-analysis engine for retinal Doppler holography" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "GPL-3.0-only" } + +dependencies = [ + "numpy>=1.24", + "h5py>=3.9", + "sv-ttk>=2.6", +] \ No newline at end of file From c7c4210e268a6d5caade0596d8bc0c0c5e416a48 Mon Sep 17 00:00:00 2001 From: D Date: Wed, 28 Jan 2026 19:02:39 +0100 Subject: [PATCH 04/71] feat: Added some optionnal dependencies (pipeline & dev) --- pyproject.toml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8bc20d9..a329b29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,4 +14,17 @@ dependencies = [ "numpy>=1.24", "h5py>=3.9", "sv-ttk>=2.6", +] + +[project.optional-dependencies] +# For specific pipelines +pipelines = [ + "torch>=2.2", + "pandas>=2.1", +] + +# For developers +dev = [ + "ruff", + "pyinstaller", ] \ No newline at end of file From 83ff84c42892f670f56144aae6813740cf312617 Mon Sep 17 00:00:00 2001 From: D Date: Wed, 28 Jan 2026 19:28:57 +0100 Subject: [PATCH 05/71] feat: [WIP] Linted && Added Scripts + Tools options + Added scripts `angioeye` && `angioeye-cli` to start easier + Added some ruff options + Wrapped `angio_eye.py` into a main function * Linted --- pyproject.toml | 38 +++++++++++++++++++++++++------------- src/angio_eye.py | 10 ++++++++-- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a329b29..d50239e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,21 +10,33 @@ readme = "README.md" requires-python = ">=3.10" license = { text = "GPL-3.0-only" } -dependencies = [ - "numpy>=1.24", - "h5py>=3.9", - "sv-ttk>=2.6", -] +# =============== [ DEPENDENCIES ] =============== + +dependencies = ["numpy>=1.24", "h5py>=3.9", "sv-ttk>=2.6"] [project.optional-dependencies] # For specific pipelines -pipelines = [ - "torch>=2.2", - "pandas>=2.1", -] +pipelines = ["torch>=2.2", "pandas>=2.1"] # For developers -dev = [ - "ruff", - "pyinstaller", -] \ No newline at end of file +dev = ["ruff", "pyinstaller"] + +# =============== [ SCRIPTS ] =============== + +[project.scripts] +# This allows users to simply type 'angioeye' in their terminal +angioeye = "angio_eye:main" +angioeye-cli = "cli:main" + +[tool.setuptools] +package-dir = { "" = "src" } +py-modules = ["angio_eye", "cli"] + +[tool.setuptools.packages.find] +where = ["src"] + +# =============== [ TOOLS OPTIONS ] =============== + +[tool.ruff] +line-length = 88 +target-version = "py39" diff --git a/src/angio_eye.py b/src/angio_eye.py index 311cf69..018a10e 100644 --- a/src/angio_eye.py +++ b/src/angio_eye.py @@ -338,7 +338,9 @@ def _populate_pipeline_checks( ) check.grid(row=idx, column=0, sticky="w", padx=(0, 8), pady=(0, 6)) tip_text = pipeline.description or "" - missing_deps = getattr(pipeline, "missing_deps", []) or getattr(pipeline, "requires", []) + missing_deps = getattr(pipeline, "missing_deps", []) or getattr( + pipeline, "requires", [] + ) if missing_deps: tip_suffix = f"\nInstall: {', '.join(missing_deps)}" tip_text = (tip_text + tip_suffix) if tip_text else tip_suffix @@ -701,6 +703,10 @@ def _write_result_h5( ) -if __name__ == "__main__": +def main(): app = ProcessApp() app.mainloop() + + +if __name__ == "__main__": + main() From c8107e61fcf72e9ebb5ffbf20c10ee5ffc353bee Mon Sep 17 00:00:00 2001 From: D Date: Wed, 28 Jan 2026 19:39:24 +0100 Subject: [PATCH 06/71] chore: Removed the requirements.txt --- requirements.txt | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 1baa844..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -numpy>=1.24 -h5py>=3.9 -sv-ttk>=2.6 From 9df661f430a94886481b1052910ee45c870268f8 Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Thu, 29 Jan 2026 12:27:44 +0100 Subject: [PATCH 07/71] Zip input format for cli + zip output format for batch processing. --- src/angio_eye.py | 164 ++++++++++++++++++++++++++--------------------- src/cli.py | 84 +++++++++++++++++++++--- 2 files changed, 167 insertions(+), 81 deletions(-) diff --git a/src/angio_eye.py b/src/angio_eye.py index 311cf69..91339f2 100644 --- a/src/angio_eye.py +++ b/src/angio_eye.py @@ -1,4 +1,5 @@ import os +import shutil import tempfile import zipfile from datetime import datetime @@ -78,6 +79,8 @@ def __init__(self) -> None: self.last_output_dir: Optional[Path] = None self.batch_input_var = tk.StringVar() self.batch_output_var = tk.StringVar(value=str(Path.cwd())) + self.batch_zip_var = tk.BooleanVar(value=False) + self.batch_zip_name_var = tk.StringVar(value="outputs.zip") self._apply_theme() self._build_ui() @@ -193,22 +196,11 @@ def _build_single_tab(self, parent: ttk.Frame) -> None: row=0, column=2, sticky="w" ) - ttk.Label(export_frame, text="Export CSV").grid( - row=1, column=0, sticky="w", pady=(6, 0) - ) - self.export_path_var = tk.StringVar(value="process_result.csv") - export_entry = ttk.Entry(export_frame, textvariable=self.export_path_var) - export_entry.grid(row=1, column=1, sticky="ew", padx=4, pady=(6, 0)) - ttk.Button(export_frame, text="Browse", command=self.choose_export_path).grid( - row=1, column=2, sticky="w", pady=(6, 0) - ) - ttk.Button( - export_frame, text="Export", command=self.export_process_result - ).grid(row=1, column=3, sticky="w", padx=6, pady=(6, 0)) - def _build_batch_tab(self, parent: ttk.Frame) -> None: parent.columnconfigure(1, weight=1) - parent.rowconfigure(4, weight=1) + parent.columnconfigure(2, weight=0) + parent.columnconfigure(3, weight=0) + parent.rowconfigure(5, weight=1) ttk.Label(parent, text="Input (folder / .h5 / .hdf5 / .zip)").grid( row=0, column=0, sticky="w" @@ -287,12 +279,31 @@ def _build_batch_tab(self, parent: ttk.Frame) -> None: run_btn = ttk.Button(parent, text="Run batch", command=self.run_batch) run_btn.grid(row=3, column=0, sticky="w", pady=(10, 4)) + ttk.Checkbutton( + parent, + text="Zip outputs after run", + variable=self.batch_zip_var, + command=self._toggle_zip_name_visibility, + ).grid(row=3, column=1, sticky="w", pady=(10, 4)) + + # Archive name placed on its own row to avoid resizing the log/list area. + self.batch_zip_label = ttk.Label(parent, text="Archive name") + self.batch_zip_label.grid( + row=4, column=0, sticky="w", pady=(2, 8), padx=(0, 4) + ) + self.batch_zip_entry = ttk.Entry( + parent, textvariable=self.batch_zip_name_var, width=28 + ) + self.batch_zip_entry.grid( + row=4, column=1, columnspan=3, sticky="w", pady=(2, 8) + ) + self._toggle_zip_name_visibility() ttk.Label(parent, text="Batch log").grid( - row=4, column=0, sticky="nw", pady=(8, 2) + row=5, column=0, sticky="nw", pady=(8, 2) ) batch_output_frame = ttk.Frame(parent) - batch_output_frame.grid(row=4, column=1, columnspan=2, sticky="nsew") + batch_output_frame.grid(row=5, column=1, columnspan=3, sticky="nsew") batch_output_frame.columnconfigure(0, weight=1) batch_output_frame.rowconfigure(0, weight=1) self.batch_output = tk.Text( @@ -432,7 +443,6 @@ def run_selected_pipeline(self) -> None: self._write_result_h5(result, output_path, pipeline_name=name) result.output_h5_path = output_path self.last_output_dir = output_dir - self._update_export_default(output_dir) except Exception as exc: # noqa: BLE001 messagebox.showerror("Output error", f"Cannot write outputs: {exc}") return @@ -486,19 +496,6 @@ def choose_batch_output(self) -> None: if path: self.batch_output_var.set(path) - def choose_export_path(self) -> None: - initial_dir = self.last_output_dir or Path( - self.output_dir_var.get() or Path.cwd() - ) - path = filedialog.asksaveasfilename( - defaultextension=".csv", - filetypes=[("CSV", "*.csv"), ("All files", "*.*")], - initialdir=str(initial_dir), - initialfile=Path(self.export_path_var.get()).name, - ) - if path: - self.export_path_var.set(Path(path).name) - def choose_output_dir(self) -> None: path = filedialog.askdirectory( initialdir=self.output_dir_var.get() or None, @@ -507,31 +504,6 @@ def choose_output_dir(self) -> None: if path: self.output_dir_var.set(path) - def export_process_result(self) -> None: - if self.last_process_result is None or self.last_process_pipeline is None: - messagebox.showwarning("No result", "Run a pipeline before exporting.") - return - if self.last_output_dir is None: - try: - self.last_output_dir = self._prepare_output_dir() - except Exception as exc: # noqa: BLE001 - messagebox.showerror( - "Export failed", f"Cannot prepare output folder: {exc}" - ) - return - export_name = Path(self.export_path_var.get() or "process_result.csv").name - final_path = self.last_output_dir / export_name - self.last_output_dir.mkdir(parents=True, exist_ok=True) - try: - final_path_str = self.last_process_pipeline.export( - self.last_process_result, str(final_path) - ) - except Exception as exc: # noqa: BLE001 - messagebox.showerror("Export failed", f"Cannot export: {exc}") - return - self.export_path_var.set(final_path_str) - messagebox.showinfo("Export done", f"Result exported to: {final_path_str}") - def run_batch(self) -> None: data_value = (self.batch_input_var.get() or "").strip() if not data_value: @@ -565,17 +537,19 @@ def run_batch(self) -> None: ) return - output_dir_value = (self.batch_output_var.get() or "").strip() - output_dir = ( - Path(output_dir_value).expanduser() if output_dir_value else Path.cwd() + base_output_value = (self.batch_output_var.get() or "").strip() + base_output_dir = ( + Path(base_output_value).expanduser() if base_output_value else Path.cwd() ) - if not output_dir.is_absolute(): - output_dir = Path.cwd() / output_dir - output_dir.mkdir(parents=True, exist_ok=True) + if not base_output_dir.is_absolute(): + base_output_dir = Path.cwd() / base_output_dir + base_output_dir.mkdir(parents=True, exist_ok=True) self._reset_batch_output("Starting batch run...\n") tempdir: Optional[tempfile.TemporaryDirectory] = None + temp_output_dir: Optional[tempfile.TemporaryDirectory] = None + clean_temp_output = False try: data_root, tempdir = self._prepare_data_root(data_path) inputs = self._find_h5_inputs(data_root) @@ -586,6 +560,11 @@ def run_batch(self) -> None: tempdir.cleanup() return + output_dir = base_output_dir + if self.batch_zip_var.get(): + temp_output_dir = tempfile.TemporaryDirectory(dir=base_output_dir) + output_dir = Path(temp_output_dir.name) + failures: List[str] = [] for h5_path in inputs: try: @@ -594,20 +573,51 @@ def run_batch(self) -> None: failures.append(f"{h5_path}: {exc}") self._log_batch(f"[FAIL] {h5_path.name}: {exc}") - self._log_batch(f"Completed. Outputs stored under: {output_dir}") + summary_msg: str + if self.batch_zip_var.get(): + try: + zip_name = self.batch_zip_name_var.get().strip() or "outputs.zip" + if not zip_name.lower().endswith(".zip"): + zip_name += ".zip" + zip_path = self._zip_output_dir( + output_dir, target_path=base_output_dir / zip_name + ) + self._log_batch(f"[ZIP] Archive created: {zip_path}") + summary_msg = f"ZIP archive: {zip_path}" + # Mark for cleanup so only the archive remains + clean_temp_output = True + except Exception as exc: # noqa: BLE001 + self._log_batch(f"[ZIP FAIL] {exc}") + messagebox.showerror( + "Zip failed", f"Could not create ZIP archive: {exc}" + ) + summary_msg = f"Outputs stored under: {output_dir}" + else: + summary_msg = f"Outputs stored under: {output_dir}" + + self._log_batch(f"Completed. {summary_msg}") + if failures: messagebox.showwarning( "Batch completed with errors", - f"{len(failures)} failure(s). See log for details.", + f"{len(failures)} failure(s). See log for details.\n\n{summary_msg}", ) else: - messagebox.showinfo( - "Batch completed", f"Outputs stored under: {output_dir}" - ) + messagebox.showinfo("Batch completed", summary_msg) + if clean_temp_output and temp_output_dir is not None: + temp_output_dir.cleanup() if tempdir is not None: tempdir.cleanup() + def _toggle_zip_name_visibility(self) -> None: + if self.batch_zip_var.get(): + self.batch_zip_label.grid() + self.batch_zip_entry.grid() + else: + self.batch_zip_label.grid_remove() + self.batch_zip_entry.grid_remove() + def _prepare_data_root( self, data_path: Path ) -> tuple[Path, Optional[tempfile.TemporaryDirectory]]: @@ -648,9 +658,6 @@ def _run_pipelines_on_file( for pipeline in pipelines: result = pipeline.run(h5file) pipeline_results.append((pipeline.name, result)) - suffix = self._safe_pipeline_suffix(pipeline.name) - csv_out = data_dir / f"{h5_path.stem}_{suffix}_metrics.csv" - pipeline.export(result, str(csv_out)) self._log_batch(f"[OK] {h5_path.name} -> {pipeline.name}") write_combined_results_h5( pipeline_results, combined_h5_out, source_file=str(h5_path) @@ -686,9 +693,22 @@ def _default_output_path(self, pipeline_name: str, output_dir: Path) -> str: ) return str(output_dir / f"{base}_{safe_name}_result.h5") - def _update_export_default(self, output_dir: Path) -> None: - export_name = Path(self.export_path_var.get() or "process_result.csv").name - self.export_path_var.set(str(output_dir / export_name)) + def _zip_output_dir(self, folder: Path, target_path: Optional[Path] = None) -> Path: + folder = folder.expanduser().resolve() + if not folder.exists() or not folder.is_dir(): + raise FileNotFoundError(f"Output folder does not exist: {folder}") + if target_path is None: + zip_name = f"{folder.name}_outputs.zip" if folder.name else "outputs.zip" + zip_path = folder.parent / zip_name + else: + zip_path = target_path.expanduser().resolve() + if zip_path.exists(): + zip_path.unlink() + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for file_path in folder.rglob("*"): + if file_path.is_file(): + zf.write(file_path, file_path.relative_to(folder)) + return zip_path def _write_result_h5( self, result: ProcessResult, path: str, pipeline_name: str diff --git a/src/cli.py b/src/cli.py index 030ee51..40fbeb4 100644 --- a/src/cli.py +++ b/src/cli.py @@ -2,17 +2,20 @@ Command-line interface to run AngioEye pipelines over a collection of HDF5 files. Usage example: - python cli.py --data data/ --pipelines pipelines.txt --output ./results + python cli.py --data data/ --pipelines pipelines.txt --output ./results --zip --zip-name my_run.zip Inputs: --data / -d Path to a directory (recursively scanned), a single .h5/.hdf5 file, or a .zip archive of .h5 files. --pipelines / -p Text file listing pipeline names (one per line, '#' and blank lines ignored). --output / -o Base directory where results will be written. A subfolder is created per input file. + --zip / -z When set, compress the outputs into a .zip archive after completion. + --zip-name Optional filename for the archive (default: outputs.zip). """ from __future__ import annotations import argparse import sys +import shutil from pathlib import Path import tempfile import zipfile @@ -92,11 +95,7 @@ def _run_pipelines_on_file( for pipeline in pipelines: result = pipeline.run(h5file) pipeline_results.append((pipeline.name, result)) - suffix = _safe_pipeline_suffix(pipeline.name) - csv_out = data_dir / f"{h5_path.stem}_{suffix}_metrics.csv" - pipeline.export(result, str(csv_out)) print(f"[OK] {h5_path.name} -> {pipeline.name}") - outputs.append(csv_out) write_combined_results_h5(pipeline_results, combined_h5_out, source_file=str(h5_path)) for _, result in pipeline_results: result.output_h5_path = str(combined_h5_out) @@ -105,10 +104,36 @@ def _run_pipelines_on_file( return outputs -def run_cli(data_path: Path, pipelines_file: Path, output_dir: Path) -> int: +def _zip_output_dir(folder: Path, target_path: Optional[Path] = None) -> Path: + folder = folder.expanduser().resolve() + if not folder.exists() or not folder.is_dir(): + raise FileNotFoundError(f"Output folder does not exist: {folder}") + if target_path is None: + zip_name = f"{folder.name}_outputs.zip" if folder.name else "outputs.zip" + zip_path = folder.parent / zip_name + else: + zip_path = target_path.expanduser().resolve() + if zip_path.exists(): + zip_path.unlink() + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for file_path in folder.rglob("*"): + if file_path.is_file(): + zf.write(file_path, file_path.relative_to(folder)) + return zip_path + + +def run_cli( + data_path: Path, + pipelines_file: Path, + output_dir: Path, + zip_outputs: bool = False, + zip_name: Optional[str] = None, +) -> int: registry = _build_pipeline_registry() pipelines = _load_pipeline_list(pipelines_file, registry) data_root, tempdir = _prepare_data_root(data_path) + work_tempdir_path: Optional[Path] = None + clean_work_output = False try: inputs = _find_h5_inputs(data_root) if not inputs: @@ -117,15 +142,36 @@ def run_cli(data_path: Path, pipelines_file: Path, output_dir: Path) -> int: output_root = output_dir.expanduser().resolve() output_root.mkdir(parents=True, exist_ok=True) + work_root = output_root + if zip_outputs: + work_tempdir_path = Path(tempfile.mkdtemp(dir=output_root)) + work_root = work_tempdir_path + failures: List[str] = [] for h5_path in inputs: try: - _run_pipelines_on_file(h5_path, pipelines, output_root) + _run_pipelines_on_file(h5_path, pipelines, work_root) except Exception as exc: # noqa: BLE001 failures.append(f"{h5_path}: {exc}") print(f"[FAIL] {h5_path.name}: {exc}", file=sys.stderr) - print(f"Completed. Outputs stored under: {output_root}") + if zip_outputs: + try: + final_name = (zip_name or "outputs.zip").strip() or "outputs.zip" + if not final_name.lower().endswith(".zip"): + final_name += ".zip" + zip_path = _zip_output_dir(work_root, target_path=output_root / final_name) + print(f"[ZIP] Archive created: {zip_path}") + summary_msg = f"ZIP archive: {zip_path}" + clean_work_output = True + except Exception as exc: # noqa: BLE001 + print(f"[ZIP FAIL] Could not create ZIP archive: {exc}", file=sys.stderr) + summary_msg = f"Outputs stored under: {work_root}" + else: + summary_msg = f"Outputs stored under: {work_root}" + + print(f"Completed. {summary_msg}") + if failures: print(f"{len(failures)} failure(s):", file=sys.stderr) for msg in failures: @@ -135,6 +181,8 @@ def run_cli(data_path: Path, pipelines_file: Path, output_dir: Path) -> int: finally: if tempdir is not None: tempdir.cleanup() + if clean_work_output and work_tempdir_path is not None: + shutil.rmtree(work_tempdir_path, ignore_errors=True) def main(argv: Optional[Iterable[str]] = None) -> int: @@ -160,10 +208,28 @@ def main(argv: Optional[Iterable[str]] = None) -> int: type=Path, help="Base output directory. A subfolder is created per input HDF5 file.", ) + parser.add_argument( + "-z", + "--zip", + action="store_true", + help="Zip the outputs after processing (only the archive is kept).", + ) + parser.add_argument( + "--zip-name", + type=str, + default="outputs.zip", + help="Archive filename to place inside the output directory (default: outputs.zip).", + ) args = parser.parse_args(argv) try: - return run_cli(args.data, args.pipelines, args.output) + return run_cli( + args.data, + args.pipelines, + args.output, + zip_outputs=args.zip, + zip_name=args.zip_name, + ) except Exception as exc: # noqa: BLE001 print(f"Error: {exc}", file=sys.stderr) return 1 From 66d1f355d895787333c577566e6e037a76273552 Mon Sep 17 00:00:00 2001 From: D Date: Fri, 30 Jan 2026 00:09:13 +0100 Subject: [PATCH 08/71] feat: [WIP] Updated a first version of the README.md NOTE: It should be updated with the new decorator to register pipelines --- README.md | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/README.md b/README.md index 0a83047..6faff13 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,109 @@ # AngioEye + AngioEye is the cohort-analysis engine for retinal Doppler holography. It browses EyeFlow .h5 outputs, reads per-segment metrics, applies QC, compares models, and aggregates results at eye/cohort level (including artery–vein summaries) to help design biomarkers. It exports clean CSV reports for stats, figures, and clinical models. + +--- + +## Setup + +### Prerequisites + +- Python 3.10 or higher. + +### Manual Setup / Dev + +This project uses a `pyproject.toml` to describe all requirements needed. To start using it, **it is better to use a Python virtual environment (venv)**. + +1. **Create a python virtual environment** + +```sh +# Creates the venv +python -m venv .venv + +# To enter the venv +./.venv/Scripts/activate +``` + +You can easily exit it with the command + +```sh +deactivate +``` + +2. **Install the core dependencies** + +```sh +pip install -e . +``` + +3. **Install pipeline-specific dependencies** (optional) + +```sh +pip install -e .[pipelines] +``` + +--- + +## Usage + +Launch the main application to process files interactively: + +```sh +# Via the entry point +angioeye + +# Or via the script +python src/angio_eye.py +``` + +A CLI version also exists + +```sh +# Via the entry point +angioeye-cli + +# Or via the script +python src/cli.py +``` + +--- + +## Pipeline System + +Pipelines are the heart of AngioEye. To add a new analysis, create a file in `src/pipelines/` with a class inheriting from `ProcessPipeline`. + +To register it to the app, add the decorator `@register_pipeline`. You can define any needed imports inside, as well as some more info. + +To see more complete examples, check out `src/pipelines/basic_stats.py` and `src/pipelines/dummy_heavy.py`. + +### Simple Pipeline Structure + +```python +from pipelines import ProcessPipeline, ProcessResult + +class MyAnalysis(ProcessPipeline): + description = "Calculates a custom clinical metric." + + def run(self, h5file): + # 1. Read data using h5py + # 2. Perform calculations + # 3. Return metrics and artifacts + + metrics={"peak_flow": 12.5} + artifacts = {"note": "Static data for demonstration"} + + # Optional attributes applied to the pipeline group and the root file. + attrs = { + "pipeline_version": "1.0", + "author": "StaticExample" + } + + file_attrs = {"example_generated": True} + + return ProcessResult( + metrics=metrics, + artifacts=artifacts, + attrs=attrs, + file_attrs=file_attrs + ) +``` From 5c68df43a3557fc25c8e43bdc607e3b401d88520 Mon Sep 17 00:00:00 2001 From: D Date: Fri, 30 Jan 2026 00:22:23 +0100 Subject: [PATCH 09/71] feat: Added some details and a TIP NOTE: - See previous note - The .exe needs to be also noted --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 6faff13..f984555 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,15 @@ pip install -e . pip install -e .[pipelines] ``` +4. **Install dev-specific dependencies** (optional) + +```sh +pip install -e .[dev] +``` + +> [!TIP] +> You can install all depencies in one go with `pip install -e .[dev,pipelines]` + --- ## Usage From cb1943c37a72e0dbe4d709cacbda1943f84836ff Mon Sep 17 00:00:00 2001 From: D Date: Fri, 30 Jan 2026 00:26:28 +0100 Subject: [PATCH 10/71] chore: Small change in look of the README.md --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f984555..a3f25e2 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,11 @@ pip install -e .[dev] ``` > [!TIP] -> You can install all depencies in one go with `pip install -e .[dev,pipelines]` +> You can install all depencies in one go with +> +> ```sh +> pip install -e .[dev,pipelines] +> ``` --- From 32053c020bc8bd4f4155c8334e7f00cc9a22018f Mon Sep 17 00:00:00 2001 From: D Date: Fri, 30 Jan 2026 00:27:22 +0100 Subject: [PATCH 11/71] chore: Typo fix README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a3f25e2..07ed2b6 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ pip install -e .[dev] ``` > [!TIP] -> You can install all depencies in one go with +> You can install all dependencies in one go with > > ```sh > pip install -e .[dev,pipelines] From 6a8791efff66c9d93d0ce3ae65f22c9d45d3490e Mon Sep 17 00:00:00 2001 From: Dr_dag <47819374+Drdaag@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:34:29 +0100 Subject: [PATCH 12/71] feat: Add CI workflow for linting and testing --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a6a9835 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: + - main + - dev + pull_request: + branches: + - main + - dev + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: 'pip' + + - name: Install Dependencies + run: | + pip install .[dev,pipelines] + + - name: Run Ruff (Lint & Format) + run: | + ruff check . + ruff format --check . From dc177243132e6e8a427b1bac237367fafd216746 Mon Sep 17 00:00:00 2001 From: Dr_dag <47819374+Drdaag@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:39:41 +0100 Subject: [PATCH 13/71] chore: Update CI workflow to remove dev branch from push --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6a9835..0cc4738 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - dev pull_request: branches: - main @@ -24,7 +23,7 @@ jobs: - name: Install Dependencies run: | - pip install .[dev,pipelines] + pip install .[dev] - name: Run Ruff (Lint & Format) run: | From bd191b3aae77c417ef1a2c328acad48e508c7422 Mon Sep 17 00:00:00 2001 From: Dr_dag <47819374+Drdaag@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:01:56 +0100 Subject: [PATCH 14/71] chore: Modified the ci and renamed to ruff-lint-check workflow file --- .../workflows/{ci.yml => ruff-lint-check.yml} | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) rename .github/workflows/{ci.yml => ruff-lint-check.yml} (60%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ruff-lint-check.yml similarity index 60% rename from .github/workflows/ci.yml rename to .github/workflows/ruff-lint-check.yml index 0cc4738..d07b776 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ruff-lint-check.yml @@ -1,4 +1,4 @@ -name: CI +name: Ruff Lint Check on: push: @@ -19,13 +19,13 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.10" - cache: 'pip' - - name: Install Dependencies + - name: Install Ruff run: | - pip install .[dev] + pip install ruff - - name: Run Ruff (Lint & Format) - run: | - ruff check . - ruff format --check . + - name: Check Linting (Ruff) + run: ruff check . + + - name: Check Formatting (Ruff Format) + run: ruff format --check . From 511348b9b02ff3471113f506a35b2cca51ced930 Mon Sep 17 00:00:00 2001 From: Dr_dag <47819374+Drdaag@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:05:14 +0100 Subject: [PATCH 15/71] chore: Add 'dev' branch to lint check workflow --- .github/workflows/ruff-lint-check.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ruff-lint-check.yml b/.github/workflows/ruff-lint-check.yml index d07b776..0fcd1e6 100644 --- a/.github/workflows/ruff-lint-check.yml +++ b/.github/workflows/ruff-lint-check.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - dev pull_request: branches: - main From c7af884715b4ea1be9548b5aa7adf671c666c4b0 Mon Sep 17 00:00:00 2001 From: D Date: Fri, 30 Jan 2026 01:45:29 +0100 Subject: [PATCH 16/71] feat: Added a linter script and moved folder + Added `ruff_linter` script * Moved the `scripts` folder inside the `src` folder for simplicity --- pyproject.toml | 2 + {scripts => src/scripts}/gen_optional_reqs.py | 0 src/scripts/ruff_linter.py | 43 +++++++++++++++++++ 3 files changed, 45 insertions(+) rename {scripts => src/scripts}/gen_optional_reqs.py (100%) create mode 100644 src/scripts/ruff_linter.py diff --git a/pyproject.toml b/pyproject.toml index d50239e..ba8acd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ dev = ["ruff", "pyinstaller"] angioeye = "angio_eye:main" angioeye-cli = "cli:main" +lint-tool = "scripts.ruff_linter:main" + [tool.setuptools] package-dir = { "" = "src" } py-modules = ["angio_eye", "cli"] diff --git a/scripts/gen_optional_reqs.py b/src/scripts/gen_optional_reqs.py similarity index 100% rename from scripts/gen_optional_reqs.py rename to src/scripts/gen_optional_reqs.py diff --git a/src/scripts/ruff_linter.py b/src/scripts/ruff_linter.py new file mode 100644 index 0000000..c4f152a --- /dev/null +++ b/src/scripts/ruff_linter.py @@ -0,0 +1,43 @@ +import subprocess +import sys +import argparse + + +def run_ruff(fix=False): + """Runs Ruff check and format.""" + + check_cmd = ["ruff", "check", "."] + format_cmd = ["ruff", "format", "."] + + if fix: + print("Applying auto-fixes...") + check_cmd.append("--fix") + else: + print("Checking code...") + format_cmd.insert(2, "--check") # adds --check to 'ruff format .' + + try: + res_check = subprocess.run(check_cmd) + res_format = subprocess.run(format_cmd) + + if res_check.returncode != 0 or res_format.returncode != 0: + print("\nErrors found. Run the script with --fix to resolve style issues.") + sys.exit(1) + + print("\nCode looks great!") + sys.exit(0) + + except FileNotFoundError: + print("Error: 'ruff' not found. Please run: pip install ruff") + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description="AngioEye Linting Tool") + parser.add_argument("--fix", action="store_true", help="Automatically fix issues") + args = parser.parse_args() + run_ruff(fix=args.fix) + + +if __name__ == "__main__": + main() From 8b1dd4a9b494260d076aeb198836ec4b4e26cdbe Mon Sep 17 00:00:00 2001 From: D Date: Sat, 31 Jan 2026 13:09:23 +0100 Subject: [PATCH 17/71] feat: Updated the pyproject.toml to add new lint (ruff) rules --- pyproject.toml | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ba8acd2..0396c51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,5 +40,47 @@ where = ["src"] # =============== [ TOOLS OPTIONS ] =============== [tool.ruff] +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] line-length = 88 -target-version = "py39" +indent-width = 4 +target-version = "py310" + +[tool.ruff.lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# pyupgrade (`UP`), isort (`I`), and flake8-bugbear (`B`). +select = ["E4", "E7", "E9", "F", "I", "UP", "B"] +ignore = [] +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" From 0754ff1ac17a37a9680ac71429e55f4d5f768b4b Mon Sep 17 00:00:00 2001 From: D Date: Sat, 31 Jan 2026 14:52:18 +0100 Subject: [PATCH 18/71] chore: Linted the whole project using the ruff config (and updated typing) --- Viewer/viewer.py | 80 ++++++++++++++++++++++-------- src/angio_eye.py | 45 ++++++++--------- src/cli.py | 63 ++++++++++++++--------- src/pipelines/__init__.py | 59 ++++++++++++++-------- src/pipelines/basic_stats.py | 14 ++++-- src/pipelines/core/base.py | 16 +++--- src/pipelines/core/utils.py | 30 +++++++---- src/pipelines/dummy_heavy.py | 6 +-- src/pipelines/static_example.py | 11 +++- src/pipelines/tauh_n10.py | 25 +++++++--- src/pipelines/tauh_n10_per_beat.py | 55 +++++++++++++------- src/scripts/gen_optional_reqs.py | 14 ++++-- src/scripts/ruff_linter.py | 4 +- 13 files changed, 272 insertions(+), 150 deletions(-) diff --git a/Viewer/viewer.py b/Viewer/viewer.py index e1c6016..b83b76c 100644 --- a/Viewer/viewer.py +++ b/Viewer/viewer.py @@ -1,7 +1,6 @@ import os import tkinter as tk from tkinter import filedialog, messagebox, ttk -from typing import Dict, List, Optional, Tuple, Union import h5py import numpy as np @@ -14,11 +13,11 @@ def __init__(self) -> None: super().__init__() self.title("HDF5 Viewer") self.geometry("1200x800") - self.h5_file: Optional[h5py.File] = None - self.current_dataset: Optional[h5py.Dataset] = None - self.current_dataset_path: Optional[str] = None - self.axis_label_to_index: Dict[str, int] = {} - self.slider_vars: Dict[int, Tuple[tk.IntVar, ttk.Label]] = {} + self.h5_file: h5py.File | None = None + self.current_dataset: h5py.Dataset | None = None + self.current_dataset_path: str | None = None + self.axis_label_to_index: dict[str, int] = {} + self.slider_vars: dict[int, tuple[tk.IntVar, ttk.Label]] = {} self.colorbar = None self._build_ui() @@ -47,8 +46,12 @@ def _build_ui(self) -> None: tree_frame.columnconfigure(0, weight=1) tree_frame.rowconfigure(0, weight=1) - self.tree = ttk.Treeview(tree_frame, columns=("path",), show="tree", selectmode="browse") - tree_scroll = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview) + self.tree = ttk.Treeview( + tree_frame, columns=("path",), show="tree", selectmode="browse" + ) + tree_scroll = ttk.Scrollbar( + tree_frame, orient="vertical", command=self.tree.yview + ) self.tree.configure(yscrollcommand=tree_scroll.set) self.tree.grid(row=0, column=0, sticky="nsew") tree_scroll.grid(row=0, column=1, sticky="ns") @@ -65,17 +68,23 @@ def _build_ui(self) -> None: self.y_axis_var = tk.StringVar() ttk.Label(axis_frame, text="X axis").grid(row=0, column=0, sticky="w") - self.x_combo = ttk.Combobox(axis_frame, textvariable=self.x_axis_var, state="readonly", width=24) + self.x_combo = ttk.Combobox( + axis_frame, textvariable=self.x_axis_var, state="readonly", width=24 + ) self.x_combo.grid(row=0, column=1, sticky="ew", padx=4, pady=2) ttk.Label(axis_frame, text="Y axis").grid(row=1, column=0, sticky="w") - self.y_combo = ttk.Combobox(axis_frame, textvariable=self.y_axis_var, state="readonly", width=24) + self.y_combo = ttk.Combobox( + axis_frame, textvariable=self.y_axis_var, state="readonly", width=24 + ) self.y_combo.grid(row=1, column=1, sticky="ew", padx=4, pady=2) self.x_combo.bind("<>", self.on_axis_change) self.y_combo.bind("<>", self.on_axis_change) - self.slider_frame = ttk.LabelFrame(sidebar, text="Other axes sliders", padding=8) + self.slider_frame = ttk.LabelFrame( + sidebar, text="Other axes sliders", padding=8 + ) self.slider_frame.grid(row=4, column=0, sticky="nsew", pady=(8, 8)) self.slider_frame.columnconfigure(1, weight=1) @@ -93,10 +102,14 @@ def _build_ui(self) -> None: self.canvas.get_tk_widget().grid(row=1, column=0, sticky="nsew") def _axis_label(self, axis: int) -> str: - size = self.current_dataset.shape[axis] if self.current_dataset is not None else "?" + size = ( + self.current_dataset.shape[axis] + if self.current_dataset is not None + else "?" + ) return f"Dim {axis} (size {size})" - def _selected_axis(self, label: str) -> Optional[int]: + def _selected_axis(self, label: str) -> int | None: if label == "(none)": return None return self.axis_label_to_index.get(label) @@ -138,17 +151,32 @@ def _populate_tree(self) -> None: self.tree.delete(*self.tree.get_children()) if self.h5_file is None: return - root_id = self.tree.insert("", "end", text=os.path.basename(self.h5_file.filename), open=True, values=("/")) + root_id = self.tree.insert( + "", + "end", + text=os.path.basename(self.h5_file.filename), + open=True, + values=("/"), + ) self._add_tree_items(root_id, self.h5_file) def _add_tree_items(self, parent: str, obj: h5py.Group) -> None: for key, item in obj.items(): if isinstance(item, h5py.Group): - node_id = self.tree.insert(parent, "end", text=key, open=False, values=(item.name,), tags=("group",)) + node_id = self.tree.insert( + parent, + "end", + text=key, + open=False, + values=(item.name,), + tags=("group",), + ) self._add_tree_items(node_id, item) else: label = f"{key} {item.shape}" - self.tree.insert(parent, "end", text=label, values=(item.name,), tags=("dataset",)) + self.tree.insert( + parent, "end", text=label, values=(item.name,), tags=("dataset",) + ) def on_tree_select(self, _event: tk.Event) -> None: item_id = self.tree.focus() @@ -166,7 +194,9 @@ def load_dataset(self, path: str) -> None: dataset = self.h5_file[path] self.current_dataset = dataset self.current_dataset_path = path - self.info_label.config(text=f"{path}\nshape={dataset.shape} dtype={dataset.dtype}") + self.info_label.config( + text=f"{path}\nshape={dataset.shape} dtype={dataset.dtype}" + ) dims = dataset.ndim labels = [self._axis_label(i) for i in range(dims)] @@ -215,7 +245,9 @@ def refresh_sliders(self) -> None: if axis == x_axis or axis == y_axis: continue row = len(self.slider_vars) - ttk.Label(self.slider_frame, text=self._axis_label(axis)).grid(row=row, column=0, sticky="w") + ttk.Label(self.slider_frame, text=self._axis_label(axis)).grid( + row=row, column=0, sticky="w" + ) var = tk.IntVar(value=0) scale = tk.Scale( self.slider_frame, @@ -231,7 +263,7 @@ def refresh_sliders(self) -> None: value_label.grid(row=row, column=2, sticky="e") self.slider_vars[axis] = (var, value_label) - def on_axis_change(self, _event: Optional[tk.Event] = None) -> None: + def on_axis_change(self, _event: tk.Event | None = None) -> None: if self.current_dataset is None: return self.refresh_sliders() @@ -262,7 +294,9 @@ def update_plot(self) -> None: dims = ds.ndim x_axis = self._selected_axis(self.x_axis_var.get()) y_axis = self._selected_axis(self.y_axis_var.get()) - slider_indices = {axis: int(var.get()) for axis, (var, _) in self.slider_vars.items()} + slider_indices = { + axis: int(var.get()) for axis, (var, _) in self.slider_vars.items() + } if dims == 0: value = ds[()] @@ -277,7 +311,7 @@ def update_plot(self) -> None: if x_axis == y_axis: self._show_placeholder("Choose two different axes") return - slices: List[Union[int, slice]] = [] + slices: list[int | slice] = [] for axis in range(dims): if axis == x_axis or axis == y_axis: slices.append(slice(None)) @@ -289,7 +323,9 @@ def update_plot(self) -> None: self.ax.set_xlabel(self._axis_label(x_axis)) self.ax.set_ylabel(ds.name) else: - kept_axes = [idx for idx, slc in enumerate(slices) if isinstance(slc, slice)] + kept_axes = [ + idx for idx, slc in enumerate(slices) if isinstance(slc, slice) + ] order = [kept_axes.index(y_axis), kept_axes.index(x_axis)] if order != [0, 1]: data = np.transpose(data, order) diff --git a/src/angio_eye.py b/src/angio_eye.py index fb685a3..c576595 100644 --- a/src/angio_eye.py +++ b/src/angio_eye.py @@ -1,12 +1,11 @@ import os -import shutil import tempfile +import tkinter as tk import zipfile +from collections.abc import Sequence from datetime import datetime from pathlib import Path -import tkinter as tk from tkinter import filedialog, messagebox, ttk -from typing import Dict, List, Optional, Sequence import h5py @@ -33,7 +32,7 @@ def __init__( self.text = text self.bg = bg self.fg = fg - self.tipwindow: Optional[tk.Toplevel] = None + self.tipwindow: tk.Toplevel | None = None widget.bind("", self._show) widget.bind("", self._hide) @@ -70,13 +69,13 @@ def __init__(self) -> None: super().__init__() self.title("HDF5 Process") self.geometry("800x600") - self.h5_file: Optional[h5py.File] = None - self.pipeline_registry: Dict[str, ProcessPipeline] = {} - self.pipeline_check_vars: Dict[str, tk.BooleanVar] = {} - self.last_process_result: Optional[ProcessResult] = None - self.last_process_pipeline: Optional[ProcessPipeline] = None + self.h5_file: h5py.File | None = None + self.pipeline_registry: dict[str, ProcessPipeline] = {} + self.pipeline_check_vars: dict[str, tk.BooleanVar] = {} + self.last_process_result: ProcessResult | None = None + self.last_process_pipeline: ProcessPipeline | None = None self.output_dir_var = tk.StringVar(value=str(Path.cwd())) - self.last_output_dir: Optional[Path] = None + self.last_output_dir: Path | None = None self.batch_input_var = tk.StringVar() self.batch_output_var = tk.StringVar(value=str(Path.cwd())) self.batch_zip_var = tk.BooleanVar(value=False) @@ -288,9 +287,7 @@ def _build_batch_tab(self, parent: ttk.Frame) -> None: # Archive name placed on its own row to avoid resizing the log/list area. self.batch_zip_label = ttk.Label(parent, text="Archive name") - self.batch_zip_label.grid( - row=4, column=0, sticky="w", pady=(2, 8), padx=(0, 4) - ) + self.batch_zip_label.grid(row=4, column=0, sticky="w", pady=(2, 8), padx=(0, 4)) self.batch_zip_entry = ttk.Entry( parent, textvariable=self.batch_zip_name_var, width=28 ) @@ -332,12 +329,12 @@ def _register_pipelines(self) -> None: self._populate_pipeline_checks(available, missing) def _populate_pipeline_checks( - self, available: List[ProcessPipeline], missing: List[ProcessPipeline] + self, available: list[ProcessPipeline], missing: list[ProcessPipeline] ) -> None: for child in self.pipeline_checks_inner.winfo_children(): child.destroy() self.pipeline_check_vars = {} - rows: List[ProcessPipeline] = [*available, *missing] + rows: list[ProcessPipeline] = [*available, *missing] for idx, pipeline in enumerate(rows): is_available = getattr(pipeline, "available", True) var = tk.BooleanVar(value=is_available) @@ -525,8 +522,8 @@ def run_batch(self) -> None: ) return - pipelines: List[ProcessPipeline] = [] - missing: List[str] = [] + pipelines: list[ProcessPipeline] = [] + missing: list[str] = [] for name in selected_names: pipeline = self.pipeline_registry.get(name) if pipeline is None: @@ -549,8 +546,8 @@ def run_batch(self) -> None: self._reset_batch_output("Starting batch run...\n") - tempdir: Optional[tempfile.TemporaryDirectory] = None - temp_output_dir: Optional[tempfile.TemporaryDirectory] = None + tempdir: tempfile.TemporaryDirectory | None = None + temp_output_dir: tempfile.TemporaryDirectory | None = None clean_temp_output = False try: data_root, tempdir = self._prepare_data_root(data_path) @@ -567,7 +564,7 @@ def run_batch(self) -> None: temp_output_dir = tempfile.TemporaryDirectory(dir=base_output_dir) output_dir = Path(temp_output_dir.name) - failures: List[str] = [] + failures: list[str] = [] for h5_path in inputs: try: self._run_pipelines_on_file(h5_path, pipelines, output_dir) @@ -622,7 +619,7 @@ def _toggle_zip_name_visibility(self) -> None: def _prepare_data_root( self, data_path: Path - ) -> tuple[Path, Optional[tempfile.TemporaryDirectory]]: + ) -> tuple[Path, tempfile.TemporaryDirectory | None]: if data_path.is_file() and data_path.suffix.lower() == ".zip": tempdir = tempfile.TemporaryDirectory() with zipfile.ZipFile(data_path, "r") as zf: @@ -630,7 +627,7 @@ def _prepare_data_root( return Path(tempdir.name), tempdir return data_path, None - def _find_h5_inputs(self, path: Path) -> List[Path]: + def _find_h5_inputs(self, path: Path) -> list[Path]: if path.is_file(): if path.suffix.lower() in {".h5", ".hdf5"}: return [path] @@ -655,7 +652,7 @@ def _run_pipelines_on_file( data_dir = output_root / h5_path.stem data_dir.mkdir(parents=True, exist_ok=True) combined_h5_out = data_dir / f"{h5_path.stem}_pipelines_result.h5" - pipeline_results: List[tuple[str, ProcessResult]] = [] + pipeline_results: list[tuple[str, ProcessResult]] = [] with h5py.File(h5_path, "r") as h5file: for pipeline in pipelines: result = pipeline.run(h5file) @@ -695,7 +692,7 @@ def _default_output_path(self, pipeline_name: str, output_dir: Path) -> str: ) return str(output_dir / f"{base}_{safe_name}_result.h5") - def _zip_output_dir(self, folder: Path, target_path: Optional[Path] = None) -> Path: + def _zip_output_dir(self, folder: Path, target_path: Path | None = None) -> Path: folder = folder.expanduser().resolve() if not folder.exists() or not folder.is_dir(): raise FileNotFoundError(f"Output folder does not exist: {folder}") diff --git a/src/cli.py b/src/cli.py index 40fbeb4..5423b28 100644 --- a/src/cli.py +++ b/src/cli.py @@ -11,15 +11,16 @@ --zip / -z When set, compress the outputs into a .zip archive after completion. --zip-name Optional filename for the archive (default: outputs.zip). """ + from __future__ import annotations import argparse -import sys import shutil -from pathlib import Path +import sys import tempfile import zipfile -from typing import Dict, Iterable, List, Optional, Sequence, Tuple +from collections.abc import Iterable, Sequence +from pathlib import Path import h5py @@ -27,15 +28,17 @@ from pipelines.core.utils import write_combined_results_h5 -def _build_pipeline_registry() -> Dict[str, ProcessPipeline]: +def _build_pipeline_registry() -> dict[str, ProcessPipeline]: pipelines = load_all_pipelines() return {p.name: p for p in pipelines} -def _load_pipeline_list(path: Path, registry: Dict[str, ProcessPipeline]) -> List[ProcessPipeline]: +def _load_pipeline_list( + path: Path, registry: dict[str, ProcessPipeline] +) -> list[ProcessPipeline]: raw_lines = path.read_text(encoding="utf-8").splitlines() - selected: List[ProcessPipeline] = [] - missing: List[str] = [] + selected: list[ProcessPipeline] = [] + missing: list[str] = [] for line in raw_lines: name = line.strip() if not name or name.startswith("#"): @@ -47,13 +50,17 @@ def _load_pipeline_list(path: Path, registry: Dict[str, ProcessPipeline]) -> Lis selected.append(pipeline) if missing: available = ", ".join(registry.keys()) - raise ValueError(f"Unknown pipeline(s): {', '.join(missing)}. Available: {available}") + raise ValueError( + f"Unknown pipeline(s): {', '.join(missing)}. Available: {available}" + ) if not selected: - raise ValueError("No pipelines selected (file is empty or only contains comments).") + raise ValueError( + "No pipelines selected (file is empty or only contains comments)." + ) return selected -def _find_h5_inputs(path: Path) -> List[Path]: +def _find_h5_inputs(path: Path) -> list[Path]: if path.is_file(): if path.suffix.lower() in {".h5", ".hdf5"}: return [path] @@ -71,7 +78,9 @@ def _safe_pipeline_suffix(name: str) -> str: return cleaned.strip("_") or "pipeline" -def _prepare_data_root(data_path: Path) -> Tuple[Path, Optional[tempfile.TemporaryDirectory]]: +def _prepare_data_root( + data_path: Path, +) -> tuple[Path, tempfile.TemporaryDirectory | None]: """Return a directory containing HDF5 files; extract zip archives when needed.""" if data_path.is_file() and data_path.suffix.lower() == ".zip": tempdir = tempfile.TemporaryDirectory() @@ -85,18 +94,20 @@ def _run_pipelines_on_file( h5_path: Path, pipelines: Sequence[ProcessPipeline], output_root: Path, -) -> List[Path]: - outputs: List[Path] = [] +) -> list[Path]: + outputs: list[Path] = [] data_dir = output_root / h5_path.stem data_dir.mkdir(parents=True, exist_ok=True) combined_h5_out = data_dir / f"{h5_path.stem}_pipelines_result.h5" - pipeline_results: List[Tuple[str, ProcessResult]] = [] + pipeline_results: list[tuple[str, ProcessResult]] = [] with h5py.File(h5_path, "r") as h5file: for pipeline in pipelines: result = pipeline.run(h5file) pipeline_results.append((pipeline.name, result)) print(f"[OK] {h5_path.name} -> {pipeline.name}") - write_combined_results_h5(pipeline_results, combined_h5_out, source_file=str(h5_path)) + write_combined_results_h5( + pipeline_results, combined_h5_out, source_file=str(h5_path) + ) for _, result in pipeline_results: result.output_h5_path = str(combined_h5_out) outputs.append(combined_h5_out) @@ -104,7 +115,7 @@ def _run_pipelines_on_file( return outputs -def _zip_output_dir(folder: Path, target_path: Optional[Path] = None) -> Path: +def _zip_output_dir(folder: Path, target_path: Path | None = None) -> Path: folder = folder.expanduser().resolve() if not folder.exists() or not folder.is_dir(): raise FileNotFoundError(f"Output folder does not exist: {folder}") @@ -127,12 +138,12 @@ def run_cli( pipelines_file: Path, output_dir: Path, zip_outputs: bool = False, - zip_name: Optional[str] = None, + zip_name: str | None = None, ) -> int: registry = _build_pipeline_registry() pipelines = _load_pipeline_list(pipelines_file, registry) data_root, tempdir = _prepare_data_root(data_path) - work_tempdir_path: Optional[Path] = None + work_tempdir_path: Path | None = None clean_work_output = False try: inputs = _find_h5_inputs(data_root) @@ -147,7 +158,7 @@ def run_cli( work_tempdir_path = Path(tempfile.mkdtemp(dir=output_root)) work_root = work_tempdir_path - failures: List[str] = [] + failures: list[str] = [] for h5_path in inputs: try: _run_pipelines_on_file(h5_path, pipelines, work_root) @@ -160,12 +171,16 @@ def run_cli( final_name = (zip_name or "outputs.zip").strip() or "outputs.zip" if not final_name.lower().endswith(".zip"): final_name += ".zip" - zip_path = _zip_output_dir(work_root, target_path=output_root / final_name) + zip_path = _zip_output_dir( + work_root, target_path=output_root / final_name + ) print(f"[ZIP] Archive created: {zip_path}") summary_msg = f"ZIP archive: {zip_path}" clean_work_output = True except Exception as exc: # noqa: BLE001 - print(f"[ZIP FAIL] Could not create ZIP archive: {exc}", file=sys.stderr) + print( + f"[ZIP FAIL] Could not create ZIP archive: {exc}", file=sys.stderr + ) summary_msg = f"Outputs stored under: {work_root}" else: summary_msg = f"Outputs stored under: {work_root}" @@ -185,8 +200,10 @@ def run_cli( shutil.rmtree(work_tempdir_path, ignore_errors=True) -def main(argv: Optional[Iterable[str]] = None) -> int: - parser = argparse.ArgumentParser(description="Run AngioEye pipelines over a folder of HDF5 files.") +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Run AngioEye pipelines over a folder of HDF5 files." + ) parser.add_argument( "-d", "--data", diff --git a/src/pipelines/__init__.py b/src/pipelines/__init__.py index 74e0c22..7a0240d 100644 --- a/src/pipelines/__init__.py +++ b/src/pipelines/__init__.py @@ -3,7 +3,6 @@ import importlib.util import inspect import pkgutil -from typing import List, Tuple from .core.base import ProcessPipeline, ProcessResult from .core.utils import write_combined_results_h5, write_result_h5 @@ -13,10 +12,12 @@ class MissingPipeline(ProcessPipeline): """Placeholder for pipelines whose dependencies are missing.""" available = False - missing_deps: List[str] - requires: List[str] + missing_deps: list[str] + requires: list[str] - def __init__(self, name: str, description: str, missing_deps: List[str], requires: List[str]) -> None: + def __init__( + self, name: str, description: str, missing_deps: list[str], requires: list[str] + ) -> None: super().__init__() self.name = name self.description = description or "Pipeline unavailable (missing dependencies)." @@ -24,8 +25,12 @@ def __init__(self, name: str, description: str, missing_deps: List[str], require self.requires = requires def run(self, _h5file): - missing = ", ".join(self.missing_deps or self.requires or ["unknown dependency"]) - raise ImportError(f"Pipeline '{self.name}' unavailable. Missing dependencies: {missing}") + missing = ", ".join( + self.missing_deps or self.requires or ["unknown dependency"] + ) + raise ImportError( + f"Pipeline '{self.name}' unavailable. Missing dependencies: {missing}" + ) def _module_docstring(module_name: str) -> str: @@ -37,7 +42,7 @@ def _module_docstring(module_name: str) -> str: if not origin.endswith((".py", ".pyw")): return "" try: - with open(origin, "r", encoding="utf-8") as f: + with open(origin, encoding="utf-8") as f: source = f.read() except OSError: return "" @@ -45,7 +50,7 @@ def _module_docstring(module_name: str) -> str: return ast.get_docstring(tree) or "" -def _parse_requires_from_source(module_name: str) -> List[str]: +def _parse_requires_from_source(module_name: str) -> list[str]: spec = importlib.util.find_spec(module_name) if not spec or not spec.origin: return [] @@ -53,7 +58,7 @@ def _parse_requires_from_source(module_name: str) -> List[str]: if not origin.endswith((".py", ".pyw")): return [] try: - with open(origin, "r", encoding="utf-8") as f: + with open(origin, encoding="utf-8") as f: tree = ast.parse(f.read(), filename=origin) except OSError: return [] @@ -64,7 +69,9 @@ def _parse_requires_from_source(module_name: str) -> List[str]: if isinstance(node.value, (ast.List, ast.Tuple)): vals = [] for elt in node.value.elts: - if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + if isinstance(elt, ast.Constant) and isinstance( + elt.value, str + ): vals.append(elt.value) return vals return [] @@ -78,8 +85,8 @@ def _normalize_req_name(req: str) -> str: return req -def _missing_requirements(requires: List[str]) -> List[str]: - missing: List[str] = [] +def _missing_requirements(requires: list[str]) -> list[str]: + missing: list[str] = [] for req in requires: pkg = _normalize_req_name(req).strip() if not pkg: @@ -89,9 +96,9 @@ def _missing_requirements(requires: List[str]) -> List[str]: return missing -def _discover_pipelines() -> Tuple[List[ProcessPipeline], List[MissingPipeline]]: - available: List[ProcessPipeline] = [] - missing: List[MissingPipeline] = [] +def _discover_pipelines() -> tuple[list[ProcessPipeline], list[MissingPipeline]]: + available: list[ProcessPipeline] = [] + missing: list[MissingPipeline] = [] seen_classes = set() for module_info in pkgutil.iter_modules(__path__): @@ -104,7 +111,9 @@ def _discover_pipelines() -> Tuple[List[ProcessPipeline], List[MissingPipeline]] # First, check for missing requirements before importing heavy modules. pre_missing = _missing_requirements(requires) if pre_missing: - missing.append(MissingPipeline(module_info.name, doc, pre_missing, requires)) + missing.append( + MissingPipeline(module_info.name, doc, pre_missing, requires) + ) continue try: @@ -112,17 +121,25 @@ def _discover_pipelines() -> Tuple[List[ProcessPipeline], List[MissingPipeline]] except ImportError as exc: # Capture missing dependency if ModuleNotFoundError has a name. missing_deps = [] - if isinstance(exc, ModuleNotFoundError) and exc.name and exc.name not in {module_name, module_info.name}: + if ( + isinstance(exc, ModuleNotFoundError) + and exc.name + and exc.name not in {module_name, module_info.name} + ): missing_deps = [exc.name] if not missing_deps: missing_deps = requires - missing.append(MissingPipeline(module_info.name, doc, missing_deps, requires)) + missing.append( + MissingPipeline(module_info.name, doc, missing_deps, requires) + ) continue module_requires = getattr(module, "REQUIRES", requires) post_missing = _missing_requirements(module_requires) if post_missing: - missing.append(MissingPipeline(module_info.name, doc, post_missing, module_requires)) + missing.append( + MissingPipeline(module_info.name, doc, post_missing, module_requires) + ) continue for _, cls in inspect.getmembers(module, inspect.isclass): if not issubclass(cls, ProcessPipeline) or cls is ProcessPipeline: @@ -146,7 +163,7 @@ def _discover_pipelines() -> Tuple[List[ProcessPipeline], List[MissingPipeline]] return available, missing -def load_all_pipelines(include_missing: bool = False) -> List[ProcessPipeline]: +def load_all_pipelines(include_missing: bool = False) -> list[ProcessPipeline]: """ Discover and instantiate pipelines. Optionally include placeholders for missing deps. """ @@ -154,7 +171,7 @@ def load_all_pipelines(include_missing: bool = False) -> List[ProcessPipeline]: return available + missing if include_missing else available -def load_pipeline_catalog() -> Tuple[List[ProcessPipeline], List[MissingPipeline]]: +def load_pipeline_catalog() -> tuple[list[ProcessPipeline], list[MissingPipeline]]: """Return (available, missing) pipelines for UI/CLI surfaces.""" return _discover_pipelines() diff --git a/src/pipelines/basic_stats.py b/src/pipelines/basic_stats.py index 934e69a..e018d1d 100644 --- a/src/pipelines/basic_stats.py +++ b/src/pipelines/basic_stats.py @@ -1,5 +1,3 @@ -from typing import Optional - import h5py import numpy as np @@ -9,8 +7,8 @@ class BasicStats(ProcessPipeline): description = "Min / Max / Mean / Std over the first dataset found in the file." - def _first_dataset(self, h5file: h5py.File) -> Optional[h5py.Dataset]: - found: Optional[h5py.Dataset] = None + def _first_dataset(self, h5file: h5py.File) -> h5py.Dataset | None: + found: h5py.Dataset | None = None def visitor(_name: str, obj: h5py.Dataset) -> None: nonlocal found @@ -28,7 +26,13 @@ def run(self, h5file: h5py.File) -> ProcessResult: finite = data[np.isfinite(data)] arr = finite if finite.size > 0 else data if arr.size == 0: - metrics = {"count": 0, "min": float("nan"), "max": float("nan"), "mean": float("nan"), "std": float("nan")} + metrics = { + "count": 0, + "min": float("nan"), + "max": float("nan"), + "mean": float("nan"), + "std": float("nan"), + } else: metrics = { "count": float(arr.size), diff --git a/src/pipelines/core/base.py b/src/pipelines/core/base.py index 22201fa..77232ce 100644 --- a/src/pipelines/core/base.py +++ b/src/pipelines/core/base.py @@ -1,17 +1,17 @@ import csv from dataclasses import dataclass -from typing import Any, Dict, Optional +from typing import Any import h5py @dataclass class ProcessResult: - metrics: Dict[str, Any] - artifacts: Optional[Dict[str, Any]] = None - attrs: Optional[Dict[str, Any]] = None # attributes stored on the pipeline group - file_attrs: Optional[Dict[str, Any]] = None # attributes stored on the root H5 file - output_h5_path: Optional[str] = None + metrics: dict[str, Any] + artifacts: dict[str, Any] | None = None + attrs: dict[str, Any] | None = None # attributes stored on the pipeline group + file_attrs: dict[str, Any] | None = None # attributes stored on the root H5 file + output_h5_path: str | None = None @dataclass @@ -19,10 +19,10 @@ class DatasetValue: """Represents a dataset payload plus optional attributes for that dataset.""" data: Any - attrs: Optional[Dict[str, Any]] = None + attrs: dict[str, Any] | None = None -def with_attrs(data: Any, attrs: Dict[str, Any]) -> DatasetValue: +def with_attrs(data: Any, attrs: dict[str, Any]) -> DatasetValue: """Convenience helper to attach attributes to a dataset value.""" return DatasetValue(data=data, attrs=attrs) diff --git a/src/pipelines/core/utils.py b/src/pipelines/core/utils.py index 0eb852e..e425185 100644 --- a/src/pipelines/core/utils.py +++ b/src/pipelines/core/utils.py @@ -1,5 +1,5 @@ +from collections.abc import Sequence from pathlib import Path -from typing import Optional, Sequence, Tuple, Union import h5py import numpy as np @@ -16,7 +16,7 @@ def safe_h5_key(name: str) -> str: return cleaned or "pipeline" -def _copy_input_contents(source_file: Optional[Union[str, Path]], dest: h5py.File) -> None: +def _copy_input_contents(source_file: str | Path | None, dest: h5py.File) -> None: """Copy all attributes and top-level objects from the input H5 into dest.""" if not source_file: return @@ -32,7 +32,11 @@ def _copy_input_contents(source_file: Optional[Union[str, Path]], dest: h5py.Fil def _ensure_pipelines_group(h5file: h5py.File) -> h5py.Group: """Return a pipelines group, creating it when missing.""" - return h5file["pipelines"] if "pipelines" in h5file else h5file.create_group("pipelines") + return ( + h5file["pipelines"] + if "pipelines" in h5file + else h5file.create_group("pipelines") + ) def _create_unique_group(parent: h5py.Group, base_name: str) -> h5py.Group: @@ -64,7 +68,9 @@ def _write_value_dataset(group: h5py.Group, key: str, value) -> None: data, ds_attrs = value if isinstance(data, str): - dataset = group.create_dataset(key, data=data, dtype=h5py.string_dtype(encoding="utf-8")) + dataset = group.create_dataset( + key, data=data, dtype=h5py.string_dtype(encoding="utf-8") + ) else: payload = data if isinstance(data, (list, tuple)): @@ -81,7 +87,7 @@ def _write_value_dataset(group: h5py.Group, key: str, value) -> None: _set_attr_safe(dataset, attr_key, attr_val) -def _set_attr_safe(h5obj: Union[h5py.File, h5py.Group], key: str, value) -> None: +def _set_attr_safe(h5obj: h5py.File | h5py.Group, key: str, value) -> None: """ Set an attribute on a file or group, falling back to string when the type is unsupported. """ @@ -102,9 +108,9 @@ def _set_attr_safe(h5obj: Union[h5py.File, h5py.Group], key: str, value) -> None def write_result_h5( result: ProcessResult, - path: Union[Path, str], + path: Path | str, pipeline_name: str, - source_file: Optional[str] = None, + source_file: str | None = None, ) -> str: """ Write pipeline results to an HDF5 file. @@ -147,9 +153,9 @@ def write_result_h5( def write_combined_results_h5( - results: Sequence[Tuple[str, ProcessResult]], - path: Union[Path, str], - source_file: Optional[str] = None, + results: Sequence[tuple[str, ProcessResult]], + path: Path | str, + source_file: str | None = None, ) -> str: """ Write multiple pipeline results into a single HDF5 file. @@ -164,7 +170,9 @@ def write_combined_results_h5( f.attrs["source_file"] = source_file pipelines_grp = _ensure_pipelines_group(f) for pipeline_name, result in results: - pipeline_grp = _create_unique_group(pipelines_grp, safe_h5_key(pipeline_name)) + pipeline_grp = _create_unique_group( + pipelines_grp, safe_h5_key(pipeline_name) + ) pipeline_grp.attrs["pipeline"] = pipeline_name if result.attrs: for key, value in result.attrs.items(): diff --git a/src/pipelines/dummy_heavy.py b/src/pipelines/dummy_heavy.py index 3b0111d..3000c22 100644 --- a/src/pipelines/dummy_heavy.py +++ b/src/pipelines/dummy_heavy.py @@ -6,16 +6,16 @@ showing the missing deps. """ -REQUIRES = ["torch>=2.2", "pandas>=2.1"] - from .core.base import ProcessPipeline, ProcessResult +REQUIRES = ["torch>=2.2", "pandas>=2.1"] + class DummyHeavy(ProcessPipeline): description = "Demo pipeline that requires torch+pandas; computes a trivial metric." def run(self, _h5file) -> ProcessResult: - import torch # noqa: F401 import pandas as pd # noqa: F401 + import torch # noqa: F401 return ProcessResult(metrics={"dummy": 1}) diff --git a/src/pipelines/static_example.py b/src/pipelines/static_example.py index 72d2806..ee2a221 100644 --- a/src/pipelines/static_example.py +++ b/src/pipelines/static_example.py @@ -24,7 +24,12 @@ def run(self, _h5file) -> ProcessResult: # Attach dataset-level attributes (min/max/name/unit) using with_attrs. "matrix_example": with_attrs( [[1, 2], [3, 4]], - {"minimum": [1], "maximum": [4], "nameID": ["matrix_example"], "unit": ["a.u."]}, + { + "minimum": [1], + "maximum": [4], + "nameID": ["matrix_example"], + "unit": ["a.u."], + }, ), "cube_example": with_attrs( np.arange(8).reshape(2, 2, 2), @@ -39,4 +44,6 @@ def run(self, _h5file) -> ProcessResult: attrs = {"pipeline_version": "1.0", "author": "StaticExample"} file_attrs = {"example_generated": True} - return ProcessResult(metrics=metrics, artifacts=artifacts, attrs=attrs, file_attrs=file_attrs) + return ProcessResult( + metrics=metrics, artifacts=artifacts, attrs=attrs, file_attrs=file_attrs + ) diff --git a/src/pipelines/tauh_n10.py b/src/pipelines/tauh_n10.py index 4484207..b3a4df5 100644 --- a/src/pipelines/tauh_n10.py +++ b/src/pipelines/tauh_n10.py @@ -1,6 +1,5 @@ import math from dataclasses import dataclass -from typing import Dict import h5py import numpy as np @@ -39,8 +38,8 @@ class TauhN10(ProcessPipeline): synthesis_points = 2048 # samples over one cardiac period for V_max estimation def run(self, h5file: h5py.File) -> ProcessResult: - metrics: Dict[str, float] = {} - artifacts: Dict[str, float] = {} + metrics: dict[str, float] = {} + artifacts: dict[str, float] = {} for vessel in ("Artery", "Vein"): vessel_result = self._compute_for_vessel(h5file, vessel) prefix = vessel.lower() @@ -78,12 +77,16 @@ def _compute_for_vessel(self, h5file: h5py.File, vessel: str) -> TauHResult: freq_n_hz = freq_n_raw if is_hz else freq_n_raw / (2 * math.pi) if fundamental_hz <= 0: - raise ValueError(f"Invalid fundamental frequency for {vessel}: {fundamental_hz}") + raise ValueError( + f"Invalid fundamental frequency for {vessel}: {fundamental_hz}" + ) # Reconstruct the band-limited waveform (0..n) to get Vmax. vmax = self._estimate_vmax(amplitudes, phases, freqs, is_hz, n) if not np.isfinite(vmax) or vmax <= 0: - return TauHResult(tau=math.nan, x_abs=math.nan, vmax=float(vmax), freq_hz=freq_n_hz) + return TauHResult( + tau=math.nan, x_abs=math.nan, vmax=float(vmax), freq_hz=freq_n_hz + ) v_n = amplitudes[n] x_abs = float(abs(v_n) / vmax) @@ -97,7 +100,9 @@ def _compute_for_vessel(self, h5file: h5py.File, vessel: str) -> TauHResult: tau = math.nan else: tau = float(math.sqrt(denom) / omega_n) - return TauHResult(tau=tau, x_abs=x_abs, vmax=float(vmax), freq_hz=float(freq_n_hz)) + return TauHResult( + tau=tau, x_abs=x_abs, vmax=float(vmax), freq_hz=float(freq_n_hz) + ) def _estimate_vmax( self, @@ -112,7 +117,13 @@ def _estimate_vmax( if fundamental_hz <= 0: return math.nan omega_factor = 2 * math.pi if is_hz else 1.0 - t = np.linspace(0.0, 1.0 / fundamental_hz, num=self.synthesis_points, endpoint=False, dtype=np.float64) + t = np.linspace( + 0.0, + 1.0 / fundamental_hz, + num=self.synthesis_points, + endpoint=False, + dtype=np.float64, + ) waveform = np.full_like(t, fill_value=amplitudes[0], dtype=np.float64) for k in range(1, n_max + 1): # cosine synthesis over one cardiac period diff --git a/src/pipelines/tauh_n10_per_beat.py b/src/pipelines/tauh_n10_per_beat.py index 8b7cbeb..1f80307 100644 --- a/src/pipelines/tauh_n10_per_beat.py +++ b/src/pipelines/tauh_n10_per_beat.py @@ -1,5 +1,4 @@ import math -from typing import Dict, List, Tuple import h5py import numpy as np @@ -18,15 +17,17 @@ class TauhN10PerBeat(ProcessPipeline): harmonic_index = 10 def run(self, h5file: h5py.File) -> ProcessResult: - metrics: Dict[str, float] = {} - artifacts: Dict[str, float] = {} + metrics: dict[str, float] = {} + artifacts: dict[str, float] = {} for vessel in ("Artery", "Vein"): vessel_metrics, vessel_artifacts = self._compute_per_beat(h5file, vessel) metrics.update(vessel_metrics) artifacts.update(vessel_artifacts) return ProcessResult(metrics=metrics, artifacts=artifacts) - def _compute_per_beat(self, h5file: h5py.File, vessel: str) -> Tuple[Dict[str, float], Dict[str, float]]: + def _compute_per_beat( + self, h5file: h5py.File, vessel: str + ) -> tuple[dict[str, float], dict[str, float]]: n = self.harmonic_index prefix = vessel.lower() # Per-beat FFT amplitudes/phases and per-beat Vmax for the band-limited signal. @@ -44,14 +45,22 @@ def _compute_per_beat(self, h5file: h5py.File, vessel: str) -> Tuple[Dict[str, f vmax = np.asarray(h5file[vmax_path]).astype(np.float64) freqs = np.asarray(h5file[freq_path]).astype(np.float64).ravel() except KeyError as exc: # noqa: BLE001 - raise ValueError(f"Missing per-beat spectral data for {vessel}: {exc}") from exc + raise ValueError( + f"Missing per-beat spectral data for {vessel}: {exc}" + ) from exc if amps.shape[0] <= n or phases.shape[0] <= n: - raise ValueError(f"Not enough harmonics in per-beat FFT for {vessel}: need index {n}") + raise ValueError( + f"Not enough harmonics in per-beat FFT for {vessel}: need index {n}" + ) if freqs.shape[0] <= n: - raise ValueError(f"Not enough frequency samples for {vessel}: need index {n}") + raise ValueError( + f"Not enough frequency samples for {vessel}: need index {n}" + ) if vmax.ndim != 2 or vmax.shape[1] != amps.shape[1]: - raise ValueError(f"Mismatch in beat count for {vessel}: vmax {vmax.shape}, amps {amps.shape}") + raise ValueError( + f"Mismatch in beat count for {vessel}: vmax {vmax.shape}, amps {amps.shape}" + ) # Frequency handling mirrors the acquisition-level pipeline. freq_unit = _freq_unit(h5file, freq_path) @@ -60,9 +69,9 @@ def _compute_per_beat(self, h5file: h5py.File, vessel: str) -> Tuple[Dict[str, f freq_n_hz = freq_n_raw if is_hz else freq_n_raw / (2 * math.pi) omega_n = (2 * math.pi * freq_n_raw) if is_hz else freq_n_raw - tau_values: List[float] = [] - x_values: List[float] = [] - vmax_values: List[float] = [] + tau_values: list[float] = [] + x_values: list[float] = [] + vmax_values: list[float] = [] beat_count = amps.shape[1] for beat_idx in range(beat_count): v_max = float(vmax[0, beat_idx]) @@ -70,18 +79,30 @@ def _compute_per_beat(self, h5file: h5py.File, vessel: str) -> Tuple[Dict[str, f v_n = float(amps[n, beat_idx]) x_abs = math.nan if v_max <= 0 else abs(v_n) / v_max x_values.append(x_abs) - if v_max <= 0 or not np.isfinite(x_abs) or x_abs <= 0 or x_abs > 1 or omega_n <= 0: + if ( + v_max <= 0 + or not np.isfinite(x_abs) + or x_abs <= 0 + or x_abs > 1 + or omega_n <= 0 + ): tau_values.append(math.nan) continue denom = (1.0 / (x_abs * x_abs)) - 1.0 - tau_values.append(float(math.sqrt(denom) / omega_n) if denom > 0 else math.nan) + tau_values.append( + float(math.sqrt(denom) / omega_n) if denom > 0 else math.nan + ) - metrics: Dict[str, float] = {} - artifacts: Dict[str, float] = {f"{prefix}_freq_hz_{n}": freq_n_hz} + metrics: dict[str, float] = {} + artifacts: dict[str, float] = {f"{prefix}_freq_hz_{n}": freq_n_hz} for i, tau in enumerate(tau_values): metrics[f"{prefix}_tauH_{n}_beat{i}"] = tau artifacts[f"{prefix}_vmax_beat{i}"] = vmax_values[i] artifacts[f"{prefix}_X_abs_{n}_beat{i}"] = x_values[i] - metrics[f"{prefix}_tauH_{n}_median"] = float(np.nanmedian(tau_values)) if tau_values else math.nan - metrics[f"{prefix}_tauH_{n}_mean"] = float(np.nanmean(tau_values)) if tau_values else math.nan + metrics[f"{prefix}_tauH_{n}_median"] = ( + float(np.nanmedian(tau_values)) if tau_values else math.nan + ) + metrics[f"{prefix}_tauH_{n}_mean"] = ( + float(np.nanmean(tau_values)) if tau_values else math.nan + ) return metrics, artifacts diff --git a/src/scripts/gen_optional_reqs.py b/src/scripts/gen_optional_reqs.py index 9686529..cdbebc5 100644 --- a/src/scripts/gen_optional_reqs.py +++ b/src/scripts/gen_optional_reqs.py @@ -5,18 +5,18 @@ Output: AngioEye/pipelines/requirements-optional.txt Usage: python scripts/gen_optional_reqs.py """ + from __future__ import annotations import ast from pathlib import Path -from typing import List, Set PROJECT_ROOT = Path(__file__).resolve().parents[1] PIPELINES_DIR = PROJECT_ROOT / "AngioEye" / "pipelines" OUTPUT_PATH = PIPELINES_DIR / "requirements-optional.txt" -def parse_requires(path: Path) -> List[str]: +def parse_requires(path: Path) -> list[str]: try: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) except OSError: @@ -28,14 +28,16 @@ def parse_requires(path: Path) -> List[str]: if isinstance(node.value, (ast.List, ast.Tuple)): vals = [] for elt in node.value.elts: - if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + if isinstance(elt, ast.Constant) and isinstance( + elt.value, str + ): vals.append(elt.value) return vals return [] def main() -> None: - requirements: Set[str] = set() + requirements: set[str] = set() for path in PIPELINES_DIR.glob("*.py"): if path.name.startswith("_") or path.stem == "core": continue @@ -44,7 +46,9 @@ def main() -> None: OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) sorted_reqs = sorted(requirements) - OUTPUT_PATH.write_text("\n".join(sorted_reqs) + ("\n" if sorted_reqs else ""), encoding="utf-8") + OUTPUT_PATH.write_text( + "\n".join(sorted_reqs) + ("\n" if sorted_reqs else ""), encoding="utf-8" + ) print(f"Wrote {len(sorted_reqs)} optional requirement(s) to {OUTPUT_PATH}") diff --git a/src/scripts/ruff_linter.py b/src/scripts/ruff_linter.py index c4f152a..55bb1a2 100644 --- a/src/scripts/ruff_linter.py +++ b/src/scripts/ruff_linter.py @@ -1,6 +1,6 @@ +import argparse import subprocess import sys -import argparse def run_ruff(fix=False): @@ -24,7 +24,7 @@ def run_ruff(fix=False): print("\nErrors found. Run the script with --fix to resolve style issues.") sys.exit(1) - print("\nCode looks great!") + print("\n\033[92mCode looks great!\033[0m") sys.exit(0) except FileNotFoundError: From 7ea5b0906d4f49f22757f4407bba9fd8eb603d38 Mon Sep 17 00:00:00 2001 From: D Date: Sat, 31 Jan 2026 15:16:23 +0100 Subject: [PATCH 19/71] feat: Added the pre-commit config yaml and updated the pyproject --- .pre-commit-config.yaml | 16 ++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b68b09c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.14 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/pyproject.toml b/pyproject.toml index 0396c51..73f3c01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = ["numpy>=1.24", "h5py>=3.9", "sv-ttk>=2.6"] pipelines = ["torch>=2.2", "pandas>=2.1"] # For developers -dev = ["ruff", "pyinstaller"] +dev = ["ruff", "pre-commit", "pyinstaller"] # =============== [ SCRIPTS ] =============== From 39b3ca1dfd92c8f594308e4e5e9eeaf7b6056e69 Mon Sep 17 00:00:00 2001 From: D Date: Sat, 31 Jan 2026 15:38:09 +0100 Subject: [PATCH 20/71] chore: Updated the README.md --- README.md | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 07ed2b6..d191a2c 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,16 @@ AngioEye is the cohort-analysis engine for retinal Doppler holography. It browse ### Prerequisites - Python 3.10 or higher. - -### Manual Setup / Dev +- It is highly recommended to use a virtual environment. This project uses a `pyproject.toml` to describe all requirements needed. To start using it, **it is better to use a Python virtual environment (venv)**. -1. **Create a python virtual environment** - ```sh # Creates the venv python -m venv .venv # To enter the venv +# If you are using Windows PowerShell, you might need to activate the "Exceution" policy ./.venv/Scripts/activate ``` @@ -30,29 +28,37 @@ You can easily exit it with the command deactivate ``` -2. **Install the core dependencies** +### 1. Basic Installation (User) ```sh pip install -e . -``` -3. **Install pipeline-specific dependencies** (optional) - -```sh -pip install -e .[pipelines] +# Installs pipeline-specific dependencies (optional) +pip install -e ".[pipelines]" ``` -4. **Install dev-specific dependencies** (optional) +### 2. Development Setup (Contributor) ```sh -pip install -e .[dev] +# Install all dependencies including dev tools (ruff, pre-commit, pyinstaller) +pip install -e ".[dev,pipelines]" + +# Initialize pre-commit hooks (optionnal) +pre-commit install ``` +> [!INFO] +> The pre-commit is really usefull to run automatic checks before pushing code, reducing chances of ugly code being pushed. + > [!TIP] -> You can install all dependencies in one go with +> You can run the linter easily once the `dev` dependencies are installed with the command: > > ```sh -> pip install -e .[dev,pipelines] +> # To only run the checks +> lint-tool +> +> # To let the linter try to fix as much as possible +> lint-tool --fix > ``` --- @@ -61,6 +67,10 @@ pip install -e .[dev] Launch the main application to process files interactively: +### GUI + +The GUI is best for interactive analysis and exploring individual HDF5 files. + ```sh # Via the entry point angioeye @@ -69,7 +79,9 @@ angioeye python src/angio_eye.py ``` -A CLI version also exists +### CLI + +The CLI is designed for batch processing in headless environments or clusters. ```sh # Via the entry point From eeefb66f64a82b2106cf8071f70ad653fd25b0f9 Mon Sep 17 00:00:00 2001 From: D Date: Sat, 31 Jan 2026 15:44:12 +0100 Subject: [PATCH 21/71] chore: Added some details on pre-commit --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d191a2c..92f2ab7 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ pre-commit install > [!INFO] > The pre-commit is really usefull to run automatic checks before pushing code, reducing chances of ugly code being pushed. +> +> If a pre-commit hook fails, it will try to fix all needed files, **so you will need to add them again before recreating the commit**. > [!TIP] > You can run the linter easily once the `dev` dependencies are installed with the command: From 836bf91fe5b53d4b532f0c830cf227428792a854 Mon Sep 17 00:00:00 2001 From: Dr_dag <47819374+Drdaag@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:48:02 +0100 Subject: [PATCH 22/71] chore: fixed a typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 92f2ab7..ab6d193 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ pip install -e ".[dev,pipelines]" pre-commit install ``` -> [!INFO] +> [!NOTE] > The pre-commit is really usefull to run automatic checks before pushing code, reducing chances of ugly code being pushed. > > If a pre-commit hook fails, it will try to fix all needed files, **so you will need to add them again before recreating the commit**. From 454c1fb17615e81f72b42ca14c744166826dd4f6 Mon Sep 17 00:00:00 2001 From: Dr_dag <47819374+Drdaag@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:49:22 +0100 Subject: [PATCH 23/71] chore: fixed phrasing inside README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ab6d193..edbf749 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pre-commit install > If a pre-commit hook fails, it will try to fix all needed files, **so you will need to add them again before recreating the commit**. > [!TIP] -> You can run the linter easily once the `dev` dependencies are installed with the command: +> You can run the linter easily, once the `dev` dependencies are installed, with the command: > > ```sh > # To only run the checks From a3704906477fe7be3b4b9af85755075babbe38a2 Mon Sep 17 00:00:00 2001 From: D Date: Sat, 31 Jan 2026 22:28:59 +0100 Subject: [PATCH 24/71] chore: Fixed some linting --- src/pipelines/__init__.py | 8 ++++---- src/pipelines/core/base.py | 8 +++++--- src/pipelines/dummy_heavy.py | 2 +- src/pipelines/static_example.py | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/pipelines/__init__.py b/src/pipelines/__init__.py index 84b2a6e..0a1ab29 100644 --- a/src/pipelines/__init__.py +++ b/src/pipelines/__init__.py @@ -1,10 +1,10 @@ import ast import importlib import importlib.util -import inspect import pkgutil -from .core.base import ProcessPipeline, ProcessResult, PIPELINE_REGISTRY +# import inspect +from .core.base import PIPELINE_REGISTRY, ProcessPipeline, ProcessResult from .core.utils import write_combined_results_h5, write_result_h5 @@ -99,7 +99,7 @@ def _missing_requirements(requires: list[str]) -> list[str]: def _discover_pipelines() -> tuple[list[ProcessPipeline], list[MissingPipeline]]: available: list[ProcessPipeline] = [] missing: list[MissingPipeline] = [] - seen_classes = set() + # seen_classes = set() for module_info in pkgutil.iter_modules(__path__): if module_info.name in {"core"} or module_info.name.startswith("_"): @@ -120,7 +120,7 @@ def _discover_pipelines() -> tuple[list[ProcessPipeline], list[MissingPipeline]] # continue try: - module = importlib.import_module(module_name) + _ = importlib.import_module(module_name) except Exception as e: # Fallback for unknown failures (SyntaxError, etc.) missing.append( diff --git a/src/pipelines/core/base.py b/src/pipelines/core/base.py index 9dcc10a..e99adf8 100644 --- a/src/pipelines/core/base.py +++ b/src/pipelines/core/base.py @@ -1,7 +1,7 @@ import csv -from dataclasses import dataclass -from typing import Any, Dict, Optional import importlib.util +from dataclasses import dataclass +from typing import Any import h5py @@ -10,7 +10,9 @@ # Decorator to register all neede pipelines -def register_pipeline(name: str, description: str = "", required_deps: list[str] = []): +def register_pipeline( + name: str, description: str = "", required_deps: list[str] | None = None +): def decorator(cls): # metadata for the class cls.name = name diff --git a/src/pipelines/dummy_heavy.py b/src/pipelines/dummy_heavy.py index 6ed6ab2..8bc005f 100644 --- a/src/pipelines/dummy_heavy.py +++ b/src/pipelines/dummy_heavy.py @@ -18,7 +18,7 @@ ) class DummyHeavy(ProcessPipeline): def run(self, h5file) -> ProcessResult: - import torch # noqa: F401 import pandas as pd # noqa: F401 + import torch # noqa: F401 return ProcessResult(metrics={"dummy": 1}) diff --git a/src/pipelines/static_example.py b/src/pipelines/static_example.py index 1c6ec76..6fb8f91 100644 --- a/src/pipelines/static_example.py +++ b/src/pipelines/static_example.py @@ -1,6 +1,6 @@ import numpy as np -from .core.base import ProcessPipeline, ProcessResult, with_attrs, register_pipeline +from .core.base import ProcessPipeline, ProcessResult, register_pipeline, with_attrs @register_pipeline(name="Static Example") From 12edfe62b055bd33790b5cd6aa20a37c3d6e270a Mon Sep 17 00:00:00 2001 From: D Date: Sat, 31 Jan 2026 22:41:34 +0100 Subject: [PATCH 25/71] chore: Renamed "register_pipeline" -> "registerPipeline" --- src/pipelines/basic_stats.py | 4 ++-- src/pipelines/core/base.py | 2 +- src/pipelines/dummy_heavy.py | 4 ++-- src/pipelines/static_example.py | 4 ++-- src/pipelines/tauh_n10.py | 4 ++-- src/pipelines/tauh_n10_per_beat.py | 4 ++-- src/pipelines/velocity_comparison.py | 4 ++-- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/pipelines/basic_stats.py b/src/pipelines/basic_stats.py index bc426a6..c0d69e5 100644 --- a/src/pipelines/basic_stats.py +++ b/src/pipelines/basic_stats.py @@ -1,10 +1,10 @@ import h5py import numpy as np -from .core.base import ProcessPipeline, ProcessResult, register_pipeline +from .core.base import ProcessPipeline, ProcessResult, registerPipeline -@register_pipeline(name="Basic Stats") +@registerPipeline(name="Basic Stats") class BasicStats(ProcessPipeline): description = "Min / Max / Mean / Std over the first dataset found in the file." diff --git a/src/pipelines/core/base.py b/src/pipelines/core/base.py index e99adf8..9ba733f 100644 --- a/src/pipelines/core/base.py +++ b/src/pipelines/core/base.py @@ -10,7 +10,7 @@ # Decorator to register all neede pipelines -def register_pipeline( +def registerPipeline( name: str, description: str = "", required_deps: list[str] | None = None ): def decorator(cls): diff --git a/src/pipelines/dummy_heavy.py b/src/pipelines/dummy_heavy.py index 8bc005f..e754a0b 100644 --- a/src/pipelines/dummy_heavy.py +++ b/src/pipelines/dummy_heavy.py @@ -8,10 +8,10 @@ # REQUIRES = ["torch>=2.2", "pandas>=2.1"] -from .core.base import ProcessPipeline, ProcessResult, register_pipeline +from .core.base import ProcessPipeline, ProcessResult, registerPipeline -@register_pipeline( +@registerPipeline( name="Dummy Heavy", description="Demo pipeline that requires torch+pandas; computes a trivial metric.", required_deps=["torch>=2.2", "pandas>=2.1"], diff --git a/src/pipelines/static_example.py b/src/pipelines/static_example.py index 6fb8f91..5557101 100644 --- a/src/pipelines/static_example.py +++ b/src/pipelines/static_example.py @@ -1,9 +1,9 @@ import numpy as np -from .core.base import ProcessPipeline, ProcessResult, register_pipeline, with_attrs +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs -@register_pipeline(name="Static Example") +@registerPipeline(name="Static Example") class StaticExample(ProcessPipeline): """ Tutorial pipeline showing the full surface area of a pipeline: diff --git a/src/pipelines/tauh_n10.py b/src/pipelines/tauh_n10.py index d8814c1..54baf8b 100644 --- a/src/pipelines/tauh_n10.py +++ b/src/pipelines/tauh_n10.py @@ -4,7 +4,7 @@ import h5py import numpy as np -from .core.base import ProcessPipeline, ProcessResult, register_pipeline +from .core.base import ProcessPipeline, ProcessResult, registerPipeline @dataclass @@ -27,7 +27,7 @@ def _freq_unit(h5file: h5py.File, path: str) -> str: return "hz" -@register_pipeline(name="TauhN10") +@registerPipeline(name="TauhN10") class TauhN10(ProcessPipeline): """ Acquisition-level τ|H|,10 using synthetic spectral amplitudes. diff --git a/src/pipelines/tauh_n10_per_beat.py b/src/pipelines/tauh_n10_per_beat.py index 6de134f..adb485e 100644 --- a/src/pipelines/tauh_n10_per_beat.py +++ b/src/pipelines/tauh_n10_per_beat.py @@ -3,11 +3,11 @@ import h5py import numpy as np -from .core.base import ProcessPipeline, ProcessResult, register_pipeline +from .core.base import ProcessPipeline, ProcessResult, registerPipeline from .tauh_n10 import _freq_unit -@register_pipeline(name="TauhN10PerBeat") +@registerPipeline(name="TauhN10PerBeat") class TauhN10PerBeat(ProcessPipeline): """ Per-beat τ|H|,10 using per-beat FFT amplitudes and VmaxPerBeatBandLimited. diff --git a/src/pipelines/velocity_comparison.py b/src/pipelines/velocity_comparison.py index ac94291..c16cfb2 100644 --- a/src/pipelines/velocity_comparison.py +++ b/src/pipelines/velocity_comparison.py @@ -1,10 +1,10 @@ import h5py import numpy as np -from .core.base import ProcessPipeline, ProcessResult, register_pipeline +from .core.base import ProcessPipeline, ProcessResult, registerPipeline -@register_pipeline(name="VelocityComparison") +@registerPipeline(name="VelocityComparison") class VelocityComparison(ProcessPipeline): description = ( "Mean of /Artery/CrossSections/velocity_whole_seg_mean and " From 8290bf78d461ad7b5ec4078bcda1228be26a4b7f Mon Sep 17 00:00:00 2001 From: D Date: Sat, 31 Jan 2026 22:42:18 +0100 Subject: [PATCH 26/71] chore: Updated the README.md with the registerPipeline --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index edbf749..d30a8af 100644 --- a/README.md +++ b/README.md @@ -108,10 +108,14 @@ To see more complete examples, check out `src/pipelines/basic_stats.py` and `src ```python from pipelines import ProcessPipeline, ProcessResult +@registerPipeline( + name="My Analysis", + description="Calculates a custom clinical metric.", + required_deps=["torch>=2.2"], +) class MyAnalysis(ProcessPipeline): - description = "Calculates a custom clinical metric." - def run(self, h5file): + import torch # 1. Read data using h5py # 2. Perform calculations # 3. Return metrics and artifacts From 16ecb5689cae53749bf4d96aef7061eb942ee1b7 Mon Sep 17 00:00:00 2001 From: D Date: Sat, 31 Jan 2026 22:43:42 +0100 Subject: [PATCH 27/71] chore: Fixed a missing import inside README.md example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d30a8af..c0e4d60 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ To see more complete examples, check out `src/pipelines/basic_stats.py` and `src ### Simple Pipeline Structure ```python -from pipelines import ProcessPipeline, ProcessResult +from pipelines import ProcessPipeline, ProcessResult, registerPipeline @registerPipeline( name="My Analysis", From d5ed5d5e0cd47005482341a56a5cb6af68bc956a Mon Sep 17 00:00:00 2001 From: D Date: Sun, 1 Feb 2026 15:35:02 +0100 Subject: [PATCH 28/71] feat: Changed the registry to a dict --- src/pipelines/__init__.py | 4 ++-- src/pipelines/core/base.py | 7 +++---- src/pipelines/dummy_heavy.py | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/pipelines/__init__.py b/src/pipelines/__init__.py index 0a1ab29..5731e90 100644 --- a/src/pipelines/__init__.py +++ b/src/pipelines/__init__.py @@ -120,7 +120,7 @@ def _discover_pipelines() -> tuple[list[ProcessPipeline], list[MissingPipeline]] # continue try: - _ = importlib.import_module(module_name) + importlib.import_module(module_name) except Exception as e: # Fallback for unknown failures (SyntaxError, etc.) missing.append( @@ -129,7 +129,7 @@ def _discover_pipelines() -> tuple[list[ProcessPipeline], list[MissingPipeline]] ) ) - for cls in PIPELINE_REGISTRY: + for _name, cls in PIPELINE_REGISTRY.items(): if getattr(cls, "is_available", True): inst = cls() # The GUI needs thoses values diff --git a/src/pipelines/core/base.py b/src/pipelines/core/base.py index 9ba733f..b06a866 100644 --- a/src/pipelines/core/base.py +++ b/src/pipelines/core/base.py @@ -6,7 +6,7 @@ import h5py # Global Registry of all imports needed by the pipelines -PIPELINE_REGISTRY = [] +PIPELINE_REGISTRY: dict[str, type["ProcessPipeline"]] = {} # Decorator to register all neede pipelines @@ -24,7 +24,7 @@ def decorator(cls): for req in cls.required_deps: # TODO: We should maybe include the version check # RM the version "torch>=2.0" -> "torch" - pkg = req.split(">=")[0].split("==")[0].strip() + pkg = req.split(">")[0].split("=")[0].split("<")[0].strip() if importlib.util.find_spec(pkg) is None: missing.append(pkg) @@ -33,8 +33,7 @@ def decorator(cls): cls.is_available = len(missing) == 0 # Add to registry - if cls not in PIPELINE_REGISTRY: - PIPELINE_REGISTRY.append(cls) + PIPELINE_REGISTRY[name] = cls return cls return decorator diff --git a/src/pipelines/dummy_heavy.py b/src/pipelines/dummy_heavy.py index e754a0b..cdf442d 100644 --- a/src/pipelines/dummy_heavy.py +++ b/src/pipelines/dummy_heavy.py @@ -18,7 +18,7 @@ ) class DummyHeavy(ProcessPipeline): def run(self, h5file) -> ProcessResult: - import pandas as pd # noqa: F401 - import torch # noqa: F401 + import pandas as pd # type: ignore # noqa: F401 + import torch # type: ignore # noqa: F401 return ProcessResult(metrics={"dummy": 1}) From a51fac199e5a0a27a74899c391a66166b61590e4 Mon Sep 17 00:00:00 2001 From: D Date: Sun, 1 Feb 2026 22:13:08 +0100 Subject: [PATCH 29/71] feat: Added the PipelineDescriptor dataclass + Added the `PipelineDescriptor` dataclass * Moved the `MissingPipeline` to base.py * Moved some attributes to `ProcessPipeline` to stay conform * Applied the `PipelineDescriptor` logic to GUI & CLI * Renamed some attribues to follow GUI * required_deps -> requires * is_available -> available - Removed the `load_all_pipelines` method NOTE: - Need extensive testing, but seems to work - The renaming was done to follow GUI, but I believe that the previous is clearer --- src/angio_eye.py | 19 ++++--- src/cli.py | 28 ++++++---- src/pipelines/__init__.py | 97 ++++++++++++++++------------------ src/pipelines/core/__init__.py | 3 +- src/pipelines/core/base.py | 70 +++++++++++++++++++++--- 5 files changed, 138 insertions(+), 79 deletions(-) diff --git a/src/angio_eye.py b/src/angio_eye.py index c576595..f1b193f 100644 --- a/src/angio_eye.py +++ b/src/angio_eye.py @@ -15,6 +15,7 @@ sv_ttk = None from pipelines import ( + PipelineDescriptor, ProcessPipeline, ProcessResult, load_pipeline_catalog, @@ -70,7 +71,7 @@ def __init__(self) -> None: self.title("HDF5 Process") self.geometry("800x600") self.h5_file: h5py.File | None = None - self.pipeline_registry: dict[str, ProcessPipeline] = {} + self.pipeline_registry: dict[str, PipelineDescriptor] = {} self.pipeline_check_vars: dict[str, tk.BooleanVar] = {} self.last_process_result: ProcessResult | None = None self.last_process_pipeline: ProcessPipeline | None = None @@ -329,12 +330,12 @@ def _register_pipelines(self) -> None: self._populate_pipeline_checks(available, missing) def _populate_pipeline_checks( - self, available: list[ProcessPipeline], missing: list[ProcessPipeline] + self, available: list[PipelineDescriptor], missing: list[PipelineDescriptor] ) -> None: for child in self.pipeline_checks_inner.winfo_children(): child.destroy() self.pipeline_check_vars = {} - rows: list[ProcessPipeline] = [*available, *missing] + rows: list[PipelineDescriptor] = [*available, *missing] for idx, pipeline in enumerate(rows): is_available = getattr(pipeline, "available", True) var = tk.BooleanVar(value=is_available) @@ -422,8 +423,8 @@ def run_selected_pipeline(self) -> None: "Missing pipeline", "Select a pipeline before running." ) return - pipeline = self.pipeline_registry.get(name) - if pipeline is None: + pipeline_desc = self.pipeline_registry.get(name) + if pipeline_desc is None: messagebox.showerror( "Pipeline missing", f"Pipeline '{name}' is not registered." ) @@ -432,6 +433,7 @@ def run_selected_pipeline(self) -> None: messagebox.showwarning("Missing file", "Load a .h5 file first.") return try: + pipeline = pipeline_desc.instantiate() result = pipeline.run(self.h5_file) except Exception as exc: # noqa: BLE001 messagebox.showerror("Pipeline error", f"Pipeline failed: {exc}") @@ -522,7 +524,7 @@ def run_batch(self) -> None: ) return - pipelines: list[ProcessPipeline] = [] + pipelines: list[PipelineDescriptor] = [] missing: list[str] = [] for name in selected_names: pipeline = self.pipeline_registry.get(name) @@ -646,7 +648,7 @@ def _safe_pipeline_suffix(self, name: str) -> str: def _run_pipelines_on_file( self, h5_path: Path, - pipelines: Sequence[ProcessPipeline], + pipelines: Sequence[PipelineDescriptor], output_root: Path, ) -> None: data_dir = output_root / h5_path.stem @@ -654,7 +656,8 @@ def _run_pipelines_on_file( combined_h5_out = data_dir / f"{h5_path.stem}_pipelines_result.h5" pipeline_results: list[tuple[str, ProcessResult]] = [] with h5py.File(h5_path, "r") as h5file: - for pipeline in pipelines: + for pipeline_desc in pipelines: + pipeline = pipeline_desc.instantiate() result = pipeline.run(h5file) pipeline_results.append((pipeline.name, result)) self._log_batch(f"[OK] {h5_path.name} -> {pipeline.name}") diff --git a/src/cli.py b/src/cli.py index 5423b28..d621c62 100644 --- a/src/cli.py +++ b/src/cli.py @@ -19,25 +19,30 @@ import sys import tempfile import zipfile -from collections.abc import Iterable, Sequence +from collections.abc import Sequence from pathlib import Path import h5py -from pipelines import ProcessPipeline, ProcessResult, load_all_pipelines +from pipelines import ( + PipelineDescriptor, + ProcessResult, + load_pipeline_catalog, +) from pipelines.core.utils import write_combined_results_h5 -def _build_pipeline_registry() -> dict[str, ProcessPipeline]: - pipelines = load_all_pipelines() - return {p.name: p for p in pipelines} +def _build_pipeline_registry() -> dict[str, PipelineDescriptor]: + available, _ = load_pipeline_catalog() + # pipelines = load_all_pipelines() + return {p.name: p for p in available} def _load_pipeline_list( - path: Path, registry: dict[str, ProcessPipeline] -) -> list[ProcessPipeline]: + path: Path, registry: dict[str, PipelineDescriptor] +) -> list[PipelineDescriptor]: raw_lines = path.read_text(encoding="utf-8").splitlines() - selected: list[ProcessPipeline] = [] + selected: list[PipelineDescriptor] = [] missing: list[str] = [] for line in raw_lines: name = line.strip() @@ -92,7 +97,7 @@ def _prepare_data_root( def _run_pipelines_on_file( h5_path: Path, - pipelines: Sequence[ProcessPipeline], + pipelines: Sequence[PipelineDescriptor], output_root: Path, ) -> list[Path]: outputs: list[Path] = [] @@ -101,7 +106,8 @@ def _run_pipelines_on_file( combined_h5_out = data_dir / f"{h5_path.stem}_pipelines_result.h5" pipeline_results: list[tuple[str, ProcessResult]] = [] with h5py.File(h5_path, "r") as h5file: - for pipeline in pipelines: + for pipeline_desc in pipelines: + pipeline = pipeline_desc.instantiate() result = pipeline.run(h5file) pipeline_results.append((pipeline.name, result)) print(f"[OK] {h5_path.name} -> {pipeline.name}") @@ -200,7 +206,7 @@ def run_cli( shutil.rmtree(work_tempdir_path, ignore_errors=True) -def main(argv: Iterable[str] | None = None) -> int: +def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Run AngioEye pipelines over a folder of HDF5 files." ) diff --git a/src/pipelines/__init__.py b/src/pipelines/__init__.py index 5731e90..e1ba317 100644 --- a/src/pipelines/__init__.py +++ b/src/pipelines/__init__.py @@ -4,35 +4,16 @@ import pkgutil # import inspect -from .core.base import PIPELINE_REGISTRY, ProcessPipeline, ProcessResult +from .core.base import ( + PIPELINE_REGISTRY, + MissingPipeline, + PipelineDescriptor, + ProcessPipeline, + ProcessResult, +) from .core.utils import write_combined_results_h5, write_result_h5 -class MissingPipeline(ProcessPipeline): - """Placeholder for pipelines whose dependencies are missing.""" - - available = False - missing_deps: list[str] - requires: list[str] - - def __init__( - self, name: str, description: str, missing_deps: list[str], requires: list[str] - ) -> None: - super().__init__() - self.name = name - self.description = description or "Pipeline unavailable (missing dependencies)." - self.missing_deps = missing_deps - self.requires = requires - - def run(self, h5file): - missing = ", ".join( - self.missing_deps or self.requires or ["unknown dependency"] - ) - raise ImportError( - f"Pipeline '{self.name}' unavailable. Missing dependencies: {missing}" - ) - - def _module_docstring(module_name: str) -> str: spec = importlib.util.find_spec(module_name) if not spec or not spec.origin: @@ -96,9 +77,9 @@ def _missing_requirements(requires: list[str]) -> list[str]: return missing -def _discover_pipelines() -> tuple[list[ProcessPipeline], list[MissingPipeline]]: - available: list[ProcessPipeline] = [] - missing: list[MissingPipeline] = [] +def _discover_pipelines() -> tuple[list[PipelineDescriptor], list[PipelineDescriptor]]: + available: list[PipelineDescriptor] = [] + missing: list[PipelineDescriptor] = [] # seen_classes = set() for module_info in pkgutil.iter_modules(__path__): @@ -124,28 +105,32 @@ def _discover_pipelines() -> tuple[list[ProcessPipeline], list[MissingPipeline]] except Exception as e: # Fallback for unknown failures (SyntaxError, etc.) missing.append( - MissingPipeline( - module_info.name, f"Error: {e}", ["Unknown"], ["Unknown"] + PipelineDescriptor( + name=module_info.name, + description=f"Import Error: {e}", + available=False, + error_msg=str(e), ) ) for _name, cls in PIPELINE_REGISTRY.items(): + desc = PipelineDescriptor( + name=cls.name, + description=cls.description, + available=cls.available, + requires=cls.requires, + missing_deps=cls.missing_deps, + pipeline_cls=cls, + ) if getattr(cls, "is_available", True): - inst = cls() + # inst = cls() # The GUI needs thoses values - inst.name = cls.name - inst.available = True - inst.requires = cls.required_deps - available.append(inst) + # inst.name = cls.name + # inst.available = True + # inst.requires = cls.required_deps + available.append(desc) else: - missing.append( - MissingPipeline( - name=getattr(cls, "name", cls.__name__), - description=getattr(cls, "description", ""), - missing_deps=getattr(cls, "missing_deps", []), - requires=getattr(cls, "required_deps", []), - ) - ) + missing.append(desc) # except ImportError as exc: # # Capture missing dependency if ModuleNotFoundError has a name. @@ -192,15 +177,23 @@ def _discover_pipelines() -> tuple[list[ProcessPipeline], list[MissingPipeline]] return available, missing -def load_all_pipelines(include_missing: bool = False) -> list[ProcessPipeline]: - """ - Discover and instantiate pipelines. Optionally include placeholders for missing deps. - """ - available, missing = _discover_pipelines() - return available + missing if include_missing else available +# def load_all_pipelines( +# include_missing: bool = False, +# ) -> list[type[ProcessPipeline] | MissingPipeline]: +# """ +# Discover pipelines. Optionally include placeholders for missing deps. +# """ +# available, missing = _discover_pipelines() +# # Cast to a common list type for the type checker +# combined: list[type[ProcessPipeline] | MissingPipeline] = list(available) +# if include_missing: +# combined.extend(missing) +# return combined -def load_pipeline_catalog() -> tuple[list[ProcessPipeline], list[MissingPipeline]]: +def load_pipeline_catalog() -> tuple[ + list[PipelineDescriptor], list[PipelineDescriptor] +]: """Return (available, missing) pipelines for UI/CLI surfaces.""" return _discover_pipelines() @@ -216,7 +209,7 @@ def load_pipeline_catalog() -> tuple[list[ProcessPipeline], list[MissingPipeline "ProcessResult", "write_result_h5", "write_combined_results_h5", - "load_all_pipelines", + # "load_all_pipelines", "load_pipeline_catalog", "MissingPipeline", *[_cls.__name__ for _cls in (p.__class__ for p in _AVAILABLE)], diff --git a/src/pipelines/core/__init__.py b/src/pipelines/core/__init__.py index 466a80c..3826dcc 100644 --- a/src/pipelines/core/__init__.py +++ b/src/pipelines/core/__init__.py @@ -1,4 +1,4 @@ -from .base import ProcessPipeline, ProcessResult +from .base import MissingPipeline, ProcessPipeline, ProcessResult from .utils import ( safe_h5_key, write_combined_results_h5, @@ -7,6 +7,7 @@ __all__ = [ "ProcessPipeline", + "MissingPipeline", "ProcessResult", "safe_h5_key", "write_result_h5", diff --git a/src/pipelines/core/base.py b/src/pipelines/core/base.py index b06a866..f78d362 100644 --- a/src/pipelines/core/base.py +++ b/src/pipelines/core/base.py @@ -1,6 +1,6 @@ import csv import importlib.util -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any import h5py @@ -17,11 +17,11 @@ def decorator(cls): # metadata for the class cls.name = name cls.description = description or getattr(cls, "description", "") - cls.required_deps = required_deps or [] + cls.requires = required_deps or [] # Check if requirements are missing in the current environment missing = [] - for req in cls.required_deps: + for req in cls.requires: # TODO: We should maybe include the version check # RM the version "torch>=2.0" -> "torch" pkg = req.split(">")[0].split("=")[0].split("<")[0].strip() @@ -30,7 +30,7 @@ def decorator(cls): missing.append(pkg) cls.missing_deps = missing - cls.is_available = len(missing) == 0 + cls.available = len(missing) == 0 # Add to registry PIPELINE_REGISTRY[name] = cls @@ -61,13 +61,46 @@ def with_attrs(data: Any, attrs: dict[str, Any]) -> DatasetValue: return DatasetValue(data=data, attrs=attrs) +# +==========================================================================+ # +# | PIPELINES CLASSES | # +# +==========================================================================+ # + + +@dataclass +class PipelineDescriptor: + name: str + description: str + available: bool + # To avoid Python Mutable Default Arguments + requires: list[str] = field(default_factory=list) + missing_deps: list[str] = field(default_factory=list) + pipeline_cls: type["ProcessPipeline"] | None = None + error_msg: str = "" + + def instantiate(self) -> "ProcessPipeline": + """Factory method to create the actual pipeline instance.""" + if not self.available or self.pipeline_cls is None: + return MissingPipeline( + self.name, + self.error_msg or self.description, + self.missing_deps, + self.requires, + ) + return self.pipeline_cls() + + class ProcessPipeline: - description: str = "" + name: str + description: str + available: bool + missing_deps: list[str] + requires: list[str] def __init__(self) -> None: # Derive the pipeline name from the module filename (e.g., basic_stats.py -> basic_stats). - module_name = (self.__class__.__module__ or "").rsplit(".", 1)[-1] - self.name: str = module_name or self.__class__.__name__ + if not getattr(self, "name", None): + module_name = (self.__class__.__module__ or "").rsplit(".", 1)[-1] + self.name: str = module_name or self.__class__.__name__ def run(self, h5file: h5py.File) -> ProcessResult: raise NotImplementedError @@ -80,3 +113,26 @@ def export(self, result: ProcessResult, output_path: str) -> str: for key, value in result.metrics.items(): writer.writerow([key, value]) return output_path + + +class MissingPipeline(ProcessPipeline): + """Placeholder for pipelines whose dependencies are missing.""" + + available = False + + def __init__( + self, name: str, description: str, missing_deps: list[str], requires: list[str] + ) -> None: + # super().__init__() + self.name = name + self.description = description or "Pipeline unavailable (missing dependencies)." + self.missing_deps = missing_deps + self.requires = requires + + def run(self, h5file): + missing = ", ".join( + self.missing_deps or self.requires or ["unknown dependency"] + ) + raise ImportError( + f"Pipeline '{self.name}' unavailable. Missing dependencies: {missing}" + ) From 3dd101ac2616353efb185f6af0e8ed915260e7c7 Mon Sep 17 00:00:00 2001 From: gregoire Date: Tue, 3 Feb 2026 14:27:25 +0100 Subject: [PATCH 30/71] First draft --- ...rterial_velocity_waveform_shape_metrics.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/pipelines/arterial_velocity_waveform_shape_metrics.py diff --git a/src/pipelines/arterial_velocity_waveform_shape_metrics.py b/src/pipelines/arterial_velocity_waveform_shape_metrics.py new file mode 100644 index 0000000..498d6a5 --- /dev/null +++ b/src/pipelines/arterial_velocity_waveform_shape_metrics.py @@ -0,0 +1,33 @@ +import numpy as np + +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs + + +@registerPipeline(name="arterialformshape") +class ArterialExample(ProcessPipeline): + """ + Tutorial pipeline showing the full surface area of a pipeline: + + - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. + - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. + - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. + - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). + - No input data is required; this pipeline is purely illustrative. + """ + + description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + v_raw = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" + v = "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" + T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + + def run(self, h5file) -> ProcessResult: + vraw_ds = np.asarray(h5file[self.v_raw]) + v_ds = np.asarray(h5file[self.v]) + t_ds = np.asarray(h5file[self.T]) + + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + metrics = {"vraw": vraw_ds, "vds": v_ds, "tds": t_ds} + + # Artifacts can store non-metric outputs (strings, paths, etc.). + + return ProcessResult(metrics=metrics) From 2cc1a178ec65084bc0f2f15667c1a0518df23e40 Mon Sep 17 00:00:00 2001 From: gregoire Date: Wed, 4 Feb 2026 11:18:14 +0100 Subject: [PATCH 31/71] arterial velocity add --- ...rterial_velocity_waveform_shape_metrics.py | 51 +++++++++++++++++-- src/pipelines/tauh_n10_per_beat.py | 6 +-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/pipelines/arterial_velocity_waveform_shape_metrics.py b/src/pipelines/arterial_velocity_waveform_shape_metrics.py index 498d6a5..aa9502a 100644 --- a/src/pipelines/arterial_velocity_waveform_shape_metrics.py +++ b/src/pipelines/arterial_velocity_waveform_shape_metrics.py @@ -19,14 +19,59 @@ class ArterialExample(ProcessPipeline): v_raw = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" v = "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + vmax = "/Artery/VelocityPerBeat/VmaxPerBeatBandLimited/value" + vmin = "/Artery/VelocityPerBeat/VminPerBeatBandLimited/value" def run(self, h5file) -> ProcessResult: - vraw_ds = np.asarray(h5file[self.v_raw]) - v_ds = np.asarray(h5file[self.v]) + vraw_ds_temp = np.asarray(h5file[self.v_raw]) + vraw_ds = np.maximum(vraw_ds_temp, 0) + v_ds_temp = np.asarray(h5file[self.v]) + v_ds = np.maximum(v_ds_temp, 0) t_ds = np.asarray(h5file[self.T]) + V_max = np.asarray(h5file[self.vmax]) + V_min = np.asarray(h5file[self.vmin]) + TMI_raw = [] + RTVI = [] + RTVI_raw = [] + for k in range(len(t_ds[0])): + D1_raw = np.sum(vraw_ds.T[k][:31]) + D2_raw = np.sum(vraw_ds.T[k][32:]) + D1 = np.sum(v_ds.T[k][:31]) + D2 = np.sum(v_ds.T[k][32:]) + RTVI.append(D1 / (D2 + 10 ** (-12))) + RTVI_raw.append(D1_raw / (D2_raw + 10 ** (-12))) + M_0 = np.sum(vraw_ds.T[k]) + M_1 = 0 + for i in range(len(vraw_ds.T[k])): + M_1 += vraw_ds[i][k] * i * t_ds[0][k] / 64 + TM1 = M_1 / (t_ds[0][k] * M_0) + TMI_raw.append(TM1) + TMI = [] + for k in range(len(t_ds[0])): + M_0 = np.sum(v_ds.T[k]) + M_1 = 0 + for i in range(len(vraw_ds.T[k])): + M_1 += v_ds[i][k] * i * t_ds[0][k] / 64 + TM1 = M_1 / (t_ds[0][k] * M_0) + TMI.append(TM1) + RI = [] + for i in range(len(V_max[0])): + RI_temp = 1 - (V_min[0][i] / V_max[0][i]) + RI.append(RI_temp) + RI_raw = [] + for i in range(len(V_max[0])): + RI_temp = 1 - (np.min(vraw_ds.T[i]) / np.max(vraw_ds.T[i])) + RI_raw.append(RI_temp) # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. - metrics = {"vraw": vraw_ds, "vds": v_ds, "tds": t_ds} + metrics = { + "TMI_raw": np.asarray(TMI_raw), + "TMI": np.asarray(TMI), + "RI": np.asarray(RI), + "RI_raw": np.asarray(RI_raw), + "RTVI": np.asarray(RTVI), + "RTVI_raw": np.asarray(RTVI_raw), + } # Artifacts can store non-metric outputs (strings, paths, etc.). diff --git a/src/pipelines/tauh_n10_per_beat.py b/src/pipelines/tauh_n10_per_beat.py index adb485e..400fa1c 100644 --- a/src/pipelines/tauh_n10_per_beat.py +++ b/src/pipelines/tauh_n10_per_beat.py @@ -32,9 +32,9 @@ def _compute_per_beat( n = self.harmonic_index prefix = vessel.lower() # Per-beat FFT amplitudes/phases and per-beat Vmax for the band-limited signal. - amp_path = f"{vessel}/PerBeat/VelocitySignalPerBeatFFT_abs/value" - phase_path = f"{vessel}/PerBeat/VelocitySignalPerBeatFFT_arg/value" - vmax_path = f"{vessel}/PerBeat/VmaxPerBeatBandLimited/value" + amp_path = f"{vessel}/VelocityPerBeat/VelocitySignalPerBeatFFT_abs/value" + phase_path = f"{vessel}/VelocityPerBeat/VelocitySignalPerBeatFFT_arg/value" + vmax_path = f"{vessel}/VelocityPerBeat/VmaxPerBeatBandLimited/value" freq_path = ( f"{vessel}/Velocity/WaveformAnalysis/syntheticSpectralAnalysis/" f"{'Arterial' if vessel.lower().startswith('arter') else 'Venous'}PeakFrequencies/value" From 0aa89fc2e56dc4570bca77f6e8282087638c3cb8 Mon Sep 17 00:00:00 2001 From: chloe Date: Wed, 4 Feb 2026 11:31:16 +0100 Subject: [PATCH 32/71] centroid time, RI, RTVI --- ...rterial_velocity_waveform_shape_metrics.py | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/pipelines/arterial_velocity_waveform_shape_metrics.py b/src/pipelines/arterial_velocity_waveform_shape_metrics.py index 498d6a5..a56ed27 100644 --- a/src/pipelines/arterial_velocity_waveform_shape_metrics.py +++ b/src/pipelines/arterial_velocity_waveform_shape_metrics.py @@ -23,10 +23,36 @@ class ArterialExample(ProcessPipeline): def run(self, h5file) -> ProcessResult: vraw_ds = np.asarray(h5file[self.v_raw]) v_ds = np.asarray(h5file[self.v]) + v_ds_max=np.maximum(v_ds,0) t_ds = np.asarray(h5file[self.T]) - + + centroid=[] + RI=[] + RTVI=[] + for k in range (len(v_ds_max[0])): + moment0=np.sum(v_ds_max.T[k]) + moment1=0 + for i in range (len(v_ds_max.T[k])): + moment1+=v_ds_max[i][k]*i*t_ds[0][k]/64 + + centroid_k=(moment1)/(moment0*t_ds[0][0]) + centroid.append(centroid_k) + + v_max=np.max(v_ds_max[k]) + v_min=np.min(v_ds_max[k]) + RI_k=1-(v_min/v_max) + RI.append(RI_k) + + epsilon=10**(-12) + moitie=len(v_ds_max.T[k])//2 + d1=np.sum(v_ds_max.T[k][:moitie]) + d2=np.sum(v_ds_max.T[k][moitie:]) + RTVI_k=d1/(d2+epsilon) + RTVI.append(RTVI_k) + + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. - metrics = {"vraw": vraw_ds, "vds": v_ds, "tds": t_ds} + metrics = {"centroid": np.array(centroid), "RI": np.array(RI), "RTVI": np.array(RTVI)} # Artifacts can store non-metric outputs (strings, paths, etc.). From 816b6a5ed386fbd3d70681e8149c15c9997da7ca Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Wed, 4 Feb 2026 13:43:58 +0100 Subject: [PATCH 33/71] Remove dead code and outdated helper functions --- src/pipelines/__init__.py | 144 +------------------------------------- 1 file changed, 3 insertions(+), 141 deletions(-) diff --git a/src/pipelines/__init__.py b/src/pipelines/__init__.py index e1ba317..0323d77 100644 --- a/src/pipelines/__init__.py +++ b/src/pipelines/__init__.py @@ -1,6 +1,4 @@ -import ast import importlib -import importlib.util import pkgutil # import inspect @@ -14,73 +12,9 @@ from .core.utils import write_combined_results_h5, write_result_h5 -def _module_docstring(module_name: str) -> str: - spec = importlib.util.find_spec(module_name) - if not spec or not spec.origin: - return "" - origin = spec.origin - # When frozen with PyInstaller, source files may not be present on disk (only .pyc). - if not origin.endswith((".py", ".pyw")): - return "" - try: - with open(origin, encoding="utf-8") as f: - source = f.read() - except OSError: - return "" - tree = ast.parse(source) - return ast.get_docstring(tree) or "" - - -def _parse_requires_from_source(module_name: str) -> list[str]: - spec = importlib.util.find_spec(module_name) - if not spec or not spec.origin: - return [] - origin = spec.origin - if not origin.endswith((".py", ".pyw")): - return [] - try: - with open(origin, encoding="utf-8") as f: - tree = ast.parse(f.read(), filename=origin) - except OSError: - return [] - for node in tree.body: - if isinstance(node, ast.Assign): - for target in node.targets: - if isinstance(target, ast.Name) and target.id == "REQUIRES": - if isinstance(node.value, (ast.List, ast.Tuple)): - vals = [] - for elt in node.value.elts: - if isinstance(elt, ast.Constant) and isinstance( - elt.value, str - ): - vals.append(elt.value) - return vals - return [] - - -def _normalize_req_name(req: str) -> str: - """Extract importable package name from a requirement string.""" - for sep in ("[", "==", ">=", "<=", "~=", "!=", ">", "<"): - if sep in req: - return req.split(sep, 1)[0] - return req - - -def _missing_requirements(requires: list[str]) -> list[str]: - missing: list[str] = [] - for req in requires: - pkg = _normalize_req_name(req).strip() - if not pkg: - continue - if importlib.util.find_spec(pkg) is None: - missing.append(pkg) - return missing - - def _discover_pipelines() -> tuple[list[PipelineDescriptor], list[PipelineDescriptor]]: available: list[PipelineDescriptor] = [] missing: list[PipelineDescriptor] = [] - # seen_classes = set() for module_info in pkgutil.iter_modules(__path__): if module_info.name in {"core"} or module_info.name.startswith("_"): @@ -88,18 +22,6 @@ def _discover_pipelines() -> tuple[list[PipelineDescriptor], list[PipelineDescri module_name = f"{__name__}.{module_info.name}" - # requires = _parse_requires_from_source(module_name) - - # First, check for missing requirements before importing heavy modules. - # pre_missing = _missing_requirements(requires) - - # if pre_missing: - # doc = _module_docstring(module_name) - # missing.append( - # MissingPipeline(module_info.name, doc, pre_missing, requires) - # ) - # continue - try: importlib.import_module(module_name) except Exception as e: @@ -123,77 +45,18 @@ def _discover_pipelines() -> tuple[list[PipelineDescriptor], list[PipelineDescri pipeline_cls=cls, ) if getattr(cls, "is_available", True): - # inst = cls() - # The GUI needs thoses values - # inst.name = cls.name - # inst.available = True - # inst.requires = cls.required_deps available.append(desc) else: missing.append(desc) - # except ImportError as exc: - # # Capture missing dependency if ModuleNotFoundError has a name. - # missing_deps = [] - # if ( - # isinstance(exc, ModuleNotFoundError) - # and exc.name - # and exc.name not in {module_name, module_info.name} - # ): - # missing_deps = [exc.name] - # if not missing_deps: - # missing_deps = requires - # missing.append( - # MissingPipeline(module_info.name, doc, missing_deps, requires) - # ) - # continue - - # module_requires = getattr(module, "REQUIRES", requires) - # post_missing = _missing_requirements(module_requires) - # if post_missing: - # missing.append( - # MissingPipeline(module_info.name, doc, post_missing, module_requires) - # ) - # continue - # for _, cls in inspect.getmembers(module, inspect.isclass): - # if not issubclass(cls, ProcessPipeline) or cls is ProcessPipeline: - # continue - # if cls.__module__ != module.__name__: - # continue - # if cls in seen_classes: - # continue - # seen_classes.add(cls) - # try: - # inst = cls() - # inst.available = True # type: ignore[attr-defined] - # inst.requires = module_requires # type: ignore[attr-defined] - # available.append(inst) - # except TypeError: - # # Skip classes requiring constructor args. - # continue - available.sort(key=lambda p: p.name.lower()) missing.sort(key=lambda p: p.name.lower()) return available, missing -# def load_all_pipelines( -# include_missing: bool = False, -# ) -> list[type[ProcessPipeline] | MissingPipeline]: -# """ -# Discover pipelines. Optionally include placeholders for missing deps. -# """ -# available, missing = _discover_pipelines() -# # Cast to a common list type for the type checker -# combined: list[type[ProcessPipeline] | MissingPipeline] = list(available) -# if include_missing: -# combined.extend(missing) -# return combined - - -def load_pipeline_catalog() -> tuple[ - list[PipelineDescriptor], list[PipelineDescriptor] -]: +def load_pipeline_catalog() -> ( + tuple[list[PipelineDescriptor], list[PipelineDescriptor]] +): """Return (available, missing) pipelines for UI/CLI surfaces.""" return _discover_pipelines() @@ -209,7 +72,6 @@ def load_pipeline_catalog() -> tuple[ "ProcessResult", "write_result_h5", "write_combined_results_h5", - # "load_all_pipelines", "load_pipeline_catalog", "MissingPipeline", *[_cls.__name__ for _cls in (p.__class__ for p in _AVAILABLE)], From cc0063c67e1d8474d2c2e646fdb06dfd90f1e486 Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Wed, 4 Feb 2026 13:52:36 +0100 Subject: [PATCH 34/71] lint --- src/pipelines/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pipelines/__init__.py b/src/pipelines/__init__.py index 0323d77..f081254 100644 --- a/src/pipelines/__init__.py +++ b/src/pipelines/__init__.py @@ -54,9 +54,9 @@ def _discover_pipelines() -> tuple[list[PipelineDescriptor], list[PipelineDescri return available, missing -def load_pipeline_catalog() -> ( - tuple[list[PipelineDescriptor], list[PipelineDescriptor]] -): +def load_pipeline_catalog() -> tuple[ + list[PipelineDescriptor], list[PipelineDescriptor] +]: """Return (available, missing) pipelines for UI/CLI surfaces.""" return _discover_pipelines() From bf2515b2cb157af9e169d260bd6ceaf961c484bb Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Wed, 4 Feb 2026 14:21:32 +0100 Subject: [PATCH 35/71] Remove Single file tab --- README.md | 2 +- src/angio_eye.py | 219 ++--------------------------------------------- 2 files changed, 6 insertions(+), 215 deletions(-) diff --git a/README.md b/README.md index c0e4d60..050caf8 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Launch the main application to process files interactively: ### GUI -The GUI is best for interactive analysis and exploring individual HDF5 files. +The GUI handles batch processing for folders, single .h5/.hdf5 files, or .zip archives and lets you run multiple pipelines at once. ```sh # Via the entry point diff --git a/src/angio_eye.py b/src/angio_eye.py index f1b193f..40cf38b 100644 --- a/src/angio_eye.py +++ b/src/angio_eye.py @@ -3,7 +3,6 @@ import tkinter as tk import zipfile from collections.abc import Sequence -from datetime import datetime from pathlib import Path from tkinter import filedialog, messagebox, ttk @@ -14,13 +13,8 @@ except ImportError: # optional dependency sv_ttk = None -from pipelines import ( - PipelineDescriptor, - ProcessPipeline, - ProcessResult, - load_pipeline_catalog, -) -from pipelines.core.utils import write_combined_results_h5, write_result_h5 +from pipelines import PipelineDescriptor, ProcessResult, load_pipeline_catalog +from pipelines.core.utils import write_combined_results_h5 class _Tooltip: @@ -70,13 +64,8 @@ def __init__(self) -> None: super().__init__() self.title("HDF5 Process") self.geometry("800x600") - self.h5_file: h5py.File | None = None self.pipeline_registry: dict[str, PipelineDescriptor] = {} self.pipeline_check_vars: dict[str, tk.BooleanVar] = {} - self.last_process_result: ProcessResult | None = None - self.last_process_pipeline: ProcessPipeline | None = None - self.output_dir_var = tk.StringVar(value=str(Path.cwd())) - self.last_output_dir: Path | None = None self.batch_input_var = tk.StringVar() self.batch_output_var = tk.StringVar(value=str(Path.cwd())) self.batch_zip_var = tk.BooleanVar(value=False) @@ -85,7 +74,6 @@ def __init__(self) -> None: self._apply_theme() self._build_ui() self._register_pipelines() - self._show_placeholder() self._reset_batch_output() def _apply_theme(self) -> None: @@ -133,68 +121,9 @@ def _apply_theme(self) -> None: self._accent_color = accent def _build_ui(self) -> None: - notebook = ttk.Notebook(self) - notebook.pack(fill="both", expand=True) - - single_tab = ttk.Frame(notebook, padding=10) - batch_tab = ttk.Frame(notebook, padding=10) - notebook.add(single_tab, text="Single file") - notebook.add(batch_tab, text="Batch") - - self._build_single_tab(single_tab) - self._build_batch_tab(batch_tab) - - def _build_single_tab(self, parent: ttk.Frame) -> None: - parent.columnconfigure(1, weight=1) - parent.rowconfigure(2, weight=1) - - top_bar = ttk.Frame(parent) - top_bar.grid(row=0, column=0, columnspan=3, sticky="ew", pady=(0, 8)) - open_btn = ttk.Button(top_bar, text="Open .h5 file", command=self.open_file) - open_btn.pack(side="left") - self.file_label = ttk.Label(top_bar, text="No file loaded", wraplength=500) - self.file_label.pack(side="left", padx=8) - - ttk.Label(parent, text="Pipeline").grid(row=1, column=0, sticky="w") - self.pipeline_var = tk.StringVar() - self.pipeline_combo = ttk.Combobox( - parent, textvariable=self.pipeline_var, state="readonly", width=40 - ) - self.pipeline_combo.grid(row=1, column=1, sticky="w") - run_btn = ttk.Button( - parent, text="Run pipeline", command=self.run_selected_pipeline - ) - run_btn.grid(row=1, column=2, sticky="w", padx=6) - - ttk.Label(parent, text="Result").grid(row=2, column=0, sticky="nw", pady=(8, 2)) - output_frame = ttk.Frame(parent) - output_frame.grid(row=2, column=1, columnspan=2, sticky="nsew") - output_frame.columnconfigure(0, weight=1) - output_frame.rowconfigure(0, weight=1) - self.process_output = tk.Text( - output_frame, - height=18, - state="disabled", - bg=self._text_bg, - fg=self._text_fg, - insertbackground=self._text_fg, - ) - output_scroll = ttk.Scrollbar( - output_frame, orient="vertical", command=self.process_output.yview - ) - self.process_output.configure(yscrollcommand=output_scroll.set) - self.process_output.grid(row=0, column=0, sticky="nsew") - output_scroll.grid(row=0, column=1, sticky="ns") - - export_frame = ttk.Frame(parent, padding=(0, 8, 0, 0)) - export_frame.grid(row=3, column=0, columnspan=3, sticky="ew") - export_frame.columnconfigure(1, weight=1) - ttk.Label(export_frame, text="Output folder").grid(row=0, column=0, sticky="w") - output_dir_entry = ttk.Entry(export_frame, textvariable=self.output_dir_var) - output_dir_entry.grid(row=0, column=1, sticky="ew", padx=4) - ttk.Button(export_frame, text="Browse", command=self.choose_output_dir).grid( - row=0, column=2, sticky="w" - ) + container = ttk.Frame(self, padding=10) + container.pack(fill="both", expand=True) + self._build_batch_tab(container) def _build_batch_tab(self, parent: ttk.Frame) -> None: parent.columnconfigure(1, weight=1) @@ -322,11 +251,6 @@ def _build_batch_tab(self, parent: ttk.Frame) -> None: def _register_pipelines(self) -> None: available, missing = load_pipeline_catalog() self.pipeline_registry = {p.name: p for p in available} - self.missing_pipelines = {p.name: p for p in missing} - self.pipeline_combo["values"] = list(self.pipeline_registry.keys()) - if available: - self.pipeline_combo.current(0) - self.pipeline_var.set(available[0].name) self._populate_pipeline_checks(available, missing) def _populate_pipeline_checks( @@ -362,14 +286,6 @@ def _populate_pipeline_checks( ) self.pipeline_check_vars[pipeline.name] = var - def _show_placeholder( - self, message: str = "Load a .h5 file then run a pipeline" - ) -> None: - self.process_output.configure(state="normal") - self.process_output.delete("1.0", "end") - self.process_output.insert("end", message) - self.process_output.configure(state="disabled") - def _reset_batch_output( self, message: str = "Select an input path, choose pipelines, then run batch." ) -> None: @@ -396,82 +312,6 @@ def clear_all_pipelines(self) -> None: if getattr(var, "_enabled", True): var.set(False) - def open_file(self) -> None: - path = filedialog.askopenfilename( - filetypes=[("HDF5", "*.h5 *.hdf5"), ("All files", "*.*")], - initialdir=os.path.abspath("h5_example"), - ) - if not path: - return - try: - if self.h5_file is not None: - self.h5_file.close() - self.h5_file = h5py.File(path, "r") - except Exception as exc: # noqa: BLE001 - messagebox.showerror("Error", f"Cannot open {path}: {exc}") - return - self.file_label.config(text=path) - self.last_process_result = None - self.last_process_pipeline = None - self.last_output_dir = None - self._show_placeholder("File loaded. Pick a pipeline and run.") - - def run_selected_pipeline(self) -> None: - name = self.pipeline_var.get() - if not name: - messagebox.showwarning( - "Missing pipeline", "Select a pipeline before running." - ) - return - pipeline_desc = self.pipeline_registry.get(name) - if pipeline_desc is None: - messagebox.showerror( - "Pipeline missing", f"Pipeline '{name}' is not registered." - ) - return - if self.h5_file is None: - messagebox.showwarning("Missing file", "Load a .h5 file first.") - return - try: - pipeline = pipeline_desc.instantiate() - result = pipeline.run(self.h5_file) - except Exception as exc: # noqa: BLE001 - messagebox.showerror("Pipeline error", f"Pipeline failed: {exc}") - return - try: - output_dir = self._prepare_output_dir() - output_path = self._default_output_path(name, output_dir) - self._write_result_h5(result, output_path, pipeline_name=name) - result.output_h5_path = output_path - self.last_output_dir = output_dir - except Exception as exc: # noqa: BLE001 - messagebox.showerror("Output error", f"Cannot write outputs: {exc}") - return - self.last_process_result = result - self.last_process_pipeline = pipeline - file_label = self.h5_file.filename or "(in-memory file)" - self._render_process_result(result, pipeline_name=name, file_path=file_label) - - def _render_process_result( - self, result: ProcessResult, pipeline_name: str, file_path: str - ) -> None: - self.process_output.configure(state="normal") - self.process_output.delete("1.0", "end") - self.process_output.insert("end", f"Pipeline: {pipeline_name}\n") - self.process_output.insert("end", f"File: {file_path}\n\n") - self.process_output.insert("end", "Metrics:\n") - for key, value in result.metrics.items(): - self.process_output.insert("end", f" - {key}: {value}\n") - if result.artifacts: - self.process_output.insert("end", "\nArtifacts:\n") - for key, value in result.artifacts.items(): - self.process_output.insert("end", f" - {key}: {value}\n") - if result.output_h5_path: - self.process_output.insert( - "end", f"\nResult HDF5: {result.output_h5_path}\n" - ) - self.process_output.configure(state="disabled") - def choose_batch_folder(self) -> None: path = filedialog.askdirectory( initialdir=self.batch_input_var.get() or None, @@ -497,14 +337,6 @@ def choose_batch_output(self) -> None: if path: self.batch_output_var.set(path) - def choose_output_dir(self) -> None: - path = filedialog.askdirectory( - initialdir=self.output_dir_var.get() or None, - title="Select base folder for outputs", - ) - if path: - self.output_dir_var.set(path) - def run_batch(self) -> None: data_value = (self.batch_input_var.get() or "").strip() if not data_value: @@ -639,12 +471,6 @@ def _find_h5_inputs(self, path: Path) -> list[Path]: return files raise FileNotFoundError(f"Input path does not exist: {path}") - def _safe_pipeline_suffix(self, name: str) -> str: - cleaned = "".join(ch if ch.isalnum() else "_" for ch in name.lower()) - while "__" in cleaned: - cleaned = cleaned.replace("__", "_") - return cleaned.strip("_") or "pipeline" - def _run_pipelines_on_file( self, h5_path: Path, @@ -670,31 +496,6 @@ def _run_pipelines_on_file( f"[OK] {h5_path.name}: combined results -> {combined_h5_out.name}" ) - def _prepare_output_dir(self) -> Path: - base_dir_value = (self.output_dir_var.get() or "").strip() - base_dir = Path(base_dir_value).expanduser() if base_dir_value else Path.cwd() - if not base_dir.is_absolute(): - base_dir = Path.cwd() / base_dir - base_dir.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - base_name = ( - Path(self.h5_file.filename).stem - if self.h5_file and self.h5_file.filename - else "output" - ) - output_dir = base_dir / f"{base_name}_{timestamp}" - output_dir.mkdir(parents=True, exist_ok=True) - return output_dir - - def _default_output_path(self, pipeline_name: str, output_dir: Path) -> str: - safe_name = self._safe_pipeline_suffix(pipeline_name) - base = ( - Path(self.h5_file.filename).stem - if self.h5_file and self.h5_file.filename - else "output" - ) - return str(output_dir / f"{base}_{safe_name}_result.h5") - def _zip_output_dir(self, folder: Path, target_path: Path | None = None) -> Path: folder = folder.expanduser().resolve() if not folder.exists() or not folder.is_dir(): @@ -712,16 +513,6 @@ def _zip_output_dir(self, folder: Path, target_path: Path | None = None) -> Path zf.write(file_path, file_path.relative_to(folder)) return zip_path - def _write_result_h5( - self, result: ProcessResult, path: str, pipeline_name: str - ) -> None: - source_file = ( - self.h5_file.filename if self.h5_file and self.h5_file.filename else None - ) - write_result_h5( - result, path, pipeline_name=pipeline_name, source_file=source_file - ) - def main(): app = ProcessApp() From ae312a6f37a91c3d38b0f463ee84269940fd24ad Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Wed, 4 Feb 2026 14:35:42 +0100 Subject: [PATCH 36/71] remove intermediate output folders --- README.md | 2 +- src/angio_eye.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 050caf8..21fc785 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Launch the main application to process files interactively: ### GUI -The GUI handles batch processing for folders, single .h5/.hdf5 files, or .zip archives and lets you run multiple pipelines at once. +The GUI handles batch processing for folders, single .h5/.hdf5 files, or .zip archives and lets you run multiple pipelines at once. Batch outputs are written directly into the chosen output directory (one combined `.h5` per input file). ```sh # Via the entry point diff --git a/src/angio_eye.py b/src/angio_eye.py index 40cf38b..bd473b3 100644 --- a/src/angio_eye.py +++ b/src/angio_eye.py @@ -477,9 +477,15 @@ def _run_pipelines_on_file( pipelines: Sequence[PipelineDescriptor], output_root: Path, ) -> None: - data_dir = output_root / h5_path.stem - data_dir.mkdir(parents=True, exist_ok=True) - combined_h5_out = data_dir / f"{h5_path.stem}_pipelines_result.h5" + # Place combined output directly in the output root (no per-file subfolder). + combined_h5_out = output_root / f"{h5_path.stem}_pipelines_result.h5" + suffix = 1 + while combined_h5_out.exists(): + combined_h5_out = ( + output_root / f"{h5_path.stem}_{suffix}_pipelines_result.h5" + ) + suffix += 1 + pipeline_results: list[tuple[str, ProcessResult]] = [] with h5py.File(h5_path, "r") as h5file: for pipeline_desc in pipelines: @@ -492,9 +498,7 @@ def _run_pipelines_on_file( ) for _, result in pipeline_results: result.output_h5_path = str(combined_h5_out) - self._log_batch( - f"[OK] {h5_path.name}: combined results -> {combined_h5_out.name}" - ) + self._log_batch(f"[OK] {h5_path.name}: combined results -> {combined_h5_out}") def _zip_output_dir(self, folder: Path, target_path: Path | None = None) -> Path: folder = folder.expanduser().resolve() From d158c87ad26b5f768d5ff8f818f434630da8338b Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Wed, 4 Feb 2026 16:26:21 +0100 Subject: [PATCH 37/71] fix typo in h5 output --- src/pipelines/core/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pipelines/core/utils.py b/src/pipelines/core/utils.py index e425185..cbf3502 100644 --- a/src/pipelines/core/utils.py +++ b/src/pipelines/core/utils.py @@ -33,9 +33,9 @@ def _copy_input_contents(source_file: str | Path | None, dest: h5py.File) -> Non def _ensure_pipelines_group(h5file: h5py.File) -> h5py.Group: """Return a pipelines group, creating it when missing.""" return ( - h5file["pipelines"] - if "pipelines" in h5file - else h5file.create_group("pipelines") + h5file["Pipelines"] + if "Pipelines" in h5file + else h5file.create_group("Pipelines") ) From 250256253207ccdc3a13f4974b85a9e97a694d9c Mon Sep 17 00:00:00 2001 From: chloe Date: Wed, 4 Feb 2026 17:05:19 +0100 Subject: [PATCH 38/71] TMI, RI,RVTI --- .../arterial_velocity_waveform_shape_metrics.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/pipelines/arterial_velocity_waveform_shape_metrics.py b/src/pipelines/arterial_velocity_waveform_shape_metrics.py index a56ed27..8164f12 100644 --- a/src/pipelines/arterial_velocity_waveform_shape_metrics.py +++ b/src/pipelines/arterial_velocity_waveform_shape_metrics.py @@ -21,14 +21,14 @@ class ArterialExample(ProcessPipeline): T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" def run(self, h5file) -> ProcessResult: - vraw_ds = np.asarray(h5file[self.v_raw]) + v_ds = np.asarray(h5file[self.v]) v_ds_max=np.maximum(v_ds,0) t_ds = np.asarray(h5file[self.T]) centroid=[] RI=[] - RTVI=[] + RVTI=[] for k in range (len(v_ds_max[0])): moment0=np.sum(v_ds_max.T[k]) moment1=0 @@ -47,13 +47,16 @@ def run(self, h5file) -> ProcessResult: moitie=len(v_ds_max.T[k])//2 d1=np.sum(v_ds_max.T[k][:moitie]) d2=np.sum(v_ds_max.T[k][moitie:]) - RTVI_k=d1/(d2+epsilon) - RTVI.append(RTVI_k) - + RVTI_k=d1/(d2+epsilon) + RVTI.append(RVTI_k) + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. - metrics = {"centroid": np.array(centroid), "RI": np.array(RI), "RTVI": np.array(RTVI)} - + + metrics = {"centroid": with_attrs(np.asarray(centroid),{"unit": [""],},), + "RI": with_attrs(np.asarray(RI),{"unit": [""],},), + "RTVI": with_attrs(np.asarray(RVTI),{"unit": [""],},), + } # Artifacts can store non-metric outputs (strings, paths, etc.). return ProcessResult(metrics=metrics) From 3ef574213a12a3f5ef05e0a4e087f80f47413210 Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Wed, 4 Feb 2026 17:17:23 +0100 Subject: [PATCH 39/71] lint --- ...rterial_velocity_waveform_shape_metrics.py | 73 +++++++++++-------- 1 file changed, 44 insertions(+), 29 deletions(-) diff --git a/src/pipelines/arterial_velocity_waveform_shape_metrics.py b/src/pipelines/arterial_velocity_waveform_shape_metrics.py index 8164f12..5dc44dc 100644 --- a/src/pipelines/arterial_velocity_waveform_shape_metrics.py +++ b/src/pipelines/arterial_velocity_waveform_shape_metrics.py @@ -21,42 +21,57 @@ class ArterialExample(ProcessPipeline): T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" def run(self, h5file) -> ProcessResult: - + v_ds = np.asarray(h5file[self.v]) - v_ds_max=np.maximum(v_ds,0) + v_ds_max = np.maximum(v_ds, 0) t_ds = np.asarray(h5file[self.T]) - - centroid=[] - RI=[] - RVTI=[] - for k in range (len(v_ds_max[0])): - moment0=np.sum(v_ds_max.T[k]) - moment1=0 - for i in range (len(v_ds_max.T[k])): - moment1+=v_ds_max[i][k]*i*t_ds[0][k]/64 - - centroid_k=(moment1)/(moment0*t_ds[0][0]) + + centroid = [] + RI = [] + RVTI = [] + for k in range(len(v_ds_max[0])): + moment0 = np.sum(v_ds_max.T[k]) + moment1 = 0 + for i in range(len(v_ds_max.T[k])): + moment1 += v_ds_max[i][k] * i * t_ds[0][k] / 64 + + centroid_k = (moment1) / (moment0 * t_ds[0][0]) centroid.append(centroid_k) - - v_max=np.max(v_ds_max[k]) - v_min=np.min(v_ds_max[k]) - RI_k=1-(v_min/v_max) + + v_max = np.max(v_ds_max[k]) + v_min = np.min(v_ds_max[k]) + RI_k = 1 - (v_min / v_max) RI.append(RI_k) - epsilon=10**(-12) - moitie=len(v_ds_max.T[k])//2 - d1=np.sum(v_ds_max.T[k][:moitie]) - d2=np.sum(v_ds_max.T[k][moitie:]) - RVTI_k=d1/(d2+epsilon) + epsilon = 10 ** (-12) + moitie = len(v_ds_max.T[k]) // 2 + d1 = np.sum(v_ds_max.T[k][:moitie]) + d2 = np.sum(v_ds_max.T[k][moitie:]) + RVTI_k = d1 / (d2 + epsilon) RVTI.append(RVTI_k) - - + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. - - metrics = {"centroid": with_attrs(np.asarray(centroid),{"unit": [""],},), - "RI": with_attrs(np.asarray(RI),{"unit": [""],},), - "RTVI": with_attrs(np.asarray(RVTI),{"unit": [""],},), - } + + metrics = { + "centroid": with_attrs( + np.asarray(centroid), + { + "unit": [""], + }, + ), + "RI": with_attrs( + np.asarray(RI), + { + "unit": [""], + }, + ), + "RTVI": with_attrs( + np.asarray(RVTI), + { + "unit": [""], + }, + ), + } # Artifacts can store non-metric outputs (strings, paths, etc.). return ProcessResult(metrics=metrics) From 23958c00d10518f31a17899cc73465a1e84af03e Mon Sep 17 00:00:00 2001 From: gregoire Date: Wed, 4 Feb 2026 17:54:07 +0100 Subject: [PATCH 40/71] metrics per branch pipeline added --- src/pipelines/arterial_velocity_segment.py | 96 +++++++++++++++++++ ...rterial_velocity_waveform_shape_metrics.py | 2 +- 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/pipelines/arterial_velocity_segment.py diff --git a/src/pipelines/arterial_velocity_segment.py b/src/pipelines/arterial_velocity_segment.py new file mode 100644 index 0000000..660d705 --- /dev/null +++ b/src/pipelines/arterial_velocity_segment.py @@ -0,0 +1,96 @@ +import numpy as np + +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs + + +@registerPipeline(name="arterialformshapesegment") +class ArterialExampleSegment(ProcessPipeline): + """ + Tutorial pipeline showing the full surface area of a pipeline: + + - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. + - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. + - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. + - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). + - No input data is required; this pipeline is purely illustrative. + """ + + description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + v_raw = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + v = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" + T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + + def run(self, h5file) -> ProcessResult: + vraw_ds_temp = np.asarray(h5file[self.v_raw]) + vraw_ds = np.maximum(vraw_ds_temp, 0) + v_ds_temp = np.asarray(h5file[self.v]) + v_ds = np.maximum(v_ds_temp, 0) + t_ds = np.asarray(h5file[self.T]) + + TMI_seg = [] + TMI_seg_band = [] + RTVI_seg = [] + RTVI_seg_band = [] + RI_seg = [] + RI_seg_band = [] + M0_seg = 0 + M1_seg = 0 + M0_seg_band = 0 + M1_seg_band = 0 + for k in range(len(vraw_ds[0, :, 0, 0])): + TMI_branch = [] + TMI_branch_band = [] + RTVI_band_branch = [] + RTVI_branch = [] + RI_branch = [] + RI_branch_band = [] + for i in range(len(vraw_ds[0, k, :, 0])): + avg_speed_band = np.nanmean(v_ds[:, k, i, :], axis=1) + avg_speed = np.nanmean(vraw_ds[:, k, i, :], axis=1) + vmin = np.min(avg_speed) + vmax = np.max(avg_speed) + vmin_band = np.min(avg_speed_band) + vmax_band = np.max(avg_speed_band) + + RI_branch.append(1 - (vmin / (vmax + 10 ** (-14)))) + RI_branch_band.append(1 - (vmin_band / (vmax_band + 10 ** (-14)))) + D1_raw = np.sum(avg_speed[:31]) + D2_raw = np.sum(avg_speed[32:]) + D1 = np.sum(avg_speed_band[:31]) + D2 = np.sum(avg_speed_band[32:]) + RTVI_band_branch.append(D1 / (D2 + 10 ** (-12))) + RTVI_branch.append(D1_raw / (D2_raw + 10 ** (-12))) + M0_seg += np.sum(avg_speed) + M0_seg_band += np.sum(avg_speed_band) + for j in range(len(avg_speed)): + M1_seg += avg_speed[j] * j * t_ds[0][k] / 64 + M1_seg_band += avg_speed_band[j] * j * t_ds[0][k] / 64 + if M0_seg != 0: + TMI_branch.append(M1_seg / (t_ds[0][k] * M0_seg)) + else: + TMI_branch.append(0) + if M0_seg_band != 0: + TMI_branch_band.append(M1_seg_band / (t_ds[0][k] * M0_seg_band)) + else: + TMI_branch_band.append(0) + + TMI_seg.append(TMI_branch) + TMI_seg_band.append(TMI_branch_band) + RI_seg.append(RI_branch) + RI_seg_band.append(RI_branch_band) + RTVI_seg.append(RTVI_branch) + RTVI_seg_band.append(RTVI_band_branch) + + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + metrics = { + "TMI_raw_seg": with_attrs(np.asarray(TMI_seg), {"unit": [""]}), + "TMI_seg": np.asarray(TMI_seg_band), + "RI": np.asarray(RI_seg_band), + "RI_raw": np.asarray(RI_seg), + "RTVI_band": np.asarray(RTVI_seg_band), + "RTVI": np.asarray(RTVI_seg), + } + + # Artifacts can store non-metric outputs (strings, paths, etc.). + + return ProcessResult(metrics=metrics) diff --git a/src/pipelines/arterial_velocity_waveform_shape_metrics.py b/src/pipelines/arterial_velocity_waveform_shape_metrics.py index aa9502a..02c714a 100644 --- a/src/pipelines/arterial_velocity_waveform_shape_metrics.py +++ b/src/pipelines/arterial_velocity_waveform_shape_metrics.py @@ -65,7 +65,7 @@ def run(self, h5file) -> ProcessResult: # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. metrics = { - "TMI_raw": np.asarray(TMI_raw), + "TMI_raw": with_attrs(np.asarray(TMI_raw), {"unit": [""]}), "TMI": np.asarray(TMI), "RI": np.asarray(RI), "RI_raw": np.asarray(RI_raw), From 779e132aaf08429b6f423c09f17789955a53f453 Mon Sep 17 00:00:00 2001 From: chloe Date: Thu, 5 Feb 2026 11:36:53 +0100 Subject: [PATCH 41/71] TMI, RI, RTVI for each branch --- src/pipelines/Segments.py | 140 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/pipelines/Segments.py diff --git a/src/pipelines/Segments.py b/src/pipelines/Segments.py new file mode 100644 index 0000000..99eb876 --- /dev/null +++ b/src/pipelines/Segments.py @@ -0,0 +1,140 @@ +import numpy as np + +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs + + +@registerPipeline(name="segmentformshape") +class ArterialSegExample(ProcessPipeline): + """ + Tutorial pipeline showing the full surface area of a pipeline: + + - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. + - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. + - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. + - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). + - No input data is required; this pipeline is purely illustrative. + """ + + description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + v_raw = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + v = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" + T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + + def run(self, h5file) -> ProcessResult: + vraw_ds = np.asarray(h5file[self.v_raw]) + v_ds = np.asarray(h5file[self.v]) + t_ds = np.asarray(h5file[self.T]) + + moment0_seg = 0 + moment1_seg = 0 + + TMI_seg = [] + RI_seg = [] + RVTI_seg = [] + for beat in range(len(v_ds[0, :, 0, 0])): + TMI_branch = [] + RI_branch = [] + RVTI_branch = [] + for branch in range(len(v_ds[0, beat, :, 0])): + speed = np.nanmean(v_ds[:, beat, branch, :], axis=1) + moment0_seg += np.sum(speed) + for i in range(len(speed)): + moment1_seg += speed[i] * i * t_ds[0][beat] / 64 + centroid_seg_branch = (moment1_seg) / (moment0_seg * t_ds[0][0]) + TMI_branch.append(centroid_seg_branch) + + speed_max = np.max(speed) + speed_min = np.min(speed) + RI_k = 1 - (speed_min / speed_max) + RI_branch.append(RI_k) + + epsilon = 10 ** (-12) + moitie = len(speed) // 2 + d1 = np.sum(speed[:moitie]) + d2 = np.sum(speed[moitie:]) + RVTI_k = d1 / (d2 + epsilon) + RVTI_branch.append(RVTI_k) + + RI_seg.append(RI_branch) + TMI_seg.append(TMI_branch) + RVTI_seg.append(RVTI_branch) + + moment0raw_seg = 0 + moment1raw_seg = 0 + + TMIraw_seg = [] + RIraw_seg = [] + RVTIraw_seg = [] + for beat in range(len(vraw_ds[0, :, 0, 0])): + TMIraw_branch = [] + RIraw_branch = [] + RVTIraw_branch = [] + for branch in range(len(vraw_ds[0, beat, :, 0])): + speed_raw = np.nanmean(vraw_ds[:, beat, branch, :], axis=1) + moment0raw_seg += np.sum(speed_raw) + for i in range(len(speed_raw)): + moment1raw_seg += speed_raw[i] * i * t_ds[0][beat] / 64 + centroidraw_seg_branch = (moment1raw_seg) / ( + moment0raw_seg * t_ds[0][0] + ) + TMIraw_branch.append(centroidraw_seg_branch) + + speedraw_max = np.max(speed_raw) + speedraw_min = np.min(speed_raw) + RIraw_k = 1 - (speedraw_min / speedraw_max) + RIraw_branch.append(RIraw_k) + + epsilon = 10 ** (-12) + moitie = len(speed_raw) // 2 + d1raw = np.sum(speed_raw[:moitie]) + d2raw = np.sum(speed_raw[moitie:]) + RVTIraw_k = d1raw / (d2raw + epsilon) + RVTIraw_branch.append(RVTIraw_k) + + RIraw_seg.append(RIraw_branch) + TMIraw_seg.append(TMIraw_branch) + RVTIraw_seg.append(RVTIraw_branch) + + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + metrics = { + "centroid": with_attrs( + np.asarray(TMI_seg), + { + "unit": [""], + }, + ), + "RI": with_attrs( + np.asarray(RI_seg), + { + "unit": [""], + }, + ), + "RTVI": with_attrs( + np.asarray(RVTI_seg), + { + "unit": [""], + }, + ), + "centroid raw": with_attrs( + np.asarray(TMIraw_seg), + { + "unit": [""], + }, + ), + "RI raw": with_attrs( + np.asarray(RIraw_seg), + { + "unit": [""], + }, + ), + "RTVI raw": with_attrs( + np.asarray(RVTIraw_seg), + { + "unit": [""], + }, + ), + } + + # Artifacts can store non-metric outputs (strings, paths, etc.). + + return ProcessResult(metrics=metrics) From 980b007da51d8174f7ae041a809e8856b73c7109 Mon Sep 17 00:00:00 2001 From: gregoire Date: Thu, 5 Feb 2026 17:32:38 +0100 Subject: [PATCH 42/71] Core pulse-shape metrics from complex harmonics --- src/pipelines/pulsewaveformshapemetrics .py | 309 ++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 src/pipelines/pulsewaveformshapemetrics .py diff --git a/src/pipelines/pulsewaveformshapemetrics .py b/src/pipelines/pulsewaveformshapemetrics .py new file mode 100644 index 0000000..cf78ef4 --- /dev/null +++ b/src/pipelines/pulsewaveformshapemetrics .py @@ -0,0 +1,309 @@ +import numpy as np + +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs + + +@registerPipeline(name="waveform") +class WaveForm(ProcessPipeline): + """ + Tutorial pipeline showing the full surface area of a pipeline: + + - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. + - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. + - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. + - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). + - No input data is required; this pipeline is purely illustrative. + """ + + description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + v_raw = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" + v = "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" + T_val = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + vmax = "/Artery/VelocityPerBeat/VmaxPerBeatBandLimited/value" + vmin = "/Artery/VelocityPerBeat/VminPerBeatBandLimited/value" + + def run(self, h5file) -> ProcessResult: + vraw_ds_temp = np.asarray(h5file[self.v_raw]) + vraw_ds = np.maximum(vraw_ds_temp, 0) + v_ds_temp = np.asarray(h5file[self.v]) + v_ds = np.maximum(v_ds_temp, 0) + t_ds = np.asarray(h5file[self.T_val]) + V_max = np.asarray(h5file[self.vmax]) + V_min = np.asarray(h5file[self.vmin]) + N = len(vraw_ds[:, 0]) + # période normalisée (1 beat) + # pulsation fondamentale + + V_coeff = [] + Xn = [] + H2 = [] + Delta_Phi_2 = [] + Delta_Phi_3 = [] + R10 = [] + HRI_2_10 = [] + HRI_2_10_noisecorrec = [] + S1_10 = [] + nc = [] + Sigma_n = [] + Hspec = [] + Fspec = [] + tau_phi = [] + tau_phi_n = [] + tau_G = [] + BV = [] + tau_M1 = [] + sigma_M = [] + TAU_H_N = [] + for i in range(len(vraw_ds[0])): + T = t_ds[0][i] + omega0 = 2 * np.pi / T + t = np.linspace(0, T, N, endpoint=False) + Vfft = np.fft.fft(vraw_ds[:, i]) / N + Vn = Vfft[:11] # harmonics + V_coeff.append(Vn) + Xn.append(Vn[1:] / Vn[1]) + H2.append(np.abs(Xn[i][1])) + phi = np.angle(Xn[i]) + absV = np.abs(Vn) + Delta_Phi_2.append(np.angle(Vn[2] * np.conj(Vn[1]) ** 2)) + Delta_Phi_3.append(np.angle(Vn[3] * np.conj(Vn[1]) ** 3)) + R10.append(np.abs(Vn[1]) / np.real(Vn[0])) + HRI_2_10.append(np.sum(np.abs(Xn[i][2:]))) + magnitudes = absV[1:11] + phi2 = np.angle(Xn[i][1:]) + absX2 = np.abs(Xn[i][1:]) + p = magnitudes / np.sum(magnitudes) + n = np.arange(1, 11) + n2 = np.arange(2, len(Xn[i]) + 1) + valid = magnitudes > 0 + + if np.sum(valid) < 3: + slope = np.nan + + else: + log_n = np.log(n[valid]) + log_mag = np.log(magnitudes[valid]) + + slope, _ = np.polyfit(log_n, log_mag, 1) + S1_10.append(slope) + HRI_2_10_noisecorrec.append([]) + nc_i = np.sum(n * p) + nc.append(nc_i) + Sigma_n.append(np.sqrt(np.sum((n - nc_i) ** 2 * p))) + Hspec.append(-np.sum(p * np.log(p + 1e-12))) + Fspec.append(np.exp(np.mean(np.log(p + 1e-12))) / np.mean(p)) + """omega_n = n2 * omega0 + + tau_phi_n_i = -phi2 / omega_n + tau_phi_n.append(tau_phi_n_i) + if np.sum(np.isfinite(tau_phi_n_i)) >= 2: + tau_phi.append(np.median(tau_phi_n_i)) + else: + tau_phi.append(np.nan) + phi_unwrap = np.unwrap(phi2) + + A = np.column_stack([-omega_n, np.ones_like(omega_n)]) + params, _, _, _ = np.linalg.lstsq(A, phi_unwrap, rcond=None) + + tau_G.append(params[0]) + bv = np.real(Vn[0]) * np.ones_like(t) + + for k in range(1, 11): + bv += ( + Vn[k] * np.exp(1j * k * omega0 * t) + + np.conj(Vn[k]) * np.exp(-1j * k * omega0 * t) + ).real + BV.append(bv) + vbase = np.min(bv) + w = np.maximum(bv - vbase, 0) + if np.sum(w) > 0: + tau_M1.append(np.sum(w * t) / np.sum(w)) + else: + tau_M1.append(np.nan) + if np.sum(w) > 0: + t2 = np.sum(w * t**2) / np.sum(w) + sigma_M.append(np.sqrt(t2 - tau_M1**2)) + else: + sigma_M.append(np.nan) + + tau_H_n = np.full_like(absX2, np.nan, dtype=float) + + valid2 = absX2 <= 1 + idx2 = np.where(valid2)[0] + tau_H_n[idx2] = (1 / (omega_n[idx2])) * np.sqrt( + (1 / (absX2[valid2] ** 2)) - 1 + ) + TAU_H_N.append(tau_H_n)""" + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + metrics = { + "Xn": with_attrs( + np.asarray(Xn), + { + "unit": [""], + "description": [""], + }, + ), + "H2": with_attrs( + np.asarray(H2), + { + "unit": [""], + "description": [ + "Strength of the second harmonic relative to the fundamental" + ], + }, + ), + "Delta_Phi_2": with_attrs( + np.asarray(Delta_Phi_2), + { + "unit": ["rad"], + "interval": ["-pi , pi"], + "description": ["Global-shift-invariant “shape phase” at n = 2"], + }, + ), + "Delta_Phi_3": with_attrs( + np.asarray(Delta_Phi_3), + { + "unit": [""], + "description": ["Global-shift-invariant “shape phase” at n = 3"], + }, + ), + "R10": with_attrs( + np.asarray(R10), + { + "unit": [""], + "description": ["Pulsatility relative to mean level"], + }, + ), + "HRI_2_10": with_attrs( + np.asarray(HRI_2_10), + { + "unit": [""], + "description": [ + "Relative high-frequency content beyond the fundamental" + ], + }, + ), + "HRI_2_10_noisecorrec": with_attrs( + np.asarray(HRI_2_10_noisecorrec), + { + "unit": [""], + "nb": ["not computed yet"], + "description": [ + "Richness corrected for an estimated noise floor to reduce bias when high harmonics approach noise" + ], + }, + ), + "S1_10": with_attrs( + np.asarray(S1_10), + { + "unit": [""], + "description": [ + "Compact damping descriptor: slope of a linear fit of log " + ], + }, + ), + "nc": with_attrs( + np.asarray(nc), + { + "unit": [""], + "description": ["nergy location across harmonics using pn"], + }, + ), + "Sigma_n": with_attrs( + np.asarray(Sigma_n), + { + "unit": [""], + "description": ["Spread of harmonic content around nc"], + }, + ), + "Hspec": with_attrs( + np.asarray(Hspec), + { + "unit": [""], + "description": [ + "Complexity of harmonic distribution: higher values indicate more evenly distributed energy" + ], + }, + ), + "Fspec": with_attrs( + np.asarray(Fspec), + { + "unit": [""], + "description": [ + "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + ], + }, + ), + "tau_phi": with_attrs( + np.asarray(tau_phi), + { + "unit": [""], + "description": [ + "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + ], + }, + ), + "tau_phi_n": with_attrs( + np.asarray(tau_phi_n), + { + "unit": [""], + "description": [ + "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + ], + }, + ), + "tau_G": with_attrs( + np.asarray(tau_G), + { + "unit": [""], + "description": [ + "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + ], + }, + ), + "BV": with_attrs( + np.asarray(BV), + { + "unit": [""], + "description": [ + "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + ], + }, + ), + "tau_M1": with_attrs( + np.asarray(tau_M1), + { + "unit": [""], + "description": [ + "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + ], + }, + ), + "sigma_M": with_attrs( + np.asarray(sigma_M), + { + "unit": [""], + "description": [ + "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + ], + }, + ), + "TAU_H_N": with_attrs( + np.asarray(TAU_H_N), + { + "unit": [""], + "description": [ + "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + ], + }, + ), + # "TMI": np.asarray(), + # "RI": np.asarray(), + # "RI_raw": np.asarray(), + # "RTVI": np.asarray(), + # "RTVI_raw": np.asarray(), + } + + # Artifacts can store non-metric outputs (strings, paths, etc.). + + return ProcessResult(metrics=metrics) From 0b26af7f97ed153bafa6a9158acfb7f2fa893083 Mon Sep 17 00:00:00 2001 From: gregoire Date: Thu, 5 Feb 2026 18:03:26 +0100 Subject: [PATCH 43/71] Tier-1 pulse waveform time metrics --- src/pipelines/pulsewaveformshapemetrics .py | 64 ++++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/src/pipelines/pulsewaveformshapemetrics .py b/src/pipelines/pulsewaveformshapemetrics .py index cf78ef4..0641fe8 100644 --- a/src/pipelines/pulsewaveformshapemetrics .py +++ b/src/pipelines/pulsewaveformshapemetrics .py @@ -53,7 +53,9 @@ def run(self, h5file) -> ProcessResult: BV = [] tau_M1 = [] sigma_M = [] - TAU_H_N = [] + TAU_H = [] + TAU_P_N = [] + TAU_P = [] for i in range(len(vraw_ds[0])): T = t_ds[0][i] omega0 = 2 * np.pi / T @@ -92,7 +94,7 @@ def run(self, h5file) -> ProcessResult: Sigma_n.append(np.sqrt(np.sum((n - nc_i) ** 2 * p))) Hspec.append(-np.sum(p * np.log(p + 1e-12))) Fspec.append(np.exp(np.mean(np.log(p + 1e-12))) / np.mean(p)) - """omega_n = n2 * omega0 + omega_n = n2 * omega0 tau_phi_n_i = -phi2 / omega_n tau_phi_n.append(tau_phi_n_i) @@ -122,7 +124,7 @@ def run(self, h5file) -> ProcessResult: tau_M1.append(np.nan) if np.sum(w) > 0: t2 = np.sum(w * t**2) / np.sum(w) - sigma_M.append(np.sqrt(t2 - tau_M1**2)) + sigma_M.append(np.sqrt(t2 - tau_M1[-1] ** 2)) else: sigma_M.append(np.nan) @@ -133,7 +135,20 @@ def run(self, h5file) -> ProcessResult: tau_H_n[idx2] = (1 / (omega_n[idx2])) * np.sqrt( (1 / (absX2[valid2] ** 2)) - 1 ) - TAU_H_N.append(tau_H_n)""" + TAU_H.append(tau_H_n) + tau_P_n = [] + + for k in range(len(Xn[i]) - 1): + taus = np.linspace(0, 10, 2000) + H = 1 / (1 + 1j * omega_n[2] * taus) + err = np.abs(Xn[i][k] - H) ** 2 + tau_P_n.append(taus[np.argmin(err)]) + + TAU_P_N.append(tau_P_n) + if np.sum(np.isfinite(tau_P_n)) >= 2: + TAU_P.append(np.nanmean(tau_P_n)) + else: + TAU_P.append(np.nan) # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. metrics = { "Xn": with_attrs( @@ -239,7 +254,7 @@ def run(self, h5file) -> ProcessResult: { "unit": [""], "description": [ - "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + "Amplitude-insensitive timing descriptor from harmonic phases: robust aggregate of per-harmonic delays " ], }, ), @@ -247,9 +262,7 @@ def run(self, h5file) -> ProcessResult: np.asarray(tau_phi_n), { "unit": [""], - "description": [ - "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " - ], + "description": [""], }, ), "tau_G": with_attrs( @@ -257,7 +270,7 @@ def run(self, h5file) -> ProcessResult: { "unit": [""], "description": [ - "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + "Robust global delay estimated as the slope of (unwrapped) arg (Xn) vs ωn " ], }, ), @@ -265,9 +278,7 @@ def run(self, h5file) -> ProcessResult: np.asarray(BV), { "unit": [""], - "description": [ - "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " - ], + "description": [""], }, ), "tau_M1": with_attrs( @@ -275,33 +286,42 @@ def run(self, h5file) -> ProcessResult: { "unit": [""], "description": [ - "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + "Point-free timing: centroid (center-of-mass) of the systolic lobe from a band-limited waveform using a positive weight w(t) within a fixed systolic window" ], }, ), "sigma_M": with_attrs( np.asarray(sigma_M), + { + "unit": [""], + "description": ["dispersion proxy "], + }, + ), + "TAU_H_N": with_attrs( + np.asarray(TAU_H), { "unit": [""], "description": [ - "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + "Scalar proxy of low-pass damping from harmonic magnitudes only. Larger τH indicates stronger attenuation of higher harmonics" ], }, ), - "TAU_H_N": with_attrs( - np.asarray(TAU_H_N), + "TAU_P_N": with_attrs( + np.asarray(TAU_P_N), + { + "unit": [""], + "description": [""], + }, + ), + "TAU_P": with_attrs( + np.asarray(TAU_P), { "unit": [""], "description": [ - "Peakedness vs flatness of the harmonic distribution; low values indicate dominance of few harmonics, high values indicate flatter spectra " + "Phase-aware scalar summary of vascular damping and phase lag, estimated from complex harmonics under explicit validity gates" ], }, ), - # "TMI": np.asarray(), - # "RI": np.asarray(), - # "RI_raw": np.asarray(), - # "RTVI": np.asarray(), - # "RTVI_raw": np.asarray(), } # Artifacts can store non-metric outputs (strings, paths, etc.). From 2517642619618758b67d89a72c7842bf67e03191 Mon Sep 17 00:00:00 2001 From: gregoire Date: Thu, 5 Feb 2026 18:10:00 +0100 Subject: [PATCH 44/71] lint-tool passed --- src/pipelines/pulsewaveformshapemetrics .py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pipelines/pulsewaveformshapemetrics .py b/src/pipelines/pulsewaveformshapemetrics .py index 0641fe8..774563c 100644 --- a/src/pipelines/pulsewaveformshapemetrics .py +++ b/src/pipelines/pulsewaveformshapemetrics .py @@ -25,11 +25,7 @@ class WaveForm(ProcessPipeline): def run(self, h5file) -> ProcessResult: vraw_ds_temp = np.asarray(h5file[self.v_raw]) vraw_ds = np.maximum(vraw_ds_temp, 0) - v_ds_temp = np.asarray(h5file[self.v]) - v_ds = np.maximum(v_ds_temp, 0) t_ds = np.asarray(h5file[self.T_val]) - V_max = np.asarray(h5file[self.vmax]) - V_min = np.asarray(h5file[self.vmin]) N = len(vraw_ds[:, 0]) # période normalisée (1 beat) # pulsation fondamentale @@ -65,7 +61,7 @@ def run(self, h5file) -> ProcessResult: V_coeff.append(Vn) Xn.append(Vn[1:] / Vn[1]) H2.append(np.abs(Xn[i][1])) - phi = np.angle(Xn[i]) + absV = np.abs(Vn) Delta_Phi_2.append(np.angle(Vn[2] * np.conj(Vn[1]) ** 2)) Delta_Phi_3.append(np.angle(Vn[3] * np.conj(Vn[1]) ** 3)) From d3e9903e01d5532cb486f2a051b194d33eb54055 Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Fri, 6 Feb 2026 14:31:31 +0100 Subject: [PATCH 45/71] first draft --- src/angio_eye.py | 8 ++++- src/cli.py | 6 +++- src/pipelines/core/errors.py | 70 ++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/pipelines/core/errors.py diff --git a/src/angio_eye.py b/src/angio_eye.py index bd473b3..236949a 100644 --- a/src/angio_eye.py +++ b/src/angio_eye.py @@ -14,6 +14,7 @@ sv_ttk = None from pipelines import PipelineDescriptor, ProcessResult, load_pipeline_catalog +from pipelines.core.errors import format_pipeline_exception from pipelines.core.utils import write_combined_results_h5 @@ -490,7 +491,12 @@ def _run_pipelines_on_file( with h5py.File(h5_path, "r") as h5file: for pipeline_desc in pipelines: pipeline = pipeline_desc.instantiate() - result = pipeline.run(h5file) + try: + result = pipeline.run(h5file) + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + format_pipeline_exception(exc, pipeline) + ) from exc pipeline_results.append((pipeline.name, result)) self._log_batch(f"[OK] {h5_path.name} -> {pipeline.name}") write_combined_results_h5( diff --git a/src/cli.py b/src/cli.py index d621c62..885fbf3 100644 --- a/src/cli.py +++ b/src/cli.py @@ -29,6 +29,7 @@ ProcessResult, load_pipeline_catalog, ) +from pipelines.core.errors import format_pipeline_exception from pipelines.core.utils import write_combined_results_h5 @@ -108,7 +109,10 @@ def _run_pipelines_on_file( with h5py.File(h5_path, "r") as h5file: for pipeline_desc in pipelines: pipeline = pipeline_desc.instantiate() - result = pipeline.run(h5file) + try: + result = pipeline.run(h5file) + except Exception as exc: # noqa: BLE001 + raise RuntimeError(format_pipeline_exception(exc, pipeline)) from exc pipeline_results.append((pipeline.name, result)) print(f"[OK] {h5_path.name} -> {pipeline.name}") write_combined_results_h5( diff --git a/src/pipelines/core/errors.py b/src/pipelines/core/errors.py new file mode 100644 index 0000000..d4b6a4b --- /dev/null +++ b/src/pipelines/core/errors.py @@ -0,0 +1,70 @@ +import inspect +import linecache +import traceback +from pathlib import Path + +from .base import ProcessPipeline + + +def _resolve_path(path: str | None) -> Path | None: + if not path: + return None + try: + return Path(path).resolve() + except (OSError, RuntimeError, ValueError): + return None + + +def _shorten_path(path: str) -> str: + try: + resolved = Path(path).resolve() + return str(resolved.relative_to(Path.cwd())) + except (OSError, RuntimeError, ValueError): + return path + + +def _pick_relevant_frame( + frames: list[traceback.FrameSummary], + pipeline_path: Path | None, +) -> traceback.FrameSummary: + if pipeline_path is not None: + for frame in reversed(frames): + frame_path = _resolve_path(frame.filename) + if frame_path and frame_path == pipeline_path: + return frame + for frame in reversed(frames): + if "pipelines" in Path(frame.filename).parts: + return frame + return frames[-1] + + +def format_pipeline_exception( + exc: BaseException, pipeline: ProcessPipeline | None = None +) -> str: + """ + Format an exception raised while running a pipeline, highlighting the + most relevant line in the pipeline code when possible. + """ + summary = f"{type(exc).__name__}: {exc}" + label = f"Pipeline '{pipeline.name}'" if pipeline is not None else "Pipeline" + + frames = traceback.extract_tb(exc.__traceback__) + if not frames: + return f"{label} failed: {summary}" + + pipeline_path = None + if pipeline is not None: + pipeline_path = _resolve_path(inspect.getsourcefile(pipeline.__class__)) + + target = _pick_relevant_frame(frames, pipeline_path) + location = f"{_shorten_path(target.filename)}:{target.lineno} in {target.name}()" + line = (target.line or linecache.getline(target.filename, target.lineno)).strip() + + if line: + return ( + f"{label} failed: {summary}\n" + f" at {location}\n" + f" {line}\n" + f" ^" + ) + return f"{label} failed: {summary}\n at {location}" From 6fefecdb63eefab95c67300a7c8d7944d761e5d2 Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Fri, 6 Feb 2026 15:03:07 +0100 Subject: [PATCH 46/71] new error message and export logs --- src/angio_eye.py | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/angio_eye.py b/src/angio_eye.py index 236949a..82cabed 100644 --- a/src/angio_eye.py +++ b/src/angio_eye.py @@ -303,6 +303,39 @@ def _log_batch(self, text: str) -> None: self.batch_output.update_idletasks() self.update_idletasks() + def _export_batch_log(self, initial_dir: Path | None = None) -> Path | None: + if initial_dir is None: + initial_dir = Path(self.batch_output_var.get() or Path.cwd()) + if not initial_dir.exists(): + initial_dir = Path.cwd() + path = filedialog.asksaveasfilename( + defaultextension=".txt", + filetypes=[("Text file", "*.txt"), ("All files", "*.*")], + initialdir=str(initial_dir), + initialfile="batch_log.txt", + title="Export batch log", + ) + if not path: + return None + try: + log_text = self.batch_output.get("1.0", "end").rstrip() + Path(path).write_text(log_text, encoding="utf-8") + self._log_batch(f"[LOG] Exported batch log -> {path}") + return Path(path) + except Exception as exc: # noqa: BLE001 + messagebox.showerror("Export failed", f"Could not save log: {exc}") + return None + + def _show_batch_error_dialog(self, message: str, initial_dir: Path) -> None: + self.bell() + export = messagebox.askyesno( + "Batch completed with errors", + f"{message}\n\nExport log to .txt?", + icon="warning", + ) + if export: + self._export_batch_log(initial_dir) + def select_all_pipelines(self) -> None: for var in self.pipeline_check_vars.values(): if getattr(var, "_enabled", True): @@ -432,9 +465,9 @@ def run_batch(self) -> None: self._log_batch(f"Completed. {summary_msg}") if failures: - messagebox.showwarning( - "Batch completed with errors", + self._show_batch_error_dialog( f"{len(failures)} failure(s). See log for details.\n\n{summary_msg}", + initial_dir=base_output_dir, ) else: messagebox.showinfo("Batch completed", summary_msg) From 9a0f6fe606c2080fa1aaaf8db12e9841a8adc09d Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Fri, 6 Feb 2026 15:05:59 +0100 Subject: [PATCH 47/71] lint --- src/pipelines/core/errors.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/pipelines/core/errors.py b/src/pipelines/core/errors.py index d4b6a4b..70bea7d 100644 --- a/src/pipelines/core/errors.py +++ b/src/pipelines/core/errors.py @@ -61,10 +61,5 @@ def format_pipeline_exception( line = (target.line or linecache.getline(target.filename, target.lineno)).strip() if line: - return ( - f"{label} failed: {summary}\n" - f" at {location}\n" - f" {line}\n" - f" ^" - ) + return f"{label} failed: {summary}\n at {location}\n {line}\n ^" return f"{label} failed: {summary}\n at {location}" From da215972d3e9cff1594e6d571613eb5620fb6f0d Mon Sep 17 00:00:00 2001 From: gregoire Date: Fri, 6 Feb 2026 15:38:57 +0100 Subject: [PATCH 48/71] Adding of the last relevant metrics (VTI metrics mainly) --- src/pipelines/pulsewaveformshapemetrics .py | 73 +++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/pipelines/pulsewaveformshapemetrics .py b/src/pipelines/pulsewaveformshapemetrics .py index 774563c..f33ca08 100644 --- a/src/pipelines/pulsewaveformshapemetrics .py +++ b/src/pipelines/pulsewaveformshapemetrics .py @@ -52,6 +52,12 @@ def run(self, h5file) -> ProcessResult: TAU_H = [] TAU_P_N = [] TAU_P = [] + AVTI = [] + VTI_0_T2 = [] + VTI_T2_T = [] + FVTI = [] + IVTI=[] + RVTI=[] for i in range(len(vraw_ds[0])): T = t_ds[0][i] omega0 = 2 * np.pi / T @@ -145,7 +151,30 @@ def run(self, h5file) -> ProcessResult: TAU_P.append(np.nanmean(tau_P_n)) else: TAU_P.append(np.nan) + dt = t[1] - t[0] + AVTI_i = np.sum(w) * dt + AVTI.append(AVTI_i) + mid = len(t) // 2 + + VTI_early = np.sum(w[:mid]) * dt + VTI_late = np.sum(w[mid:]) * dt + + VTI_0_T2.append(VTI_early) + VTI_T2_T.append(VTI_late) + if AVTI_i > 0: + FVTI.append(VTI_early / AVTI_i) + else: + FVTI.append(np.nan) + den = VTI_early + VTI_late + if den > 0: + IVTI.append((VTI_early - VTI_late) / den) + else: + IVTI.append(np.nan) + eps = 1e-12 + RVTI.append(VTI_early / (VTI_late + eps)) + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + metrics = { "Xn": with_attrs( np.asarray(Xn), @@ -318,6 +347,50 @@ def run(self, h5file) -> ProcessResult: ], }, ), + "AVTI": with_attrs( + np.asarray(AVTI), + { + "unit": [""], + "description": [ + "Primary arterial stroke-distance proxy (non-negative)" + ], + }, + ), + "VTI_0_T2": with_attrs( + np.asarray(VTI_0_T2), + { + "unit": [""], + "description": ["Early-cycle stroke distance"], + }, + ), + "VTI_T2_T": with_attrs( + np.asarray(VTI_T2_T), + { + "unit": [""], + "description": ["Late-cycle stroke distance"], + }, + ), + "FVTI": with_attrs( + np.asarray(FVTI), + { + "unit": [""], + "description": ["Scale-free partition"], + }, + ), + "IVTI": with_attrs( + np.asarray(IVTI), + { + "unit": [""], + "description": ["Signed asymmetry in [−1, 1] (renamed to avoid symbol collision)"], + }, + ), + "RVTI": with_attrs( + np.asarray(RVTI), + { + "unit": [""], + "description": ["Partition imbalance"], + }, + ), } # Artifacts can store non-metric outputs (strings, paths, etc.). From 233fb5abbdfd6f166e1282eb48d626e164b088be Mon Sep 17 00:00:00 2001 From: gregoire Date: Fri, 6 Feb 2026 15:40:34 +0100 Subject: [PATCH 49/71] lint-tool --- src/pipelines/pulsewaveformshapemetrics .py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pipelines/pulsewaveformshapemetrics .py b/src/pipelines/pulsewaveformshapemetrics .py index f33ca08..cce7e97 100644 --- a/src/pipelines/pulsewaveformshapemetrics .py +++ b/src/pipelines/pulsewaveformshapemetrics .py @@ -56,8 +56,8 @@ def run(self, h5file) -> ProcessResult: VTI_0_T2 = [] VTI_T2_T = [] FVTI = [] - IVTI=[] - RVTI=[] + IVTI = [] + RVTI = [] for i in range(len(vraw_ds[0])): T = t_ds[0][i] omega0 = 2 * np.pi / T @@ -172,7 +172,7 @@ def run(self, h5file) -> ProcessResult: IVTI.append(np.nan) eps = 1e-12 RVTI.append(VTI_early / (VTI_late + eps)) - + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. metrics = { @@ -381,7 +381,9 @@ def run(self, h5file) -> ProcessResult: np.asarray(IVTI), { "unit": [""], - "description": ["Signed asymmetry in [−1, 1] (renamed to avoid symbol collision)"], + "description": [ + "Signed asymmetry in [−1, 1] (renamed to avoid symbol collision)" + ], }, ), "RVTI": with_attrs( From 6ecaca39be1b58d91f49293e014fc529283afa8c Mon Sep 17 00:00:00 2001 From: chloe Date: Fri, 6 Feb 2026 16:10:51 +0100 Subject: [PATCH 50/71] pulse waveform shape metrics --- ...veform_shape_metrics(harmonic analysis).py | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py diff --git a/src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py b/src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py new file mode 100644 index 0000000..78d1cd2 --- /dev/null +++ b/src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py @@ -0,0 +1,172 @@ +import numpy as np + +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs + + +@registerPipeline(name="Harmonic metrics") +class ArterialHarmonicAnalysis(ProcessPipeline): + """ + Tutorial pipeline showing the full surface area of a pipeline: + + - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. + - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. + - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. + - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). + - No input data is required; this pipeline is purely illustrative. + """ + + description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + v_raw = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" + v = "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" + T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + + def run(self, h5file) -> ProcessResult: + v_raw = np.asarray(h5file[self.v]) + v_ds = np.asarray(h5file[self.v]) + t_ds = np.asarray(h5file[self.T]) + + N=len(v_ds[:,0]) + nb_harmonic=10 + Xn=[] + + CV_1=[] + CV_2=[] + Vn_tot=[] + + sigma_phase_tot=[] + v_hat=[] + CF_tot=[] + D_alpha=[] + V0=[] + FVTI=[] + IVTI=[] + max=[] + + + alpha=0.5 #that needs to be specified + for k in range(len(v_ds[0])): + + fft_vals=np.fft.fft(v_ds[:,k])/N + limit=nb_harmonic+1 + Vn=fft_vals[1:limit] + V1=Vn[0] + Xn_k=Vn/V1 + Xn.append(Xn_k) + Vn_tot.append(Vn) + + omega0= (2 * np.pi)/t_ds[0][k] + V0.append(np.mean(v_ds[:,k])) + + v_hat_k=np.zeros_like(t_ds[0][k]) + + for n, v_coeff in enumerate (Vn,1): + h=n*omega0*2*(v_coeff*np.exp(1j*n*omega0*t_ds[0][k])).real + v_hat_k+=h + v_hat_V0=v_hat_k+V0[k] + v_hat.append(v_hat_V0) + + v_hat_np=np.asarray(v_hat) + diff=v_hat_np.T[k]-v_ds[:,k] + max.append(np.maximum(diff, 0)) + max_np=np.asarray(max) + + RMST=np.sqrt(np.mean(v_hat[k]**2)) + CF=np.max(v_hat)/RMST + CF_tot.append(CF) + + amp1=np.abs(np.asarray(Vn_tot)[:,0]) + amp2=np.abs(np.asarray(Vn_tot)[:,1]) + + CV_1.append(np.std(amp1)/np.mean(amp1)) + CV_2.append(np.std(amp2)/np.mean(amp2)) + + phi1=np.angle(np.asarray(Xn)[:,0]) + phi2=np.angle(np.asarray(Xn)[:,1]) + + diff_phase=phi2-2*phi1 + diff_phase_wrap=(diff_phase+np.pi)%(2*np.pi)-np.pi + sigma_phase=np.std(diff_phase_wrap) + sigma_phase_tot.append(sigma_phase) + + seuil=V0[k]+alpha*np.std(v_hat[k]) + condition= v_hat[k] > seuil + D_alpha.append(np.mean(condition)) + + + moitie = len(max) // 2 + d1 = np.sum(max_np.T[k][:moitie]) + d2 = np.sum(max_np.T[k][moitie:]) + AVTI=np.sum(max_np.T[k]) + FVTI.append(d1/AVTI) + IVTI.append((d1-d2)/(d1+d2)) + + + metrics = { + "Xn": with_attrs( + np.asarray(Xn), + { + "unit": [""], + }, + ), + "Beat to beat amplitude stability (n=1)": with_attrs( + np.asarray(CV_1), + { + "unit": [""], + }, + ), + "Beat to beat amplitude stability (n=2)": with_attrs( + np.asarray(CV_2), + { + "unit": [""], + }, + ), + "Beat to beat phase coupling stability (n=2)": with_attrs( + np.asarray(sigma_phase_tot), + { + "unit": [""], + }, + ), + "Band limited waveform (definition) : v_hat": with_attrs( + np.asarray(v_hat), + { + "unit": [""], + }, + ), + "Band limited crest factor : CF": with_attrs( + np.asarray(CF_tot), + { + "unit": [""], + }, + ), + "Effective Duty cycle : D_alpha": with_attrs( + np.asarray(D_alpha), + { + "unit": [""], + }, + ), + "V0": with_attrs( + np.asarray(V0), + { + "unit": [""], + }, + ), + "Normalised first half fraction : FVTI": with_attrs( + np.asarray(FVTI), + { + "unit": [""], + }, + ), + + "VTI asymmetry index : IVTI": with_attrs( + np.asarray(IVTI), + { + "unit": [""], + }, + ), + + + + } + # Artifacts can store non-metric outputs (strings, paths, etc.). + + return ProcessResult(metrics=metrics) \ No newline at end of file From c38b356c9d09313b45184b40d4108eef2717f8d0 Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Mon, 9 Feb 2026 14:02:12 +0100 Subject: [PATCH 51/71] Subfolders_in_h5_outputs --- src/pipelines/core/utils.py | 45 ++++++++++++++++++++++++++++----- src/pipelines/static_example.py | 18 +++++++++---- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/pipelines/core/utils.py b/src/pipelines/core/utils.py index cbf3502..c8f3759 100644 --- a/src/pipelines/core/utils.py +++ b/src/pipelines/core/utils.py @@ -49,6 +49,35 @@ def _create_unique_group(parent: h5py.Group, base_name: str) -> h5py.Group: return parent.create_group(candidate) +def _resolve_dataset_target(root_group: h5py.Group, key: str) -> tuple[h5py.Group, str]: + """ + Resolve a metric/artifact key to (parent_group, dataset_name). + + Supports nested paths like "vesselA/tauH_10" under the provided root group. + Intermediate groups are created on demand. + """ + normalized_key = str(key).replace("\\", "/").strip("/") + parts = [part for part in normalized_key.split("/") if part] + if not parts: + raise ValueError("Dataset key cannot be empty.") + + parent = root_group + for part in parts[:-1]: + existing = parent.get(part) + if existing is None: + parent = parent.create_group(part) + continue + if isinstance(existing, h5py.Group): + parent = existing + continue + raise ValueError( + f"Cannot create subgroup '{part}' for key '{key}': a dataset already exists at that path." + ) + + dataset_name = parts[-1] + return parent, dataset_name + + def _write_value_dataset(group: h5py.Group, key: str, value) -> None: """ Create a dataset under group for the given value. @@ -67,19 +96,21 @@ def _write_value_dataset(group: h5py.Group, key: str, value) -> None: elif isinstance(value, tuple) and len(value) == 2 and isinstance(value[1], dict): data, ds_attrs = value + target_group, dataset_key = _resolve_dataset_target(group, str(key)) + if isinstance(data, str): - dataset = group.create_dataset( - key, data=data, dtype=h5py.string_dtype(encoding="utf-8") + dataset = target_group.create_dataset( + dataset_key, data=data, dtype=h5py.string_dtype(encoding="utf-8") ) else: payload = data if isinstance(data, (list, tuple)): payload = np.asarray(data) try: - dataset = group.create_dataset(key, data=payload) + dataset = target_group.create_dataset(dataset_key, data=payload) except (TypeError, ValueError): - dataset = group.create_dataset( - key, data=str(data), dtype=h5py.string_dtype(encoding="utf-8") + dataset = target_group.create_dataset( + dataset_key, data=str(data), dtype=h5py.string_dtype(encoding="utf-8") ) if ds_attrs: @@ -118,8 +149,8 @@ def write_result_h5( Attributes: pipeline: pipeline display name. source_file: optional path to the originating HDF5 input. - metrics: stored under /metrics/. - artifacts: stored under /artifacts/ when present. + metrics: stored under /metrics/, supporting nested paths in keys. + artifacts: stored under /artifacts/ when present, also supporting nested paths. """ out_path = Path(path) out_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/pipelines/static_example.py b/src/pipelines/static_example.py index 5557101..38d68cb 100644 --- a/src/pipelines/static_example.py +++ b/src/pipelines/static_example.py @@ -10,20 +10,24 @@ class StaticExample(ProcessPipeline): - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. + - Use "/" inside metric/artifact keys to create nested groups in output HDF5. - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). - No input data is required; this pipeline is purely illustrative. """ - description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + description = "Tutorial: metrics/artifacts + nested output groups + attrs." def run(self, h5file) -> ProcessResult: - # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + # Each key becomes a dataset under /Pipelines//metrics. + # Keys with "/" automatically create sub-groups (e.g. artery/tauH_10). metrics = { "scalar_example": 42.0, "vector_example": [1.0, 2.0, 3.0], + "subfolder/metrics_1": 2.5, + "subfolder/metrics_2": 3.1, # Attach dataset-level attributes (min/max/name/unit) using with_attrs. - "matrix_example": with_attrs( + "aggregates/matrix_example": with_attrs( [[1, 2], [3, 4]], { "minimum": [1], @@ -38,8 +42,12 @@ def run(self, h5file) -> ProcessResult: ), } - # Artifacts can store non-metric outputs (strings, paths, etc.). - artifacts = {"note": "Static data for demonstration"} + # Artifacts support the same nested-key behavior. + artifacts = { + "note": "Static data for demonstration", + "logs/run_id": "static_demo_001", + "logs/message": "Nested artifact example", + } # Optional attributes applied to the pipeline group and the root file. attrs = {"pipeline_version": "1.0", "author": "StaticExample"} From b102ab40e18df5c5ff6ef0d70d2a90d1e875dfd0 Mon Sep 17 00:00:00 2001 From: gregoire Date: Tue, 10 Feb 2026 14:56:50 +0100 Subject: [PATCH 52/71] clean version --- src/pipelines/pulsewaveformshapemetrics .py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipelines/pulsewaveformshapemetrics .py b/src/pipelines/pulsewaveformshapemetrics .py index cce7e97..beab013 100644 --- a/src/pipelines/pulsewaveformshapemetrics .py +++ b/src/pipelines/pulsewaveformshapemetrics .py @@ -59,7 +59,7 @@ def run(self, h5file) -> ProcessResult: IVTI = [] RVTI = [] for i in range(len(vraw_ds[0])): - T = t_ds[0][i] + T = t_ds[0, i] omega0 = 2 * np.pi / T t = np.linspace(0, T, N, endpoint=False) Vfft = np.fft.fft(vraw_ds[:, i]) / N From 5655c591aaa3c3cd4c24b9ddf4541adc3590bfe8 Mon Sep 17 00:00:00 2001 From: chloe Date: Tue, 10 Feb 2026 16:24:25 +0100 Subject: [PATCH 53/71] new metrics --- ...veform_shape_metrics(harmonic analysis).py | 90 +++++++++---------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py b/src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py index 78d1cd2..91c3e2e 100644 --- a/src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py +++ b/src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py @@ -25,78 +25,78 @@ def run(self, h5file) -> ProcessResult: v_ds = np.asarray(h5file[self.v]) t_ds = np.asarray(h5file[self.T]) + + N=len(v_ds[:,0]) nb_harmonic=10 Xn=[] CV_1=[] CV_2=[] - Vn_tot=[] + Vn_tot=[] sigma_phase_tot=[] v_hat=[] CF_tot=[] D_alpha=[] - V0=[] + FVTI=[] IVTI=[] - max=[] + AVTI_tot=[] alpha=0.5 #that needs to be specified for k in range(len(v_ds[0])): - + T=t_ds[0][k] fft_vals=np.fft.fft(v_ds[:,k])/N limit=nb_harmonic+1 - Vn=fft_vals[1:limit] - V1=Vn[0] + Vn=fft_vals[:limit] + V1=Vn[1] Xn_k=Vn/V1 Xn.append(Xn_k) Vn_tot.append(Vn) - omega0= (2 * np.pi)/t_ds[0][k] - V0.append(np.mean(v_ds[:,k])) + omega0= (2 * np.pi)/T + t=np.linspace(0,T,N) - v_hat_k=np.zeros_like(t_ds[0][k]) + v_hat_k=np.real(Vn[0])*np.ones_like(t) - for n, v_coeff in enumerate (Vn,1): - h=n*omega0*2*(v_coeff*np.exp(1j*n*omega0*t_ds[0][k])).real + for i in range (1,limit): + h=2*(Vn[i]*np.exp(1j*i*omega0*t)).real v_hat_k+=h - v_hat_V0=v_hat_k+V0[k] - v_hat.append(v_hat_V0) - - v_hat_np=np.asarray(v_hat) - diff=v_hat_np.T[k]-v_ds[:,k] - max.append(np.maximum(diff, 0)) - max_np=np.asarray(max) + v_hat.append(v_hat_k) RMST=np.sqrt(np.mean(v_hat[k]**2)) - CF=np.max(v_hat)/RMST + CF=np.max(v_hat_k)/RMST CF_tot.append(CF) - amp1=np.abs(np.asarray(Vn_tot)[:,0]) - amp2=np.abs(np.asarray(Vn_tot)[:,1]) + amp1=np.abs(np.asarray(Vn_tot)[:,1]) + amp2=np.abs(np.asarray(Vn_tot)[:,2]) CV_1.append(np.std(amp1)/np.mean(amp1)) CV_2.append(np.std(amp2)/np.mean(amp2)) - phi1=np.angle(np.asarray(Xn)[:,0]) - phi2=np.angle(np.asarray(Xn)[:,1]) + phi1=np.angle(np.asarray(Xn)[:,1]) + phi2=np.angle(np.asarray(Xn)[:,2]) diff_phase=phi2-2*phi1 diff_phase_wrap=(diff_phase+np.pi)%(2*np.pi)-np.pi sigma_phase=np.std(diff_phase_wrap) sigma_phase_tot.append(sigma_phase) - seuil=V0[k]+alpha*np.std(v_hat[k]) - condition= v_hat[k] > seuil + seuil=Vn[0]+alpha*np.std(v_hat) + condition= v_hat > seuil D_alpha.append(np.mean(condition)) - - moitie = len(max) // 2 - d1 = np.sum(max_np.T[k][:moitie]) - d2 = np.sum(max_np.T[k][moitie:]) - AVTI=np.sum(max_np.T[k]) + v_base=np.min(v_hat[k]) + max=np.maximum(v_hat[k]-v_base, 0) + + dt=t[1]-t[0] + moitie = len(t) // 2 + d1 = np.sum(max[:moitie])*dt + d2 = np.sum(max[moitie:])*dt + AVTI=np.sum(max)*dt + AVTI_tot.append(AVTI) FVTI.append(d1/AVTI) IVTI.append((d1-d2)/(d1+d2)) @@ -108,56 +108,56 @@ def run(self, h5file) -> ProcessResult: "unit": [""], }, ), - "Beat to beat amplitude stability (n=1)": with_attrs( + "AVTI": with_attrs( + np.asarray(AVTI_tot), + { + "unit": [""], + }, + ), + "CV1 : Beat to beat amplitude stability (n=1)": with_attrs( np.asarray(CV_1), { "unit": [""], }, ), - "Beat to beat amplitude stability (n=2)": with_attrs( + "CV2 : Beat to beat amplitude stability (n=2)": with_attrs( np.asarray(CV_2), { "unit": [""], }, ), - "Beat to beat phase coupling stability (n=2)": with_attrs( + "sigma : Beat to beat phase coupling stability (n=2)": with_attrs( np.asarray(sigma_phase_tot), { "unit": [""], }, ), - "Band limited waveform (definition) : v_hat": with_attrs( + "v_hat : Band limited waveform (definition)": with_attrs( np.asarray(v_hat), { "unit": [""], }, ), - "Band limited crest factor : CF": with_attrs( + "CF : Band limited crest factor ": with_attrs( np.asarray(CF_tot), { "unit": [""], }, ), - "Effective Duty cycle : D_alpha": with_attrs( + " D_alpha : Effective Duty cycle ": with_attrs( np.asarray(D_alpha), { "unit": [""], }, - ), - "V0": with_attrs( - np.asarray(V0), - { - "unit": [""], - }, - ), - "Normalised first half fraction : FVTI": with_attrs( + ), + "FVTI : Normalised first half fraction ": with_attrs( np.asarray(FVTI), { "unit": [""], }, ), - "VTI asymmetry index : IVTI": with_attrs( + "IVTI : VTI asymmetry index ": with_attrs( np.asarray(IVTI), { "unit": [""], From 21f6b93529bb225597a61160b09166b461ca2eb7 Mon Sep 17 00:00:00 2001 From: gregoire Date: Tue, 10 Feb 2026 16:58:19 +0100 Subject: [PATCH 54/71] extrapolation of velocity profiles using sides of the profile --- src/pipelines/recreatesig.py | 149 +++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/pipelines/recreatesig.py diff --git a/src/pipelines/recreatesig.py b/src/pipelines/recreatesig.py new file mode 100644 index 0000000..57a3aa8 --- /dev/null +++ b/src/pipelines/recreatesig.py @@ -0,0 +1,149 @@ +import numpy as np + +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs + + +@registerPipeline(name="reconstruct") +class Reconstruct(ProcessPipeline): + """ + Tutorial pipeline showing the full surface area of a pipeline: + + - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. + - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. + - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. + - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). + - No input data is required; this pipeline is purely illustrative. + """ + + description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + v_profile = "/Artery/Velocity/VelocityProfiles/value" + vsystol = "/Artery/Velocity/SystolicAccelerationPeakIndexes" + T_val = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + vmax = "/Artery/VelocityPerBeat/VmaxPerBeatBandLimited/value" + vmin = "/Artery/VelocityPerBeat/VminPerBeatBandLimited/value" + + def gaussian(x, A, mu, sigma, c): + return A * np.exp(-((x - mu) ** 2) / (2 * sigma**2)) + c + + def run(self, h5file) -> ProcessResult: + v_seg = np.asarray(h5file[self.v_profile]) + t_ds = np.asarray(h5file[self.T_val]) + + V = [] + threshold = 3 + + V_corrected = [] + V_ceil = [] + V_gauss = [] + + for k in range(len(v_seg[0, :, 0, 0])): + VIT_Time = 0 + Vit_br = [] + for br in range(len(v_seg[0, k, :, 0])): + v_branch = np.nanmean(v_seg[:, k, br, :], axis=1) + Vit_br.append(v_branch) + + V.append(np.mean(Vit_br)) + for k in range(len(v_seg[0, :, 0, 0])): + Vit = [] + for br in range(len(v_seg[0, k, :, 0])): + Vit_br = [] + for seg in range(len(v_seg[0, k, br, :])): + values = list(v_seg[:, k, br, seg]) + + try: + temp = values[ + : np.minimum( + values.index(next(filter(lambda x: x != 0, values))) + + threshold, + 17, + ) + ] + other = values[ + np.minimum( + values.index(next(filter(lambda x: x != 0, values))) + + threshold, + 17, + ) : + ] + test = other[ + np.maximum( + other.index(next(filter(lambda x: x == 0, other))) + - threshold, + 0, + ) : + ] + Vit_br.append(np.nanmean(temp + test)) + except: + Vit_br.append(np.nan) + + Vit.append(np.nanmean(Vit_br)) + + V_corrected.append(np.nanmean(Vit)) + for k in range(len(v_seg[0, :, 0, 0])): + Vit = [] + Vit_gauss = [] + for br in range(len(v_seg[0, k, :, 0])): + Vit_br = [] + for seg in range(len(v_seg[0, k, br, :])): + values = list(v_seg[:, k, br, seg]) + + try: + first = values.index(next(filter(lambda x: x != 0, values))) + other = values[ + np.minimum( + values.index(next(filter(lambda x: x != 0, values))) + + threshold, + 17, + ) : + ] + last = first + other.index( + next(filter(lambda x: x == 0, other)) + ) + + Comp = [ + values[first + threshold] + for v in values[first + threshold : last - threshold] + ] + Vit_br.append( + np.nanmean( + values[: first + threshold] + + Comp + + values[last - threshold :] + ) + ) + except: + Vit_br.append(np.nan) + + Vit.append(np.nanmean(Vit_br)) + + V_ceil.append(np.nanmean(Vit)) + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + + metrics = { + "Xn": with_attrs( + np.asarray(V), + { + "unit": [""], + "description": [""], + }, + ), + "Xn_correc": with_attrs( + np.asarray(V_corrected), + { + "unit": [""], + "description": [""], + }, + ), + "Xn_ceil": with_attrs( + np.asarray(V_ceil), + { + "unit": [""], + "description": [""], + }, + ), + } + + # Artifacts can store non-metric outputs (strings, paths, etc.). + + return ProcessResult(metrics=metrics) From ff6c071cc027e3b0cb498deb49f9448899109015 Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Wed, 11 Feb 2026 13:35:18 +0100 Subject: [PATCH 55/71] Remove artefact and file attr + remove metrics subfolder --- README.md | 11 +++-------- src/pipelines/basic_stats.py | 2 +- src/pipelines/core/base.py | 2 -- src/pipelines/core/utils.py | 27 ++++++--------------------- src/pipelines/static_example.py | 30 ++++++++++++------------------ src/pipelines/tauh_n10.py | 7 +++---- src/pipelines/tauh_n10_per_beat.py | 17 +++++++---------- 7 files changed, 32 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 21fc785..8704058 100644 --- a/README.md +++ b/README.md @@ -118,23 +118,18 @@ class MyAnalysis(ProcessPipeline): import torch # 1. Read data using h5py # 2. Perform calculations - # 3. Return metrics and artifacts + # 3. Return metrics metrics={"peak_flow": 12.5} - artifacts = {"note": "Static data for demonstration"} - # Optional attributes applied to the pipeline group and the root file. + # Optional attributes applied to the pipeline group. attrs = { "pipeline_version": "1.0", "author": "StaticExample" } - file_attrs = {"example_generated": True} - return ProcessResult( metrics=metrics, - artifacts=artifacts, - attrs=attrs, - file_attrs=file_attrs + attrs=attrs ) ``` diff --git a/src/pipelines/basic_stats.py b/src/pipelines/basic_stats.py index c0d69e5..9c14b24 100644 --- a/src/pipelines/basic_stats.py +++ b/src/pipelines/basic_stats.py @@ -42,4 +42,4 @@ def run(self, h5file: h5py.File) -> ProcessResult: "mean": float(np.mean(arr)), "std": float(np.std(arr)), } - return ProcessResult(metrics=metrics, artifacts={"dataset": dataset.name}) + return ProcessResult(metrics=metrics, attrs={"dataset": dataset.name}) diff --git a/src/pipelines/core/base.py b/src/pipelines/core/base.py index f78d362..7a26039 100644 --- a/src/pipelines/core/base.py +++ b/src/pipelines/core/base.py @@ -42,9 +42,7 @@ def decorator(cls): @dataclass class ProcessResult: metrics: dict[str, Any] - artifacts: dict[str, Any] | None = None attrs: dict[str, Any] | None = None # attributes stored on the pipeline group - file_attrs: dict[str, Any] | None = None # attributes stored on the root H5 file output_h5_path: str | None = None diff --git a/src/pipelines/core/utils.py b/src/pipelines/core/utils.py index c8f3759..1ce8f4c 100644 --- a/src/pipelines/core/utils.py +++ b/src/pipelines/core/utils.py @@ -51,7 +51,7 @@ def _create_unique_group(parent: h5py.Group, base_name: str) -> h5py.Group: def _resolve_dataset_target(root_group: h5py.Group, key: str) -> tuple[h5py.Group, str]: """ - Resolve a metric/artifact key to (parent_group, dataset_name). + Resolve a metric key to (parent_group, dataset_name). Supports nested paths like "vesselA/tauH_10" under the provided root group. Intermediate groups are created on demand. @@ -149,8 +149,8 @@ def write_result_h5( Attributes: pipeline: pipeline display name. source_file: optional path to the originating HDF5 input. - metrics: stored under /metrics/, supporting nested paths in keys. - artifacts: stored under /artifacts/ when present, also supporting nested paths. + metrics: stored under /Pipelines//, + supporting nested paths in keys. """ out_path = Path(path) out_path.parent.mkdir(parents=True, exist_ok=True) @@ -160,11 +160,6 @@ def write_result_h5( f.attrs["pipeline"] = pipeline_name if source_file: f.attrs["source_file"] = source_file - if result.file_attrs: - for key, value in result.file_attrs.items(): - if key in {"pipeline", "source_file"}: - continue - _set_attr_safe(f, key, value) pipelines_grp = _ensure_pipelines_group(f) pipeline_grp = _create_unique_group(pipelines_grp, safe_h5_key(pipeline_name)) pipeline_grp.attrs["pipeline"] = pipeline_name @@ -173,13 +168,8 @@ def write_result_h5( if key == "pipeline": continue _set_attr_safe(pipeline_grp, key, value) - metrics_grp = pipeline_grp.create_group("metrics") for key, value in result.metrics.items(): - _write_value_dataset(metrics_grp, key, value) - if result.artifacts: - artifacts_grp = pipeline_grp.create_group("artifacts") - for key, value in result.artifacts.items(): - _write_value_dataset(artifacts_grp, key, value) + _write_value_dataset(pipeline_grp, key, value) return str(out_path) @@ -191,7 +181,7 @@ def write_combined_results_h5( """ Write multiple pipeline results into a single HDF5 file. - The file groups results under /pipelines//{metrics,artifacts}. + The file groups results under /Pipelines//. """ out_path = Path(path) out_path.parent.mkdir(parents=True, exist_ok=True) @@ -210,11 +200,6 @@ def write_combined_results_h5( if key == "pipeline": continue _set_attr_safe(pipeline_grp, key, value) - metrics_grp = pipeline_grp.create_group("metrics") for key, value in result.metrics.items(): - _write_value_dataset(metrics_grp, key, value) - if result.artifacts: - artifacts_grp = pipeline_grp.create_group("artifacts") - for key, value in result.artifacts.items(): - _write_value_dataset(artifacts_grp, key, value) + _write_value_dataset(pipeline_grp, key, value) return str(out_path) diff --git a/src/pipelines/static_example.py b/src/pipelines/static_example.py index 38d68cb..aa60526 100644 --- a/src/pipelines/static_example.py +++ b/src/pipelines/static_example.py @@ -9,18 +9,18 @@ class StaticExample(ProcessPipeline): Tutorial pipeline showing the full surface area of a pipeline: - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. - - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. - - Use "/" inside metric/artifact keys to create nested groups in output HDF5. + - Return metrics (scalars, vectors, matrices, cubes). + - Use "/" inside metric keys to create nested groups in output HDF5. - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. - - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). + - Add attributes to the pipeline group (`attrs`). - No input data is required; this pipeline is purely illustrative. """ - description = "Tutorial: metrics/artifacts + nested output groups + attrs." + description = "Tutorial: metrics + nested output groups + attrs." def run(self, h5file) -> ProcessResult: - # Each key becomes a dataset under /Pipelines//metrics. - # Keys with "/" automatically create sub-groups (e.g. artery/tauH_10). + # Each key becomes a dataset under /Pipelines//. + # Keys with "/" automatically create sub-groups. metrics = { "scalar_example": 42.0, "vector_example": [1.0, 2.0, 3.0], @@ -42,17 +42,11 @@ def run(self, h5file) -> ProcessResult: ), } - # Artifacts support the same nested-key behavior. - artifacts = { - "note": "Static data for demonstration", - "logs/run_id": "static_demo_001", - "logs/message": "Nested artifact example", + # Optional attributes applied to the pipeline group. + attrs = { + "pipeline_version": "1.0", + "author": "StaticExample", + "example_generated": True, } - # Optional attributes applied to the pipeline group and the root file. - attrs = {"pipeline_version": "1.0", "author": "StaticExample"} - file_attrs = {"example_generated": True} - - return ProcessResult( - metrics=metrics, artifacts=artifacts, attrs=attrs, file_attrs=file_attrs - ) + return ProcessResult(metrics=metrics, attrs=attrs) diff --git a/src/pipelines/tauh_n10.py b/src/pipelines/tauh_n10.py index 54baf8b..dd59e92 100644 --- a/src/pipelines/tauh_n10.py +++ b/src/pipelines/tauh_n10.py @@ -40,15 +40,14 @@ class TauhN10(ProcessPipeline): def run(self, h5file: h5py.File) -> ProcessResult: metrics: dict[str, float] = {} - artifacts: dict[str, float] = {} for vessel in ("Artery", "Vein"): vessel_result = self._compute_for_vessel(h5file, vessel) prefix = vessel.lower() metrics[f"{prefix}_tauH_{self.harmonic_index}"] = vessel_result.tau metrics[f"{prefix}_X_abs_{self.harmonic_index}"] = vessel_result.x_abs - artifacts[f"{prefix}_vmax"] = vessel_result.vmax - artifacts[f"{prefix}_freq_hz_{self.harmonic_index}"] = vessel_result.freq_hz - return ProcessResult(metrics=metrics, artifacts=artifacts) + metrics[f"{prefix}_vmax"] = vessel_result.vmax + metrics[f"{prefix}_freq_hz_{self.harmonic_index}"] = vessel_result.freq_hz + return ProcessResult(metrics=metrics) def _compute_for_vessel(self, h5file: h5py.File, vessel: str) -> TauHResult: spectral_prefix = "Arterial" if vessel.lower().startswith("arter") else "Venous" diff --git a/src/pipelines/tauh_n10_per_beat.py b/src/pipelines/tauh_n10_per_beat.py index adb485e..19e2a88 100644 --- a/src/pipelines/tauh_n10_per_beat.py +++ b/src/pipelines/tauh_n10_per_beat.py @@ -19,16 +19,14 @@ class TauhN10PerBeat(ProcessPipeline): def run(self, h5file: h5py.File) -> ProcessResult: metrics: dict[str, float] = {} - artifacts: dict[str, float] = {} for vessel in ("Artery", "Vein"): - vessel_metrics, vessel_artifacts = self._compute_per_beat(h5file, vessel) + vessel_metrics = self._compute_per_beat(h5file, vessel) metrics.update(vessel_metrics) - artifacts.update(vessel_artifacts) - return ProcessResult(metrics=metrics, artifacts=artifacts) + return ProcessResult(metrics=metrics) def _compute_per_beat( self, h5file: h5py.File, vessel: str - ) -> tuple[dict[str, float], dict[str, float]]: + ) -> dict[str, float]: n = self.harmonic_index prefix = vessel.lower() # Per-beat FFT amplitudes/phases and per-beat Vmax for the band-limited signal. @@ -94,16 +92,15 @@ def _compute_per_beat( float(math.sqrt(denom) / omega_n) if denom > 0 else math.nan ) - metrics: dict[str, float] = {} - artifacts: dict[str, float] = {f"{prefix}_freq_hz_{n}": freq_n_hz} + metrics: dict[str, float] = {f"{prefix}_freq_hz_{n}": freq_n_hz} for i, tau in enumerate(tau_values): metrics[f"{prefix}_tauH_{n}_beat{i}"] = tau - artifacts[f"{prefix}_vmax_beat{i}"] = vmax_values[i] - artifacts[f"{prefix}_X_abs_{n}_beat{i}"] = x_values[i] + metrics[f"{prefix}_vmax_beat{i}"] = vmax_values[i] + metrics[f"{prefix}_X_abs_{n}_beat{i}"] = x_values[i] metrics[f"{prefix}_tauH_{n}_median"] = ( float(np.nanmedian(tau_values)) if tau_values else math.nan ) metrics[f"{prefix}_tauH_{n}_mean"] = ( float(np.nanmean(tau_values)) if tau_values else math.nan ) - return metrics, artifacts + return metrics From aa40b402638471c95dfa121d2365083adcd45da4 Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Wed, 11 Feb 2026 13:35:52 +0100 Subject: [PATCH 56/71] lint --- src/pipelines/tauh_n10_per_beat.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pipelines/tauh_n10_per_beat.py b/src/pipelines/tauh_n10_per_beat.py index 19e2a88..31aa080 100644 --- a/src/pipelines/tauh_n10_per_beat.py +++ b/src/pipelines/tauh_n10_per_beat.py @@ -24,9 +24,7 @@ def run(self, h5file: h5py.File) -> ProcessResult: metrics.update(vessel_metrics) return ProcessResult(metrics=metrics) - def _compute_per_beat( - self, h5file: h5py.File, vessel: str - ) -> dict[str, float]: + def _compute_per_beat(self, h5file: h5py.File, vessel: str) -> dict[str, float]: n = self.harmonic_index prefix = vessel.lower() # Per-beat FFT amplitudes/phases and per-beat Vmax for the band-limited signal. From 99db1e3b8b37b1e09ccc86a7f283e13fcf119738 Mon Sep 17 00:00:00 2001 From: gregoire Date: Thu, 12 Feb 2026 12:18:01 +0100 Subject: [PATCH 57/71] clean R_VTI tau_M1 and RI both global and bandlimited metrics pipeline code --- ...rterial_velocity_waveform_shape_metrics.py | 139 ++++++++++++------ 1 file changed, 92 insertions(+), 47 deletions(-) diff --git a/src/pipelines/arterial_velocity_waveform_shape_metrics.py b/src/pipelines/arterial_velocity_waveform_shape_metrics.py index 02c714a..cd0fb2a 100644 --- a/src/pipelines/arterial_velocity_waveform_shape_metrics.py +++ b/src/pipelines/arterial_velocity_waveform_shape_metrics.py @@ -3,7 +3,7 @@ from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs -@registerPipeline(name="arterialformshape") +@registerPipeline(name="arterial_waveform_shape_metrics") class ArterialExample(ProcessPipeline): """ Tutorial pipeline showing the full surface area of a pipeline: @@ -16,61 +16,106 @@ class ArterialExample(ProcessPipeline): """ description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." - v_raw = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" - v = "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" + v_raw_input = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" + v_bandlimited_input = ( + "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" + ) T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" - vmax = "/Artery/VelocityPerBeat/VmaxPerBeatBandLimited/value" - vmin = "/Artery/VelocityPerBeat/VminPerBeatBandLimited/value" + v_bandlimited_max_input = "/Artery/VelocityPerBeat/VmaxPerBeatBandLimited/value" + v_bandlimited_min_input = "/Artery/VelocityPerBeat/VminPerBeatBandLimited/value" def run(self, h5file) -> ProcessResult: - vraw_ds_temp = np.asarray(h5file[self.v_raw]) - vraw_ds = np.maximum(vraw_ds_temp, 0) - v_ds_temp = np.asarray(h5file[self.v]) - v_ds = np.maximum(v_ds_temp, 0) - t_ds = np.asarray(h5file[self.T]) - V_max = np.asarray(h5file[self.vmax]) - V_min = np.asarray(h5file[self.vmin]) - TMI_raw = [] - RTVI = [] - RTVI_raw = [] - for k in range(len(t_ds[0])): - D1_raw = np.sum(vraw_ds.T[k][:31]) - D2_raw = np.sum(vraw_ds.T[k][32:]) - D1 = np.sum(v_ds.T[k][:31]) - D2 = np.sum(v_ds.T[k][32:]) - RTVI.append(D1 / (D2 + 10 ** (-12))) - RTVI_raw.append(D1_raw / (D2_raw + 10 ** (-12))) - M_0 = np.sum(vraw_ds.T[k]) + v_raw = np.asarray(h5file[self.v_raw_input]) + v_raw = np.maximum(v_raw, 0) + v_bandlimited = np.asarray(h5file[self.v_bandlimited_input]) + v_bandlimited = np.maximum(v_bandlimited, 0) + T_ds = np.asarray(h5file[self.T]) + v_bandlimited_max = np.asarray(h5file[self.v_bandlimited_max_input]) + v_bandlimited_max = np.maximum(v_bandlimited_max, 0) + v_bandlimited_min = np.asarray(h5file[self.v_bandlimited_min_input]) + v_bandlimited_min = np.maximum(v_bandlimited_min, 0) + tau_M1_raw = [] + tau_M1_over_T_raw = [] + tau_M1_bandlimited = [] + tau_M1_over_T_bandlimited = [] + + R_VTI_bandlimited = [] + R_VTI_raw = [] + + RI_bandlimited = [] + RI_raw = [] + + ratio_systole_diastole_R_VTI = 0.5 + + for beat_idx in range(len(T_ds[0])): + t = T_ds[0][beat_idx] / len(v_raw.T[beat_idx]) + D1_raw = np.sum( + v_raw.T[beat_idx][ + : int(np.ceil(len(v_raw.T[0]) * ratio_systole_diastole_R_VTI)) + ] + ) + D2_raw = np.sum( + v_raw.T[beat_idx][ + int(np.ceil(len(v_raw.T[0]) * ratio_systole_diastole_R_VTI)) : + ] + ) + D1_bandlimited = np.sum( + v_bandlimited.T[beat_idx][ + : int( + np.ceil(len(v_bandlimited.T[0]) * ratio_systole_diastole_R_VTI) + ) + ] + ) + D2_bandlimited = np.sum( + v_bandlimited.T[beat_idx][ + int( + np.ceil(len(v_bandlimited.T[0]) * ratio_systole_diastole_R_VTI) + ) : + ] + ) + R_VTI_bandlimited.append(D2_bandlimited / (D1_bandlimited + 10 ** (-12))) + R_VTI_raw.append(D2_raw / (D1_raw + 10 ** (-12))) + M_0 = np.sum(v_raw.T[beat_idx]) M_1 = 0 - for i in range(len(vraw_ds.T[k])): - M_1 += vraw_ds[i][k] * i * t_ds[0][k] / 64 - TM1 = M_1 / (t_ds[0][k] * M_0) - TMI_raw.append(TM1) - TMI = [] - for k in range(len(t_ds[0])): - M_0 = np.sum(v_ds.T[k]) + for time_idx in range(len(v_raw.T[beat_idx])): + M_1 += v_raw[time_idx][beat_idx] * time_idx * t + TM1 = M_1 / M_0 + tau_M1_raw.append(TM1) + tau_M1_over_T_raw.append(TM1 / T_ds[0][beat_idx]) + + for beat_idx in range(len(T_ds[0])): + t = T_ds[0][beat_idx] / len(v_raw.T[beat_idx]) + M_0 = np.sum(v_bandlimited.T[beat_idx]) M_1 = 0 - for i in range(len(vraw_ds.T[k])): - M_1 += v_ds[i][k] * i * t_ds[0][k] / 64 - TM1 = M_1 / (t_ds[0][k] * M_0) - TMI.append(TM1) - RI = [] - for i in range(len(V_max[0])): - RI_temp = 1 - (V_min[0][i] / V_max[0][i]) - RI.append(RI_temp) - RI_raw = [] - for i in range(len(V_max[0])): - RI_temp = 1 - (np.min(vraw_ds.T[i]) / np.max(vraw_ds.T[i])) - RI_raw.append(RI_temp) + for time_idx in range(len(v_raw.T[beat_idx])): + M_1 += v_bandlimited[time_idx][beat_idx] * time_idx * t + TM1 = M_1 / M_0 + tau_M1_bandlimited.append(TM1) + tau_M1_over_T_bandlimited.append(TM1 / T_ds[0][beat_idx]) + + for beat_idx in range(len(v_bandlimited_max[0])): + RI_bandlimited_temp = 1 - ( + v_bandlimited_min[0][beat_idx] / v_bandlimited_max[0][beat_idx] + ) + RI_bandlimited.append(RI_bandlimited_temp) + + for beat_idx in range(len(v_bandlimited_max[0])): + RI_raw_temp = 1 - (np.min(v_raw.T[beat_idx]) / np.max(v_raw.T[beat_idx])) + RI_raw.append(RI_raw_temp) # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. metrics = { - "TMI_raw": with_attrs(np.asarray(TMI_raw), {"unit": [""]}), - "TMI": np.asarray(TMI), - "RI": np.asarray(RI), + "tau_M1_raw": with_attrs(np.asarray(tau_M1_raw), {"unit": [""]}), + "tau_M1_bandlimited": np.asarray(tau_M1_bandlimited), + "tau_M1_over_T_raw": with_attrs( + np.asarray(tau_M1_over_T_raw), {"unit": [""]} + ), + "tau_M1_over_T_bandlimited": np.asarray(tau_M1_over_T_bandlimited), + "RI_bandlimited": np.asarray(RI_bandlimited), "RI_raw": np.asarray(RI_raw), - "RTVI": np.asarray(RTVI), - "RTVI_raw": np.asarray(RTVI_raw), + "R_VTI_bandlimited": np.asarray(R_VTI_bandlimited), + "R_VTI_raw": np.asarray(R_VTI_raw), + "ratio_systole_diastole_R_VTI": np.asarray(ratio_systole_diastole_R_VTI), } # Artifacts can store non-metric outputs (strings, paths, etc.). From 663b1d9b5ea689099a0ddf724ad31748a6b6c46e Mon Sep 17 00:00:00 2001 From: maxBA1602 Date: Thu, 12 Feb 2026 12:47:18 +0100 Subject: [PATCH 58/71] lint --- src/pipelines/recreatesig.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/pipelines/recreatesig.py b/src/pipelines/recreatesig.py index 57a3aa8..7aca14a 100644 --- a/src/pipelines/recreatesig.py +++ b/src/pipelines/recreatesig.py @@ -27,17 +27,17 @@ def gaussian(x, A, mu, sigma, c): def run(self, h5file) -> ProcessResult: v_seg = np.asarray(h5file[self.v_profile]) - t_ds = np.asarray(h5file[self.T_val]) + # t_ds = np.asarray(h5file[self.T_val]) V = [] threshold = 3 V_corrected = [] V_ceil = [] - V_gauss = [] + # V_gauss = [] for k in range(len(v_seg[0, :, 0, 0])): - VIT_Time = 0 + # VIT_Time = 0 Vit_br = [] for br in range(len(v_seg[0, k, :, 0])): v_branch = np.nanmean(v_seg[:, k, br, :], axis=1) @@ -74,15 +74,16 @@ def run(self, h5file) -> ProcessResult: ) : ] Vit_br.append(np.nanmean(temp + test)) - except: + except Exception: # noqa: BLE001 Vit_br.append(np.nan) + return None Vit.append(np.nanmean(Vit_br)) V_corrected.append(np.nanmean(Vit)) for k in range(len(v_seg[0, :, 0, 0])): Vit = [] - Vit_gauss = [] + # Vit_gauss = [] for br in range(len(v_seg[0, k, :, 0])): Vit_br = [] for seg in range(len(v_seg[0, k, br, :])): @@ -112,8 +113,9 @@ def run(self, h5file) -> ProcessResult: + values[last - threshold :] ) ) - except: + except Exception: # noqa: BLE001 Vit_br.append(np.nan) + return None Vit.append(np.nanmean(Vit_br)) From 36f69214e3a3aae6d30834297fef8d8af2744d67 Mon Sep 17 00:00:00 2001 From: chloe Date: Thu, 12 Feb 2026 16:09:28 +0100 Subject: [PATCH 59/71] add new metrics RI, tau M1 and RTVI for each branch --- ...egments_velocity_waveform_shape_metrics.py | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 src/pipelines/Segments_velocity_waveform_shape_metrics.py diff --git a/src/pipelines/Segments_velocity_waveform_shape_metrics.py b/src/pipelines/Segments_velocity_waveform_shape_metrics.py new file mode 100644 index 0000000..fc91e1d --- /dev/null +++ b/src/pipelines/Segments_velocity_waveform_shape_metrics.py @@ -0,0 +1,195 @@ +import numpy as np + +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs + + +@registerPipeline(name="segment_waveform_shape_metrics") +class ArterialSegExample(ProcessPipeline): + """ + Tutorial pipeline showing the full surface area of a pipeline: + + - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. + - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. + - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. + - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). + - No input data is required; this pipeline is purely illustrative. + """ + + description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + v_raw_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + v_bandlimited_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" + T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + + + + def run(self, h5file) -> ProcessResult: + + v_raw = np.asarray(h5file[self.v_raw_input]) + v_raw = np.maximum(v_raw, 0) + + v_bandlimited = np.asarray(h5file[self.v_bandlimited_input]) + v_bandlimited = np.maximum(v_bandlimited, 0) + + T = np.asarray(h5file[self.T]) + + moment_0_segment = 0 + moment_1_segment = 0 + + Tau_M1_bandlimited_segment = [] + Tau_M1_over_T_bandlimited_segment = [] + RI_bandlimited_segment = [] + R_VTI_bandlimited_segment = [] + + Tau_M1_raw_segment = [] + Tau_M1_over_T_raw_segment = [] + RI_raw_segment = [] + R_VTI_raw_segment = [] + + ratio_systole_diastole_R_VTI = 0.5 + + for beat_idx in range(len(v_bandlimited[0, :, 0, 0])): + + Tau_M1_bandlimited_branch = [] + Tau_M1_over_T_bandlimited_branch = [] + RI_bandlimited_branch = [] + R_VTI_bandlimited_branch = [] + + + for branch_idx in range(len(v_bandlimited[0, beat_idx, :, 0])): + + v_bandlimited_average = np.nanmean(v_bandlimited[:, beat_idx, branch_idx, :], axis=1) + t = T[0][beat_idx] / len(v_bandlimited_average) + + moment_0_segment += np.sum(v_bandlimited_average) + for time_idx in range(len(v_bandlimited_average)): + moment_1_segment += v_bandlimited_average[time_idx] * time_idx * t + + if moment_0_segment!=0: + TM1=moment_1_segment/moment_0_segment + Tau_M1_bandlimited_branch.append(TM1) + Tau_M1_over_T_bandlimited_branch.append(TM1/T[0][beat_idx]) + else: + Tau_M1_bandlimited_branch.append(0) + Tau_M1_over_T_bandlimited_branch.append(0) + + + v_bandlimited_max=np.max(v_bandlimited_average) + v_bandlimited_min=np.min(v_bandlimited_average) + + RI_bandlimited_branch_idx = 1 - (v_bandlimited_min / v_bandlimited_max) + RI_bandlimited_branch.append(RI_bandlimited_branch_idx) + + epsilon = 10 ** (-12) + D1_bandlimited = np.sum(v_bandlimited_average[: int(np.ceil(len(v_bandlimited_average) * ratio_systole_diastole_R_VTI))]) + D2_bandlimited = np.sum(v_bandlimited_average[int(np.ceil(len(v_bandlimited_average) * ratio_systole_diastole_R_VTI)) :]) + R_VTI_bandlimited_branch.append(D2_bandlimited / (D1_bandlimited + epsilon)) + + Tau_M1_bandlimited_segment.append(Tau_M1_bandlimited_branch) + Tau_M1_over_T_bandlimited_segment.append(Tau_M1_over_T_bandlimited_branch) + RI_bandlimited_segment.append(RI_bandlimited_branch) + R_VTI_bandlimited_segment.append(R_VTI_bandlimited_branch) + + + + + for beat_idx in range(len(v_raw[0, :, 0, 0])): + + Tau_M1_raw_branch = [] + Tau_M1_over_T_raw_branch = [] + RI_raw_branch = [] + R_VTI_raw_branch = [] + + + for branch_idx in range(len(v_raw[0, beat_idx, :, 0])): + + v_raw_average = np.nanmean(v_raw[:, beat_idx, branch_idx, :], axis=1) + t = T[0][beat_idx] / len(v_raw_average) + + moment_0_segment += np.sum(v_raw_average) + for time_idx in range(len(v_raw_average)): + moment_1_segment += v_raw_average[time_idx] * time_idx * t + + if moment_0_segment!=0: + TM1=moment_1_segment/moment_0_segment + Tau_M1_raw_branch.append(TM1) + Tau_M1_over_T_raw_branch.append(TM1/T[0][beat_idx]) + else: + Tau_M1_raw_branch.append(0) + Tau_M1_over_T_raw_branch.append(0) + + + v_raw_max=np.max(v_raw_average) + v_raw_min=np.min(v_raw_average) + + RI_raw_branch_idx = 1 - (v_raw_min / v_raw_max) + RI_raw_branch.append(RI_raw_branch_idx) + + epsilon = 10 ** (-12) + D1_raw = np.sum(v_raw_average[: int(np.ceil(len(v_raw_average) * ratio_systole_diastole_R_VTI))]) + D2_raw = np.sum(v_raw_average[int(np.ceil(len(v_raw_average) * ratio_systole_diastole_R_VTI)) :]) + R_VTI_raw_branch.append(D2_raw / (D1_raw + epsilon)) + + Tau_M1_raw_segment.append(Tau_M1_raw_branch) + Tau_M1_over_T_raw_segment.append(Tau_M1_over_T_raw_branch) + RI_raw_segment.append(RI_raw_branch) + R_VTI_raw_segment.append(R_VTI_raw_branch) + + + + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + metrics = { + "tau_M1_bandlimited_segment": with_attrs( + np.asarray(Tau_M1_bandlimited_segment), + { + "unit": [""], + }, + ), + "R_VTI_bandlimited_segment": with_attrs( + np.asarray( R_VTI_bandlimited_segment), + { + "unit": [""], + }, + ), + "RI_bandlimited_segment": with_attrs( + np.asarray(RI_bandlimited_segment), + { + "unit": [""], + }, + ), + "tau_M1_over_T_bandlimited_segment": with_attrs( + np.asarray(Tau_M1_over_T_bandlimited_segment), + { + "unit": [""], + }, + ), + + "tau_M1_raw_segment": with_attrs( + np.asarray(Tau_M1_raw_segment), + { + "unit": [""], + }, + ), + "R_VTI_raw_segment": with_attrs( + np.asarray( R_VTI_raw_segment), + { + "unit": [""], + }, + ), + "RI_raw_segment": with_attrs( + np.asarray(RI_raw_segment), + { + "unit": [""], + }, + ), + "tau_M1_over_T_raw_segment": with_attrs( + np.asarray(Tau_M1_over_T_raw_segment), + { + "unit": [""], + }, + ), + "ratio_systole_diastole_R_VTI": np.asarray(ratio_systole_diastole_R_VTI), + } + + # Artifacts can store non-metric outputs (strings, paths, etc.). + + return ProcessResult(metrics=metrics) From 6a062a686a01997947ec4acd5e53b4bf6954db85 Mon Sep 17 00:00:00 2001 From: chloe Date: Fri, 13 Feb 2026 11:47:16 +0100 Subject: [PATCH 60/71] new version : metrics R_VTI , RI, TauM1 for each branch --- ...segment_velocity_waveform_shape_metrics.py | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 src/pipelines/segment_velocity_waveform_shape_metrics.py diff --git a/src/pipelines/segment_velocity_waveform_shape_metrics.py b/src/pipelines/segment_velocity_waveform_shape_metrics.py new file mode 100644 index 0000000..ab1fcd3 --- /dev/null +++ b/src/pipelines/segment_velocity_waveform_shape_metrics.py @@ -0,0 +1,226 @@ +import numpy as np + +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs + + +@registerPipeline(name="segment_waveform_shape_metrics") +class ArterialSegExample(ProcessPipeline): + """ + Tutorial pipeline showing the full surface area of a pipeline: + + - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. + - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. + - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. + - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). + - No input data is required; this pipeline is purely illustrative. + """ + + description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + v_raw_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + v_bandlimited_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" + T_input = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + + + + def run(self, h5file) -> ProcessResult: + + v_raw = np.asarray(h5file[self.v_raw_input]) + v_raw = np.maximum(v_raw, 0) + + v_bandlimited = np.asarray(h5file[self.v_bandlimited_input]) + v_bandlimited = np.maximum(v_bandlimited, 0) + + T = np.asarray(h5file[self.T_input]) + + moment_0_segment = 0 + moment_1_segment = 0 + + Tau_M1_bandlimited_segment = [] + Tau_M1_over_T_bandlimited_segment = [] + RI_bandlimited_segment = [] + R_VTI_bandlimited_segment = [] + + Tau_M1_raw_segment = [] + Tau_M1_over_T_raw_segment = [] + RI_raw_segment = [] + R_VTI_raw_segment = [] + + ratio_systole_diastole_R_VTI = 0.5 + + for beat_idx in range(len(v_bandlimited[0, :, 0, 0])): + + Tau_M1_bandlimited_branch = [] + Tau_M1_over_T_bandlimited_branch = [] + RI_bandlimited_branch = [] + R_VTI_bandlimited_branch = [] + + t = T[0][beat_idx] / len(v_bandlimited) + + for branch_idx in range(len(v_bandlimited[0, beat_idx, :, 0])): + + Tau_M1_bandlimited_radius = [] + Tau_M1_over_T_bandlimited_radius = [] + RI_bandlimited_radius = [] + R_VTI_bandlimited_radius = [] + + for radius_idx in range (len(v_bandlimited[0, beat_idx, branch_idx, :])): + + v_bandlimited_idx = v_bandlimited[:,beat_idx,branch_idx,radius_idx] + + moment_0_segment = np.sum(v_bandlimited_idx) + moment_1_segment = 0 + + for time_idx in range(len(v_bandlimited_idx)): + moment_1_segment += v_bandlimited_idx[time_idx] * time_idx * t + + + TM1=moment_1_segment/moment_0_segment + Tau_M1_bandlimited_radius.append(TM1) + Tau_M1_over_T_bandlimited_radius.append(TM1/T[0][beat_idx]) + + + + v_bandlimited_max=np.max(v_bandlimited_idx) + v_bandlimited_min=np.min(v_bandlimited_idx) + + RI_bandlimited_radius_idx = 1 - (v_bandlimited_min / v_bandlimited_max) + RI_bandlimited_radius.append(RI_bandlimited_radius_idx) + + epsilon = 10 ** (-12) + D1_bandlimited = np.sum(v_bandlimited_idx[: int(np.ceil(len(v_bandlimited_idx) * ratio_systole_diastole_R_VTI))]) + D2_bandlimited = np.sum(v_bandlimited_idx[int(np.ceil(len(v_bandlimited_idx) * ratio_systole_diastole_R_VTI)) :]) + R_VTI_bandlimited_radius.append(D1_bandlimited / (D2_bandlimited + epsilon)) + + Tau_M1_bandlimited_branch.append(Tau_M1_bandlimited_radius) + Tau_M1_over_T_bandlimited_branch.append(Tau_M1_over_T_bandlimited_radius) + RI_bandlimited_branch.append(RI_bandlimited_radius) + R_VTI_bandlimited_branch.append(R_VTI_bandlimited_radius) + + Tau_M1_bandlimited_segment.append(Tau_M1_bandlimited_branch) + Tau_M1_over_T_bandlimited_segment.append(Tau_M1_over_T_bandlimited_branch) + RI_bandlimited_segment.append(RI_bandlimited_branch) + R_VTI_bandlimited_segment.append(R_VTI_bandlimited_branch) + + + for beat_idx in range(len(v_raw[0, :, 0, 0])): + + Tau_M1_raw_branch = [] + Tau_M1_over_T_raw_branch = [] + RI_raw_branch = [] + R_VTI_raw_branch = [] + + + t = T[0][beat_idx] / len(v_raw) + + for branch_idx in range(len(v_raw[0, beat_idx, :, 0])): + + Tau_M1_raw_radius = [] + Tau_M1_over_T_raw_radius = [] + RI_raw_radius = [] + R_VTI_raw_radius = [] + + + for radius_idx in range (len(v_raw[0, beat_idx, branch_idx, :])): + + v_raw_idx = v_raw[:,beat_idx,branch_idx,radius_idx] + + + moment_0_segment = np.sum(v_raw_idx) + moment_1_segment = 0 + for time_idx in range(len(v_raw_idx)): + moment_1_segment += v_raw_idx[time_idx] * time_idx * t + + + TM1=moment_1_segment/moment_0_segment + Tau_M1_raw_radius.append(TM1) + Tau_M1_over_T_raw_radius.append(TM1/T[0][beat_idx]) + + + + v_raw_max=np.max(v_raw_idx) + v_raw_min=np.min(v_raw_idx) + + RI_raw_radius_idx = 1 - (v_raw_min / v_raw_max) + RI_raw_radius.append(RI_raw_radius_idx) + + epsilon = 10 ** (-12) + D1_raw = np.sum(v_raw_idx[: int(np.ceil(len(v_raw_idx) * ratio_systole_diastole_R_VTI))]) + D2_raw = np.sum(v_raw_idx[int(np.ceil(len(v_raw_idx) * ratio_systole_diastole_R_VTI)) :]) + R_VTI_raw_radius.append(D1_raw / (D2_raw + epsilon)) + + + Tau_M1_raw_branch.append(Tau_M1_raw_radius) + Tau_M1_over_T_raw_branch.append(Tau_M1_over_T_raw_radius) + RI_raw_branch.append(RI_raw_radius) + R_VTI_raw_branch.append(R_VTI_raw_radius) + + + + Tau_M1_raw_segment.append(Tau_M1_raw_branch) + Tau_M1_over_T_raw_segment.append(Tau_M1_over_T_raw_branch) + RI_raw_segment.append(RI_raw_branch) + R_VTI_raw_segment.append(R_VTI_raw_branch) + + + + + + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + metrics = { + "tau_M1_bandlimited_segment": with_attrs( + np.asarray(Tau_M1_bandlimited_segment), + { + "unit": [""], + }, + ), + "R_VTI_bandlimited_segment": with_attrs( + np.asarray( R_VTI_bandlimited_segment), + { + "unit": [""], + }, + ), + "RI_bandlimited_segment": with_attrs( + np.asarray(RI_bandlimited_segment), + { + "unit": [""], + }, + ), + "tau_M1_over_T_bandlimited_segment": with_attrs( + np.asarray(Tau_M1_over_T_bandlimited_segment), + { + "unit": [""], + }, + ), + + "tau_M1_raw_segment": with_attrs( + np.asarray(Tau_M1_raw_segment), + { + "unit": [""], + }, + ), + "R_VTI_raw_segment": with_attrs( + np.asarray( R_VTI_raw_segment), + { + "unit": [""], + }, + ), + "RI_raw_segment": with_attrs( + np.asarray(RI_raw_segment), + { + "unit": [""], + }, + ), + "tau_M1_over_T_raw_segment": with_attrs( + np.asarray(Tau_M1_over_T_raw_segment), + { + "unit": [""], + }, + ), + "ratio_systole_diastole_R_VTI": np.asarray(ratio_systole_diastole_R_VTI), + } + + # Artifacts can store non-metric outputs (strings, paths, etc.). + + return ProcessResult(metrics=metrics) + + From 55433c62615b031bfeff0b13c7a8ce57125b57eb Mon Sep 17 00:00:00 2001 From: gregoire Date: Fri, 13 Feb 2026 17:11:24 +0100 Subject: [PATCH 61/71] metrics evalutaion on a factor 2 reduced sampling frequency with to approaches, crop and ceil base on velocity_seg_interponebeat --- .../signal_reconstruction_factor_reduced.py | 563 ++++++++++++++++++ 1 file changed, 563 insertions(+) create mode 100644 src/pipelines/signal_reconstruction_factor_reduced.py diff --git a/src/pipelines/signal_reconstruction_factor_reduced.py b/src/pipelines/signal_reconstruction_factor_reduced.py new file mode 100644 index 0000000..7224176 --- /dev/null +++ b/src/pipelines/signal_reconstruction_factor_reduced.py @@ -0,0 +1,563 @@ +import numpy as np + +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs + + +@registerPipeline(name="signal_reconstruction") +class Reconstruct(ProcessPipeline): + """ + Tutorial pipeline showing the full surface area of a pipeline: + + - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. + - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. + - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. + - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). + - No input data is required; this pipeline is purely illustrative. + """ + + description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + v_profile = "/Artery/CrossSections/VelocityProfileSeg/value" + v_profile_interp_onebeat = ( + "/Artery/CrossSections/VelocityProfilesSegInterpOneBeat/value" + ) + vsystol = "/Artery/Velocity/SystolicAccelerationPeakIndexes" + T_input = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + systole_idx_input = "/Artery/WaveformAnalysis/SystoleIndices/value" + + def gaussian(x, A, mu, sigma, c): + return A * np.exp(-((x - mu) ** 2) / (2 * sigma**2)) + c + + def run(self, h5file) -> ProcessResult: + v_seg = np.maximum(np.asarray(h5file[self.v_profile]), 0) + v_interp_onebeat = np.maximum( + np.asarray(h5file[self.v_profile_interp_onebeat]), 0 + ) + systole_idx = np.asarray(h5file[self.systole_idx_input]) + T = np.asarray(h5file[self.T_input]) + + V = [] + v_profile_beat_threshold = [] + v_profile_beat_ceiled_threshold = [] + v_profile_beat_cropped_threshold = [] + + for beat in range(len(T[0])): + vit_beat = [] + for time_idx in range( + int(systole_idx[0][beat]), int(systole_idx[0][beat + 1]) + ): + Vit_br = [] + for br in range(len(v_seg[0, time_idx, :, 0])): + vit_seg = [] + for segment in range(len(v_seg[0, time_idx, br, :])): + vit_seg.append(v_seg[:, time_idx, br, segment]) + Vit_br.append(vit_seg) + + vit_beat.append(Vit_br) + V.append(vit_beat) + threshold = 6 + + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + """for threshold_idx in range(threshold + 1): + v_profile_beat = [] + v_profile_beat_ceiled = [] + v_profile_beat_cropped = [] + for beat in range(len(T[0])): + vit_beat = [] + vit_beat_ceiled = [] + vit_beat_cropped = [] + v_raw_temp = np.asarray(V[beat]) + for time_idx in range(len(v_raw_temp[:, 0, 0, 0])): + vit_br = [] + vit_br_ceiled = [] + vit_br_cropped = [] + for br in range(len(v_raw_temp[time_idx, :, 0, 0])): + vit_seg = [] + vit_seg_ceiled = [] + vit_seg_cropped = [] + for segment in range(len(v_raw_temp[time_idx, br, :, 0])): + values_temp = v_raw_temp[time_idx, br, segment, :] + values = list(values_temp) + try: + first = values.index( + next(filter(lambda x: str(x) != "nan", values)) + ) + + other = values[ + np.minimum( + values.index( + next( + filter( + lambda x: str(x) != "nan", values + ) + ) + ), + 17, + ) : + ] + last = first + other.index( + next(filter(lambda x: str(x) == "nan", other)) + ) + + ceil_completion = [ + values[first + threshold_idx - 1] + for v in values[ + first + threshold_idx : last - threshold_idx + ] + ] + v_seg_band_ceiled = ( + values[first : first + threshold_idx] + + ceil_completion + + values[last - threshold_idx : last] + ) + vit_seg_cropped.append( + np.nanmean( + values[first : first + threshold_idx] + + values[last - threshold_idx : last] + ) + ) + vit_seg_ceiled.append(np.nanmean(v_seg_band_ceiled)) + vit_seg.append(np.nanmean(values_temp)) + except Exception: # noqa: BLE001 + vit_seg_cropped.append(np.nan) + vit_seg_ceiled.append(np.nan) + vit_seg.append(np.nanmean(values_temp)) + continue + + vit_br.append(vit_seg) + vit_br_ceiled.append(vit_seg_ceiled) + vit_br_cropped.append(vit_seg_cropped) + + vit_beat.append(vit_br) + vit_beat_ceiled.append(vit_br_ceiled) + vit_beat_cropped.append(vit_br_cropped) + + v_profile_beat.append(vit_beat) + v_profile_beat_ceiled.append(vit_beat_ceiled) + v_profile_beat_cropped.append(vit_beat_cropped) + v_profile_beat_threshold.append(v_profile_beat) + v_profile_beat_ceiled_threshold.append(v_profile_beat_ceiled) + v_profile_beat_cropped_threshold.append(v_profile_beat_cropped) + target_len = 128 + n_beats = len(v_profile_beat) + n_branches = len(v_profile_beat[0][0]) + n_segments = len(v_profile_beat[0][0][0]) + v_threshold_beat_segment = np.zeros( + (threshold + 1, n_beats, target_len, n_branches, n_segments) + ) + v_threshold_beat_segment_cropped = np.zeros( + (threshold + 1, n_beats, target_len, n_branches, n_segments) + ) + v_threshold_beat_segment_ceiled = np.zeros( + (threshold + 1, n_beats, target_len, n_branches, n_segments) + ) + for threshold_idx in range(threshold + 1): + for beat in range(n_beats): + beat_data = np.asarray(v_profile_beat_threshold[threshold_idx][beat]) + beat_data_ceiled = np.asarray( + v_profile_beat_ceiled_threshold[threshold_idx][beat] + ) + beat_data_cropped = np.asarray( + v_profile_beat_cropped_threshold[threshold_idx][beat] + ) + # shape: (time_len, branches, segments) + + time_len = beat_data.shape[0] + + old_indices = np.linspace(0, 1, time_len) + new_indices = np.linspace(0, 1, target_len) + + for br in range(n_branches): + for seg in range(n_segments): + signal = beat_data[:, br, seg] + signal_ceiled = beat_data_ceiled[:, br, seg] + signal_cropped = beat_data_cropped[:, br, seg] + + new_values = np.interp(new_indices, old_indices, signal) + new_values_ceiled = np.interp( + new_indices, old_indices, signal_ceiled + ) + new_values_cropped = np.interp( + new_indices, old_indices, signal_cropped + ) + + v_threshold_beat_segment[threshold_idx, beat, :, br, seg] = ( + new_values + ) + v_threshold_beat_segment_cropped[ + threshold_idx, beat, :, br, seg + ] = new_values_ceiled + v_threshold_beat_segment_ceiled[ + threshold_idx, beat, :, br, seg + ] = new_values_cropped""" + v_raw_temp = np.asarray(v_interp_onebeat) + for threshold_idx in range(threshold + 1): + v_profile = [] + v_profile_ceiled = [] + v_profile_cropped = [] + + for time_idx in range(len(v_raw_temp[:, 0, 0, 0])): + vit_br = [] + vit_br_ceiled = [] + vit_br_cropped = [] + for br in range(len(v_raw_temp[time_idx, 0, :, 0])): + vit_seg = [] + vit_seg_ceiled = [] + vit_seg_cropped = [] + for segment in range(len(v_raw_temp[time_idx, 0, 0, :])): + values_temp = v_raw_temp[time_idx, :, br, segment] + values = list(values_temp) + try: + first = values.index( + next(filter(lambda x: str(x) != "nan", values)) + ) + + other = values[ + np.minimum( + values.index( + next(filter(lambda x: str(x) != "nan", values)) + ), + 17, + ) : + ] + last = first + other.index( + next(filter(lambda x: str(x) == "nan", other)) + ) + len_signal = last - first + ceil_completion = [ + values[first + threshold_idx - 1] + for v in values[ + int( + np.minimum( + first + threshold_idx, + np.floor(len_signal / 3), + ) + ) : int( + np.maximum( + last - threshold_idx, + np.ceil(len_signal * 2 / 3), + ) + ) + ] + ] + v_seg_band_ceiled = ( + values[ + first : int( + np.minimum( + first + threshold_idx, + np.floor(len_signal / 3), + ) + ) + ] + + ceil_completion + + values[ + int( + np.maximum( + last - threshold_idx, + np.ceil(len_signal * 2 / 3), + ) + ) : last + ] + ) + vit_seg_cropped.append( + np.nanmean( + values[ + first : int( + np.minimum( + first + threshold_idx, + np.floor(len_signal / 3), + ) + ) + ] + + values[ + int( + np.maximum( + last - threshold_idx, + np.ceil(len_signal * 2 / 3), + ) + ) : last + ] + ) + ) + vit_seg_ceiled.append(np.nanmean(v_seg_band_ceiled)) + vit_seg.append(np.nanmean(values_temp)) + except Exception: # noqa: BLE001 + vit_seg_cropped.append(np.nan) + vit_seg_ceiled.append(np.nan) + vit_seg.append(np.nanmean(values_temp)) + continue + + vit_br.append(vit_seg) + vit_br_ceiled.append(vit_seg_ceiled) + vit_br_cropped.append(vit_seg_cropped) + + v_profile.append(vit_br) + v_profile_ceiled.append(vit_br_ceiled) + v_profile_cropped.append(vit_br_cropped) + v_profile_beat_threshold.append(v_profile) + v_profile_beat_ceiled_threshold.append(v_profile_ceiled) + v_profile_beat_cropped_threshold.append(v_profile_cropped) + v_raw = np.asarray(v_profile_beat_threshold) + v_raw = np.maximum(v_raw, 0) + v_raw_ceiled = np.asarray(v_profile_beat_ceiled_threshold) + v_raw_ceiled = np.maximum(v_raw_ceiled, 0) + v_raw_cropped = np.asarray(v_profile_beat_cropped_threshold) + v_raw_cropped = np.maximum(v_raw_cropped, 0) + + moment_1_segment = 0 + + moment_1_segment_cropped = 0 + + moment_1_segment_ceiled = 0 + + Tau_M1_raw_segment = [] + Tau_M1_over_T_raw_segment = [] + RI_raw_segment = [] + R_VTI_raw_segment = [] + + Tau_M1_raw_segment_cropped = [] + Tau_M1_over_T_raw_segment_cropped = [] + RI_raw_segment_cropped = [] + R_VTI_raw_segment_cropped = [] + + Tau_M1_raw_segment_ceiled = [] + Tau_M1_over_T_raw_segment_ceiled = [] + RI_raw_segment_ceiled = [] + R_VTI_raw_segment_ceiled = [] + + ratio_systole_diastole_R_VTI = 0.5 + + for threshold_idx in range(len(v_raw[:, 0, 0, 0])): + Tau_M1_raw_global = [] + Tau_M1_over_T_raw_global = [] + RI_raw_global = [] + R_VTI_raw_global = [] + + Tau_M1_raw_global_ceiled = [] + Tau_M1_over_T_raw_global_ceiled = [] + RI_raw_global_ceiled = [] + R_VTI_raw_global_ceiled = [] + + Tau_M1_raw_global_cropped = [] + Tau_M1_over_T_raw_global_cropped = [] + RI_raw_global_cropped = [] + R_VTI_raw_global_cropped = [] + + for branch_idx in range(len(v_raw[threshold_idx, 0, :, 0])): + Tau_M1_raw_branch = [] + Tau_M1_over_T_raw_branch = [] + RI_raw_branch = [] + R_VTI_raw_branch = [] + + Tau_M1_raw_branch_ceiled = [] + Tau_M1_over_T_raw_branch_ceiled = [] + RI_raw_branch_ceiled = [] + R_VTI_raw_branch_ceiled = [] + + Tau_M1_raw_branch_cropped = [] + Tau_M1_over_T_raw_branch_cropped = [] + RI_raw_branch_cropped = [] + R_VTI_raw_branch_cropped = [] + for radius_idx in range(len(v_raw[threshold_idx, 0, 0, :])): + v_raw_average = np.nanmean( + v_raw[threshold_idx, :, branch_idx, :], axis=1 + ) + v_raw_ceiled_average = np.nanmean( + v_raw_ceiled[threshold_idx, :, branch_idx, :], axis=1 + ) + v_raw_cropped_average = np.nanmean( + v_raw_cropped[threshold_idx, :, branch_idx, :], axis=1 + ) + t = T[0][0] / len(v_raw_average) + + moment_0_segment = np.nansum(v_raw_average) + moment_0_segment_cropped = np.nansum(v_raw_cropped_average) + moment_0_segment_ceiled = np.nansum(v_raw_ceiled_average) + moment_1_segment = 0 + + moment_1_segment_cropped = 0 + + moment_1_segment_ceiled = 0 + for time_idx in range(len(v_raw_average)): + moment_1_segment += v_raw_average[time_idx] * time_idx * t + + moment_1_segment_cropped += ( + v_raw_cropped_average[time_idx] * time_idx * t + ) + + moment_1_segment_ceiled += ( + v_raw_ceiled_average[time_idx] * time_idx * t + ) + + if moment_0_segment != 0: + TM1 = moment_1_segment / moment_0_segment + Tau_M1_raw_branch.append(TM1) + Tau_M1_over_T_raw_branch.append(TM1 / T[0][0]) + else: + Tau_M1_raw_branch.append(0) + Tau_M1_over_T_raw_branch.append(0) + if moment_0_segment_cropped != 0: + TM1_cropped = ( + moment_1_segment_cropped / moment_0_segment_cropped + ) + Tau_M1_raw_branch_cropped.append(TM1_cropped) + Tau_M1_over_T_raw_branch_cropped.append(TM1_cropped / T[0][0]) + else: + Tau_M1_raw_branch_cropped.append(0) + Tau_M1_over_T_raw_branch_cropped.append(0) + if moment_0_segment_ceiled != 0: + TM1_ceiled = moment_1_segment_ceiled / moment_0_segment_ceiled + Tau_M1_raw_branch_ceiled.append(TM1_ceiled) + Tau_M1_over_T_raw_branch_ceiled.append( + TM1_ceiled / np.nanmean(T[0][:]) + ) + else: + Tau_M1_raw_branch_ceiled.append(0) + Tau_M1_over_T_raw_branch_ceiled.append(0) + + v_raw_max = np.max(v_raw_average) + v_raw_min = np.min(v_raw_average) + v_raw_max_cropped = np.max(v_raw_cropped_average) + v_raw_min_cropped = np.min(v_raw_cropped_average) + v_raw_max_ceiled = np.max(v_raw_ceiled_average) + v_raw_min_ceiled = np.min(v_raw_ceiled_average) + + RI_raw_branch_idx = 1 - (v_raw_min / v_raw_max) + RI_raw_branch.append(RI_raw_branch_idx) + RI_raw_branch_idx_cropped = 1 - ( + v_raw_min_cropped / v_raw_max_cropped + ) + RI_raw_branch_cropped.append(RI_raw_branch_idx_cropped) + RI_raw_branch_idx_ceiled = 1 - (v_raw_min_ceiled / v_raw_max_ceiled) + RI_raw_branch_ceiled.append(RI_raw_branch_idx_ceiled) + + epsilon = 10 ** (-12) + D1_raw = np.sum( + v_raw_average[ + : int( + np.ceil( + len(v_raw_average) * ratio_systole_diastole_R_VTI + ) + ) + ] + ) + D1_raw_cropped = np.sum( + v_raw_cropped_average[ + : int( + np.ceil( + len(v_raw_cropped_average) + * ratio_systole_diastole_R_VTI + ) + ) + ] + ) + D1_raw_ceiled = np.sum( + v_raw_ceiled_average[ + : int( + np.ceil( + len(v_raw_ceiled_average) + * ratio_systole_diastole_R_VTI + ) + ) + ] + ) + D2_raw = np.sum( + v_raw_average[ + int( + np.ceil( + len(v_raw_average) * ratio_systole_diastole_R_VTI + ) + ) : + ] + ) + D2_raw_cropped = np.sum( + v_raw_cropped_average[ + int( + np.ceil( + len(v_raw_cropped_average) + * ratio_systole_diastole_R_VTI + ) + ) : + ] + ) + D2_raw_ceiled = np.sum( + v_raw_ceiled_average[ + int( + np.ceil( + len(v_raw_ceiled_average) + * ratio_systole_diastole_R_VTI + ) + ) : + ] + ) + R_VTI_raw_branch.append(D1_raw / (D2_raw + epsilon)) + R_VTI_raw_branch_cropped.append( + D1_raw_cropped / (D2_raw_cropped + epsilon) + ) + R_VTI_raw_branch_ceiled.append( + D1_raw_ceiled / (D2_raw_ceiled + epsilon) + ) + + Tau_M1_raw_global.append(Tau_M1_raw_branch) + Tau_M1_over_T_raw_global.append(Tau_M1_over_T_raw_branch) + RI_raw_global.append(RI_raw_branch) + R_VTI_raw_global.append(R_VTI_raw_branch) + + Tau_M1_raw_global_cropped.append(Tau_M1_raw_branch_cropped) + Tau_M1_over_T_raw_global_cropped.append( + Tau_M1_over_T_raw_branch_cropped + ) + RI_raw_global_cropped.append(RI_raw_branch_cropped) + R_VTI_raw_global_cropped.append(R_VTI_raw_branch_cropped) + + Tau_M1_raw_global_ceiled.append(Tau_M1_raw_branch_ceiled) + Tau_M1_over_T_raw_global_ceiled.append(Tau_M1_over_T_raw_branch_ceiled) + RI_raw_global_ceiled.append(RI_raw_branch_ceiled) + R_VTI_raw_global_ceiled.append(R_VTI_raw_branch_ceiled) + Tau_M1_raw_segment.append(Tau_M1_raw_global) + Tau_M1_over_T_raw_segment.append(Tau_M1_over_T_raw_global) + RI_raw_segment.append(RI_raw_global) + R_VTI_raw_segment.append(R_VTI_raw_global) + + Tau_M1_raw_segment_cropped.append(Tau_M1_raw_global_cropped) + Tau_M1_over_T_raw_segment_cropped.append(Tau_M1_over_T_raw_global_cropped) + RI_raw_segment_cropped.append(RI_raw_global_cropped) + R_VTI_raw_segment_cropped.append(R_VTI_raw_global_cropped) + + Tau_M1_raw_segment_ceiled.append(Tau_M1_raw_global_ceiled) + Tau_M1_over_T_raw_segment_ceiled.append(Tau_M1_over_T_raw_global_ceiled) + RI_raw_segment_ceiled.append(RI_raw_global_ceiled) + R_VTI_raw_segment_ceiled.append(R_VTI_raw_global_ceiled) + + metrics = { + "signals/v_profile": np.asarray(v_profile_beat_threshold), + "signals/v_profile_cropped": np.asarray(v_profile_beat_ceiled_threshold), + "signals/v_profile_ceiled": np.asarray(v_profile_beat_cropped_threshold), + "tau_M1/tau_M1_raw": with_attrs( + np.asarray(Tau_M1_raw_segment), {"unit": [""]} + ), + "tau_M1_over_T/tau_M1_over_T_raw": with_attrs( + np.asarray(Tau_M1_over_T_raw_segment), {"unit": [""]} + ), + "RI/RI_raw": np.asarray(RI_raw_segment), + "R_VTI/R_VTI_raw": np.asarray(R_VTI_raw_segment), + "tau_M1/tau_M1_raw_cropped": with_attrs( + np.asarray(Tau_M1_raw_segment_cropped), {"unit": [""]} + ), + "tau_M1_over_T/tau_M1_over_T_raw_cropped": with_attrs( + np.asarray(Tau_M1_over_T_raw_segment_cropped), {"unit": [""]} + ), + "RI/RI_raw_cropped": np.asarray(RI_raw_segment_cropped), + "R_VTI/R_VTI_raw_cropped": np.asarray(R_VTI_raw_segment_cropped), + "tau_M1/tau_M1_raw_ceiled": with_attrs( + np.asarray(Tau_M1_raw_segment_ceiled), {"unit": [""]} + ), + "tau_M1_over_T/tau_M1_over_T_raw_ceiled": with_attrs( + np.asarray(Tau_M1_over_T_raw_segment_ceiled), {"unit": [""]} + ), + "RI/RI_raw_ceiled": np.asarray(RI_raw_segment_ceiled), + "R_VTI/R_VTI_raw_ceiled": np.asarray(R_VTI_raw_segment_ceiled), + } + + # Artifacts can store non-metric outputs (strings, paths, etc.). + + return ProcessResult(metrics=metrics) From a216c55aa03f5af3eed8d34a014ee4837afb9eeb Mon Sep 17 00:00:00 2001 From: gregoire Date: Mon, 16 Feb 2026 11:29:55 +0100 Subject: [PATCH 62/71] segment reconstruction with factor 2 --- ...egments_velocity_waveform_shape_metrics.py | 100 ++++++++------ src/pipelines/recreatesig.py | 96 +++++++++---- ...segment_velocity_waveform_shape_metrics.py | 128 ++++++++++-------- .../signal_reconstruction_factor_reduced.py | 16 +-- 4 files changed, 206 insertions(+), 134 deletions(-) diff --git a/src/pipelines/Segments_velocity_waveform_shape_metrics.py b/src/pipelines/Segments_velocity_waveform_shape_metrics.py index fc91e1d..e6c1ecd 100644 --- a/src/pipelines/Segments_velocity_waveform_shape_metrics.py +++ b/src/pipelines/Segments_velocity_waveform_shape_metrics.py @@ -3,7 +3,7 @@ from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs -@registerPipeline(name="segment_waveform_shape_metrics") +@registerPipeline(name="old_segment_waveform_shape_metrics") class ArterialSegExample(ProcessPipeline): """ Tutorial pipeline showing the full surface area of a pipeline: @@ -16,11 +16,11 @@ class ArterialSegExample(ProcessPipeline): """ description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." - v_raw_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + v_raw_input = ( + "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + ) v_bandlimited_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" - - def run(self, h5file) -> ProcessResult: @@ -39,69 +39,81 @@ def run(self, h5file) -> ProcessResult: Tau_M1_over_T_bandlimited_segment = [] RI_bandlimited_segment = [] R_VTI_bandlimited_segment = [] - + Tau_M1_raw_segment = [] Tau_M1_over_T_raw_segment = [] RI_raw_segment = [] R_VTI_raw_segment = [] - + ratio_systole_diastole_R_VTI = 0.5 for beat_idx in range(len(v_bandlimited[0, :, 0, 0])): - Tau_M1_bandlimited_branch = [] Tau_M1_over_T_bandlimited_branch = [] RI_bandlimited_branch = [] R_VTI_bandlimited_branch = [] - for branch_idx in range(len(v_bandlimited[0, beat_idx, :, 0])): - - v_bandlimited_average = np.nanmean(v_bandlimited[:, beat_idx, branch_idx, :], axis=1) + v_bandlimited_average = np.nanmean( + v_bandlimited[:, beat_idx, branch_idx, :], axis=1 + ) t = T[0][beat_idx] / len(v_bandlimited_average) moment_0_segment += np.sum(v_bandlimited_average) for time_idx in range(len(v_bandlimited_average)): moment_1_segment += v_bandlimited_average[time_idx] * time_idx * t - if moment_0_segment!=0: - TM1=moment_1_segment/moment_0_segment + if moment_0_segment != 0: + TM1 = moment_1_segment / moment_0_segment Tau_M1_bandlimited_branch.append(TM1) - Tau_M1_over_T_bandlimited_branch.append(TM1/T[0][beat_idx]) + Tau_M1_over_T_bandlimited_branch.append(TM1 / T[0][beat_idx]) else: Tau_M1_bandlimited_branch.append(0) Tau_M1_over_T_bandlimited_branch.append(0) - - v_bandlimited_max=np.max(v_bandlimited_average) - v_bandlimited_min=np.min(v_bandlimited_average) - + v_bandlimited_max = np.max(v_bandlimited_average) + v_bandlimited_min = np.min(v_bandlimited_average) + RI_bandlimited_branch_idx = 1 - (v_bandlimited_min / v_bandlimited_max) RI_bandlimited_branch.append(RI_bandlimited_branch_idx) epsilon = 10 ** (-12) - D1_bandlimited = np.sum(v_bandlimited_average[: int(np.ceil(len(v_bandlimited_average) * ratio_systole_diastole_R_VTI))]) - D2_bandlimited = np.sum(v_bandlimited_average[int(np.ceil(len(v_bandlimited_average) * ratio_systole_diastole_R_VTI)) :]) - R_VTI_bandlimited_branch.append(D2_bandlimited / (D1_bandlimited + epsilon)) + D1_bandlimited = np.sum( + v_bandlimited_average[ + : int( + np.ceil( + len(v_bandlimited_average) + * ratio_systole_diastole_R_VTI + ) + ) + ] + ) + D2_bandlimited = np.sum( + v_bandlimited_average[ + int( + np.ceil( + len(v_bandlimited_average) + * ratio_systole_diastole_R_VTI + ) + ) : + ] + ) + R_VTI_bandlimited_branch.append( + D2_bandlimited / (D1_bandlimited + epsilon) + ) Tau_M1_bandlimited_segment.append(Tau_M1_bandlimited_branch) Tau_M1_over_T_bandlimited_segment.append(Tau_M1_over_T_bandlimited_branch) RI_bandlimited_segment.append(RI_bandlimited_branch) R_VTI_bandlimited_segment.append(R_VTI_bandlimited_branch) - - - for beat_idx in range(len(v_raw[0, :, 0, 0])): - Tau_M1_raw_branch = [] Tau_M1_over_T_raw_branch = [] RI_raw_branch = [] R_VTI_raw_branch = [] - for branch_idx in range(len(v_raw[0, beat_idx, :, 0])): - v_raw_average = np.nanmean(v_raw[:, beat_idx, branch_idx, :], axis=1) t = T[0][beat_idx] / len(v_raw_average) @@ -109,24 +121,35 @@ def run(self, h5file) -> ProcessResult: for time_idx in range(len(v_raw_average)): moment_1_segment += v_raw_average[time_idx] * time_idx * t - if moment_0_segment!=0: - TM1=moment_1_segment/moment_0_segment + if moment_0_segment != 0: + TM1 = moment_1_segment / moment_0_segment Tau_M1_raw_branch.append(TM1) - Tau_M1_over_T_raw_branch.append(TM1/T[0][beat_idx]) + Tau_M1_over_T_raw_branch.append(TM1 / T[0][beat_idx]) else: Tau_M1_raw_branch.append(0) Tau_M1_over_T_raw_branch.append(0) - - v_raw_max=np.max(v_raw_average) - v_raw_min=np.min(v_raw_average) - + v_raw_max = np.max(v_raw_average) + v_raw_min = np.min(v_raw_average) + RI_raw_branch_idx = 1 - (v_raw_min / v_raw_max) RI_raw_branch.append(RI_raw_branch_idx) epsilon = 10 ** (-12) - D1_raw = np.sum(v_raw_average[: int(np.ceil(len(v_raw_average) * ratio_systole_diastole_R_VTI))]) - D2_raw = np.sum(v_raw_average[int(np.ceil(len(v_raw_average) * ratio_systole_diastole_R_VTI)) :]) + D1_raw = np.sum( + v_raw_average[ + : int( + np.ceil(len(v_raw_average) * ratio_systole_diastole_R_VTI) + ) + ] + ) + D2_raw = np.sum( + v_raw_average[ + int( + np.ceil(len(v_raw_average) * ratio_systole_diastole_R_VTI) + ) : + ] + ) R_VTI_raw_branch.append(D2_raw / (D1_raw + epsilon)) Tau_M1_raw_segment.append(Tau_M1_raw_branch) @@ -134,8 +157,6 @@ def run(self, h5file) -> ProcessResult: RI_raw_segment.append(RI_raw_branch) R_VTI_raw_segment.append(R_VTI_raw_branch) - - # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. metrics = { "tau_M1_bandlimited_segment": with_attrs( @@ -145,7 +166,7 @@ def run(self, h5file) -> ProcessResult: }, ), "R_VTI_bandlimited_segment": with_attrs( - np.asarray( R_VTI_bandlimited_segment), + np.asarray(R_VTI_bandlimited_segment), { "unit": [""], }, @@ -162,7 +183,6 @@ def run(self, h5file) -> ProcessResult: "unit": [""], }, ), - "tau_M1_raw_segment": with_attrs( np.asarray(Tau_M1_raw_segment), { @@ -170,7 +190,7 @@ def run(self, h5file) -> ProcessResult: }, ), "R_VTI_raw_segment": with_attrs( - np.asarray( R_VTI_raw_segment), + np.asarray(R_VTI_raw_segment), { "unit": [""], }, diff --git a/src/pipelines/recreatesig.py b/src/pipelines/recreatesig.py index 7aca14a..657780e 100644 --- a/src/pipelines/recreatesig.py +++ b/src/pipelines/recreatesig.py @@ -16,11 +16,9 @@ class Reconstruct(ProcessPipeline): """ description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." - v_profile = "/Artery/Velocity/VelocityProfiles/value" + v_profile = "/Artery/CrossSections/VelocityProfileSeg/value" vsystol = "/Artery/Velocity/SystolicAccelerationPeakIndexes" T_val = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" - vmax = "/Artery/VelocityPerBeat/VmaxPerBeatBandLimited/value" - vmin = "/Artery/VelocityPerBeat/VminPerBeatBandLimited/value" def gaussian(x, A, mu, sigma, c): return A * np.exp(-((x - mu) ** 2) / (2 * sigma**2)) + c @@ -34,10 +32,10 @@ def run(self, h5file) -> ProcessResult: V_corrected = [] V_ceil = [] - # V_gauss = [] + V_gauss = [] for k in range(len(v_seg[0, :, 0, 0])): - # VIT_Time = 0 + VIT_Time = 0 Vit_br = [] for br in range(len(v_seg[0, k, :, 0])): v_branch = np.nanmean(v_seg[:, k, br, :], axis=1) @@ -81,9 +79,69 @@ def run(self, h5file) -> ProcessResult: Vit.append(np.nanmean(Vit_br)) V_corrected.append(np.nanmean(Vit)) + + '''vraw_ds = np.asarray(v_threshold_beat_segment) + vraw_ds_temp = vraw_ds.transpose(1, 0, 2, 3) + vraw_ds = np.maximum(vraw_ds_temp, 0) + v_ds = vraw_ds + t_ds = np.asarray(h5file[self.T_input]) + + TMI_seg = [] + TMI_seg_band = [] + RTVI_seg = [] + RTVI_seg_band = [] + RI_seg = [] + RI_seg_band = [] + M0_seg = 0 + M1_seg = 0 + M0_seg_band = 0 + M1_seg_band = 0 + for k in range(len(vraw_ds[0, :, 0, 0])): + TMI_branch = [] + TMI_branch_band = [] + RTVI_band_branch = [] + RTVI_branch = [] + RI_branch = [] + RI_branch_band = [] + for i in range(len(vraw_ds[0, k, :, 0])): + avg_speed_band = np.nanmean(v_ds[:, k, i, :], axis=1) + avg_speed = np.nanmean(vraw_ds[:, k, i, :], axis=1) + vmin = np.min(avg_speed) + vmax = np.max(avg_speed) + vmin_band = np.min(avg_speed_band) + vmax_band = np.max(avg_speed_band) + + RI_branch.append(1 - (vmin / (vmax + 10 ** (-14)))) + RI_branch_band.append(1 - (vmin_band / (vmax_band + 10 ** (-14)))) + D1_raw = np.sum(avg_speed[:31]) + D2_raw = np.sum(avg_speed[32:]) + D1 = np.sum(avg_speed_band[:31]) + D2 = np.sum(avg_speed_band[32:]) + RTVI_band_branch.append(D1 / (D2 + 10 ** (-12))) + RTVI_branch.append(D1_raw / (D2_raw + 10 ** (-12))) + M0_seg += np.sum(avg_speed) + M0_seg_band += np.sum(avg_speed_band) + for j in range(len(avg_speed)): + M1_seg += avg_speed[j] * j * t_ds[0][k] / 64 + M1_seg_band += avg_speed_band[j] * j * t_ds[0][k] / 64 + if M0_seg != 0: + TMI_branch.append(M1_seg / (t_ds[0][k] * M0_seg)) + else: + TMI_branch.append(0) + if M0_seg_band != 0: + TMI_branch_band.append(M1_seg_band / (t_ds[0][k] * M0_seg_band)) + else: + TMI_branch_band.append(0) + + TMI_seg.append(TMI_branch) + TMI_seg_band.append(TMI_branch_band) + RI_seg.append(RI_branch) + RI_seg_band.append(RI_branch_band) + RTVI_seg.append(RTVI_branch) + RTVI_seg_band.append(RTVI_band_branch)'''' for k in range(len(v_seg[0, :, 0, 0])): Vit = [] - # Vit_gauss = [] + Vit_gauss = [] for br in range(len(v_seg[0, k, :, 0])): Vit_br = [] for seg in range(len(v_seg[0, k, br, :])): @@ -113,7 +171,7 @@ def run(self, h5file) -> ProcessResult: + values[last - threshold :] ) ) - except Exception: # noqa: BLE001 + except Exception: Vit_br.append(np.nan) return None @@ -123,27 +181,9 @@ def run(self, h5file) -> ProcessResult: # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. metrics = { - "Xn": with_attrs( - np.asarray(V), - { - "unit": [""], - "description": [""], - }, - ), - "Xn_correc": with_attrs( - np.asarray(V_corrected), - { - "unit": [""], - "description": [""], - }, - ), - "Xn_ceil": with_attrs( - np.asarray(V_ceil), - { - "unit": [""], - "description": [""], - }, - ), + "Xn": np.asarray(V), + "Xn_correc": np.asarray(V_corrected), + "Xn_ceil": np.asarray(V_ceil), } # Artifacts can store non-metric outputs (strings, paths, etc.). diff --git a/src/pipelines/segment_velocity_waveform_shape_metrics.py b/src/pipelines/segment_velocity_waveform_shape_metrics.py index ab1fcd3..71878fb 100644 --- a/src/pipelines/segment_velocity_waveform_shape_metrics.py +++ b/src/pipelines/segment_velocity_waveform_shape_metrics.py @@ -16,11 +16,11 @@ class ArterialSegExample(ProcessPipeline): """ description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." - v_raw_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + v_raw_input = ( + "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + ) v_bandlimited_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" T_input = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" - - def run(self, h5file) -> ProcessResult: @@ -39,60 +39,80 @@ def run(self, h5file) -> ProcessResult: Tau_M1_over_T_bandlimited_segment = [] RI_bandlimited_segment = [] R_VTI_bandlimited_segment = [] - + Tau_M1_raw_segment = [] Tau_M1_over_T_raw_segment = [] RI_raw_segment = [] R_VTI_raw_segment = [] - + ratio_systole_diastole_R_VTI = 0.5 for beat_idx in range(len(v_bandlimited[0, :, 0, 0])): - Tau_M1_bandlimited_branch = [] Tau_M1_over_T_bandlimited_branch = [] RI_bandlimited_branch = [] R_VTI_bandlimited_branch = [] - + t = T[0][beat_idx] / len(v_bandlimited) for branch_idx in range(len(v_bandlimited[0, beat_idx, :, 0])): - Tau_M1_bandlimited_radius = [] Tau_M1_over_T_bandlimited_radius = [] RI_bandlimited_radius = [] R_VTI_bandlimited_radius = [] - - for radius_idx in range (len(v_bandlimited[0, beat_idx, branch_idx, :])): - v_bandlimited_idx = v_bandlimited[:,beat_idx,branch_idx,radius_idx] - + for radius_idx in range(len(v_bandlimited[0, beat_idx, branch_idx, :])): + v_bandlimited_idx = v_bandlimited[ + :, beat_idx, branch_idx, radius_idx + ] + moment_0_segment = np.sum(v_bandlimited_idx) moment_1_segment = 0 for time_idx in range(len(v_bandlimited_idx)): moment_1_segment += v_bandlimited_idx[time_idx] * time_idx * t - - TM1=moment_1_segment/moment_0_segment + TM1 = moment_1_segment / moment_0_segment Tau_M1_bandlimited_radius.append(TM1) - Tau_M1_over_T_bandlimited_radius.append(TM1/T[0][beat_idx]) - - - - v_bandlimited_max=np.max(v_bandlimited_idx) - v_bandlimited_min=np.min(v_bandlimited_idx) - - RI_bandlimited_radius_idx = 1 - (v_bandlimited_min / v_bandlimited_max) + Tau_M1_over_T_bandlimited_radius.append(TM1 / T[0][beat_idx]) + + v_bandlimited_max = np.max(v_bandlimited_idx) + v_bandlimited_min = np.min(v_bandlimited_idx) + + RI_bandlimited_radius_idx = 1 - ( + v_bandlimited_min / v_bandlimited_max + ) RI_bandlimited_radius.append(RI_bandlimited_radius_idx) epsilon = 10 ** (-12) - D1_bandlimited = np.sum(v_bandlimited_idx[: int(np.ceil(len(v_bandlimited_idx) * ratio_systole_diastole_R_VTI))]) - D2_bandlimited = np.sum(v_bandlimited_idx[int(np.ceil(len(v_bandlimited_idx) * ratio_systole_diastole_R_VTI)) :]) - R_VTI_bandlimited_radius.append(D1_bandlimited / (D2_bandlimited + epsilon)) + D1_bandlimited = np.sum( + v_bandlimited_idx[ + : int( + np.ceil( + len(v_bandlimited_idx) + * ratio_systole_diastole_R_VTI + ) + ) + ] + ) + D2_bandlimited = np.sum( + v_bandlimited_idx[ + int( + np.ceil( + len(v_bandlimited_idx) + * ratio_systole_diastole_R_VTI + ) + ) : + ] + ) + R_VTI_bandlimited_radius.append( + D1_bandlimited / (D2_bandlimited + epsilon) + ) Tau_M1_bandlimited_branch.append(Tau_M1_bandlimited_radius) - Tau_M1_over_T_bandlimited_branch.append(Tau_M1_over_T_bandlimited_radius) + Tau_M1_over_T_bandlimited_branch.append( + Tau_M1_over_T_bandlimited_radius + ) RI_bandlimited_branch.append(RI_bandlimited_radius) R_VTI_bandlimited_branch.append(R_VTI_bandlimited_radius) @@ -101,71 +121,66 @@ def run(self, h5file) -> ProcessResult: RI_bandlimited_segment.append(RI_bandlimited_branch) R_VTI_bandlimited_segment.append(R_VTI_bandlimited_branch) - for beat_idx in range(len(v_raw[0, :, 0, 0])): - Tau_M1_raw_branch = [] Tau_M1_over_T_raw_branch = [] RI_raw_branch = [] R_VTI_raw_branch = [] - t = T[0][beat_idx] / len(v_raw) for branch_idx in range(len(v_raw[0, beat_idx, :, 0])): - Tau_M1_raw_radius = [] Tau_M1_over_T_raw_radius = [] RI_raw_radius = [] R_VTI_raw_radius = [] - - for radius_idx in range (len(v_raw[0, beat_idx, branch_idx, :])): - - v_raw_idx = v_raw[:,beat_idx,branch_idx,radius_idx] - + for radius_idx in range(len(v_raw[0, beat_idx, branch_idx, :])): + v_raw_idx = v_raw[:, beat_idx, branch_idx, radius_idx] moment_0_segment = np.sum(v_raw_idx) moment_1_segment = 0 for time_idx in range(len(v_raw_idx)): moment_1_segment += v_raw_idx[time_idx] * time_idx * t - - TM1=moment_1_segment/moment_0_segment + TM1 = moment_1_segment / moment_0_segment Tau_M1_raw_radius.append(TM1) - Tau_M1_over_T_raw_radius.append(TM1/T[0][beat_idx]) - + Tau_M1_over_T_raw_radius.append(TM1 / T[0][beat_idx]) + + v_raw_max = np.max(v_raw_idx) + v_raw_min = np.min(v_raw_idx) - - v_raw_max=np.max(v_raw_idx) - v_raw_min=np.min(v_raw_idx) - RI_raw_radius_idx = 1 - (v_raw_min / v_raw_max) RI_raw_radius.append(RI_raw_radius_idx) epsilon = 10 ** (-12) - D1_raw = np.sum(v_raw_idx[: int(np.ceil(len(v_raw_idx) * ratio_systole_diastole_R_VTI))]) - D2_raw = np.sum(v_raw_idx[int(np.ceil(len(v_raw_idx) * ratio_systole_diastole_R_VTI)) :]) + D1_raw = np.sum( + v_raw_idx[ + : int( + np.ceil(len(v_raw_idx) * ratio_systole_diastole_R_VTI) + ) + ] + ) + D2_raw = np.sum( + v_raw_idx[ + int( + np.ceil(len(v_raw_idx) * ratio_systole_diastole_R_VTI) + ) : + ] + ) R_VTI_raw_radius.append(D1_raw / (D2_raw + epsilon)) - Tau_M1_raw_branch.append(Tau_M1_raw_radius) Tau_M1_over_T_raw_branch.append(Tau_M1_over_T_raw_radius) RI_raw_branch.append(RI_raw_radius) R_VTI_raw_branch.append(R_VTI_raw_radius) - - Tau_M1_raw_segment.append(Tau_M1_raw_branch) Tau_M1_over_T_raw_segment.append(Tau_M1_over_T_raw_branch) RI_raw_segment.append(RI_raw_branch) R_VTI_raw_segment.append(R_VTI_raw_branch) - - - - - # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. metrics = { "tau_M1_bandlimited_segment": with_attrs( np.asarray(Tau_M1_bandlimited_segment), @@ -174,7 +189,7 @@ def run(self, h5file) -> ProcessResult: }, ), "R_VTI_bandlimited_segment": with_attrs( - np.asarray( R_VTI_bandlimited_segment), + np.asarray(R_VTI_bandlimited_segment), { "unit": [""], }, @@ -191,7 +206,6 @@ def run(self, h5file) -> ProcessResult: "unit": [""], }, ), - "tau_M1_raw_segment": with_attrs( np.asarray(Tau_M1_raw_segment), { @@ -199,7 +213,7 @@ def run(self, h5file) -> ProcessResult: }, ), "R_VTI_raw_segment": with_attrs( - np.asarray( R_VTI_raw_segment), + np.asarray(R_VTI_raw_segment), { "unit": [""], }, @@ -222,5 +236,3 @@ def run(self, h5file) -> ProcessResult: # Artifacts can store non-metric outputs (strings, paths, etc.). return ProcessResult(metrics=metrics) - - diff --git a/src/pipelines/signal_reconstruction_factor_reduced.py b/src/pipelines/signal_reconstruction_factor_reduced.py index 7224176..b1ade9a 100644 --- a/src/pipelines/signal_reconstruction_factor_reduced.py +++ b/src/pipelines/signal_reconstruction_factor_reduced.py @@ -229,12 +229,12 @@ def run(self, h5file) -> ProcessResult: int( np.minimum( first + threshold_idx, - np.floor(len_signal / 3), + first + np.floor(len_signal / 3), ) ) : int( np.maximum( last - threshold_idx, - np.ceil(len_signal * 2 / 3), + last - np.ceil(len_signal * 2 / 3), ) ) ] @@ -244,7 +244,7 @@ def run(self, h5file) -> ProcessResult: first : int( np.minimum( first + threshold_idx, - np.floor(len_signal / 3), + first + np.floor(len_signal / 3), ) ) ] @@ -253,7 +253,7 @@ def run(self, h5file) -> ProcessResult: int( np.maximum( last - threshold_idx, - np.ceil(len_signal * 2 / 3), + last - np.ceil(len_signal * 2 / 3), ) ) : last ] @@ -261,10 +261,10 @@ def run(self, h5file) -> ProcessResult: vit_seg_cropped.append( np.nanmean( values[ - first : int( + : int( np.minimum( first + threshold_idx, - np.floor(len_signal / 3), + first + np.floor(len_signal / 3), ) ) ] @@ -272,9 +272,9 @@ def run(self, h5file) -> ProcessResult: int( np.maximum( last - threshold_idx, - np.ceil(len_signal * 2 / 3), + last - np.ceil(len_signal * 2 / 3), ) - ) : last + ) : ] ) ) From a5d2b46686e03d3af98e41fe6c90e91364ffa61b Mon Sep 17 00:00:00 2001 From: gregoire Date: Mon, 16 Feb 2026 16:45:59 +0100 Subject: [PATCH 63/71] M0 and sqrt(M2/M0) temporal(U reshaped to 512*512) and spatial (Vt) modal analysis --- src/pipelines/modal_analysis.py | 112 ++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/pipelines/modal_analysis.py diff --git a/src/pipelines/modal_analysis.py b/src/pipelines/modal_analysis.py new file mode 100644 index 0000000..90a1347 --- /dev/null +++ b/src/pipelines/modal_analysis.py @@ -0,0 +1,112 @@ +import numpy as np +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs +from scipy.linalg import eigh +from scipy.signal import savgol_filter, medfilt, find_peaks + + +@registerPipeline(name="modal_analysis") +class ArterialExample(ProcessPipeline): + """ + Tutorial pipeline showing the full surface area of a pipeline: + + - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. + - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. + - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. + - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). + - No input data is required; this pipeline is purely illustrative. + """ + + description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + M0_input = "/moment0" + M1_input = "/moment1" + M2_input = "/moment2" + registration_input = "/registration" + + def run(self, h5file) -> ProcessResult: + from scipy.sparse.linalg import svds + + moment_0 = np.asarray(h5file[self.M0_input]) + moment_1 = np.asarray(h5file[self.M1_input]) + moment_2 = np.asarray(h5file[self.M2_input]) + registration = np.asarray(h5file[self.registration_input]) + M0_matrix = [] + M1_matrix = [] + M2_matrix = [] + M2_over_M0_squared = [] + x_size = len(moment_0[0, 0, :, 0]) + y_size = len(moment_0[0, 0, 0, :]) + for time_idx in range(len(moment_0[:, 0, 0, 0])): + M0_matrix_time = [] + M1_matrix_time = [] + M2_matrix_time = [] + for x_idx in range(x_size): + for y_idx in range(y_size): + M0 = moment_0[time_idx, 0, x_idx, y_idx] + M1 = moment_1[time_idx, 0, x_idx, y_idx] + M2 = moment_2[time_idx, 0, x_idx, y_idx] + M0_matrix_time.append(M0) + M2_over_M0_squared.append(np.sqrt(M2 / M0)) + + M1_matrix_time.append(moment_1[time_idx, 0, x_idx, y_idx]) + + M2_matrix_time.append(moment_2[time_idx, 0, x_idx, y_idx]) + + M0_matrix.append(M0_matrix_time) + M1_matrix.append(M1_matrix_time) + M2_matrix.append(M2_matrix_time) + M0_matrix = np.transpose(np.asarray(M0_matrix)) + M2_over_M0_squared = np.asarray(M2_over_M0_squared) + k = 6 + U_0, S_0, Vt_0 = svds(M0_matrix, k=k) + + idx = np.argsort(S_0)[::-1] + S_0 = S_0[idx] + U_0 = U_0[:, idx] + Vt_0 = Vt_0[idx, :] + + idx = np.argsort(S_0)[::-1] + S_0 = S_0[idx] + U_0 = U_0[:, idx] + Vt_0 = Vt_0[idx, :] + spatial_modes = [] + for mode_idx in range(len(U_0[0])): + spatial_modes.append(U_0[:, mode_idx].reshape(x_size, y_size)) + + U_M2_over_M0_squared, S_M2_over_M0_squared, Vt_M2_over_M0_squared = svds( + M0_matrix, k=k + ) + idx = np.argsort(S_0)[::-1] + S_M2_over_M0_squared = S_M2_over_M0_squared[idx] + U_M2_over_M0_squared = U_M2_over_M0_squared[:, idx] + Vt_M2_over_M0_squared = Vt_M2_over_M0_squared[idx, :] + spatial_modes_M2_over_M0_squared = [] + for mode_idx in range(len(U_M2_over_M0_squared[0])): + spatial_modes_M2_over_M0_squared.append( + U_M2_over_M0_squared[:, mode_idx].reshape(x_size, y_size) + ) + + # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. + metrics = { + "Vt_moment0": with_attrs(np.asarray(Vt_0), {"unit": [""]}), + "spatial_modes_moment0": with_attrs( + np.asarray(spatial_modes), {"unit": [""]} + ), + "S_moment0": with_attrs(np.asarray(S_0), {"unit": [""]}), + "U_moment0": with_attrs(np.asarray(U_0), {"unit": [""]}), + "Vt_M2_over_M0_squared": with_attrs( + np.asarray(Vt_M2_over_M0_squared), {"unit": [""]} + ), + "spatial_modes_M2_over_M0_squared": with_attrs( + np.asarray(spatial_modes_M2_over_M0_squared), {"unit": [""]} + ), + "S_M2_over_M0_squared": with_attrs( + np.asarray(S_M2_over_M0_squared), {"unit": [""]} + ), + "U_M2_over_M0_squared": with_attrs( + np.asarray(U_M2_over_M0_squared), {"unit": [""]} + ), + } + + # Artifacts can store non-metric outputs (strings, paths, etc.). + + return ProcessResult(metrics=metrics) From 603a063d23b7e061600c81c41aa8b2e69725bbd2 Mon Sep 17 00:00:00 2001 From: gregoire Date: Mon, 16 Feb 2026 17:14:46 +0100 Subject: [PATCH 64/71] deploy codex version of segmented metrics computing --- ...egments_velocity_waveform_shape_metrics.py | 533 +++++++++++++----- ...rterial_velocity_waveform_shape_metrics.py | 4 +- 2 files changed, 380 insertions(+), 157 deletions(-) diff --git a/src/pipelines/Segments_velocity_waveform_shape_metrics.py b/src/pipelines/Segments_velocity_waveform_shape_metrics.py index e6c1ecd..0e96cdd 100644 --- a/src/pipelines/Segments_velocity_waveform_shape_metrics.py +++ b/src/pipelines/Segments_velocity_waveform_shape_metrics.py @@ -3,213 +3,436 @@ from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs -@registerPipeline(name="old_segment_waveform_shape_metrics") +@registerPipeline(name="segment_waveform_shape_metrics_test") class ArterialSegExample(ProcessPipeline): """ - Tutorial pipeline showing the full surface area of a pipeline: - - - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. - - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. - - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. - - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). - - No input data is required; this pipeline is purely illustrative. + Waveform-shape metrics on per-beat, per-branch, per-radius velocity waveforms. + + Expected v_block layout: + v_block[:, beat_idx, branch_idx, radius_idx] + i.e. v_block shape: (n_t, n_beats, n_branches, n_radii) + + Outputs + ------- + A) Per-segment (flattened branch×radius): + *_segment : shape (n_beats, n_segments) + where n_segments = n_branches * n_radii and + seg_idx = branch_idx * n_radii + radius_idx (branch-major) + + B) Aggregated: + *_branch : shape (n_beats, n_branches) (median over radii) + *_global : shape (n_beats,) (mean over all branches & radii) + + Metric definitions + ------------------ + - Rectification: v <- max(v, 0) (NaNs preserved) + - tau_M1: first moment time / zeroth moment on rectified waveform + tau_M1 = M1/M0, M0 = sum(v), M1 = sum(v * t_k), t_k = k * (Tbeat/n_t) + - tau_M1_over_T: (tau_M1 / Tbeat) + - RI (robust): RI = 1 - vmin/vmax with guards for vmax<=0 or all-NaN + - R_VTI_*: kept dataset name for compatibility, but uses PAPER convention: + RVTI = D1 / (D2 + eps) + D1 = sum(v[0:k]), D2 = sum(v[k:n_t]), k = ceil(n_t * ratio), ratio=0.5 """ - description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." + description = ( + "Segment waveform shape metrics (tau, RI, RVTI) + branch/global aggregates." + ) + v_raw_input = ( "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" ) v_bandlimited_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" - T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + T_input = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + + @staticmethod + def _rectify_keep_nan(x: np.ndarray) -> np.ndarray: + x = np.asarray(x, dtype=float) + return np.where(np.isfinite(x), np.maximum(x, 0.0), np.nan) + + @staticmethod + def _safe_nanmean(x: np.ndarray) -> float: + if x.size == 0 or not np.any(np.isfinite(x)): + return np.nan + return float(np.nanmean(x)) + + @staticmethod + def _safe_nanmedian(x: np.ndarray) -> float: + if x.size == 0 or not np.any(np.isfinite(x)): + return np.nan + return float(np.nanmedian(x)) + + @staticmethod + def _metrics_from_waveform( + v: np.ndarray, + Tbeat: float, + ratio: float = 0.5, + eps: float = 1e-12, + ): + v = ArterialSegExample._rectify_keep_nan(v) + + n = int(v.size) + if n <= 0: + return 0.0, 0.0, 0.0, 0.0 + + # tau_M1 and tau_M1/T (PER WAVEFORM ONLY) + if (not np.isfinite(Tbeat)) or Tbeat <= 0: + tau_M1 = np.nan + tau_M1_over_T = np.nan + else: + m0 = np.nansum(v) + if (not np.isfinite(m0)) or m0 <= 0: + tau_M1 = np.nan + tau_M1_over_T = np.nan + else: + dt = Tbeat / n + t = np.arange(n, dtype=float) * dt + m1 = np.nansum(v * t) + tau_M1 = (m1 / m0) if np.isfinite(m1) else np.nan + tau_M1_over_T = tau_M1 / Tbeat + + # RI robust + if not np.any(np.isfinite(v)): + RI = np.nan + else: + vmax = np.nanmax(v) + if (not np.isfinite(vmax)) or vmax <= 0: + RI = np.nan + else: + vmin = np.nanmin(v) + RI = 1.0 - (vmin / vmax) + if not np.isfinite(RI): + RI = np.nan + else: + RI = float(np.clip(RI, 0.0, 1.0)) + + # RVTI (paper): D1/(D2+eps) + k = int(np.ceil(n * ratio)) + k = max(0, min(n, k)) + D1 = np.nansum(v[:k]) if k > 0 else np.nan + D2 = np.nansum(v[k:]) if k < n else np.nan + if not np.isfinite(D1) or D1 == 0.0: + D1 = np.nan + if not np.isfinite(D2) or D2 == 0.0: + D2 = np.nan + RVTI = float(D1 / (D2 + eps)) + + return float(tau_M1), float(tau_M1_over_T), float(RI), RVTI + + def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): + if v_block.ndim != 4: + raise ValueError( + f"Expected (n_t,n_beats,n_branches,n_radii), got {v_block.shape}" + ) + + n_t, n_beats, n_branches, n_radii = v_block.shape + n_segments = n_branches * n_radii + + # Per-segment flattened (beat, segment) + tau_seg = np.zeros((n_beats, n_segments), dtype=float) + tauT_seg = np.zeros((n_beats, n_segments), dtype=float) + RI_seg = np.zeros((n_beats, n_segments), dtype=float) + RVTI_seg = np.zeros((n_beats, n_segments), dtype=float) + + # Aggregated + tau_branch = np.zeros((n_beats, n_branches), dtype=float) + tauT_branch = np.zeros((n_beats, n_branches), dtype=float) + RI_branch = np.zeros((n_beats, n_branches), dtype=float) + RVTI_branch = np.zeros((n_beats, n_branches), dtype=float) + + tau_global = np.zeros((n_beats,), dtype=float) + tauT_global = np.zeros((n_beats,), dtype=float) + RI_global = np.zeros((n_beats,), dtype=float) + RVTI_global = np.zeros((n_beats,), dtype=float) + + for beat_idx in range(n_beats): + Tbeat = float(T[0][beat_idx]) + + # Global accumulators for this beat + tau_vals = [] + tauT_vals = [] + RI_vals = [] + RVTI_vals = [] + + for branch_idx in range(n_branches): + # Branch accumulators across radii + tau_b = [] + tauT_b = [] + RI_b = [] + RVTI_b = [] + + for radius_idx in range(n_radii): + v = v_block[:, beat_idx, branch_idx, radius_idx] + tM1, tM1T, ri, rvti = self._metrics_from_waveform( + v=v, Tbeat=Tbeat, ratio=ratio, eps=1e-12 + ) + + seg_idx = branch_idx * n_radii + radius_idx + tau_seg[beat_idx, seg_idx] = tM1 + tauT_seg[beat_idx, seg_idx] = tM1T + RI_seg[beat_idx, seg_idx] = ri + RVTI_seg[beat_idx, seg_idx] = rvti + + tau_b.append(tM1) + tauT_b.append(tM1T) + RI_b.append(ri) + RVTI_b.append(rvti) + + tau_vals.append(tM1) + tauT_vals.append(tM1T) + RI_vals.append(ri) + RVTI_vals.append(rvti) + + # Branch aggregates: MEDIAN over radii + tau_branch[beat_idx, branch_idx] = self._safe_nanmedian( + np.asarray(tau_b) + ) + tauT_branch[beat_idx, branch_idx] = self._safe_nanmedian( + np.asarray(tauT_b) + ) + RI_branch[beat_idx, branch_idx] = self._safe_nanmedian(np.asarray(RI_b)) + RVTI_branch[beat_idx, branch_idx] = self._safe_nanmedian( + np.asarray(RVTI_b) + ) - def run(self, h5file) -> ProcessResult: + # Global aggregates: MEAN over all branches & radii + tau_global[beat_idx] = self._safe_nanmean(np.asarray(tau_vals)) + tauT_global[beat_idx] = self._safe_nanmean(np.asarray(tauT_vals)) + RI_global[beat_idx] = self._safe_nanmean(np.asarray(RI_vals)) + RVTI_global[beat_idx] = self._safe_nanmean(np.asarray(RVTI_vals)) + + return ( + tau_seg, + tauT_seg, + RI_seg, + RVTI_seg, + tau_branch, + tauT_branch, + RI_branch, + RVTI_branch, + tau_global, + tauT_global, + RI_global, + RVTI_global, + n_branches, + n_radii, + ) + def run(self, h5file) -> ProcessResult: v_raw = np.asarray(h5file[self.v_raw_input]) - v_raw = np.maximum(v_raw, 0) - - v_bandlimited = np.asarray(h5file[self.v_bandlimited_input]) - v_bandlimited = np.maximum(v_bandlimited, 0) + v_band = np.asarray(h5file[self.v_bandlimited_input]) + T = np.asarray(h5file[self.T_input]) - T = np.asarray(h5file[self.T]) - - moment_0_segment = 0 - moment_1_segment = 0 - - Tau_M1_bandlimited_segment = [] - Tau_M1_over_T_bandlimited_segment = [] - RI_bandlimited_segment = [] - R_VTI_bandlimited_segment = [] - - Tau_M1_raw_segment = [] - Tau_M1_over_T_raw_segment = [] - RI_raw_segment = [] - R_VTI_raw_segment = [] + v_raw = self._rectify_keep_nan(v_raw) + v_band = self._rectify_keep_nan(v_band) ratio_systole_diastole_R_VTI = 0.5 - for beat_idx in range(len(v_bandlimited[0, :, 0, 0])): - Tau_M1_bandlimited_branch = [] - Tau_M1_over_T_bandlimited_branch = [] - RI_bandlimited_branch = [] - R_VTI_bandlimited_branch = [] - - for branch_idx in range(len(v_bandlimited[0, beat_idx, :, 0])): - v_bandlimited_average = np.nanmean( - v_bandlimited[:, beat_idx, branch_idx, :], axis=1 - ) - t = T[0][beat_idx] / len(v_bandlimited_average) - - moment_0_segment += np.sum(v_bandlimited_average) - for time_idx in range(len(v_bandlimited_average)): - moment_1_segment += v_bandlimited_average[time_idx] * time_idx * t - - if moment_0_segment != 0: - TM1 = moment_1_segment / moment_0_segment - Tau_M1_bandlimited_branch.append(TM1) - Tau_M1_over_T_bandlimited_branch.append(TM1 / T[0][beat_idx]) - else: - Tau_M1_bandlimited_branch.append(0) - Tau_M1_over_T_bandlimited_branch.append(0) - - v_bandlimited_max = np.max(v_bandlimited_average) - v_bandlimited_min = np.min(v_bandlimited_average) - - RI_bandlimited_branch_idx = 1 - (v_bandlimited_min / v_bandlimited_max) - RI_bandlimited_branch.append(RI_bandlimited_branch_idx) - - epsilon = 10 ** (-12) - D1_bandlimited = np.sum( - v_bandlimited_average[ - : int( - np.ceil( - len(v_bandlimited_average) - * ratio_systole_diastole_R_VTI - ) - ) - ] - ) - D2_bandlimited = np.sum( - v_bandlimited_average[ - int( - np.ceil( - len(v_bandlimited_average) - * ratio_systole_diastole_R_VTI - ) - ) : - ] - ) - R_VTI_bandlimited_branch.append( - D2_bandlimited / (D1_bandlimited + epsilon) - ) - - Tau_M1_bandlimited_segment.append(Tau_M1_bandlimited_branch) - Tau_M1_over_T_bandlimited_segment.append(Tau_M1_over_T_bandlimited_branch) - RI_bandlimited_segment.append(RI_bandlimited_branch) - R_VTI_bandlimited_segment.append(R_VTI_bandlimited_branch) - - for beat_idx in range(len(v_raw[0, :, 0, 0])): - Tau_M1_raw_branch = [] - Tau_M1_over_T_raw_branch = [] - RI_raw_branch = [] - R_VTI_raw_branch = [] - - for branch_idx in range(len(v_raw[0, beat_idx, :, 0])): - v_raw_average = np.nanmean(v_raw[:, beat_idx, branch_idx, :], axis=1) - t = T[0][beat_idx] / len(v_raw_average) - - moment_0_segment += np.sum(v_raw_average) - for time_idx in range(len(v_raw_average)): - moment_1_segment += v_raw_average[time_idx] * time_idx * t - - if moment_0_segment != 0: - TM1 = moment_1_segment / moment_0_segment - Tau_M1_raw_branch.append(TM1) - Tau_M1_over_T_raw_branch.append(TM1 / T[0][beat_idx]) - else: - Tau_M1_raw_branch.append(0) - Tau_M1_over_T_raw_branch.append(0) - - v_raw_max = np.max(v_raw_average) - v_raw_min = np.min(v_raw_average) - - RI_raw_branch_idx = 1 - (v_raw_min / v_raw_max) - RI_raw_branch.append(RI_raw_branch_idx) - - epsilon = 10 ** (-12) - D1_raw = np.sum( - v_raw_average[ - : int( - np.ceil(len(v_raw_average) * ratio_systole_diastole_R_VTI) - ) - ] - ) - D2_raw = np.sum( - v_raw_average[ - int( - np.ceil(len(v_raw_average) * ratio_systole_diastole_R_VTI) - ) : - ] - ) - R_VTI_raw_branch.append(D2_raw / (D1_raw + epsilon)) - - Tau_M1_raw_segment.append(Tau_M1_raw_branch) - Tau_M1_over_T_raw_segment.append(Tau_M1_over_T_raw_branch) - RI_raw_segment.append(RI_raw_branch) - R_VTI_raw_segment.append(R_VTI_raw_branch) + ( + tau_seg_b, + tauT_seg_b, + RI_seg_b, + RVTI_seg_b, + tau_br_b, + tauT_br_b, + RI_br_b, + RVTI_br_b, + tau_gl_b, + tauT_gl_b, + RI_gl_b, + RVTI_gl_b, + n_branches_b, + n_radii_b, + ) = self._compute_block(v_band, T, ratio_systole_diastole_R_VTI) + + ( + tau_seg_r, + tauT_seg_r, + RI_seg_r, + RVTI_seg_r, + tau_br_r, + tauT_br_r, + RI_br_r, + RVTI_br_r, + tau_gl_r, + tauT_gl_r, + RI_gl_r, + RVTI_gl_r, + n_branches_r, + n_radii_r, + ) = self._compute_block(v_raw, T, ratio_systole_diastole_R_VTI) + + # Consistency attributes (optional but useful) + seg_order_note = ( + "seg_idx = branch_idx * n_radii + radius_idx (branch-major flattening)" + ) + if n_radii_b != n_radii_r or n_branches_b != n_branches_r: + seg_order_note += " | WARNING: raw/bandlimited branch/radius dims differ." - # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. metrics = { + # --- Existing datasets (unchanged names/shapes) --- "tau_M1_bandlimited_segment": with_attrs( - np.asarray(Tau_M1_bandlimited_segment), + tau_seg_b, { - "unit": [""], + "unit": ["s"], + "definition": ["tau_M1 = M1/M0 on rectified waveform"], + "segment_indexing": [seg_order_note], }, ), - "R_VTI_bandlimited_segment": with_attrs( - np.asarray(R_VTI_bandlimited_segment), + "tau_M1_over_T_bandlimited_segment": with_attrs( + tauT_seg_b, { "unit": [""], + "definition": ["tau_M1_over_T = (M1/M0)/T"], + "segment_indexing": [seg_order_note], }, ), "RI_bandlimited_segment": with_attrs( - np.asarray(RI_bandlimited_segment), + RI_seg_b, { "unit": [""], + "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], + "segment_indexing": [seg_order_note], }, ), - "tau_M1_over_T_bandlimited_segment": with_attrs( - np.asarray(Tau_M1_over_T_bandlimited_segment), + "R_VTI_bandlimited_segment": with_attrs( + RVTI_seg_b, { "unit": [""], + "definition": ["paper RVTI = D1/(D2+eps)"], + "segment_indexing": [seg_order_note], }, ), "tau_M1_raw_segment": with_attrs( - np.asarray(Tau_M1_raw_segment), + tau_seg_r, + { + "unit": ["s"], + "definition": ["tau_M1 = M1/M0 on rectified waveform"], + "segment_indexing": [seg_order_note], + }, + ), + "tau_M1_over_T_raw_segment": with_attrs( + tauT_seg_r, + { + "unit": [""], + "definition": ["tau_M1_over_T = (M1/M0)/T"], + "segment_indexing": [seg_order_note], + }, + ), + "RI_raw_segment": with_attrs( + RI_seg_r, { "unit": [""], + "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], + "segment_indexing": [seg_order_note], }, ), "R_VTI_raw_segment": with_attrs( - np.asarray(R_VTI_raw_segment), + RVTI_seg_r, { "unit": [""], + "definition": ["paper RVTI = D1/(D2+eps)"], + "segment_indexing": [seg_order_note], }, ), - "RI_raw_segment": with_attrs( - np.asarray(RI_raw_segment), + "ratio_systole_diastole_R_VTI": np.asarray( + ratio_systole_diastole_R_VTI, dtype=float + ), + # --- New aggregated outputs --- + "tau_M1_bandlimited_branch": with_attrs( + tau_br_b, + {"unit": ["s"], "definition": ["median over radii: tau_M1 per branch"]}, + ), + "tau_M1_over_T_bandlimited_branch": with_attrs( + tauT_br_b, { "unit": [""], + "definition": ["median over radii: tau_M1/T per branch"], }, ), - "tau_M1_over_T_raw_segment": with_attrs( - np.asarray(Tau_M1_over_T_raw_segment), + "RI_bandlimited_branch": with_attrs( + RI_br_b, + {"unit": [""], "definition": ["median over radii: RI per branch"]}, + ), + "R_VTI_bandlimited_branch": with_attrs( + RVTI_br_b, + { + "unit": [""], + "definition": ["median over radii: paper RVTI per branch"], + }, + ), + "tau_M1_bandlimited_global": with_attrs( + tau_gl_b, + { + "unit": ["s"], + "definition": ["mean over branches & radii: tau_M1 global"], + }, + ), + "tau_M1_over_T_bandlimited_global": with_attrs( + tauT_gl_b, + { + "unit": [""], + "definition": ["mean over branches & radii: tau_M1/T global"], + }, + ), + "RI_bandlimited_global": with_attrs( + RI_gl_b, + {"unit": [""], "definition": ["mean over branches & radii: RI global"]}, + ), + "R_VTI_bandlimited_global": with_attrs( + RVTI_gl_b, + { + "unit": [""], + "definition": ["mean over branches & radii: paper RVTI global"], + }, + ), + "tau_M1_raw_branch": with_attrs( + tau_br_r, + {"unit": ["s"], "definition": ["median over radii: tau_M1 per branch"]}, + ), + "tau_M1_over_T_raw_branch": with_attrs( + tauT_br_r, + { + "unit": [""], + "definition": ["median over radii: tau_M1/T per branch"], + }, + ), + "RI_raw_branch": with_attrs( + RI_br_r, + {"unit": [""], "definition": ["median over radii: RI per branch"]}, + ), + "R_VTI_raw_branch": with_attrs( + RVTI_br_r, + { + "unit": [""], + "definition": ["median over radii: paper RVTI per branch"], + }, + ), + "tau_M1_raw_global": with_attrs( + tau_gl_r, + { + "unit": ["s"], + "definition": ["mean over branches & radii: tau_M1 global"], + }, + ), + "tau_M1_over_T_raw_global": with_attrs( + tauT_gl_r, + { + "unit": [""], + "definition": ["mean over branches & radii: tau_M1/T global"], + }, + ), + "RI_raw_global": with_attrs( + RI_gl_r, + {"unit": [""], "definition": ["mean over branches & radii: RI global"]}, + ), + "R_VTI_raw_global": with_attrs( + RVTI_gl_r, { "unit": [""], + "definition": ["mean over branches & radii: paper RVTI global"], }, ), - "ratio_systole_diastole_R_VTI": np.asarray(ratio_systole_diastole_R_VTI), } - # Artifacts can store non-metric outputs (strings, paths, etc.). - return ProcessResult(metrics=metrics) diff --git a/src/pipelines/arterial_velocity_waveform_shape_metrics.py b/src/pipelines/arterial_velocity_waveform_shape_metrics.py index cd0fb2a..030241e 100644 --- a/src/pipelines/arterial_velocity_waveform_shape_metrics.py +++ b/src/pipelines/arterial_velocity_waveform_shape_metrics.py @@ -73,8 +73,8 @@ def run(self, h5file) -> ProcessResult: ) : ] ) - R_VTI_bandlimited.append(D2_bandlimited / (D1_bandlimited + 10 ** (-12))) - R_VTI_raw.append(D2_raw / (D1_raw + 10 ** (-12))) + R_VTI_bandlimited.append(D1_bandlimited / (D2_bandlimited + 10 ** (-12))) + R_VTI_raw.append(D1_raw / (D2_raw + 10 ** (-12))) M_0 = np.sum(v_raw.T[beat_idx]) M_1 = 0 for time_idx in range(len(v_raw.T[beat_idx])): From 3db0396a5c2814e05594e612e61179f995cec6bd Mon Sep 17 00:00:00 2001 From: gregoire Date: Mon, 16 Feb 2026 17:43:01 +0100 Subject: [PATCH 65/71] fusion global and computed by segment of the metrics R_VTI RI and TauM1 --- ...egments_velocity_waveform_shape_metrics.py | 516 +++++++++++------- 1 file changed, 320 insertions(+), 196 deletions(-) diff --git a/src/pipelines/Segments_velocity_waveform_shape_metrics.py b/src/pipelines/Segments_velocity_waveform_shape_metrics.py index 0e96cdd..fd66a08 100644 --- a/src/pipelines/Segments_velocity_waveform_shape_metrics.py +++ b/src/pipelines/Segments_velocity_waveform_shape_metrics.py @@ -39,10 +39,16 @@ class ArterialSegExample(ProcessPipeline): "Segment waveform shape metrics (tau, RI, RVTI) + branch/global aggregates." ) - v_raw_input = ( - "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + v_raw_global_input = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" + v_bandlimited_global_input = ( + "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" + ) + v_bandlimited_global_max_input = ( + "/Artery/VelocityPerBeat/VmaxPerBeatBandLimited/value" + ) + v_bandlimited_global_min_input = ( + "/Artery/VelocityPerBeat/VminPerBeatBandLimited/value" ) - v_bandlimited_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" T_input = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" @staticmethod @@ -219,220 +225,338 @@ def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): ) def run(self, h5file) -> ProcessResult: - v_raw = np.asarray(h5file[self.v_raw_input]) - v_band = np.asarray(h5file[self.v_bandlimited_input]) T = np.asarray(h5file[self.T_input]) - - v_raw = self._rectify_keep_nan(v_raw) - v_band = self._rectify_keep_nan(v_band) - ratio_systole_diastole_R_VTI = 0.5 - - ( - tau_seg_b, - tauT_seg_b, - RI_seg_b, - RVTI_seg_b, - tau_br_b, - tauT_br_b, - RI_br_b, - RVTI_br_b, - tau_gl_b, - tauT_gl_b, - RI_gl_b, - RVTI_gl_b, - n_branches_b, - n_radii_b, - ) = self._compute_block(v_band, T, ratio_systole_diastole_R_VTI) - - ( - tau_seg_r, - tauT_seg_r, - RI_seg_r, - RVTI_seg_r, - tau_br_r, - tauT_br_r, - RI_br_r, - RVTI_br_r, - tau_gl_r, - tauT_gl_r, - RI_gl_r, - RVTI_gl_r, - n_branches_r, - n_radii_r, - ) = self._compute_block(v_raw, T, ratio_systole_diastole_R_VTI) - - # Consistency attributes (optional but useful) - seg_order_note = ( - "seg_idx = branch_idx * n_radii + radius_idx (branch-major flattening)" - ) - if n_radii_b != n_radii_r or n_branches_b != n_branches_r: - seg_order_note += " | WARNING: raw/bandlimited branch/radius dims differ." - - metrics = { - # --- Existing datasets (unchanged names/shapes) --- - "tau_M1_bandlimited_segment": with_attrs( + segment = False + try: + v_raw_input = ( + "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + ) + v_bandlimited_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" + segment = True + except Exception: # noqa: BLE001 + segment = False + if segment: + v_raw = np.asarray(h5file[v_raw_input]) + v_band = np.asarray(h5file[v_bandlimited_input]) + v_raw = self._rectify_keep_nan(v_raw) + v_band = self._rectify_keep_nan(v_band) + + ( tau_seg_b, - { - "unit": ["s"], - "definition": ["tau_M1 = M1/M0 on rectified waveform"], - "segment_indexing": [seg_order_note], - }, - ), - "tau_M1_over_T_bandlimited_segment": with_attrs( tauT_seg_b, - { - "unit": [""], - "definition": ["tau_M1_over_T = (M1/M0)/T"], - "segment_indexing": [seg_order_note], - }, - ), - "RI_bandlimited_segment": with_attrs( RI_seg_b, - { - "unit": [""], - "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], - "segment_indexing": [seg_order_note], - }, - ), - "R_VTI_bandlimited_segment": with_attrs( RVTI_seg_b, - { - "unit": [""], - "definition": ["paper RVTI = D1/(D2+eps)"], - "segment_indexing": [seg_order_note], - }, - ), - "tau_M1_raw_segment": with_attrs( - tau_seg_r, - { - "unit": ["s"], - "definition": ["tau_M1 = M1/M0 on rectified waveform"], - "segment_indexing": [seg_order_note], - }, - ), - "tau_M1_over_T_raw_segment": with_attrs( - tauT_seg_r, - { - "unit": [""], - "definition": ["tau_M1_over_T = (M1/M0)/T"], - "segment_indexing": [seg_order_note], - }, - ), - "RI_raw_segment": with_attrs( - RI_seg_r, - { - "unit": [""], - "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], - "segment_indexing": [seg_order_note], - }, - ), - "R_VTI_raw_segment": with_attrs( - RVTI_seg_r, - { - "unit": [""], - "definition": ["paper RVTI = D1/(D2+eps)"], - "segment_indexing": [seg_order_note], - }, - ), - "ratio_systole_diastole_R_VTI": np.asarray( - ratio_systole_diastole_R_VTI, dtype=float - ), - # --- New aggregated outputs --- - "tau_M1_bandlimited_branch": with_attrs( tau_br_b, - {"unit": ["s"], "definition": ["median over radii: tau_M1 per branch"]}, - ), - "tau_M1_over_T_bandlimited_branch": with_attrs( tauT_br_b, - { - "unit": [""], - "definition": ["median over radii: tau_M1/T per branch"], - }, - ), - "RI_bandlimited_branch": with_attrs( RI_br_b, - {"unit": [""], "definition": ["median over radii: RI per branch"]}, - ), - "R_VTI_bandlimited_branch": with_attrs( RVTI_br_b, - { - "unit": [""], - "definition": ["median over radii: paper RVTI per branch"], - }, - ), - "tau_M1_bandlimited_global": with_attrs( tau_gl_b, - { - "unit": ["s"], - "definition": ["mean over branches & radii: tau_M1 global"], - }, - ), - "tau_M1_over_T_bandlimited_global": with_attrs( tauT_gl_b, - { - "unit": [""], - "definition": ["mean over branches & radii: tau_M1/T global"], - }, - ), - "RI_bandlimited_global": with_attrs( RI_gl_b, - {"unit": [""], "definition": ["mean over branches & radii: RI global"]}, - ), - "R_VTI_bandlimited_global": with_attrs( RVTI_gl_b, - { - "unit": [""], - "definition": ["mean over branches & radii: paper RVTI global"], - }, - ), - "tau_M1_raw_branch": with_attrs( + n_branches_b, + n_radii_b, + ) = self._compute_block(v_band, T, ratio_systole_diastole_R_VTI) + + ( + tau_seg_r, + tauT_seg_r, + RI_seg_r, + RVTI_seg_r, tau_br_r, - {"unit": ["s"], "definition": ["median over radii: tau_M1 per branch"]}, - ), - "tau_M1_over_T_raw_branch": with_attrs( tauT_br_r, - { - "unit": [""], - "definition": ["median over radii: tau_M1/T per branch"], - }, - ), - "RI_raw_branch": with_attrs( RI_br_r, - {"unit": [""], "definition": ["median over radii: RI per branch"]}, - ), - "R_VTI_raw_branch": with_attrs( RVTI_br_r, - { - "unit": [""], - "definition": ["median over radii: paper RVTI per branch"], - }, - ), - "tau_M1_raw_global": with_attrs( tau_gl_r, - { - "unit": ["s"], - "definition": ["mean over branches & radii: tau_M1 global"], - }, - ), - "tau_M1_over_T_raw_global": with_attrs( tauT_gl_r, - { - "unit": [""], - "definition": ["mean over branches & radii: tau_M1/T global"], - }, - ), - "RI_raw_global": with_attrs( RI_gl_r, - {"unit": [""], "definition": ["mean over branches & radii: RI global"]}, - ), - "R_VTI_raw_global": with_attrs( RVTI_gl_r, - { - "unit": [""], - "definition": ["mean over branches & radii: paper RVTI global"], - }, - ), - } + n_branches_r, + n_radii_r, + ) = self._compute_block(v_raw, T, ratio_systole_diastole_R_VTI) + + # Consistency attributes (optional but useful) + seg_order_note = ( + "seg_idx = branch_idx * n_radii + radius_idx (branch-major flattening)" + ) + if n_radii_b != n_radii_r or n_branches_b != n_branches_r: + seg_order_note += ( + " | WARNING: raw/bandlimited branch/radius dims differ." + ) + + metrics = { + # --- Existing datasets (unchanged names/shapes) --- + "computed_by_segment/tau_M1_bandlimited_segment": with_attrs( + tau_seg_b, + { + "unit": ["s"], + "definition": ["tau_M1 = M1/M0 on rectified waveform"], + "segment_indexing": [seg_order_note], + }, + ), + "computed_by_segment/tau_M1_over_T_bandlimited_segment": with_attrs( + tauT_seg_b, + { + "unit": [""], + "definition": ["tau_M1_over_T = (M1/M0)/T"], + "segment_indexing": [seg_order_note], + }, + ), + "computed_by_segment/RI_bandlimited_segment": with_attrs( + RI_seg_b, + { + "unit": [""], + "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], + "segment_indexing": [seg_order_note], + }, + ), + "computed_by_segment/R_VTI_bandlimited_segment": with_attrs( + RVTI_seg_b, + { + "unit": [""], + "definition": ["paper RVTI = D1/(D2+eps)"], + "segment_indexing": [seg_order_note], + }, + ), + "computed_by_segment/tau_M1_raw_segment": with_attrs( + tau_seg_r, + { + "unit": ["s"], + "definition": ["tau_M1 = M1/M0 on rectified waveform"], + "segment_indexing": [seg_order_note], + }, + ), + "computed_by_segment/tau_M1_over_T_raw_segment": with_attrs( + tauT_seg_r, + { + "unit": [""], + "definition": ["tau_M1_over_T = (M1/M0)/T"], + "segment_indexing": [seg_order_note], + }, + ), + "computed_by_segment/RI_raw_segment": with_attrs( + RI_seg_r, + { + "unit": [""], + "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], + "segment_indexing": [seg_order_note], + }, + ), + "computed_by_segment/R_VTI_raw_segment": with_attrs( + RVTI_seg_r, + { + "unit": [""], + "definition": ["paper RVTI = D1/(D2+eps)"], + "segment_indexing": [seg_order_note], + }, + ), + "computed_by_segment/ratio_systole_diastole_R_VTI": np.asarray( + ratio_systole_diastole_R_VTI, dtype=float + ), + # --- New aggregated outputs --- + "computed_by_segment/tau_M1_bandlimited_branch": with_attrs( + tau_br_b, + { + "unit": ["s"], + "definition": ["median over radii: tau_M1 per branch"], + }, + ), + "computed_by_segment/tau_M1_over_T_bandlimited_branch": with_attrs( + tauT_br_b, + { + "unit": [""], + "definition": ["median over radii: tau_M1/T per branch"], + }, + ), + "computed_by_segment/RI_bandlimited_branch": with_attrs( + RI_br_b, + {"unit": [""], "definition": ["median over radii: RI per branch"]}, + ), + "computed_by_segment/R_VTI_bandlimited_branch": with_attrs( + RVTI_br_b, + { + "unit": [""], + "definition": ["median over radii: paper RVTI per branch"], + }, + ), + "computed_by_segment/tau_M1_bandlimited_global": with_attrs( + tau_gl_b, + { + "unit": ["s"], + "definition": ["mean over branches & radii: tau_M1 global"], + }, + ), + "computed_by_segment/tau_M1_over_T_bandlimited_global": with_attrs( + tauT_gl_b, + { + "unit": [""], + "definition": ["mean over branches & radii: tau_M1/T global"], + }, + ), + "computed_by_segment/RI_bandlimited_global": with_attrs( + RI_gl_b, + { + "unit": [""], + "definition": ["mean over branches & radii: RI global"], + }, + ), + "computed_by_segment/R_VTI_bandlimited_global": with_attrs( + RVTI_gl_b, + { + "unit": [""], + "definition": ["mean over branches & radii: paper RVTI global"], + }, + ), + "computed_by_segment/tau_M1_raw_branch": with_attrs( + tau_br_r, + { + "unit": ["s"], + "definition": ["median over radii: tau_M1 per branch"], + }, + ), + "computed_by_segment/tau_M1_over_T_raw_branch": with_attrs( + tauT_br_r, + { + "unit": [""], + "definition": ["median over radii: tau_M1/T per branch"], + }, + ), + "computed_by_segment/RI_raw_branch": with_attrs( + RI_br_r, + {"unit": [""], "definition": ["median over radii: RI per branch"]}, + ), + "computed_by_segment/R_VTI_raw_branch": with_attrs( + RVTI_br_r, + { + "unit": [""], + "definition": ["median over radii: paper RVTI per branch"], + }, + ), + "computed_by_segment/tau_M1_raw_global": with_attrs( + tau_gl_r, + { + "unit": ["s"], + "definition": ["mean over branches & radii: tau_M1 global"], + }, + ), + "computed_by_segment/tau_M1_over_T_raw_global": with_attrs( + tauT_gl_r, + { + "unit": [""], + "definition": ["mean over branches & radii: tau_M1/T global"], + }, + ), + "computed_by_segment/RI_raw_global": with_attrs( + RI_gl_r, + { + "unit": [""], + "definition": ["mean over branches & radii: RI global"], + }, + ), + "computed_by_segment/R_VTI_raw_global": with_attrs( + RVTI_gl_r, + { + "unit": [""], + "definition": ["mean over branches & radii: paper RVTI global"], + }, + ), + } + else: + metrics = {} + v_raw = np.asarray(h5file[self.v_raw_global_input]) + v_raw = np.maximum(v_raw, 0) + v_bandlimited = np.asarray(h5file[self.v_bandlimited_global_input]) + v_bandlimited = np.maximum(v_bandlimited, 0) + v_bandlimited_max = np.asarray(h5file[self.v_bandlimited_global_max_input]) + v_bandlimited_max = np.maximum(v_bandlimited_max, 0) + v_bandlimited_min = np.asarray(h5file[self.v_bandlimited_global_min_input]) + v_bandlimited_min = np.maximum(v_bandlimited_min, 0) + tau_M1_raw = [] + tau_M1_over_T_raw = [] + tau_M1_bandlimited = [] + tau_M1_over_T_bandlimited = [] + + R_VTI_bandlimited = [] + R_VTI_raw = [] + + RI_bandlimited = [] + RI_raw = [] + + ratio_systole_diastole_R_VTI = 0.5 + for beat_idx in range(len(T[0])): + t = T[0][beat_idx] / len(v_raw.T[beat_idx]) + D1_raw = np.sum( + v_raw.T[beat_idx][ + : int(np.ceil(len(v_raw.T[0]) * ratio_systole_diastole_R_VTI)) + ] + ) + D2_raw = np.sum( + v_raw.T[beat_idx][ + int(np.ceil(len(v_raw.T[0]) * ratio_systole_diastole_R_VTI)) : + ] + ) + D1_bandlimited = np.sum( + v_bandlimited.T[beat_idx][ + : int( + np.ceil(len(v_bandlimited.T[0]) * ratio_systole_diastole_R_VTI) + ) + ] + ) + D2_bandlimited = np.sum( + v_bandlimited.T[beat_idx][ + int( + np.ceil(len(v_bandlimited.T[0]) * ratio_systole_diastole_R_VTI) + ) : + ] + ) + R_VTI_bandlimited.append(D1_bandlimited / (D2_bandlimited + 10 ** (-12))) + R_VTI_raw.append(D1_raw / (D2_raw + 10 ** (-12))) + M_0 = np.sum(v_raw.T[beat_idx]) + M_1 = 0 + for time_idx in range(len(v_raw.T[beat_idx])): + M_1 += v_raw[time_idx][beat_idx] * time_idx * t + TM1 = M_1 / M_0 + tau_M1_raw.append(TM1) + tau_M1_over_T_raw.append(TM1 / T[0][beat_idx]) + + for beat_idx in range(len(T[0])): + t = T[0][beat_idx] / len(v_raw.T[beat_idx]) + M_0 = np.sum(v_bandlimited.T[beat_idx]) + M_1 = 0 + for time_idx in range(len(v_raw.T[beat_idx])): + M_1 += v_bandlimited[time_idx][beat_idx] * time_idx * t + TM1 = M_1 / M_0 + tau_M1_bandlimited.append(TM1) + tau_M1_over_T_bandlimited.append(TM1 / T[0][beat_idx]) + + for beat_idx in range(len(v_bandlimited_max[0])): + RI_bandlimited_temp = 1 - ( + v_bandlimited_min[0][beat_idx] / v_bandlimited_max[0][beat_idx] + ) + RI_bandlimited.append(RI_bandlimited_temp) + + for beat_idx in range(len(v_bandlimited_max[0])): + RI_raw_temp = 1 - (np.min(v_raw.T[beat_idx]) / np.max(v_raw.T[beat_idx])) + RI_raw.append(RI_raw_temp) + metrics.update( + { + "global/tau_M1_raw": with_attrs(np.asarray(tau_M1_raw), {"unit": [""]}), + "global/tau_M1_bandlimited": np.asarray(tau_M1_bandlimited), + "global/tau_M1_over_T_raw": with_attrs( + np.asarray(tau_M1_over_T_raw), {"unit": [""]} + ), + "global/tau_M1_over_T_bandlimited": np.asarray( + tau_M1_over_T_bandlimited + ), + "global/RI_bandlimited": np.asarray(RI_bandlimited), + "global/RI_raw": np.asarray(RI_raw), + "global/R_VTI_bandlimited": np.asarray(R_VTI_bandlimited), + "global/R_VTI_raw": np.asarray(R_VTI_raw), + "global/ratio_systole_diastole_R_VTI": np.asarray( + ratio_systole_diastole_R_VTI + ), + } + ) return ProcessResult(metrics=metrics) From 029a5f442f3f383c3c72f2987857f6bc618a03e1 Mon Sep 17 00:00:00 2001 From: gregoire Date: Tue, 17 Feb 2026 13:22:33 +0100 Subject: [PATCH 66/71] adding and correction of m2 over M0 spatial and temporal modes --- ...egments_velocity_waveform_shape_metrics.py | 65 +++++++++++++++++++ src/pipelines/modal_analysis.py | 21 +++--- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/pipelines/Segments_velocity_waveform_shape_metrics.py b/src/pipelines/Segments_velocity_waveform_shape_metrics.py index fd66a08..ce86d6a 100644 --- a/src/pipelines/Segments_velocity_waveform_shape_metrics.py +++ b/src/pipelines/Segments_velocity_waveform_shape_metrics.py @@ -284,6 +284,14 @@ def run(self, h5file) -> ProcessResult: seg_order_note += ( " | WARNING: raw/bandlimited branch/radius dims differ." ) + std_tau_seg_b = np.nanstd(tau_seg_b) + std_tau_seg_r = np.nanstd(tau_seg_r) + std_tauT_seg_b = np.nanstd(tauT_seg_b) + std_tauT_seg_r = np.nanstd(tauT_seg_r) + std_RI_seg_b = np.nanstd(RI_seg_b) + std_RI_seg_r = np.nanstd(RI_seg_r) + std_RVTI_seg_b = np.nanstd(RVTI_seg_b) + std_RVTI_seg_r = np.nanstd(RVTI_seg_r) metrics = { # --- Existing datasets (unchanged names/shapes) --- @@ -461,7 +469,64 @@ def run(self, h5file) -> ProcessResult: "definition": ["mean over branches & radii: paper RVTI global"], }, ), + "computed_by_segment/standard_deviation/std_tau_M1_segment_bandlimited": with_attrs( + std_tau_seg_b, + { + "unit": [""], + "definition": [""], + }, + ), + "computed_by_segment/standard_deviation/std_tau_M1_segment_raw": with_attrs( + std_tau_seg_r, + { + "unit": [""], + "definition": [""], + }, + ), + "computed_by_segment/standard_deviation/std_tau_M1_overT_segment_bandlimited": with_attrs( + std_tauT_seg_b, + { + "unit": [""], + "definition": [""], + }, + ), + "computed_by_segment/standard_deviation/std_tau_M1_overT_segment_raw": with_attrs( + std_tauT_seg_r, + { + "unit": [""], + "definition": [""], + }, + ), + "computed_by_segment/standard_deviation/std_RI_segment_raw": with_attrs( + std_RI_seg_r, + { + "unit": [""], + "definition": [""], + }, + ), + "computed_by_segment/standard_deviation/std_RI_segment_bandlimited": with_attrs( + std_RI_seg_b, + { + "unit": [""], + "definition": [""], + }, + ), + "computed_by_segment/standard_deviation/std_R_VTI_segment_raw": with_attrs( + std_RVTI_seg_r, + { + "unit": [""], + "definition": [""], + }, + ), + "computed_by_segment/standard_deviation/std_R_VTI_segment_bandlimited": with_attrs( + std_RVTI_seg_b, + { + "unit": [""], + "definition": [""], + }, + ), } + else: metrics = {} v_raw = np.asarray(h5file[self.v_raw_global_input]) diff --git a/src/pipelines/modal_analysis.py b/src/pipelines/modal_analysis.py index 90a1347..7b15214 100644 --- a/src/pipelines/modal_analysis.py +++ b/src/pipelines/modal_analysis.py @@ -39,13 +39,14 @@ def run(self, h5file) -> ProcessResult: M0_matrix_time = [] M1_matrix_time = [] M2_matrix_time = [] + M2_over_M0_squared_time = [] for x_idx in range(x_size): for y_idx in range(y_size): M0 = moment_0[time_idx, 0, x_idx, y_idx] M1 = moment_1[time_idx, 0, x_idx, y_idx] M2 = moment_2[time_idx, 0, x_idx, y_idx] M0_matrix_time.append(M0) - M2_over_M0_squared.append(np.sqrt(M2 / M0)) + M2_over_M0_squared_time.append(np.sqrt(M2 / M0)) M1_matrix_time.append(moment_1[time_idx, 0, x_idx, y_idx]) @@ -54,32 +55,32 @@ def run(self, h5file) -> ProcessResult: M0_matrix.append(M0_matrix_time) M1_matrix.append(M1_matrix_time) M2_matrix.append(M2_matrix_time) + M2_over_M0_squared.append(M2_over_M0_squared_time) M0_matrix = np.transpose(np.asarray(M0_matrix)) - M2_over_M0_squared = np.asarray(M2_over_M0_squared) - k = 6 - U_0, S_0, Vt_0 = svds(M0_matrix, k=k) + M2_over_M0_squared = np.transpose(np.asarray(M2_over_M0_squared)) + n_modes = 20 + U_0, S_0, Vt_0 = svds(M0_matrix, k=n_modes) idx = np.argsort(S_0)[::-1] S_0 = S_0[idx] U_0 = U_0[:, idx] Vt_0 = Vt_0[idx, :] - idx = np.argsort(S_0)[::-1] - S_0 = S_0[idx] - U_0 = U_0[:, idx] - Vt_0 = Vt_0[idx, :] spatial_modes = [] for mode_idx in range(len(U_0[0])): spatial_modes.append(U_0[:, mode_idx].reshape(x_size, y_size)) + # M2 over M0 + U_M2_over_M0_squared, S_M2_over_M0_squared, Vt_M2_over_M0_squared = svds( - M0_matrix, k=k + M2_over_M0_squared, k=n_modes ) - idx = np.argsort(S_0)[::-1] + idx = np.argsort(S_M2_over_M0_squared)[::-1] S_M2_over_M0_squared = S_M2_over_M0_squared[idx] U_M2_over_M0_squared = U_M2_over_M0_squared[:, idx] Vt_M2_over_M0_squared = Vt_M2_over_M0_squared[idx, :] spatial_modes_M2_over_M0_squared = [] + for mode_idx in range(len(U_M2_over_M0_squared[0])): spatial_modes_M2_over_M0_squared.append( U_M2_over_M0_squared[:, mode_idx].reshape(x_size, y_size) From 72c155d5f049d51f9ed907332dae6157e9d5f0cf Mon Sep 17 00:00:00 2001 From: gregoire Date: Tue, 17 Feb 2026 14:29:42 +0100 Subject: [PATCH 67/71] rectification of global metrics computing on files missing velocity per segment --- .../Segments_velocity_waveform_shape_metrics.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/pipelines/Segments_velocity_waveform_shape_metrics.py b/src/pipelines/Segments_velocity_waveform_shape_metrics.py index ce86d6a..f5791dc 100644 --- a/src/pipelines/Segments_velocity_waveform_shape_metrics.py +++ b/src/pipelines/Segments_velocity_waveform_shape_metrics.py @@ -227,16 +227,13 @@ def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): def run(self, h5file) -> ProcessResult: T = np.asarray(h5file[self.T_input]) ratio_systole_diastole_R_VTI = 0.5 - segment = False + try: v_raw_input = ( "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" ) v_bandlimited_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" - segment = True - except Exception: # noqa: BLE001 - segment = False - if segment: + v_raw = np.asarray(h5file[v_raw_input]) v_band = np.asarray(h5file[v_bandlimited_input]) v_raw = self._rectify_keep_nan(v_raw) @@ -527,7 +524,7 @@ def run(self, h5file) -> ProcessResult: ), } - else: + except Exception: # noqa: BLE001 metrics = {} v_raw = np.asarray(h5file[self.v_raw_global_input]) v_raw = np.maximum(v_raw, 0) From 0cb69d5a1fa93082f3c35663606427ac6e24d3a7 Mon Sep 17 00:00:00 2001 From: gregoire Date: Tue, 17 Feb 2026 16:25:11 +0100 Subject: [PATCH 68/71] veloctiy_waveform_shape_metrics adding PI --- ...egments_velocity_waveform_shape_metrics.py | 194 +++++++++--------- 1 file changed, 102 insertions(+), 92 deletions(-) diff --git a/src/pipelines/Segments_velocity_waveform_shape_metrics.py b/src/pipelines/Segments_velocity_waveform_shape_metrics.py index f5791dc..2c72a38 100644 --- a/src/pipelines/Segments_velocity_waveform_shape_metrics.py +++ b/src/pipelines/Segments_velocity_waveform_shape_metrics.py @@ -3,7 +3,7 @@ from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs -@registerPipeline(name="segment_waveform_shape_metrics_test") +@registerPipeline(name="waveform_shape_metrics") class ArterialSegExample(ProcessPipeline): """ Waveform-shape metrics on per-beat, per-branch, per-radius velocity waveforms. @@ -100,18 +100,23 @@ def _metrics_from_waveform( # RI robust if not np.any(np.isfinite(v)): RI = np.nan + PI = np.nan else: vmax = np.nanmax(v) + mean = np.nanmean(v) if (not np.isfinite(vmax)) or vmax <= 0: RI = np.nan + PI = np.nan else: vmin = np.nanmin(v) RI = 1.0 - (vmin / vmax) + PI = (vmax - vmin) / mean if not np.isfinite(RI): RI = np.nan + PI = np.nan else: RI = float(np.clip(RI, 0.0, 1.0)) - + PI = float(PI) # RVTI (paper): D1/(D2+eps) k = int(np.ceil(n * ratio)) k = max(0, min(n, k)) @@ -123,7 +128,7 @@ def _metrics_from_waveform( D2 = np.nan RVTI = float(D1 / (D2 + eps)) - return float(tau_M1), float(tau_M1_over_T), float(RI), RVTI + return float(tau_M1), float(tau_M1_over_T), float(RI), RVTI, float(PI) def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): if v_block.ndim != 4: @@ -138,17 +143,20 @@ def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): tau_seg = np.zeros((n_beats, n_segments), dtype=float) tauT_seg = np.zeros((n_beats, n_segments), dtype=float) RI_seg = np.zeros((n_beats, n_segments), dtype=float) + PI_seg = np.zeros((n_beats, n_segments), dtype=float) RVTI_seg = np.zeros((n_beats, n_segments), dtype=float) # Aggregated tau_branch = np.zeros((n_beats, n_branches), dtype=float) tauT_branch = np.zeros((n_beats, n_branches), dtype=float) RI_branch = np.zeros((n_beats, n_branches), dtype=float) + PI_branch = np.zeros((n_beats, n_branches), dtype=float) RVTI_branch = np.zeros((n_beats, n_branches), dtype=float) tau_global = np.zeros((n_beats,), dtype=float) tauT_global = np.zeros((n_beats,), dtype=float) RI_global = np.zeros((n_beats,), dtype=float) + PI_global = np.zeros((n_beats,), dtype=float) RVTI_global = np.zeros((n_beats,), dtype=float) for beat_idx in range(n_beats): @@ -158,6 +166,7 @@ def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): tau_vals = [] tauT_vals = [] RI_vals = [] + PI_vals = [] RVTI_vals = [] for branch_idx in range(n_branches): @@ -165,11 +174,12 @@ def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): tau_b = [] tauT_b = [] RI_b = [] + PI_b = [] RVTI_b = [] for radius_idx in range(n_radii): v = v_block[:, beat_idx, branch_idx, radius_idx] - tM1, tM1T, ri, rvti = self._metrics_from_waveform( + tM1, tM1T, ri, rvti, pi = self._metrics_from_waveform( v=v, Tbeat=Tbeat, ratio=ratio, eps=1e-12 ) @@ -178,16 +188,19 @@ def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): tauT_seg[beat_idx, seg_idx] = tM1T RI_seg[beat_idx, seg_idx] = ri RVTI_seg[beat_idx, seg_idx] = rvti + PI_seg[beat_idx, seg_idx] = pi tau_b.append(tM1) tauT_b.append(tM1T) RI_b.append(ri) RVTI_b.append(rvti) + PI_b.append(pi) tau_vals.append(tM1) tauT_vals.append(tM1T) RI_vals.append(ri) RVTI_vals.append(rvti) + PI_vals.append(pi) # Branch aggregates: MEDIAN over radii tau_branch[beat_idx, branch_idx] = self._safe_nanmedian( @@ -197,6 +210,7 @@ def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): np.asarray(tauT_b) ) RI_branch[beat_idx, branch_idx] = self._safe_nanmedian(np.asarray(RI_b)) + PI_branch[beat_idx, branch_idx] = self._safe_nanmedian(np.asarray(PI_b)) RVTI_branch[beat_idx, branch_idx] = self._safe_nanmedian( np.asarray(RVTI_b) ) @@ -206,19 +220,23 @@ def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): tauT_global[beat_idx] = self._safe_nanmean(np.asarray(tauT_vals)) RI_global[beat_idx] = self._safe_nanmean(np.asarray(RI_vals)) RVTI_global[beat_idx] = self._safe_nanmean(np.asarray(RVTI_vals)) + PI_global[beat_idx] = self._safe_nanmean(np.asarray(PI_vals)) return ( tau_seg, tauT_seg, RI_seg, + PI_seg, RVTI_seg, tau_branch, tauT_branch, RI_branch, + PI_branch, RVTI_branch, tau_global, tauT_global, RI_global, + PI_global, RVTI_global, n_branches, n_radii, @@ -243,14 +261,17 @@ def run(self, h5file) -> ProcessResult: tau_seg_b, tauT_seg_b, RI_seg_b, + PI_seg_b, RVTI_seg_b, tau_br_b, tauT_br_b, RI_br_b, + PI_br_b, RVTI_br_b, tau_gl_b, tauT_gl_b, RI_gl_b, + PI_gl_b, RVTI_gl_b, n_branches_b, n_radii_b, @@ -260,14 +281,17 @@ def run(self, h5file) -> ProcessResult: tau_seg_r, tauT_seg_r, RI_seg_r, + PI_seg_r, RVTI_seg_r, tau_br_r, tauT_br_r, RI_br_r, + PI_br_r, RVTI_br_r, tau_gl_r, tauT_gl_r, RI_gl_r, + PI_gl_r, RVTI_gl_r, n_branches_r, n_radii_r, @@ -281,18 +305,10 @@ def run(self, h5file) -> ProcessResult: seg_order_note += ( " | WARNING: raw/bandlimited branch/radius dims differ." ) - std_tau_seg_b = np.nanstd(tau_seg_b) - std_tau_seg_r = np.nanstd(tau_seg_r) - std_tauT_seg_b = np.nanstd(tauT_seg_b) - std_tauT_seg_r = np.nanstd(tauT_seg_r) - std_RI_seg_b = np.nanstd(RI_seg_b) - std_RI_seg_r = np.nanstd(RI_seg_r) - std_RVTI_seg_b = np.nanstd(RVTI_seg_b) - std_RVTI_seg_r = np.nanstd(RVTI_seg_r) metrics = { # --- Existing datasets (unchanged names/shapes) --- - "computed_by_segment/tau_M1_bandlimited_segment": with_attrs( + "by_segment/tau_M1_bandlimited_segment": with_attrs( tau_seg_b, { "unit": ["s"], @@ -300,7 +316,7 @@ def run(self, h5file) -> ProcessResult: "segment_indexing": [seg_order_note], }, ), - "computed_by_segment/tau_M1_over_T_bandlimited_segment": with_attrs( + "by_segment/tau_M1_over_T_bandlimited_segment": with_attrs( tauT_seg_b, { "unit": [""], @@ -308,7 +324,7 @@ def run(self, h5file) -> ProcessResult: "segment_indexing": [seg_order_note], }, ), - "computed_by_segment/RI_bandlimited_segment": with_attrs( + "by_segment/RI_bandlimited_segment": with_attrs( RI_seg_b, { "unit": [""], @@ -316,7 +332,15 @@ def run(self, h5file) -> ProcessResult: "segment_indexing": [seg_order_note], }, ), - "computed_by_segment/R_VTI_bandlimited_segment": with_attrs( + "by_segment/PI_bandlimited_segment": with_attrs( + PI_seg_b, + { + "unit": [""], + "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/R_VTI_bandlimited_segment": with_attrs( RVTI_seg_b, { "unit": [""], @@ -324,7 +348,7 @@ def run(self, h5file) -> ProcessResult: "segment_indexing": [seg_order_note], }, ), - "computed_by_segment/tau_M1_raw_segment": with_attrs( + "by_segment/tau_M1_raw_segment": with_attrs( tau_seg_r, { "unit": ["s"], @@ -332,7 +356,7 @@ def run(self, h5file) -> ProcessResult: "segment_indexing": [seg_order_note], }, ), - "computed_by_segment/tau_M1_over_T_raw_segment": with_attrs( + "by_segment/tau_M1_over_T_raw_segment": with_attrs( tauT_seg_r, { "unit": [""], @@ -340,7 +364,7 @@ def run(self, h5file) -> ProcessResult: "segment_indexing": [seg_order_note], }, ), - "computed_by_segment/RI_raw_segment": with_attrs( + "by_segment/RI_raw_segment": with_attrs( RI_seg_r, { "unit": [""], @@ -348,7 +372,15 @@ def run(self, h5file) -> ProcessResult: "segment_indexing": [seg_order_note], }, ), - "computed_by_segment/R_VTI_raw_segment": with_attrs( + "by_segment/PI_raw_segment": with_attrs( + PI_seg_r, + { + "unit": [""], + "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/R_VTI_raw_segment": with_attrs( RVTI_seg_r, { "unit": [""], @@ -356,170 +388,136 @@ def run(self, h5file) -> ProcessResult: "segment_indexing": [seg_order_note], }, ), - "computed_by_segment/ratio_systole_diastole_R_VTI": np.asarray( + "by_segment/ratio_systole_diastole_R_VTI": np.asarray( ratio_systole_diastole_R_VTI, dtype=float ), # --- New aggregated outputs --- - "computed_by_segment/tau_M1_bandlimited_branch": with_attrs( + "by_segment/tau_M1_bandlimited_branch": with_attrs( tau_br_b, { "unit": ["s"], "definition": ["median over radii: tau_M1 per branch"], }, ), - "computed_by_segment/tau_M1_over_T_bandlimited_branch": with_attrs( + "by_segment/tau_M1_over_T_bandlimited_branch": with_attrs( tauT_br_b, { "unit": [""], "definition": ["median over radii: tau_M1/T per branch"], }, ), - "computed_by_segment/RI_bandlimited_branch": with_attrs( + "by_segment/RI_bandlimited_branch": with_attrs( RI_br_b, {"unit": [""], "definition": ["median over radii: RI per branch"]}, ), - "computed_by_segment/R_VTI_bandlimited_branch": with_attrs( + "by_segment/PI_bandlimited_branch": with_attrs( + PI_br_b, + {"unit": [""], "definition": ["median over radii: RI per branch"]}, + ), + "by_segment/R_VTI_bandlimited_branch": with_attrs( RVTI_br_b, { "unit": [""], "definition": ["median over radii: paper RVTI per branch"], }, ), - "computed_by_segment/tau_M1_bandlimited_global": with_attrs( + "by_segment/tau_M1_bandlimited_global": with_attrs( tau_gl_b, { "unit": ["s"], "definition": ["mean over branches & radii: tau_M1 global"], }, ), - "computed_by_segment/tau_M1_over_T_bandlimited_global": with_attrs( + "by_segment/tau_M1_over_T_bandlimited_global": with_attrs( tauT_gl_b, { "unit": [""], "definition": ["mean over branches & radii: tau_M1/T global"], }, ), - "computed_by_segment/RI_bandlimited_global": with_attrs( + "by_segment/RI_bandlimited_global": with_attrs( RI_gl_b, { "unit": [""], "definition": ["mean over branches & radii: RI global"], }, ), - "computed_by_segment/R_VTI_bandlimited_global": with_attrs( + "by_segment/PI_bandlimited_global": with_attrs( + PI_gl_b, + { + "unit": [""], + "definition": ["mean over branches & radii: RI global"], + }, + ), + "by_segment/R_VTI_bandlimited_global": with_attrs( RVTI_gl_b, { "unit": [""], "definition": ["mean over branches & radii: paper RVTI global"], }, ), - "computed_by_segment/tau_M1_raw_branch": with_attrs( + "by_segment/tau_M1_raw_branch": with_attrs( tau_br_r, { "unit": ["s"], "definition": ["median over radii: tau_M1 per branch"], }, ), - "computed_by_segment/tau_M1_over_T_raw_branch": with_attrs( + "by_segment/tau_M1_over_T_raw_branch": with_attrs( tauT_br_r, { "unit": [""], "definition": ["median over radii: tau_M1/T per branch"], }, ), - "computed_by_segment/RI_raw_branch": with_attrs( + "by_segment/RI_raw_branch": with_attrs( RI_br_r, {"unit": [""], "definition": ["median over radii: RI per branch"]}, ), - "computed_by_segment/R_VTI_raw_branch": with_attrs( + "by_segment/PI_raw_branch": with_attrs( + PI_br_r, + {"unit": [""], "definition": ["median over radii: RI per branch"]}, + ), + "by_segment/R_VTI_raw_branch": with_attrs( RVTI_br_r, { "unit": [""], "definition": ["median over radii: paper RVTI per branch"], }, ), - "computed_by_segment/tau_M1_raw_global": with_attrs( + "by_segment/tau_M1_raw_global": with_attrs( tau_gl_r, { "unit": ["s"], "definition": ["mean over branches & radii: tau_M1 global"], }, ), - "computed_by_segment/tau_M1_over_T_raw_global": with_attrs( + "by_segment/tau_M1_over_T_raw_global": with_attrs( tauT_gl_r, { "unit": [""], "definition": ["mean over branches & radii: tau_M1/T global"], }, ), - "computed_by_segment/RI_raw_global": with_attrs( + "by_segment/RI_raw_global": with_attrs( RI_gl_r, { "unit": [""], "definition": ["mean over branches & radii: RI global"], }, ), - "computed_by_segment/R_VTI_raw_global": with_attrs( - RVTI_gl_r, - { - "unit": [""], - "definition": ["mean over branches & radii: paper RVTI global"], - }, - ), - "computed_by_segment/standard_deviation/std_tau_M1_segment_bandlimited": with_attrs( - std_tau_seg_b, + "by_segment/PI_raw_global": with_attrs( + PI_gl_r, { "unit": [""], - "definition": [""], - }, - ), - "computed_by_segment/standard_deviation/std_tau_M1_segment_raw": with_attrs( - std_tau_seg_r, - { - "unit": [""], - "definition": [""], - }, - ), - "computed_by_segment/standard_deviation/std_tau_M1_overT_segment_bandlimited": with_attrs( - std_tauT_seg_b, - { - "unit": [""], - "definition": [""], - }, - ), - "computed_by_segment/standard_deviation/std_tau_M1_overT_segment_raw": with_attrs( - std_tauT_seg_r, - { - "unit": [""], - "definition": [""], - }, - ), - "computed_by_segment/standard_deviation/std_RI_segment_raw": with_attrs( - std_RI_seg_r, - { - "unit": [""], - "definition": [""], - }, - ), - "computed_by_segment/standard_deviation/std_RI_segment_bandlimited": with_attrs( - std_RI_seg_b, - { - "unit": [""], - "definition": [""], - }, - ), - "computed_by_segment/standard_deviation/std_R_VTI_segment_raw": with_attrs( - std_RVTI_seg_r, - { - "unit": [""], - "definition": [""], + "definition": ["mean over branches & radii: RI global"], }, ), - "computed_by_segment/standard_deviation/std_R_VTI_segment_bandlimited": with_attrs( - std_RVTI_seg_b, + "by_segment/R_VTI_raw_global": with_attrs( + RVTI_gl_r, { "unit": [""], - "definition": [""], + "definition": ["mean over branches & radii: paper RVTI global"], }, ), } @@ -544,6 +542,8 @@ def run(self, h5file) -> ProcessResult: RI_bandlimited = [] RI_raw = [] + PI_bandlimited = [] + PI_raw = [] ratio_systole_diastole_R_VTI = 0.5 @@ -595,13 +595,21 @@ def run(self, h5file) -> ProcessResult: for beat_idx in range(len(v_bandlimited_max[0])): RI_bandlimited_temp = 1 - ( - v_bandlimited_min[0][beat_idx] / v_bandlimited_max[0][beat_idx] + np.min(v_bandlimited.T[beat_idx]) / np.max(v_bandlimited.T[beat_idx]) ) RI_bandlimited.append(RI_bandlimited_temp) + PI_bandlimited_temp = ( + np.max(v_bandlimited.T[beat_idx]) - np.min(v_bandlimited.T[beat_idx]) + ) / np.mean(v_bandlimited.T[beat_idx]) + PI_bandlimited.append(PI_bandlimited_temp) for beat_idx in range(len(v_bandlimited_max[0])): RI_raw_temp = 1 - (np.min(v_raw.T[beat_idx]) / np.max(v_raw.T[beat_idx])) RI_raw.append(RI_raw_temp) + PI_raw_temp = ( + np.max(v_raw.T[beat_idx]) - np.min(v_raw.T[beat_idx]) + ) / np.mean(v_raw.T[beat_idx]) + PI_raw.append(PI_raw_temp) metrics.update( { "global/tau_M1_raw": with_attrs(np.asarray(tau_M1_raw), {"unit": [""]}), @@ -614,6 +622,8 @@ def run(self, h5file) -> ProcessResult: ), "global/RI_bandlimited": np.asarray(RI_bandlimited), "global/RI_raw": np.asarray(RI_raw), + "global/PI_raw": np.asarray(PI_raw), + "global/PI_bandlimited": np.asarray(PI_bandlimited), "global/R_VTI_bandlimited": np.asarray(R_VTI_bandlimited), "global/R_VTI_raw": np.asarray(R_VTI_raw), "global/ratio_systole_diastole_R_VTI": np.asarray( From 829b0c405c80de5470e2d347a307e3d71e65fc8e Mon Sep 17 00:00:00 2001 From: Michael Atlan Date: Wed, 18 Feb 2026 00:55:20 +0100 Subject: [PATCH 69/71] Refactor waveform shape metrics for segments and globals --- ...egments_velocity_waveform_shape_metrics.py | 975 ++++++++---------- 1 file changed, 411 insertions(+), 564 deletions(-) diff --git a/src/pipelines/Segments_velocity_waveform_shape_metrics.py b/src/pipelines/Segments_velocity_waveform_shape_metrics.py index 2c72a38..fe7f88e 100644 --- a/src/pipelines/Segments_velocity_waveform_shape_metrics.py +++ b/src/pipelines/Segments_velocity_waveform_shape_metrics.py @@ -2,55 +2,97 @@ from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs - @registerPipeline(name="waveform_shape_metrics") class ArterialSegExample(ProcessPipeline): """ Waveform-shape metrics on per-beat, per-branch, per-radius velocity waveforms. - Expected v_block layout: - v_block[:, beat_idx, branch_idx, radius_idx] - i.e. v_block shape: (n_t, n_beats, n_branches, n_radii) + Expected segment layout: + v_seg[t, beat, branch, radius] + i.e. v_seg shape: (n_t, n_beats, n_branches, n_radii) Outputs ------- - A) Per-segment (flattened branch×radius): - *_segment : shape (n_beats, n_segments) - where n_segments = n_branches * n_radii and - seg_idx = branch_idx * n_radii + radius_idx (branch-major) + A) Per-segment (flattened branch x radius): + by_segment/*_segment : shape (n_beats, n_segments) + n_segments = n_branches * n_radii + seg_idx = branch_idx * n_radii + radius_idx (branch-major) B) Aggregated: - *_branch : shape (n_beats, n_branches) (median over radii) - *_global : shape (n_beats,) (mean over all branches & radii) - - Metric definitions - ------------------ - - Rectification: v <- max(v, 0) (NaNs preserved) - - tau_M1: first moment time / zeroth moment on rectified waveform - tau_M1 = M1/M0, M0 = sum(v), M1 = sum(v * t_k), t_k = k * (Tbeat/n_t) - - tau_M1_over_T: (tau_M1 / Tbeat) - - RI (robust): RI = 1 - vmin/vmax with guards for vmax<=0 or all-NaN - - R_VTI_*: kept dataset name for compatibility, but uses PAPER convention: - RVTI = D1 / (D2 + eps) - D1 = sum(v[0:k]), D2 = sum(v[k:n_t]), k = ceil(n_t * ratio), ratio=0.5 + by_segment/*_branch : shape (n_beats, n_branches) (median over radii) + by_segment/*_global : shape (n_beats,) (mean over all branches & radii) + + C) Independent global metrics (from global waveform path): + global/* : shape (n_beats,) + + Definitions (gain-invariant / shape metrics) + -------------------------------------------- + Rectification: + v <- max(v, 0) with NaNs preserved + + Basic: + tau_M1 = M1 / M0 + tau_M1_over_T = (M1/M0) / T + + RI = 1 - vmin/vmax (robust) + PI = (vmax - vmin) / mean(v) (robust) + + RVTI (paper) = D1 / (D2 + eps), split at 1/2 T (ratio_rvti = 0.5) + + New: + SF (systolic fraction) = D1_1/3 / (D1_1/3 + D2_2/3 + eps) + where D1_1/3 is integral over first 1/3 of samples, D2_2/3 over remaining 2/3 + + Normalized central moments (shape, not scale): + mu2_norm = mu2 / (M0 * T^2 + eps) (variance-like) + mu3_norm = mu3 / (M0 * T^3 + eps) (skewness-like) + + with central moments around t_bar = tau_M1: + mu2 = sum(v * (t - t_bar)^2) + mu3 = sum(v * (t - t_bar)^3) + + Quantile timing (on cumulative integral): + C(t) = cumsum(v) / sum(v) + t10_over_T, t50_over_T, t90_over_T + + Spectral shape ratios (per beat): + Compute FFT power P(f) of v(t). Define harmonic index h = f * T (cycles/beat). + E_total = sum_{h>=0} P + E_low = sum_{h in [1..H_LOW]} P + E_high = sum_{h in [H_HIGH1..H_HIGH2]} P + Return E_low_over_E_total and E_high_over_E_total + + Default bands: + low: 1..3 harmonics + high: 4..8 harmonics """ - description = ( - "Segment waveform shape metrics (tau, RI, RVTI) + branch/global aggregates." - ) + description = "Waveform shape metrics (segment + aggregates + global), gain-invariant and robust." + + # Segment inputs + v_raw_segment_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + v_band_segment_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" + # Global inputs v_raw_global_input = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" - v_bandlimited_global_input = ( - "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" - ) - v_bandlimited_global_max_input = ( - "/Artery/VelocityPerBeat/VmaxPerBeatBandLimited/value" - ) - v_bandlimited_global_min_input = ( - "/Artery/VelocityPerBeat/VminPerBeatBandLimited/value" - ) + v_band_global_input = "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" + + # Beat period T_input = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + # Parameters + eps = 1e-12 + ratio_rvti = 0.5 # split for RVTI + ratio_sf = 1.0 / 3.0 # split for SF + + # Spectral bands (harmonic indices, inclusive) + H_LOW_MAX = 3 + H_HIGH_MIN = 4 + H_HIGH_MAX = 8 + + # ------------------------- + # Helpers + # ------------------------- @staticmethod def _rectify_keep_nan(x: np.ndarray) -> np.ndarray: x = np.asarray(x, dtype=float) @@ -69,566 +111,371 @@ def _safe_nanmedian(x: np.ndarray) -> float: return float(np.nanmedian(x)) @staticmethod - def _metrics_from_waveform( - v: np.ndarray, - Tbeat: float, - ratio: float = 0.5, - eps: float = 1e-12, - ): - v = ArterialSegExample._rectify_keep_nan(v) + def _ensure_time_by_beat(v2: np.ndarray, n_beats: int) -> np.ndarray: + """ + Ensure v2 is shaped (n_t, n_beats). If it is (n_beats, n_t), transpose. + """ + v2 = np.asarray(v2, dtype=float) + if v2.ndim != 2: + raise ValueError(f"Expected 2D global waveform, got shape {v2.shape}") + + if v2.shape[1] == n_beats: + return v2 + if v2.shape[0] == n_beats and v2.shape[1] != n_beats: + return v2.T + + # Fallback: if ambiguous, assume (n_t, n_beats) + return v2 + + def _quantile_time_over_T(self, v: np.ndarray, Tbeat: float, q: float) -> float: + """ + v: rectified 1D waveform (NaNs allowed) + Returns t_q / Tbeat where C(t_q) >= q, with C = cumsum(v)/sum(v). + """ + if (not np.isfinite(Tbeat)) or Tbeat <= 0: + return np.nan + + if v.size == 0 or not np.any(np.isfinite(v)): + return np.nan + + vv = np.where(np.isfinite(v), v, 0.0) + m0 = float(np.sum(vv)) + if m0 <= 0: + return np.nan + + c = np.cumsum(vv) / m0 + idx = int(np.searchsorted(c, q, side="left")) + idx = max(0, min(v.size - 1, idx)) + + dt = Tbeat / v.size + t_q = idx * dt + return float(t_q / Tbeat) + + def _spectral_ratios(self, v: np.ndarray, Tbeat: float) -> tuple[float, float]: + """ + Return (E_low/E_total, E_high/E_total) using harmonic-index bands. + """ + if (not np.isfinite(Tbeat)) or Tbeat <= 0: + return np.nan, np.nan + + if v.size == 0 or not np.any(np.isfinite(v)): + return np.nan, np.nan + + vv = np.where(np.isfinite(v), v, 0.0) + + n = vv.size + if n < 2: + return np.nan, np.nan + + # Remove DC? For "shape" we typically keep DC in total energy but exclude it from low/high + # Here: total includes all bins (including DC). Low/high exclude DC by construction (harmonics >= 1). + fs = n / Tbeat # Hz + X = np.fft.rfft(vv) + P = (np.abs(X) ** 2) + + f = np.fft.rfftfreq(n, d=1.0 / fs) # Hz + h = f * Tbeat # cycles per beat (harmonic index, continuous) + E_total = float(np.sum(P)) + if not np.isfinite(E_total) or E_total <= 0: + return np.nan, np.nan + + low_mask = (h >= 1.0) & (h <= float(self.H_LOW_MAX)) + high_mask = (h >= float(self.H_HIGH_MIN)) & (h <= float(self.H_HIGH_MAX)) + + E_low = float(np.sum(P[low_mask])) + E_high = float(np.sum(P[high_mask])) + + return float(E_low / E_total), float(E_high / E_total) + + def _compute_metrics_1d(self, v: np.ndarray, Tbeat: float) -> dict: + """ + Canonical metric kernel: compute all waveform-shape metrics from a single 1D waveform v(t). + Returns a dict of scalar metrics (floats). + """ + v = self._rectify_keep_nan(v) n = int(v.size) if n <= 0: - return 0.0, 0.0, 0.0, 0.0 + return {k: np.nan for k in self._metric_keys()} - # tau_M1 and tau_M1/T (PER WAVEFORM ONLY) + # If Tbeat invalid, many metrics become NaN if (not np.isfinite(Tbeat)) or Tbeat <= 0: - tau_M1 = np.nan - tau_M1_over_T = np.nan - else: - m0 = np.nansum(v) - if (not np.isfinite(m0)) or m0 <= 0: - tau_M1 = np.nan - tau_M1_over_T = np.nan - else: - dt = Tbeat / n - t = np.arange(n, dtype=float) * dt - m1 = np.nansum(v * t) - tau_M1 = (m1 / m0) if np.isfinite(m1) else np.nan - tau_M1_over_T = tau_M1 / Tbeat - - # RI robust - if not np.any(np.isfinite(v)): + return {k: np.nan for k in self._metric_keys()} + + vv = np.where(np.isfinite(v), v, 0.0) + m0 = float(np.sum(vv)) + if m0 <= 0: + return {k: np.nan for k in self._metric_keys()} + + dt = Tbeat / n + t = np.arange(n, dtype=float) * dt + + # First moment + m1 = float(np.sum(vv * t)) + tau_M1 = m1 / m0 + tau_M1_over_T = tau_M1 / Tbeat + + # RI / PI robust + vmax = float(np.max(vv)) + vmin = float(np.min(vv)) + meanv = float(np.mean(vv)) + + if vmax <= 0: RI = np.nan PI = np.nan else: - vmax = np.nanmax(v) - mean = np.nanmean(v) - if (not np.isfinite(vmax)) or vmax <= 0: - RI = np.nan + RI = 1.0 - (vmin / vmax) + RI = float(np.clip(RI, 0.0, 1.0)) if np.isfinite(RI) else np.nan + + if (not np.isfinite(meanv)) or meanv <= 0: PI = np.nan else: - vmin = np.nanmin(v) - RI = 1.0 - (vmin / vmax) - PI = (vmax - vmin) / mean - if not np.isfinite(RI): - RI = np.nan - PI = np.nan - else: - RI = float(np.clip(RI, 0.0, 1.0)) - PI = float(PI) - # RVTI (paper): D1/(D2+eps) - k = int(np.ceil(n * ratio)) - k = max(0, min(n, k)) - D1 = np.nansum(v[:k]) if k > 0 else np.nan - D2 = np.nansum(v[k:]) if k < n else np.nan - if not np.isfinite(D1) or D1 == 0.0: - D1 = np.nan - if not np.isfinite(D2) or D2 == 0.0: - D2 = np.nan - RVTI = float(D1 / (D2 + eps)) - - return float(tau_M1), float(tau_M1_over_T), float(RI), RVTI, float(PI) - - def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): + PI = (vmax - vmin) / meanv + PI = float(PI) if np.isfinite(PI) else np.nan + + # RVTI (paper, split 1/2) + k_rvti = int(np.ceil(n * self.ratio_rvti)) + k_rvti = max(0, min(n, k_rvti)) + D1_rvti = float(np.sum(vv[:k_rvti])) if k_rvti > 0 else 0.0 + D2_rvti = float(np.sum(vv[k_rvti:])) if k_rvti < n else 0.0 + RVTI = D1_rvti / (D2_rvti + self.eps) + + # SF (split 1/3 vs 2/3) + k_sf = int(np.ceil(n * self.ratio_sf)) + k_sf = max(0, min(n, k_sf)) + D1_sf = float(np.sum(vv[:k_sf])) if k_sf > 0 else 0.0 + D2_sf = float(np.sum(vv[k_sf:])) if k_sf < n else 0.0 + SF = D1_sf / (D1_sf + D2_sf + self.eps) + + # Central moments around tau_M1 (t_bar) + # mu2 = sum(v*(t-tau)^2), mu3 = sum(v*(t-tau)^3) + dtau = t - tau_M1 + mu2 = float(np.sum(vv * (dtau ** 2))) + mu3 = float(np.sum(vv * (dtau ** 3))) + + mu2_norm = mu2 / (m0 * (Tbeat ** 2) + self.eps) + mu3_norm = mu3 / (m0 * (Tbeat ** 3) + self.eps) + + # Quantile timing features (on cumulative integral) + t10_over_T = self._quantile_time_over_T(vv, Tbeat, 0.10) + t50_over_T = self._quantile_time_over_T(vv, Tbeat, 0.50) + t90_over_T = self._quantile_time_over_T(vv, Tbeat, 0.90) + + # Spectral ratios + E_low_over_E_total, E_high_over_E_total = self._spectral_ratios(vv, Tbeat) + + return { + "tau_M1": float(tau_M1), + "tau_M1_over_T": float(tau_M1_over_T), + "RI": float(RI) if np.isfinite(RI) else np.nan, + "PI": float(PI) if np.isfinite(PI) else np.nan, + "R_VTI": float(RVTI), + "SF": float(SF), + "mu2_norm": float(mu2_norm), + "mu3_norm": float(mu3_norm), + "t10_over_T": float(t10_over_T), + "t50_over_T": float(t50_over_T), + "t90_over_T": float(t90_over_T), + "E_low_over_E_total": float(E_low_over_E_total), + "E_high_over_E_total": float(E_high_over_E_total), + } + + @staticmethod + def _metric_keys() -> list[str]: + return [ + "tau_M1", + "tau_M1_over_T", + "RI", + "PI", + "R_VTI", + "SF", + "mu2_norm", + "mu3_norm", + "t10_over_T", + "t50_over_T", + "t90_over_T", + "E_low_over_E_total", + "E_high_over_E_total", + ] + + def _compute_block_segment(self, v_block: np.ndarray, T: np.ndarray): + """ + v_block: (n_t, n_beats, n_branches, n_radii) + Returns: + per-segment arrays: (n_beats, n_segments) + per-branch arrays: (n_beats, n_branches) (median over radii) + global arrays: (n_beats,) (mean over all branches & radii) + """ if v_block.ndim != 4: - raise ValueError( - f"Expected (n_t,n_beats,n_branches,n_radii), got {v_block.shape}" - ) + raise ValueError(f"Expected (n_t,n_beats,n_branches,n_radii), got {v_block.shape}") n_t, n_beats, n_branches, n_radii = v_block.shape n_segments = n_branches * n_radii - # Per-segment flattened (beat, segment) - tau_seg = np.zeros((n_beats, n_segments), dtype=float) - tauT_seg = np.zeros((n_beats, n_segments), dtype=float) - RI_seg = np.zeros((n_beats, n_segments), dtype=float) - PI_seg = np.zeros((n_beats, n_segments), dtype=float) - RVTI_seg = np.zeros((n_beats, n_segments), dtype=float) - - # Aggregated - tau_branch = np.zeros((n_beats, n_branches), dtype=float) - tauT_branch = np.zeros((n_beats, n_branches), dtype=float) - RI_branch = np.zeros((n_beats, n_branches), dtype=float) - PI_branch = np.zeros((n_beats, n_branches), dtype=float) - RVTI_branch = np.zeros((n_beats, n_branches), dtype=float) - - tau_global = np.zeros((n_beats,), dtype=float) - tauT_global = np.zeros((n_beats,), dtype=float) - RI_global = np.zeros((n_beats,), dtype=float) - PI_global = np.zeros((n_beats,), dtype=float) - RVTI_global = np.zeros((n_beats,), dtype=float) + # Allocate per metric + seg = {k: np.full((n_beats, n_segments), np.nan, dtype=float) for k in self._metric_keys()} + br = {k: np.full((n_beats, n_branches), np.nan, dtype=float) for k in self._metric_keys()} + gl = {k: np.full((n_beats,), np.nan, dtype=float) for k in self._metric_keys()} for beat_idx in range(n_beats): Tbeat = float(T[0][beat_idx]) - # Global accumulators for this beat - tau_vals = [] - tauT_vals = [] - RI_vals = [] - PI_vals = [] - RVTI_vals = [] + # For global aggregate at this beat + gl_vals = {k: [] for k in self._metric_keys()} for branch_idx in range(n_branches): - # Branch accumulators across radii - tau_b = [] - tauT_b = [] - RI_b = [] - PI_b = [] - RVTI_b = [] + # For branch aggregate over radii + br_vals = {k: [] for k in self._metric_keys()} for radius_idx in range(n_radii): v = v_block[:, beat_idx, branch_idx, radius_idx] - tM1, tM1T, ri, rvti, pi = self._metrics_from_waveform( - v=v, Tbeat=Tbeat, ratio=ratio, eps=1e-12 - ) + m = self._compute_metrics_1d(v, Tbeat) seg_idx = branch_idx * n_radii + radius_idx - tau_seg[beat_idx, seg_idx] = tM1 - tauT_seg[beat_idx, seg_idx] = tM1T - RI_seg[beat_idx, seg_idx] = ri - RVTI_seg[beat_idx, seg_idx] = rvti - PI_seg[beat_idx, seg_idx] = pi - - tau_b.append(tM1) - tauT_b.append(tM1T) - RI_b.append(ri) - RVTI_b.append(rvti) - PI_b.append(pi) - - tau_vals.append(tM1) - tauT_vals.append(tM1T) - RI_vals.append(ri) - RVTI_vals.append(rvti) - PI_vals.append(pi) - - # Branch aggregates: MEDIAN over radii - tau_branch[beat_idx, branch_idx] = self._safe_nanmedian( - np.asarray(tau_b) - ) - tauT_branch[beat_idx, branch_idx] = self._safe_nanmedian( - np.asarray(tauT_b) - ) - RI_branch[beat_idx, branch_idx] = self._safe_nanmedian(np.asarray(RI_b)) - PI_branch[beat_idx, branch_idx] = self._safe_nanmedian(np.asarray(PI_b)) - RVTI_branch[beat_idx, branch_idx] = self._safe_nanmedian( - np.asarray(RVTI_b) - ) - - # Global aggregates: MEAN over all branches & radii - tau_global[beat_idx] = self._safe_nanmean(np.asarray(tau_vals)) - tauT_global[beat_idx] = self._safe_nanmean(np.asarray(tauT_vals)) - RI_global[beat_idx] = self._safe_nanmean(np.asarray(RI_vals)) - RVTI_global[beat_idx] = self._safe_nanmean(np.asarray(RVTI_vals)) - PI_global[beat_idx] = self._safe_nanmean(np.asarray(PI_vals)) - - return ( - tau_seg, - tauT_seg, - RI_seg, - PI_seg, - RVTI_seg, - tau_branch, - tauT_branch, - RI_branch, - PI_branch, - RVTI_branch, - tau_global, - tauT_global, - RI_global, - PI_global, - RVTI_global, - n_branches, - n_radii, - ) + for k in self._metric_keys(): + seg[k][beat_idx, seg_idx] = m[k] + br_vals[k].append(m[k]) + gl_vals[k].append(m[k]) + + # Branch aggregates: median over radii (nanmedian) + for k in self._metric_keys(): + br[k][beat_idx, branch_idx] = self._safe_nanmedian(np.asarray(br_vals[k], dtype=float)) + # Global aggregates: mean over all branches & radii (nanmean) + for k in self._metric_keys(): + gl[k][beat_idx] = self._safe_nanmean(np.asarray(gl_vals[k], dtype=float)) + + seg_order_note = "seg_idx = branch_idx * n_radii + radius_idx (branch-major flattening)" + return seg, br, gl, n_branches, n_radii, seg_order_note + + def _compute_block_global(self, v_global: np.ndarray, T: np.ndarray): + """ + v_global: (n_t, n_beats) after _ensure_time_by_beat + Returns dict of arrays each shaped (n_beats,) + """ + n_beats = int(T.shape[1]) + v_global = self._ensure_time_by_beat(v_global, n_beats) + v_global = self._rectify_keep_nan(v_global) + + out = {k: np.full((n_beats,), np.nan, dtype=float) for k in self._metric_keys()} + + for beat_idx in range(n_beats): + Tbeat = float(T[0][beat_idx]) + v = v_global[:, beat_idx] + m = self._compute_metrics_1d(v, Tbeat) + for k in self._metric_keys(): + out[k][beat_idx] = m[k] + + return out + + # ------------------------- + # Pipeline entrypoint + # ------------------------- def run(self, h5file) -> ProcessResult: T = np.asarray(h5file[self.T_input]) - ratio_systole_diastole_R_VTI = 0.5 - - try: - v_raw_input = ( - "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" - ) - v_bandlimited_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" - - v_raw = np.asarray(h5file[v_raw_input]) - v_band = np.asarray(h5file[v_bandlimited_input]) - v_raw = self._rectify_keep_nan(v_raw) - v_band = self._rectify_keep_nan(v_band) - - ( - tau_seg_b, - tauT_seg_b, - RI_seg_b, - PI_seg_b, - RVTI_seg_b, - tau_br_b, - tauT_br_b, - RI_br_b, - PI_br_b, - RVTI_br_b, - tau_gl_b, - tauT_gl_b, - RI_gl_b, - PI_gl_b, - RVTI_gl_b, - n_branches_b, - n_radii_b, - ) = self._compute_block(v_band, T, ratio_systole_diastole_R_VTI) - - ( - tau_seg_r, - tauT_seg_r, - RI_seg_r, - PI_seg_r, - RVTI_seg_r, - tau_br_r, - tauT_br_r, - RI_br_r, - PI_br_r, - RVTI_br_r, - tau_gl_r, - tauT_gl_r, - RI_gl_r, - PI_gl_r, - RVTI_gl_r, - n_branches_r, - n_radii_r, - ) = self._compute_block(v_raw, T, ratio_systole_diastole_R_VTI) - - # Consistency attributes (optional but useful) - seg_order_note = ( - "seg_idx = branch_idx * n_radii + radius_idx (branch-major flattening)" - ) - if n_radii_b != n_radii_r or n_branches_b != n_branches_r: - seg_order_note += ( - " | WARNING: raw/bandlimited branch/radius dims differ." - ) - - metrics = { - # --- Existing datasets (unchanged names/shapes) --- - "by_segment/tau_M1_bandlimited_segment": with_attrs( - tau_seg_b, - { - "unit": ["s"], - "definition": ["tau_M1 = M1/M0 on rectified waveform"], - "segment_indexing": [seg_order_note], - }, - ), - "by_segment/tau_M1_over_T_bandlimited_segment": with_attrs( - tauT_seg_b, - { - "unit": [""], - "definition": ["tau_M1_over_T = (M1/M0)/T"], - "segment_indexing": [seg_order_note], - }, - ), - "by_segment/RI_bandlimited_segment": with_attrs( - RI_seg_b, - { - "unit": [""], - "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], - "segment_indexing": [seg_order_note], - }, - ), - "by_segment/PI_bandlimited_segment": with_attrs( - PI_seg_b, - { - "unit": [""], - "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], - "segment_indexing": [seg_order_note], - }, - ), - "by_segment/R_VTI_bandlimited_segment": with_attrs( - RVTI_seg_b, - { - "unit": [""], - "definition": ["paper RVTI = D1/(D2+eps)"], - "segment_indexing": [seg_order_note], - }, - ), - "by_segment/tau_M1_raw_segment": with_attrs( - tau_seg_r, - { - "unit": ["s"], - "definition": ["tau_M1 = M1/M0 on rectified waveform"], - "segment_indexing": [seg_order_note], - }, - ), - "by_segment/tau_M1_over_T_raw_segment": with_attrs( - tauT_seg_r, - { - "unit": [""], - "definition": ["tau_M1_over_T = (M1/M0)/T"], - "segment_indexing": [seg_order_note], - }, - ), - "by_segment/RI_raw_segment": with_attrs( - RI_seg_r, - { - "unit": [""], - "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], - "segment_indexing": [seg_order_note], - }, - ), - "by_segment/PI_raw_segment": with_attrs( - PI_seg_r, - { - "unit": [""], - "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], - "segment_indexing": [seg_order_note], - }, - ), - "by_segment/R_VTI_raw_segment": with_attrs( - RVTI_seg_r, - { - "unit": [""], - "definition": ["paper RVTI = D1/(D2+eps)"], - "segment_indexing": [seg_order_note], - }, - ), - "by_segment/ratio_systole_diastole_R_VTI": np.asarray( - ratio_systole_diastole_R_VTI, dtype=float - ), - # --- New aggregated outputs --- - "by_segment/tau_M1_bandlimited_branch": with_attrs( - tau_br_b, - { - "unit": ["s"], - "definition": ["median over radii: tau_M1 per branch"], - }, - ), - "by_segment/tau_M1_over_T_bandlimited_branch": with_attrs( - tauT_br_b, - { - "unit": [""], - "definition": ["median over radii: tau_M1/T per branch"], - }, - ), - "by_segment/RI_bandlimited_branch": with_attrs( - RI_br_b, - {"unit": [""], "definition": ["median over radii: RI per branch"]}, - ), - "by_segment/PI_bandlimited_branch": with_attrs( - PI_br_b, - {"unit": [""], "definition": ["median over radii: RI per branch"]}, - ), - "by_segment/R_VTI_bandlimited_branch": with_attrs( - RVTI_br_b, - { - "unit": [""], - "definition": ["median over radii: paper RVTI per branch"], - }, - ), - "by_segment/tau_M1_bandlimited_global": with_attrs( - tau_gl_b, - { - "unit": ["s"], - "definition": ["mean over branches & radii: tau_M1 global"], - }, - ), - "by_segment/tau_M1_over_T_bandlimited_global": with_attrs( - tauT_gl_b, - { - "unit": [""], - "definition": ["mean over branches & radii: tau_M1/T global"], - }, - ), - "by_segment/RI_bandlimited_global": with_attrs( - RI_gl_b, - { - "unit": [""], - "definition": ["mean over branches & radii: RI global"], - }, - ), - "by_segment/PI_bandlimited_global": with_attrs( - PI_gl_b, - { - "unit": [""], - "definition": ["mean over branches & radii: RI global"], - }, - ), - "by_segment/R_VTI_bandlimited_global": with_attrs( - RVTI_gl_b, - { - "unit": [""], - "definition": ["mean over branches & radii: paper RVTI global"], - }, - ), - "by_segment/tau_M1_raw_branch": with_attrs( - tau_br_r, - { - "unit": ["s"], - "definition": ["median over radii: tau_M1 per branch"], - }, - ), - "by_segment/tau_M1_over_T_raw_branch": with_attrs( - tauT_br_r, - { - "unit": [""], - "definition": ["median over radii: tau_M1/T per branch"], - }, - ), - "by_segment/RI_raw_branch": with_attrs( - RI_br_r, - {"unit": [""], "definition": ["median over radii: RI per branch"]}, - ), - "by_segment/PI_raw_branch": with_attrs( - PI_br_r, - {"unit": [""], "definition": ["median over radii: RI per branch"]}, - ), - "by_segment/R_VTI_raw_branch": with_attrs( - RVTI_br_r, - { - "unit": [""], - "definition": ["median over radii: paper RVTI per branch"], - }, - ), - "by_segment/tau_M1_raw_global": with_attrs( - tau_gl_r, - { - "unit": ["s"], - "definition": ["mean over branches & radii: tau_M1 global"], - }, - ), - "by_segment/tau_M1_over_T_raw_global": with_attrs( - tauT_gl_r, - { - "unit": [""], - "definition": ["mean over branches & radii: tau_M1/T global"], - }, - ), - "by_segment/RI_raw_global": with_attrs( - RI_gl_r, - { - "unit": [""], - "definition": ["mean over branches & radii: RI global"], - }, - ), - "by_segment/PI_raw_global": with_attrs( - PI_gl_r, - { - "unit": [""], - "definition": ["mean over branches & radii: RI global"], - }, - ), - "by_segment/R_VTI_raw_global": with_attrs( - RVTI_gl_r, - { - "unit": [""], - "definition": ["mean over branches & radii: paper RVTI global"], - }, - ), - } - - except Exception: # noqa: BLE001 - metrics = {} - v_raw = np.asarray(h5file[self.v_raw_global_input]) - v_raw = np.maximum(v_raw, 0) - v_bandlimited = np.asarray(h5file[self.v_bandlimited_global_input]) - v_bandlimited = np.maximum(v_bandlimited, 0) - v_bandlimited_max = np.asarray(h5file[self.v_bandlimited_global_max_input]) - v_bandlimited_max = np.maximum(v_bandlimited_max, 0) - v_bandlimited_min = np.asarray(h5file[self.v_bandlimited_global_min_input]) - v_bandlimited_min = np.maximum(v_bandlimited_min, 0) - tau_M1_raw = [] - tau_M1_over_T_raw = [] - tau_M1_bandlimited = [] - tau_M1_over_T_bandlimited = [] - - R_VTI_bandlimited = [] - R_VTI_raw = [] - - RI_bandlimited = [] - RI_raw = [] - PI_bandlimited = [] - PI_raw = [] - - ratio_systole_diastole_R_VTI = 0.5 - - for beat_idx in range(len(T[0])): - t = T[0][beat_idx] / len(v_raw.T[beat_idx]) - D1_raw = np.sum( - v_raw.T[beat_idx][ - : int(np.ceil(len(v_raw.T[0]) * ratio_systole_diastole_R_VTI)) - ] - ) - D2_raw = np.sum( - v_raw.T[beat_idx][ - int(np.ceil(len(v_raw.T[0]) * ratio_systole_diastole_R_VTI)) : - ] + metrics = {} + + # ------------------------- + # Segment metrics (raw + bandlimited) + # ------------------------- + have_seg = (self.v_raw_segment_input in h5file) and (self.v_band_segment_input in h5file) + if have_seg: + v_raw_seg = np.asarray(h5file[self.v_raw_segment_input]) + v_band_seg = np.asarray(h5file[self.v_band_segment_input]) + + seg_b, br_b, gl_b, nb_b, nr_b, seg_note_b = self._compute_block_segment(v_band_seg, T) + seg_r, br_r, gl_r, nb_r, nr_r, seg_note_r = self._compute_block_segment(v_raw_seg, T) + + seg_note = seg_note_b + if (nb_b != nb_r) or (nr_b != nr_r): + seg_note = seg_note_b + " | WARNING: raw/band branch/radius dims differ." + + # Helper to pack dict-of-arrays into HDF5 metric keys + def pack(prefix: str, d: dict, attrs_common: dict): + for k, arr in d.items(): + metrics[f"{prefix}/{k}"] = with_attrs(arr, attrs_common) + + # Per-segment outputs (compat dataset names) + pack( + "by_segment/bandlimited_segment", + { + "tau_M1": seg_b["tau_M1"], + "tau_M1_over_T": seg_b["tau_M1_over_T"], + "RI": seg_b["RI"], + "PI": seg_b["PI"], + "R_VTI": seg_b["R_VTI"], + "SF": seg_b["SF"], + "mu2_norm": seg_b["mu2_norm"], + "mu3_norm": seg_b["mu3_norm"], + "t10_over_T": seg_b["t10_over_T"], + "t50_over_T": seg_b["t50_over_T"], + "t90_over_T": seg_b["t90_over_T"], + "E_low_over_E_total": seg_b["E_low_over_E_total"], + "E_high_over_E_total": seg_b["E_high_over_E_total"], + }, + { + "segment_indexing": [seg_note], + }, ) - D1_bandlimited = np.sum( - v_bandlimited.T[beat_idx][ - : int( - np.ceil(len(v_bandlimited.T[0]) * ratio_systole_diastole_R_VTI) - ) - ] + pack( + "by_segment/raw_segment", + { + "tau_M1": seg_r["tau_M1"], + "tau_M1_over_T": seg_r["tau_M1_over_T"], + "RI": seg_r["RI"], + "PI": seg_r["PI"], + "R_VTI": seg_r["R_VTI"], + "SF": seg_r["SF"], + "mu2_norm": seg_r["mu2_norm"], + "mu3_norm": seg_r["mu3_norm"], + "t10_over_T": seg_r["t10_over_T"], + "t50_over_T": seg_r["t50_over_T"], + "t90_over_T": seg_r["t90_over_T"], + "E_low_over_E_total": seg_r["E_low_over_E_total"], + "E_high_over_E_total": seg_r["E_high_over_E_total"], + }, + { + "segment_indexing": [seg_note], + }, ) - D2_bandlimited = np.sum( - v_bandlimited.T[beat_idx][ - int( - np.ceil(len(v_bandlimited.T[0]) * ratio_systole_diastole_R_VTI) - ) : - ] - ) - R_VTI_bandlimited.append(D1_bandlimited / (D2_bandlimited + 10 ** (-12))) - R_VTI_raw.append(D1_raw / (D2_raw + 10 ** (-12))) - M_0 = np.sum(v_raw.T[beat_idx]) - M_1 = 0 - for time_idx in range(len(v_raw.T[beat_idx])): - M_1 += v_raw[time_idx][beat_idx] * time_idx * t - TM1 = M_1 / M_0 - tau_M1_raw.append(TM1) - tau_M1_over_T_raw.append(TM1 / T[0][beat_idx]) - - for beat_idx in range(len(T[0])): - t = T[0][beat_idx] / len(v_raw.T[beat_idx]) - M_0 = np.sum(v_bandlimited.T[beat_idx]) - M_1 = 0 - for time_idx in range(len(v_raw.T[beat_idx])): - M_1 += v_bandlimited[time_idx][beat_idx] * time_idx * t - TM1 = M_1 / M_0 - tau_M1_bandlimited.append(TM1) - tau_M1_over_T_bandlimited.append(TM1 / T[0][beat_idx]) - - for beat_idx in range(len(v_bandlimited_max[0])): - RI_bandlimited_temp = 1 - ( - np.min(v_bandlimited.T[beat_idx]) / np.max(v_bandlimited.T[beat_idx]) - ) - RI_bandlimited.append(RI_bandlimited_temp) - PI_bandlimited_temp = ( - np.max(v_bandlimited.T[beat_idx]) - np.min(v_bandlimited.T[beat_idx]) - ) / np.mean(v_bandlimited.T[beat_idx]) - PI_bandlimited.append(PI_bandlimited_temp) - - for beat_idx in range(len(v_bandlimited_max[0])): - RI_raw_temp = 1 - (np.min(v_raw.T[beat_idx]) / np.max(v_raw.T[beat_idx])) - RI_raw.append(RI_raw_temp) - PI_raw_temp = ( - np.max(v_raw.T[beat_idx]) - np.min(v_raw.T[beat_idx]) - ) / np.mean(v_raw.T[beat_idx]) - PI_raw.append(PI_raw_temp) - metrics.update( - { - "global/tau_M1_raw": with_attrs(np.asarray(tau_M1_raw), {"unit": [""]}), - "global/tau_M1_bandlimited": np.asarray(tau_M1_bandlimited), - "global/tau_M1_over_T_raw": with_attrs( - np.asarray(tau_M1_over_T_raw), {"unit": [""]} - ), - "global/tau_M1_over_T_bandlimited": np.asarray( - tau_M1_over_T_bandlimited - ), - "global/RI_bandlimited": np.asarray(RI_bandlimited), - "global/RI_raw": np.asarray(RI_raw), - "global/PI_raw": np.asarray(PI_raw), - "global/PI_bandlimited": np.asarray(PI_bandlimited), - "global/R_VTI_bandlimited": np.asarray(R_VTI_bandlimited), - "global/R_VTI_raw": np.asarray(R_VTI_raw), - "global/ratio_systole_diastole_R_VTI": np.asarray( - ratio_systole_diastole_R_VTI - ), - } - ) + + # Branch aggregates (median over radii) + pack("by_segment/bandlimited_branch", br_b, {"definition": ["median over radii per branch"]}) + pack("by_segment/raw_branch", br_r, {"definition": ["median over radii per branch"]}) + + # Global aggregates (mean over all branches & radii) + pack("by_segment/bandlimited_global", gl_b, {"definition": ["mean over branches and radii"]}) + pack("by_segment/raw_global", gl_r, {"definition": ["mean over branches and radii"]}) + + # Store parameters used (for provenance) + metrics["by_segment/params/ratio_rvti"] = np.asarray(self.ratio_rvti, dtype=float) + metrics["by_segment/params/ratio_sf"] = np.asarray(self.ratio_sf, dtype=float) + metrics["by_segment/params/eps"] = np.asarray(self.eps, dtype=float) + metrics["by_segment/params/H_LOW_MAX"] = np.asarray(self.H_LOW_MAX, dtype=int) + metrics["by_segment/params/H_HIGH_MIN"] = np.asarray(self.H_HIGH_MIN, dtype=int) + metrics["by_segment/params/H_HIGH_MAX"] = np.asarray(self.H_HIGH_MAX, dtype=int) + + # ------------------------- + # Independent global metrics (raw + bandlimited) + # ------------------------- + have_glob = (self.v_raw_global_input in h5file) and (self.v_band_global_input in h5file) + if have_glob: + v_raw_gl = np.asarray(h5file[self.v_raw_global_input]) + v_band_gl = np.asarray(h5file[self.v_band_global_input]) + + out_raw = self._compute_block_global(v_raw_gl, T) + out_band = self._compute_block_global(v_band_gl, T) + + for k in self._metric_keys(): + metrics[f"global/raw/{k}"] = out_raw[k] + metrics[f"global/bandlimited/{k}"] = out_band[k] + + # provenance + metrics["global/params/ratio_rvti"] = np.asarray(self.ratio_rvti, dtype=float) + metrics["global/params/ratio_sf"] = np.asarray(self.ratio_sf, dtype=float) + metrics["global/params/eps"] = np.asarray(self.eps, dtype=float) + metrics["global/params/H_LOW_MAX"] = np.asarray(self.H_LOW_MAX, dtype=int) + metrics["global/params/H_HIGH_MIN"] = np.asarray(self.H_HIGH_MIN, dtype=int) + metrics["global/params/H_HIGH_MAX"] = np.asarray(self.H_HIGH_MAX, dtype=int) + return ProcessResult(metrics=metrics) From 80d1f90a82e2ccbb72d3edebf84c76e0a9ed597f Mon Sep 17 00:00:00 2001 From: gregoire Date: Wed, 18 Feb 2026 15:41:22 +0100 Subject: [PATCH 70/71] clean version of metrics computation globally and by segment --- ...rterial_velocity_waveform_shape_metrics.py | 123 ---- .../arterial_waveform_shape_metrics.py | 569 ++++++++++++++++++ 2 files changed, 569 insertions(+), 123 deletions(-) delete mode 100644 src/pipelines/arterial_velocity_waveform_shape_metrics.py create mode 100644 src/pipelines/arterial_waveform_shape_metrics.py diff --git a/src/pipelines/arterial_velocity_waveform_shape_metrics.py b/src/pipelines/arterial_velocity_waveform_shape_metrics.py deleted file mode 100644 index 030241e..0000000 --- a/src/pipelines/arterial_velocity_waveform_shape_metrics.py +++ /dev/null @@ -1,123 +0,0 @@ -import numpy as np - -from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs - - -@registerPipeline(name="arterial_waveform_shape_metrics") -class ArterialExample(ProcessPipeline): - """ - Tutorial pipeline showing the full surface area of a pipeline: - - - Subclass ProcessPipeline and implement `run(self, h5file) -> ProcessResult`. - - Return metrics (scalars, vectors, matrices, cubes) and optional artifacts. - - Attach HDF5 attributes to any metric via `with_attrs(data, attrs_dict)`. - - Add attributes to the pipeline group (`attrs`) or root file (`file_attrs`). - - No input data is required; this pipeline is purely illustrative. - """ - - description = "Tutorial: metrics + artifacts + dataset attrs + file/pipeline attrs." - v_raw_input = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" - v_bandlimited_input = ( - "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" - ) - T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" - v_bandlimited_max_input = "/Artery/VelocityPerBeat/VmaxPerBeatBandLimited/value" - v_bandlimited_min_input = "/Artery/VelocityPerBeat/VminPerBeatBandLimited/value" - - def run(self, h5file) -> ProcessResult: - v_raw = np.asarray(h5file[self.v_raw_input]) - v_raw = np.maximum(v_raw, 0) - v_bandlimited = np.asarray(h5file[self.v_bandlimited_input]) - v_bandlimited = np.maximum(v_bandlimited, 0) - T_ds = np.asarray(h5file[self.T]) - v_bandlimited_max = np.asarray(h5file[self.v_bandlimited_max_input]) - v_bandlimited_max = np.maximum(v_bandlimited_max, 0) - v_bandlimited_min = np.asarray(h5file[self.v_bandlimited_min_input]) - v_bandlimited_min = np.maximum(v_bandlimited_min, 0) - tau_M1_raw = [] - tau_M1_over_T_raw = [] - tau_M1_bandlimited = [] - tau_M1_over_T_bandlimited = [] - - R_VTI_bandlimited = [] - R_VTI_raw = [] - - RI_bandlimited = [] - RI_raw = [] - - ratio_systole_diastole_R_VTI = 0.5 - - for beat_idx in range(len(T_ds[0])): - t = T_ds[0][beat_idx] / len(v_raw.T[beat_idx]) - D1_raw = np.sum( - v_raw.T[beat_idx][ - : int(np.ceil(len(v_raw.T[0]) * ratio_systole_diastole_R_VTI)) - ] - ) - D2_raw = np.sum( - v_raw.T[beat_idx][ - int(np.ceil(len(v_raw.T[0]) * ratio_systole_diastole_R_VTI)) : - ] - ) - D1_bandlimited = np.sum( - v_bandlimited.T[beat_idx][ - : int( - np.ceil(len(v_bandlimited.T[0]) * ratio_systole_diastole_R_VTI) - ) - ] - ) - D2_bandlimited = np.sum( - v_bandlimited.T[beat_idx][ - int( - np.ceil(len(v_bandlimited.T[0]) * ratio_systole_diastole_R_VTI) - ) : - ] - ) - R_VTI_bandlimited.append(D1_bandlimited / (D2_bandlimited + 10 ** (-12))) - R_VTI_raw.append(D1_raw / (D2_raw + 10 ** (-12))) - M_0 = np.sum(v_raw.T[beat_idx]) - M_1 = 0 - for time_idx in range(len(v_raw.T[beat_idx])): - M_1 += v_raw[time_idx][beat_idx] * time_idx * t - TM1 = M_1 / M_0 - tau_M1_raw.append(TM1) - tau_M1_over_T_raw.append(TM1 / T_ds[0][beat_idx]) - - for beat_idx in range(len(T_ds[0])): - t = T_ds[0][beat_idx] / len(v_raw.T[beat_idx]) - M_0 = np.sum(v_bandlimited.T[beat_idx]) - M_1 = 0 - for time_idx in range(len(v_raw.T[beat_idx])): - M_1 += v_bandlimited[time_idx][beat_idx] * time_idx * t - TM1 = M_1 / M_0 - tau_M1_bandlimited.append(TM1) - tau_M1_over_T_bandlimited.append(TM1 / T_ds[0][beat_idx]) - - for beat_idx in range(len(v_bandlimited_max[0])): - RI_bandlimited_temp = 1 - ( - v_bandlimited_min[0][beat_idx] / v_bandlimited_max[0][beat_idx] - ) - RI_bandlimited.append(RI_bandlimited_temp) - - for beat_idx in range(len(v_bandlimited_max[0])): - RI_raw_temp = 1 - (np.min(v_raw.T[beat_idx]) / np.max(v_raw.T[beat_idx])) - RI_raw.append(RI_raw_temp) - - # Metrics are the main numerical outputs; each key becomes a dataset under /pipelines//metrics. - metrics = { - "tau_M1_raw": with_attrs(np.asarray(tau_M1_raw), {"unit": [""]}), - "tau_M1_bandlimited": np.asarray(tau_M1_bandlimited), - "tau_M1_over_T_raw": with_attrs( - np.asarray(tau_M1_over_T_raw), {"unit": [""]} - ), - "tau_M1_over_T_bandlimited": np.asarray(tau_M1_over_T_bandlimited), - "RI_bandlimited": np.asarray(RI_bandlimited), - "RI_raw": np.asarray(RI_raw), - "R_VTI_bandlimited": np.asarray(R_VTI_bandlimited), - "R_VTI_raw": np.asarray(R_VTI_raw), - "ratio_systole_diastole_R_VTI": np.asarray(ratio_systole_diastole_R_VTI), - } - - # Artifacts can store non-metric outputs (strings, paths, etc.). - - return ProcessResult(metrics=metrics) diff --git a/src/pipelines/arterial_waveform_shape_metrics.py b/src/pipelines/arterial_waveform_shape_metrics.py new file mode 100644 index 0000000..7400834 --- /dev/null +++ b/src/pipelines/arterial_waveform_shape_metrics.py @@ -0,0 +1,569 @@ +import numpy as np + +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs + + +@registerPipeline(name="arterial_waveform_shape_metrics") +class ArterialSegExample(ProcessPipeline): + """ + Waveform-shape metrics on per-beat, per-branch, per-radius velocity waveforms. + + Expected segment layout: + v_seg[t, beat, branch, radius] + i.e. v_seg shape: (n_t, n_beats, n_branches, n_radii) + + Outputs + ------- + A) Per-segment (flattened branch x radius): + by_segment/*_segment : shape (n_beats, n_segments) + n_segments = n_branches * n_radii + seg_idx = branch_idx * n_radii + radius_idx (branch-major) + + B) Aggregated: + by_segment/*_branch : shape (n_beats, n_branches) (median over radii) + by_segment/*_global : shape (n_beats,) (mean over all branches & radii) + + C) Independent global metrics (from global waveform path): + global/* : shape (n_beats,) + + Definitions (gain-invariant / shape metrics) + -------------------------------------------- + Rectification: + v <- max(v, 0) with NaNs preserved + + Basic: + tau_M1 = M1 / M0 + tau_M1_over_T = (M1/M0) / T + + RI = 1 - vmin/vmax (robust) + PI = (vmax - vmin) / mean(v) (robust) + + RVTI (paper) = D1 / (D2 + eps), split at 1/2 T (ratio_rvti = 0.5) + + New: + SF_VTI (systolic fraction) = D1_1/3 / (D1_1/3 + D2_2/3 + eps) + where D1_1/3 is integral over first 1/3 of samples, D2_2/3 over remaining 2/3 + + Normalized central moments (shape, not scale): + mu2_norm = mu2 / (M0 * T^2 + eps) (variance-like) + + + with central moments around t_bar = tau_M1: + mu2 = sum(v * (t - t_bar)^2) + + + Quantile timing (on cumulative integral): + C(t) = cumsum(v) / sum(v) + t10_over_T,t25_over_T, t50_over_T,t75_over_T, t90_over_T + + Spectral shape ratios (per beat): + Compute FFT power P(f) of v(t). Define harmonic index h = f * T (cycles/beat). + E_total = sum_{h>=0} P + E_low = sum_{h in [1..H_LOW]} P + E_high = sum_{h in [H_HIGH1..H_HIGH2]} P + Return E_low_over_E_total and E_high_over_E_total + + Default bands: + low: 1..3 harmonics + high: 4..8 harmonics + """ + + description = "Waveform shape metrics (segment + aggregates + global), gain-invariant and robust." + + # Segment inputs + v_raw_segment_input = ( + "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + ) + v_band_segment_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" + + # Global inputs + v_raw_global_input = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" + v_band_global_input = ( + "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" + ) + + # Beat period + T_input = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + + # Parameters + eps = 1e-12 + ratio_rvti = 0.5 # split for RVTI + ratio_sf_vti = 1.0 / 3.0 # split for SF_VTI + + # Spectral bands (harmonic indices, inclusive) + H_LOW_MAX = 3 + H_HIGH_MIN = 4 + H_HIGH_MAX = 8 + + # ------------------------- + # Helpers + # ------------------------- + @staticmethod + def _rectify_keep_nan(x: np.ndarray) -> np.ndarray: + x = np.asarray(x, dtype=float) + return np.where(np.isfinite(x), np.maximum(x, 0.0), np.nan) + + @staticmethod + def _safe_nanmean(x: np.ndarray) -> float: + if x.size == 0 or not np.any(np.isfinite(x)): + return np.nan + return float(np.nanmean(x)) + + @staticmethod + def _safe_nanmedian(x: np.ndarray) -> float: + if x.size == 0 or not np.any(np.isfinite(x)): + return np.nan + return float(np.nanmedian(x)) + + @staticmethod + def _ensure_time_by_beat(v2: np.ndarray, n_beats: int) -> np.ndarray: + """ + Ensure v2 is shaped (n_t, n_beats). If it is (n_beats, n_t), transpose. + """ + v2 = np.asarray(v2, dtype=float) + if v2.ndim != 2: + raise ValueError(f"Expected 2D global waveform, got shape {v2.shape}") + + if v2.shape[1] == n_beats: + return v2 + if v2.shape[0] == n_beats and v2.shape[1] != n_beats: + return v2.T + + # Fallback: if ambiguous, assume (n_t, n_beats) + return v2 + + def _quantile_time_over_T(self, v: np.ndarray, Tbeat: float, q: float) -> float: + """ + v: rectified 1D waveform (NaNs allowed) + Returns t_q / Tbeat where C(t_q) >= q, with C = cumsum(v)/sum(v). + """ + if (not np.isfinite(Tbeat)) or Tbeat <= 0: + return np.nan + + if v.size == 0 or not np.any(np.isfinite(v)): + return np.nan + + vv = np.where(np.isfinite(v), v, 0.0) + m0 = float(np.sum(vv)) + if m0 <= 0: + return np.nan + + c = np.cumsum(vv) / m0 + idx = int(np.searchsorted(c, q, side="left")) + idx = max(0, min(v.size - 1, idx)) + + dt = Tbeat / v.size + t_q = idx * dt + return float(t_q / Tbeat) + + def _spectral_ratios(self, v: np.ndarray, Tbeat: float) -> tuple[float, float]: + """ + Return (E_low/E_total, E_high/E_total) using harmonic-index bands. + """ + if (not np.isfinite(Tbeat)) or Tbeat <= 0: + return np.nan, np.nan + + if v.size == 0 or not np.any(np.isfinite(v)): + return np.nan, np.nan + + vv = np.where(np.isfinite(v), v, 0.0) + + n = vv.size + if n < 2: + return np.nan, np.nan + + # Remove DC? For "shape" we typically keep DC in total energy but exclude it from low/high + # Here: total includes all bins (including DC). Low/high exclude DC by construction (harmonics >= 1). + fs = n / Tbeat # Hz + X = np.fft.rfft(vv) + P = np.abs(X) ** 2 + A = np.abs(X) + f = np.fft.rfftfreq(n, d=1.0 / fs) # Hz + h = f * Tbeat # cycles per beat (harmonic index, continuous) + # vv_no_mean = vv - np.mean(vv) + # idx_fund = np.argmax(A[1:]) + 1 + # f1 = f[idx_fund] + # V1 = A[idx_fund] + # if V1 <= 0: + # return np.nan, np.nan, np.nan + # HRI_2_10 = float(np.nan) + """for k in range(2, 11): + target_freq = k * f1 + + # trouver le bin le plus proche + idx = np.argmin(np.abs(f - target_freq)) + + # éviter de sortir du spectre + if idx < len(A): + HRI_2_10 += A[idx] / V1""" + E_total = float(np.sum(P)) + if not np.isfinite(E_total) or E_total <= 0: + return np.nan, np.nan, np.nan + + low_mask = (h >= 1.0) & (h <= float(self.H_LOW_MAX)) + high_mask = (h >= float(self.H_HIGH_MIN)) & (h <= float(self.H_HIGH_MAX)) + + E_low = float(np.sum(P[low_mask])) + E_high = float(np.sum(P[high_mask])) + + return float(E_low / E_total), float(E_high / E_total) + + def _compute_metrics_1d(self, v: np.ndarray, Tbeat: float) -> dict: + """ + Canonical metric kernel: compute all waveform-shape metrics from a single 1D waveform v(t). + Returns a dict of scalar metrics (floats). + """ + v = self._rectify_keep_nan(v) + n = int(v.size) + if n <= 0: + return {k: np.nan for k in self._metric_keys()} + + # If Tbeat invalid, many metrics become NaN + if (not np.isfinite(Tbeat)) or Tbeat <= 0: + return {k: np.nan for k in self._metric_keys()} + + vv = np.where( + np.isfinite(v), v, np.nan + ) # vv = np.where(np.isfinite(v), v, 0.0) + m0 = float(np.nansum(vv)) + if m0 <= 0: + return {k: np.nan for k in self._metric_keys()} + + dt = Tbeat / n + t = np.arange(n, dtype=float) * dt + + # First moment + m1 = float(np.nansum(vv * t)) + tau_M1 = m1 / m0 + tau_M1_over_T = tau_M1 / Tbeat + + # RI / PI robust + vmax = float(np.nanmax(vv)) + vmin = float(np.nanmin(v)) # vmin = float(np.min(vv)) + meanv = float(self._safe_nanmean(v)) # meanv = float(np.mean(vv)) + + if vmax <= 0: + RI = np.nan + PI = np.nan + else: + RI = 1.0 - (vmin / vmax) + RI = float(np.clip(RI, 0.0, 1.0)) if np.isfinite(RI) else np.nan + + if (not np.isfinite(meanv)) or meanv <= 0: + PI = np.nan + else: + PI = (vmax - vmin) / meanv + PI = float(PI) if np.isfinite(PI) else np.nan + + # RVTI (paper, split 1/2) + k_rvti = int(np.ceil(n * self.ratio_rvti)) + k_rvti = max(0, min(n, k_rvti)) + D1_rvti = float(np.sum(vv[:k_rvti])) if k_rvti > 0 else np.nan + D2_rvti = float(np.sum(vv[k_rvti:])) if k_rvti < n else np.nan + RVTI = D1_rvti / (D2_rvti + self.eps) + + # SF_VTI (split 1/3 vs 2/3) + k_sf = int(np.ceil(n * self.ratio_sf_vti)) + k_sf = max(0, min(n, k_sf)) + D1_sf = float(np.nansum(vv[:k_sf])) if k_sf > 0 else np.nan + D2_sf = float(np.nansum(vv[k_sf:])) if k_sf < n else np.nan + SF_VTI = D1_sf / (D1_sf + D2_sf + self.eps) + + # Central moments around tau_M1 (t_bar) + # mu2 = sum(v*(t-tau)^2) + dtau = t - tau_M1 + mu2 = float(np.nansum(vv * (dtau**2))) + tau_M2 = np.sqrt(mu2 / m0 + self.eps) + tau_M2_over_T = tau_M2 / Tbeat + + # Quantile timing features (on cumulative integral) + t10_over_T = self._quantile_time_over_T(vv, Tbeat, 0.10) + t25_over_T = self._quantile_time_over_T(vv, Tbeat, 0.25) + t50_over_T = self._quantile_time_over_T(vv, Tbeat, 0.50) + t75_over_T = self._quantile_time_over_T(vv, Tbeat, 0.75) + t90_over_T = self._quantile_time_over_T(vv, Tbeat, 0.90) + + # Spectral ratios + E_low_over_E_total, E_high_over_E_total = self._spectral_ratios(vv, Tbeat) + + return { + "tau_M1": float(tau_M1), + "tau_M1_over_T": float(tau_M1_over_T), + "RI": float(RI) if np.isfinite(RI) else np.nan, + "PI": float(PI) if np.isfinite(PI) else np.nan, + "R_VTI": float(RVTI), + "SF_VTI": float(SF_VTI), + "tau_M2_over_T": float(tau_M2_over_T), + "tau_M2": float(tau_M2), + "t10_over_T": float(t10_over_T), + "t25_over_T": float(t25_over_T), + "t50_over_T": float(t50_over_T), + "t75_over_T": float(t75_over_T), + "t90_over_T": float(t90_over_T), + "E_low_over_E_total": float(E_low_over_E_total), + "E_high_over_E_total": float(E_high_over_E_total), + # "HRI_2_10_total": float(HRI_2_10_total), + } + + @staticmethod + def _metric_keys() -> list[str]: + return [ + "tau_M1", + "tau_M1_over_T", + "RI", + "PI", + "R_VTI", + "SF_VTI", + "tau_M2_over_T", + "tau_M2", + "t10_over_T", + "t25_over_T", + "t50_over_T", + "t75_over_T", + "t90_over_T", + "E_low_over_E_total", + "E_high_over_E_total", + # "HRI_2_10_total", + ] + + def _compute_block_segment(self, v_block: np.ndarray, T: np.ndarray): + """ + v_block: (n_t, n_beats, n_branches, n_radii) + Returns: + per-segment arrays: (n_beats, n_segments) + per-branch arrays: (n_beats, n_branches) (median over radii) + global arrays: (n_beats,) (mean over all branches & radii) + """ + if v_block.ndim != 4: + raise ValueError( + f"Expected (n_t,n_beats,n_branches,n_radii), got {v_block.shape}" + ) + + n_t, n_beats, n_branches, n_radii = v_block.shape + n_segments = n_branches * n_radii + + # Allocate per metric + seg = { + k: np.full((n_beats, n_segments), np.nan, dtype=float) + for k in self._metric_keys() + } + br = { + k: np.full((n_beats, n_branches), np.nan, dtype=float) + for k in self._metric_keys() + } + gl = {k: np.full((n_beats,), np.nan, dtype=float) for k in self._metric_keys()} + + for beat_idx in range(n_beats): + Tbeat = float(T[0][beat_idx]) + + # For global aggregate at this beat + gl_vals = {k: [] for k in self._metric_keys()} + + for branch_idx in range(n_branches): + # For branch aggregate over radii + br_vals = {k: [] for k in self._metric_keys()} + + for radius_idx in range(n_radii): + v = v_block[:, beat_idx, branch_idx, radius_idx] + m = self._compute_metrics_1d(v, Tbeat) + + seg_idx = branch_idx * n_radii + radius_idx + for k in self._metric_keys(): + seg[k][beat_idx, seg_idx] = m[k] + br_vals[k].append(m[k]) + gl_vals[k].append(m[k]) + + # Branch aggregates: median over radii (nanmedian) + for k in self._metric_keys(): + br[k][beat_idx, branch_idx] = self._safe_nanmedian( + np.asarray(br_vals[k], dtype=float) + ) + + # Global aggregates: mean over all branches & radii (nanmean) + for k in self._metric_keys(): + gl[k][beat_idx] = self._safe_nanmean( + np.asarray(gl_vals[k], dtype=float) + ) + + seg_order_note = ( + "seg_idx = branch_idx * n_radii + radius_idx (branch-major flattening)" + ) + return seg, br, gl, n_branches, n_radii, seg_order_note + + def _compute_block_global(self, v_global: np.ndarray, T: np.ndarray): + """ + v_global: (n_t, n_beats) after _ensure_time_by_beat + Returns dict of arrays each shaped (n_beats,) + """ + n_beats = int(T.shape[1]) + v_global = self._ensure_time_by_beat(v_global, n_beats) + v_global = self._rectify_keep_nan(v_global) + + out = {k: np.full((n_beats,), np.nan, dtype=float) for k in self._metric_keys()} + + for beat_idx in range(n_beats): + Tbeat = float(T[0][beat_idx]) + v = v_global[:, beat_idx] + m = self._compute_metrics_1d(v, Tbeat) + for k in self._metric_keys(): + out[k][beat_idx] = m[k] + + return out + + # ------------------------- + # Pipeline entrypoint + # ------------------------- + def run(self, h5file) -> ProcessResult: + T = np.asarray(h5file[self.T_input]) + metrics = {} + + # ------------------------- + # Segment metrics (raw + bandlimited) + # ------------------------- + have_seg = (self.v_raw_segment_input in h5file) and ( + self.v_band_segment_input in h5file + ) + if have_seg: + v_raw_seg = np.asarray(h5file[self.v_raw_segment_input]) + v_band_seg = np.asarray(h5file[self.v_band_segment_input]) + + seg_b, br_b, gl_b, nb_b, nr_b, seg_note_b = self._compute_block_segment( + v_band_seg, T + ) + seg_r, br_r, gl_r, nb_r, nr_r, seg_note_r = self._compute_block_segment( + v_raw_seg, T + ) + + seg_note = seg_note_b + if (nb_b != nb_r) or (nr_b != nr_r): + seg_note = ( + seg_note_b + " | WARNING: raw/band branch/radius dims differ." + ) + + # Helper to pack dict-of-arrays into HDF5 metric keys + def pack(prefix: str, d: dict, attrs_common: dict): + for k, arr in d.items(): + metrics[f"{prefix}/{k}"] = with_attrs(arr, attrs_common) + + # Per-segment outputs (compat dataset names) + pack( + "by_segment/bandlimited_segment", + { + "tau_M1": seg_b["tau_M1"], + "tau_M1_over_T": seg_b["tau_M1_over_T"], + "RI": seg_b["RI"], + "PI": seg_b["PI"], + "R_VTI": seg_b["R_VTI"], + "SF_VTI": seg_b["SF_VTI"], + "tau_M2_over_T": seg_b["tau_M2_over_T"], + "tau_M2": seg_b["tau_M2"], + "t10_over_T": seg_b["t10_over_T"], + "t25_over_T": seg_b["t25_over_T"], + "t50_over_T": seg_b["t50_over_T"], + "t75_over_T": seg_b["t75_over_T"], + "t90_over_T": seg_b["t90_over_T"], + "E_low_over_E_total": seg_b["E_low_over_E_total"], + "E_high_over_E_total": seg_b["E_high_over_E_total"], + # "HRI_2_10_total": seg_b["HRI_2_10_total"], + }, + { + "segment_indexing": [seg_note], + }, + ) + pack( + "by_segment/raw_segment", + { + "tau_M1": seg_r["tau_M1"], + "tau_M1_over_T": seg_r["tau_M1_over_T"], + "RI": seg_r["RI"], + "PI": seg_r["PI"], + "R_VTI": seg_r["R_VTI"], + "SF_VTI": seg_r["SF_VTI"], + "tau_M2_over_T": seg_r["tau_M2_over_T"], + "tau_M2": seg_r["tau_M2"], + "t10_over_T": seg_r["t10_over_T"], + "t25_over_T": seg_r["t25_over_T"], + "t50_over_T": seg_r["t50_over_T"], + "t75_over_T": seg_r["t75_over_T"], + "t90_over_T": seg_r["t90_over_T"], + "E_low_over_E_total": seg_r["E_low_over_E_total"], + "E_high_over_E_total": seg_r["E_high_over_E_total"], + # "HRI_2_10_total": seg_r["HRI_2_10_total"], + }, + { + "segment_indexing": [seg_note], + }, + ) + + # Branch aggregates (median over radii) + pack( + "by_segment/bandlimited_branch", + br_b, + {"definition": ["median over radii per branch"]}, + ) + pack( + "by_segment/raw_branch", + br_r, + {"definition": ["median over radii per branch"]}, + ) + + # Global aggregates (mean over all branches & radii) + pack( + "by_segment/bandlimited_global", + gl_b, + {"definition": ["mean over branches and radii"]}, + ) + pack( + "by_segment/raw_global", + gl_r, + {"definition": ["mean over branches and radii"]}, + ) + + # Store parameters used (for provenance) + metrics["by_segment/params/ratio_rvti"] = np.asarray( + self.ratio_rvti, dtype=float + ) + metrics["by_segment/params/ratio_sf_vti"] = np.asarray( + self.ratio_sf_vti, dtype=float + ) + metrics["by_segment/params/eps"] = np.asarray(self.eps, dtype=float) + metrics["by_segment/params/H_LOW_MAX"] = np.asarray( + self.H_LOW_MAX, dtype=int + ) + metrics["by_segment/params/H_HIGH_MIN"] = np.asarray( + self.H_HIGH_MIN, dtype=int + ) + metrics["by_segment/params/H_HIGH_MAX"] = np.asarray( + self.H_HIGH_MAX, dtype=int + ) + + # ------------------------- + # Independent global metrics (raw + bandlimited) + # ------------------------- + have_glob = (self.v_raw_global_input in h5file) and ( + self.v_band_global_input in h5file + ) + if have_glob: + v_raw_gl = np.asarray(h5file[self.v_raw_global_input]) + v_band_gl = np.asarray(h5file[self.v_band_global_input]) + + out_raw = self._compute_block_global(v_raw_gl, T) + out_band = self._compute_block_global(v_band_gl, T) + + for k in self._metric_keys(): + metrics[f"global/raw/{k}"] = out_raw[k] + metrics[f"global/bandlimited/{k}"] = out_band[k] + + # provenance + metrics["global/params/ratio_rvti"] = np.asarray( + self.ratio_rvti, dtype=float + ) + metrics["global/params/ratio_sf_vti"] = np.asarray( + self.ratio_sf_vti, dtype=float + ) + metrics["global/params/eps"] = np.asarray(self.eps, dtype=float) + metrics["global/params/H_LOW_MAX"] = np.asarray(self.H_LOW_MAX, dtype=int) + metrics["global/params/H_HIGH_MIN"] = np.asarray(self.H_HIGH_MIN, dtype=int) + metrics["global/params/H_HIGH_MAX"] = np.asarray(self.H_HIGH_MAX, dtype=int) + + return ProcessResult(metrics=metrics) From ca7d8133a5bb4a36c08b54e6fb06b1ee32b804c3 Mon Sep 17 00:00:00 2001 From: gregoire Date: Wed, 18 Feb 2026 15:52:21 +0100 Subject: [PATCH 71/71] lint-tool --- ...egments_velocity_waveform_shape_metrics.py | 481 ------------- .../arterial_waveform_shape_metrics.py | 1 - src/pipelines/modal_analysis.py | 7 +- src/pipelines/old_seg_metrics.py | 634 ++++++++++++++++++ ...veform_shape_metrics(harmonic analysis).py | 136 ++-- src/pipelines/recreatesig.py | 9 +- .../signal_reconstruction_factor_reduced.py | 2 +- 7 files changed, 705 insertions(+), 565 deletions(-) delete mode 100644 src/pipelines/Segments_velocity_waveform_shape_metrics.py create mode 100644 src/pipelines/old_seg_metrics.py diff --git a/src/pipelines/Segments_velocity_waveform_shape_metrics.py b/src/pipelines/Segments_velocity_waveform_shape_metrics.py deleted file mode 100644 index fe7f88e..0000000 --- a/src/pipelines/Segments_velocity_waveform_shape_metrics.py +++ /dev/null @@ -1,481 +0,0 @@ -import numpy as np - -from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs - -@registerPipeline(name="waveform_shape_metrics") -class ArterialSegExample(ProcessPipeline): - """ - Waveform-shape metrics on per-beat, per-branch, per-radius velocity waveforms. - - Expected segment layout: - v_seg[t, beat, branch, radius] - i.e. v_seg shape: (n_t, n_beats, n_branches, n_radii) - - Outputs - ------- - A) Per-segment (flattened branch x radius): - by_segment/*_segment : shape (n_beats, n_segments) - n_segments = n_branches * n_radii - seg_idx = branch_idx * n_radii + radius_idx (branch-major) - - B) Aggregated: - by_segment/*_branch : shape (n_beats, n_branches) (median over radii) - by_segment/*_global : shape (n_beats,) (mean over all branches & radii) - - C) Independent global metrics (from global waveform path): - global/* : shape (n_beats,) - - Definitions (gain-invariant / shape metrics) - -------------------------------------------- - Rectification: - v <- max(v, 0) with NaNs preserved - - Basic: - tau_M1 = M1 / M0 - tau_M1_over_T = (M1/M0) / T - - RI = 1 - vmin/vmax (robust) - PI = (vmax - vmin) / mean(v) (robust) - - RVTI (paper) = D1 / (D2 + eps), split at 1/2 T (ratio_rvti = 0.5) - - New: - SF (systolic fraction) = D1_1/3 / (D1_1/3 + D2_2/3 + eps) - where D1_1/3 is integral over first 1/3 of samples, D2_2/3 over remaining 2/3 - - Normalized central moments (shape, not scale): - mu2_norm = mu2 / (M0 * T^2 + eps) (variance-like) - mu3_norm = mu3 / (M0 * T^3 + eps) (skewness-like) - - with central moments around t_bar = tau_M1: - mu2 = sum(v * (t - t_bar)^2) - mu3 = sum(v * (t - t_bar)^3) - - Quantile timing (on cumulative integral): - C(t) = cumsum(v) / sum(v) - t10_over_T, t50_over_T, t90_over_T - - Spectral shape ratios (per beat): - Compute FFT power P(f) of v(t). Define harmonic index h = f * T (cycles/beat). - E_total = sum_{h>=0} P - E_low = sum_{h in [1..H_LOW]} P - E_high = sum_{h in [H_HIGH1..H_HIGH2]} P - Return E_low_over_E_total and E_high_over_E_total - - Default bands: - low: 1..3 harmonics - high: 4..8 harmonics - """ - - description = "Waveform shape metrics (segment + aggregates + global), gain-invariant and robust." - - # Segment inputs - v_raw_segment_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" - v_band_segment_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" - - # Global inputs - v_raw_global_input = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" - v_band_global_input = "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" - - # Beat period - T_input = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" - - # Parameters - eps = 1e-12 - ratio_rvti = 0.5 # split for RVTI - ratio_sf = 1.0 / 3.0 # split for SF - - # Spectral bands (harmonic indices, inclusive) - H_LOW_MAX = 3 - H_HIGH_MIN = 4 - H_HIGH_MAX = 8 - - # ------------------------- - # Helpers - # ------------------------- - @staticmethod - def _rectify_keep_nan(x: np.ndarray) -> np.ndarray: - x = np.asarray(x, dtype=float) - return np.where(np.isfinite(x), np.maximum(x, 0.0), np.nan) - - @staticmethod - def _safe_nanmean(x: np.ndarray) -> float: - if x.size == 0 or not np.any(np.isfinite(x)): - return np.nan - return float(np.nanmean(x)) - - @staticmethod - def _safe_nanmedian(x: np.ndarray) -> float: - if x.size == 0 or not np.any(np.isfinite(x)): - return np.nan - return float(np.nanmedian(x)) - - @staticmethod - def _ensure_time_by_beat(v2: np.ndarray, n_beats: int) -> np.ndarray: - """ - Ensure v2 is shaped (n_t, n_beats). If it is (n_beats, n_t), transpose. - """ - v2 = np.asarray(v2, dtype=float) - if v2.ndim != 2: - raise ValueError(f"Expected 2D global waveform, got shape {v2.shape}") - - if v2.shape[1] == n_beats: - return v2 - if v2.shape[0] == n_beats and v2.shape[1] != n_beats: - return v2.T - - # Fallback: if ambiguous, assume (n_t, n_beats) - return v2 - - def _quantile_time_over_T(self, v: np.ndarray, Tbeat: float, q: float) -> float: - """ - v: rectified 1D waveform (NaNs allowed) - Returns t_q / Tbeat where C(t_q) >= q, with C = cumsum(v)/sum(v). - """ - if (not np.isfinite(Tbeat)) or Tbeat <= 0: - return np.nan - - if v.size == 0 or not np.any(np.isfinite(v)): - return np.nan - - vv = np.where(np.isfinite(v), v, 0.0) - m0 = float(np.sum(vv)) - if m0 <= 0: - return np.nan - - c = np.cumsum(vv) / m0 - idx = int(np.searchsorted(c, q, side="left")) - idx = max(0, min(v.size - 1, idx)) - - dt = Tbeat / v.size - t_q = idx * dt - return float(t_q / Tbeat) - - def _spectral_ratios(self, v: np.ndarray, Tbeat: float) -> tuple[float, float]: - """ - Return (E_low/E_total, E_high/E_total) using harmonic-index bands. - """ - if (not np.isfinite(Tbeat)) or Tbeat <= 0: - return np.nan, np.nan - - if v.size == 0 or not np.any(np.isfinite(v)): - return np.nan, np.nan - - vv = np.where(np.isfinite(v), v, 0.0) - - n = vv.size - if n < 2: - return np.nan, np.nan - - # Remove DC? For "shape" we typically keep DC in total energy but exclude it from low/high - # Here: total includes all bins (including DC). Low/high exclude DC by construction (harmonics >= 1). - fs = n / Tbeat # Hz - X = np.fft.rfft(vv) - P = (np.abs(X) ** 2) - - f = np.fft.rfftfreq(n, d=1.0 / fs) # Hz - h = f * Tbeat # cycles per beat (harmonic index, continuous) - - E_total = float(np.sum(P)) - if not np.isfinite(E_total) or E_total <= 0: - return np.nan, np.nan - - low_mask = (h >= 1.0) & (h <= float(self.H_LOW_MAX)) - high_mask = (h >= float(self.H_HIGH_MIN)) & (h <= float(self.H_HIGH_MAX)) - - E_low = float(np.sum(P[low_mask])) - E_high = float(np.sum(P[high_mask])) - - return float(E_low / E_total), float(E_high / E_total) - - def _compute_metrics_1d(self, v: np.ndarray, Tbeat: float) -> dict: - """ - Canonical metric kernel: compute all waveform-shape metrics from a single 1D waveform v(t). - Returns a dict of scalar metrics (floats). - """ - v = self._rectify_keep_nan(v) - n = int(v.size) - if n <= 0: - return {k: np.nan for k in self._metric_keys()} - - # If Tbeat invalid, many metrics become NaN - if (not np.isfinite(Tbeat)) or Tbeat <= 0: - return {k: np.nan for k in self._metric_keys()} - - vv = np.where(np.isfinite(v), v, 0.0) - m0 = float(np.sum(vv)) - if m0 <= 0: - return {k: np.nan for k in self._metric_keys()} - - dt = Tbeat / n - t = np.arange(n, dtype=float) * dt - - # First moment - m1 = float(np.sum(vv * t)) - tau_M1 = m1 / m0 - tau_M1_over_T = tau_M1 / Tbeat - - # RI / PI robust - vmax = float(np.max(vv)) - vmin = float(np.min(vv)) - meanv = float(np.mean(vv)) - - if vmax <= 0: - RI = np.nan - PI = np.nan - else: - RI = 1.0 - (vmin / vmax) - RI = float(np.clip(RI, 0.0, 1.0)) if np.isfinite(RI) else np.nan - - if (not np.isfinite(meanv)) or meanv <= 0: - PI = np.nan - else: - PI = (vmax - vmin) / meanv - PI = float(PI) if np.isfinite(PI) else np.nan - - # RVTI (paper, split 1/2) - k_rvti = int(np.ceil(n * self.ratio_rvti)) - k_rvti = max(0, min(n, k_rvti)) - D1_rvti = float(np.sum(vv[:k_rvti])) if k_rvti > 0 else 0.0 - D2_rvti = float(np.sum(vv[k_rvti:])) if k_rvti < n else 0.0 - RVTI = D1_rvti / (D2_rvti + self.eps) - - # SF (split 1/3 vs 2/3) - k_sf = int(np.ceil(n * self.ratio_sf)) - k_sf = max(0, min(n, k_sf)) - D1_sf = float(np.sum(vv[:k_sf])) if k_sf > 0 else 0.0 - D2_sf = float(np.sum(vv[k_sf:])) if k_sf < n else 0.0 - SF = D1_sf / (D1_sf + D2_sf + self.eps) - - # Central moments around tau_M1 (t_bar) - # mu2 = sum(v*(t-tau)^2), mu3 = sum(v*(t-tau)^3) - dtau = t - tau_M1 - mu2 = float(np.sum(vv * (dtau ** 2))) - mu3 = float(np.sum(vv * (dtau ** 3))) - - mu2_norm = mu2 / (m0 * (Tbeat ** 2) + self.eps) - mu3_norm = mu3 / (m0 * (Tbeat ** 3) + self.eps) - - # Quantile timing features (on cumulative integral) - t10_over_T = self._quantile_time_over_T(vv, Tbeat, 0.10) - t50_over_T = self._quantile_time_over_T(vv, Tbeat, 0.50) - t90_over_T = self._quantile_time_over_T(vv, Tbeat, 0.90) - - # Spectral ratios - E_low_over_E_total, E_high_over_E_total = self._spectral_ratios(vv, Tbeat) - - return { - "tau_M1": float(tau_M1), - "tau_M1_over_T": float(tau_M1_over_T), - "RI": float(RI) if np.isfinite(RI) else np.nan, - "PI": float(PI) if np.isfinite(PI) else np.nan, - "R_VTI": float(RVTI), - "SF": float(SF), - "mu2_norm": float(mu2_norm), - "mu3_norm": float(mu3_norm), - "t10_over_T": float(t10_over_T), - "t50_over_T": float(t50_over_T), - "t90_over_T": float(t90_over_T), - "E_low_over_E_total": float(E_low_over_E_total), - "E_high_over_E_total": float(E_high_over_E_total), - } - - @staticmethod - def _metric_keys() -> list[str]: - return [ - "tau_M1", - "tau_M1_over_T", - "RI", - "PI", - "R_VTI", - "SF", - "mu2_norm", - "mu3_norm", - "t10_over_T", - "t50_over_T", - "t90_over_T", - "E_low_over_E_total", - "E_high_over_E_total", - ] - - def _compute_block_segment(self, v_block: np.ndarray, T: np.ndarray): - """ - v_block: (n_t, n_beats, n_branches, n_radii) - Returns: - per-segment arrays: (n_beats, n_segments) - per-branch arrays: (n_beats, n_branches) (median over radii) - global arrays: (n_beats,) (mean over all branches & radii) - """ - if v_block.ndim != 4: - raise ValueError(f"Expected (n_t,n_beats,n_branches,n_radii), got {v_block.shape}") - - n_t, n_beats, n_branches, n_radii = v_block.shape - n_segments = n_branches * n_radii - - # Allocate per metric - seg = {k: np.full((n_beats, n_segments), np.nan, dtype=float) for k in self._metric_keys()} - br = {k: np.full((n_beats, n_branches), np.nan, dtype=float) for k in self._metric_keys()} - gl = {k: np.full((n_beats,), np.nan, dtype=float) for k in self._metric_keys()} - - for beat_idx in range(n_beats): - Tbeat = float(T[0][beat_idx]) - - # For global aggregate at this beat - gl_vals = {k: [] for k in self._metric_keys()} - - for branch_idx in range(n_branches): - # For branch aggregate over radii - br_vals = {k: [] for k in self._metric_keys()} - - for radius_idx in range(n_radii): - v = v_block[:, beat_idx, branch_idx, radius_idx] - m = self._compute_metrics_1d(v, Tbeat) - - seg_idx = branch_idx * n_radii + radius_idx - for k in self._metric_keys(): - seg[k][beat_idx, seg_idx] = m[k] - br_vals[k].append(m[k]) - gl_vals[k].append(m[k]) - - # Branch aggregates: median over radii (nanmedian) - for k in self._metric_keys(): - br[k][beat_idx, branch_idx] = self._safe_nanmedian(np.asarray(br_vals[k], dtype=float)) - - # Global aggregates: mean over all branches & radii (nanmean) - for k in self._metric_keys(): - gl[k][beat_idx] = self._safe_nanmean(np.asarray(gl_vals[k], dtype=float)) - - seg_order_note = "seg_idx = branch_idx * n_radii + radius_idx (branch-major flattening)" - return seg, br, gl, n_branches, n_radii, seg_order_note - - def _compute_block_global(self, v_global: np.ndarray, T: np.ndarray): - """ - v_global: (n_t, n_beats) after _ensure_time_by_beat - Returns dict of arrays each shaped (n_beats,) - """ - n_beats = int(T.shape[1]) - v_global = self._ensure_time_by_beat(v_global, n_beats) - v_global = self._rectify_keep_nan(v_global) - - out = {k: np.full((n_beats,), np.nan, dtype=float) for k in self._metric_keys()} - - for beat_idx in range(n_beats): - Tbeat = float(T[0][beat_idx]) - v = v_global[:, beat_idx] - m = self._compute_metrics_1d(v, Tbeat) - for k in self._metric_keys(): - out[k][beat_idx] = m[k] - - return out - - # ------------------------- - # Pipeline entrypoint - # ------------------------- - def run(self, h5file) -> ProcessResult: - T = np.asarray(h5file[self.T_input]) - metrics = {} - - # ------------------------- - # Segment metrics (raw + bandlimited) - # ------------------------- - have_seg = (self.v_raw_segment_input in h5file) and (self.v_band_segment_input in h5file) - if have_seg: - v_raw_seg = np.asarray(h5file[self.v_raw_segment_input]) - v_band_seg = np.asarray(h5file[self.v_band_segment_input]) - - seg_b, br_b, gl_b, nb_b, nr_b, seg_note_b = self._compute_block_segment(v_band_seg, T) - seg_r, br_r, gl_r, nb_r, nr_r, seg_note_r = self._compute_block_segment(v_raw_seg, T) - - seg_note = seg_note_b - if (nb_b != nb_r) or (nr_b != nr_r): - seg_note = seg_note_b + " | WARNING: raw/band branch/radius dims differ." - - # Helper to pack dict-of-arrays into HDF5 metric keys - def pack(prefix: str, d: dict, attrs_common: dict): - for k, arr in d.items(): - metrics[f"{prefix}/{k}"] = with_attrs(arr, attrs_common) - - # Per-segment outputs (compat dataset names) - pack( - "by_segment/bandlimited_segment", - { - "tau_M1": seg_b["tau_M1"], - "tau_M1_over_T": seg_b["tau_M1_over_T"], - "RI": seg_b["RI"], - "PI": seg_b["PI"], - "R_VTI": seg_b["R_VTI"], - "SF": seg_b["SF"], - "mu2_norm": seg_b["mu2_norm"], - "mu3_norm": seg_b["mu3_norm"], - "t10_over_T": seg_b["t10_over_T"], - "t50_over_T": seg_b["t50_over_T"], - "t90_over_T": seg_b["t90_over_T"], - "E_low_over_E_total": seg_b["E_low_over_E_total"], - "E_high_over_E_total": seg_b["E_high_over_E_total"], - }, - { - "segment_indexing": [seg_note], - }, - ) - pack( - "by_segment/raw_segment", - { - "tau_M1": seg_r["tau_M1"], - "tau_M1_over_T": seg_r["tau_M1_over_T"], - "RI": seg_r["RI"], - "PI": seg_r["PI"], - "R_VTI": seg_r["R_VTI"], - "SF": seg_r["SF"], - "mu2_norm": seg_r["mu2_norm"], - "mu3_norm": seg_r["mu3_norm"], - "t10_over_T": seg_r["t10_over_T"], - "t50_over_T": seg_r["t50_over_T"], - "t90_over_T": seg_r["t90_over_T"], - "E_low_over_E_total": seg_r["E_low_over_E_total"], - "E_high_over_E_total": seg_r["E_high_over_E_total"], - }, - { - "segment_indexing": [seg_note], - }, - ) - - # Branch aggregates (median over radii) - pack("by_segment/bandlimited_branch", br_b, {"definition": ["median over radii per branch"]}) - pack("by_segment/raw_branch", br_r, {"definition": ["median over radii per branch"]}) - - # Global aggregates (mean over all branches & radii) - pack("by_segment/bandlimited_global", gl_b, {"definition": ["mean over branches and radii"]}) - pack("by_segment/raw_global", gl_r, {"definition": ["mean over branches and radii"]}) - - # Store parameters used (for provenance) - metrics["by_segment/params/ratio_rvti"] = np.asarray(self.ratio_rvti, dtype=float) - metrics["by_segment/params/ratio_sf"] = np.asarray(self.ratio_sf, dtype=float) - metrics["by_segment/params/eps"] = np.asarray(self.eps, dtype=float) - metrics["by_segment/params/H_LOW_MAX"] = np.asarray(self.H_LOW_MAX, dtype=int) - metrics["by_segment/params/H_HIGH_MIN"] = np.asarray(self.H_HIGH_MIN, dtype=int) - metrics["by_segment/params/H_HIGH_MAX"] = np.asarray(self.H_HIGH_MAX, dtype=int) - - # ------------------------- - # Independent global metrics (raw + bandlimited) - # ------------------------- - have_glob = (self.v_raw_global_input in h5file) and (self.v_band_global_input in h5file) - if have_glob: - v_raw_gl = np.asarray(h5file[self.v_raw_global_input]) - v_band_gl = np.asarray(h5file[self.v_band_global_input]) - - out_raw = self._compute_block_global(v_raw_gl, T) - out_band = self._compute_block_global(v_band_gl, T) - - for k in self._metric_keys(): - metrics[f"global/raw/{k}"] = out_raw[k] - metrics[f"global/bandlimited/{k}"] = out_band[k] - - # provenance - metrics["global/params/ratio_rvti"] = np.asarray(self.ratio_rvti, dtype=float) - metrics["global/params/ratio_sf"] = np.asarray(self.ratio_sf, dtype=float) - metrics["global/params/eps"] = np.asarray(self.eps, dtype=float) - metrics["global/params/H_LOW_MAX"] = np.asarray(self.H_LOW_MAX, dtype=int) - metrics["global/params/H_HIGH_MIN"] = np.asarray(self.H_HIGH_MIN, dtype=int) - metrics["global/params/H_HIGH_MAX"] = np.asarray(self.H_HIGH_MAX, dtype=int) - - return ProcessResult(metrics=metrics) diff --git a/src/pipelines/arterial_waveform_shape_metrics.py b/src/pipelines/arterial_waveform_shape_metrics.py index 7400834..33f94f4 100644 --- a/src/pipelines/arterial_waveform_shape_metrics.py +++ b/src/pipelines/arterial_waveform_shape_metrics.py @@ -177,7 +177,6 @@ def _spectral_ratios(self, v: np.ndarray, Tbeat: float) -> tuple[float, float]: fs = n / Tbeat # Hz X = np.fft.rfft(vv) P = np.abs(X) ** 2 - A = np.abs(X) f = np.fft.rfftfreq(n, d=1.0 / fs) # Hz h = f * Tbeat # cycles per beat (harmonic index, continuous) # vv_no_mean = vv - np.mean(vv) diff --git a/src/pipelines/modal_analysis.py b/src/pipelines/modal_analysis.py index 7b15214..5ff20a9 100644 --- a/src/pipelines/modal_analysis.py +++ b/src/pipelines/modal_analysis.py @@ -1,7 +1,6 @@ import numpy as np + from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs -from scipy.linalg import eigh -from scipy.signal import savgol_filter, medfilt, find_peaks @registerPipeline(name="modal_analysis") @@ -28,7 +27,7 @@ def run(self, h5file) -> ProcessResult: moment_0 = np.asarray(h5file[self.M0_input]) moment_1 = np.asarray(h5file[self.M1_input]) moment_2 = np.asarray(h5file[self.M2_input]) - registration = np.asarray(h5file[self.registration_input]) + M0_matrix = [] M1_matrix = [] M2_matrix = [] @@ -43,7 +42,7 @@ def run(self, h5file) -> ProcessResult: for x_idx in range(x_size): for y_idx in range(y_size): M0 = moment_0[time_idx, 0, x_idx, y_idx] - M1 = moment_1[time_idx, 0, x_idx, y_idx] + M2 = moment_2[time_idx, 0, x_idx, y_idx] M0_matrix_time.append(M0) M2_over_M0_squared_time.append(np.sqrt(M2 / M0)) diff --git a/src/pipelines/old_seg_metrics.py b/src/pipelines/old_seg_metrics.py new file mode 100644 index 0000000..cee5908 --- /dev/null +++ b/src/pipelines/old_seg_metrics.py @@ -0,0 +1,634 @@ +import numpy as np + +from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs + + +@registerPipeline(name="old_waveform_shape_metrics") +class ArterialSegExample(ProcessPipeline): + """ + Waveform-shape metrics on per-beat, per-branch, per-radius velocity waveforms. + + Expected v_block layout: + v_block[:, beat_idx, branch_idx, radius_idx] + i.e. v_block shape: (n_t, n_beats, n_branches, n_radii) + + Outputs + ------- + A) Per-segment (flattened branch×radius): + *_segment : shape (n_beats, n_segments) + where n_segments = n_branches * n_radii and + seg_idx = branch_idx * n_radii + radius_idx (branch-major) + + B) Aggregated: + *_branch : shape (n_beats, n_branches) (median over radii) + *_global : shape (n_beats,) (mean over all branches & radii) + + Metric definitions + ------------------ + - Rectification: v <- max(v, 0) (NaNs preserved) + - tau_M1: first moment time / zeroth moment on rectified waveform + tau_M1 = M1/M0, M0 = sum(v), M1 = sum(v * t_k), t_k = k * (Tbeat/n_t) + - tau_M1_over_T: (tau_M1 / Tbeat) + - RI (robust): RI = 1 - vmin/vmax with guards for vmax<=0 or all-NaN + - R_VTI_*: kept dataset name for compatibility, but uses PAPER convention: + RVTI = D1 / (D2 + eps) + D1 = sum(v[0:k]), D2 = sum(v[k:n_t]), k = ceil(n_t * ratio), ratio=0.5 + """ + + description = ( + "Segment waveform shape metrics (tau, RI, RVTI) + branch/global aggregates." + ) + + v_raw_global_input = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" + v_bandlimited_global_input = ( + "/Artery/VelocityPerBeat/VelocitySignalPerBeatBandLimited/value" + ) + v_bandlimited_global_max_input = ( + "/Artery/VelocityPerBeat/VmaxPerBeatBandLimited/value" + ) + v_bandlimited_global_min_input = ( + "/Artery/VelocityPerBeat/VminPerBeatBandLimited/value" + ) + T_input = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" + + @staticmethod + def _rectify_keep_nan(x: np.ndarray) -> np.ndarray: + x = np.asarray(x, dtype=float) + return np.where(np.isfinite(x), np.maximum(x, 0.0), np.nan) + + @staticmethod + def _safe_nanmean(x: np.ndarray) -> float: + if x.size == 0 or not np.any(np.isfinite(x)): + return np.nan + return float(np.nanmean(x)) + + @staticmethod + def _safe_nanmedian(x: np.ndarray) -> float: + if x.size == 0 or not np.any(np.isfinite(x)): + return np.nan + return float(np.nanmedian(x)) + + @staticmethod + def _metrics_from_waveform( + v: np.ndarray, + Tbeat: float, + ratio: float = 0.5, + eps: float = 1e-12, + ): + v = ArterialSegExample._rectify_keep_nan(v) + + n = int(v.size) + if n <= 0: + return 0.0, 0.0, 0.0, 0.0 + + # tau_M1 and tau_M1/T (PER WAVEFORM ONLY) + if (not np.isfinite(Tbeat)) or Tbeat <= 0: + tau_M1 = np.nan + tau_M1_over_T = np.nan + else: + m0 = np.nansum(v) + if (not np.isfinite(m0)) or m0 <= 0: + tau_M1 = np.nan + tau_M1_over_T = np.nan + else: + dt = Tbeat / n + t = np.arange(n, dtype=float) * dt + m1 = np.nansum(v * t) + tau_M1 = (m1 / m0) if np.isfinite(m1) else np.nan + tau_M1_over_T = tau_M1 / Tbeat + + # RI robust + if not np.any(np.isfinite(v)): + RI = np.nan + PI = np.nan + else: + vmax = np.nanmax(v) + mean = np.nanmean(v) + if (not np.isfinite(vmax)) or vmax <= 0: + RI = np.nan + PI = np.nan + else: + vmin = np.nanmin(v) + RI = 1.0 - (vmin / vmax) + PI = (vmax - vmin) / mean + if not np.isfinite(RI): + RI = np.nan + PI = np.nan + else: + RI = float(np.clip(RI, 0.0, 1.0)) + PI = float(PI) + # RVTI (paper): D1/(D2+eps) + k = int(np.ceil(n * ratio)) + k = max(0, min(n, k)) + D1 = np.nansum(v[:k]) if k > 0 else np.nan + D2 = np.nansum(v[k:]) if k < n else np.nan + if not np.isfinite(D1) or D1 == 0.0: + D1 = np.nan + if not np.isfinite(D2) or D2 == 0.0: + D2 = np.nan + RVTI = float(D1 / (D2 + eps)) + + return float(tau_M1), float(tau_M1_over_T), float(RI), RVTI, float(PI) + + def _compute_block(self, v_block: np.ndarray, T: np.ndarray, ratio: float): + if v_block.ndim != 4: + raise ValueError( + f"Expected (n_t,n_beats,n_branches,n_radii), got {v_block.shape}" + ) + + n_t, n_beats, n_branches, n_radii = v_block.shape + n_segments = n_branches * n_radii + + # Per-segment flattened (beat, segment) + tau_seg = np.zeros((n_beats, n_segments), dtype=float) + tauT_seg = np.zeros((n_beats, n_segments), dtype=float) + RI_seg = np.zeros((n_beats, n_segments), dtype=float) + PI_seg = np.zeros((n_beats, n_segments), dtype=float) + RVTI_seg = np.zeros((n_beats, n_segments), dtype=float) + + # Aggregated + tau_branch = np.zeros((n_beats, n_branches), dtype=float) + tauT_branch = np.zeros((n_beats, n_branches), dtype=float) + RI_branch = np.zeros((n_beats, n_branches), dtype=float) + PI_branch = np.zeros((n_beats, n_branches), dtype=float) + RVTI_branch = np.zeros((n_beats, n_branches), dtype=float) + + tau_global = np.zeros((n_beats,), dtype=float) + tauT_global = np.zeros((n_beats,), dtype=float) + RI_global = np.zeros((n_beats,), dtype=float) + PI_global = np.zeros((n_beats,), dtype=float) + RVTI_global = np.zeros((n_beats,), dtype=float) + + for beat_idx in range(n_beats): + Tbeat = float(T[0][beat_idx]) + + # Global accumulators for this beat + tau_vals = [] + tauT_vals = [] + RI_vals = [] + PI_vals = [] + RVTI_vals = [] + + for branch_idx in range(n_branches): + # Branch accumulators across radii + tau_b = [] + tauT_b = [] + RI_b = [] + PI_b = [] + RVTI_b = [] + + for radius_idx in range(n_radii): + v = v_block[:, beat_idx, branch_idx, radius_idx] + tM1, tM1T, ri, rvti, pi = self._metrics_from_waveform( + v=v, Tbeat=Tbeat, ratio=ratio, eps=1e-12 + ) + + seg_idx = branch_idx * n_radii + radius_idx + tau_seg[beat_idx, seg_idx] = tM1 + tauT_seg[beat_idx, seg_idx] = tM1T + RI_seg[beat_idx, seg_idx] = ri + RVTI_seg[beat_idx, seg_idx] = rvti + PI_seg[beat_idx, seg_idx] = pi + + tau_b.append(tM1) + tauT_b.append(tM1T) + RI_b.append(ri) + RVTI_b.append(rvti) + PI_b.append(pi) + + tau_vals.append(tM1) + tauT_vals.append(tM1T) + RI_vals.append(ri) + RVTI_vals.append(rvti) + PI_vals.append(pi) + + # Branch aggregates: MEDIAN over radii + tau_branch[beat_idx, branch_idx] = self._safe_nanmedian( + np.asarray(tau_b) + ) + tauT_branch[beat_idx, branch_idx] = self._safe_nanmedian( + np.asarray(tauT_b) + ) + RI_branch[beat_idx, branch_idx] = self._safe_nanmedian(np.asarray(RI_b)) + PI_branch[beat_idx, branch_idx] = self._safe_nanmedian(np.asarray(PI_b)) + RVTI_branch[beat_idx, branch_idx] = self._safe_nanmedian( + np.asarray(RVTI_b) + ) + + # Global aggregates: MEAN over all branches & radii + tau_global[beat_idx] = self._safe_nanmean(np.asarray(tau_vals)) + tauT_global[beat_idx] = self._safe_nanmean(np.asarray(tauT_vals)) + RI_global[beat_idx] = self._safe_nanmean(np.asarray(RI_vals)) + RVTI_global[beat_idx] = self._safe_nanmean(np.asarray(RVTI_vals)) + PI_global[beat_idx] = self._safe_nanmean(np.asarray(PI_vals)) + + return ( + tau_seg, + tauT_seg, + RI_seg, + PI_seg, + RVTI_seg, + tau_branch, + tauT_branch, + RI_branch, + PI_branch, + RVTI_branch, + tau_global, + tauT_global, + RI_global, + PI_global, + RVTI_global, + n_branches, + n_radii, + ) + + def run(self, h5file) -> ProcessResult: + T = np.asarray(h5file[self.T_input]) + ratio_systole_diastole_R_VTI = 0.5 + + try: + v_raw_input = ( + "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" + ) + v_bandlimited_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" + + v_raw = np.asarray(h5file[v_raw_input]) + v_band = np.asarray(h5file[v_bandlimited_input]) + v_raw = self._rectify_keep_nan(v_raw) + v_band = self._rectify_keep_nan(v_band) + + ( + tau_seg_b, + tauT_seg_b, + RI_seg_b, + PI_seg_b, + RVTI_seg_b, + tau_br_b, + tauT_br_b, + RI_br_b, + PI_br_b, + RVTI_br_b, + tau_gl_b, + tauT_gl_b, + RI_gl_b, + PI_gl_b, + RVTI_gl_b, + n_branches_b, + n_radii_b, + ) = self._compute_block(v_band, T, ratio_systole_diastole_R_VTI) + + ( + tau_seg_r, + tauT_seg_r, + RI_seg_r, + PI_seg_r, + RVTI_seg_r, + tau_br_r, + tauT_br_r, + RI_br_r, + PI_br_r, + RVTI_br_r, + tau_gl_r, + tauT_gl_r, + RI_gl_r, + PI_gl_r, + RVTI_gl_r, + n_branches_r, + n_radii_r, + ) = self._compute_block(v_raw, T, ratio_systole_diastole_R_VTI) + + # Consistency attributes (optional but useful) + seg_order_note = ( + "seg_idx = branch_idx * n_radii + radius_idx (branch-major flattening)" + ) + if n_radii_b != n_radii_r or n_branches_b != n_branches_r: + seg_order_note += ( + " | WARNING: raw/bandlimited branch/radius dims differ." + ) + + metrics = { + # --- Existing datasets (unchanged names/shapes) --- + "by_segment/tau_M1_bandlimited_segment": with_attrs( + tau_seg_b, + { + "unit": ["s"], + "definition": ["tau_M1 = M1/M0 on rectified waveform"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/tau_M1_over_T_bandlimited_segment": with_attrs( + tauT_seg_b, + { + "unit": [""], + "definition": ["tau_M1_over_T = (M1/M0)/T"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/RI_bandlimited_segment": with_attrs( + RI_seg_b, + { + "unit": [""], + "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/PI_bandlimited_segment": with_attrs( + PI_seg_b, + { + "unit": [""], + "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/R_VTI_bandlimited_segment": with_attrs( + RVTI_seg_b, + { + "unit": [""], + "definition": ["paper RVTI = D1/(D2+eps)"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/tau_M1_raw_segment": with_attrs( + tau_seg_r, + { + "unit": ["s"], + "definition": ["tau_M1 = M1/M0 on rectified waveform"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/tau_M1_over_T_raw_segment": with_attrs( + tauT_seg_r, + { + "unit": [""], + "definition": ["tau_M1_over_T = (M1/M0)/T"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/RI_raw_segment": with_attrs( + RI_seg_r, + { + "unit": [""], + "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/PI_raw_segment": with_attrs( + PI_seg_r, + { + "unit": [""], + "definition": ["RI = 1 - vmin/vmax (robust, rectified)"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/R_VTI_raw_segment": with_attrs( + RVTI_seg_r, + { + "unit": [""], + "definition": ["paper RVTI = D1/(D2+eps)"], + "segment_indexing": [seg_order_note], + }, + ), + "by_segment/ratio_systole_diastole_R_VTI": np.asarray( + ratio_systole_diastole_R_VTI, dtype=float + ), + # --- New aggregated outputs --- + "by_segment/tau_M1_bandlimited_branch": with_attrs( + tau_br_b, + { + "unit": ["s"], + "definition": ["median over radii: tau_M1 per branch"], + }, + ), + "by_segment/tau_M1_over_T_bandlimited_branch": with_attrs( + tauT_br_b, + { + "unit": [""], + "definition": ["median over radii: tau_M1/T per branch"], + }, + ), + "by_segment/RI_bandlimited_branch": with_attrs( + RI_br_b, + {"unit": [""], "definition": ["median over radii: RI per branch"]}, + ), + "by_segment/PI_bandlimited_branch": with_attrs( + PI_br_b, + {"unit": [""], "definition": ["median over radii: RI per branch"]}, + ), + "by_segment/R_VTI_bandlimited_branch": with_attrs( + RVTI_br_b, + { + "unit": [""], + "definition": ["median over radii: paper RVTI per branch"], + }, + ), + "by_segment/tau_M1_bandlimited_global": with_attrs( + tau_gl_b, + { + "unit": ["s"], + "definition": ["mean over branches & radii: tau_M1 global"], + }, + ), + "by_segment/tau_M1_over_T_bandlimited_global": with_attrs( + tauT_gl_b, + { + "unit": [""], + "definition": ["mean over branches & radii: tau_M1/T global"], + }, + ), + "by_segment/RI_bandlimited_global": with_attrs( + RI_gl_b, + { + "unit": [""], + "definition": ["mean over branches & radii: RI global"], + }, + ), + "by_segment/PI_bandlimited_global": with_attrs( + PI_gl_b, + { + "unit": [""], + "definition": ["mean over branches & radii: RI global"], + }, + ), + "by_segment/R_VTI_bandlimited_global": with_attrs( + RVTI_gl_b, + { + "unit": [""], + "definition": ["mean over branches & radii: paper RVTI global"], + }, + ), + "by_segment/tau_M1_raw_branch": with_attrs( + tau_br_r, + { + "unit": ["s"], + "definition": ["median over radii: tau_M1 per branch"], + }, + ), + "by_segment/tau_M1_over_T_raw_branch": with_attrs( + tauT_br_r, + { + "unit": [""], + "definition": ["median over radii: tau_M1/T per branch"], + }, + ), + "by_segment/RI_raw_branch": with_attrs( + RI_br_r, + {"unit": [""], "definition": ["median over radii: RI per branch"]}, + ), + "by_segment/PI_raw_branch": with_attrs( + PI_br_r, + {"unit": [""], "definition": ["median over radii: RI per branch"]}, + ), + "by_segment/R_VTI_raw_branch": with_attrs( + RVTI_br_r, + { + "unit": [""], + "definition": ["median over radii: paper RVTI per branch"], + }, + ), + "by_segment/tau_M1_raw_global": with_attrs( + tau_gl_r, + { + "unit": ["s"], + "definition": ["mean over branches & radii: tau_M1 global"], + }, + ), + "by_segment/tau_M1_over_T_raw_global": with_attrs( + tauT_gl_r, + { + "unit": [""], + "definition": ["mean over branches & radii: tau_M1/T global"], + }, + ), + "by_segment/RI_raw_global": with_attrs( + RI_gl_r, + { + "unit": [""], + "definition": ["mean over branches & radii: RI global"], + }, + ), + "by_segment/PI_raw_global": with_attrs( + PI_gl_r, + { + "unit": [""], + "definition": ["mean over branches & radii: RI global"], + }, + ), + "by_segment/R_VTI_raw_global": with_attrs( + RVTI_gl_r, + { + "unit": [""], + "definition": ["mean over branches & radii: paper RVTI global"], + }, + ), + } + + except Exception: # noqa: BLE001 + metrics = {} + v_raw = np.asarray(h5file[self.v_raw_global_input]) + v_raw = np.maximum(v_raw, 0) + v_bandlimited = np.asarray(h5file[self.v_bandlimited_global_input]) + v_bandlimited = np.maximum(v_bandlimited, 0) + v_bandlimited_max = np.asarray(h5file[self.v_bandlimited_global_max_input]) + v_bandlimited_max = np.maximum(v_bandlimited_max, 0) + v_bandlimited_min = np.asarray(h5file[self.v_bandlimited_global_min_input]) + v_bandlimited_min = np.maximum(v_bandlimited_min, 0) + tau_M1_raw = [] + tau_M1_over_T_raw = [] + tau_M1_bandlimited = [] + tau_M1_over_T_bandlimited = [] + + R_VTI_bandlimited = [] + R_VTI_raw = [] + + RI_bandlimited = [] + RI_raw = [] + PI_bandlimited = [] + PI_raw = [] + + ratio_systole_diastole_R_VTI = 0.5 + + for beat_idx in range(len(T[0])): + t = T[0][beat_idx] / len(v_raw.T[beat_idx]) + D1_raw = np.sum( + v_raw.T[beat_idx][ + : int(np.ceil(len(v_raw.T[0]) * ratio_systole_diastole_R_VTI)) + ] + ) + D2_raw = np.sum( + v_raw.T[beat_idx][ + int(np.ceil(len(v_raw.T[0]) * ratio_systole_diastole_R_VTI)) : + ] + ) + D1_bandlimited = np.sum( + v_bandlimited.T[beat_idx][ + : int( + np.ceil(len(v_bandlimited.T[0]) * ratio_systole_diastole_R_VTI) + ) + ] + ) + D2_bandlimited = np.sum( + v_bandlimited.T[beat_idx][ + int( + np.ceil(len(v_bandlimited.T[0]) * ratio_systole_diastole_R_VTI) + ) : + ] + ) + R_VTI_bandlimited.append(D1_bandlimited / (D2_bandlimited + 10 ** (-12))) + R_VTI_raw.append(D1_raw / (D2_raw + 10 ** (-12))) + M_0 = np.sum(v_raw.T[beat_idx]) + M_1 = 0 + for time_idx in range(len(v_raw.T[beat_idx])): + M_1 += v_raw[time_idx][beat_idx] * time_idx * t + TM1 = M_1 / M_0 + tau_M1_raw.append(TM1) + tau_M1_over_T_raw.append(TM1 / T[0][beat_idx]) + + for beat_idx in range(len(T[0])): + t = T[0][beat_idx] / len(v_raw.T[beat_idx]) + M_0 = np.sum(v_bandlimited.T[beat_idx]) + M_1 = 0 + for time_idx in range(len(v_raw.T[beat_idx])): + M_1 += v_bandlimited[time_idx][beat_idx] * time_idx * t + TM1 = M_1 / M_0 + tau_M1_bandlimited.append(TM1) + tau_M1_over_T_bandlimited.append(TM1 / T[0][beat_idx]) + + for beat_idx in range(len(v_bandlimited_max[0])): + RI_bandlimited_temp = 1 - ( + np.min(v_bandlimited.T[beat_idx]) / np.max(v_bandlimited.T[beat_idx]) + ) + RI_bandlimited.append(RI_bandlimited_temp) + PI_bandlimited_temp = ( + np.max(v_bandlimited.T[beat_idx]) - np.min(v_bandlimited.T[beat_idx]) + ) / np.mean(v_bandlimited.T[beat_idx]) + PI_bandlimited.append(PI_bandlimited_temp) + + for beat_idx in range(len(v_bandlimited_max[0])): + RI_raw_temp = 1 - (np.min(v_raw.T[beat_idx]) / np.max(v_raw.T[beat_idx])) + RI_raw.append(RI_raw_temp) + PI_raw_temp = ( + np.max(v_raw.T[beat_idx]) - np.min(v_raw.T[beat_idx]) + ) / np.mean(v_raw.T[beat_idx]) + PI_raw.append(PI_raw_temp) + metrics.update( + { + "global/tau_M1_raw": with_attrs(np.asarray(tau_M1_raw), {"unit": [""]}), + "global/tau_M1_bandlimited": np.asarray(tau_M1_bandlimited), + "global/tau_M1_over_T_raw": with_attrs( + np.asarray(tau_M1_over_T_raw), {"unit": [""]} + ), + "global/tau_M1_over_T_bandlimited": np.asarray( + tau_M1_over_T_bandlimited + ), + "global/RI_bandlimited": np.asarray(RI_bandlimited), + "global/RI_raw": np.asarray(RI_raw), + "global/PI_raw": np.asarray(PI_raw), + "global/PI_bandlimited": np.asarray(PI_bandlimited), + "global/R_VTI_bandlimited": np.asarray(R_VTI_bandlimited), + "global/R_VTI_raw": np.asarray(R_VTI_raw), + "global/ratio_systole_diastole_R_VTI": np.asarray( + ratio_systole_diastole_R_VTI + ), + } + ) + return ProcessResult(metrics=metrics) diff --git a/src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py b/src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py index 91c3e2e..7eaf3fd 100644 --- a/src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py +++ b/src/pipelines/pulse_waveform_shape_metrics(harmonic analysis).py @@ -21,86 +21,82 @@ class ArterialHarmonicAnalysis(ProcessPipeline): T = "/Artery/VelocityPerBeat/beatPeriodSeconds/value" def run(self, h5file) -> ProcessResult: - v_raw = np.asarray(h5file[self.v]) + v_ds = np.asarray(h5file[self.v]) t_ds = np.asarray(h5file[self.T]) - - - - N=len(v_ds[:,0]) - nb_harmonic=10 - Xn=[] - - CV_1=[] - CV_2=[] - - Vn_tot=[] - sigma_phase_tot=[] - v_hat=[] - CF_tot=[] - D_alpha=[] - - FVTI=[] - IVTI=[] - AVTI_tot=[] - - - alpha=0.5 #that needs to be specified + + N = len(v_ds[:, 0]) + nb_harmonic = 10 + Xn = [] + + CV_1 = [] + CV_2 = [] + + Vn_tot = [] + sigma_phase_tot = [] + v_hat = [] + CF_tot = [] + D_alpha = [] + + FVTI = [] + IVTI = [] + AVTI_tot = [] + + alpha = 0.5 # that needs to be specified for k in range(len(v_ds[0])): - T=t_ds[0][k] - fft_vals=np.fft.fft(v_ds[:,k])/N - limit=nb_harmonic+1 - Vn=fft_vals[:limit] - V1=Vn[1] - Xn_k=Vn/V1 + T = t_ds[0][k] + fft_vals = np.fft.fft(v_ds[:, k]) / N + limit = nb_harmonic + 1 + Vn = fft_vals[:limit] + V1 = Vn[1] + Xn_k = Vn / V1 Xn.append(Xn_k) Vn_tot.append(Vn) - - omega0= (2 * np.pi)/T - t=np.linspace(0,T,N) - - v_hat_k=np.real(Vn[0])*np.ones_like(t) - - for i in range (1,limit): - h=2*(Vn[i]*np.exp(1j*i*omega0*t)).real - v_hat_k+=h + + omega0 = (2 * np.pi) / T + t = np.linspace(0, T, N) + + v_hat_k = np.real(Vn[0]) * np.ones_like(t) + + for i in range(1, limit): + h = 2 * (Vn[i] * np.exp(1j * i * omega0 * t)).real + v_hat_k += h v_hat.append(v_hat_k) - RMST=np.sqrt(np.mean(v_hat[k]**2)) - CF=np.max(v_hat_k)/RMST + RMST = np.sqrt(np.mean(v_hat[k] ** 2)) + CF = np.max(v_hat_k) / RMST CF_tot.append(CF) - - amp1=np.abs(np.asarray(Vn_tot)[:,1]) - amp2=np.abs(np.asarray(Vn_tot)[:,2]) - CV_1.append(np.std(amp1)/np.mean(amp1)) - CV_2.append(np.std(amp2)/np.mean(amp2)) + amp1 = np.abs(np.asarray(Vn_tot)[:, 1]) + amp2 = np.abs(np.asarray(Vn_tot)[:, 2]) + + CV_1.append(np.std(amp1) / np.mean(amp1)) + CV_2.append(np.std(amp2) / np.mean(amp2)) - phi1=np.angle(np.asarray(Xn)[:,1]) - phi2=np.angle(np.asarray(Xn)[:,2]) + phi1 = np.angle(np.asarray(Xn)[:, 1]) + phi2 = np.angle(np.asarray(Xn)[:, 2]) - diff_phase=phi2-2*phi1 - diff_phase_wrap=(diff_phase+np.pi)%(2*np.pi)-np.pi - sigma_phase=np.std(diff_phase_wrap) + diff_phase = phi2 - 2 * phi1 + diff_phase_wrap = (diff_phase + np.pi) % (2 * np.pi) - np.pi + sigma_phase = np.std(diff_phase_wrap) sigma_phase_tot.append(sigma_phase) - seuil=Vn[0]+alpha*np.std(v_hat) - condition= v_hat > seuil + seuil = Vn[0] + alpha * np.std(v_hat) + condition = v_hat > seuil D_alpha.append(np.mean(condition)) - v_base=np.min(v_hat[k]) - max=np.maximum(v_hat[k]-v_base, 0) - - dt=t[1]-t[0] + v_base = np.min(v_hat[k]) + max = np.maximum(v_hat[k] - v_base, 0) + + dt = t[1] - t[0] moitie = len(t) // 2 - d1 = np.sum(max[:moitie])*dt - d2 = np.sum(max[moitie:])*dt - AVTI=np.sum(max)*dt + d1 = np.sum(max[:moitie]) * dt + d2 = np.sum(max[moitie:]) * dt + AVTI = np.sum(max) * dt AVTI_tot.append(AVTI) - FVTI.append(d1/AVTI) - IVTI.append((d1-d2)/(d1+d2)) + FVTI.append(d1 / AVTI) + IVTI.append((d1 - d2) / (d1 + d2)) - metrics = { "Xn": with_attrs( np.asarray(Xn), @@ -132,18 +128,18 @@ def run(self, h5file) -> ProcessResult: "unit": [""], }, ), - "v_hat : Band limited waveform (definition)": with_attrs( + "v_hat : Band limited waveform (definition)": with_attrs( np.asarray(v_hat), { "unit": [""], }, - ), + ), "CF : Band limited crest factor ": with_attrs( np.asarray(CF_tot), { "unit": [""], }, - ), + ), " D_alpha : Effective Duty cycle ": with_attrs( np.asarray(D_alpha), { @@ -155,18 +151,14 @@ def run(self, h5file) -> ProcessResult: { "unit": [""], }, - ), - + ), "IVTI : VTI asymmetry index ": with_attrs( np.asarray(IVTI), { "unit": [""], }, - ), - - - + ), } # Artifacts can store non-metric outputs (strings, paths, etc.). - return ProcessResult(metrics=metrics) \ No newline at end of file + return ProcessResult(metrics=metrics) diff --git a/src/pipelines/recreatesig.py b/src/pipelines/recreatesig.py index 657780e..db13b30 100644 --- a/src/pipelines/recreatesig.py +++ b/src/pipelines/recreatesig.py @@ -1,6 +1,6 @@ import numpy as np -from .core.base import ProcessPipeline, ProcessResult, registerPipeline, with_attrs +from .core.base import ProcessPipeline, ProcessResult, registerPipeline @registerPipeline(name="reconstruct") @@ -32,10 +32,8 @@ def run(self, h5file) -> ProcessResult: V_corrected = [] V_ceil = [] - V_gauss = [] for k in range(len(v_seg[0, :, 0, 0])): - VIT_Time = 0 Vit_br = [] for br in range(len(v_seg[0, k, :, 0])): v_branch = np.nanmean(v_seg[:, k, br, :], axis=1) @@ -80,7 +78,7 @@ def run(self, h5file) -> ProcessResult: V_corrected.append(np.nanmean(Vit)) - '''vraw_ds = np.asarray(v_threshold_beat_segment) + """vraw_ds = np.asarray(v_threshold_beat_segment) vraw_ds_temp = vraw_ds.transpose(1, 0, 2, 3) vraw_ds = np.maximum(vraw_ds_temp, 0) v_ds = vraw_ds @@ -138,10 +136,9 @@ def run(self, h5file) -> ProcessResult: RI_seg.append(RI_branch) RI_seg_band.append(RI_branch_band) RTVI_seg.append(RTVI_branch) - RTVI_seg_band.append(RTVI_band_branch)'''' + RTVI_seg_band.append(RTVI_band_branch)""" for k in range(len(v_seg[0, :, 0, 0])): Vit = [] - Vit_gauss = [] for br in range(len(v_seg[0, k, :, 0])): Vit_br = [] for seg in range(len(v_seg[0, k, br, :])): diff --git a/src/pipelines/signal_reconstruction_factor_reduced.py b/src/pipelines/signal_reconstruction_factor_reduced.py index b1ade9a..0a19b84 100644 --- a/src/pipelines/signal_reconstruction_factor_reduced.py +++ b/src/pipelines/signal_reconstruction_factor_reduced.py @@ -357,7 +357,7 @@ def run(self, h5file) -> ProcessResult: Tau_M1_over_T_raw_branch_cropped = [] RI_raw_branch_cropped = [] R_VTI_raw_branch_cropped = [] - for radius_idx in range(len(v_raw[threshold_idx, 0, 0, :])): + for _radius_idx in range(len(v_raw[threshold_idx, 0, 0, :])): v_raw_average = np.nanmean( v_raw[threshold_idx, :, branch_idx, :], axis=1 )