{"spec_id":"ecg-twelve-lead","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\necg-twelve-lead: ECG/EKG 12-Lead Waveform Display\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme-adaptive chrome (Imprint palette) — single script renders both themes\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette positions used here:\n#   limb leads      -> brand green  #009E73 (first categorical series, always)\n#   precordial V1-6 -> blue         #4467A3\n#   ECG paper grid  -> matte red    #AE3030 (semantic: ECG grid is universally red)\nLIMB = \"#009E73\"\nPRECORDIAL = \"#4467A3\"\n# Grid intensity tuned per theme so the red ECG grid reads on cream and near-black.\nGRID_MAJOR = \"rgba(174,48,48,0.55)\" if THEME == \"light\" else \"rgba(174,48,48,0.60)\"\nGRID_MINOR = \"rgba(174,48,48,0.20)\" if THEME == \"light\" else \"rgba(174,48,48,0.26)\"\n\n# Data — synthetic ECG via Gaussian pulse model (flat script, no helper functions)\nnp.random.seed(42)\nsampling_rate = 1000\nduration = 2.5\nnum_samples = int(sampling_rate * duration)\nt = np.linspace(0, duration, num_samples)\ncycle_duration = 0.8\n\n# Lead parameters: (p_amp, q_amp, r_amp, s_amp, t_amp)\nlead_params = {\n    \"I\": (0.15, -0.08, 0.9, -0.15, 0.25),\n    \"II\": (0.20, -0.10, 1.2, -0.20, 0.35),\n    \"III\": (0.10, -0.05, 0.6, -0.10, 0.20),\n    \"aVR\": (-0.15, 0.05, -0.9, 0.10, -0.25),\n    \"aVL\": (0.08, -0.06, 0.5, -0.08, 0.12),\n    \"aVF\": (0.15, -0.08, 0.8, -0.15, 0.28),\n    \"V1\": (0.10, -0.15, 0.3, -0.80, 0.15),\n    \"V2\": (0.12, -0.20, 0.6, -1.00, 0.30),\n    \"V3\": (0.15, -0.15, 1.0, -0.60, 0.35),\n    \"V4\": (0.18, -0.10, 1.4, -0.30, 0.40),\n    \"V5\": (0.18, -0.08, 1.2, -0.20, 0.35),\n    \"V6\": (0.15, -0.06, 0.9, -0.15, 0.30),\n}\n\n# Generate all lead signals inline\nleads = {}\nfor name, (p_a, q_a, r_a, s_a, t_a) in lead_params.items():\n    signal = np.zeros_like(t)\n    for cycle_start in np.arange(0, duration, cycle_duration):\n        tc = t - cycle_start\n        mask = (tc >= 0) & (tc < cycle_duration)\n        signal[mask] += p_a * np.exp(-((tc[mask] - 0.16) ** 2) / (2 * 0.025**2))\n        signal[mask] += q_a * np.exp(-((tc[mask] - 0.28) ** 2) / (2 * 0.008**2))\n        signal[mask] += r_a * np.exp(-((tc[mask] - 0.30) ** 2) / (2 * 0.012**2))\n        signal[mask] += s_a * np.exp(-((tc[mask] - 0.33) ** 2) / (2 * 0.008**2))\n        signal[mask] += t_a * np.exp(-((tc[mask] - 0.48) ** 2) / (2 * 0.035**2))\n    signal += np.random.normal(0, 0.012, len(t))\n    leads[name] = signal\n\n# Rhythm strip (Lead II, longer duration)\nrhythm_duration = 10.0\nrhythm_samples = int(rhythm_duration * sampling_rate)\nrhythm_t = np.linspace(0, rhythm_duration, rhythm_samples)\nrhythm_signal = np.zeros(rhythm_samples)\nfor cycle_start in np.arange(0, rhythm_duration, cycle_duration):\n    tc = rhythm_t - cycle_start\n    mask = (tc >= 0) & (tc < cycle_duration)\n    rhythm_signal[mask] += 0.20 * np.exp(-((tc[mask] - 0.16) ** 2) / (2 * 0.025**2))\n    rhythm_signal[mask] += -0.10 * np.exp(-((tc[mask] - 0.28) ** 2) / (2 * 0.008**2))\n    rhythm_signal[mask] += 1.2 * np.exp(-((tc[mask] - 0.30) ** 2) / (2 * 0.012**2))\n    rhythm_signal[mask] += -0.20 * np.exp(-((tc[mask] - 0.33) ** 2) / (2 * 0.008**2))\n    rhythm_signal[mask] += 0.35 * np.exp(-((tc[mask] - 0.48) ** 2) / (2 * 0.035**2))\nrhythm_signal += np.random.normal(0, 0.012, rhythm_samples)\n\n# Clinical 3x4 grid layout\ngrid_layout = [[\"I\", \"aVR\", \"V1\", \"V4\"], [\"II\", \"aVL\", \"V2\", \"V5\"], [\"III\", \"aVF\", \"V3\", \"V6\"]]\n\n# Layout parameters (data units)\ncol_width = 2.5\ncol_gap = 0.3\ncol_offset = col_width + col_gap\nrow_height = 4.0\nnum_rows = 3\nnum_cols = 4\namp_scale = 1.5\n\n# Chart coordinate range\nx_min = -0.6\nx_max = num_cols * col_offset + 0.1\ny_min = -num_rows * row_height - 2.5\ny_max = row_height * 0.7\n\n# Canvas (hard rule: landscape 3200x1800) and margins (kept pure so the\n# injected-label affine transform below matches pygal's plot box).\nWIDTH, HEIGHT = 3200, 1800\nM_TOP, M_BOTTOM, M_LEFT, M_RIGHT = 120, 40, 24, 24\n\n# Series colors in add() order: 4 grid + 3 calibration + 12 waveforms + 1 rhythm\ngrid_colors = (GRID_MAJOR, GRID_MAJOR, GRID_MINOR, GRID_MINOR)\ncal_colors = (INK,) * num_rows\nwaveform_colors = ()\nfor row_leads in grid_layout:\n    for lead_name in row_leads:\n        waveform_colors += (PRECORDIAL,) if lead_name.startswith(\"V\") else (LIMB,)\nrhythm_color = (LIMB,)\nall_colors = grid_colors + cal_colors + waveform_colors + rhythm_color\n\necg_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=all_colors,\n    label_font_size=0,\n    major_label_font_size=0,\n    legend_font_size=0,\n    value_font_size=0,\n    stroke_width=2.5,\n    font_family=\"monospace\",\n)\n\nchart = pygal.XY(\n    width=WIDTH,\n    height=HEIGHT,\n    style=ecg_style,\n    show_dots=False,\n    stroke=True,\n    show_x_guides=False,\n    show_y_guides=False,\n    show_legend=False,\n    show_x_labels=False,\n    show_y_labels=False,\n    allow_interruptions=True,\n    js=[],\n    print_values=False,\n    margin_top=M_TOP,\n    margin_bottom=M_BOTTOM,\n    margin_left=M_LEFT,\n    margin_right=M_RIGHT,\n    range=(y_min, y_max),\n    xrange=(x_min, x_max),\n)\n\n# ECG paper grid — major lines every 0.5 mV / 0.2 s, minor every 0.1 mV / 0.04 s\nmajor_h = []\nfor y_val in np.arange(y_min, y_max + 0.01, 0.5):\n    major_h.extend([(x_min, float(y_val)), (x_max, float(y_val)), None])\nchart.add(None, major_h, show_dots=False, stroke_style={\"width\": 1.6})\n\nmajor_v = []\nfor x_val in np.arange(x_min, x_max + 0.01, 0.2):\n    major_v.extend([(float(x_val), y_min), (float(x_val), y_max), None])\nchart.add(None, major_v, show_dots=False, stroke_style={\"width\": 1.6})\n\nminor_h = []\nfor y_val in np.arange(y_min, y_max + 0.01, 0.1):\n    minor_h.extend([(x_min, float(y_val)), (x_max, float(y_val)), None])\nchart.add(None, minor_h, show_dots=False, stroke_style={\"width\": 0.6})\n\nminor_v = []\nfor x_val in np.arange(x_min, x_max + 0.01, 0.04):\n    minor_v.extend([(float(x_val), y_min), (float(x_val), y_max), None])\nchart.add(None, minor_v, show_dots=False, stroke_style={\"width\": 0.6})\n\n# 1 mV calibration pulse at the left margin of each row\nfor row in range(num_rows):\n    y_base = -row * row_height\n    cal = [(-0.42, y_base), (-0.42, y_base + 1.0 * amp_scale), (-0.22, y_base + 1.0 * amp_scale), (-0.22, y_base)]\n    chart.add(None, cal, show_dots=False, stroke_style={\"width\": 3.0, \"linecap\": \"square\", \"linejoin\": \"miter\"})\n\n# ECG waveforms — limb leads green, precordial leads blue (downsampled for size)\nds = 3\nt_ds = t[::ds]\nfor row_idx, row_leads in enumerate(grid_layout):\n    for col_idx, lead_name in enumerate(row_leads):\n        signal = leads[lead_name][::ds] * amp_scale\n        x_off = col_idx * col_offset\n        y_off = -row_idx * row_height\n        pts = list(zip((t_ds + x_off).tolist(), (signal + y_off).tolist(), strict=True))\n        stroke_w = 3.6 if lead_name.startswith(\"V\") else 3.0\n        chart.add(None, pts, show_dots=False, stroke_style={\"width\": stroke_w, \"linecap\": \"round\", \"linejoin\": \"round\"})\n\n# Lead II rhythm strip across the bottom (full width)\nrhythm_x_scale = (x_max - x_min) / rhythm_duration\nrhythm_ds = 4\nrx = rhythm_t[::rhythm_ds] * rhythm_x_scale + x_min\nry = rhythm_signal[::rhythm_ds] * amp_scale + (-num_rows * row_height - 1.0)\nrhythm_pts = list(zip(rx.tolist(), ry.tolist(), strict=True))\nchart.add(None, rhythm_pts, show_dots=False, stroke_style={\"width\": 3.2, \"linecap\": \"round\", \"linejoin\": \"round\"})\n\n# Render SVG, then inject title + lead labels + scale annotation as text.\nsvg = chart.render(is_unicode=True)\n\n# Affine transform: data coords -> source pixels within the pure margin box.\nplot_x0, plot_w = M_LEFT, WIDTH - M_LEFT - M_RIGHT\nplot_y0, plot_h = M_TOP, HEIGHT - M_TOP - M_BOTTOM\n\n\ndef to_px(x, y):\n    px = plot_x0 + (x - x_min) / (x_max - x_min) * plot_w\n    py = plot_y0 + (y_max - y) / (y_max - y_min) * plot_h\n    return px, py\n\n\nlabels_svg = \"\"\n\n# Title (centered, INK)\nlabels_svg += (\n    f'<text x=\"{WIDTH / 2:.0f}\" y=\"78\" font-family=\"monospace\" font-size=\"60\" '\n    f'font-weight=\"bold\" text-anchor=\"middle\" fill=\"{INK}\">'\n    \"ecg-twelve-lead · python · pygal · anyplot.ai</text>\\n\"\n)\n\n# Lead labels above each waveform\nfor row_idx, row_leads in enumerate(grid_layout):\n    for col_idx, lead_name in enumerate(row_leads):\n        x_off = col_idx * col_offset\n        y_off = -row_idx * row_height\n        px, py = to_px(x_off + 0.05, y_off + 1.75)\n        labels_svg += (\n            f'<text x=\"{px:.0f}\" y=\"{py:.0f}\" font-family=\"monospace\" font-size=\"46\" '\n            f'font-weight=\"bold\" fill=\"{INK}\">{lead_name}</text>\\n'\n        )\n\n# Rhythm strip label\nrpx, rpy = to_px(x_min + 0.08, -num_rows * row_height - 1.0 + 1.7)\nlabels_svg += (\n    f'<text x=\"{rpx:.0f}\" y=\"{rpy:.0f}\" font-family=\"monospace\" font-size=\"46\" '\n    f'font-weight=\"bold\" fill=\"{INK}\">II · rhythm</text>\\n'\n)\n\n# Single scale annotation, bottom-right (no longer duplicated with x_title)\nlabels_svg += (\n    f'<text x=\"{WIDTH - M_RIGHT - 6}\" y=\"{HEIGHT - 16}\" font-family=\"monospace\" '\n    f'font-size=\"44\" fill=\"{INK_MUTED}\" text-anchor=\"end\">'\n    \"25 mm/s · 10 mm/mV · 1 mV cal</text>\\n\"\n)\n\nsvg = svg.replace(\"</svg>\", labels_svg + \"</svg>\")\n\n# Save — theme-suffixed PNG (gallery) + HTML (interactive detail view)\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(svg)\ncairosvg.svg2png(bytestring=svg.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\", output_width=WIDTH, output_height=HEIGHT)\n"}