{"spec_id":"ecg-twelve-lead","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\necg-twelve-lead: ECG/EKG 12-Lead Waveform Display\nLibrary: bokeh 3.9.1 | Python 3.13.14\nQuality: 92/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.layouts import column, gridplot\nfrom bokeh.models import ColumnDataSource, HoverTool, Label, Range1d, Span\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme-adaptive chrome (Imprint palette)\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — brand green is the single ECG trace (position 1, always first).\n# The ECG-paper look comes from a red ruling (Imprint matte red #AE3030) over the\n# theme background instead of a custom pink paper fill, keeping the brand surface.\nTRACE_COLOR = \"#009E73\"\nECG_RED = \"#AE3030\"\ngrid_major_alpha = 0.45 if THEME == \"light\" else 0.50\ngrid_minor_alpha = 0.18 if THEME == \"light\" else 0.22\n\n# Data - Synthetic ECG waveform generation using Gaussian pulse model\nnp.random.seed(42)\n\nsampling_rate = 1000\nduration = 2.5\nn_samples = int(sampling_rate * duration)\nt = np.linspace(0, duration, n_samples)\n\nheart_rate_bpm = 72\nbeat_interval = 60.0 / heart_rate_bpm\nbeat_centers = np.arange(beat_interval / 2, duration, beat_interval)\n\n# Gaussian pulse parameters: (center_offset, sigma, amplitude)\np_wave_params = [(-0.18, 0.012, 0.15), (-0.15, 0.012, 0.10)]\nqrs_params = [(-0.04, 0.004, -0.10), (0.0, 0.004, 1.20), (0.03, 0.004, -0.25)]\nt_wave_params = [(0.20, 0.025, 0.30)]\n\n# Lead transformation factors (approximate Einthoven/Goldberger/Wilson relations)\nlead_transforms = {\n    \"I\": {\"scale\": 0.65, \"invert\": False, \"p_scale\": 0.8, \"t_scale\": 0.7},\n    \"II\": {\"scale\": 1.0, \"invert\": False, \"p_scale\": 1.0, \"t_scale\": 1.0},\n    \"III\": {\"scale\": 0.55, \"invert\": False, \"p_scale\": 0.5, \"t_scale\": 0.6},\n    \"aVR\": {\"scale\": 0.75, \"invert\": True, \"p_scale\": 0.9, \"t_scale\": 0.8},\n    \"aVL\": {\"scale\": 0.45, \"invert\": False, \"p_scale\": 0.6, \"t_scale\": 0.5},\n    \"aVF\": {\"scale\": 0.70, \"invert\": False, \"p_scale\": 0.7, \"t_scale\": 0.8},\n    \"V1\": {\"scale\": 0.80, \"invert\": True, \"p_scale\": 0.3, \"t_scale\": 0.4},\n    \"V2\": {\"scale\": 1.10, \"invert\": False, \"p_scale\": 0.4, \"t_scale\": 0.5},\n    \"V3\": {\"scale\": 1.30, \"invert\": False, \"p_scale\": 0.5, \"t_scale\": 0.6},\n    \"V4\": {\"scale\": 1.20, \"invert\": False, \"p_scale\": 0.7, \"t_scale\": 0.8},\n    \"V5\": {\"scale\": 0.90, \"invert\": False, \"p_scale\": 0.8, \"t_scale\": 0.9},\n    \"V6\": {\"scale\": 0.70, \"invert\": False, \"p_scale\": 0.9, \"t_scale\": 0.8},\n}\n\n\ndef synth_beat(t_axis, centers, params):\n    \"\"\"Sum Gaussian P-QRS-T complexes at each beat center for one lead.\"\"\"\n    signal = np.zeros(len(t_axis))\n    for center in centers:\n        t_shifted = t_axis - center\n        for offset, sigma, amp in p_wave_params:\n            signal += amp * params[\"p_scale\"] * np.exp(-((t_shifted - offset) ** 2) / (2 * sigma**2))\n        for offset, sigma, amp in qrs_params:\n            signal += amp * params[\"scale\"] * np.exp(-((t_shifted - offset) ** 2) / (2 * sigma**2))\n        for offset, sigma, amp in t_wave_params:\n            signal += amp * params[\"t_scale\"] * np.exp(-((t_shifted - offset) ** 2) / (2 * sigma**2))\n    if params[\"invert\"]:\n        signal = -signal\n    return signal + np.random.normal(0, 0.015, len(t_axis))\n\n\nleads = {name: synth_beat(t, beat_centers, params) for name, params in lead_transforms.items()}\n\n# Standard clinical 3x4 grid layout\ngrid_layout = [[\"I\", \"aVR\", \"V1\", \"V4\"], [\"II\", \"aVL\", \"V2\", \"V5\"], [\"III\", \"aVF\", \"V3\", \"V6\"]]\n\n# Plot - canvas is a fixed 3200x1800: title band (140) + 3x4 grid (3*390) + rhythm strip (490)\npanel_w = 800  # 4 columns * 800 = 3200\npanel_h = 390  # 3 rows * 390 = 1170\ntitle_h = 140\nrhythm_h = 490\ny_range_mv = 2.0\n\n\ndef add_ecg_grid(p, x_max, y_min, y_max):\n    \"\"\"Draw ECG-paper ruling: bold lines every 5mm (0.2s / 0.5mV), fine every 1mm.\"\"\"\n    for x_major in np.arange(0, x_max + 0.01, 0.2):\n        p.add_layout(\n            Span(location=x_major, dimension=\"height\", line_color=ECG_RED, line_width=2, line_alpha=grid_major_alpha)\n        )\n    for y_major in np.arange(y_min, y_max + 0.01, 0.5):\n        p.add_layout(\n            Span(location=y_major, dimension=\"width\", line_color=ECG_RED, line_width=2, line_alpha=grid_major_alpha)\n        )\n    for x_minor in np.arange(0, x_max + 0.01, 0.04):\n        p.add_layout(\n            Span(location=x_minor, dimension=\"height\", line_color=ECG_RED, line_width=1, line_alpha=grid_minor_alpha)\n        )\n    for y_minor in np.arange(y_min, y_max + 0.01, 0.1):\n        p.add_layout(\n            Span(location=y_minor, dimension=\"width\", line_color=ECG_RED, line_width=1, line_alpha=grid_minor_alpha)\n        )\n\n\ndef style_ecg_panel(p):\n    \"\"\"Apply common ECG panel styling (hidden axes, theme surface).\"\"\"\n    p.xaxis.visible = False\n    p.yaxis.visible = False\n    p.xgrid.grid_line_color = None\n    p.ygrid.grid_line_color = None\n    p.background_fill_color = PAGE_BG\n    p.border_fill_color = PAGE_BG\n    p.outline_line_color = ECG_RED\n    p.outline_line_width = 1\n    p.outline_line_alpha = grid_major_alpha\n    p.min_border = 0\n\n\ndef draw_trace(p, t_axis, signal, label):\n    \"\"\"Add the ECG trace, hover readout, and lead label to a panel.\"\"\"\n    source = ColumnDataSource(data={\"time\": t_axis, \"voltage\": signal})\n    line = p.line(x=\"time\", y=\"voltage\", source=source, line_color=TRACE_COLOR, line_width=3.0, line_alpha=0.95)\n    p.add_tools(\n        HoverTool(\n            renderers=[line],\n            tooltips=[(\"Lead\", label), (\"t\", \"@time{0.000} s\"), (\"mV\", \"@voltage{0.00}\")],\n            mode=\"vline\",\n        )\n    )\n    p.add_layout(\n        Label(x=0.06, y=y_range_mv * 0.72, text=label, text_font_size=\"24pt\", text_font_style=\"bold\", text_color=INK)\n    )\n\n\nfigures = []\nfor lead_row in grid_layout:\n    fig_row = []\n    for col_idx, lead_name in enumerate(lead_row):\n        p = figure(\n            width=panel_w,\n            height=panel_h,\n            x_range=Range1d(0, duration),\n            y_range=Range1d(-y_range_mv, y_range_mv),\n            toolbar_location=None,\n        )\n        add_ecg_grid(p, duration, -y_range_mv, y_range_mv)\n        draw_trace(p, t, leads[lead_name], lead_name)\n\n        # 1mV calibration pulse at the start of the leftmost column\n        if col_idx == 0:\n            p.line(\n                x=[0.02, 0.02, 0.08, 0.08], y=[0.0, 1.0, 1.0, 0.0], line_color=INK_SOFT, line_width=2.5, line_alpha=0.8\n            )\n\n        style_ecg_panel(p)\n        fig_row.append(p)\n    figures.append(fig_row)\n\n# Rhythm strip (Lead II, 10 seconds)\nrhythm_duration = 10.0\nrhythm_t = np.linspace(0, rhythm_duration, int(sampling_rate * rhythm_duration))\nrhythm_centers = np.arange(beat_interval / 2, rhythm_duration, beat_interval)\nrhythm_signal = synth_beat(rhythm_t, rhythm_centers, lead_transforms[\"II\"])\n\np_rhythm = figure(\n    width=panel_w * 4,\n    height=rhythm_h,\n    x_range=Range1d(0, rhythm_duration),\n    y_range=Range1d(-y_range_mv, y_range_mv),\n    toolbar_location=None,\n)\nadd_ecg_grid(p_rhythm, rhythm_duration, -y_range_mv, y_range_mv)\ndraw_trace(p_rhythm, rhythm_t, rhythm_signal, \"II (Rhythm)\")\nstyle_ecg_panel(p_rhythm)\n\n# Title band\np_title = figure(width=panel_w * 4, height=title_h, toolbar_location=None, x_range=Range1d(0, 1), y_range=Range1d(0, 1))\np_title.add_layout(\n    Label(\n        x=0.5,\n        y=0.52,\n        text=\"ecg-twelve-lead · python · bokeh · anyplot.ai\",\n        text_font_size=\"44pt\",\n        text_color=INK,\n        text_align=\"center\",\n    )\n)\np_title.add_layout(\n    Label(\n        x=0.5,\n        y=0.12,\n        text=\"25 mm/s  ·  10 mm/mV  ·  Normal Sinus Rhythm  ·  72 BPM\",\n        text_font_size=\"26pt\",\n        text_color=INK_MUTED,\n        text_align=\"center\",\n    )\n)\np_title.xaxis.visible = False\np_title.yaxis.visible = False\np_title.xgrid.grid_line_color = None\np_title.ygrid.grid_line_color = None\np_title.background_fill_color = PAGE_BG\np_title.border_fill_color = PAGE_BG\np_title.outline_line_color = None\np_title.min_border = 0\n\n# Assemble layout (total 3200x1800)\ngrid = gridplot(figures, toolbar_location=None, merge_tools=False)\nlayout = column(p_title, grid, p_rhythm, spacing=0, background=PAGE_BG)\n\n# Save — HTML artifact, then screenshot it with headless Chrome (export_png is unreliable here)\noutput_file(f\"plot-{THEME}.html\", title=\"ecg-twelve-lead · python · bokeh · anyplot.ai\")\nsave(layout)\n\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\n# Force the rendering viewport to exactly W x H — headless Chrome's window chrome\n# otherwise shrinks the captured viewport (3200x1800 window -> ~3200x1657 screenshot).\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)  # let bokeh's JS render the canvas\ndriver.execute_script(f\"document.body.style.margin='0';document.documentElement.style.background='{PAGE_BG}';\")\ntime.sleep(0.3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}