deepecgkit 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.
- deepecgkit/__init__.py +42 -0
- deepecgkit/__main__.py +6 -0
- deepecgkit/cli/__init__.py +61 -0
- deepecgkit/cli/commands/__init__.py +16 -0
- deepecgkit/cli/commands/evaluate.py +83 -0
- deepecgkit/cli/commands/info.py +73 -0
- deepecgkit/cli/commands/predict.py +94 -0
- deepecgkit/cli/commands/train.py +331 -0
- deepecgkit/cli/config.py +23 -0
- deepecgkit/cli/evaluation.py +251 -0
- deepecgkit/cli/logger.py +26 -0
- deepecgkit/cli/main.py +429 -0
- deepecgkit/cli/registry.py +32 -0
- deepecgkit/datasets/__init__.py +33 -0
- deepecgkit/datasets/af_classification.py +301 -0
- deepecgkit/datasets/base.py +146 -0
- deepecgkit/datasets/ltafdb.py +363 -0
- deepecgkit/datasets/mitbih_afdb.py +287 -0
- deepecgkit/datasets/modules.py +222 -0
- deepecgkit/datasets/preprocessing.py +208 -0
- deepecgkit/datasets/ptbxl.py +567 -0
- deepecgkit/datasets/splitting.py +225 -0
- deepecgkit/datasets/unified_af.py +316 -0
- deepecgkit/evaluation/__init__.py +40 -0
- deepecgkit/evaluation/evaluator.py +398 -0
- deepecgkit/evaluation/metrics.py +164 -0
- deepecgkit/evaluation/visualization.py +285 -0
- deepecgkit/models/__init__.py +51 -0
- deepecgkit/models/af_classifier.py +177 -0
- deepecgkit/models/convnext_v2_1d.py +183 -0
- deepecgkit/models/crnn.py +117 -0
- deepecgkit/models/deep_res_cnn.py +215 -0
- deepecgkit/models/ecg_dualnet.py +230 -0
- deepecgkit/models/fcn_wang.py +88 -0
- deepecgkit/models/gru_model.py +99 -0
- deepecgkit/models/inception_time.py +197 -0
- deepecgkit/models/kanres_wide_x.py +150 -0
- deepecgkit/models/kanres_x.py +126 -0
- deepecgkit/models/lstm_model.py +96 -0
- deepecgkit/models/mamba1d.py +232 -0
- deepecgkit/models/medformer.py +229 -0
- deepecgkit/models/resnet1d.py +189 -0
- deepecgkit/models/resnet1d_wang.py +182 -0
- deepecgkit/models/se_resnet1d.py +232 -0
- deepecgkit/models/simple_cnn.py +82 -0
- deepecgkit/models/tcn.py +178 -0
- deepecgkit/models/transformer_ecg.py +127 -0
- deepecgkit/models/xresnet1d.py +265 -0
- deepecgkit/models/xresnet1d_benchmark.py +227 -0
- deepecgkit/registry.py +97 -0
- deepecgkit/training/__init__.py +9 -0
- deepecgkit/training/train.py +171 -0
- deepecgkit/utils/__init__.py +72 -0
- deepecgkit/utils/download.py +123 -0
- deepecgkit/utils/weights.py +107 -0
- deepecgkit-0.1.0.dist-info/METADATA +99 -0
- deepecgkit-0.1.0.dist-info/RECORD +61 -0
- deepecgkit-0.1.0.dist-info/WHEEL +5 -0
- deepecgkit-0.1.0.dist-info/entry_points.txt +2 -0
- deepecgkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- deepecgkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import zipfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar, Dict, List, Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import torch
|
|
10
|
+
import wfdb
|
|
11
|
+
|
|
12
|
+
from deepecgkit.registry import register_dataset
|
|
13
|
+
|
|
14
|
+
from ..utils.download import download_file
|
|
15
|
+
from .base import BaseECGDataset
|
|
16
|
+
from .preprocessing import (
|
|
17
|
+
ECGSegmenter,
|
|
18
|
+
ECGStandardizer,
|
|
19
|
+
RhythmAnnotationExtractor,
|
|
20
|
+
convert_to_tensor,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@register_dataset(
|
|
25
|
+
name="ltafdb",
|
|
26
|
+
input_channels=2,
|
|
27
|
+
num_classes=4,
|
|
28
|
+
description="Long-Term AF Database (2-lead, binary or 4-class)",
|
|
29
|
+
)
|
|
30
|
+
class LTAFDBDataset(BaseECGDataset):
|
|
31
|
+
"""Long-Term AF Database (LTAFDB) Dataset.
|
|
32
|
+
|
|
33
|
+
Contains 84 long-term (typically 24-hour) two-lead ECG recordings of subjects
|
|
34
|
+
with paroxysmal or sustained atrial fibrillation. Rhythm annotations indicate
|
|
35
|
+
Normal, AF, Atrial Flutter, and Junctional rhythm segments.
|
|
36
|
+
|
|
37
|
+
Reference:
|
|
38
|
+
Petrutiu S, Sahakian AV, Swiryn S. Abrupt changes in fibrillatory wave
|
|
39
|
+
characteristics at the termination of paroxysmal atrial fibrillation in humans.
|
|
40
|
+
Europace. 2007;9(7):466-470.
|
|
41
|
+
|
|
42
|
+
URL:
|
|
43
|
+
https://physionet.org/content/ltafdb/1.0.0/
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
CLASS_LABELS: ClassVar[List[str]] = ["Normal", "AF", "AFL", "J"]
|
|
47
|
+
LABEL_MAPPING: ClassVar[Dict[str, int]] = {
|
|
48
|
+
"(N": 0,
|
|
49
|
+
"(AFIB": 1,
|
|
50
|
+
"(AFL": 2,
|
|
51
|
+
"(J": 3,
|
|
52
|
+
}
|
|
53
|
+
LEADS: ClassVar[List[str]] = ["ECG1", "ECG2"]
|
|
54
|
+
SAMPLING_RATE: ClassVar[int] = 128
|
|
55
|
+
|
|
56
|
+
DOWNLOAD_URL: ClassVar[str] = "https://physionet.org/content/ltafdb/get-zip/1.0.0/"
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
data_dir: Optional[Union[str, Path]] = None,
|
|
61
|
+
sampling_rate: int = 300,
|
|
62
|
+
segment_duration_seconds: float = 10.0,
|
|
63
|
+
segment_overlap: float = 0.0,
|
|
64
|
+
binary_classification: bool = False,
|
|
65
|
+
use_both_leads: bool = False,
|
|
66
|
+
normalization: str = "zscore",
|
|
67
|
+
max_segments_per_record: Optional[int] = None,
|
|
68
|
+
transform: Optional[callable] = None,
|
|
69
|
+
target_transform: Optional[callable] = None,
|
|
70
|
+
download: bool = False,
|
|
71
|
+
force_download: bool = False,
|
|
72
|
+
verbose: bool = True,
|
|
73
|
+
):
|
|
74
|
+
self.segment_duration_seconds = segment_duration_seconds
|
|
75
|
+
self.segment_overlap = segment_overlap
|
|
76
|
+
self.binary_classification = binary_classification
|
|
77
|
+
self.use_both_leads = use_both_leads
|
|
78
|
+
self.max_segments_per_record = max_segments_per_record
|
|
79
|
+
self.verbose = verbose
|
|
80
|
+
|
|
81
|
+
self.standardizer = ECGStandardizer(
|
|
82
|
+
target_sampling_rate=sampling_rate,
|
|
83
|
+
normalization=normalization,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self.segmenter = ECGSegmenter(
|
|
87
|
+
segment_duration_seconds=segment_duration_seconds,
|
|
88
|
+
sampling_rate=sampling_rate,
|
|
89
|
+
overlap=segment_overlap,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
self.rhythm_extractor = RhythmAnnotationExtractor(
|
|
93
|
+
sampling_rate=sampling_rate, binary_classification=binary_classification
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
self.signals: np.ndarray = np.array([])
|
|
97
|
+
self.labels: np.ndarray = np.array([], dtype=np.int64)
|
|
98
|
+
self.record_names: List[str] = []
|
|
99
|
+
|
|
100
|
+
super().__init__(
|
|
101
|
+
data_dir=data_dir,
|
|
102
|
+
sampling_rate=sampling_rate,
|
|
103
|
+
leads=self.LEADS if use_both_leads else [self.LEADS[0]],
|
|
104
|
+
transform=transform,
|
|
105
|
+
target_transform=target_transform,
|
|
106
|
+
download=download,
|
|
107
|
+
force_download=force_download,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _cache_key(self) -> str:
|
|
111
|
+
params = (
|
|
112
|
+
f"sr{self.sampling_rate}_dur{self.segment_duration_seconds}_"
|
|
113
|
+
f"ovlp{self.segment_overlap}_bin{self.binary_classification}_"
|
|
114
|
+
f"leads{self.use_both_leads}_norm{self.standardizer.normalization}_"
|
|
115
|
+
f"max{self.max_segments_per_record}"
|
|
116
|
+
)
|
|
117
|
+
return hashlib.md5(params.encode()).hexdigest()[:12]
|
|
118
|
+
|
|
119
|
+
def _cache_dir(self) -> Path:
|
|
120
|
+
return self.data_dir / f"cache_{self._cache_key()}"
|
|
121
|
+
|
|
122
|
+
def download(self):
|
|
123
|
+
if self.verbose:
|
|
124
|
+
print(f"Downloading Long-Term AF Database to {self.data_dir}")
|
|
125
|
+
print("Note: This is a large dataset (~1.7GB). Download may take a while.")
|
|
126
|
+
|
|
127
|
+
os.makedirs(self.data_dir, exist_ok=True)
|
|
128
|
+
|
|
129
|
+
zip_path = self.data_dir / "ltafdb-1.0.0.zip"
|
|
130
|
+
download_file(
|
|
131
|
+
self.DOWNLOAD_URL,
|
|
132
|
+
zip_path,
|
|
133
|
+
desc="Downloading LTAFDB",
|
|
134
|
+
max_retries=5,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if self.verbose:
|
|
138
|
+
print("Extracting dataset...")
|
|
139
|
+
|
|
140
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
141
|
+
zf.extractall(self.data_dir)
|
|
142
|
+
|
|
143
|
+
nested_dir = self.data_dir / "ltafdb-1.0.0"
|
|
144
|
+
if nested_dir.is_dir():
|
|
145
|
+
for item in nested_dir.iterdir():
|
|
146
|
+
dest = self.data_dir / item.name
|
|
147
|
+
if dest.exists():
|
|
148
|
+
if dest.is_dir():
|
|
149
|
+
shutil.rmtree(dest)
|
|
150
|
+
else:
|
|
151
|
+
dest.unlink()
|
|
152
|
+
shutil.move(str(item), str(dest))
|
|
153
|
+
nested_dir.rmdir()
|
|
154
|
+
|
|
155
|
+
zip_path.unlink(missing_ok=True)
|
|
156
|
+
|
|
157
|
+
if self.verbose:
|
|
158
|
+
print("Download complete!")
|
|
159
|
+
|
|
160
|
+
def _resolve_record_dir(self) -> Path:
|
|
161
|
+
"""Find the directory containing .hea/.atr record files."""
|
|
162
|
+
if any(self.data_dir.glob("*.hea")):
|
|
163
|
+
return self.data_dir
|
|
164
|
+
files_dir = self.data_dir / "files"
|
|
165
|
+
if files_dir.is_dir() and any(files_dir.glob("*.hea")):
|
|
166
|
+
return files_dir
|
|
167
|
+
return self.data_dir
|
|
168
|
+
|
|
169
|
+
def _discover_records(self, record_dir: Path) -> List[str]:
|
|
170
|
+
"""Discover available records by finding files with both .hea and .atr extensions."""
|
|
171
|
+
hea_stems = {p.stem for p in record_dir.glob("*.hea")}
|
|
172
|
+
atr_stems = {p.stem for p in record_dir.glob("*.atr")}
|
|
173
|
+
return sorted(hea_stems & atr_stems, key=lambda x: (len(x), x))
|
|
174
|
+
|
|
175
|
+
def _load_data(self):
|
|
176
|
+
if self.verbose:
|
|
177
|
+
print("Loading Long-Term AF Database...")
|
|
178
|
+
|
|
179
|
+
cache_dir = self._cache_dir()
|
|
180
|
+
signals_path = cache_dir / "signals.npy"
|
|
181
|
+
labels_path = cache_dir / "labels.npy"
|
|
182
|
+
record_names_path = cache_dir / "record_names.npy"
|
|
183
|
+
|
|
184
|
+
if signals_path.exists() and labels_path.exists():
|
|
185
|
+
if self.verbose:
|
|
186
|
+
print(f"Loading from cache: {cache_dir.name}")
|
|
187
|
+
self.signals = np.load(signals_path, mmap_mode="r")
|
|
188
|
+
self.labels = np.load(labels_path, mmap_mode="r")
|
|
189
|
+
if record_names_path.exists():
|
|
190
|
+
self.record_names = np.load(record_names_path, allow_pickle=True).tolist()
|
|
191
|
+
else:
|
|
192
|
+
self.record_names = ["unknown"] * len(self.labels)
|
|
193
|
+
if self.verbose:
|
|
194
|
+
print(f"Loaded {len(self.labels)} segments from cache (memory-mapped)")
|
|
195
|
+
self._print_class_distribution()
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
record_dir = self._resolve_record_dir()
|
|
199
|
+
record_names = self._discover_records(record_dir)
|
|
200
|
+
if not record_names:
|
|
201
|
+
raise FileNotFoundError(
|
|
202
|
+
f"No valid records found in {self.data_dir}. "
|
|
203
|
+
"Expected .hea and .atr files from LTAFDB."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if self.verbose:
|
|
207
|
+
print(f"Found {len(record_names)} records — processing (first run only)...")
|
|
208
|
+
|
|
209
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
tmp_signals = cache_dir / "signals_tmp.npy"
|
|
211
|
+
|
|
212
|
+
num_leads = 2 if self.use_both_leads else 1
|
|
213
|
+
seg_len = self.segmenter.segment_length
|
|
214
|
+
|
|
215
|
+
writer = None
|
|
216
|
+
write_offset = 0
|
|
217
|
+
label_chunks: List[np.ndarray] = []
|
|
218
|
+
all_record_names: List[str] = []
|
|
219
|
+
total_segments = 0
|
|
220
|
+
|
|
221
|
+
for record_name in record_names:
|
|
222
|
+
record_path = record_dir / record_name
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
record = wfdb.rdrecord(str(record_path))
|
|
226
|
+
annotation = wfdb.rdann(str(record_path), "atr")
|
|
227
|
+
|
|
228
|
+
signals = record.p_signal.T
|
|
229
|
+
|
|
230
|
+
if not self.use_both_leads:
|
|
231
|
+
signals = signals[0:1, :]
|
|
232
|
+
|
|
233
|
+
standardized_signal = self.standardizer.resample(signals, self.SAMPLING_RATE)
|
|
234
|
+
|
|
235
|
+
labels = self.rhythm_extractor.extract_labels(
|
|
236
|
+
annotation, standardized_signal.shape[-1], self.SAMPLING_RATE
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
segments, start_indices = self.segmenter.segment(standardized_signal)
|
|
240
|
+
|
|
241
|
+
if len(segments) == 0:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
if (
|
|
245
|
+
self.max_segments_per_record is not None
|
|
246
|
+
and len(segments) > self.max_segments_per_record
|
|
247
|
+
):
|
|
248
|
+
indices = np.random.choice(
|
|
249
|
+
len(segments),
|
|
250
|
+
self.max_segments_per_record,
|
|
251
|
+
replace=False,
|
|
252
|
+
)
|
|
253
|
+
segments = segments[indices]
|
|
254
|
+
start_indices = start_indices[indices]
|
|
255
|
+
|
|
256
|
+
segment_labels = self.rhythm_extractor.segment_with_labels(
|
|
257
|
+
labels, start_indices, self.segmenter.segment_length
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
normalized = np.stack([self.standardizer.normalize(seg) for seg in segments])
|
|
261
|
+
|
|
262
|
+
if writer is None:
|
|
263
|
+
estimated_total = len(record_names) * len(segments)
|
|
264
|
+
writer = np.lib.format.open_memmap(
|
|
265
|
+
str(tmp_signals),
|
|
266
|
+
mode="w+",
|
|
267
|
+
dtype=np.float32,
|
|
268
|
+
shape=(estimated_total, num_leads, seg_len),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
needed = write_offset + len(normalized)
|
|
272
|
+
if needed > writer.shape[0]:
|
|
273
|
+
new_size = max(needed, writer.shape[0] * 2)
|
|
274
|
+
writer = np.lib.format.open_memmap(
|
|
275
|
+
str(tmp_signals),
|
|
276
|
+
mode="r+",
|
|
277
|
+
dtype=np.float32,
|
|
278
|
+
shape=(new_size, num_leads, seg_len),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
writer[write_offset : write_offset + len(normalized)] = normalized
|
|
282
|
+
label_chunks.append(np.array(segment_labels, dtype=np.int64))
|
|
283
|
+
all_record_names.extend([record_name] * len(segments))
|
|
284
|
+
write_offset += len(normalized)
|
|
285
|
+
total_segments += len(segments)
|
|
286
|
+
|
|
287
|
+
if self.verbose:
|
|
288
|
+
print(
|
|
289
|
+
f"Loaded {record_name}: {len(segments)} segments (total: {total_segments})"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
except Exception as e:
|
|
293
|
+
if self.verbose:
|
|
294
|
+
print(f"Error loading {record_name}: {e}")
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
if write_offset == 0:
|
|
298
|
+
shutil.rmtree(cache_dir)
|
|
299
|
+
self.signals = np.array([])
|
|
300
|
+
self.labels = np.array([], dtype=np.int64)
|
|
301
|
+
self.record_names = []
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
del writer
|
|
305
|
+
|
|
306
|
+
final_signals = np.lib.format.open_memmap(
|
|
307
|
+
str(tmp_signals), mode="r+", shape=(write_offset, num_leads, seg_len)
|
|
308
|
+
)
|
|
309
|
+
np.save(signals_path, final_signals[:write_offset])
|
|
310
|
+
del final_signals
|
|
311
|
+
tmp_signals.unlink(missing_ok=True)
|
|
312
|
+
|
|
313
|
+
all_labels = np.concatenate(label_chunks)
|
|
314
|
+
np.save(labels_path, all_labels)
|
|
315
|
+
np.save(record_names_path, np.array(all_record_names))
|
|
316
|
+
|
|
317
|
+
self.signals = np.load(signals_path, mmap_mode="r")
|
|
318
|
+
self.labels = np.load(labels_path, mmap_mode="r")
|
|
319
|
+
self.record_names = all_record_names
|
|
320
|
+
|
|
321
|
+
if self.verbose:
|
|
322
|
+
print(f"\nCached {len(self.labels)} segments to {cache_dir.name} (memory-mapped)")
|
|
323
|
+
self._print_class_distribution()
|
|
324
|
+
|
|
325
|
+
def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
326
|
+
signal = self.signals[idx]
|
|
327
|
+
label = self.labels[idx]
|
|
328
|
+
|
|
329
|
+
signal = convert_to_tensor(signal, dtype=torch.float32)
|
|
330
|
+
label = torch.tensor(label, dtype=torch.long)
|
|
331
|
+
|
|
332
|
+
if self.transform is not None:
|
|
333
|
+
signal = self.transform(signal)
|
|
334
|
+
|
|
335
|
+
if self.target_transform is not None:
|
|
336
|
+
label = self.target_transform(label)
|
|
337
|
+
|
|
338
|
+
return signal, label
|
|
339
|
+
|
|
340
|
+
def __len__(self) -> int:
|
|
341
|
+
return len(self.labels)
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def num_classes(self) -> int:
|
|
345
|
+
return 2 if self.binary_classification else len(self.CLASS_LABELS)
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def class_names(self) -> List[str]:
|
|
349
|
+
if self.binary_classification:
|
|
350
|
+
return ["Non-AF", "AF"]
|
|
351
|
+
return self.CLASS_LABELS
|
|
352
|
+
|
|
353
|
+
def get_record_info(self, idx: int) -> Dict:
|
|
354
|
+
return {
|
|
355
|
+
"record_name": self.record_names[idx],
|
|
356
|
+
"label": int(self.labels[idx]),
|
|
357
|
+
"class_name": self.class_names[int(self.labels[idx])],
|
|
358
|
+
"signal_shape": self.signals[idx].shape,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
def get_class_distribution(self) -> Dict[str, int]:
|
|
362
|
+
unique, counts = np.unique(self.labels, return_counts=True)
|
|
363
|
+
return {self.class_names[int(label)]: int(count) for label, count in zip(unique, counts)}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import zipfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import ClassVar, Dict, List, Optional, Tuple, Union
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import torch
|
|
9
|
+
import wfdb
|
|
10
|
+
|
|
11
|
+
from deepecgkit.registry import register_dataset
|
|
12
|
+
|
|
13
|
+
from ..utils.download import download_file
|
|
14
|
+
from .base import BaseECGDataset
|
|
15
|
+
from .preprocessing import (
|
|
16
|
+
ECGSegmenter,
|
|
17
|
+
ECGStandardizer,
|
|
18
|
+
RhythmAnnotationExtractor,
|
|
19
|
+
convert_to_tensor,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@register_dataset(
|
|
24
|
+
name="mitbih-afdb",
|
|
25
|
+
input_channels=2,
|
|
26
|
+
num_classes=4,
|
|
27
|
+
description="MIT-BIH AF Database (2-lead, binary or 4-class)",
|
|
28
|
+
)
|
|
29
|
+
class MITBIHAFDBDataset(BaseECGDataset):
|
|
30
|
+
"""MIT-BIH Atrial Fibrillation Database (AFDB) Dataset.
|
|
31
|
+
|
|
32
|
+
Contains 25 long-term (10-hour) two-lead ECG recordings from subjects with
|
|
33
|
+
atrial fibrillation (mostly paroxysmal). Rhythm annotations indicate Normal,
|
|
34
|
+
AF, Atrial Flutter, and Junctional rhythm segments.
|
|
35
|
+
|
|
36
|
+
Reference:
|
|
37
|
+
Moody GB, Mark RG. A new method for detecting atrial fibrillation using R-R
|
|
38
|
+
intervals. Computers in Cardiology. 1983;10:227-230.
|
|
39
|
+
|
|
40
|
+
URL:
|
|
41
|
+
https://physionet.org/content/afdb/1.0.0/
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
CLASS_LABELS: ClassVar[List[str]] = ["Normal", "AF", "AFL", "J"]
|
|
45
|
+
LABEL_MAPPING: ClassVar[Dict[str, int]] = {
|
|
46
|
+
"(N": 0,
|
|
47
|
+
"(AFIB": 1,
|
|
48
|
+
"(AFL": 2,
|
|
49
|
+
"(J": 3,
|
|
50
|
+
}
|
|
51
|
+
LEADS: ClassVar[List[str]] = ["ECG1", "ECG2"]
|
|
52
|
+
SAMPLING_RATE: ClassVar[int] = 250
|
|
53
|
+
DOWNLOAD_URL: ClassVar[str] = "https://physionet.org/content/afdb/get-zip/1.0.0/"
|
|
54
|
+
|
|
55
|
+
RECORD_NAMES: ClassVar[List[str]] = [
|
|
56
|
+
"04015",
|
|
57
|
+
"04043",
|
|
58
|
+
"04048",
|
|
59
|
+
"04126",
|
|
60
|
+
"04746",
|
|
61
|
+
"04908",
|
|
62
|
+
"04936",
|
|
63
|
+
"05091",
|
|
64
|
+
"05121",
|
|
65
|
+
"05261",
|
|
66
|
+
"06426",
|
|
67
|
+
"06453",
|
|
68
|
+
"06995",
|
|
69
|
+
"07162",
|
|
70
|
+
"07859",
|
|
71
|
+
"07879",
|
|
72
|
+
"07910",
|
|
73
|
+
"08215",
|
|
74
|
+
"08219",
|
|
75
|
+
"08378",
|
|
76
|
+
"08405",
|
|
77
|
+
"08434",
|
|
78
|
+
"08455",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
data_dir: Optional[Union[str, Path]] = None,
|
|
84
|
+
sampling_rate: int = 300,
|
|
85
|
+
segment_duration_seconds: float = 10.0,
|
|
86
|
+
segment_overlap: float = 0.0,
|
|
87
|
+
binary_classification: bool = False,
|
|
88
|
+
use_both_leads: bool = False,
|
|
89
|
+
normalization: str = "zscore",
|
|
90
|
+
transform: Optional[callable] = None,
|
|
91
|
+
target_transform: Optional[callable] = None,
|
|
92
|
+
download: bool = False,
|
|
93
|
+
force_download: bool = False,
|
|
94
|
+
verbose: bool = True,
|
|
95
|
+
):
|
|
96
|
+
self.segment_duration_seconds = segment_duration_seconds
|
|
97
|
+
self.segment_overlap = segment_overlap
|
|
98
|
+
self.binary_classification = binary_classification
|
|
99
|
+
self.use_both_leads = use_both_leads
|
|
100
|
+
self.verbose = verbose
|
|
101
|
+
|
|
102
|
+
self.standardizer = ECGStandardizer(
|
|
103
|
+
target_sampling_rate=sampling_rate,
|
|
104
|
+
normalization=normalization,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self.segmenter = ECGSegmenter(
|
|
108
|
+
segment_duration_seconds=segment_duration_seconds,
|
|
109
|
+
sampling_rate=sampling_rate,
|
|
110
|
+
overlap=segment_overlap,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
self.rhythm_extractor = RhythmAnnotationExtractor(
|
|
114
|
+
sampling_rate=sampling_rate, binary_classification=binary_classification
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
self.signals: List[np.ndarray] = []
|
|
118
|
+
self.labels: List[int] = []
|
|
119
|
+
self.record_names: List[str] = []
|
|
120
|
+
|
|
121
|
+
super().__init__(
|
|
122
|
+
data_dir=data_dir,
|
|
123
|
+
sampling_rate=sampling_rate,
|
|
124
|
+
leads=self.LEADS if use_both_leads else [self.LEADS[0]],
|
|
125
|
+
transform=transform,
|
|
126
|
+
target_transform=target_transform,
|
|
127
|
+
download=download,
|
|
128
|
+
force_download=force_download,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def download(self):
|
|
132
|
+
if self.verbose:
|
|
133
|
+
print(f"Downloading MIT-BIH AFDB to {self.data_dir}")
|
|
134
|
+
print("Note: This is a ~440MB download.")
|
|
135
|
+
|
|
136
|
+
os.makedirs(self.data_dir, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
zip_path = self.data_dir / "afdb-1.0.0.zip"
|
|
139
|
+
download_file(
|
|
140
|
+
self.DOWNLOAD_URL,
|
|
141
|
+
zip_path,
|
|
142
|
+
desc="Downloading MIT-BIH AFDB",
|
|
143
|
+
max_retries=5,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if self.verbose:
|
|
147
|
+
print("Extracting dataset...")
|
|
148
|
+
|
|
149
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
150
|
+
zf.extractall(self.data_dir)
|
|
151
|
+
|
|
152
|
+
nested_dir = self.data_dir / "afdb-1.0.0"
|
|
153
|
+
if nested_dir.is_dir():
|
|
154
|
+
for item in nested_dir.iterdir():
|
|
155
|
+
dest = self.data_dir / item.name
|
|
156
|
+
if dest.exists():
|
|
157
|
+
if dest.is_dir():
|
|
158
|
+
shutil.rmtree(dest)
|
|
159
|
+
else:
|
|
160
|
+
dest.unlink()
|
|
161
|
+
shutil.move(str(item), str(dest))
|
|
162
|
+
nested_dir.rmdir()
|
|
163
|
+
|
|
164
|
+
# PhysioNet zips may nest records inside a files/ subdirectory
|
|
165
|
+
files_dir = self.data_dir / "files"
|
|
166
|
+
if files_dir.is_dir() and any(files_dir.glob("*.hea")):
|
|
167
|
+
for item in files_dir.iterdir():
|
|
168
|
+
dest = self.data_dir / item.name
|
|
169
|
+
if dest.exists():
|
|
170
|
+
if dest.is_dir():
|
|
171
|
+
shutil.rmtree(dest)
|
|
172
|
+
else:
|
|
173
|
+
dest.unlink()
|
|
174
|
+
shutil.move(str(item), str(dest))
|
|
175
|
+
files_dir.rmdir()
|
|
176
|
+
|
|
177
|
+
zip_path.unlink(missing_ok=True)
|
|
178
|
+
|
|
179
|
+
if self.verbose:
|
|
180
|
+
print("Download complete!")
|
|
181
|
+
|
|
182
|
+
def _load_data(self):
|
|
183
|
+
if self.verbose:
|
|
184
|
+
print("Loading MIT-BIH AFDB data...")
|
|
185
|
+
|
|
186
|
+
# PhysioNet zips may nest records inside a files/ subdirectory
|
|
187
|
+
files_dir = self.data_dir / "files"
|
|
188
|
+
if (
|
|
189
|
+
not any(self.data_dir.glob("*.hea"))
|
|
190
|
+
and files_dir.is_dir()
|
|
191
|
+
and any(files_dir.glob("*.hea"))
|
|
192
|
+
):
|
|
193
|
+
self.data_dir = files_dir
|
|
194
|
+
|
|
195
|
+
if not any(self.data_dir.glob("*.hea")):
|
|
196
|
+
raise FileNotFoundError(
|
|
197
|
+
f"No record files found in {self.data_dir}. Expected .hea files from MIT-BIH AFDB."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
for record_name in self.RECORD_NAMES:
|
|
201
|
+
record_path = self.data_dir / record_name
|
|
202
|
+
|
|
203
|
+
if not (self.data_dir / f"{record_name}.hea").exists():
|
|
204
|
+
if self.verbose:
|
|
205
|
+
print(f"Skipping {record_name} (not found)")
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
record = wfdb.rdrecord(str(record_path))
|
|
210
|
+
annotation = wfdb.rdann(str(record_path), "atr")
|
|
211
|
+
|
|
212
|
+
signals = record.p_signal.T
|
|
213
|
+
|
|
214
|
+
if not self.use_both_leads:
|
|
215
|
+
signals = signals[0:1, :]
|
|
216
|
+
|
|
217
|
+
standardized_signal = self.standardizer.resample(signals, self.SAMPLING_RATE)
|
|
218
|
+
|
|
219
|
+
labels = self.rhythm_extractor.extract_labels(
|
|
220
|
+
annotation, standardized_signal.shape[-1], self.SAMPLING_RATE
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
segments, start_indices = self.segmenter.segment(standardized_signal)
|
|
224
|
+
|
|
225
|
+
if len(segments) == 0:
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
segment_labels = self.rhythm_extractor.segment_with_labels(
|
|
229
|
+
labels, start_indices, self.segmenter.segment_length
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
for segment, label in zip(segments, segment_labels):
|
|
233
|
+
normalized_segment = self.standardizer.normalize(segment)
|
|
234
|
+
self.signals.append(normalized_segment)
|
|
235
|
+
self.labels.append(label)
|
|
236
|
+
self.record_names.append(record_name)
|
|
237
|
+
|
|
238
|
+
if self.verbose:
|
|
239
|
+
print(f"Loaded {record_name}: {len(segments)} segments")
|
|
240
|
+
|
|
241
|
+
except Exception as e:
|
|
242
|
+
if self.verbose:
|
|
243
|
+
print(f"Error loading {record_name}: {e}")
|
|
244
|
+
|
|
245
|
+
if self.verbose:
|
|
246
|
+
print(f"Total segments loaded: {len(self.signals)}")
|
|
247
|
+
self._print_class_distribution()
|
|
248
|
+
|
|
249
|
+
def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
250
|
+
signal = self.signals[idx]
|
|
251
|
+
label = self.labels[idx]
|
|
252
|
+
|
|
253
|
+
signal = convert_to_tensor(signal, dtype=torch.float32)
|
|
254
|
+
label = torch.tensor(label, dtype=torch.long)
|
|
255
|
+
|
|
256
|
+
if self.transform is not None:
|
|
257
|
+
signal = self.transform(signal)
|
|
258
|
+
|
|
259
|
+
if self.target_transform is not None:
|
|
260
|
+
label = self.target_transform(label)
|
|
261
|
+
|
|
262
|
+
return signal, label
|
|
263
|
+
|
|
264
|
+
def __len__(self) -> int:
|
|
265
|
+
return len(self.labels)
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def num_classes(self) -> int:
|
|
269
|
+
return 2 if self.binary_classification else len(self.CLASS_LABELS)
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def class_names(self) -> List[str]:
|
|
273
|
+
if self.binary_classification:
|
|
274
|
+
return ["Non-AF", "AF"]
|
|
275
|
+
return self.CLASS_LABELS
|
|
276
|
+
|
|
277
|
+
def get_record_info(self, idx: int) -> Dict:
|
|
278
|
+
return {
|
|
279
|
+
"record_name": self.record_names[idx],
|
|
280
|
+
"label": self.labels[idx],
|
|
281
|
+
"class_name": self.class_names[self.labels[idx]],
|
|
282
|
+
"signal_shape": self.signals[idx].shape,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
def get_class_distribution(self) -> Dict[str, int]:
|
|
286
|
+
unique, counts = np.unique(self.labels, return_counts=True)
|
|
287
|
+
return {self.class_names[int(label)]: int(count) for label, count in zip(unique, counts)}
|