Clinically-grounded discrete tokenization and per-frame wave segmentation for electrocardiograms
Project description
OpenECG
Clinically-grounded ECG wave segmentation that ships an int8 TFLite model in 1.5 MB.
OpenECG ships:
- A pretrained per-frame P / QRS / T classifier with a parallel boundary-regression head — 0.99 M params, trained on LUDB + QTDB + ISP + a synthetic AV-block mix. Beats NeuroKit2 DWT and WTdelineator (Martínez 2004) on every public benchmark we tested (see Performance).
- A TFLite int8 deploy artifact (~1.5 MB) bundled inside the wheel
so
Inference()works with no extra downloads — usable on Android, iOS, Raspberry Pi, AED-class embedded targets. Inference uses onlytflite-runtime(~5 MB) + numpy; no PyTorch, no TensorFlow. - A 13-symbol RLE token format (
openecg.codec,openecg.vocab) that compresses 12-lead ECGs into a clinically interpretable sequence. - Loaders for LUDB, QTDB, ISP, BUT PDB, PTB-XL and the synthetic AV-block dataset so every number in this README is reproducible from a clean clone.
Install
# Core (numpy-only): tokenizer + signal processing primitives
pip install openecg
# Inference (ships a 1.5 MB int8 TFLite model — no torch needed)
pip install "openecg[deploy]"
# Training / evaluation (loaders, stage2 transformer, optional NeuroKit2)
pip install "openecg[loaders,stage2]"
PyTorch is only required for training and for [deploy-export].
The default inference path uses TFLite via tflite-runtime.
Quickstart
Boundary detection from a numpy signal
import numpy as np
from openecg.deploy import Inference
# Loads the bundled v56c int8 TFLite model — no download needed.
det = Inference()
# Any 1-D float array at 250 Hz; e.g. one lead of a 10-second clip.
ecg_250hz = np.load("my_ecg.npy") # shape (N,) at 250 Hz
# Slides a 10-s window with no overlap; trailing samples are zero-padded.
windows = det.predict(ecg_250hz)
for w in windows:
for b in w:
print(b.name, b.start, b.end) # "P 145 215", "QRS 320 365", ...
b.start / b.end are sample-indexed (0-based) inside the 10-s
window. Each window yields up to ~50 boundaries (P + QRS + T per beat).
The model expects single-channel input at 250 Hz — resample
upstream if your source is 500 / 1000 Hz.
Tokenize a hand-built event stream
from openecg import codec, vocab
events = [
(vocab.ID_ISO, 200), (vocab.ID_P, 80), (vocab.ID_ISO, 80),
(vocab.ID_Q, 20), (vocab.ID_R, 40), (vocab.ID_S, 40),
(vocab.ID_ISO, 120), (vocab.ID_T, 200), (vocab.ID_ISO, 220),
]
packed = codec.encode(events) # uint16 RLE pack
print(codec.render_compact(events)) # one char per event
print(codec.decode(packed) == events) # round-trip
Layered codec — three label streams, one call
import openecg
model = openecg.load_codec() # bundled pure-real codec (frame/beat/rhythm)
codec = openecg.encode(ecg_500hz, fs=500, model=model) # 10-s window @ 500 Hz
codec.channels # uint8 (3, 5000) at sample resolution
codec.frame, codec.beat, codec.rhythm # per-layer views
codec.events("beat", drop_class=0) # [(start, end, class_id), ...]
codec.to_codec_string(layer="frame") # ASCII rendering
The bundled codec (openecg.load_codec(), 1.16 M params) is trained on
pure real, human-expert annotations only — no synthetic, no pseudo-labels.
Held-out test: beat sinus F1 0.985 / VPC 0.884, rhythm sinus 0.899 / AFib 0.791.
bbb / paced / avb rhythm are experimental (weak recall) — see the
model card. Deploy artifacts (ONNX
fp32 + int8) ship in openecg/models/.
The three channels run in parallel at the input signal's sample rate. Each layer is a separate label stream at a different abstraction — wave boundaries on the bottom, beat type per QRS in the middle, rhythm class on top — and segment starts / ends are recoverable from class transitions on any single channel.
| Layer | Granularity | Classes | Convention aligned with |
|---|---|---|---|
0 frame |
per sample | other / P / QRS / T / paced_QRS | LUDB, QTDB, ISP wave annotation |
1 beat |
per QRS span | none / sinus / VPC / paced / fusion / unknown | WFDB AAMI EC57 beat codes (N/V/F///Q) |
2 rhythm |
per sample (sub-window) | sinus / AVB / paced / AFib / BBB / ventricular | WFDB rhythm aux notes (N (AFIB (VT ... |
VTach, AFib, and similar rhythm-level events sit on layer 2 — they're
not a new frame class, matching MIT-BIH / WFDB convention. See
openecg/layered.py for the predictor-injection points.
Continuous-use codec — 2-s edge guard
Predictions in the outer 2 seconds of any 10-s window have limited past / future context. Held-out evaluation and stream stitching exclude these samples so adjacent windows can be concatenated seamlessly:
codec = openecg.encode(ecg_250hz) # 10-s window
codec.eval_mask # bool[2500], True only in [2s, 8s]
codec.events("beat", eval_only=True) # restricts to the inner band
inner = codec.inner() # sliced copy, margin=0
# Long signal -> sliding inference with stride = window - 2*margin = 6 s.
# Every emitted sample had ≥2 s of past AND ≥2 s of future context.
holter_codec = openecg.encode_stream(long_signal_250hz) # arbitrary length
The same philosophy applies to training and evaluation: loss is computed on the full 10-s window (the model needs context to learn), but held-out metrics are masked to the inner 6-s band. This is the only way to get a codec whose output is continuously stitchable without boundary artefacts — a prerequisite for any honest foundation-model claim.
Performance
The shipped model is v56c — vit_transformer_noaux_1ch, L8/d=128
(0.99 M params), trained with soft-T α=0.9 on LUDB + QTDB + ISP +
synthetic AV-block data and rank-normalised input. The exported TFLite
int8 is bit-equivalent (Δ macro-F1 = -0.0025 vs torch fp32).
Macro-F1 across the six P / QRS / T on/off boundaries, with Martínez 2004 tolerances (P 50 ms, QRS 40 ms, T_on 50 ms, T_off 100 ms), lead II only:
| Dataset (n records) | openecg v56c | NeuroKit2 DWT | WTdelineator |
|---|---|---|---|
| LUDB val (41) | 0.963 | 0.788 | 0.596 |
| ISP test (72) | 0.971 | 0.703 | 0.604 |
| QTDB T-subset (44) | 0.908 | 0.605 | 0.535 |
openecg also hits ≤16 ms median timing error on every boundary,
meeting the clinical 20 ms spec target — the wavelet baselines miss it
on T_off (~44 ms) and on every P boundary. Full per-boundary
F1 / Se / P+ / SD / median error tables are in
docs/benchmarks/v56c_vs_baselines.md.
Representative cases
Each figure overlays the four detectors on the same ECG strip from each benchmark dataset, lead II. P = red, QRS = blue, T = green; shaded regions are the predicted wave durations, vertical ticks at the top mark predicted onsets and offsets.
LUDB val record 16 — clean sinus rhythm with prominent P / QRS / T; openecg matches the cardiologist annotation, NeuroKit2 places P boundaries off the true wave and the WTdelineator drops most P / T detections after the first beat.
ISP test record 2 — dense rhythm with subtle P and biphasic T; openecg locks onto every beat, NeuroKit2 misses the first beat entirely and shifts QRS/T positions, WTdelineator's T spans run far beyond the true T-wave.
QTDB record sel100 (MLII) — low-amplitude T waves, the regime where wavelet methods struggle. openecg keeps tight P and QRS spans on every beat; NeuroKit2 produces sporadic T detections far from the T wave; WTdelineator drops two of the four beats.
Reproduce these figures:
python -m scripts.viz_benchmark_v56c
# writes docs/figures/v56c_vs_baselines_{ludb,isp,qtdb}.png
python -m scripts.benchmark_v56c --leads ii --out out/benchmark_v56c.json
Deploy footprint
| Path | Size | Macro-F1 | Latency / 10-s window |
|---|---|---|---|
| Torch fp32 (training) | 4.0 MB | 0.9299 | 44 ms |
| TFLite fp32 | 4.4 MB | 0.9299 | 44 ms |
| TFLite int8 (bundled) | 1.5 MB | 0.9274 | 44 ms |
We benchmarked ExecuTorch on the same checkpoint and TFLite int8 won
by 3.5× on latency and -0.004 less F1 loss — TFLite stays canonical
until ExecuTorch ships a weight-only int8 recipe. See
docs/benchmarks/v56c_vs_baselines.md
for the full backend comparison.
Optional extras
pyproject.toml declares optional dependency groups so each install is
minimal:
[deploy]—tflite-runtime+numpy; what end users install. Pulls the bundled.tflitemodel from the wheel; no PyTorch needed.[loaders]—wfdb+scipyfor LUDB / ISP / QTDB / BUT PDB / PTB-XL.[stage2]— torch + transformers for the training-time backbones.[delineate]— NeuroKit2 + scipy for the baseline comparison.[deploy-export]— torch + ai-edge-torch for re-exporting the.tflitefrom a torch checkpoint (Linux / WSL only).
pip install "openecg[deploy]" # end-user inference
pip install "openecg[loaders,delineate]" # reproduce the benchmark table
Toward an ECG foundation model
OpenECG's design treats the three-channel layered codec as the output interface of a single foundation model:
Wave → beat → rhythm, all at sample resolution, stitchable across windows, learned jointly from every public corpus that carries the matching label level.
The three pieces required to call this a foundation model are:
- A common output schema across datasets. No dataset is large enough on its own — LUDB has wave labels for 200 records, MIT-BIH Arrhythmia has beat labels for 48 — but each is a partial label of the same codec. Training is multi-task with a per-sample loss mask over the layers each dataset annotates.
- A continuously-stitchable output. Honest inference on Holter or 24-h streams requires that adjacent windows produce a seamless codec. The 2-s edge guard (see above) is the mechanism: training and evaluation only count the inner band, so the model is rewarded for producing predictions that are stable when re-evaluated 2 s later with new future context.
- Convention alignment. Frame layer matches LUDB / QTDB / ISP; beat layer matches WFDB AAMI EC57 codes; rhythm layer matches WFDB aux-note rhythms. A foundation model that invents its own taxonomy is unusable downstream.
Public datasets, mapped to codec layers
The annotation level dictates which layer's loss is unmasked for each record. Datasets carrying both beat and rhythm annotations (in bold below) supervise two layers simultaneously and are the spine of the multi-task pool.
| Dataset | Records | Hours | Layer 0 (frame) | Layer 1 (beat) | Layer 2 (rhythm) |
|---|---|---|---|---|---|
| LUDB | 200 | 0.6 | ✓ | ||
| QTDB | 105 | 0.9 | ✓ | ✓ | |
| ISP | 160 | — | ✓ | ||
| BUT PDB | 50 | 1.7 | ✓ (P-peak) | ✓ (AVB) | |
| MIT-BIH Arrhythmia | 48 | 24 | ✓ | ✓ | |
| MIT-BIH SVDB | 78 | 39 | ✓ | ✓ | |
| MIT-BIH LTDB | 7 | ~120 | ✓ | ✓ | |
| MIT-BIH NSR | 18 | 432 | ✓ (sinus) | ||
| MIT-BIH AFDB | 25 | 250 | ✓ (AFib) | ||
| MIT-BIH MVE (VFDB) | 22 | 11 | ✓ | ✓ (VT/VF) | |
| MIT-BIH Polysomnographic | 18 | ~110 | ✓ | ||
| Fantasia | 40 | 80 | ✓ (sinus) | ||
| Sudden Cardiac Death Holter | 23 | 552 | ✓ | ✓ | |
| INCART (12-lead) | 75 | 37 | ✓ | ||
| European ST-T | 90 | 180 | ✓ | ||
| BIDMC CHF | 15 | 300 | ✓ | ||
| PTB-XL | 21,837 | 61 | ✓ (SCP, window) | ||
| Chapman-Shaoxing 12-lead | 10,646 | 30 | ✓ (window) |
Excluded by design. CinC 2021 (SNOMED codes aggregated from six heterogeneous sources), Icentia 11k (model-predicted pseudo-labels), and CODE-15% (AI-derived binary diag flags) are deliberately not in the foundation pool. The codec layers are defined relative to human-expert annotation conventions (cardiologist wave boundaries, AAMI beat codes, WFDB rhythm aux-notes); mixing in machine-derived labels would erode the very ground truth the codec is supposed to represent. These corpora remain useful for downstream external validation but not for training the codec heads.
All listed corpora are PhysioNet- or Zenodo-distributed (CC-BY /
ODC-BY). Loaders for LUDB / QTDB / ISP / BUT PDB / PTB-XL ship in
openecg.*; the remaining ones are pulled in over the standard WFDB
interface during multi-task training.
The dataset survey above is mirrored in
G:\Shared drives\Datasets\ECG\DATASETS.md for the SNUH research
group; the public OpenECG repo will publish a smaller DATASETS.md
restricted to the open corpora once the multi-task model ships.
Status
| Component | Today | Roadmap (v0.5+) |
|---|---|---|
| Layer 0 — frame delineator | v56c TFLite int8 (bundled, 1.5 MB) | Re-export v56d (AVB-augmented) once torch.int1 mismatch resolved |
| Layer 1 — per-beat classifier | rule-stub (paced / VT-rhythm fallback) | Multi-head v57 trained on MIT-BIH Arrhythmia + SVDB + LTDB + INCART + Fantasia |
| Layer 2 — rhythm classifier | 6-class CNN (openecg.rhythm), window-constant |
Per-patch head from multi-head v57 → sub-window rhythm segmentation |
| 2-s edge-guarded codec | eval_mask / eval_only / encode_stream |
Used as the gating metric for all v57 training checkpoints |
| Continuous-stitch inference | openecg.encode_stream(signal) works today |
Native multi-window deploy path in TFLite |
The single-pass multi-head architecture lives at
openecg.stage2.model_variants.FrameClassifierTransformerLayered1Ch
(arch id vit_transformer_layered_1ch). It loads from the v56d weight
file via strict=False; the new beat / rhythm heads start zero-init so
the untrained model emits safe defaults (BEAT_NONE / RHYTHM_SINUS)
until per-layer supervision lands.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file openecg-0.5.0.tar.gz.
File metadata
- Download URL: openecg-0.5.0.tar.gz
- Upload date:
- Size: 8.9 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6c4f822c28a0dbc9f3878afd4b0c64386fee9db146595a27fb4aa7188bf7685a
|
|
| MD5 |
2095d83c828d522ded6feebcb2119ca9
|
|
| BLAKE2b-256 |
82d35976ffb0a815d7c99c09383ee9233b7556bd9b51da7b961da132145ef53f
|
File details
Details for the file openecg-0.5.0-py3-none-any.whl.
File metadata
- Download URL: openecg-0.5.0-py3-none-any.whl
- Upload date:
- Size: 9.0 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
da076cc746e10f0c1cf3a5ed372962332da155a893e4a9ac02be9b350f03015b
|
|
| MD5 |
b1be5c40cc4bb07c7917a7fc6afb781f
|
|
| BLAKE2b-256 |
06d3981a7460b3c0a337bfe4ca92e4cd4e8e2e3626693b3a0973a7bb03cd22e1
|