This Python tool reads and writes raw 12-lead ECG recordings across common research formats and converts between them if needed. Further, the tool allows to apply several pre-processing steps if needed. Supported ECG formats for reading and writing are MATLAB .mat, WFDB .dat/.hea, CSV, ASC, DICOM waveform .dcm, XML, and HL7 aECG. The optional signal-processing pipeline includes e.g. notch-filtering, bandpass, wavelet baseline removal, EMD denoising, and resampling. The app also is able to derive missing limb leads and optionally to anonymizes patient data. Besides the individual ECG output files one consolidated metadata table is created.
ECG files (mat / wfdb / csv / asc / dcm / xml / hl7) ──► filtered, resampled 12-lead output (csv / xml / dcm / hl7 / wfdb / mat / asc) + ecg_summary.csv (metadata)
NEW [09/2026] V1.2.0: Pacemaker Spike Detection
- Downloads
- Features
- Quick start
- Output formats
- How it works
- Technical stack
- Configuration
- Project structure
- Debugging
- Limitations and data-quality notes
- License
Ready-to-use executables of the cross-platform GUI app are built by GitHub Actions and published in the Releases tab:
| Platform | Artifact |
|---|---|
| Windows x86_64 | ECG-Format-Converter-windows-x86_64.zip |
| macOS Apple Silicon (arm64) | ECG-Format-Converter-macos-arm64.app.zip |
| Linux x86_64 | ECG-Format-Converter-linux-x86_64.zip |
Download, unzip, and run. No Python installation required. Builds are produced with PyInstaller.
- Seven input and output formats: MATLAB
.mat(keystemplate/val/ecg), WFDB.dat/.heapairs, CSV, ASC, DICOM waveform.dcm, XML (wavedata/ECG_RHYTHMSschema withpatdatademographics), and HL7 aECG (AnnotatedECG) - optional Signal-processing pipeline: IIR notch (50 or 60 Hz), Butterworth bandpass 0.5-40 Hz, wavelet baseline removal (db4, level 8), EMD denoising (see below); steps run in the order given
- optional Resampling to any target rate via FFT or linear interpolation
- optional Lead derivation: missing limb leads are computed from Einthoven and Goldberger relations (I, II, III, aVR, aVL, aVF); every output carries the standard 12 leads in fixed order, underivable leads are zero-filled and reported
- optional Pacemaker Detection using raw ECG data before application of any filter
- Metadata carried through: patient ID, name, birth date, sex, age, weight, height, pacemaker flag, exam date and time, P-wave annotations, all collected in
ecg_summary.csv - opptional Anonymization* flag that drops patient ID, names, and birth date from outputs and the summary
- Metadata mapping for
.ascbatches: an external CSV with ID, sex, and age is joined onto the summary by the numeric filename prefix - Cross-platform desktop GUI with live progress, per-file log, processed/skipped/failed counters, a 12-lead waveform viewer in the standard 6x2 clinical layout, and a sortable summary table
- Batch behavior: each file fails loudly on its own; one broken export never stops the run
- Download the executable for your OS (Windows/MacOS/Linux) from the Releases page (or run
python ui.py). - Select an input folder or single file. The app scans it immediately and shows a per-format count. You can also drop a file or folder anywhere on the window.
- Select an output folder and the target format (CSV, XML, DICOM, HL7 aECG, WFDB, MATLAB, or ASC).
- Tick the filter pipeline steps you want. Under Sampling, tick Input sampling rate and pick (or type) the rate to read CSV/ASC files at — the panel tells you how many of the selected files need it — and enable resampling if needed. Anonymize removes ID, name, and birth date.
- Hit Start processing. Progress, per-file log lines, and counters update live; when the run ends, pick any record to inspect its 12-lead plot or open the summary table. Cancel stops after the current file and keeps everything already written.
git clone https://github.com/CeBiDa/ECG-Format-Converter && cd ECG-Format-Converter
# create & activate a virtual environment (recommended)
python3 -m venv .venv
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\activate # Windows (cmd: .venv\Scripts\activate.bat)
pip install -r requirements.txt # Python 3.10+
# convert + pre-process: everything in a folder to DICOM, notch + bandpass, resample to 250 Hz
python3 ecg_processor.py data/input_ecgs --format dcm --output data/processed_ecgs \
--fs 500 --pipeline notch bandpass --notch 50 --resample 250 --resample_method fft
# convert a folder to MATLAB .mat, bandpass, with 500 Hz assumed for the rate-less files
python3 ecg_processor.py data/input_ecgs --format mat --output data/processed_ecgs \
--fs 500 --pipeline bandpass
# anonymize while converting
python3 ecg_processor.py my_exports/ --format csv --output out/ --anonymize
Run python3 ecg_processor.py with no arguments for interactive prompts, and --help for the full flag list (--metadata, --no-override, --config). Defaults for every option live in config.ini.
from runner.execution_runner import ExecutionRunner
runner = ExecutionRunner(
config_path="config.ini", # optional, defaults to the repo config
path_source="path/to/input_folder", # file or folder
path_sink="path/to/output_folder",
)
runner.output_format = "dcm" # csv | xml | dcm | hl7 | wfdb | mat | asc
runner.pipeline = ["notch", "bandpass"] # any of notch, bandpass, wavelet, emd
runner.emd_options["method"] = "emd" # tuning for the emd step, see [emd] in config.ini
runner.resample = 250
runner.default_fs = 500 # used when a file carries no rate
runner.anonymize = False
runner.keep_signals = True # retain waveforms in the returned dict
records = runner.run()
# records: {record_id: {"signals": (12, n) float32 ndarray in the order
# I,II,III,aVR,aVL,aVF,V1..V6, "metadata": {...},
# "output_file": "<path>"}}
# runner.stats -> {"success": ..., "skipped": ..., "failed": ...}
# runner.summary_path -> path of ecg_summary.csvrun() also takes progress_cb(done, total, filename), log_cb(text), and cancel_cb() -> bool for embedding in other tools; the GUI is built on exactly this interface.
The repository ships agent-ready instructions. CLAUDE.md provides project context and commands for coding agents, and skills/ecg-format-converter/SKILL.md is a portable agent skill that lets an agent operate the converter autonomously: setup, conversion of arbitrary folders, output verification, and troubleshooting.
The skill is already installed for both supported harnesses, so cloning the repo is enough — no copying required:
| Harness | Path | Notes |
|---|---|---|
| Claude Code | .claude/skills/ecg-format-converter/ |
Picked up automatically in this repo |
| opencode | .opencode/skills/ecg-format-converter/ |
opencode also reads .claude/skills/ as a documented fallback |
To make the skill available in every project, install it into your user-level skill directories (~/.claude/skills/ and ~/.config/opencode/skills/):
sh skills/sync.sh --userBoth in-repo copies are generated from the canonical file under skills/. After editing it, run sh skills/sync.sh to propagate the change, or sh skills/sync.sh --check (exit code 1 on drift) to verify them in CI. opencode requires a name: field in the skill front matter; Claude Code does not, but accepts it, so the single file works unmodified in both.
For each input ECG file the tool writes one output ECG file with the same stem and the chosen extension — except WFDB, which writes the record pair <stem>.dat + <stem>.hea. All outputs carry 12 leads in the fixed order I,II,III,AVR,AVL,AVF,V1,V2,V3,V4,V5,V6; leads that were neither recorded nor derivable are zero-filled.
- CSV: n_samples rows x 12 columns, header = lead names, one amplitude value per cell.
- XML: a
<metadata>block (patient, exam, lead provenance) followed by one<lead name="...">element per lead with the comma-separated waveform. - DICOM: 12-lead ECG waveform object (SOP class 1.2.840.10008.5.1.4.1.1.9.1.1), 16-bit samples in microvolts, channel definitions labeled per lead, patient and study modules filled from the source metadata, P-wave annotations (
pon/poff/pdur) when the source had them. - HL7 aECG:
AnnotatedECGdocument with patient demographics, a relative time sequence carrying the sampling period, and oneSLIST_PQsequence per lead in microvolts. - WFDB: a PhysioNet record pair —
<stem>.heaheader plus<stem>.datsamples, format 16, one signal per lead. The header unit ismVoruVby the same amplitude heuristic the DICOM and HL7 writers use, with the ADC gain set so one unit is 1 µV. A sampling rate is mandatory (it goes into the header), so rate-less inputs are skipped unless--fsis given. Record names take only letters, digits,-and_; any other character in the file stem becomes_, and the run log says so. - MATLAB
.mat: a SciPy-written MAT file holding the 12 x n_samples matrix under the keyval, plus whichever offs,study_id,age,sexand the P-wave annotationspon/poff/pdurthe source carried — keys with no value are dropped instead of written out. - ASC: the CSV layout under the
.ascextension — comma-separated, lead names in the header row, n_samples rows x 12 columns — which is what the ASC reader reads back.
CSV and ASC store waveforms only, and MAT just the handful of fields above, so the demographics that the XML, DICOM, and HL7 writers carry survive a conversion to those three formats only in the summary table.
A consolidated ecg_summary.csv is written next to the outputs, one row per processed file, with these 21 columns:
filename, patient_id, patient_firstname, patient_lastname, patient_birthdate, patient_sex, exam_date, exam_time, patient_age, n_leads, n_samples, sampling_freq, signal_names, pon, poff, pdur, patient_weight, patient_height, pacemaker, original_leads, calculated_leads
Fields absent from a given source stay empty. Note that name, ID, and birth date are personal health information; handle outputs accordingly or run with --anonymize.
┌──────────────────────────────────────────────────┐
ECG file ──► reader │ mat | wfdb | csv | asc | dcm | xml | hl7 │
│ waveforms (leads x samples) + metadata dict │
└───────────────────┬──────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────────────┐
│ 1. anonymize (optional): drop ID, names, birth date │
│ 2. pipeline (optional, in order): notch -> bandpass -> │
│ wavelet baseline removal -> EMD denoise │
│ 3. resample (optional): FFT or linear interpolation to target Hz │
│ 4. impute NaN gaps by linear interpolation │
│ 5. derive missing limb leads (Einthoven / Goldberger) │
│ 6. reorder to I,II,III,aVR,aVL,aVF,V1..V6; zero-fill the rest │
└───────────────────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ writer: csv | xml | dcm | hl7 | wfdb | mat | asc │
│ + one summary row per file -> ecg_summary.csv │
└──────────────────────────────────────────────────┘
Plain XML-to-DICOM conversions without pipeline or resampling take a direct fast path that copies the vendor waveform straight into the DICOM object.
Empirical Mode Decomposition splits each lead into intrinsic mode functions (IMFs), from the fastest oscillation down to a monotonic trend. The naive recipe — throw away IMF 1 — throws away the steep flanks of every QRS with it and still leaves the baseline wander in place. Each component is instead classified by its mean frequency and handled on its own terms:
| Component | Treatment |
|---|---|
Above noise_cut (20 Hz) |
Interval thresholding: the IMF is cut at its zero crossings and each oscillation is kept or dropped as a whole, so transients that stand above the noise floor survive and the rest is zeroed |
| Inside a QRS complex | The threshold is relaxed by qrs_threshold_scale across a window around every detected R peak, because a QRS is a genuine burst of high-frequency energy |
Below baseline_cut (0.5 Hz), and the trend |
Dropped when remove_baseline is on — this is what removes baseline wander |
| Everything in between | Passed through untouched: P, QRS, T and the ST segment |
The threshold per component is threshold x sigma x sqrt(2 ln N), where sigma is a median
absolute deviation tracked over a sliding adaptive_window. MAD reads the noise floor, not
the peaks: a sparse burst (QRS, pacing spike) barely moves it, so the burst survives, while a
dense oscillation (mains hum, EMG) raises it until the whole component is thresholded away.
Tracking it over a window rather than the whole record is what lets a one-second motion
artifact be removed without over-filtering the quiet minutes around it.
Measured against synthetic 12-lead records with known ground truth (white noise + 50 Hz mains
- baseline wander, sharp QRS with a mid-QRS notch, pacing spikes and an elevated ST segment):
| Input SNR | Noisy | Old (drop IMF 1) | Current |
|---|---|---|---|
| 0 dB | -5.2 dB | -4.3 dB | +6.6 dB |
| 5 dB | -3.9 dB | -3.6 dB | +10.5 dB |
| 10 dB | -3.4 dB | -3.3 dB | +13.0 dB |
R-wave amplitude, measured against the PQ isoelectric line, stays within ~1 % throughout, and
on records that are already clean the step is close to a no-op (QRS shape correlation 0.9993,
R amplitude preserved to 99.4 %). Set method = ceemdan for another 0.5-3.5 dB where the
extra 20-45x run time is acceptable.
| Layer | Technology |
|---|---|
| Language | Python 3.10+ |
| Signal processing | NumPy, SciPy (iirnotch, butter/filtfilt, resample), PyWavelets, EMD-signal |
| Format I/O | pydicom (DICOM), wfdb (WFDB), XML, SciPy (MAT), pandas (CSV/ASC) |
| Desktop GUI | PySide6 (Qt6), dark theme, threaded workers |
| Visualization | Matplotlib (12-lead viewer, QtAgg canvas) |
| Progress UX | tqdm (CLI), Qt signal bridge (GUI) |
| Packaging | PyInstaller (.spec included) + GitHub Actions multi-platform builds |
Dependencies are listed in requirements.txt.
config.ini, section [processing], sets the defaults for the CLI and the GUI. Command-line flags and GUI controls override it per run.
| Setting | Type | Default | Description |
|---|---|---|---|
format |
string | csv |
Output format: csv, xml, dcm, hl7, wfdb, mat, asc. |
override |
bool | True |
Re-process files even if the output already exists. |
pipeline |
list | empty | Comma-separated steps: notch, bandpass, wavelet, emd. |
notch |
int | 50 |
Notch filter frequency, 50 or 60 Hz. |
default_fs |
int | blank | Fallback sampling rate for files that carry none. |
resample |
int | blank | Target sampling rate; blank keeps the original. |
resample_method |
string | fft |
fft or interpolation. |
anonymize |
bool | False |
Drop patient ID, names, and birth date from outputs. |
Section [emd] tunes the EMD step; it is read only when emd is in the pipeline. Blank
entries keep the default.
| Setting | Type | Default | Description |
|---|---|---|---|
method |
string | emd |
emd, eemd, or ceemdan. The ensemble variants mix modes less, at 20-45x the run time. |
remove_baseline |
bool | True |
Drop the sub-baseline_cut components and the trend. |
baseline_cut |
float | 0.5 |
Hz below which a component counts as baseline wander. |
noise_cut |
float | 20.0 |
Hz above which a component counts as noise. |
threshold |
float | 1.0 |
Threshold scale; higher removes more noise, 0 keeps the noise components whole. |
thresholding |
string | hard |
hard keeps amplitudes; soft shrinks them (R waves land ~10 % low). |
protect_qrs |
bool | True |
Relax the threshold inside QRS complexes instead of applying it blindly. |
qrs_halfwidth |
float | 0.06 |
Seconds of protected half-window around each detected R peak. |
qrs_threshold_scale |
float | 0.5 |
Threshold multiplier inside that window; 0 leaves the QRS untouched. |
adaptive_window |
float | 0.5 |
Seconds the noise level is tracked over; 0 uses one global estimate. |
trials |
int | 30 |
Ensemble size for eemd / ceemdan. |
The same knobs are available per run as --emd_method, --emd_threshold, --emd_noise_cut,
--emd_soft, --emd_keep_baseline, and --emd_no_qrs_protection, and programmatically
through runner.emd_options.
ECG-Format-Converter/
├── config.ini # Run defaults
├── ecg_processor.py # CLI entry point
├── ui.py # PySide6 desktop app
├── debug_ecg.py # Input inspector (readers only, writes nothing)
├── requirements.txt
├── ECG-Format-Converter.spec # PyInstaller build recipe
├── .github/workflows/build.yml # Release binary CI (Win / macOS / Linux)
├── runner/
│ └── execution_runner.py # Orchestrator: discover -> convert -> summarize
├── ecgproc/
│ ├── cli.py # Argument parsing + interactive prompts
│ ├── convert.py # Per-file read -> process -> write
│ ├── readers.py # mat / wfdb / csv / asc / dcm / xml / hl7
│ ├── writers.py # csv / xml / dcm / hl7 / mat / wfdb / asc
│ ├── preprocess.py # notch, bandpass, wavelet, EMD, resampling
│ ├── leads.py # Einthoven / Goldberger lead derivation
│ ├── xml_to_dicom.py # Direct vendor-XML -> DICOM fast path
│ ├── xml_utils.py # Vendor XML parsing helpers
│ ├── files.py, constants.py, stats.py, deps.py
├── skills/
│ ├── ecg-format-converter/SKILL.md # Canonical agent skill
│ └── sync.sh # Copies it into .claude/ and .opencode/
├── .claude/skills/ # Claude Code discovery path (generated)
├── .opencode/skills/ # opencode discovery path (generated)
└── data/
├── input_ecgs/ # <- put your ECG files here (one demo per input format)
└── processed_ecgs/ # <- outputs land here
python3 debug_ecg.py [path] runs every supported file under a path through the readers and prints lead count, sampling rate, sample count, and every metadata field found, without writing anything. Use it first when a device export fails to convert; the per-file error tells you which reader gave up and why.
- CSV and ASC files carry no sampling rate. Filtering, resampling, and WFDB output need one; pass
--fs(CLI), tick Input sampling rate (GUI), or setdefault_fsinconfig.ini. Without it the file is still converted to CSV/XML/DICOM/HL7/MAT/ASC — only the pre-processing steps are skipped, with a log line — but WFDB output skips the file entirely, because the rate belongs in the header. - XML input targets two schemas: the vendor
wavedata/ECG_RHYTHMSlayout (withpatdatademographics) and HL7 aECGAnnotatedECG. Other XML dialects fail loudly per file. The XML the tool writes is its own generic schema and is meant for downstream consumers, not for feeding back in. - Amplitude heuristic for DICOM and HL7 output: signals whose absolute maximum is below 30 are treated as millivolts and multiplied by 1000; everything is stored as int16 microvolts. Pre-scaled integer data far outside that range passes through unchanged.
- Derived leads are estimates. Limb leads computed from Einthoven and Goldberger relations are listed in
calculated_leads; leads that could not be derived are zero-filled, so checkoriginal_leadsbefore treating all 12 columns as measured. - Anonymize covers ID, names, and birth date. Sex, age, weight, exam date, and the original file name remain; rename files separately if the name itself identifies the patient.
- EMD is slow (seconds per lead per recording, and
eemd/ceemdanare 20-45x slower again). Keep it off for large batches unless you need it. - EMD removes the baseline trend by default, so its output is centred on the isoelectric line. Set
remove_baseline = Falsein[emd](or pass--emd_keep_baseline) to keep the original offset. - HL7 files without a usable time increment default to 500 Hz.
- Outputs can contain patient identifiers (PHI); never commit processed data from real recordings.
Released under the MIT License, see LICENSE.md.