Make 10 ECGS and diagnosis
ls /home/daytona/workspace/ 2>/dev/null || mkdir -p /home/daytona/workspace/ecg-…
mkdir -p /home/daytona/workspace/ecg-cases && ls /home/daytona/workspace/ecg-cas…
~/ecg-cases/generate_ecgs.py
#!/usr/bin/env python3
"""
Generate 10 ECG cases with text/grid representation and diagnoses.
Uses matplotlib to render ASCII-style ECG waveforms on a grid background.
"""
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
from matplotlib.gridspec import GridSpec
import os
OUT_DIR = "/home/daytona/workspace/ecg-cases/output"
os.makedirs(OUT_DIR, exist_ok=True)
# ── helpers ──────────────────────────────────────────────────────────────────
def draw_grid(ax, x_max, y_min=-2.5, y_max=2.5):
"""Draw standard ECG grid (large = 5 mm, small = 1 mm)."""
ax.set_facecolor('#fff8f0')
# minor 1 mm
ax.set_xticks(np.arange(0, x_max, 0.04), minor=True)
ax.set_yticks(np.arange(y_min, y_max, 0.1), minor=True)
# major 5 mm
ax.set_xticks(np.arange(0, x_max, 0.2))
ax.set_yticks(np.arange(y_min, y_max, 0.5))
ax.grid(which='major', color='#e09090', linewidth=0.8, linestyle='-')
ax.grid(which='minor', color='#f0c0c0', linewidth=0.3, linestyle='-')
ax.tick_params(labelbottom=False, labelleft=False, length=0)
ax.set_xlim(0, x_max)
ax.set_ylim(y_min, y_max)
def plot_ecg(ax, signal, fs=250):
"""Plot a single ECG lead."""
t = np.arange(len(signal)) / fs
ax.plot(t, signal, color='black', linewidth=1.0)
# ── waveform generators ───────────────────────────────────────────────────────
def gaussian(x, mu, sigma, amp):
return amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
def normal_beat(t_offset=0, hr=72, baseline=0):
"""Return one normal sinus beat array at 250 Hz."""
fs = 250
rr = 60 / hr
n = int(rr * fs)
t = np.linspace(0, rr, n)
sig = np.zeros(n)
# P wave
sig += gaussian(t, 0.10, 0.025, 0.15)
# QRS
sig += gaussian(t, 0.22, 0.008, -0.1) # Q
sig += gaussian(t, 0.24, 0.010, 1.5) # R
sig += gaussian(t, 0.26, 0.008, -0.15) # S
# T wave
sig += gaussian(t, 0.38, 0.040, 0.35)
return sig + baseline
def make_repeating(beat_fn, n_beats, **kwargs):
return np.concatenate([beat_fn(**kwargs) for _ in range(n_beats)])
# ── 10 ECG cases ─────────────────────────────────────────────────────────────
CASES = []
# ─── Case 1: Normal Sinus Rhythm ─────────────────────────────────────────────
def case1():
fs = 250
beats = []
for _ in range(6):
beats.append(normal_beat(hr=72))
return np.concatenate(beats)
CASES.append({
"title": "Case 1 — 45-year-old male, routine checkup",
"vitals": "HR 72 bpm | BP 120/80 | No symptoms",
"signal": case1(),
"lead": "Lead II",
"diagnosis": "NORMAL SINUS RHYTHM",
"findings": [
"Rate: 72 bpm (regular)",
"P waves: upright, precede every QRS, uniform morphology",
"PR interval: 160 ms (normal 120–200 ms)",
"QRS duration: 80 ms (narrow, normal <120 ms)",
"QT interval: 380 ms (normal)",
"ST segment: isoelectric",
"T waves: upright, concordant",
],
"teaching": (
"Normal sinus rhythm requires: rate 60–100 bpm, P before every QRS, "
"regular P-P and R-R intervals, PR 120–200 ms, QRS <120 ms. "
"All criteria are met here."
),
})
# ─── Case 2: Atrial Fibrillation ─────────────────────────────────────────────
def case2():
fs = 250
rng = np.random.default_rng(42)
sig = []
t = 0
while t < 2500:
rr_var = int(rng.integers(150, 350)) # irregularly irregular
beat_t = np.linspace(0, rr_var / fs, rr_var)
beat = np.zeros(rr_var)
# fibrillatory baseline
for f in [4, 6, 8, 10, 12]:
beat += rng.uniform(0.02, 0.06) * np.sin(
2 * np.pi * f * beat_t + rng.uniform(0, 2 * np.pi))
# QRS (no P wave)
mid = rr_var // 2
beat += gaussian(beat_t, beat_t[mid - 8], 0.010, -0.08)
beat += gaussian(beat_t, beat_t[mid], 0.010, 1.4)
beat += gaussian(beat_t, beat_t[mid + 8], 0.010, -0.12)
# T wave
beat += gaussian(beat_t, beat_t[mid] + 0.14, 0.040, 0.28)
sig.append(beat)
t += rr_var
return np.concatenate(sig)[:2500]
CASES.append({
"title": "Case 2 — 68-year-old male, palpitations × 3 hours",
"vitals": "HR ~110 bpm (irregular) | BP 135/88 | SpO₂ 97%",
"signal": case2(),
"lead": "Lead II",
"diagnosis": "ATRIAL FIBRILLATION (uncontrolled ventricular rate)",
"findings": [
"Rate: 100–130 bpm (irregularly irregular)",
"No discernible P waves — replaced by fibrillatory (f) waves",
"R-R intervals: variable (hallmark of AF)",
"QRS: narrow (<120 ms) — normal ventricular conduction",
"ST segment: no significant deviation",
],
"teaching": (
"AF key criteria: absent P waves, irregularly irregular rhythm, "
"narrow QRS (unless aberrant conduction/WPW). "
"Rate control target <110 bpm at rest. Always assess CHA₂DS₂-VASc for anticoagulation."
),
})
# ─── Case 3: ST-Elevation MI (Inferior) ──────────────────────────────────────
def case3():
fs = 250
beats = []
for _ in range(5):
n = int(250 * (60 / 88))
t = np.linspace(0, 60/88, n)
sig = np.zeros(n)
sig += gaussian(t, 0.10, 0.025, 0.14) # P
sig += gaussian(t, 0.22, 0.008, -0.08) # Q (deep in inferior)
sig += gaussian(t, 0.24, 0.010, 1.3) # R
sig += gaussian(t, 0.26, 0.008, -0.1) # S
# ST elevation 2 mm + hyperacute T
sig += 0.2 * (t > 0.27) * (t < 0.50) * np.exp(-8*(t - 0.37))
sig += gaussian(t, 0.44, 0.045, 0.65) # hyperacute T
beats.append(sig)
return np.concatenate(beats)
CASES.append({
"title": "Case 3 — 58-year-old male, crushing chest pain × 45 min",
"vitals": "HR 88 bpm | BP 100/65 | Diaphoretic",
"signal": case3(),
"lead": "Lead II (inferior leads II, III, aVF affected)",
"diagnosis": "INFERIOR STEMI — Right Coronary Artery (RCA) occlusion",
"findings": [
"ST elevation ≥2 mm in II, III, aVF",
"Reciprocal ST depression in I, aVL",
"Hyperacute (tall, peaked) T waves",
"Q waves beginning to form (early infarct)",
"HR 88 bpm, sinus rhythm",
],
"teaching": (
"Inferior STEMI: ST↑ in II, III, aVF with reciprocal changes in I/aVL. "
"RCA supplies inferior wall in 85% of patients. "
"Always check right-sided leads (V3R, V4R) for RV infarct. "
"Door-to-balloon time <90 min. Avoid nitrates if RV involvement."
),
})
# ─── Case 4: Complete Heart Block (3rd Degree AV Block) ──────────────────────
def case4():
fs = 250
duration = 2500
sig = np.zeros(duration)
t = np.arange(duration) / fs
# P waves at 75 bpm (atrial rate) — marching through
p_interval = int(fs * 60 / 75)
for p in range(0, duration, p_interval):
for i, pt in enumerate(t):
idx = int(p + i)
if idx < duration:
sig[idx] += gaussian(np.array([t[min(idx, duration-1)]]),
t[p] + 0.10, 0.025, 0.14)[0]
# Escape QRS at 38 bpm (junctional escape) — wide
qrs_interval = int(fs * 60 / 38)
for q in range(int(qrs_interval * 0.3), duration, qrs_interval):
bt = np.linspace(0, 0.6, int(0.6 * fs))
beat = np.zeros(int(0.6 * fs))
beat += gaussian(bt, 0.20, 0.025, -0.15)
beat += gaussian(bt, 0.25, 0.018, 1.2)
beat += gaussian(bt, 0.30, 0.018, -0.25)
beat += gaussian(bt, 0.45, 0.060, 0.4) # wide T
end = min(q + len(beat), duration)
sig[q:end] += beat[:end - q]
return sig
CASES.append({
"title": "Case 4 — 72-year-old female, syncope, near-fall",
"vitals": "HR 38 bpm | BP 88/55 | Pale",
"signal": case4(),
"lead": "Lead II",
"diagnosis": "COMPLETE (3rd DEGREE) AV BLOCK with junctional escape",
"findings": [
"Atrial rate: 75 bpm (regular P waves)",
"Ventricular rate: 38 bpm (regular but independent)",
"AV dissociation: P waves and QRS march independently",
"QRS: wide (~140 ms) — ventricular escape rhythm",
"No relationship between P waves and QRS (PR interval varies)",
],
"teaching": (
"3rd degree AV block: complete failure of AV conduction. "
"Atria and ventricles beat independently. "
"Escape rhythm source determines QRS width: junctional (narrow) vs ventricular (wide). "
"Immediate transcutaneous pacing, then permanent pacemaker. "
"Causes: inferior MI (RCA), Lyme disease, medication toxicity (digoxin, beta-blockers)."
),
})
# ─── Case 5: Ventricular Tachycardia ─────────────────────────────────────────
def case5():
fs = 250
duration = 2500
t = np.arange(duration) / fs
sig = np.zeros(duration)
vt_rate = 160
vt_interval = fs * 60 // vt_rate
for q in range(0, duration, vt_interval):
bt = np.linspace(0, 0.4, int(0.4 * fs))
beat = np.zeros(int(0.4 * fs))
beat += gaussian(bt, 0.08, 0.030, -0.3) # broad initial deflection
beat += gaussian(bt, 0.16, 0.030, 1.6) # tall R
beat += gaussian(bt, 0.24, 0.030, -0.5) # broad S
beat += gaussian(bt, 0.33, 0.040, -0.4) # discordant T
end = min(q + len(beat), duration)
sig[q:end] += beat[:end - q]
return sig
CASES.append({
"title": "Case 5 — 55-year-old male, sudden palpitations, presyncope",
"vitals": "HR 160 bpm (regular) | BP 85/50 | Diaphoretic",
"signal": case5(),
"lead": "Lead II",
"diagnosis": "VENTRICULAR TACHYCARDIA (monomorphic)",
"findings": [
"Rate: 160 bpm, regular",
"QRS: wide (>140 ms), bizarre morphology",
"No visible P waves (buried or absent)",
"AV dissociation (if P waves visible, march independently)",
"Concordance across precordial leads (positive)",
"Northwest axis deviation",
],
"teaching": (
"VT criteria (Brugada algorithm): absence of RS in precordial leads, "
"RS interval >100 ms, AV dissociation, morphology criteria (LBBB/RBBB pattern). "
"Wide complex tachycardia = VT until proven otherwise. "
"Hemodynamically unstable: immediate synchronized cardioversion. "
"Stable: amiodarone 150 mg IV. Check K⁺, Mg²⁺, ischemia."
),
})
# ─── Case 6: Wolff-Parkinson-White (WPW) ─────────────────────────────────────
def case6():
fs = 250
beats = []
for _ in range(5):
n = int(fs * 60 / 78)
t = np.linspace(0, 60/78, n)
sig = np.zeros(n)
sig += gaussian(t, 0.10, 0.022, 0.14) # P wave
# Delta wave (slurred upstroke) + short PR
delta_start = 0.17
sig += 0.3 * np.clip((t - delta_start) / 0.025, 0, 1) * \
np.exp(-4 * np.maximum(t - delta_start - 0.030, 0))
sig += gaussian(t, 0.215, 0.010, 1.3) # R
sig += gaussian(t, 0.240, 0.010, -0.25) # S
sig += gaussian(t, 0.37, 0.040, -0.30) # discordant T (negative)
beats.append(sig)
return np.concatenate(beats)
CASES.append({
"title": "Case 6 — 28-year-old female, episodic palpitations since teens",
"vitals": "HR 78 bpm | BP 118/76 | Currently asymptomatic",
"signal": case6(),
"lead": "Lead II",
"diagnosis": "WOLFF-PARKINSON-WHITE SYNDROME (WPW)",
"findings": [
"Short PR interval: <120 ms",
"Delta wave: slurred upstroke of QRS (pre-excitation)",
"Wide QRS: >120 ms (from delta wave contribution)",
"ST-T changes: secondary, discordant to delta/QRS",
"Tachyarrhythmia history: AVRT (SVT via accessory pathway)",
],
"teaching": (
"WPW triad: short PR + delta wave + wide QRS. "
"Accessory pathway (Bundle of Kent) bypasses AV node → ventricular pre-excitation. "
"Risk: AF with rapid ventricular response (can degenerate to VF — AVOID AV nodal blockers). "
"Definitive treatment: radiofrequency catheter ablation."
),
})
# ─── Case 7: Hyperkalemia ────────────────────────────────────────────────────
def case7():
fs = 250
beats = []
for _ in range(5):
n = int(fs * 60 / 60)
t = np.linspace(0, 1.0, n)
sig = np.zeros(n)
# Flattened P wave
sig += gaussian(t, 0.10, 0.040, 0.06)
# QRS widened
sig += gaussian(t, 0.22, 0.012, -0.08)
sig += gaussian(t, 0.25, 0.018, 1.2)
sig += gaussian(t, 0.28, 0.012, -0.15)
# TALL peaked T wave (tent-shaped)
sig += gaussian(t, 0.42, 0.025, 0.90)
beats.append(sig)
return np.concatenate(beats)
CASES.append({
"title": "Case 7 — 65-year-old male, CKD stage 4, weakness",
"vitals": "HR 60 bpm | BP 155/95 | K⁺ = 7.1 mEq/L",
"signal": case7(),
"lead": "Lead II / V5",
"diagnosis": "HYPERKALEMIA (severe, K⁺ ≈ 7.1 mEq/L)",
"findings": [
"Tall, narrow, peaked (tent-shaped) T waves — earliest sign",
"Flattened/absent P waves (severe hyperkalemia)",
"PR interval prolongation",
"QRS widening (>100 ms) — sine-wave pattern if worsens",
"Sinus rate 60 bpm",
],
"teaching": (
"Hyperkalemia ECG progression: peaked T → flat P → wide QRS → sine wave → VF. "
"Immediate management: "
"(1) Ca²⁺ gluconate 1 g IV (membrane stabilization within 1–3 min), "
"(2) Insulin 10 U + dextrose (shift K⁺ into cells), "
"(3) Sodium bicarbonate (if acidosis), "
"(4) Kayexalate/patiromer/SZC (excretion), "
"(5) Dialysis if refractory."
),
})
# ─── Case 8: Left Bundle Branch Block (LBBB) ─────────────────────────────────
def case8():
fs = 250
beats = []
for _ in range(5):
n = int(fs * 60 / 70)
t = np.linspace(0, 60/70, n)
sig = np.zeros(n)
sig += gaussian(t, 0.10, 0.025, 0.14) # P
# Wide notched R in V5/V6 pattern (LBBB)
sig += gaussian(t, 0.21, 0.010, -0.05) # no Q (absent septal Q)
sig += gaussian(t, 0.26, 0.022, 0.9) # broad R peak 1
sig += gaussian(t, 0.31, 0.022, 1.1) # broad R peak 2 (notch)
sig += gaussian(t, 0.36, 0.015, -0.3) # S descent
# Discordant T wave (opposite to QRS)
sig += gaussian(t, 0.52, 0.045, -0.45)
beats.append(sig)
return np.concatenate(beats)
CASES.append({
"title": "Case 8 — 62-year-old female, dyspnea on exertion",
"vitals": "HR 70 bpm | BP 142/88 | History of HTN, no chest pain",
"signal": case8(),
"lead": "Lead V5 (lateral)",
"diagnosis": "LEFT BUNDLE BRANCH BLOCK (LBBB) — New onset",
"findings": [
"QRS duration: ≥120 ms (broad)",
"Broad, notched (M-shaped) R wave in lateral leads (I, aVL, V5–V6)",
"Absent septal Q waves in I, V5, V6",
"ST and T wave changes: discordant (opposite to QRS direction)",
"Left axis deviation",
],
"teaching": (
"LBBB criteria (Sgarbossa if STEMI suspected): QRS ≥120 ms, broad notched R in V5/V6, "
"no septal Q in I/V5/V6, discordant ST-T. "
"NEW LBBB = STEMI equivalent until proven otherwise (activates cath lab). "
"Old LBBB: look for concordant ST elevation ≥1 mm or "
"ST elevation ≥5 mm discordant (Sgarbossa criteria)."
),
})
# ─── Case 9: Pulmonary Embolism ───────────────────────────────────────────────
def case9():
"""S1Q3T3 pattern + sinus tachycardia + RBBB features."""
fs = 250
beats = []
for _ in range(6):
n = int(fs * 60 / 105)
t = np.linspace(0, 60/105, n)
sig = np.zeros(n)
sig += gaussian(t, 0.08, 0.020, 0.13) # P
# S1: deep S in lead I (shown here)
sig += gaussian(t, 0.19, 0.009, -0.06) # Q
sig += gaussian(t, 0.21, 0.010, 1.1) # R
sig += gaussian(t, 0.24, 0.012, -0.45) # deep S (S1 pattern)
# RBBB pattern: rSR' (terminal R')
sig += gaussian(t, 0.29, 0.012, 0.35) # R' (terminal conduction delay)
# T wave inversion (Q3T3 — lead III equivalent)
sig += gaussian(t, 0.40, 0.035, -0.28) # T inversion
beats.append(sig)
return np.concatenate(beats)
CASES.append({
"title": "Case 9 — 38-year-old female, acute dyspnea, pleuritic chest pain",
"vitals": "HR 105 bpm | BP 105/70 | SpO₂ 91% | Recent long-haul flight",
"signal": case9(),
"lead": "Lead I / III composite (S₁Q₃T₃ pattern shown)",
"diagnosis": "PULMONARY EMBOLISM (massive/submassive)",
"findings": [
"Sinus tachycardia: 105 bpm",
"S1Q3T3 pattern: deep S in I, Q wave + T inversion in III",
"Incomplete RBBB (rSR' in V1, wide S in V6)",
"T wave inversions V1–V4 (RV strain)",
"Right axis deviation",
],
"teaching": (
"PE ECG findings (none are specific or sensitive): sinus tachycardia (most common), "
"S1Q3T3, RBBB, T inversions V1–V4, right axis deviation. "
"ECG normal in ~40% of PE. Diagnose with CT-PA (preferred) or V/Q scan. "
"Massive PE (hemodynamic instability): systemic thrombolysis or embolectomy. "
"Submassive: anticoagulation ± catheter-directed thrombolysis."
),
})
# ─── Case 10: Torsades de Pointes (TdP) ──────────────────────────────────────
def case10():
fs = 250
rng = np.random.default_rng(7)
duration = 2500
sig = np.zeros(duration)
# Preceding long QT beat
for q_start in [0, 280]:
bt = np.linspace(0, 1.1, int(1.1 * fs))
beat = np.zeros(int(1.1 * fs))
beat += gaussian(bt, 0.10, 0.022, 0.13)
beat += gaussian(bt, 0.22, 0.008, -0.07)
beat += gaussian(bt, 0.24, 0.010, 1.2)
beat += gaussian(bt, 0.26, 0.008, -0.12)
beat += gaussian(bt, 0.65, 0.070, 0.40) # very late T = long QT
end = min(q_start + len(beat), duration)
sig[q_start:end] += beat[:end - q_start]
# TdP burst: twisting QRS around baseline
burst_start = 650
burst_rate = 220
burst_interval = int(fs * 60 / burst_rate)
amplitude_env = np.sin(np.linspace(0, 3 * np.pi, (duration - burst_start)))
t_burst = np.arange(duration - burst_start) / fs
for i, q in enumerate(range(burst_start, duration, burst_interval)):
amp = 0.8 * abs(np.sin(i * 0.35))
sign = 1 if np.sin(i * 0.35) >= 0 else -1
bt = np.linspace(0, burst_interval / fs, burst_interval)
beat = sign * amp * (
gaussian(bt, bt[-1]*0.3, 0.010, -0.15) +
gaussian(bt, bt[-1]*0.5, 0.015, 1.0) +
gaussian(bt, bt[-1]*0.7, 0.010, -0.20)
)
end = min(q + burst_interval, duration)
sig[q:end] += beat[:end - q]
return sig
CASES.append({
"title": "Case 10 — 54-year-old female, recurrent syncope on amiodarone",
"vitals": "HR variable | BP unobtainable during episode | QTc 560 ms baseline",
"signal": case10(),
"lead": "Lead II — rhythm strip",
"diagnosis": "TORSADES DE POINTES (TdP) — Drug-induced Long QT",
"findings": [
"Preceding prolonged QT interval (QTc ≥500 ms)",
"Classic twisting of QRS axis around isoelectric baseline",
"Rate 200–250 bpm during TdP",
"Polymorphic VT morphology — QRS amplitude waxes and wanes",
"Initiated by short-long-short R-R sequence (pause-dependent)",
],
"teaching": (
"TdP = polymorphic VT in setting of prolonged QT. "
"Causes: QT-prolonging drugs (amiodarone, sotalol, haloperidol, macrolides, fluoroquinolones), "
"hypokalemia, hypomagnesemia, congenital LQTS. "
"Treatment: Mg²⁺ sulfate 2 g IV bolus (drug of choice), "
"correct electrolytes, overdrive pacing, isoproterenol (increase HR to shorten QT). "
"Stop offending drug. AVOID amiodarone and other QT-prolongers."
),
})
# ── render ────────────────────────────────────────────────────────────────────
def render_case(case, idx, out_dir):
fig = plt.figure(figsize=(16, 9), facecolor='white')
fig.patch.set_facecolor('white')
# Layout
gs = GridSpec(3, 1, figure=fig,
height_ratios=[0.28, 1.5, 0.55],
hspace=0.35)
# ── Header ──
ax_head = fig.add_subplot(gs[0])
ax_head.axis('off')
ax_head.text(0.01, 0.85, case["title"],
fontsize=14, fontweight='bold', color='#1a1a2e', va='top')
ax_head.text(0.01, 0.45, f"Vitals: {case['vitals']}",
fontsize=10, color='#333333', va='top', style='italic')
ax_head.text(0.01, 0.10, f"Lead shown: {case['lead']}",
fontsize=9, color='#555555', va='top')
# Red diagnosis box
ax_head.text(0.99, 0.50, f"⚡ {case['diagnosis']}",
fontsize=13, fontweight='bold', color='white',
va='center', ha='right',
bbox=dict(facecolor='#c0392b', edgecolor='none',
boxstyle='round,pad=0.4', alpha=0.92))
# ── ECG Trace ──
ax_ecg = fig.add_subplot(gs[1])
fs = 250
sig = case["signal"]
draw_grid(ax_ecg, len(sig) / fs)
plot_ecg(ax_ecg, sig, fs)
ax_ecg.set_ylabel("Amplitude (mV)", fontsize=8, color='#666666')
# 1 mV calibration marker
ax_ecg.annotate('', xy=(0.08, 1.0), xytext=(0.08, 0.0),
arrowprops=dict(arrowstyle='-', color='blue', lw=1.5))
ax_ecg.text(0.06, 0.5, '1 mV', fontsize=7, color='blue', ha='right', va='center')
ax_ecg.set_title("ECG Rhythm Strip | 25 mm/s | 10 mm/mV",
fontsize=9, color='#444444', loc='right', pad=3)
# ── Findings + Teaching ──
ax_info = fig.add_subplot(gs[2])
ax_info.axis('off')
# Findings column
findings_text = "KEY FINDINGS:\n" + "\n".join(f" • {f}" for f in case["findings"])
ax_info.text(0.01, 0.98, findings_text,
fontsize=8.5, va='top', ha='left', color='#1a1a2e',
fontfamily='monospace',
bbox=dict(facecolor='#eaf4fb', edgecolor='#aed6f1',
boxstyle='round,pad=0.5', alpha=0.9))
# Teaching point
ax_info.text(0.62, 0.98,
"TEACHING POINT:\n" + case["teaching"],
fontsize=8, va='top', ha='left', color='#1a1a2e',
wrap=True,
bbox=dict(facecolor='#fef9e7', edgecolor='#f9e79f',
boxstyle='round,pad=0.5', alpha=0.9))
# Page number
ax_info.text(0.99, 0.02, f"Case {idx}/10", fontsize=8,
ha='right', va='bottom', color='#999999')
fname = os.path.join(out_dir, f"ecg_case_{idx:02d}.png")
fig.savefig(fname, dpi=150, bbox_inches='tight', facecolor='white')
plt.close(fig)
print(f" Saved: {fname}")
return fname
# ── main ──────────────────────────────────────────────────────────────────────
print("Generating 10 ECG cases...")
file_list = []
for i, case in enumerate(CASES, start=1):
f = render_case(case, i, OUT_DIR)
file_list.append(f)
print(f"\nDone! {len(file_list)} ECG images saved to {OUT_DIR}")
# ── Combine into a single PDF ─────────────────────────────────────────────────
try:
from reportlab.lib.pagesizes import A4, landscape
from reportlab.platypus import SimpleDocTemplate, Image as RLImage, Spacer
from reportlab.lib.units import inch
pdf_path = "/home/daytona/workspace/ecg-cases/output/10_ECG_Cases.pdf"
doc = SimpleDocTemplate(pdf_path, pagesize=landscape(A4),
leftMargin=0.3*inch, rightMargin=0.3*inch,
topMargin=0.3*inch, bottomMargin=0.3*inch)
story = []
for f in file_list:
img = RLImage(f, width=10.5*inch, height=5.9*inch)
story.append(img)
doc.build(story)
print(f"PDF saved: {pdf_path}")
except Exception as e:
print(f"PDF generation note: {e}")
cd /home/daytona/workspace/ecg-cases && python3 generate_ecgs.py 2>&1
| # | Diagnosis | Key Feature |
|---|---|---|
| 1 | Normal Sinus Rhythm | Rate 72, upright P→QRS, PR 160 ms, QRS 80 ms |
| 2 | Atrial Fibrillation | No P waves, irregularly irregular RR, narrow QRS |
| 3 | Inferior STEMI | ST↑ in II/III/aVF, hyperacute T waves, reciprocal depression I/aVL |
| 4 | Complete (3rd Degree) AV Block | AV dissociation, P-rate 75 independent of escape rate 38, wide QRS |
| 5 | Monomorphic Ventricular Tachycardia | Rate 160, wide bizarre QRS >140 ms, AV dissociation |
| 6 | WPW Syndrome | Short PR <120 ms, delta wave, wide QRS, discordant T waves |
| 7 | Severe Hyperkalemia (K⁺ 7.1) | Tall peaked tent-shaped T waves, flat P waves, QRS widening |
| 8 | Left Bundle Branch Block (New) | QRS ≥120 ms, notched M-shaped R in V5/V6, absent septal Q, discordant ST-T |
| 9 | Pulmonary Embolism | Sinus tachycardia, S₁Q₃T₃, incomplete RBBB, T inversions V1–V4 |
| 10 | Torsades de Pointes | Long QTc 560 ms preceding, twisting polymorphic VT 200–250 bpm |
10 ECG Cases
PDF Document · PDF
ecg case 01 Image · PNG | ecg case 02 Image · PNG | ecg case 03 Image · PNG |
ecg case 04 Image · PNG | ecg case 05 Image · PNG | ecg case 06 Image · PNG |
ecg case 07 Image · PNG | ecg case 08 Image · PNG | ecg case 09 Image · PNG |
ecg case 10 Image · PNG |