ecg-plotter 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.
- ecg_plotter/__init__.py +63 -0
- ecg_plotter/_version.py +3 -0
- ecg_plotter/cli.py +172 -0
- ecg_plotter/filters.py +142 -0
- ecg_plotter/io.py +169 -0
- ecg_plotter/leads.py +195 -0
- ecg_plotter/py.typed +0 -0
- ecg_plotter/sheet.py +489 -0
- ecg_plotter-0.1.0.dist-info/METADATA +231 -0
- ecg_plotter-0.1.0.dist-info/RECORD +14 -0
- ecg_plotter-0.1.0.dist-info/WHEEL +5 -0
- ecg_plotter-0.1.0.dist-info/entry_points.txt +2 -0
- ecg_plotter-0.1.0.dist-info/licenses/LICENSE +21 -0
- ecg_plotter-0.1.0.dist-info/top_level.txt +1 -0
ecg_plotter/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""ECG Plotter — filter and render 12-lead ECG traces for research / visualisation.
|
|
2
|
+
|
|
3
|
+
Not intended for diagnostic use. Dependencies such as matplotlib, scipy, wfdb,
|
|
4
|
+
pandas, and Pillow are imported lazily when needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib import import_module
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ecg_plotter._version import __version__
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ANONYMOUS_ID",
|
|
16
|
+
"LEAD_NAMES",
|
|
17
|
+
"STANDARD_LAYOUT",
|
|
18
|
+
"__version__",
|
|
19
|
+
"apply_to_leads",
|
|
20
|
+
"as_lead_dict",
|
|
21
|
+
"bandpass",
|
|
22
|
+
"leads_to_array",
|
|
23
|
+
"load_jsonl",
|
|
24
|
+
"load_wfdb",
|
|
25
|
+
"median_baseline",
|
|
26
|
+
"notch",
|
|
27
|
+
"paper_calibration",
|
|
28
|
+
"plot",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# name -> (module, attribute)
|
|
32
|
+
# Drawing lives in ``sheet``, not a module named ``plot``. A submodule called
|
|
33
|
+
# ``plot`` would replace the public ``ecg.plot`` function as soon as that
|
|
34
|
+
# submodule was imported.
|
|
35
|
+
_LAZY_ATTRS: dict[str, tuple[str, str]] = {
|
|
36
|
+
"ANONYMOUS_ID": ("ecg_plotter.sheet", "ANONYMOUS_ID"),
|
|
37
|
+
"LEAD_NAMES": ("ecg_plotter.leads", "LEAD_NAMES"),
|
|
38
|
+
"STANDARD_LAYOUT": ("ecg_plotter.leads", "STANDARD_LAYOUT"),
|
|
39
|
+
"apply_to_leads": ("ecg_plotter.filters", "apply_to_leads"),
|
|
40
|
+
"as_lead_dict": ("ecg_plotter.leads", "as_lead_dict"),
|
|
41
|
+
"bandpass": ("ecg_plotter.filters", "bandpass"),
|
|
42
|
+
"leads_to_array": ("ecg_plotter.io", "leads_to_array"),
|
|
43
|
+
"load_jsonl": ("ecg_plotter.io", "load_jsonl"),
|
|
44
|
+
"load_wfdb": ("ecg_plotter.io", "load_wfdb"),
|
|
45
|
+
"median_baseline": ("ecg_plotter.filters", "median_baseline"),
|
|
46
|
+
"notch": ("ecg_plotter.filters", "notch"),
|
|
47
|
+
"paper_calibration": ("ecg_plotter.sheet", "paper_calibration"),
|
|
48
|
+
"plot": ("ecg_plotter.sheet", "plot"),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def __getattr__(name: str) -> Any:
|
|
53
|
+
try:
|
|
54
|
+
module_name, attr_name = _LAZY_ATTRS[name]
|
|
55
|
+
except KeyError as exc:
|
|
56
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
|
57
|
+
value = getattr(import_module(module_name), attr_name)
|
|
58
|
+
globals()[name] = value
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def __dir__() -> list[str]:
|
|
63
|
+
return sorted(set(globals()) | set(__all__))
|
ecg_plotter/_version.py
ADDED
ecg_plotter/cli.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Command-line interface for ecg-plotter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ecg_plotter._version import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="ecg-plotter",
|
|
15
|
+
description="Plot a 12-lead ECG as PNG, PDF, or SVG.",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"record",
|
|
19
|
+
nargs="?",
|
|
20
|
+
help="WFDB record path (with or without .hea/.dat), or omit when using --jsonl",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-o",
|
|
24
|
+
"--output",
|
|
25
|
+
required=True,
|
|
26
|
+
type=Path,
|
|
27
|
+
help="Output path (.png / .pdf / .svg)",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--jsonl",
|
|
31
|
+
type=Path,
|
|
32
|
+
help="JSON Lines file of lead arrays (uses --index)",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--index",
|
|
36
|
+
type=int,
|
|
37
|
+
default=0,
|
|
38
|
+
help="Record index within --jsonl (default: 0)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--inches",
|
|
42
|
+
nargs=2,
|
|
43
|
+
type=float,
|
|
44
|
+
metavar=("WIDTH", "HEIGHT"),
|
|
45
|
+
help="Figure size in inches (mutually exclusive with --resolution)",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--resolution",
|
|
49
|
+
nargs=2,
|
|
50
|
+
type=int,
|
|
51
|
+
metavar=("WIDTH_PX", "HEIGHT_PX"),
|
|
52
|
+
help="Figure size in pixels (mutually exclusive with --inches)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("--dpi", type=int, default=300, help="DPI (default: 300)")
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--fs",
|
|
57
|
+
type=float,
|
|
58
|
+
default=None,
|
|
59
|
+
help="Sampling frequency in Hz (overrides WFDB/JSONL metadata when set)",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--greyscale",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="Write greyscale output instead of pink colour paper",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--anonymise",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="Replace footer ID with the fixed anonymous placeholder",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--filter",
|
|
73
|
+
action="store_true",
|
|
74
|
+
help="Apply notch/bandpass using --notch-hz / --bandpass-hz",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--notch-hz",
|
|
78
|
+
type=float,
|
|
79
|
+
default=None,
|
|
80
|
+
help="Notch frequency in Hz (footer and optional filtering)",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--bandpass-hz",
|
|
84
|
+
nargs=2,
|
|
85
|
+
type=float,
|
|
86
|
+
metavar=("LOW", "HIGH"),
|
|
87
|
+
default=None,
|
|
88
|
+
help="Band-pass edges in Hz (footer and optional filtering)",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--id",
|
|
92
|
+
default=None,
|
|
93
|
+
help="Footer ID text (ignored when --anonymise is set)",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--format",
|
|
97
|
+
choices=("png", "pdf", "svg"),
|
|
98
|
+
default=None,
|
|
99
|
+
help="Output format (default: inferred from --output suffix)",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--version",
|
|
103
|
+
action="version",
|
|
104
|
+
version=f"%(prog)s {__version__}",
|
|
105
|
+
)
|
|
106
|
+
return parser
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main(argv: list[str] | None = None) -> int:
|
|
110
|
+
parser = _build_parser()
|
|
111
|
+
args = parser.parse_args(argv)
|
|
112
|
+
|
|
113
|
+
if args.inches is None and args.resolution is None:
|
|
114
|
+
args.inches = [9.84, 6.3]
|
|
115
|
+
if args.inches is not None and args.resolution is not None:
|
|
116
|
+
parser.error("Provide only one of --inches or --resolution")
|
|
117
|
+
if args.record is None and args.jsonl is None:
|
|
118
|
+
parser.error("Provide a WFDB record path or --jsonl")
|
|
119
|
+
if args.record is not None and args.jsonl is not None:
|
|
120
|
+
parser.error("Provide either a WFDB record path or --jsonl, not both")
|
|
121
|
+
|
|
122
|
+
from ecg_plotter.io import load_jsonl, load_wfdb
|
|
123
|
+
from ecg_plotter.sheet import plot
|
|
124
|
+
|
|
125
|
+
if args.jsonl is not None:
|
|
126
|
+
if args.index < 0:
|
|
127
|
+
parser.error("--index must be >= 0")
|
|
128
|
+
# Stop once the requested record is found. A research JSONL file can
|
|
129
|
+
# be huge; there is no need to parse every later line.
|
|
130
|
+
chosen = None
|
|
131
|
+
n_seen = 0
|
|
132
|
+
for n_seen, item in enumerate(load_jsonl(args.jsonl), start=1):
|
|
133
|
+
if n_seen - 1 == args.index:
|
|
134
|
+
chosen = item
|
|
135
|
+
break
|
|
136
|
+
if chosen is None:
|
|
137
|
+
if n_seen == 0:
|
|
138
|
+
parser.error(f"No records found in {args.jsonl}")
|
|
139
|
+
parser.error(f"--index {args.index} out of range (0..{n_seen - 1})")
|
|
140
|
+
assert chosen is not None
|
|
141
|
+
leads = chosen["leads"]
|
|
142
|
+
fs = args.fs if args.fs is not None else chosen["fs"]
|
|
143
|
+
record_id = args.id if args.id is not None else (
|
|
144
|
+
str(chosen["id"]) if chosen.get("id") is not None else None
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
data = load_wfdb(args.record)
|
|
148
|
+
leads = data["leads"]
|
|
149
|
+
fs = args.fs if args.fs is not None else data["fs"]
|
|
150
|
+
record_id = args.id if args.id is not None else data["record_name"]
|
|
151
|
+
|
|
152
|
+
path = plot(
|
|
153
|
+
leads,
|
|
154
|
+
args.output,
|
|
155
|
+
inches=tuple(args.inches) if args.inches is not None else None,
|
|
156
|
+
resolution=tuple(args.resolution) if args.resolution is not None else None,
|
|
157
|
+
dpi=args.dpi,
|
|
158
|
+
fs=fs,
|
|
159
|
+
colour=not args.greyscale,
|
|
160
|
+
id=record_id,
|
|
161
|
+
anonymise=args.anonymise,
|
|
162
|
+
filter=args.filter,
|
|
163
|
+
notch_hz=args.notch_hz,
|
|
164
|
+
bandpass_hz=tuple(args.bandpass_hz) if args.bandpass_hz is not None else None,
|
|
165
|
+
format=args.format,
|
|
166
|
+
)
|
|
167
|
+
print(path)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
sys.exit(main())
|
ecg_plotter/filters.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Signal conditioning filters for ECG traces.
|
|
2
|
+
|
|
3
|
+
Each function operates on a single 1-D lead. Use :func:`apply_to_leads` to
|
|
4
|
+
run the same filter across a full 12-lead recording.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from numpy import asarray, ndarray
|
|
13
|
+
from scipy.signal import butter, filtfilt, iirnotch, medfilt
|
|
14
|
+
|
|
15
|
+
from ecg_plotter.leads import LeadInput, _is_dataframe, as_lead_dict
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pandas import DataFrame
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _require_filtfilt_length(x: ndarray, b: ndarray, a: ndarray, *, name: str) -> None:
|
|
22
|
+
"""Raise a clear error before filtfilt's cryptic padlen failure."""
|
|
23
|
+
padlen = 3 * max(len(a), len(b))
|
|
24
|
+
if len(x) <= padlen:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"{name}: signal length ({len(x)}) is too short for zero-phase filtering "
|
|
27
|
+
f"(needs more than {padlen} samples for this filter). "
|
|
28
|
+
"Use a longer recording, lower filter order, or skip filtering."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def bandpass(
|
|
33
|
+
signal: ndarray,
|
|
34
|
+
fs: float,
|
|
35
|
+
lowcut: float = 0.05,
|
|
36
|
+
highcut: float = 150.0,
|
|
37
|
+
order: int = 4,
|
|
38
|
+
) -> ndarray:
|
|
39
|
+
"""Zero-phase Butterworth band-pass filter.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
signal
|
|
44
|
+
1-D ECG samples in millivolts (mV).
|
|
45
|
+
fs
|
|
46
|
+
Sampling frequency in Hz.
|
|
47
|
+
lowcut, highcut
|
|
48
|
+
Pass-band edges in Hz.
|
|
49
|
+
order
|
|
50
|
+
Filter order.
|
|
51
|
+
"""
|
|
52
|
+
x = asarray(signal, dtype=float).reshape(-1)
|
|
53
|
+
if lowcut <= 0 or highcut <= lowcut:
|
|
54
|
+
raise ValueError("Require 0 < lowcut < highcut.")
|
|
55
|
+
nyq = 0.5 * fs
|
|
56
|
+
if highcut >= nyq:
|
|
57
|
+
raise ValueError(f"highcut ({highcut}) must be < Nyquist ({nyq}).")
|
|
58
|
+
low = lowcut / nyq
|
|
59
|
+
high = highcut / nyq
|
|
60
|
+
b, a = butter(order, [low, high], btype="band")
|
|
61
|
+
_require_filtfilt_length(x, b, a, name="bandpass")
|
|
62
|
+
return filtfilt(b, a, x)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def notch(
|
|
66
|
+
signal: ndarray,
|
|
67
|
+
fs: float,
|
|
68
|
+
freq: float = 60.0,
|
|
69
|
+
quality: float = 30.0,
|
|
70
|
+
) -> ndarray:
|
|
71
|
+
"""Zero-phase IIR notch filter (mains rejection).
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
signal
|
|
76
|
+
1-D ECG samples in millivolts (mV).
|
|
77
|
+
fs
|
|
78
|
+
Sampling frequency in Hz.
|
|
79
|
+
freq
|
|
80
|
+
Notch centre frequency in Hz (typically 50 or 60).
|
|
81
|
+
quality
|
|
82
|
+
Quality factor ``Q = freq / bandwidth``. Higher Q → narrower notch.
|
|
83
|
+
"""
|
|
84
|
+
x = asarray(signal, dtype=float).reshape(-1)
|
|
85
|
+
nyq = 0.5 * fs
|
|
86
|
+
if not 0 < freq < nyq:
|
|
87
|
+
raise ValueError(f"Notch frequency must be in (0, {nyq}); got {freq}.")
|
|
88
|
+
b, a = iirnotch(freq, quality, fs=fs)
|
|
89
|
+
_require_filtfilt_length(x, b, a, name="notch")
|
|
90
|
+
return filtfilt(b, a, x)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def median_baseline(
|
|
94
|
+
signal: ndarray,
|
|
95
|
+
kernel_size: int = 201,
|
|
96
|
+
) -> ndarray:
|
|
97
|
+
"""Remove baseline wander by subtracting a median-filtered estimate.
|
|
98
|
+
|
|
99
|
+
``kernel_size`` must be odd. Larger kernels track slower drift.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
signal
|
|
104
|
+
1-D ECG samples in millivolts (mV).
|
|
105
|
+
kernel_size
|
|
106
|
+
Odd window length for the median baseline estimate.
|
|
107
|
+
"""
|
|
108
|
+
x = asarray(signal, dtype=float).reshape(-1)
|
|
109
|
+
if kernel_size < 1 or kernel_size % 2 == 0:
|
|
110
|
+
raise ValueError("kernel_size must be a positive odd integer.")
|
|
111
|
+
if kernel_size > len(x):
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"kernel_size ({kernel_size}) cannot exceed signal length ({len(x)})."
|
|
114
|
+
)
|
|
115
|
+
baseline = medfilt(x, kernel_size=kernel_size)
|
|
116
|
+
return x - baseline
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def apply_to_leads(
|
|
120
|
+
leads: LeadInput,
|
|
121
|
+
filter_fn: Callable[..., ndarray],
|
|
122
|
+
**kwargs: Any,
|
|
123
|
+
) -> dict[str, ndarray] | DataFrame:
|
|
124
|
+
"""Apply a 1-D filter function to every lead.
|
|
125
|
+
|
|
126
|
+
Accepts a lead mapping, ``(12, n)`` / ``(n, 12)`` array, or DataFrame.
|
|
127
|
+
DataFrame inputs are returned as a DataFrame with the same lead columns;
|
|
128
|
+
other inputs return a lead dictionary.
|
|
129
|
+
|
|
130
|
+
Example
|
|
131
|
+
-------
|
|
132
|
+
>>> cleaned = apply_to_leads(leads, bandpass, fs=500, lowcut=0.05, highcut=150)
|
|
133
|
+
>>> cleaned = apply_to_leads(cleaned, notch, fs=500, freq=60)
|
|
134
|
+
"""
|
|
135
|
+
was_df = _is_dataframe(leads)
|
|
136
|
+
lead_map = as_lead_dict(leads)
|
|
137
|
+
out = {name: filter_fn(sig, **kwargs) for name, sig in lead_map.items()}
|
|
138
|
+
if was_df:
|
|
139
|
+
from pandas import DataFrame
|
|
140
|
+
|
|
141
|
+
return DataFrame(out)
|
|
142
|
+
return out
|
ecg_plotter/io.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Load ECG recordings from WFDB paths or JSON Lines files.
|
|
2
|
+
|
|
3
|
+
Lead samples are expected in millivolts (mV). ``load_wfdb`` reads
|
|
4
|
+
``record.p_signal``, which WFDB already scales to physical units using the
|
|
5
|
+
header gain; a warning is issued if those units are not mV.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import warnings
|
|
12
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from numpy import asarray, ndarray, vstack
|
|
17
|
+
|
|
18
|
+
from ecg_plotter.leads import LEAD_NAMES, as_lead_dict
|
|
19
|
+
|
|
20
|
+
_MV_ALIASES = frozenset({"mv", "millivolt", "millivolts"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _warn_if_units_not_mv(units: Sequence[Any] | None, record_path: Path) -> None:
|
|
24
|
+
if not units:
|
|
25
|
+
return
|
|
26
|
+
unexpected = [
|
|
27
|
+
str(unit)
|
|
28
|
+
for unit in units
|
|
29
|
+
if str(unit).strip().lower() not in _MV_ALIASES
|
|
30
|
+
]
|
|
31
|
+
if unexpected:
|
|
32
|
+
warnings.warn(
|
|
33
|
+
f"WFDB record {record_path} has non-mV units {unexpected}; "
|
|
34
|
+
"ecg-plotter expects lead amplitudes in millivolts (mV).",
|
|
35
|
+
UserWarning,
|
|
36
|
+
stacklevel=3,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def leads_to_array(
|
|
41
|
+
leads: Mapping[str, ndarray],
|
|
42
|
+
lead_names: Sequence[str] = LEAD_NAMES,
|
|
43
|
+
) -> ndarray:
|
|
44
|
+
"""Stack a lead mapping into an array of shape ``(n_leads, n_samples)``.
|
|
45
|
+
|
|
46
|
+
Values are treated as millivolts (mV).
|
|
47
|
+
"""
|
|
48
|
+
missing = [name for name in lead_names if name not in leads]
|
|
49
|
+
if missing:
|
|
50
|
+
raise KeyError(f"Missing leads: {missing}")
|
|
51
|
+
return vstack([asarray(leads[name], dtype=float) for name in lead_names])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_wfdb(
|
|
55
|
+
record_path: str | Path,
|
|
56
|
+
*,
|
|
57
|
+
lead_names: Sequence[str] | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Load a WFDB record into a lead dictionary plus metadata.
|
|
60
|
+
|
|
61
|
+
Lead arrays come from ``record.p_signal`` and are expected in millivolts
|
|
62
|
+
(mV). If the header ``units`` are not mV, a :class:`UserWarning` is
|
|
63
|
+
emitted; values are still returned unchanged.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
record_path
|
|
68
|
+
Path to the record *without* extension, or with ``.hea`` / ``.dat``.
|
|
69
|
+
lead_names
|
|
70
|
+
Optional override for signal names (must match the number of channels).
|
|
71
|
+
If omitted, names are taken from the WFDB header (``record.sig_name``),
|
|
72
|
+
so any 12-lead WFDB dataset works regardless of channel order
|
|
73
|
+
(e.g. ``aVL`` before or after ``aVF``).
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
dict
|
|
78
|
+
``leads`` (name → array in mV), ``fs``, ``sig_name``, ``record_name``,
|
|
79
|
+
``n_samples``, ``units``, and the raw ``wfdb.Record`` under ``record``.
|
|
80
|
+
"""
|
|
81
|
+
from wfdb import rdrecord
|
|
82
|
+
|
|
83
|
+
path = Path(record_path)
|
|
84
|
+
if path.suffix.lower() in {".hea", ".dat", ".mat"}:
|
|
85
|
+
path = path.with_suffix("")
|
|
86
|
+
|
|
87
|
+
record = rdrecord(str(path))
|
|
88
|
+
signal = asarray(record.p_signal, dtype=float)
|
|
89
|
+
names = list(record.sig_name) if lead_names is None else list(lead_names)
|
|
90
|
+
units = list(record.units) if getattr(record, "units", None) is not None else None
|
|
91
|
+
_warn_if_units_not_mv(units, path)
|
|
92
|
+
|
|
93
|
+
if signal.ndim != 2 or signal.shape[1] != len(names):
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"Signal shape {signal.shape} does not match {len(names)} lead names."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
leads = {name: signal[:, i].copy() for i, name in enumerate(names)}
|
|
99
|
+
return {
|
|
100
|
+
"leads": leads,
|
|
101
|
+
"fs": float(record.fs),
|
|
102
|
+
"sig_name": names,
|
|
103
|
+
"record_name": record.record_name,
|
|
104
|
+
"n_samples": int(record.sig_len),
|
|
105
|
+
"units": units,
|
|
106
|
+
"record": record,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_jsonl(
|
|
111
|
+
path: str | Path,
|
|
112
|
+
*,
|
|
113
|
+
lead_names: Sequence[str] | None = None,
|
|
114
|
+
) -> Iterator[dict[str, Any]]:
|
|
115
|
+
"""Yield records from a JSON Lines file of lead arrays.
|
|
116
|
+
|
|
117
|
+
Each line must be a JSON object with:
|
|
118
|
+
|
|
119
|
+
- lead arrays in millivolts (mV), e.g. ``"I": [...]``
|
|
120
|
+
- required ``fs`` (sampling frequency in Hz; must be positive)
|
|
121
|
+
- optional metadata such as ``id`` / ``label``
|
|
122
|
+
|
|
123
|
+
Non-lead keys are returned alongside ``leads``.
|
|
124
|
+
"""
|
|
125
|
+
names = set(lead_names) if lead_names is not None else set(LEAD_NAMES)
|
|
126
|
+
path = Path(path)
|
|
127
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
128
|
+
for line_no, line in enumerate(fh, start=1):
|
|
129
|
+
line = line.strip()
|
|
130
|
+
if not line:
|
|
131
|
+
continue
|
|
132
|
+
try:
|
|
133
|
+
row = json.loads(line)
|
|
134
|
+
except json.JSONDecodeError as exc:
|
|
135
|
+
raise ValueError(f"Invalid JSON on line {line_no} of {path}") from exc
|
|
136
|
+
if not isinstance(row, dict):
|
|
137
|
+
raise ValueError(f"Expected object on line {line_no}; got {type(row)}.")
|
|
138
|
+
|
|
139
|
+
lead_map = {
|
|
140
|
+
key: asarray(val, dtype=float).reshape(-1)
|
|
141
|
+
for key, val in row.items()
|
|
142
|
+
if key in names and not isinstance(val, str)
|
|
143
|
+
}
|
|
144
|
+
if not lead_map:
|
|
145
|
+
raise ValueError(f"No lead arrays found on line {line_no} of {path}.")
|
|
146
|
+
|
|
147
|
+
meta = {key: val for key, val in row.items() if key not in lead_map}
|
|
148
|
+
if "fs" not in meta or meta["fs"] is None:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Missing required field 'fs' on line {line_no} of {path}."
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
fs = float(meta["fs"])
|
|
154
|
+
except (TypeError, ValueError) as exc:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"Invalid 'fs' on line {line_no} of {path}: {meta['fs']!r}."
|
|
157
|
+
) from exc
|
|
158
|
+
if fs <= 0:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"'fs' must be positive on line {line_no} of {path}; got {fs}."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
yield {
|
|
164
|
+
"leads": as_lead_dict(lead_map),
|
|
165
|
+
"fs": fs,
|
|
166
|
+
"id": meta.get("id", meta.get("study_id")),
|
|
167
|
+
"label": meta.get("label", meta.get("condition")),
|
|
168
|
+
"meta": meta,
|
|
169
|
+
}
|