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.
@@ -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__))
@@ -0,0 +1,3 @@
1
+ """Package version — single source of truth for runtime and packaging."""
2
+
3
+ __version__ = "0.1.0"
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
+ }