atriakit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atriakit/__init__.py +65 -0
- atriakit/configs/annotations_loader_config.py +24 -0
- atriakit/configs/feature_computation_config.py +45 -0
- atriakit/configs/pipeline_config.py +32 -0
- atriakit/configs/segment_config.py +11 -0
- atriakit/configs/signal_preprocessor_config.py +48 -0
- atriakit/constants.py +18 -0
- atriakit/datasets.py +286 -0
- atriakit/feature_calculator.py +1403 -0
- atriakit/features/__init__.py +0 -0
- atriakit/features/amplitude.py +19 -0
- atriakit/features/area.py +104 -0
- atriakit/features/complexity.py +51 -0
- atriakit/features/entropy.py +51 -0
- atriakit/features/fragments.py +117 -0
- atriakit/features/morphology.py +189 -0
- atriakit/features/vcg.py +81 -0
- atriakit/io/__init__.py +13 -0
- atriakit/io/annotations_loader.py +223 -0
- atriakit/io/base_loader.py +15 -0
- atriakit/io/dicom_loader.py +66 -0
- atriakit/io/ecg_loader.py +47 -0
- atriakit/models/__init__.py +3 -0
- atriakit/models/annotation_row.py +18 -0
- atriakit/models/annotation_schema.py +25 -0
- atriakit/models/annotations.py +193 -0
- atriakit/models/ecg_data.py +94 -0
- atriakit/preprocessing/__init__.py +9 -0
- atriakit/preprocessing/annotations.py +43 -0
- atriakit/preprocessing/signals.py +247 -0
- atriakit/processing/dataset.py +215 -0
- atriakit/processing/pipeline.py +542 -0
- atriakit/processing/segment_processor.py +122 -0
- atriakit/utils.py +180 -0
- atriakit-0.1.0.dist-info/METADATA +330 -0
- atriakit-0.1.0.dist-info/RECORD +39 -0
- atriakit-0.1.0.dist-info/WHEEL +5 -0
- atriakit-0.1.0.dist-info/licenses/LICENSE +21 -0
- atriakit-0.1.0.dist-info/top_level.txt +1 -0
atriakit/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Public package interface for the ECG feature library."""
|
|
2
|
+
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
7
|
+
__version__ = version("atriakit")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
__version__ = "unknown"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"create_demo_data",
|
|
13
|
+
"AnnotationRow",
|
|
14
|
+
"Annotations",
|
|
15
|
+
"AnnotationsLoaderConfig",
|
|
16
|
+
"AnnotationsLoader",
|
|
17
|
+
"BaseLoader",
|
|
18
|
+
"DicomLoader",
|
|
19
|
+
"ECGData",
|
|
20
|
+
"ECGDataset",
|
|
21
|
+
"ECGLoader",
|
|
22
|
+
"FeatureCalculators",
|
|
23
|
+
"FeatureComputationConfig",
|
|
24
|
+
"Pipeline",
|
|
25
|
+
"PipelineConfig",
|
|
26
|
+
"SegmentConfig",
|
|
27
|
+
"SignalPreprocessor",
|
|
28
|
+
"SignalPreprocessorConfig",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_PUBLIC_IMPORTS = {
|
|
32
|
+
"create_demo_data": ("atriakit.datasets", "generate"),
|
|
33
|
+
"AnnotationRow": ("atriakit.models.annotation_row", "AnnotationRow"),
|
|
34
|
+
"Annotations": ("atriakit.models.annotations", "Annotations"),
|
|
35
|
+
"AnnotationsLoaderConfig": ("atriakit.configs.annotations_loader_config", "AnnotationsLoaderConfig"),
|
|
36
|
+
"AnnotationsLoader": ("atriakit.io", "AnnotationsLoader"),
|
|
37
|
+
"BaseLoader": ("atriakit.io", "BaseLoader"),
|
|
38
|
+
"DicomLoader": ("atriakit.io", "DicomLoader"),
|
|
39
|
+
"ECGData": ("atriakit.models.ecg_data", "ECGData"),
|
|
40
|
+
"ECGDataset": ("atriakit.processing.dataset", "ECGDataset"),
|
|
41
|
+
"ECGLoader": ("atriakit.io", "ECGLoader"),
|
|
42
|
+
"FeatureCalculators": ("atriakit.feature_calculator", "FeatureCalculators"),
|
|
43
|
+
"FeatureComputationConfig": (
|
|
44
|
+
"atriakit.configs.feature_computation_config",
|
|
45
|
+
"FeatureComputationConfig",
|
|
46
|
+
),
|
|
47
|
+
"Pipeline": ("atriakit.processing.pipeline", "Pipeline"),
|
|
48
|
+
"PipelineConfig": ("atriakit.configs.pipeline_config", "PipelineConfig"),
|
|
49
|
+
"SegmentConfig": ("atriakit.configs.segment_config", "SegmentConfig"),
|
|
50
|
+
"SignalPreprocessor": ("atriakit.preprocessing", "SignalPreprocessor"),
|
|
51
|
+
"SignalPreprocessorConfig": (
|
|
52
|
+
"atriakit.configs.signal_preprocessor_config",
|
|
53
|
+
"SignalPreprocessorConfig",
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def __getattr__(name):
|
|
59
|
+
if name not in _PUBLIC_IMPORTS:
|
|
60
|
+
raise AttributeError(f"module 'atriakit' has no attribute {name!r}")
|
|
61
|
+
|
|
62
|
+
module_name, attr_name = _PUBLIC_IMPORTS[name]
|
|
63
|
+
value = getattr(import_module(module_name), attr_name)
|
|
64
|
+
globals()[name] = value
|
|
65
|
+
return value
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(slots=True)
|
|
6
|
+
class AnnotationsLoaderConfig:
|
|
7
|
+
"""Configuration for the AnnotationsLoader.
|
|
8
|
+
|
|
9
|
+
Parameters
|
|
10
|
+
----------
|
|
11
|
+
boundary_mode : {"per_lead", "cross_lead"}
|
|
12
|
+
Controls which onset/offset values are treated as active after loading.
|
|
13
|
+
|
|
14
|
+
``"cross_lead"`` (default)
|
|
15
|
+
``onset`` and ``offset`` hold the multilead consensus values
|
|
16
|
+
(cross-lead minimum onset, cross-lead maximum offset).
|
|
17
|
+
No substitution is performed.
|
|
18
|
+
|
|
19
|
+
``"per_lead"``
|
|
20
|
+
``onset`` and ``offset`` are replaced by ``onset_original`` and
|
|
21
|
+
``offset_original`` — the raw per-lead annotation values.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
boundary_mode: Literal["per_lead", "cross_lead"] = "cross_lead"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(slots=True)
|
|
5
|
+
class FeatureComputationConfig:
|
|
6
|
+
"""Feature-computation parameters passed to FeatureCalculators.compute_all()."""
|
|
7
|
+
|
|
8
|
+
# Entropy
|
|
9
|
+
shannon_entropy_n_bins: int = 32 # number of histogram bins for Shannon entropy
|
|
10
|
+
shannon_entropy_bin_range: tuple[float, float] | None = (
|
|
11
|
+
None # fixed amplitude range for the histogram; None uses per-signal min/max. For comparable results should be set to the same value across the datasets.
|
|
12
|
+
)
|
|
13
|
+
sample_entropy_m: int = (
|
|
14
|
+
2 # embedding dimension (template length) for sample entropy
|
|
15
|
+
)
|
|
16
|
+
sample_entropy_r_factor: float = (
|
|
17
|
+
0.25 # tolerance as fraction of segment SD; controls how strictly templates must match
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Complexity / extrema
|
|
21
|
+
extrema_threshold_multiplier: float = (
|
|
22
|
+
0.1 # peak must exceed this fraction of max amplitude to be counted (complexity feature)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Noise & morphology
|
|
26
|
+
noise_sd_multiplier: float = (
|
|
27
|
+
3.0 # a signal deflection must exceed this many noise SDs to be counted as a fragment or extremum
|
|
28
|
+
)
|
|
29
|
+
morphology_min_phase_fraction: float = (
|
|
30
|
+
0.1 # minimum fraction of segment length a sign group must span to count as a distinct morphology phase (0.1 = at least 10% of P-wave)
|
|
31
|
+
)
|
|
32
|
+
morphology_noise_sd_multiplier: float = (
|
|
33
|
+
3.0 # same as noise_sd_multiplier but applied only inside the morphology classifier
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Fragmentation
|
|
37
|
+
fragment_noise_multiplier: float = (
|
|
38
|
+
3.0 # a fragment's amplitude change must exceed this many noise SDs to count as a valid fragment boundary
|
|
39
|
+
)
|
|
40
|
+
min_fragment_length_ms: float = (
|
|
41
|
+
0.0 # fragments shorter than this (ms) are discarded
|
|
42
|
+
)
|
|
43
|
+
normalize_by_duration: bool = (
|
|
44
|
+
False # normalise fragment metrics per 100ms of P-wave duration; True matches the original paper
|
|
45
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from atriakit.configs.feature_computation_config import FeatureComputationConfig
|
|
4
|
+
from atriakit.configs.segment_config import SegmentConfig
|
|
5
|
+
from atriakit.configs.signal_preprocessor_config import (
|
|
6
|
+
SignalPreprocessorConfig,
|
|
7
|
+
default_morphology_preprocessor_config,
|
|
8
|
+
default_signal_preprocessor_config,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class PipelineConfig:
|
|
14
|
+
"""Batch pipeline parameters."""
|
|
15
|
+
|
|
16
|
+
feature_computation: FeatureComputationConfig = field(
|
|
17
|
+
default_factory=FeatureComputationConfig # settings passed to FeatureCalculators.compute_all(); see FeatureComputationConfig
|
|
18
|
+
)
|
|
19
|
+
segment_config: SegmentConfig = field(
|
|
20
|
+
default_factory=SegmentConfig # segment-boundary and baseline-correction settings; see SegmentConfig
|
|
21
|
+
)
|
|
22
|
+
signal_preprocessor_config: SignalPreprocessorConfig = field(
|
|
23
|
+
default_factory=default_signal_preprocessor_config # preprocessing for amplitude and duration features
|
|
24
|
+
)
|
|
25
|
+
morphology_preprocessor_config: SignalPreprocessorConfig = field(
|
|
26
|
+
default_factory=default_morphology_preprocessor_config # preprocessing for shape-based features
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Grouping
|
|
30
|
+
group_tolerance_ms: int = (
|
|
31
|
+
200 # max time gap (ms) between P-wave peaks across leads to be grouped as the same beat
|
|
32
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic.dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class SegmentConfig:
|
|
8
|
+
"""Segment-boundary and baseline-correction settings for ``SegmentProcessor``."""
|
|
9
|
+
|
|
10
|
+
baseline_correction_type: Literal["none", "linear", "onset"] = "none" # Baseline correction applied to extracted segments before feature calculation.
|
|
11
|
+
skip_first_ms: int = 0 # Drops the first samples of each annotated segment before feature extraction.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class SignalPreprocessorConfig:
|
|
9
|
+
"""Default parameters for ECG signal preprocessing."""
|
|
10
|
+
|
|
11
|
+
lowcut: float | None = (
|
|
12
|
+
None # Low cutoff for the bandpass filter; `None` disables bandpass filtering.
|
|
13
|
+
)
|
|
14
|
+
highcut: float | None = (
|
|
15
|
+
None # High cutoff for the bandpass filter; `None` disables bandpass filtering.
|
|
16
|
+
)
|
|
17
|
+
notch_freq: float | list[float] | None = (
|
|
18
|
+
50.0 # Line-noise notch frequency/frequencies in Hz; set to `None` to skip the notch filter.
|
|
19
|
+
)
|
|
20
|
+
order: int = 2 # Butterworth bandpass filter order.
|
|
21
|
+
quality_factor: float = (
|
|
22
|
+
20.0 # Q factor for the notch filter; higher values make the notch narrower.
|
|
23
|
+
)
|
|
24
|
+
global_baseline_mode: Literal["none", "p_onset_spline"] = (
|
|
25
|
+
"p_onset_spline" # Full-recording baseline correction mode applied before segment extraction.
|
|
26
|
+
)
|
|
27
|
+
global_baseline_smoothing_factor: float = (
|
|
28
|
+
0 # Dimensionless smoothing strength for the SciPy spline fit; 0 keeps exact interpolation, larger values follow only slower baseline drift.
|
|
29
|
+
)
|
|
30
|
+
normalization_type: Literal["none", "zscore"] = (
|
|
31
|
+
"none" # Dataset-level normalization to apply after filtering; use `"none"` to disable it.
|
|
32
|
+
)
|
|
33
|
+
mean: np.ndarray | None = (
|
|
34
|
+
None # Per-lead mean used for zscore normalization; Either provide or compute with ECGDataset. Required if `normalization_type` is `"zscore"`.
|
|
35
|
+
)
|
|
36
|
+
std: np.ndarray | None = (
|
|
37
|
+
None # Per-lead standard deviation used for zscore normalization; Either provide or compute with ECGDataset. Required if `normalization_type` is `"zscore"`.
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_signal_preprocessor_config() -> SignalPreprocessorConfig:
|
|
42
|
+
"""Low-pass at 120 Hz with 50/100 Hz notch. Used for amplitude and duration features."""
|
|
43
|
+
return SignalPreprocessorConfig(lowcut=None, highcut=120, notch_freq=[50.0, 100.0])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_morphology_preprocessor_config() -> SignalPreprocessorConfig:
|
|
47
|
+
"""Low-pass at 30 Hz with 50/100 Hz notch. Used for P-wave shape classification and inflection detection."""
|
|
48
|
+
return SignalPreprocessorConfig(lowcut=None, highcut=30, notch_freq=[50.0, 100.0])
|
atriakit/constants.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Package-level constants shared across modules."""
|
|
2
|
+
|
|
3
|
+
# Leads required by the Kors regression matrix to compute a 3-lead VCG.
|
|
4
|
+
VCG_LEADS: list[str] = ["I", "II", "V1", "V2", "V3", "V4", "V5", "V6"]
|
|
5
|
+
|
|
6
|
+
# Output columns produced by VCG feature computation.
|
|
7
|
+
VCG_FEATURE_COLUMNS: list[str] = [
|
|
8
|
+
"vcg_eigenvalues_1", "vcg_eigenvalues_2", "vcg_eigenvalues_3",
|
|
9
|
+
"vcg_roundness", "vcg_flatness", "vcg_area",
|
|
10
|
+
"vcg_axis_elevation", "vcg_axis_azimuth",
|
|
11
|
+
"vcg_sum_fragment_count", "vcg_sum_fragment_width", "vcg_sum_fragment_height",
|
|
12
|
+
"vcg_x_fragment_count", "vcg_x_fragment_width", "vcg_x_fragment_height",
|
|
13
|
+
"vcg_y_fragment_count", "vcg_y_fragment_width", "vcg_y_fragment_height",
|
|
14
|
+
"vcg_z_fragment_count", "vcg_z_fragment_width", "vcg_z_fragment_height",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
# Leads required to compute the frontal P-wave axis.
|
|
18
|
+
AXIS_LEADS: list[str] = ["I", "aVF"]
|
atriakit/datasets.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Synthetic demo ECG data for AtriaKit.
|
|
2
|
+
|
|
3
|
+
Requires neurokit2, which is not installed by default::
|
|
4
|
+
|
|
5
|
+
pip install neurokit2
|
|
6
|
+
from atriakit import create_demo_data
|
|
7
|
+
create_demo_data() # writes to ./demo_data/
|
|
8
|
+
create_demo_data(output_dir="./my_data")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
import pydicom
|
|
18
|
+
from pydicom.dataset import Dataset, FileDataset, FileMetaDataset
|
|
19
|
+
from pydicom.sequence import Sequence
|
|
20
|
+
from pydicom.uid import generate_uid
|
|
21
|
+
|
|
22
|
+
from atriakit.models.annotation_schema import AnnotationSchema
|
|
23
|
+
|
|
24
|
+
LEADS = ["I", "II", "III", "aVR", "aVL", "aVF", "V1", "V2", "V3", "V4", "V5", "V6"]
|
|
25
|
+
FS = 500
|
|
26
|
+
DURATION = 10 # seconds
|
|
27
|
+
|
|
28
|
+
_LEAD_SCALES = {
|
|
29
|
+
"I": 0.70, "II": 1.00, "III": 0.40,
|
|
30
|
+
"aVR": -0.80, "aVL": 0.30, "aVF": 0.70,
|
|
31
|
+
"V1": -0.30, "V2": 0.20, "V3": 0.80,
|
|
32
|
+
"V4": 1.20, "V5": 1.10, "V6": 0.90,
|
|
33
|
+
}
|
|
34
|
+
_MV_TO_RAW = 1000 # 1 mV → 1000 int16 units; sensitivity = 0.001 mV/unit
|
|
35
|
+
|
|
36
|
+
DEMO_RECORDINGS = [
|
|
37
|
+
{
|
|
38
|
+
"heart_rate": 60,
|
|
39
|
+
AnnotationSchema.PATIENT_ID: "DEMO001",
|
|
40
|
+
"seed": 0,
|
|
41
|
+
"date": "20240101",
|
|
42
|
+
"time": "090000",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"heart_rate": 75,
|
|
46
|
+
AnnotationSchema.PATIENT_ID: "DEMO002",
|
|
47
|
+
"seed": 1,
|
|
48
|
+
"date": "20240615",
|
|
49
|
+
"time": "143000",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"heart_rate": 90,
|
|
53
|
+
AnnotationSchema.PATIENT_ID: "DEMO003",
|
|
54
|
+
"seed": 2,
|
|
55
|
+
"date": "20241201",
|
|
56
|
+
"time": "111500",
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _require_neurokit():
|
|
62
|
+
try:
|
|
63
|
+
import neurokit2 as nk
|
|
64
|
+
|
|
65
|
+
return nk
|
|
66
|
+
except ImportError:
|
|
67
|
+
raise SystemExit(
|
|
68
|
+
"Demo data generation requires neurokit2.\n"
|
|
69
|
+
"Install it with: pip install neurokit2"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def simulate_12lead(heart_rate: int, noise: float = 0.02, seed: int = 0) -> np.ndarray:
|
|
74
|
+
"""Simulate a 12-lead ECG and return a ``(12, n_samples)`` array in mV."""
|
|
75
|
+
nk = _require_neurokit()
|
|
76
|
+
rng = np.random.default_rng(seed)
|
|
77
|
+
lead_ii = np.array(
|
|
78
|
+
nk.ecg_simulate(
|
|
79
|
+
duration=DURATION, sampling_rate=FS, heart_rate=heart_rate, noise=noise
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
ecg = np.zeros((12, len(lead_ii)))
|
|
83
|
+
for i, lead in enumerate(LEADS):
|
|
84
|
+
per_lead_noise = rng.normal(0, noise * 0.05, len(lead_ii))
|
|
85
|
+
ecg[i] = _LEAD_SCALES[lead] * lead_ii + per_lead_noise
|
|
86
|
+
return ecg
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def to_dicom(
|
|
90
|
+
ecg: np.ndarray,
|
|
91
|
+
patient_id: str,
|
|
92
|
+
date: str = "20240101",
|
|
93
|
+
time: str = "120000",
|
|
94
|
+
leads: list[str] | None = None,
|
|
95
|
+
) -> FileDataset:
|
|
96
|
+
"""Pack an ECG array into a DICOM FileDataset.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
ecg: Signal array with shape ``(n_leads, n_samples)`` in mV.
|
|
100
|
+
patient_id: Patient identifier written into DICOM metadata.
|
|
101
|
+
date: Study date in ``YYYYMMDD`` format.
|
|
102
|
+
time: Study time in ``HHMMSS`` format.
|
|
103
|
+
leads: Channel labels matching the row order of ``ecg``.
|
|
104
|
+
Defaults to the standard 12-lead ``LEADS`` list.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
A ``pydicom.FileDataset`` ready to save with ``ds.save_as(path)``.
|
|
108
|
+
"""
|
|
109
|
+
if leads is None:
|
|
110
|
+
leads = LEADS
|
|
111
|
+
sop_uid = generate_uid()
|
|
112
|
+
|
|
113
|
+
file_meta = FileMetaDataset()
|
|
114
|
+
file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.9.1.1"
|
|
115
|
+
file_meta.MediaStorageSOPInstanceUID = sop_uid
|
|
116
|
+
file_meta.TransferSyntaxUID = pydicom.uid.ExplicitVRLittleEndian
|
|
117
|
+
|
|
118
|
+
ds = FileDataset(None, {}, file_meta=file_meta, preamble=b"\0" * 128)
|
|
119
|
+
ds.is_implicit_VR = False
|
|
120
|
+
ds.is_little_endian = True
|
|
121
|
+
|
|
122
|
+
ds.SOPClassUID = file_meta.MediaStorageSOPClassUID
|
|
123
|
+
ds.SOPInstanceUID = sop_uid
|
|
124
|
+
ds.PatientID = patient_id
|
|
125
|
+
ds.PatientName = f"Demo^{patient_id}"
|
|
126
|
+
ds.ContentDate = date
|
|
127
|
+
ds.ContentTime = time
|
|
128
|
+
ds.StudyDate = date
|
|
129
|
+
ds.StudyTime = time
|
|
130
|
+
|
|
131
|
+
wf = Dataset()
|
|
132
|
+
wf.MultiplexGroupLabel = "ECG"
|
|
133
|
+
wf.SamplingFrequency = float(FS)
|
|
134
|
+
|
|
135
|
+
n_ch, n_samples = ecg.shape
|
|
136
|
+
wf.add_new(pydicom.tag.Tag(0x003A, 0x0005), "US", n_ch) # NumberOfChannels
|
|
137
|
+
wf.add_new(
|
|
138
|
+
pydicom.tag.Tag(0x003A, 0x0010), "UL", n_samples
|
|
139
|
+
) # NumberOfWaveformSamples
|
|
140
|
+
wf.WaveformBitsAllocated = 16
|
|
141
|
+
wf.WaveformSampleInterpretation = "SS"
|
|
142
|
+
|
|
143
|
+
channels = []
|
|
144
|
+
for lead in leads:
|
|
145
|
+
ch = Dataset()
|
|
146
|
+
ch.ChannelLabel = lead
|
|
147
|
+
units = Dataset()
|
|
148
|
+
units.CodeValue = "mV"
|
|
149
|
+
units.CodingSchemeDesignator = "UCUM"
|
|
150
|
+
units.CodeMeaning = "millivolt"
|
|
151
|
+
ch.ChannelSensitivityUnitsSequence = Sequence([units])
|
|
152
|
+
ch.ChannelSensitivity = str(1.0 / _MV_TO_RAW)
|
|
153
|
+
ch.ChannelSensitivityCorrectionFactor = "1.0"
|
|
154
|
+
ch.WaveformBitsStored = 16
|
|
155
|
+
channels.append(ch)
|
|
156
|
+
wf.ChannelDefinitionSequence = Sequence(channels)
|
|
157
|
+
|
|
158
|
+
raw = np.clip((ecg * _MV_TO_RAW).T, -32768, 32767).astype(np.int16)
|
|
159
|
+
wf.WaveformData = raw.tobytes()
|
|
160
|
+
|
|
161
|
+
ds.WaveformSequence = Sequence([wf])
|
|
162
|
+
return ds
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def detect_p_waves(ecg_12lead: np.ndarray) -> list[dict]:
|
|
166
|
+
"""Detect P-wave onsets and offsets on Lead II using NeuroKit2.
|
|
167
|
+
|
|
168
|
+
Returns a list of dicts with keys ``onset``, ``offset``, and ``qrs_onset``
|
|
169
|
+
(sample indices at the pipeline's sampling rate).
|
|
170
|
+
"""
|
|
171
|
+
nk = _require_neurokit()
|
|
172
|
+
lead_ii = ecg_12lead[1]
|
|
173
|
+
signals, _ = nk.ecg_process(lead_ii, sampling_rate=FS)
|
|
174
|
+
|
|
175
|
+
p_onset_mask = (
|
|
176
|
+
signals.get("ECG_P_Onsets", pd.Series(0, index=signals.index))
|
|
177
|
+
.fillna(0)
|
|
178
|
+
.astype(int)
|
|
179
|
+
)
|
|
180
|
+
p_offset_mask = (
|
|
181
|
+
signals.get("ECG_P_Offsets", pd.Series(0, index=signals.index))
|
|
182
|
+
.fillna(0)
|
|
183
|
+
.astype(int)
|
|
184
|
+
)
|
|
185
|
+
r_peak_mask = (
|
|
186
|
+
signals.get("ECG_R_Peaks", pd.Series(0, index=signals.index))
|
|
187
|
+
.fillna(0)
|
|
188
|
+
.astype(int)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
onsets = np.where(p_onset_mask == 1)[0]
|
|
192
|
+
offsets = np.where(p_offset_mask == 1)[0]
|
|
193
|
+
r_peaks = np.where(r_peak_mask == 1)[0]
|
|
194
|
+
|
|
195
|
+
records: list[dict] = []
|
|
196
|
+
used_offsets: set[int] = set()
|
|
197
|
+
for onset in onsets:
|
|
198
|
+
future_offsets = offsets[offsets > onset]
|
|
199
|
+
if not len(future_offsets):
|
|
200
|
+
continue
|
|
201
|
+
offset = int(future_offsets[0])
|
|
202
|
+
if offset in used_offsets:
|
|
203
|
+
continue
|
|
204
|
+
used_offsets.add(offset)
|
|
205
|
+
following_r = r_peaks[r_peaks > offset]
|
|
206
|
+
qrs_onset = int(following_r[0]) - 20 if len(following_r) else -1
|
|
207
|
+
records.append(
|
|
208
|
+
{
|
|
209
|
+
AnnotationSchema.ONSET: int(onset),
|
|
210
|
+
AnnotationSchema.OFFSET: int(offset),
|
|
211
|
+
AnnotationSchema.QRS_ONSET: qrs_onset,
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return records
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def build_annotations(
|
|
219
|
+
file_path: str,
|
|
220
|
+
patient_id: str,
|
|
221
|
+
p_waves: list[dict],
|
|
222
|
+
seed: int = 42,
|
|
223
|
+
) -> pd.DataFrame:
|
|
224
|
+
"""Expand Lead II P-wave detections into a per-lead annotations DataFrame.
|
|
225
|
+
|
|
226
|
+
Adds small random jitter to onset/offset per lead to simulate realistic
|
|
227
|
+
inter-lead variation.
|
|
228
|
+
"""
|
|
229
|
+
rng = np.random.default_rng(seed)
|
|
230
|
+
rows = []
|
|
231
|
+
for p_wave_id, pw in enumerate(p_waves):
|
|
232
|
+
for lead in LEADS:
|
|
233
|
+
rows.append(
|
|
234
|
+
{
|
|
235
|
+
AnnotationSchema.PATIENT_ID: patient_id,
|
|
236
|
+
AnnotationSchema.LEAD: lead,
|
|
237
|
+
AnnotationSchema.P_WAVE_ID: p_wave_id,
|
|
238
|
+
AnnotationSchema.ONSET: pw[AnnotationSchema.ONSET]
|
|
239
|
+
+ int(rng.integers(-2, 3)),
|
|
240
|
+
AnnotationSchema.OFFSET: pw[AnnotationSchema.OFFSET]
|
|
241
|
+
+ int(rng.integers(-2, 3)),
|
|
242
|
+
AnnotationSchema.QRS_ONSET: pw[AnnotationSchema.QRS_ONSET],
|
|
243
|
+
AnnotationSchema.IGNORE: False,
|
|
244
|
+
AnnotationSchema.FILE_PATH: file_path,
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
return pd.DataFrame(rows)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def generate(output_dir: str | Path) -> Path:
|
|
251
|
+
"""Generate demo DICOM files and an annotations CSV into ``output_dir``.
|
|
252
|
+
|
|
253
|
+
Creates one DICOM per entry in ``DEMO_RECORDINGS`` and a combined
|
|
254
|
+
``demo_annotations.csv``. Returns the output directory path.
|
|
255
|
+
"""
|
|
256
|
+
out = Path(output_dir)
|
|
257
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
|
|
259
|
+
all_annotations: list[pd.DataFrame] = []
|
|
260
|
+
for cfg in DEMO_RECORDINGS:
|
|
261
|
+
patient_id = cfg[AnnotationSchema.PATIENT_ID]
|
|
262
|
+
print(f"[{patient_id}] Simulating 12-lead ECG at {cfg['heart_rate']} bpm …")
|
|
263
|
+
|
|
264
|
+
ecg = simulate_12lead(cfg["heart_rate"], seed=cfg["seed"])
|
|
265
|
+
|
|
266
|
+
dcm = to_dicom(ecg, patient_id=patient_id, date=cfg["date"], time=cfg["time"])
|
|
267
|
+
dcm_path = out / f"{patient_id}.dcm"
|
|
268
|
+
dcm.save_as(str(dcm_path))
|
|
269
|
+
print(f" Saved {dcm_path}")
|
|
270
|
+
|
|
271
|
+
p_waves = detect_p_waves(ecg)
|
|
272
|
+
print(f" Detected {len(p_waves)} P-waves")
|
|
273
|
+
|
|
274
|
+
anns = build_annotations(
|
|
275
|
+
f"{patient_id}.dcm", patient_id, p_waves, seed=cfg["seed"]
|
|
276
|
+
)
|
|
277
|
+
all_annotations.append(anns)
|
|
278
|
+
|
|
279
|
+
combined = pd.concat(all_annotations, ignore_index=True)
|
|
280
|
+
ann_path = out / "demo_annotations.csv"
|
|
281
|
+
combined.to_csv(ann_path, index=False)
|
|
282
|
+
print(
|
|
283
|
+
f"\nSaved {ann_path} "
|
|
284
|
+
f"({len(combined)} rows across {combined[AnnotationSchema.PATIENT_ID].nunique()} patients)"
|
|
285
|
+
)
|
|
286
|
+
return out
|