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/leads.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Lead naming conventions and the classic 3x4 + rhythm layout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Union
|
|
8
|
+
|
|
9
|
+
from numpy import asarray, ndarray
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pandas import DataFrame
|
|
13
|
+
|
|
14
|
+
# Common published order (aVL before aVF). For WFDB records, prefer the
|
|
15
|
+
# header ``sig_name`` via :func:`ecg_plotter.load_wfdb` rather than assuming
|
|
16
|
+
# a fixed channel order.
|
|
17
|
+
LEAD_NAMES: tuple[str, ...] = (
|
|
18
|
+
"I",
|
|
19
|
+
"II",
|
|
20
|
+
"III",
|
|
21
|
+
"aVR",
|
|
22
|
+
"aVL",
|
|
23
|
+
"aVF",
|
|
24
|
+
"V1",
|
|
25
|
+
"V2",
|
|
26
|
+
"V3",
|
|
27
|
+
"V4",
|
|
28
|
+
"V5",
|
|
29
|
+
"V6",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Classic clinical-style sheet: three rows of four columns, plus Lead II rhythm.
|
|
33
|
+
STANDARD_LAYOUT: tuple[tuple[str, ...], ...] = (
|
|
34
|
+
("I", "aVR", "V1", "V4"),
|
|
35
|
+
("II", "aVL", "V2", "V5"),
|
|
36
|
+
("III", "aVF", "V3", "V6"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
RHYTHM_LEAD = "II"
|
|
40
|
+
|
|
41
|
+
# Vertical stacking: each lead row occupies this many mV of display space.
|
|
42
|
+
ROW_MV = 4.0
|
|
43
|
+
N_ROWS = 4 # three lead rows + rhythm
|
|
44
|
+
DISPLAY_MV = ROW_MV * N_ROWS # 16 mV full-scale height
|
|
45
|
+
|
|
46
|
+
LeadInput = Union[Mapping[str, ndarray], ndarray, "DataFrame"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_dataframe(obj: Any) -> bool:
|
|
50
|
+
"""Return True if ``obj`` is a pandas DataFrame without importing pandas."""
|
|
51
|
+
cls = type(obj)
|
|
52
|
+
return cls.__name__ == "DataFrame" and cls.__module__.startswith("pandas")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_sample_series(value: Any) -> bool:
|
|
56
|
+
"""True when a cell looks like a full lead waveform rather than a scalar."""
|
|
57
|
+
if isinstance(value, (str, bytes)):
|
|
58
|
+
return False
|
|
59
|
+
try:
|
|
60
|
+
arr = asarray(value, dtype=float).reshape(-1)
|
|
61
|
+
except (TypeError, ValueError):
|
|
62
|
+
return False
|
|
63
|
+
return arr.size > 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_df_lead_columns(
|
|
67
|
+
df: DataFrame,
|
|
68
|
+
lead_names: Sequence[str] | None,
|
|
69
|
+
) -> tuple[list[Any], list[str]]:
|
|
70
|
+
"""Return ``(columns to read, names to give them)``.
|
|
71
|
+
|
|
72
|
+
The two lists are the same when the columns are already lead names.
|
|
73
|
+
An unlabelled 12-column table keeps its own column labels for reading
|
|
74
|
+
and is named with :data:`LEAD_NAMES` in that column order.
|
|
75
|
+
"""
|
|
76
|
+
if lead_names is not None:
|
|
77
|
+
missing = [name for name in lead_names if name not in df.columns]
|
|
78
|
+
if missing:
|
|
79
|
+
raise KeyError(f"DataFrame is missing lead columns: {missing}")
|
|
80
|
+
names = list(lead_names)
|
|
81
|
+
return names, names
|
|
82
|
+
|
|
83
|
+
known = list(LEAD_NAMES)
|
|
84
|
+
found = [name for name in known if name in df.columns]
|
|
85
|
+
# Preserve clinical order from LEAD_NAMES where possible.
|
|
86
|
+
ordered = [name for name in LEAD_NAMES if name in found]
|
|
87
|
+
ordered.extend(name for name in found if name not in ordered)
|
|
88
|
+
if ordered:
|
|
89
|
+
return ordered, ordered
|
|
90
|
+
|
|
91
|
+
if df.shape[1] == len(LEAD_NAMES):
|
|
92
|
+
# warn -> this function -> _dataframe_to_leads -> as_lead_dict -> caller
|
|
93
|
+
warnings.warn(
|
|
94
|
+
"DataFrame columns are not lead names, so the 12 columns are "
|
|
95
|
+
f"assumed to be {list(LEAD_NAMES)} in that order. "
|
|
96
|
+
"Pass lead_names= if this order is wrong.",
|
|
97
|
+
UserWarning,
|
|
98
|
+
stacklevel=4,
|
|
99
|
+
)
|
|
100
|
+
return list(df.columns), list(LEAD_NAMES)
|
|
101
|
+
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"Could not infer lead columns from DataFrame. "
|
|
104
|
+
f"Expected named leads such as {list(LEAD_NAMES)}, or pass lead_names=..."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _dataframe_to_leads(
|
|
109
|
+
df: DataFrame,
|
|
110
|
+
*,
|
|
111
|
+
lead_names: Sequence[str] | None = None,
|
|
112
|
+
) -> dict[str, ndarray]:
|
|
113
|
+
if df.empty:
|
|
114
|
+
raise ValueError("DataFrame is empty.")
|
|
115
|
+
|
|
116
|
+
columns, names = _resolve_df_lead_columns(df, lead_names)
|
|
117
|
+
subset = df.loc[:, columns]
|
|
118
|
+
|
|
119
|
+
# Index by position so an unlabelled table can be renamed to LEAD_NAMES
|
|
120
|
+
# without looking those names up as column titles.
|
|
121
|
+
if len(subset) == 1 and all(
|
|
122
|
+
_is_sample_series(subset.iloc[0, i]) for i in range(subset.shape[1])
|
|
123
|
+
):
|
|
124
|
+
# Single-row JSONL-style frame: each lead cell holds an array/list.
|
|
125
|
+
out = {
|
|
126
|
+
name: asarray(subset.iloc[0, i], dtype=float).reshape(-1)
|
|
127
|
+
for i, name in enumerate(names)
|
|
128
|
+
}
|
|
129
|
+
else:
|
|
130
|
+
# Wide time-series frame: rows = samples, columns = leads.
|
|
131
|
+
out = {
|
|
132
|
+
name: asarray(subset.iloc[:, i], dtype=float).reshape(-1)
|
|
133
|
+
for i, name in enumerate(names)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
lengths = {name: len(sig) for name, sig in out.items()}
|
|
137
|
+
if len(set(lengths.values())) != 1:
|
|
138
|
+
raise ValueError(f"All leads must share the same length; got {lengths}.")
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def as_lead_dict(
|
|
143
|
+
leads: LeadInput,
|
|
144
|
+
*,
|
|
145
|
+
lead_names: Sequence[str] | None = None,
|
|
146
|
+
) -> dict[str, ndarray]:
|
|
147
|
+
"""Normalise lead input to ``{name: 1-D float array}``.
|
|
148
|
+
|
|
149
|
+
Amplitudes are expected in millivolts (mV).
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
leads
|
|
154
|
+
One of:
|
|
155
|
+
|
|
156
|
+
- mapping of lead name → samples
|
|
157
|
+
- array of shape ``(12, n)`` or ``(n, 12)``
|
|
158
|
+
- ``pandas.DataFrame`` with lead columns (rows = samples), or a
|
|
159
|
+
single-row frame whose lead cells contain arrays/lists
|
|
160
|
+
lead_names
|
|
161
|
+
Names for array columns/rows, or the DataFrame columns to use.
|
|
162
|
+
Defaults to :data:`LEAD_NAMES` for arrays. For DataFrames, columns
|
|
163
|
+
named as leads are used when this is omitted. A 12-column table with
|
|
164
|
+
no lead names is assumed to be :data:`LEAD_NAMES` in column order,
|
|
165
|
+
and a warning is issued.
|
|
166
|
+
"""
|
|
167
|
+
if _is_dataframe(leads):
|
|
168
|
+
return _dataframe_to_leads(leads, lead_names=lead_names)
|
|
169
|
+
|
|
170
|
+
if isinstance(leads, Mapping):
|
|
171
|
+
out = {str(k): asarray(v, dtype=float).reshape(-1) for k, v in leads.items()}
|
|
172
|
+
if not out:
|
|
173
|
+
raise ValueError("Lead mapping is empty.")
|
|
174
|
+
lengths = {name: len(sig) for name, sig in out.items()}
|
|
175
|
+
if len(set(lengths.values())) != 1:
|
|
176
|
+
raise ValueError(f"All leads must share the same length; got {lengths}.")
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
arr = asarray(leads, dtype=float)
|
|
180
|
+
names = tuple(lead_names) if lead_names is not None else LEAD_NAMES
|
|
181
|
+
|
|
182
|
+
if arr.ndim != 2:
|
|
183
|
+
raise ValueError(f"Array input must be 2-D; got shape {arr.shape}.")
|
|
184
|
+
|
|
185
|
+
if arr.shape[0] == len(names):
|
|
186
|
+
rows = arr
|
|
187
|
+
elif arr.shape[1] == len(names):
|
|
188
|
+
rows = arr.T
|
|
189
|
+
else:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"Expected a dimension of size {len(names)} for leads {list(names)}; "
|
|
192
|
+
f"got shape {arr.shape}."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return {name: rows[i].reshape(-1) for i, name in enumerate(names)}
|
ecg_plotter/py.typed
ADDED
|
File without changes
|
ecg_plotter/sheet.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""Render a classic 12-lead ECG sheet to PNG, PDF, or SVG."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import warnings
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from numpy import arange
|
|
11
|
+
|
|
12
|
+
from ecg_plotter.leads import (
|
|
13
|
+
DISPLAY_MV,
|
|
14
|
+
RHYTHM_LEAD,
|
|
15
|
+
ROW_MV,
|
|
16
|
+
STANDARD_LAYOUT,
|
|
17
|
+
LeadInput,
|
|
18
|
+
as_lead_dict,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Classic ECG thermal-paper look (RGB).
|
|
22
|
+
_PAPER_FACE = "#FFF5F5"
|
|
23
|
+
_GRID_MINOR = "#F2C4C4"
|
|
24
|
+
_GRID_MAJOR = "#E09090"
|
|
25
|
+
_DIVIDER = "#8B3030"
|
|
26
|
+
_TRACE = "#1A1A1A"
|
|
27
|
+
_LABEL = "#1A1A1A"
|
|
28
|
+
|
|
29
|
+
# Greyscale sheet.
|
|
30
|
+
_GREY_FACE = "white"
|
|
31
|
+
_GREY_MINOR = "#B0B0B0"
|
|
32
|
+
_GREY_MAJOR = "#696969"
|
|
33
|
+
_GREY_DIVIDER = "#2A2A2A"
|
|
34
|
+
|
|
35
|
+
# Lead-panel separators (heavier than major grid lines).
|
|
36
|
+
_DIVIDER_LINEWIDTH = 0.9
|
|
37
|
+
|
|
38
|
+
# Fixed footer ID used when ``anonymise=True``.
|
|
39
|
+
ANONYMOUS_ID = "15030416"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def paper_calibration(
|
|
43
|
+
width_in: float,
|
|
44
|
+
height_in: float,
|
|
45
|
+
*,
|
|
46
|
+
dpi: int,
|
|
47
|
+
duration_s: float,
|
|
48
|
+
display_mv: float = DISPLAY_MV,
|
|
49
|
+
) -> dict[str, float]:
|
|
50
|
+
"""Compute physical paper speed and gain from figure geometry.
|
|
51
|
+
|
|
52
|
+
Pixel rounding is applied the same way matplotlib will when saving, so
|
|
53
|
+
the reported mm/s and mm/mV match the written PNG.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
width_in, height_in
|
|
58
|
+
Figure size in inches.
|
|
59
|
+
dpi
|
|
60
|
+
Dots per inch used when saving.
|
|
61
|
+
duration_s
|
|
62
|
+
Duration of the plotted recording in seconds (full trace width).
|
|
63
|
+
display_mv
|
|
64
|
+
Millivolts spanned by the full vertical axis (default 16 = 4 rows × 4 mV).
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
dict
|
|
69
|
+
``width_px``, ``height_px``, ``width_mm``, ``height_mm``,
|
|
70
|
+
``speed_mm_s``, ``gain_mm_mv``, ``duration_s``.
|
|
71
|
+
"""
|
|
72
|
+
if dpi <= 0:
|
|
73
|
+
raise ValueError("dpi must be positive.")
|
|
74
|
+
if duration_s <= 0:
|
|
75
|
+
raise ValueError("duration_s must be positive.")
|
|
76
|
+
if display_mv <= 0:
|
|
77
|
+
raise ValueError("display_mv must be positive.")
|
|
78
|
+
|
|
79
|
+
width_px = int(round(width_in * dpi))
|
|
80
|
+
height_px = int(round(height_in * dpi))
|
|
81
|
+
width_mm = (width_px / dpi) * 25.4
|
|
82
|
+
height_mm = (height_px / dpi) * 25.4
|
|
83
|
+
return {
|
|
84
|
+
"width_px": float(width_px),
|
|
85
|
+
"height_px": float(height_px),
|
|
86
|
+
"width_mm": width_mm,
|
|
87
|
+
"height_mm": height_mm,
|
|
88
|
+
"speed_mm_s": width_mm / duration_s,
|
|
89
|
+
"gain_mm_mv": height_mm / display_mv,
|
|
90
|
+
"duration_s": duration_s,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_figsize(
|
|
95
|
+
*,
|
|
96
|
+
inches: tuple[float, float] | None,
|
|
97
|
+
resolution: tuple[int, int] | None,
|
|
98
|
+
dpi: int,
|
|
99
|
+
) -> tuple[float, float]:
|
|
100
|
+
if inches is not None and resolution is not None:
|
|
101
|
+
raise ValueError("Provide only one of `inches` or `resolution`, not both.")
|
|
102
|
+
if inches is None and resolution is None:
|
|
103
|
+
raise ValueError("Provide `inches=(w, h)` or `resolution=(width_px, height_px)`.")
|
|
104
|
+
if dpi <= 0:
|
|
105
|
+
raise ValueError("dpi must be positive.")
|
|
106
|
+
|
|
107
|
+
if resolution is not None:
|
|
108
|
+
width_px, height_px = resolution
|
|
109
|
+
if width_px <= 0 or height_px <= 0:
|
|
110
|
+
raise ValueError("resolution values must be positive.")
|
|
111
|
+
return (width_px / dpi, height_px / dpi)
|
|
112
|
+
|
|
113
|
+
width_in, height_in = inches # type: ignore[misc]
|
|
114
|
+
if width_in <= 0 or height_in <= 0:
|
|
115
|
+
raise ValueError("inches values must be positive.")
|
|
116
|
+
return (float(width_in), float(height_in))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _format_filter_label(
|
|
120
|
+
*,
|
|
121
|
+
notch_hz: float | None,
|
|
122
|
+
bandpass_hz: tuple[float, float] | None,
|
|
123
|
+
filter_label: str | None,
|
|
124
|
+
) -> str | None:
|
|
125
|
+
if filter_label is not None:
|
|
126
|
+
return filter_label
|
|
127
|
+
parts: list[str] = []
|
|
128
|
+
if notch_hz is not None:
|
|
129
|
+
parts.append(str(int(notch_hz) if float(notch_hz).is_integer() else notch_hz))
|
|
130
|
+
if bandpass_hz is not None:
|
|
131
|
+
low, high = bandpass_hz
|
|
132
|
+
low_s = int(low) if float(low).is_integer() else low
|
|
133
|
+
high_s = int(high) if float(high).is_integer() else high
|
|
134
|
+
parts.append(f"{low_s}-{high_s} Hz")
|
|
135
|
+
if not parts:
|
|
136
|
+
return None
|
|
137
|
+
if len(parts) == 1:
|
|
138
|
+
return parts[0]
|
|
139
|
+
return f"{parts[0]} ~ {parts[1]}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _resolve_footer_id(id: str | None, anonymise: bool) -> str | None:
|
|
143
|
+
if anonymise:
|
|
144
|
+
return ANONYMOUS_ID
|
|
145
|
+
return str(id) if id is not None else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
SUPPORTED_FORMATS = frozenset({"png", "pdf", "svg"})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _resolve_format(save_path: Path, format: str | None) -> str:
|
|
152
|
+
if format is None:
|
|
153
|
+
suffix = Path(save_path).suffix.lstrip(".").lower()
|
|
154
|
+
if not suffix:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
"Could not infer output format from save_path; "
|
|
157
|
+
"pass format='png'|'pdf'|'svg' or use a matching file extension."
|
|
158
|
+
)
|
|
159
|
+
format = suffix
|
|
160
|
+
fmt = format.lower()
|
|
161
|
+
if fmt not in SUPPORTED_FORMATS:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"Unsupported format {format!r}; expected one of {sorted(SUPPORTED_FORMATS)}."
|
|
164
|
+
)
|
|
165
|
+
return fmt
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def plot(
|
|
169
|
+
leads: LeadInput,
|
|
170
|
+
save_path: str | Path,
|
|
171
|
+
*,
|
|
172
|
+
inches: tuple[float, float] | None = None,
|
|
173
|
+
resolution: tuple[int, int] | None = None,
|
|
174
|
+
dpi: int = 300,
|
|
175
|
+
fs: float = 500.0,
|
|
176
|
+
colour: bool = True,
|
|
177
|
+
lead_names: Sequence[str] | None = None,
|
|
178
|
+
layout: Sequence[Sequence[str]] = STANDARD_LAYOUT,
|
|
179
|
+
rhythm_lead: str = RHYTHM_LEAD,
|
|
180
|
+
linewidth: float = 0.8,
|
|
181
|
+
fontname: str = "DejaVu Serif",
|
|
182
|
+
id: str | None = None,
|
|
183
|
+
anonymise: bool = False,
|
|
184
|
+
loc_text: str | None = None,
|
|
185
|
+
filter: bool = False,
|
|
186
|
+
notch_hz: float | None = None,
|
|
187
|
+
bandpass_hz: tuple[float, float] | None = None,
|
|
188
|
+
filter_label: str | None = None,
|
|
189
|
+
extra_footer: str | None = None,
|
|
190
|
+
format: str | None = None,
|
|
191
|
+
) -> Path:
|
|
192
|
+
"""Plot a 12-lead ECG and save PNG, PDF, or SVG.
|
|
193
|
+
|
|
194
|
+
Size may be given as physical ``inches=(w, h)`` or as
|
|
195
|
+
``resolution=(width_px, height_px)``. Resolution is converted with
|
|
196
|
+
``inches = pixels / dpi``. Default ``dpi`` is 300.
|
|
197
|
+
|
|
198
|
+
Colour mode uses a pink ECG-paper grid; greyscale uses a grey grid.
|
|
199
|
+
PNG colour output is RGB (3 channels); PNG greyscale is mode ``L``
|
|
200
|
+
(1 channel). PDF and SVG are written as vector graphics.
|
|
201
|
+
|
|
202
|
+
Paper speed (mm/s) and gain (mm/mV) in the footer are derived from the
|
|
203
|
+
saved figure geometry and the recording duration, so they stay accurate
|
|
204
|
+
when size or DPI change.
|
|
205
|
+
|
|
206
|
+
The trace is split into four equal columns. If the sample count is not
|
|
207
|
+
divisible by 4, the last 1–3 samples are dropped and a warning is raised,
|
|
208
|
+
so the columns and the rhythm strip share one time axis. Speed uses that
|
|
209
|
+
drawn duration.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
leads
|
|
214
|
+
Lead mapping, ``(12, n)`` / ``(n, 12)`` array, or ``pandas.DataFrame``
|
|
215
|
+
with lead columns (or a single-row frame of lead arrays). Amplitudes
|
|
216
|
+
are expected in millivolts (mV).
|
|
217
|
+
save_path
|
|
218
|
+
Output path. Extension may be ``.png``, ``.pdf``, or ``.svg``.
|
|
219
|
+
inches, resolution
|
|
220
|
+
Mutually exclusive figure size specifications.
|
|
221
|
+
dpi
|
|
222
|
+
Output resolution (controls PNG pixel size; also stored as metadata).
|
|
223
|
+
fs
|
|
224
|
+
Sampling frequency in Hz (used for duration, mm/s, and optional filtering).
|
|
225
|
+
colour
|
|
226
|
+
``True`` → pink paper; ``False`` → greyscale.
|
|
227
|
+
lead_names
|
|
228
|
+
Names for array inputs (default :data:`LEAD_NAMES`).
|
|
229
|
+
layout
|
|
230
|
+
3×4 lead arrangement.
|
|
231
|
+
rhythm_lead
|
|
232
|
+
Lead used for the continuous bottom strip.
|
|
233
|
+
id, loc_text, extra_footer
|
|
234
|
+
Optional footer strings. ``loc_text`` defaults to a placeholder when
|
|
235
|
+
location metadata is unavailable.
|
|
236
|
+
anonymise
|
|
237
|
+
If ``True``, replace the footer ID with a fixed value
|
|
238
|
+
(``15030416``) so real record IDs cannot leak into ML training images.
|
|
239
|
+
filter
|
|
240
|
+
If ``True``, apply ``notch`` / ``bandpass`` using ``notch_hz`` and
|
|
241
|
+
``bandpass_hz`` before plotting. If ``False``, those values are only
|
|
242
|
+
used for the footer annotation (signals are left unchanged). Standalone
|
|
243
|
+
filter functions remain available for custom pipelines.
|
|
244
|
+
notch_hz, bandpass_hz, filter_label
|
|
245
|
+
Filter settings and/or footer annotation (e.g. ``60 ~ 0.05-150 Hz``).
|
|
246
|
+
Pass ``filter_label`` to override the auto-built string entirely.
|
|
247
|
+
format
|
|
248
|
+
``"png"``, ``"pdf"``, or ``"svg"``. If omitted, inferred from
|
|
249
|
+
``save_path`` suffix.
|
|
250
|
+
|
|
251
|
+
Returns
|
|
252
|
+
-------
|
|
253
|
+
pathlib.Path
|
|
254
|
+
Path to the written file.
|
|
255
|
+
"""
|
|
256
|
+
# Agg draws into memory and writes a file. pyplot's default backend may
|
|
257
|
+
# try to open a window (Tk), which fails on headless machines and in CI.
|
|
258
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
259
|
+
from matplotlib.figure import Figure
|
|
260
|
+
from matplotlib.ticker import MultipleLocator
|
|
261
|
+
|
|
262
|
+
if fs <= 0:
|
|
263
|
+
raise ValueError("fs must be positive.")
|
|
264
|
+
|
|
265
|
+
lead_map = as_lead_dict(leads, lead_names=lead_names)
|
|
266
|
+
if filter:
|
|
267
|
+
from ecg_plotter.filters import bandpass, notch
|
|
268
|
+
|
|
269
|
+
if notch_hz is not None:
|
|
270
|
+
lead_map = {
|
|
271
|
+
name: notch(sig, fs=fs, freq=notch_hz) for name, sig in lead_map.items()
|
|
272
|
+
}
|
|
273
|
+
if bandpass_hz is not None:
|
|
274
|
+
lowcut, highcut = bandpass_hz
|
|
275
|
+
lead_map = {
|
|
276
|
+
name: bandpass(sig, fs=fs, lowcut=lowcut, highcut=highcut)
|
|
277
|
+
for name, sig in lead_map.items()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
# Four equal columns. Drop at most 3 leftover samples so the panels and
|
|
281
|
+
# the rhythm strip share one time axis, and the footer matches what is drawn.
|
|
282
|
+
n_cols = 4
|
|
283
|
+
n_raw = next(iter(lead_map.values())).shape[0]
|
|
284
|
+
col_len = n_raw // n_cols
|
|
285
|
+
if col_len < 1:
|
|
286
|
+
raise ValueError("Recording is too short to split into a 3×4 layout.")
|
|
287
|
+
n_samples = col_len * n_cols
|
|
288
|
+
dropped = n_raw - n_samples
|
|
289
|
+
if dropped:
|
|
290
|
+
warnings.warn(
|
|
291
|
+
f"Dropped the last {dropped} sample(s) so the four columns stay equal "
|
|
292
|
+
f"({n_raw} samples is not divisible by 4).",
|
|
293
|
+
UserWarning,
|
|
294
|
+
stacklevel=2,
|
|
295
|
+
)
|
|
296
|
+
lead_map = {name: sig[:n_samples] for name, sig in lead_map.items()}
|
|
297
|
+
|
|
298
|
+
for row_leads in layout:
|
|
299
|
+
if len(row_leads) != n_cols:
|
|
300
|
+
raise ValueError("Each layout row must contain exactly 4 lead names.")
|
|
301
|
+
for name in row_leads:
|
|
302
|
+
if name not in lead_map:
|
|
303
|
+
raise KeyError(f"Layout lead '{name}' not present in input.")
|
|
304
|
+
if rhythm_lead not in lead_map:
|
|
305
|
+
raise KeyError(f"Rhythm lead '{rhythm_lead}' not present in input.")
|
|
306
|
+
|
|
307
|
+
duration_s = n_samples / fs
|
|
308
|
+
width_in, height_in = _resolve_figsize(inches=inches, resolution=resolution, dpi=dpi)
|
|
309
|
+
calib = paper_calibration(width_in, height_in, dpi=dpi, duration_s=duration_s)
|
|
310
|
+
x = arange(n_samples, dtype=float)
|
|
311
|
+
|
|
312
|
+
fmt = _resolve_format(Path(save_path), format)
|
|
313
|
+
if fmt == "png":
|
|
314
|
+
from PIL.Image import open as open_image
|
|
315
|
+
|
|
316
|
+
if colour:
|
|
317
|
+
face, minor_c, major_c, div_c, trace_c, label_c = (
|
|
318
|
+
_PAPER_FACE,
|
|
319
|
+
_GRID_MINOR,
|
|
320
|
+
_GRID_MAJOR,
|
|
321
|
+
_DIVIDER,
|
|
322
|
+
_TRACE,
|
|
323
|
+
_LABEL,
|
|
324
|
+
)
|
|
325
|
+
else:
|
|
326
|
+
face, minor_c, major_c, div_c, trace_c, label_c = (
|
|
327
|
+
_GREY_FACE,
|
|
328
|
+
_GREY_MINOR,
|
|
329
|
+
_GREY_MAJOR,
|
|
330
|
+
_GREY_DIVIDER,
|
|
331
|
+
"black",
|
|
332
|
+
"black",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Row baselines (mV offsets): top lead row → rhythm strip.
|
|
336
|
+
row_offsets = [DISPLAY_MV - ROW_MV * (i + 0.5) for i in range(len(layout))] + [
|
|
337
|
+
ROW_MV * 0.5
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
fig = Figure(figsize=(width_in, height_in), dpi=dpi, facecolor=face)
|
|
341
|
+
FigureCanvasAgg(fig)
|
|
342
|
+
try:
|
|
343
|
+
ax = fig.add_axes([0.0, 0.0, 1.0, 1.0], facecolor=face)
|
|
344
|
+
|
|
345
|
+
# Lead panels.
|
|
346
|
+
for row_idx, row_leads in enumerate(layout):
|
|
347
|
+
y0 = row_offsets[row_idx]
|
|
348
|
+
for col_idx, name in enumerate(row_leads):
|
|
349
|
+
start = col_idx * col_len
|
|
350
|
+
stop = start + col_len
|
|
351
|
+
segment = lead_map[name][start:stop] + y0
|
|
352
|
+
ax.plot(
|
|
353
|
+
x[start:stop],
|
|
354
|
+
segment,
|
|
355
|
+
color=trace_c,
|
|
356
|
+
linewidth=linewidth,
|
|
357
|
+
solid_capstyle="round",
|
|
358
|
+
)
|
|
359
|
+
ax.text(
|
|
360
|
+
start + col_len * 0.02,
|
|
361
|
+
y0 + ROW_MV * 0.385,
|
|
362
|
+
name,
|
|
363
|
+
fontsize=10,
|
|
364
|
+
fontname=fontname,
|
|
365
|
+
color=label_c,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Rhythm strip (full width of the samples actually drawn).
|
|
369
|
+
rhythm = lead_map[rhythm_lead] + row_offsets[-1]
|
|
370
|
+
ax.plot(x, rhythm, color=trace_c, linewidth=linewidth, solid_capstyle="round")
|
|
371
|
+
ax.text(
|
|
372
|
+
n_samples * 0.005,
|
|
373
|
+
row_offsets[-1] + ROW_MV * 0.385,
|
|
374
|
+
rhythm_lead,
|
|
375
|
+
fontsize=10,
|
|
376
|
+
fontname=fontname,
|
|
377
|
+
color=label_c,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Horizontal dividers between rows (at 12, 8, 4 mV for the default stack).
|
|
381
|
+
for y in (ROW_MV * 3, ROW_MV * 2, ROW_MV):
|
|
382
|
+
ax.plot([0, n_samples], [y, y], color=div_c, linewidth=_DIVIDER_LINEWIDTH)
|
|
383
|
+
# Vertical column walls (lead panels only).
|
|
384
|
+
for boundary in (col_len, 2 * col_len, 3 * col_len):
|
|
385
|
+
ax.plot(
|
|
386
|
+
[boundary, boundary],
|
|
387
|
+
[ROW_MV, DISPLAY_MV],
|
|
388
|
+
color=div_c,
|
|
389
|
+
linewidth=_DIVIDER_LINEWIDTH,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# Grid: 0.1 mV minor / 0.5 mV major vertically;
|
|
393
|
+
# horizontally, major ≈ 0.2 s (large square) at the given fs.
|
|
394
|
+
samples_per_major = max(fs * 0.2, 1.0)
|
|
395
|
+
samples_per_minor = max(samples_per_major / 5.0, 1.0)
|
|
396
|
+
ax.yaxis.set_minor_locator(MultipleLocator(0.1))
|
|
397
|
+
ax.yaxis.set_major_locator(MultipleLocator(0.5))
|
|
398
|
+
ax.xaxis.set_minor_locator(MultipleLocator(samples_per_minor))
|
|
399
|
+
ax.xaxis.set_major_locator(MultipleLocator(samples_per_major))
|
|
400
|
+
ax.grid(which="minor", color=minor_c, linestyle="-", linewidth=0.3, alpha=0.85)
|
|
401
|
+
ax.grid(which="major", color=major_c, linestyle="-", linewidth=0.55, alpha=0.9)
|
|
402
|
+
|
|
403
|
+
ax.set_xlim(0, n_samples)
|
|
404
|
+
ax.set_ylim(0, DISPLAY_MV)
|
|
405
|
+
ax.set_autoscale_on(False)
|
|
406
|
+
ax.tick_params(
|
|
407
|
+
which="both", labelbottom=False, labelleft=False, bottom=False, left=False
|
|
408
|
+
)
|
|
409
|
+
for spine in ax.spines.values():
|
|
410
|
+
spine.set_visible(False)
|
|
411
|
+
|
|
412
|
+
# Footer.
|
|
413
|
+
speed = calib["speed_mm_s"]
|
|
414
|
+
gain = calib["gain_mm_mv"]
|
|
415
|
+
filt = _format_filter_label(
|
|
416
|
+
notch_hz=notch_hz, bandpass_hz=bandpass_hz, filter_label=filter_label
|
|
417
|
+
)
|
|
418
|
+
loc = loc_text if loc_text is not None else "LOC 00000-0000"
|
|
419
|
+
calib_text = (
|
|
420
|
+
f"Speed: {speed:.1f} mm/s "
|
|
421
|
+
f"Limb: {gain:.1f} mm/mV "
|
|
422
|
+
f"Chest: {gain:.1f} mm/mV"
|
|
423
|
+
)
|
|
424
|
+
if extra_footer:
|
|
425
|
+
calib_text = f"{calib_text} {extra_footer}"
|
|
426
|
+
|
|
427
|
+
footer_id = _resolve_footer_id(id, anonymise)
|
|
428
|
+
footer_y = 0.19
|
|
429
|
+
ax.text(
|
|
430
|
+
n_samples * 0.027, footer_y, loc, fontsize=8, fontname=fontname, color=label_c
|
|
431
|
+
)
|
|
432
|
+
ax.text(
|
|
433
|
+
n_samples * 0.187,
|
|
434
|
+
footer_y,
|
|
435
|
+
calib_text,
|
|
436
|
+
fontsize=8,
|
|
437
|
+
fontname=fontname,
|
|
438
|
+
color=label_c,
|
|
439
|
+
)
|
|
440
|
+
if filt:
|
|
441
|
+
ax.text(
|
|
442
|
+
n_samples * 0.707,
|
|
443
|
+
footer_y,
|
|
444
|
+
filt,
|
|
445
|
+
fontsize=8,
|
|
446
|
+
fontname=fontname,
|
|
447
|
+
color=label_c,
|
|
448
|
+
bbox={"facecolor": "none", "edgecolor": label_c, "boxstyle": "square,pad=0.2"},
|
|
449
|
+
)
|
|
450
|
+
if footer_id is not None:
|
|
451
|
+
ax.text(
|
|
452
|
+
n_samples * 0.939,
|
|
453
|
+
footer_y,
|
|
454
|
+
footer_id,
|
|
455
|
+
fontsize=8,
|
|
456
|
+
fontname=fontname,
|
|
457
|
+
color=label_c,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
save_path = Path(save_path)
|
|
461
|
+
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
462
|
+
|
|
463
|
+
if fmt == "png":
|
|
464
|
+
buf = io.BytesIO()
|
|
465
|
+
fig.savefig(
|
|
466
|
+
buf, format="png", dpi=dpi, facecolor=face, edgecolor="none", pad_inches=0
|
|
467
|
+
)
|
|
468
|
+
buf.seek(0)
|
|
469
|
+
image = open_image(buf)
|
|
470
|
+
if colour:
|
|
471
|
+
image = image.convert("RGB")
|
|
472
|
+
else:
|
|
473
|
+
image = image.convert("L")
|
|
474
|
+
image.save(save_path, format="PNG", dpi=(dpi, dpi))
|
|
475
|
+
buf.close()
|
|
476
|
+
else:
|
|
477
|
+
fig.savefig(
|
|
478
|
+
save_path,
|
|
479
|
+
format=fmt,
|
|
480
|
+
dpi=dpi,
|
|
481
|
+
facecolor=face,
|
|
482
|
+
edgecolor="none",
|
|
483
|
+
pad_inches=0,
|
|
484
|
+
)
|
|
485
|
+
finally:
|
|
486
|
+
# Drop artists so a failed save does not keep a large figure alive.
|
|
487
|
+
fig.clear()
|
|
488
|
+
|
|
489
|
+
return save_path.resolve()
|