ecgen-eval 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.
ecgen_eval/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ ECGEN-Eval: Evaluation metrics and interactive reports for synthetic ECG quality assessment.
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+ __author__ = "Vajira Thambawita"
7
+
8
+ from ecgen_eval.data.loader import load_ecg_folder, load_ecg_npy, load_ecg_wfdb
9
+ from ecgen_eval.metrics import AVAILABLE_METRICS
10
+
11
+ __all__ = ["load_ecg_folder", "load_ecg_npy", "load_ecg_wfdb", "AVAILABLE_METRICS"]
ecgen_eval/cli.py ADDED
@@ -0,0 +1,277 @@
1
+ """
2
+ ecgen-eval CLI — entry point.
3
+
4
+ Commands
5
+ --------
6
+ ecgen-eval eval Run evaluation metrics and generate HTML report.
7
+ ecgen-eval visualize Generate ECG sample comparison plots only.
8
+ ecgen-eval list-metrics List available metrics.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import List, Optional, Tuple
16
+
17
+ import click
18
+
19
+ from ecgen_eval import __version__
20
+ from ecgen_eval.data.loader import load_ecg_folder
21
+ from ecgen_eval.metrics import AVAILABLE_METRICS
22
+ from ecgen_eval.report.html_report import generate_report
23
+
24
+
25
+ # ------------------------------------------------------------------ #
26
+ # CLI group #
27
+ # ------------------------------------------------------------------ #
28
+
29
+ @click.group()
30
+ @click.version_option(__version__, prog_name="ecgen-eval")
31
+ def main():
32
+ """ECGEN-Eval: Evaluation metrics for synthetic ECG quality assessment."""
33
+ pass
34
+
35
+
36
+ # ------------------------------------------------------------------ #
37
+ # eval command #
38
+ # ------------------------------------------------------------------ #
39
+
40
+ @main.command()
41
+ @click.option(
42
+ "--real", "-r",
43
+ required=True,
44
+ type=click.Path(exists=True),
45
+ help="Path to folder containing real ECG CSV files.",
46
+ )
47
+ @click.option(
48
+ "--synthetic", "-s",
49
+ multiple=True,
50
+ required=True,
51
+ type=click.Path(exists=True),
52
+ help="Path to folder(s) containing synthetic ECG CSV files. Repeatable.",
53
+ )
54
+ @click.option(
55
+ "--metrics", "-m",
56
+ multiple=True,
57
+ default=list(AVAILABLE_METRICS.keys()),
58
+ show_default=True,
59
+ type=click.Choice(list(AVAILABLE_METRICS.keys()), case_sensitive=False),
60
+ help="Metrics to compute. Repeatable. Defaults to all available metrics.",
61
+ )
62
+ @click.option(
63
+ "--output", "-o",
64
+ default="ecgen_eval_report.html",
65
+ show_default=True,
66
+ help="Output HTML report path.",
67
+ )
68
+ @click.option(
69
+ "--fs",
70
+ default=500.0,
71
+ show_default=True,
72
+ help="Sampling frequency in Hz.",
73
+ )
74
+ @click.option(
75
+ "--n-samples",
76
+ default=3,
77
+ show_default=True,
78
+ help="Number of ECG samples to show in gallery.",
79
+ )
80
+ @click.option(
81
+ "--leads",
82
+ default=None,
83
+ help="Comma-separated list of lead names to show (e.g. 'I,II,V1'). Default: all.",
84
+ )
85
+ @click.option(
86
+ "--compare-mode",
87
+ default="overlay",
88
+ show_default=True,
89
+ type=click.Choice(["overlay", "sidebyside", "grid"], case_sensitive=False),
90
+ help="ECG comparison mode.",
91
+ )
92
+ @click.option(
93
+ "--max-samples",
94
+ default=None,
95
+ type=int,
96
+ help="Cap number of loaded recordings per folder (useful for quick tests).",
97
+ )
98
+ @click.option(
99
+ "--offline",
100
+ is_flag=True,
101
+ default=False,
102
+ help="Embed Plotly.js in HTML (larger file, no internet needed to view).",
103
+ )
104
+ @click.option(
105
+ "--real-label",
106
+ default=None,
107
+ help="Display label for real dataset. Defaults to folder name.",
108
+ )
109
+ @click.option(
110
+ "--synth-labels",
111
+ default=None,
112
+ help="Comma-separated labels for synthetic datasets. Defaults to folder names.",
113
+ )
114
+ def eval(
115
+ real,
116
+ synthetic,
117
+ metrics,
118
+ output,
119
+ fs,
120
+ n_samples,
121
+ leads,
122
+ compare_mode,
123
+ max_samples,
124
+ offline,
125
+ real_label,
126
+ synth_labels,
127
+ ):
128
+ """Run evaluation metrics and generate an interactive HTML report."""
129
+
130
+ click.echo(f"\n{'='*60}")
131
+ click.echo(f" ECGEN-Eval v{__version__}")
132
+ click.echo(f"{'='*60}\n")
133
+
134
+ # Parse optional labels
135
+ synth_label_list = (
136
+ [s.strip() for s in synth_labels.split(",")] if synth_labels else []
137
+ )
138
+ leads_list = [l.strip() for l in leads.split(",")] if leads else None
139
+
140
+ # -------------------------------------------------------------- #
141
+ # Load datasets #
142
+ # -------------------------------------------------------------- #
143
+ click.echo("Loading datasets...")
144
+ real_ds = load_ecg_folder(
145
+ real, fs=fs, label=real_label, max_samples=max_samples
146
+ )
147
+ click.echo(f" Real: {real_ds}")
148
+
149
+ synth_datasets = []
150
+ for i, s_path in enumerate(synthetic):
151
+ lbl = synth_label_list[i] if i < len(synth_label_list) else None
152
+ ds = load_ecg_folder(s_path, fs=fs, label=lbl, max_samples=max_samples)
153
+ synth_datasets.append(ds)
154
+ click.echo(f" Synth {i+1}: {ds}")
155
+
156
+ # -------------------------------------------------------------- #
157
+ # Compute metrics #
158
+ # -------------------------------------------------------------- #
159
+ click.echo("\nComputing metrics...")
160
+ metric_results: dict = {}
161
+
162
+ for metric_key in metrics:
163
+ metric_cls = AVAILABLE_METRICS[metric_key.lower()]
164
+ metric_obj = metric_cls()
165
+ click.echo(f" [{metric_key.upper()}]")
166
+ by_synth = {}
167
+ for synth_ds in synth_datasets:
168
+ click.echo(f" Computing against {synth_ds.label} ...", nl=False)
169
+ result = metric_obj.compute(
170
+ real=real_ds.data,
171
+ synth=synth_ds.data,
172
+ lead_names=real_ds.lead_names,
173
+ )
174
+ by_synth[synth_ds.label] = result
175
+ click.echo(f" score = {result.score:.6f}")
176
+ metric_results[metric_key] = by_synth
177
+
178
+ # -------------------------------------------------------------- #
179
+ # Generate report #
180
+ # -------------------------------------------------------------- #
181
+ click.echo(f"\nGenerating report → {output}")
182
+ out_path = generate_report(
183
+ real=real_ds,
184
+ synthetics=synth_datasets,
185
+ metric_results=metric_results,
186
+ output_path=output,
187
+ n_ecg_samples=n_samples,
188
+ leads=leads_list,
189
+ compare_mode=compare_mode,
190
+ plotly_cdn=not offline,
191
+ )
192
+ click.echo(f"\nDone! Report saved to: {out_path.resolve()}")
193
+ click.echo("Open in any modern browser.\n")
194
+
195
+
196
+ # ------------------------------------------------------------------ #
197
+ # visualize command #
198
+ # ------------------------------------------------------------------ #
199
+
200
+ @main.command()
201
+ @click.option("--real", "-r", required=True, type=click.Path(exists=True))
202
+ @click.option("--synthetic", "-s", multiple=True, required=True, type=click.Path(exists=True))
203
+ @click.option("--output", "-o", default="ecg_comparison.html", show_default=True)
204
+ @click.option("--fs", default=500.0, show_default=True)
205
+ @click.option("--n-samples", default=3, show_default=True)
206
+ @click.option("--leads", default=None, help="Comma-separated lead names.")
207
+ @click.option(
208
+ "--compare-mode",
209
+ default="overlay",
210
+ type=click.Choice(["overlay", "sidebyside", "grid"]),
211
+ )
212
+ @click.option("--max-samples", default=None, type=int)
213
+ @click.option("--offline", is_flag=True, default=False)
214
+ def visualize(real, synthetic, output, fs, n_samples, leads, compare_mode, max_samples, offline):
215
+ """Generate ECG sample comparison plots (no metrics)."""
216
+ from ecgen_eval.visualization.ecg_waveform import plot_ecg_comparison
217
+
218
+ leads_list = [l.strip() for l in leads.split(",")] if leads else None
219
+
220
+ real_ds = load_ecg_folder(real, fs=fs, max_samples=max_samples)
221
+ synth_datasets = [
222
+ load_ecg_folder(s, fs=fs, max_samples=max_samples) for s in synthetic
223
+ ]
224
+
225
+ n_samples = min(n_samples, real_ds.n_samples)
226
+ plots_html_parts = []
227
+ include_js = "cdn" if not offline else True
228
+
229
+ for i in range(n_samples):
230
+ fig = plot_ecg_comparison(
231
+ real=real_ds,
232
+ synthetics=synth_datasets,
233
+ sample_index=i,
234
+ leads=leads_list,
235
+ mode=compare_mode,
236
+ add_calibration_pulse=True,
237
+ )
238
+ plots_html_parts.append(
239
+ f"<h3>Sample {i}</h3>"
240
+ + fig.to_html(
241
+ full_html=False,
242
+ include_plotlyjs=include_js if i == 0 else False,
243
+ config={"responsive": True},
244
+ )
245
+ )
246
+
247
+ cdn_tag = (
248
+ '<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>'
249
+ if not offline else ""
250
+ )
251
+ body = "\n".join(plots_html_parts)
252
+ html = f"""<!DOCTYPE html>
253
+ <html><head><meta charset="UTF-8"/>
254
+ <title>ECG Comparison</title>{cdn_tag}
255
+ <style>body{{font-family:sans-serif;max-width:1400px;margin:auto;padding:24px}}</style>
256
+ </head><body><h1>ECG Comparison ({compare_mode})</h1>{body}</body></html>"""
257
+
258
+ Path(output).write_text(html, encoding="utf-8")
259
+ click.echo(f"Saved: {Path(output).resolve()}")
260
+
261
+
262
+ # ------------------------------------------------------------------ #
263
+ # list-metrics command #
264
+ # ------------------------------------------------------------------ #
265
+
266
+ @main.command(name="list-metrics")
267
+ def list_metrics():
268
+ """List all available evaluation metrics."""
269
+ click.echo(f"\nAvailable metrics in ecgen-eval v{__version__}:\n")
270
+ for key, cls in AVAILABLE_METRICS.items():
271
+ obj = cls()
272
+ click.echo(f" {key:<12} {obj.description[:80]}")
273
+ click.echo(f" {'':12} Ref: {obj.reference[:80]}...\n")
274
+
275
+
276
+ if __name__ == "__main__":
277
+ main()
@@ -0,0 +1,4 @@
1
+ from ecgen_eval.data.loader import load_ecg_folder, load_ecg_npy, load_ecg_wfdb
2
+ from ecgen_eval.data.dataset import ECGDataset
3
+
4
+ __all__ = ["load_ecg_folder", "load_ecg_npy", "load_ecg_wfdb", "ECGDataset"]
@@ -0,0 +1,103 @@
1
+ """ECGDataset: in-memory container for a collection of ECG recordings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+ import numpy as np
10
+
11
+
12
+ # Standard lead names for 12-lead and 8-lead ECGs
13
+ LEADS_12 = ["I", "II", "III", "aVR", "aVL", "aVF", "V1", "V2", "V3", "V4", "V5", "V6"]
14
+ LEADS_8 = ["I", "II", "V1", "V2", "V3", "V4", "V5", "V6"]
15
+
16
+
17
+ @dataclass
18
+ class ECGDataset:
19
+ """
20
+ Container for a collection of ECG recordings loaded from a folder.
21
+
22
+ Attributes
23
+ ----------
24
+ data : np.ndarray
25
+ Shape (n_samples, n_leads, n_timepoints).
26
+ lead_names : List[str]
27
+ Ordered list of lead names.
28
+ fs : float
29
+ Sampling frequency in Hz.
30
+ source_path : Path
31
+ Folder the data was loaded from.
32
+ label : str
33
+ Human-readable label used in plots/reports (defaults to folder name).
34
+ file_names : List[str]
35
+ Original filenames in load order.
36
+ """
37
+
38
+ data: np.ndarray # (N, L, T)
39
+ lead_names: List[str]
40
+ fs: float
41
+ source_path: Path
42
+ label: str
43
+ file_names: List[str] = field(default_factory=list)
44
+
45
+ # ------------------------------------------------------------------ #
46
+ # Convenience properties #
47
+ # ------------------------------------------------------------------ #
48
+
49
+ @property
50
+ def n_samples(self) -> int:
51
+ return self.data.shape[0]
52
+
53
+ @property
54
+ def n_leads(self) -> int:
55
+ return self.data.shape[1]
56
+
57
+ @property
58
+ def n_timepoints(self) -> int:
59
+ return self.data.shape[2]
60
+
61
+ @property
62
+ def duration_sec(self) -> float:
63
+ return self.n_timepoints / self.fs
64
+
65
+ @property
66
+ def time_axis(self) -> np.ndarray:
67
+ """Time axis in seconds."""
68
+ return np.arange(self.n_timepoints) / self.fs
69
+
70
+ # ------------------------------------------------------------------ #
71
+ # Subsetting helpers #
72
+ # ------------------------------------------------------------------ #
73
+
74
+ def select_leads(self, leads: List[str]) -> "ECGDataset":
75
+ """Return a new ECGDataset restricted to the requested leads."""
76
+ indices = [self.lead_names.index(l) for l in leads]
77
+ return ECGDataset(
78
+ data=self.data[:, indices, :],
79
+ lead_names=leads,
80
+ fs=self.fs,
81
+ source_path=self.source_path,
82
+ label=self.label,
83
+ file_names=self.file_names,
84
+ )
85
+
86
+ def sample(self, n: int, seed: Optional[int] = 42) -> "ECGDataset":
87
+ """Return a new ECGDataset with n randomly selected recordings."""
88
+ rng = np.random.default_rng(seed)
89
+ idx = rng.choice(self.n_samples, size=min(n, self.n_samples), replace=False)
90
+ return ECGDataset(
91
+ data=self.data[idx],
92
+ lead_names=self.lead_names,
93
+ fs=self.fs,
94
+ source_path=self.source_path,
95
+ label=self.label,
96
+ file_names=[self.file_names[i] for i in idx] if self.file_names else [],
97
+ )
98
+
99
+ def __repr__(self) -> str:
100
+ return (
101
+ f"ECGDataset(label={self.label!r}, n_samples={self.n_samples}, "
102
+ f"leads={self.n_leads}, timepoints={self.n_timepoints}, fs={self.fs} Hz)"
103
+ )