"""Synthetic ECG waveform generator rendered as a 1-bit bitmap.
The curve is built by summing a handful of Gaussians modelling the
classic PQRST waves:
* **P** — atrial depolarisation (small positive bump).
* **Q** — start of ventricular depolarisation (small negative dip).
* **R** — ventricular depolarisation (dominant spike).
* **S** — late ventricular depolarisation (small negative dip).
* **T** — ventricular repolarisation (medium positive bump).
Two orientations are available:
* **Landscape** (``portrait=False``) — time runs across the paper width.
* **Portrait** (``portrait=True``) — time runs down the paper, amplitude
runs across the width, giving an unlimited-length recording.
"""
from goojprt.constants import PAPER_WIDTH_PX
[docs]
def render_ekg(
beats: int = 4,
height_px: int = 160,
line_width: int = 2,
grid: bool = True,
grid_step_px: int = 32,
amplitude: float = 0.82,
portrait: bool = False,
px_per_beat: int = 240,
):
"""Render a synthetic ECG strip.
:param beats: Number of consecutive heartbeats on the strip.
:param height_px: Landscape only — strip height in pixels. Ignored
in portrait mode (height becomes ``beats × px_per_beat``).
:param line_width: Curve thickness, 1–3 pixels.
:param grid: Draw the standard dashed ECG grid.
:param grid_step_px: Grid spacing in pixels (``32`` ≈ 4.7 mm at
203 DPI).
:param amplitude: Relative height of the R-wave, 0.0–1.0.
:param portrait: Orientation selector (see module docstring).
:param px_per_beat: Portrait only — pixels per heartbeat along the
paper. More pixels means a more detailed curve.
:returns: PIL image in mode ``"1"``.
"""
import numpy as np
WAVES = np.array([
( 0.14, 0.15, 0.018), # P
(-0.06, 0.245, 0.007), # Q
( 1.00, 0.270, 0.009), # R
(-0.18, 0.300, 0.007), # S
( 0.30, 0.440, 0.045), # T
]) # shape (5, 3): columns are amplitude, mu, sigma
def signal_array(t: np.ndarray) -> np.ndarray:
"""Evaluate ECG signal for an array of beat-local times ``t`` ∈ [0, 1]."""
a, mu, sig = WAVES[:, 0], WAVES[:, 1], WAVES[:, 2]
return np.sum(a * np.exp(-((t[:, None] - mu) ** 2) / (2 * sig ** 2)), axis=1)
if portrait:
return _render_ekg_portrait(
beats, px_per_beat, line_width, grid, grid_step_px,
amplitude, signal_array,
)
return _render_ekg_landscape(
beats, height_px, line_width, grid, grid_step_px,
amplitude, signal_array,
)
def _render_ekg_landscape(beats, height_px, line_width, grid, grid_step_px,
amplitude, signal_array):
"""Render a landscape (time → horizontal) ECG strip."""
import numpy as np
from PIL import Image as PILImage, ImageDraw
width = PAPER_WIDTH_PX
i = np.arange(width)
t = (i % (width / beats)) / (width / beats)
raw = signal_array(t).tolist()
sig_min, sig_max = min(raw), max(raw)
sig_range = sig_max - sig_min or 1.0
pad = int(height_px * 0.08)
draw_h = height_px - 2 * pad
def to_y(v):
"""Map an amplitude sample to its y coordinate."""
norm = (v - sig_min) / sig_range
norm = (norm - 0.5) * amplitude + 0.5
return int(pad + (1.0 - norm) * draw_h)
signal_y = [to_y(v) for v in raw]
img = PILImage.new("L", (width, height_px), 255)
draw = ImageDraw.Draw(img)
if grid:
dash_on, dash_off = 3, 4
for gx in range(0, width, grid_step_px):
y = 0
while y < height_px:
draw.line([(gx, y), (gx, min(y + dash_on, height_px))], fill=180, width=1)
y += dash_on + dash_off
for gy in range(0, height_px, grid_step_px):
x = 0
while x < width:
draw.line([(x, gy), (min(x + dash_on, width), gy)], fill=180, width=1)
x += dash_on + dash_off
# Isoelectric baseline (dashed).
baseline_y = to_y(0.0)
x = 0
while x < width:
draw.line([(x, baseline_y), (min(x + 6, width), baseline_y)], fill=210, width=1)
x += 10
for x in range(width - 1):
draw.line([(x, signal_y[x]), (x + 1, signal_y[x + 1])], fill=0, width=line_width)
return img.convert("1")
def _render_ekg_portrait(beats, px_per_beat, line_width, grid, grid_step_px,
amplitude, signal_array):
"""Render a portrait (time → vertical) ECG strip.
Produces an image of width :data:`PAPER_WIDTH_PX` and height
``beats × px_per_beat``, suitable for long scrolling recordings.
"""
import numpy as np
from PIL import Image as PILImage, ImageDraw
width = PAPER_WIDTH_PX
total_rows = beats * px_per_beat
rows = np.arange(total_rows)
t = (rows % px_per_beat) / px_per_beat
raw = signal_array(t).tolist()
sig_min, sig_max = min(raw), max(raw)
sig_range = sig_max - sig_min or 1.0
pad = int(width * 0.05) # 5% margin on each side
draw_w = width - 2 * pad
def to_x(v: float) -> int:
"""Map an amplitude sample to its x coordinate (centre = zero line)."""
norm = (v - sig_min) / sig_range
norm = (norm - 0.5) * amplitude + 0.5
return int(pad + norm * draw_w)
signal_x = [to_x(v) for v in raw]
img = PILImage.new("L", (width, total_rows), 255)
draw = ImageDraw.Draw(img)
# Grid — horizontal lines every grid_step_px rows (time ticks),
# vertical lines every grid_step_px columns (amplitude ticks).
if grid:
dash_on, dash_off = 3, 4
for gy in range(0, total_rows, grid_step_px):
x = 0
while x < width:
draw.line([(x, gy), (min(x + dash_on, width), gy)], fill=180, width=1)
x += dash_on + dash_off
for gx in range(0, width, grid_step_px):
y = 0
while y < total_rows:
draw.line([(gx, y), (gx, min(y + dash_on, total_rows))], fill=180, width=1)
y += dash_on + dash_off
# Isoelectric baseline — vertical dashed line in the centre.
baseline_x = to_x(0.0)
y = 0
while y < total_rows:
draw.line([(baseline_x, y), (baseline_x, min(y + 6, total_rows))], fill=210, width=1)
y += 10
# Dotted separator at each beat boundary.
for beat_idx in range(1, beats):
sep_y = beat_idx * px_per_beat
x = 0
while x < width:
draw.point((x, sep_y), fill=150)
x += 6
# ECG curve — horizontal = amplitude, vertical = time.
for row in range(total_rows - 1):
draw.line(
[(signal_x[row], row), (signal_x[row + 1], row + 1)],
fill=0, width=line_width,
)
return img.convert("1")