ecg-plotter 0.1.0__tar.gz
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-0.1.0/.github/workflows/ci.yml +29 -0
- ecg_plotter-0.1.0/CITATION.cff +26 -0
- ecg_plotter-0.1.0/LICENSE +21 -0
- ecg_plotter-0.1.0/MANIFEST.in +21 -0
- ecg_plotter-0.1.0/PKG-INFO +231 -0
- ecg_plotter-0.1.0/README.md +197 -0
- ecg_plotter-0.1.0/ecg_plotter/__init__.py +63 -0
- ecg_plotter-0.1.0/ecg_plotter/_version.py +3 -0
- ecg_plotter-0.1.0/ecg_plotter/cli.py +172 -0
- ecg_plotter-0.1.0/ecg_plotter/filters.py +142 -0
- ecg_plotter-0.1.0/ecg_plotter/io.py +169 -0
- ecg_plotter-0.1.0/ecg_plotter/leads.py +195 -0
- ecg_plotter-0.1.0/ecg_plotter/py.typed +0 -0
- ecg_plotter-0.1.0/ecg_plotter/sheet.py +489 -0
- ecg_plotter-0.1.0/ecg_plotter.egg-info/SOURCES.txt +40 -0
- ecg_plotter-0.1.0/examples/data/README.md +15 -0
- ecg_plotter-0.1.0/examples/data/generate_synthetic.py +158 -0
- ecg_plotter-0.1.0/examples/data/synthetic_data.jsonl +3 -0
- ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0001.dat +0 -0
- ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0001.hea +15 -0
- ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0002.dat +0 -0
- ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0002.hea +15 -0
- ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0003.dat +0 -0
- ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0003.hea +15 -0
- ecg_plotter-0.1.0/examples/data/synthetic_records.csv +4 -0
- ecg_plotter-0.1.0/examples/plot_colour_and_greyscale.py +51 -0
- ecg_plotter-0.1.0/examples/plot_from_dataframe.py +68 -0
- ecg_plotter-0.1.0/examples/plot_from_jsonl.py +35 -0
- ecg_plotter-0.1.0/examples/plot_from_wfdb.py +32 -0
- ecg_plotter-0.1.0/examples/plot_low_dpi.py +33 -0
- ecg_plotter-0.1.0/examples/plot_nonstandard_calibration.py +43 -0
- ecg_plotter-0.1.0/examples/plot_pdf_and_svg.py +44 -0
- ecg_plotter-0.1.0/examples/plot_with_standalone_filters.py +45 -0
- ecg_plotter-0.1.0/pyproject.toml +62 -0
- ecg_plotter-0.1.0/setup.cfg +4 -0
- ecg_plotter-0.1.0/tests/conftest.py +41 -0
- ecg_plotter-0.1.0/tests/test_calibration.py +45 -0
- ecg_plotter-0.1.0/tests/test_cli_and_formats.py +360 -0
- ecg_plotter-0.1.0/tests/test_filters.py +104 -0
- ecg_plotter-0.1.0/tests/test_io.py +271 -0
- ecg_plotter-0.1.0/tests/test_leads.py +96 -0
- ecg_plotter-0.1.0/tests/test_plot_errors.py +142 -0
- ecg_plotter-0.1.0/tests/test_plot_output.py +117 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, master]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.9", "3.11", "3.12"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
cache: pip
|
|
24
|
+
|
|
25
|
+
- name: Install package with test deps
|
|
26
|
+
run: pip install -e ".[dev]"
|
|
27
|
+
|
|
28
|
+
- name: Run tests
|
|
29
|
+
run: pytest --tb=short
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use this software, please cite it as below."
|
|
3
|
+
title: "ecg-plotter"
|
|
4
|
+
version: "0.1.0"
|
|
5
|
+
date-released: 2026-11-05
|
|
6
|
+
doi: 10.5281/zenodo.23984258
|
|
7
|
+
url: "https://github.com/JarodPH/ecg-plotter"
|
|
8
|
+
repository-code: "https://github.com/JarodPH/ecg-plotter"
|
|
9
|
+
|
|
10
|
+
type: software
|
|
11
|
+
authors:
|
|
12
|
+
- family-names: Hartley
|
|
13
|
+
given-names: Jarod P.
|
|
14
|
+
email: jarodph.contact@gmail.com
|
|
15
|
+
orcid: "https://orcid.org/0009-0006-0736-5100"
|
|
16
|
+
affiliation: Swansea University
|
|
17
|
+
license: MIT
|
|
18
|
+
abstract: >-
|
|
19
|
+
Plot 12-lead ECGs at settable physical size or pixel resolution and DPI,
|
|
20
|
+
with optional signal filters, for research, teaching, and ML dataset
|
|
21
|
+
creation. Not intended for diagnostic use or clinical decision-making.
|
|
22
|
+
keywords:
|
|
23
|
+
- ecg
|
|
24
|
+
- electrocardiogram
|
|
25
|
+
- plot
|
|
26
|
+
- wfdb
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jarod P. Hartley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
include README.md
|
|
2
|
+
include LICENSE
|
|
3
|
+
include pyproject.toml
|
|
4
|
+
include CITATION.cff
|
|
5
|
+
include ecg_plotter/py.typed
|
|
6
|
+
recursive-include ecg_plotter *.py
|
|
7
|
+
recursive-include tests *.py
|
|
8
|
+
recursive-include examples *.py
|
|
9
|
+
recursive-include examples *.md
|
|
10
|
+
recursive-include examples *.jsonl
|
|
11
|
+
recursive-include examples *.csv
|
|
12
|
+
recursive-include examples *.hea
|
|
13
|
+
recursive-include examples *.dat
|
|
14
|
+
recursive-include .github *.yml
|
|
15
|
+
|
|
16
|
+
global-exclude *.py[cod]
|
|
17
|
+
prune examples/output
|
|
18
|
+
prune dist
|
|
19
|
+
prune build
|
|
20
|
+
prune .pytest_cache
|
|
21
|
+
prune ecg_plotter.egg-info
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ecg-plotter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Research/visualisation ECG sheets at settable size and DPI (not for diagnostic use).
|
|
5
|
+
Author-email: "Jarod P. Hartley" <jarodph.contact@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/JarodPH/ecg-plotter
|
|
8
|
+
Project-URL: Repository, https://github.com/JarodPH/ecg-plotter
|
|
9
|
+
Project-URL: Zenodo, https://doi.org/10.5281/zenodo.23984258
|
|
10
|
+
Project-URL: ORCID, https://orcid.org/0009-0006-0736-5100
|
|
11
|
+
Keywords: ecg,electrocardiogram,plot,wfdb
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: numpy>=1.24
|
|
26
|
+
Requires-Dist: pandas>=2.0
|
|
27
|
+
Requires-Dist: scipy>=1.10
|
|
28
|
+
Requires-Dist: matplotlib>=3.7
|
|
29
|
+
Requires-Dist: Pillow>=9.0
|
|
30
|
+
Requires-Dist: wfdb>=4.1
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# ECG Plotter
|
|
36
|
+
|
|
37
|
+
Plot 12-lead ECGs as classic clinical-style sheets with **settable physical size or pixel resolution**, **DPI**, optional **signal filters**, and accurate **mm/s / mm/mV** footer reporting.
|
|
38
|
+
|
|
39
|
+
**Research / visualisation only — not for diagnostic use.** This package renders ECG traces for research, teaching, and ML dataset creation. It is not a medical device and must not be used for clinical diagnosis or patient care.
|
|
40
|
+
|
|
41
|
+
**Author:** Jarod P. Hartley ([ORCID](https://orcid.org/0009-0006-0736-5100)), Swansea University — jarodph.contact@gmail.com
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install ecg-plotter
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Dependencies, installed automatically: `numpy`, `pandas`, `scipy`, `matplotlib`, `Pillow`, `wfdb`.
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import ecg_plotter as ecg
|
|
57
|
+
|
|
58
|
+
data = ecg.load_wfdb("path/to/record") # without .hea/.dat extension
|
|
59
|
+
leads, fs = data["leads"], data["fs"]
|
|
60
|
+
|
|
61
|
+
# Option A: filter inside plot (notch/bandpass from the same kwargs)
|
|
62
|
+
ecg.plot(
|
|
63
|
+
leads,
|
|
64
|
+
"out.png",
|
|
65
|
+
inches=(9.84, 6.3), # or resolution=(2952, 1890)
|
|
66
|
+
dpi=300, # default 300
|
|
67
|
+
fs=fs,
|
|
68
|
+
colour=True, # pink RGB paper; False → 1-channel greyscale
|
|
69
|
+
id=data["record_name"],
|
|
70
|
+
anonymise=True,
|
|
71
|
+
filter=True, # apply notch/bandpass; False → footer text only
|
|
72
|
+
notch_hz=60,
|
|
73
|
+
bandpass_hz=(0.05, 150),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Option B: filter yourself first (median / custom chains)
|
|
77
|
+
# leads = ecg.apply_to_leads(leads, ecg.median_baseline, kernel_size=201)
|
|
78
|
+
# ecg.plot(leads, "out.png", ..., filter=False, notch_hz=60, bandpass_hz=(0.05, 150))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Size and calibration
|
|
82
|
+
|
|
83
|
+
Provide **either** `inches=(width, height)` **or** `resolution=(width_px, height_px)`.
|
|
84
|
+
|
|
85
|
+
- Resolution → inches via `pixels / dpi`
|
|
86
|
+
- Default DPI is **300**
|
|
87
|
+
- Footer speed / gain are computed from the **saved** geometry:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
duration_s = n_samples / fs
|
|
91
|
+
speed_mm/s = (width_px / dpi) * 25.4 / duration_s
|
|
92
|
+
gain_mm/mV = (height_px / dpi) * 25.4 / 16
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
At a fixed recording length, a larger page increases mm/s. A taller page increases mm/mV. The footer prints those computed values.
|
|
96
|
+
|
|
97
|
+
## Filters
|
|
98
|
+
|
|
99
|
+
| Function / option | Role |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `plot(..., filter=True)` | Apply `notch_hz` / `bandpass_hz` inside `plot`; with `filter=False` those kwargs only set the footer text |
|
|
102
|
+
| `bandpass` | Butterworth band-pass (default 0.05–150 Hz) |
|
|
103
|
+
| `notch` | IIR mains notch (50/60 Hz) |
|
|
104
|
+
| `median_baseline` | Median-filter baseline subtraction |
|
|
105
|
+
| `apply_to_leads` | Map any 1-D filter across leads (dict / array / DataFrame) |
|
|
106
|
+
|
|
107
|
+
Notch and band-pass run forward and back, so the QRS is not shifted in time. The default band-pass needs more than about 28 samples. A shorter trace raises `ValueError`.
|
|
108
|
+
|
|
109
|
+
## Colour vs greyscale
|
|
110
|
+
|
|
111
|
+
- `colour=True` → pink ECG-paper grid
|
|
112
|
+
- `colour=False` → greyscale grid
|
|
113
|
+
- PNG colour is RGB (3 channels); PNG greyscale is mode `L` (1 channel)
|
|
114
|
+
|
|
115
|
+
## Output formats
|
|
116
|
+
|
|
117
|
+
`plot` writes **PNG**, **PDF**, or **SVG** (inferred from the file extension, or set `format=`):
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
ecg.plot(leads, "sheet.pdf", inches=(9.84, 6.3), fs=fs) # vector PDF
|
|
121
|
+
ecg.plot(leads, "sheet.svg", inches=(9.84, 6.3), fs=fs)
|
|
122
|
+
ecg.plot(leads, "sheet.png", resolution=(2952, 1890), dpi=300, fs=fs)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## CLI
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
ecg-plotter path/to/record -o out.pdf --inches 9.84 6.3 --anonymise --filter --notch-hz 60 --bandpass-hz 0.05 150
|
|
129
|
+
ecg-plotter --jsonl examples/data/synthetic_data.jsonl --index 0 -o out.svg --greyscale
|
|
130
|
+
ecg-plotter --version
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Anonymisation
|
|
134
|
+
|
|
135
|
+
For ML dataset creation, set `anonymise=True` so the footer ID is always the fixed placeholder `15030416` instead of a real record ID (which models can overfit on).
|
|
136
|
+
|
|
137
|
+
## Input formats
|
|
138
|
+
|
|
139
|
+
Amplitudes are in millivolts. `load_wfdb` reads WFDB `p_signal`.
|
|
140
|
+
|
|
141
|
+
`plot` and `apply_to_leads` accept:
|
|
142
|
+
|
|
143
|
+
- `dict[str, array]` with lead names (`I`, `II`, … `V6`)
|
|
144
|
+
- `ndarray` of shape `(12, n)` or `(n, 12)` (default name order: `LEAD_NAMES`)
|
|
145
|
+
- `pandas.DataFrame` with lead columns:
|
|
146
|
+
- **wide**: rows = samples, columns = lead names
|
|
147
|
+
- **single-row**: one row whose lead cells contain arrays/lists (e.g. from JSONL)
|
|
148
|
+
- **12 columns and no lead names**: the columns are taken in order as `LEAD_NAMES`, and a warning is issued. Pass `lead_names=` if that order is wrong. Any other unlabelled table is rejected.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
import pandas as pd
|
|
152
|
+
|
|
153
|
+
df = pd.DataFrame(
|
|
154
|
+
{
|
|
155
|
+
"I": signal["I"],
|
|
156
|
+
"II": signal["II"],
|
|
157
|
+
"III": signal["III"],
|
|
158
|
+
"aVR": signal["aVR"],
|
|
159
|
+
"aVL": signal["aVL"],
|
|
160
|
+
"aVF": signal["aVF"],
|
|
161
|
+
"V1": signal["V1"],
|
|
162
|
+
"V2": signal["V2"],
|
|
163
|
+
"V3": signal["V3"],
|
|
164
|
+
"V4": signal["V4"],
|
|
165
|
+
"V5": signal["V5"],
|
|
166
|
+
"V6": signal["V6"],
|
|
167
|
+
}
|
|
168
|
+
) # shape: (n_samples, 12)
|
|
169
|
+
ecg.plot(df, "out.png", inches=(9.84, 6.3), fs=500)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
JSON Lines records from `load_jsonl` must include a positive ``fs`` field (Hz). The CLI uses that value unless ``--fs`` overrides it.
|
|
173
|
+
|
|
174
|
+
## Layout
|
|
175
|
+
|
|
176
|
+
Classic sheet:
|
|
177
|
+
|
|
178
|
+
```text
|
|
179
|
+
I aVR V1 V4
|
|
180
|
+
II aVL V2 V5
|
|
181
|
+
III aVF V3 V6
|
|
182
|
+
─── II rhythm ────
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Column width and sample count scale with the recording length (split into four equal segments). If the sample count is not divisible by 4, the last 1–3 samples are dropped so every column has the same duration, and a warning is raised. Sampling rate drives grid spacing (major ≈ 0.2 s).
|
|
186
|
+
|
|
187
|
+
## Examples
|
|
188
|
+
|
|
189
|
+
Sample data in `examples/data/` is **synthetic only** (no real patient recordings).
|
|
190
|
+
Regenerate with `python examples/data/generate_synthetic.py`.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
python examples/plot_from_wfdb.py
|
|
194
|
+
python examples/plot_from_dataframe.py
|
|
195
|
+
python examples/plot_from_jsonl.py
|
|
196
|
+
python examples/plot_colour_and_greyscale.py
|
|
197
|
+
python examples/plot_low_dpi.py
|
|
198
|
+
python examples/plot_nonstandard_calibration.py
|
|
199
|
+
python examples/plot_with_standalone_filters.py
|
|
200
|
+
python examples/plot_pdf_and_svg.py
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
| Script | Shows |
|
|
204
|
+
|---|---|
|
|
205
|
+
| `plot_from_wfdb.py` | Load synthetic WFDB `synth_0001`, filter inside `plot`, colour PNG |
|
|
206
|
+
| `plot_from_dataframe.py` | Build an explicit lead DataFrame and plot it |
|
|
207
|
+
| `plot_from_jsonl.py` | Load the first record from `synthetic_data.jsonl` |
|
|
208
|
+
| `plot_colour_and_greyscale.py` | Inches vs resolution, colour vs greyscale |
|
|
209
|
+
| `plot_low_dpi.py` | Same physical size at 150 DPI (smaller pixel dimensions) |
|
|
210
|
+
| `plot_nonstandard_calibration.py` | Non-standard sizes. Footers report about 50 mm/s with 5 mm/mV, and about 20 mm/s with 12.5 mm/mV |
|
|
211
|
+
| `plot_with_standalone_filters.py` | Filter with `apply_to_leads`, then `plot(..., filter=False)` |
|
|
212
|
+
| `plot_pdf_and_svg.py` | Vector outputs: colour PDF and greyscale SVG |
|
|
213
|
+
|
|
214
|
+
Outputs are written to `examples/output/`.
|
|
215
|
+
|
|
216
|
+

|
|
217
|
+
|
|
218
|
+
## How to cite
|
|
219
|
+
|
|
220
|
+
If you use this software, please cite it:
|
|
221
|
+
|
|
222
|
+
Hartley, J. P. (2026). *ecg-plotter*. Zenodo. https://doi.org/10.5281/zenodo.23984258
|
|
223
|
+
|
|
224
|
+
## Tests
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
pip install -e ".[dev]"
|
|
228
|
+
pytest
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
GitHub Actions runs these commands on Python 3.9, 3.11, and 3.12.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# ECG Plotter
|
|
2
|
+
|
|
3
|
+
Plot 12-lead ECGs as classic clinical-style sheets with **settable physical size or pixel resolution**, **DPI**, optional **signal filters**, and accurate **mm/s / mm/mV** footer reporting.
|
|
4
|
+
|
|
5
|
+
**Research / visualisation only — not for diagnostic use.** This package renders ECG traces for research, teaching, and ML dataset creation. It is not a medical device and must not be used for clinical diagnosis or patient care.
|
|
6
|
+
|
|
7
|
+
**Author:** Jarod P. Hartley ([ORCID](https://orcid.org/0009-0006-0736-5100)), Swansea University — jarodph.contact@gmail.com
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install ecg-plotter
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Dependencies, installed automatically: `numpy`, `pandas`, `scipy`, `matplotlib`, `Pillow`, `wfdb`.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import ecg_plotter as ecg
|
|
23
|
+
|
|
24
|
+
data = ecg.load_wfdb("path/to/record") # without .hea/.dat extension
|
|
25
|
+
leads, fs = data["leads"], data["fs"]
|
|
26
|
+
|
|
27
|
+
# Option A: filter inside plot (notch/bandpass from the same kwargs)
|
|
28
|
+
ecg.plot(
|
|
29
|
+
leads,
|
|
30
|
+
"out.png",
|
|
31
|
+
inches=(9.84, 6.3), # or resolution=(2952, 1890)
|
|
32
|
+
dpi=300, # default 300
|
|
33
|
+
fs=fs,
|
|
34
|
+
colour=True, # pink RGB paper; False → 1-channel greyscale
|
|
35
|
+
id=data["record_name"],
|
|
36
|
+
anonymise=True,
|
|
37
|
+
filter=True, # apply notch/bandpass; False → footer text only
|
|
38
|
+
notch_hz=60,
|
|
39
|
+
bandpass_hz=(0.05, 150),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Option B: filter yourself first (median / custom chains)
|
|
43
|
+
# leads = ecg.apply_to_leads(leads, ecg.median_baseline, kernel_size=201)
|
|
44
|
+
# ecg.plot(leads, "out.png", ..., filter=False, notch_hz=60, bandpass_hz=(0.05, 150))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Size and calibration
|
|
48
|
+
|
|
49
|
+
Provide **either** `inches=(width, height)` **or** `resolution=(width_px, height_px)`.
|
|
50
|
+
|
|
51
|
+
- Resolution → inches via `pixels / dpi`
|
|
52
|
+
- Default DPI is **300**
|
|
53
|
+
- Footer speed / gain are computed from the **saved** geometry:
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
duration_s = n_samples / fs
|
|
57
|
+
speed_mm/s = (width_px / dpi) * 25.4 / duration_s
|
|
58
|
+
gain_mm/mV = (height_px / dpi) * 25.4 / 16
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
At a fixed recording length, a larger page increases mm/s. A taller page increases mm/mV. The footer prints those computed values.
|
|
62
|
+
|
|
63
|
+
## Filters
|
|
64
|
+
|
|
65
|
+
| Function / option | Role |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `plot(..., filter=True)` | Apply `notch_hz` / `bandpass_hz` inside `plot`; with `filter=False` those kwargs only set the footer text |
|
|
68
|
+
| `bandpass` | Butterworth band-pass (default 0.05–150 Hz) |
|
|
69
|
+
| `notch` | IIR mains notch (50/60 Hz) |
|
|
70
|
+
| `median_baseline` | Median-filter baseline subtraction |
|
|
71
|
+
| `apply_to_leads` | Map any 1-D filter across leads (dict / array / DataFrame) |
|
|
72
|
+
|
|
73
|
+
Notch and band-pass run forward and back, so the QRS is not shifted in time. The default band-pass needs more than about 28 samples. A shorter trace raises `ValueError`.
|
|
74
|
+
|
|
75
|
+
## Colour vs greyscale
|
|
76
|
+
|
|
77
|
+
- `colour=True` → pink ECG-paper grid
|
|
78
|
+
- `colour=False` → greyscale grid
|
|
79
|
+
- PNG colour is RGB (3 channels); PNG greyscale is mode `L` (1 channel)
|
|
80
|
+
|
|
81
|
+
## Output formats
|
|
82
|
+
|
|
83
|
+
`plot` writes **PNG**, **PDF**, or **SVG** (inferred from the file extension, or set `format=`):
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
ecg.plot(leads, "sheet.pdf", inches=(9.84, 6.3), fs=fs) # vector PDF
|
|
87
|
+
ecg.plot(leads, "sheet.svg", inches=(9.84, 6.3), fs=fs)
|
|
88
|
+
ecg.plot(leads, "sheet.png", resolution=(2952, 1890), dpi=300, fs=fs)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## CLI
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
ecg-plotter path/to/record -o out.pdf --inches 9.84 6.3 --anonymise --filter --notch-hz 60 --bandpass-hz 0.05 150
|
|
95
|
+
ecg-plotter --jsonl examples/data/synthetic_data.jsonl --index 0 -o out.svg --greyscale
|
|
96
|
+
ecg-plotter --version
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Anonymisation
|
|
100
|
+
|
|
101
|
+
For ML dataset creation, set `anonymise=True` so the footer ID is always the fixed placeholder `15030416` instead of a real record ID (which models can overfit on).
|
|
102
|
+
|
|
103
|
+
## Input formats
|
|
104
|
+
|
|
105
|
+
Amplitudes are in millivolts. `load_wfdb` reads WFDB `p_signal`.
|
|
106
|
+
|
|
107
|
+
`plot` and `apply_to_leads` accept:
|
|
108
|
+
|
|
109
|
+
- `dict[str, array]` with lead names (`I`, `II`, … `V6`)
|
|
110
|
+
- `ndarray` of shape `(12, n)` or `(n, 12)` (default name order: `LEAD_NAMES`)
|
|
111
|
+
- `pandas.DataFrame` with lead columns:
|
|
112
|
+
- **wide**: rows = samples, columns = lead names
|
|
113
|
+
- **single-row**: one row whose lead cells contain arrays/lists (e.g. from JSONL)
|
|
114
|
+
- **12 columns and no lead names**: the columns are taken in order as `LEAD_NAMES`, and a warning is issued. Pass `lead_names=` if that order is wrong. Any other unlabelled table is rejected.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import pandas as pd
|
|
118
|
+
|
|
119
|
+
df = pd.DataFrame(
|
|
120
|
+
{
|
|
121
|
+
"I": signal["I"],
|
|
122
|
+
"II": signal["II"],
|
|
123
|
+
"III": signal["III"],
|
|
124
|
+
"aVR": signal["aVR"],
|
|
125
|
+
"aVL": signal["aVL"],
|
|
126
|
+
"aVF": signal["aVF"],
|
|
127
|
+
"V1": signal["V1"],
|
|
128
|
+
"V2": signal["V2"],
|
|
129
|
+
"V3": signal["V3"],
|
|
130
|
+
"V4": signal["V4"],
|
|
131
|
+
"V5": signal["V5"],
|
|
132
|
+
"V6": signal["V6"],
|
|
133
|
+
}
|
|
134
|
+
) # shape: (n_samples, 12)
|
|
135
|
+
ecg.plot(df, "out.png", inches=(9.84, 6.3), fs=500)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
JSON Lines records from `load_jsonl` must include a positive ``fs`` field (Hz). The CLI uses that value unless ``--fs`` overrides it.
|
|
139
|
+
|
|
140
|
+
## Layout
|
|
141
|
+
|
|
142
|
+
Classic sheet:
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
I aVR V1 V4
|
|
146
|
+
II aVL V2 V5
|
|
147
|
+
III aVF V3 V6
|
|
148
|
+
─── II rhythm ────
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Column width and sample count scale with the recording length (split into four equal segments). If the sample count is not divisible by 4, the last 1–3 samples are dropped so every column has the same duration, and a warning is raised. Sampling rate drives grid spacing (major ≈ 0.2 s).
|
|
152
|
+
|
|
153
|
+
## Examples
|
|
154
|
+
|
|
155
|
+
Sample data in `examples/data/` is **synthetic only** (no real patient recordings).
|
|
156
|
+
Regenerate with `python examples/data/generate_synthetic.py`.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
python examples/plot_from_wfdb.py
|
|
160
|
+
python examples/plot_from_dataframe.py
|
|
161
|
+
python examples/plot_from_jsonl.py
|
|
162
|
+
python examples/plot_colour_and_greyscale.py
|
|
163
|
+
python examples/plot_low_dpi.py
|
|
164
|
+
python examples/plot_nonstandard_calibration.py
|
|
165
|
+
python examples/plot_with_standalone_filters.py
|
|
166
|
+
python examples/plot_pdf_and_svg.py
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
| Script | Shows |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `plot_from_wfdb.py` | Load synthetic WFDB `synth_0001`, filter inside `plot`, colour PNG |
|
|
172
|
+
| `plot_from_dataframe.py` | Build an explicit lead DataFrame and plot it |
|
|
173
|
+
| `plot_from_jsonl.py` | Load the first record from `synthetic_data.jsonl` |
|
|
174
|
+
| `plot_colour_and_greyscale.py` | Inches vs resolution, colour vs greyscale |
|
|
175
|
+
| `plot_low_dpi.py` | Same physical size at 150 DPI (smaller pixel dimensions) |
|
|
176
|
+
| `plot_nonstandard_calibration.py` | Non-standard sizes. Footers report about 50 mm/s with 5 mm/mV, and about 20 mm/s with 12.5 mm/mV |
|
|
177
|
+
| `plot_with_standalone_filters.py` | Filter with `apply_to_leads`, then `plot(..., filter=False)` |
|
|
178
|
+
| `plot_pdf_and_svg.py` | Vector outputs: colour PDF and greyscale SVG |
|
|
179
|
+
|
|
180
|
+
Outputs are written to `examples/output/`.
|
|
181
|
+
|
|
182
|
+

|
|
183
|
+
|
|
184
|
+
## How to cite
|
|
185
|
+
|
|
186
|
+
If you use this software, please cite it:
|
|
187
|
+
|
|
188
|
+
Hartley, J. P. (2026). *ecg-plotter*. Zenodo. https://doi.org/10.5281/zenodo.23984258
|
|
189
|
+
|
|
190
|
+
## Tests
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pip install -e ".[dev]"
|
|
194
|
+
pytest
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
GitHub Actions runs these commands on Python 3.9, 3.11, and 3.12.
|
|
@@ -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__))
|