ecg-plotter 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.
ecg_plotter/leads.py ADDED
@@ -0,0 +1,195 @@
1
+ """Lead naming conventions and the classic 3x4 + rhythm layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import TYPE_CHECKING, Any, Union
8
+
9
+ from numpy import asarray, ndarray
10
+
11
+ if TYPE_CHECKING:
12
+ from pandas import DataFrame
13
+
14
+ # Common published order (aVL before aVF). For WFDB records, prefer the
15
+ # header ``sig_name`` via :func:`ecg_plotter.load_wfdb` rather than assuming
16
+ # a fixed channel order.
17
+ LEAD_NAMES: tuple[str, ...] = (
18
+ "I",
19
+ "II",
20
+ "III",
21
+ "aVR",
22
+ "aVL",
23
+ "aVF",
24
+ "V1",
25
+ "V2",
26
+ "V3",
27
+ "V4",
28
+ "V5",
29
+ "V6",
30
+ )
31
+
32
+ # Classic clinical-style sheet: three rows of four columns, plus Lead II rhythm.
33
+ STANDARD_LAYOUT: tuple[tuple[str, ...], ...] = (
34
+ ("I", "aVR", "V1", "V4"),
35
+ ("II", "aVL", "V2", "V5"),
36
+ ("III", "aVF", "V3", "V6"),
37
+ )
38
+
39
+ RHYTHM_LEAD = "II"
40
+
41
+ # Vertical stacking: each lead row occupies this many mV of display space.
42
+ ROW_MV = 4.0
43
+ N_ROWS = 4 # three lead rows + rhythm
44
+ DISPLAY_MV = ROW_MV * N_ROWS # 16 mV full-scale height
45
+
46
+ LeadInput = Union[Mapping[str, ndarray], ndarray, "DataFrame"]
47
+
48
+
49
+ def _is_dataframe(obj: Any) -> bool:
50
+ """Return True if ``obj`` is a pandas DataFrame without importing pandas."""
51
+ cls = type(obj)
52
+ return cls.__name__ == "DataFrame" and cls.__module__.startswith("pandas")
53
+
54
+
55
+ def _is_sample_series(value: Any) -> bool:
56
+ """True when a cell looks like a full lead waveform rather than a scalar."""
57
+ if isinstance(value, (str, bytes)):
58
+ return False
59
+ try:
60
+ arr = asarray(value, dtype=float).reshape(-1)
61
+ except (TypeError, ValueError):
62
+ return False
63
+ return arr.size > 1
64
+
65
+
66
+ def _resolve_df_lead_columns(
67
+ df: DataFrame,
68
+ lead_names: Sequence[str] | None,
69
+ ) -> tuple[list[Any], list[str]]:
70
+ """Return ``(columns to read, names to give them)``.
71
+
72
+ The two lists are the same when the columns are already lead names.
73
+ An unlabelled 12-column table keeps its own column labels for reading
74
+ and is named with :data:`LEAD_NAMES` in that column order.
75
+ """
76
+ if lead_names is not None:
77
+ missing = [name for name in lead_names if name not in df.columns]
78
+ if missing:
79
+ raise KeyError(f"DataFrame is missing lead columns: {missing}")
80
+ names = list(lead_names)
81
+ return names, names
82
+
83
+ known = list(LEAD_NAMES)
84
+ found = [name for name in known if name in df.columns]
85
+ # Preserve clinical order from LEAD_NAMES where possible.
86
+ ordered = [name for name in LEAD_NAMES if name in found]
87
+ ordered.extend(name for name in found if name not in ordered)
88
+ if ordered:
89
+ return ordered, ordered
90
+
91
+ if df.shape[1] == len(LEAD_NAMES):
92
+ # warn -> this function -> _dataframe_to_leads -> as_lead_dict -> caller
93
+ warnings.warn(
94
+ "DataFrame columns are not lead names, so the 12 columns are "
95
+ f"assumed to be {list(LEAD_NAMES)} in that order. "
96
+ "Pass lead_names= if this order is wrong.",
97
+ UserWarning,
98
+ stacklevel=4,
99
+ )
100
+ return list(df.columns), list(LEAD_NAMES)
101
+
102
+ raise ValueError(
103
+ "Could not infer lead columns from DataFrame. "
104
+ f"Expected named leads such as {list(LEAD_NAMES)}, or pass lead_names=..."
105
+ )
106
+
107
+
108
+ def _dataframe_to_leads(
109
+ df: DataFrame,
110
+ *,
111
+ lead_names: Sequence[str] | None = None,
112
+ ) -> dict[str, ndarray]:
113
+ if df.empty:
114
+ raise ValueError("DataFrame is empty.")
115
+
116
+ columns, names = _resolve_df_lead_columns(df, lead_names)
117
+ subset = df.loc[:, columns]
118
+
119
+ # Index by position so an unlabelled table can be renamed to LEAD_NAMES
120
+ # without looking those names up as column titles.
121
+ if len(subset) == 1 and all(
122
+ _is_sample_series(subset.iloc[0, i]) for i in range(subset.shape[1])
123
+ ):
124
+ # Single-row JSONL-style frame: each lead cell holds an array/list.
125
+ out = {
126
+ name: asarray(subset.iloc[0, i], dtype=float).reshape(-1)
127
+ for i, name in enumerate(names)
128
+ }
129
+ else:
130
+ # Wide time-series frame: rows = samples, columns = leads.
131
+ out = {
132
+ name: asarray(subset.iloc[:, i], dtype=float).reshape(-1)
133
+ for i, name in enumerate(names)
134
+ }
135
+
136
+ lengths = {name: len(sig) for name, sig in out.items()}
137
+ if len(set(lengths.values())) != 1:
138
+ raise ValueError(f"All leads must share the same length; got {lengths}.")
139
+ return out
140
+
141
+
142
+ def as_lead_dict(
143
+ leads: LeadInput,
144
+ *,
145
+ lead_names: Sequence[str] | None = None,
146
+ ) -> dict[str, ndarray]:
147
+ """Normalise lead input to ``{name: 1-D float array}``.
148
+
149
+ Amplitudes are expected in millivolts (mV).
150
+
151
+ Parameters
152
+ ----------
153
+ leads
154
+ One of:
155
+
156
+ - mapping of lead name → samples
157
+ - array of shape ``(12, n)`` or ``(n, 12)``
158
+ - ``pandas.DataFrame`` with lead columns (rows = samples), or a
159
+ single-row frame whose lead cells contain arrays/lists
160
+ lead_names
161
+ Names for array columns/rows, or the DataFrame columns to use.
162
+ Defaults to :data:`LEAD_NAMES` for arrays. For DataFrames, columns
163
+ named as leads are used when this is omitted. A 12-column table with
164
+ no lead names is assumed to be :data:`LEAD_NAMES` in column order,
165
+ and a warning is issued.
166
+ """
167
+ if _is_dataframe(leads):
168
+ return _dataframe_to_leads(leads, lead_names=lead_names)
169
+
170
+ if isinstance(leads, Mapping):
171
+ out = {str(k): asarray(v, dtype=float).reshape(-1) for k, v in leads.items()}
172
+ if not out:
173
+ raise ValueError("Lead mapping is empty.")
174
+ lengths = {name: len(sig) for name, sig in out.items()}
175
+ if len(set(lengths.values())) != 1:
176
+ raise ValueError(f"All leads must share the same length; got {lengths}.")
177
+ return out
178
+
179
+ arr = asarray(leads, dtype=float)
180
+ names = tuple(lead_names) if lead_names is not None else LEAD_NAMES
181
+
182
+ if arr.ndim != 2:
183
+ raise ValueError(f"Array input must be 2-D; got shape {arr.shape}.")
184
+
185
+ if arr.shape[0] == len(names):
186
+ rows = arr
187
+ elif arr.shape[1] == len(names):
188
+ rows = arr.T
189
+ else:
190
+ raise ValueError(
191
+ f"Expected a dimension of size {len(names)} for leads {list(names)}; "
192
+ f"got shape {arr.shape}."
193
+ )
194
+
195
+ return {name: rows[i].reshape(-1) for i, name in enumerate(names)}
ecg_plotter/py.typed ADDED
File without changes
ecg_plotter/sheet.py ADDED
@@ -0,0 +1,489 @@
1
+ """Render a classic 12-lead ECG sheet to PNG, PDF, or SVG."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import warnings
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from numpy import arange
11
+
12
+ from ecg_plotter.leads import (
13
+ DISPLAY_MV,
14
+ RHYTHM_LEAD,
15
+ ROW_MV,
16
+ STANDARD_LAYOUT,
17
+ LeadInput,
18
+ as_lead_dict,
19
+ )
20
+
21
+ # Classic ECG thermal-paper look (RGB).
22
+ _PAPER_FACE = "#FFF5F5"
23
+ _GRID_MINOR = "#F2C4C4"
24
+ _GRID_MAJOR = "#E09090"
25
+ _DIVIDER = "#8B3030"
26
+ _TRACE = "#1A1A1A"
27
+ _LABEL = "#1A1A1A"
28
+
29
+ # Greyscale sheet.
30
+ _GREY_FACE = "white"
31
+ _GREY_MINOR = "#B0B0B0"
32
+ _GREY_MAJOR = "#696969"
33
+ _GREY_DIVIDER = "#2A2A2A"
34
+
35
+ # Lead-panel separators (heavier than major grid lines).
36
+ _DIVIDER_LINEWIDTH = 0.9
37
+
38
+ # Fixed footer ID used when ``anonymise=True``.
39
+ ANONYMOUS_ID = "15030416"
40
+
41
+
42
+ def paper_calibration(
43
+ width_in: float,
44
+ height_in: float,
45
+ *,
46
+ dpi: int,
47
+ duration_s: float,
48
+ display_mv: float = DISPLAY_MV,
49
+ ) -> dict[str, float]:
50
+ """Compute physical paper speed and gain from figure geometry.
51
+
52
+ Pixel rounding is applied the same way matplotlib will when saving, so
53
+ the reported mm/s and mm/mV match the written PNG.
54
+
55
+ Parameters
56
+ ----------
57
+ width_in, height_in
58
+ Figure size in inches.
59
+ dpi
60
+ Dots per inch used when saving.
61
+ duration_s
62
+ Duration of the plotted recording in seconds (full trace width).
63
+ display_mv
64
+ Millivolts spanned by the full vertical axis (default 16 = 4 rows × 4 mV).
65
+
66
+ Returns
67
+ -------
68
+ dict
69
+ ``width_px``, ``height_px``, ``width_mm``, ``height_mm``,
70
+ ``speed_mm_s``, ``gain_mm_mv``, ``duration_s``.
71
+ """
72
+ if dpi <= 0:
73
+ raise ValueError("dpi must be positive.")
74
+ if duration_s <= 0:
75
+ raise ValueError("duration_s must be positive.")
76
+ if display_mv <= 0:
77
+ raise ValueError("display_mv must be positive.")
78
+
79
+ width_px = int(round(width_in * dpi))
80
+ height_px = int(round(height_in * dpi))
81
+ width_mm = (width_px / dpi) * 25.4
82
+ height_mm = (height_px / dpi) * 25.4
83
+ return {
84
+ "width_px": float(width_px),
85
+ "height_px": float(height_px),
86
+ "width_mm": width_mm,
87
+ "height_mm": height_mm,
88
+ "speed_mm_s": width_mm / duration_s,
89
+ "gain_mm_mv": height_mm / display_mv,
90
+ "duration_s": duration_s,
91
+ }
92
+
93
+
94
+ def _resolve_figsize(
95
+ *,
96
+ inches: tuple[float, float] | None,
97
+ resolution: tuple[int, int] | None,
98
+ dpi: int,
99
+ ) -> tuple[float, float]:
100
+ if inches is not None and resolution is not None:
101
+ raise ValueError("Provide only one of `inches` or `resolution`, not both.")
102
+ if inches is None and resolution is None:
103
+ raise ValueError("Provide `inches=(w, h)` or `resolution=(width_px, height_px)`.")
104
+ if dpi <= 0:
105
+ raise ValueError("dpi must be positive.")
106
+
107
+ if resolution is not None:
108
+ width_px, height_px = resolution
109
+ if width_px <= 0 or height_px <= 0:
110
+ raise ValueError("resolution values must be positive.")
111
+ return (width_px / dpi, height_px / dpi)
112
+
113
+ width_in, height_in = inches # type: ignore[misc]
114
+ if width_in <= 0 or height_in <= 0:
115
+ raise ValueError("inches values must be positive.")
116
+ return (float(width_in), float(height_in))
117
+
118
+
119
+ def _format_filter_label(
120
+ *,
121
+ notch_hz: float | None,
122
+ bandpass_hz: tuple[float, float] | None,
123
+ filter_label: str | None,
124
+ ) -> str | None:
125
+ if filter_label is not None:
126
+ return filter_label
127
+ parts: list[str] = []
128
+ if notch_hz is not None:
129
+ parts.append(str(int(notch_hz) if float(notch_hz).is_integer() else notch_hz))
130
+ if bandpass_hz is not None:
131
+ low, high = bandpass_hz
132
+ low_s = int(low) if float(low).is_integer() else low
133
+ high_s = int(high) if float(high).is_integer() else high
134
+ parts.append(f"{low_s}-{high_s} Hz")
135
+ if not parts:
136
+ return None
137
+ if len(parts) == 1:
138
+ return parts[0]
139
+ return f"{parts[0]} ~ {parts[1]}"
140
+
141
+
142
+ def _resolve_footer_id(id: str | None, anonymise: bool) -> str | None:
143
+ if anonymise:
144
+ return ANONYMOUS_ID
145
+ return str(id) if id is not None else None
146
+
147
+
148
+ SUPPORTED_FORMATS = frozenset({"png", "pdf", "svg"})
149
+
150
+
151
+ def _resolve_format(save_path: Path, format: str | None) -> str:
152
+ if format is None:
153
+ suffix = Path(save_path).suffix.lstrip(".").lower()
154
+ if not suffix:
155
+ raise ValueError(
156
+ "Could not infer output format from save_path; "
157
+ "pass format='png'|'pdf'|'svg' or use a matching file extension."
158
+ )
159
+ format = suffix
160
+ fmt = format.lower()
161
+ if fmt not in SUPPORTED_FORMATS:
162
+ raise ValueError(
163
+ f"Unsupported format {format!r}; expected one of {sorted(SUPPORTED_FORMATS)}."
164
+ )
165
+ return fmt
166
+
167
+
168
+ def plot(
169
+ leads: LeadInput,
170
+ save_path: str | Path,
171
+ *,
172
+ inches: tuple[float, float] | None = None,
173
+ resolution: tuple[int, int] | None = None,
174
+ dpi: int = 300,
175
+ fs: float = 500.0,
176
+ colour: bool = True,
177
+ lead_names: Sequence[str] | None = None,
178
+ layout: Sequence[Sequence[str]] = STANDARD_LAYOUT,
179
+ rhythm_lead: str = RHYTHM_LEAD,
180
+ linewidth: float = 0.8,
181
+ fontname: str = "DejaVu Serif",
182
+ id: str | None = None,
183
+ anonymise: bool = False,
184
+ loc_text: str | None = None,
185
+ filter: bool = False,
186
+ notch_hz: float | None = None,
187
+ bandpass_hz: tuple[float, float] | None = None,
188
+ filter_label: str | None = None,
189
+ extra_footer: str | None = None,
190
+ format: str | None = None,
191
+ ) -> Path:
192
+ """Plot a 12-lead ECG and save PNG, PDF, or SVG.
193
+
194
+ Size may be given as physical ``inches=(w, h)`` or as
195
+ ``resolution=(width_px, height_px)``. Resolution is converted with
196
+ ``inches = pixels / dpi``. Default ``dpi`` is 300.
197
+
198
+ Colour mode uses a pink ECG-paper grid; greyscale uses a grey grid.
199
+ PNG colour output is RGB (3 channels); PNG greyscale is mode ``L``
200
+ (1 channel). PDF and SVG are written as vector graphics.
201
+
202
+ Paper speed (mm/s) and gain (mm/mV) in the footer are derived from the
203
+ saved figure geometry and the recording duration, so they stay accurate
204
+ when size or DPI change.
205
+
206
+ The trace is split into four equal columns. If the sample count is not
207
+ divisible by 4, the last 1–3 samples are dropped and a warning is raised,
208
+ so the columns and the rhythm strip share one time axis. Speed uses that
209
+ drawn duration.
210
+
211
+ Parameters
212
+ ----------
213
+ leads
214
+ Lead mapping, ``(12, n)`` / ``(n, 12)`` array, or ``pandas.DataFrame``
215
+ with lead columns (or a single-row frame of lead arrays). Amplitudes
216
+ are expected in millivolts (mV).
217
+ save_path
218
+ Output path. Extension may be ``.png``, ``.pdf``, or ``.svg``.
219
+ inches, resolution
220
+ Mutually exclusive figure size specifications.
221
+ dpi
222
+ Output resolution (controls PNG pixel size; also stored as metadata).
223
+ fs
224
+ Sampling frequency in Hz (used for duration, mm/s, and optional filtering).
225
+ colour
226
+ ``True`` → pink paper; ``False`` → greyscale.
227
+ lead_names
228
+ Names for array inputs (default :data:`LEAD_NAMES`).
229
+ layout
230
+ 3×4 lead arrangement.
231
+ rhythm_lead
232
+ Lead used for the continuous bottom strip.
233
+ id, loc_text, extra_footer
234
+ Optional footer strings. ``loc_text`` defaults to a placeholder when
235
+ location metadata is unavailable.
236
+ anonymise
237
+ If ``True``, replace the footer ID with a fixed value
238
+ (``15030416``) so real record IDs cannot leak into ML training images.
239
+ filter
240
+ If ``True``, apply ``notch`` / ``bandpass`` using ``notch_hz`` and
241
+ ``bandpass_hz`` before plotting. If ``False``, those values are only
242
+ used for the footer annotation (signals are left unchanged). Standalone
243
+ filter functions remain available for custom pipelines.
244
+ notch_hz, bandpass_hz, filter_label
245
+ Filter settings and/or footer annotation (e.g. ``60 ~ 0.05-150 Hz``).
246
+ Pass ``filter_label`` to override the auto-built string entirely.
247
+ format
248
+ ``"png"``, ``"pdf"``, or ``"svg"``. If omitted, inferred from
249
+ ``save_path`` suffix.
250
+
251
+ Returns
252
+ -------
253
+ pathlib.Path
254
+ Path to the written file.
255
+ """
256
+ # Agg draws into memory and writes a file. pyplot's default backend may
257
+ # try to open a window (Tk), which fails on headless machines and in CI.
258
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
259
+ from matplotlib.figure import Figure
260
+ from matplotlib.ticker import MultipleLocator
261
+
262
+ if fs <= 0:
263
+ raise ValueError("fs must be positive.")
264
+
265
+ lead_map = as_lead_dict(leads, lead_names=lead_names)
266
+ if filter:
267
+ from ecg_plotter.filters import bandpass, notch
268
+
269
+ if notch_hz is not None:
270
+ lead_map = {
271
+ name: notch(sig, fs=fs, freq=notch_hz) for name, sig in lead_map.items()
272
+ }
273
+ if bandpass_hz is not None:
274
+ lowcut, highcut = bandpass_hz
275
+ lead_map = {
276
+ name: bandpass(sig, fs=fs, lowcut=lowcut, highcut=highcut)
277
+ for name, sig in lead_map.items()
278
+ }
279
+
280
+ # Four equal columns. Drop at most 3 leftover samples so the panels and
281
+ # the rhythm strip share one time axis, and the footer matches what is drawn.
282
+ n_cols = 4
283
+ n_raw = next(iter(lead_map.values())).shape[0]
284
+ col_len = n_raw // n_cols
285
+ if col_len < 1:
286
+ raise ValueError("Recording is too short to split into a 3×4 layout.")
287
+ n_samples = col_len * n_cols
288
+ dropped = n_raw - n_samples
289
+ if dropped:
290
+ warnings.warn(
291
+ f"Dropped the last {dropped} sample(s) so the four columns stay equal "
292
+ f"({n_raw} samples is not divisible by 4).",
293
+ UserWarning,
294
+ stacklevel=2,
295
+ )
296
+ lead_map = {name: sig[:n_samples] for name, sig in lead_map.items()}
297
+
298
+ for row_leads in layout:
299
+ if len(row_leads) != n_cols:
300
+ raise ValueError("Each layout row must contain exactly 4 lead names.")
301
+ for name in row_leads:
302
+ if name not in lead_map:
303
+ raise KeyError(f"Layout lead '{name}' not present in input.")
304
+ if rhythm_lead not in lead_map:
305
+ raise KeyError(f"Rhythm lead '{rhythm_lead}' not present in input.")
306
+
307
+ duration_s = n_samples / fs
308
+ width_in, height_in = _resolve_figsize(inches=inches, resolution=resolution, dpi=dpi)
309
+ calib = paper_calibration(width_in, height_in, dpi=dpi, duration_s=duration_s)
310
+ x = arange(n_samples, dtype=float)
311
+
312
+ fmt = _resolve_format(Path(save_path), format)
313
+ if fmt == "png":
314
+ from PIL.Image import open as open_image
315
+
316
+ if colour:
317
+ face, minor_c, major_c, div_c, trace_c, label_c = (
318
+ _PAPER_FACE,
319
+ _GRID_MINOR,
320
+ _GRID_MAJOR,
321
+ _DIVIDER,
322
+ _TRACE,
323
+ _LABEL,
324
+ )
325
+ else:
326
+ face, minor_c, major_c, div_c, trace_c, label_c = (
327
+ _GREY_FACE,
328
+ _GREY_MINOR,
329
+ _GREY_MAJOR,
330
+ _GREY_DIVIDER,
331
+ "black",
332
+ "black",
333
+ )
334
+
335
+ # Row baselines (mV offsets): top lead row → rhythm strip.
336
+ row_offsets = [DISPLAY_MV - ROW_MV * (i + 0.5) for i in range(len(layout))] + [
337
+ ROW_MV * 0.5
338
+ ]
339
+
340
+ fig = Figure(figsize=(width_in, height_in), dpi=dpi, facecolor=face)
341
+ FigureCanvasAgg(fig)
342
+ try:
343
+ ax = fig.add_axes([0.0, 0.0, 1.0, 1.0], facecolor=face)
344
+
345
+ # Lead panels.
346
+ for row_idx, row_leads in enumerate(layout):
347
+ y0 = row_offsets[row_idx]
348
+ for col_idx, name in enumerate(row_leads):
349
+ start = col_idx * col_len
350
+ stop = start + col_len
351
+ segment = lead_map[name][start:stop] + y0
352
+ ax.plot(
353
+ x[start:stop],
354
+ segment,
355
+ color=trace_c,
356
+ linewidth=linewidth,
357
+ solid_capstyle="round",
358
+ )
359
+ ax.text(
360
+ start + col_len * 0.02,
361
+ y0 + ROW_MV * 0.385,
362
+ name,
363
+ fontsize=10,
364
+ fontname=fontname,
365
+ color=label_c,
366
+ )
367
+
368
+ # Rhythm strip (full width of the samples actually drawn).
369
+ rhythm = lead_map[rhythm_lead] + row_offsets[-1]
370
+ ax.plot(x, rhythm, color=trace_c, linewidth=linewidth, solid_capstyle="round")
371
+ ax.text(
372
+ n_samples * 0.005,
373
+ row_offsets[-1] + ROW_MV * 0.385,
374
+ rhythm_lead,
375
+ fontsize=10,
376
+ fontname=fontname,
377
+ color=label_c,
378
+ )
379
+
380
+ # Horizontal dividers between rows (at 12, 8, 4 mV for the default stack).
381
+ for y in (ROW_MV * 3, ROW_MV * 2, ROW_MV):
382
+ ax.plot([0, n_samples], [y, y], color=div_c, linewidth=_DIVIDER_LINEWIDTH)
383
+ # Vertical column walls (lead panels only).
384
+ for boundary in (col_len, 2 * col_len, 3 * col_len):
385
+ ax.plot(
386
+ [boundary, boundary],
387
+ [ROW_MV, DISPLAY_MV],
388
+ color=div_c,
389
+ linewidth=_DIVIDER_LINEWIDTH,
390
+ )
391
+
392
+ # Grid: 0.1 mV minor / 0.5 mV major vertically;
393
+ # horizontally, major ≈ 0.2 s (large square) at the given fs.
394
+ samples_per_major = max(fs * 0.2, 1.0)
395
+ samples_per_minor = max(samples_per_major / 5.0, 1.0)
396
+ ax.yaxis.set_minor_locator(MultipleLocator(0.1))
397
+ ax.yaxis.set_major_locator(MultipleLocator(0.5))
398
+ ax.xaxis.set_minor_locator(MultipleLocator(samples_per_minor))
399
+ ax.xaxis.set_major_locator(MultipleLocator(samples_per_major))
400
+ ax.grid(which="minor", color=minor_c, linestyle="-", linewidth=0.3, alpha=0.85)
401
+ ax.grid(which="major", color=major_c, linestyle="-", linewidth=0.55, alpha=0.9)
402
+
403
+ ax.set_xlim(0, n_samples)
404
+ ax.set_ylim(0, DISPLAY_MV)
405
+ ax.set_autoscale_on(False)
406
+ ax.tick_params(
407
+ which="both", labelbottom=False, labelleft=False, bottom=False, left=False
408
+ )
409
+ for spine in ax.spines.values():
410
+ spine.set_visible(False)
411
+
412
+ # Footer.
413
+ speed = calib["speed_mm_s"]
414
+ gain = calib["gain_mm_mv"]
415
+ filt = _format_filter_label(
416
+ notch_hz=notch_hz, bandpass_hz=bandpass_hz, filter_label=filter_label
417
+ )
418
+ loc = loc_text if loc_text is not None else "LOC 00000-0000"
419
+ calib_text = (
420
+ f"Speed: {speed:.1f} mm/s "
421
+ f"Limb: {gain:.1f} mm/mV "
422
+ f"Chest: {gain:.1f} mm/mV"
423
+ )
424
+ if extra_footer:
425
+ calib_text = f"{calib_text} {extra_footer}"
426
+
427
+ footer_id = _resolve_footer_id(id, anonymise)
428
+ footer_y = 0.19
429
+ ax.text(
430
+ n_samples * 0.027, footer_y, loc, fontsize=8, fontname=fontname, color=label_c
431
+ )
432
+ ax.text(
433
+ n_samples * 0.187,
434
+ footer_y,
435
+ calib_text,
436
+ fontsize=8,
437
+ fontname=fontname,
438
+ color=label_c,
439
+ )
440
+ if filt:
441
+ ax.text(
442
+ n_samples * 0.707,
443
+ footer_y,
444
+ filt,
445
+ fontsize=8,
446
+ fontname=fontname,
447
+ color=label_c,
448
+ bbox={"facecolor": "none", "edgecolor": label_c, "boxstyle": "square,pad=0.2"},
449
+ )
450
+ if footer_id is not None:
451
+ ax.text(
452
+ n_samples * 0.939,
453
+ footer_y,
454
+ footer_id,
455
+ fontsize=8,
456
+ fontname=fontname,
457
+ color=label_c,
458
+ )
459
+
460
+ save_path = Path(save_path)
461
+ save_path.parent.mkdir(parents=True, exist_ok=True)
462
+
463
+ if fmt == "png":
464
+ buf = io.BytesIO()
465
+ fig.savefig(
466
+ buf, format="png", dpi=dpi, facecolor=face, edgecolor="none", pad_inches=0
467
+ )
468
+ buf.seek(0)
469
+ image = open_image(buf)
470
+ if colour:
471
+ image = image.convert("RGB")
472
+ else:
473
+ image = image.convert("L")
474
+ image.save(save_path, format="PNG", dpi=(dpi, dpi))
475
+ buf.close()
476
+ else:
477
+ fig.savefig(
478
+ save_path,
479
+ format=fmt,
480
+ dpi=dpi,
481
+ facecolor=face,
482
+ edgecolor="none",
483
+ pad_inches=0,
484
+ )
485
+ finally:
486
+ # Drop artists so a failed save does not keep a large figure alive.
487
+ fig.clear()
488
+
489
+ return save_path.resolve()