ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Canvas绘制动态心电图:波形建模、滚动绘制与性能优化实战

Canvas绘制动态心电图:波形建模、滚动绘制与性能优化实战

简介:Canvas 动态心电图绘制示例,面向前端开发与医疗健康可视化场景,解决在浏览器中实时渲染心电信号、模拟监护仪波形的问题。示例围绕 CanvasXjt 展开,覆盖画布基础操作、ECG 数据坐标换算、定时刷新绘制等关键环节,并给出数据平滑、异常值处理与性能优化思路。压缩包共 7 个文件,以 HTML 页面、两个 JavaScript 脚本(含 jQuery 与绘制主逻辑)、CSS 样式及说明文档为主,整包约 41KB,打开即用。目前已有 3003 人学习下载。通过对照示例代码,可系统掌握 Canvas 动态绘图的完整流程,并迁移到心率监测、生理数据展示等实际项目。

1. canvas绘制动态心电图到底在解决什么问题

接到一类需求时你会很头疼:大屏或者Web看板上要放一段“心电图”,不能是静态图,也不能是折线图那种生硬的定时刷新,得像监护仪那样,波形从左往右持续滚动、每个QRS波清晰可辨、一眼看过去就有医疗看板的味道。如果你也遇到过这种需求,那canvas绘制动态心电图就是最直接、也最可控的解法。它本质上不是“画一张图”,而是把心电信号建模成采样数据,用Canvas把采样点按时间推进方向持续重绘成滚动波形。这篇文章会从波形建模、滚动绘制、网格与分辨率适配,一路写到性能排查和代码级避坑,让新手能copy出第一版,让熟手能看到边界参数和踩坑点。

2. 把心电波形变成数据:PQRST建模与采样数组

在做任何Canvas绘制之前,先想明白一件事:动态心电图里“动”的是画面,但“源头”一定是数据。如果你直接手写绘图指令去画一条连续的曲线,画面就会很死,也没法接真实设备。常见做法是先把一个完整心动周期转换成采样数组,再把这个数组滚动播放。这就是整篇文章的地基。

2.1 拆解心电形态:PQRST特征与合成波形参数表

一张标准心电图上,一次正常心搏由P波、QRS波群和T波组成,P波之后有一段PR段,QRS之后有ST段。做可视化时不需要医学级精度,但波形形态必须“看着像那么回事”,否则懂行的人一眼就看穿。我一般会先对照教科书上典型心搏的时间特征来定参数。

特征典型时间范围可视化取值建议作用
P波0.08s - 0.11s0.10s波形开头的圆钝小凸起
PR间期0.12s - 0.20s0.16sP波结束到QRS开始的平段
QRS波群0.06s - 0.10s0.08s主峰,是整个波形最醒目的部分
ST段0.08s - 0.12s0.10sQRS结束到T波开始的平缓段
T波0.10s - 0.25s0.20s平缓的大圆顶波
RR间期0.6s - 1.0s0.8s控制心率,对应75次/分

这里有一个选型问题值得展开:直接用真实心电数据不香吗?答案是香,但没必要作为第一版方案。真实数据采样率高、包含噪声,而且你需要额外维护一份数据文件;合成波形只要参数稳定、形态干净,用来开发界面、写动画循环、调视觉效果完全够用。等界面稳定了,再把数据源替换成设备接口就行,Canvas这部分代码一行都不用改。这也是为什么我强烈建议把“数据生产”和“画面渲染”拆成两个模块。

2.2 用分段高斯函数生成一个心动周期采样数组

把上面的时间参数转成波形,最省事也最稳的办法是用高斯函数做波形叠加。P波、QRS主波、T波本来就是近似钟形的电位变化,用高斯曲线去逼近,形态足够自然,而且参数直观,调起来不玄学。下面这个函数可以生成一个心动周期的采样值:

function gauss(t, center, sigma) { // 高斯曲线,center控制波峰位置,sigma控制波峰宽度 const x = (t - center) / sigma; return Math.exp(-0.5 * x * x); } function buildCycle(sampleRate = 240, bpm = 75) { // 一个心动周期时长(ms),再映射到归一化时间轴 t ∈ [0, 1] const cycleMs = 60000 / bpm; // 0.8秒 const samples = new Float32Array(sampleRate); for (let i = 0; i < sampleRate; i++) { const t = i / sampleRate; // 归一化时间 // P波:中心在 0.12 处,宽度较窄,幅度约 0.12 let v = 0.12 * gauss(t, 0.12, 0.022); // Q波:负向小凹陷 v -= 0.25 * gauss(t, 0.30, 0.010); // R波:主峰,幅度最大 v += 1.00 * gauss(t, 0.34, 0.013); // S波:负向偏移,幅度略小于Q v -= 0.18 * gauss(t, 0.40, 0.016); // T波:宽大圆顶,中心在 0.58 处 v += 0.22 * gauss(t, 0.58, 0.045); samples[i] = v; } return samples; }

这段代码的关键在于系数:R波幅度给到1.0,P波和T波给到0.1-0.2,负向的Q、S波用小系数压低,这样波形的主次关系就拉开了,瞳孔扫过去第一时间看到的一定是QRS主波。sampleRate是每个心动周期的采样点数,240意味着0.8秒的周期里有240个点,在60Hz刷新率下每秒会推进约32个点,画面足够平滑。

有一点建议直接照做:把采样数固定成2的倍数,例如240、256或者320。这不是性能需求,而是方便后面做循环滚动时用位运算取余。如果后期要接真实设备,把buildCycle替换成从设备缓冲读取数据即可,输出格式统一为Float32Array,下游函数不需要感知数据来源。

3. 让波形动起来:滚动绘制与动画循环

数据有了,接下来就是把采样数组变成屏幕上滚动的波形。这一章是动态心电图的核心,也是最容易写出“看起来动了但很廉价”效果的部分。两种滚动思路先对比一下,你就知道为什么很多人做出来像心电图但总觉得哪里不对。

3.1 从采样点到波形渲染:坐标映射与第一版静态波形

Canvas绘制波形这一步本身不难,难点在坐标换算。采样数组里存的是归一化幅度,屏幕上有像素纵坐标;心动周期长度要映射成画布宽度上的一段横向距离,这个比例直接决定波形疏密。先看第一版静态渲染:

function renderWave() { const ctx = canvas.getContext('2d'); const width = canvas.clientWidth; const height = canvas.clientHeight; const midY = height * 0.5; const cycle = buildCycle(240); // 一个周期在屏幕上占 300px,可以按需调整 const cyclePixelWidth = 300; const amp = height * 0.35; // 幅度放缩 ctx.clearRect(0, 0, width, height); ctx.strokeStyle = '#43d17c'; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = 1; i < cycle.length; i++) { const prevX = (i - 1) / cycle.length * cyclePixelWidth; const x = i / cycle.length * cyclePixelWidth; const prevY = midY - cycle[i - 1] * amp; const y = midY - cycle[i] * amp; ctx.moveTo(prevX, prevY); ctx.lineTo(x, y); } ctx.stroke(); }

这里amp取高度的35%而不是25%,原因是QRS主峰幅度是1.0,P波和T波只有0.12-0.22,如果幅度系数太小,P波和T波在屏幕上会几乎看不见。我见过不少新人在这一步翻车,把幅度系数定成80%高度,结果P波和T波被压出画布,波形烂成一团。选35%意味着心率变异性、P波细节都能看出来,又不会让主峰削顶。

3.2 右进左出:维护可见区数据窗口并逐帧左移

静态波形没问题后,滚动就容易了。心电图机的滚动方式叫“右进左出”,新数据不断从右侧进入,旧数据整体向左移动然后消失。实现上我建议用一个长度等于画布像素宽度的visibleData数组作为“可见区窗口”,每一帧做三件事:左移已有数据、右侧补入新采样、重绘整条波形。这样代码直白,而且天然支持变速和回放。

class ECGMonitor { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.cycle = buildCycle(240); this.width = canvas.clientWidth; this.height = canvas.clientHeight; this.midY = this.height * 0.5; this.amp = this.height * 0.35; // 可见数据窗口:x 从 0 到 width,存的是当前该像素点的采样值 this.visible = new Float32Array(this.width); // 当前在 cycle 中的相位 this.phase = 0; // 滚动速度:1px/s 即每秒滚动多少像素,数值自行感受 this.speed = 60; } // advances 本轮需要推进的像素数 advance(dtMs) { const step = this.speed * dtMs / 1000; // 1) 左侧数据整体左移 step 个像素(步数不足1时可取整并累计) for (let x = 0; x < this.width - step; x++) { this.visible[x] = this.visible[x + step]; } // 2) 右侧补入 step 个新采样值 for (let x = this.width - step; x < this.width; x++) { this.visible[x] = this.cycle[Math.floor(this.phase)]; this.phase = (this.phase + 0.7) % this.cycle.length; } this.render(); } render() { const ctx = this.ctx; ctx.clearRect(0, 0, this.width, this.height); ctx.strokeStyle = '#43d17c'; ctx.lineWidth = 1.5; ctx.beginPath(); for (let x = 1; x < this.width; x++) { const prevY = this.midY - this.visible[x - 1] * this.amp; const y = this.midY - this.visible[x] * this.amp; ctx.moveTo(x - 1, prevY); ctx.lineTo(x, y); } ctx.stroke(); } }

这段代码的滚动动作没有直接用translate或drawImage,而是把数据左移再重绘。好处是逻辑上完全贴合“时间窗口”的概念,想暂停、回放、缩放都只需要改数据槽位;坏处是每帧要重绘整条线,但几百个像素点的折线在2D Canvas里压力很小,普通电脑上50条这样的波形同时跑才可能卡。如果你要追求极高帧率,最后一章有专门的分层和自拷屏优化法。

3.3 时间驱动还是帧驱动:setInterval和requestAnimationFrame的选择

很多人写动画第一反应是用setInterval,这在动态心电图上是明显的坑。setInterval(step, 16)在后台标签页会被浏览器节流,定时器变慢;而且如果本轮step执行超过16ms,后面的回调会排队,造成画面抖动。我采用的方式是requestAnimationFrame+ 时间增量:

let lastTime = performance.now(); function loop(timestamp) { const dt = timestamp - lastTime; lastTime = timestamp; monitor.advance(dt); // 传的是毫秒 requestAnimationFrame(loop); } requestAnimationFrame(loop);

核心逻辑是:不管浏览器实际刷新间隔是多少,每次回调都拿真实的dt去推进。哪怕标签页切走再切回来,波形也只会按真实流逝的时间往前走一步,而不会突然跳一大段或者卡死。对比一下两种常见方案:

方案问题适用场景
setInterval(step, 16)后台节流、回调堆积、节奏不稳仅演示、不接真实数据
rAF + 固定步进高刷屏会变快,低刷屏会变慢低端演示
rAF + 时间增量帧率自适应,时间语义正确接近监护仪行为

注意advance(dt)内部如果speed * dtMs / 1000算出小数像素,比如60px/s在16.6ms内只走了0.996px,直接clearRect再重绘时波形实际上没有左移。所以上面的代码用const step = Math.round(this.speed * dtMs / 1000)取整,视觉上每帧移动1px,在1500px宽的画布上完全够平滑。追求亚像素级平滑,需要把visible数组扩大两倍做插值,现阶段不建议为了这点视觉收益增加复杂度。

4. 让它像真实监护仪:网格、暗色主题与设备像素比适配

动态心电图动起来之后,下一步是让它“像医疗设备”。这套视觉方案基本是行业通用做法:深色底、绿色或黄绿色波形、带小方格的刻度网格。网格不是装饰,它是判断波形幅度和时间间隔的参照系,做医疗类Demo时最好保留,做通用数据看板也可以换皮肤,但网格画法值得单独聊聊,因为很多人死在网格上。

4.1 网格背景的正确画法:0.5像素偏移与离屏Canvas缓存

Canvas画1px线在整数坐标位置时,因为像素是方格模型,会同时占用相邻两列的半像素,结果画出来的线是2px宽且发虚。解决办法是让线落在半像素坐标上。画网格的代码:

function drawGrid(ctx, w, h) { const size = 25; // 大格25px,小格由5px细分,心电图标准近似 ctx.strokeStyle = 'rgba(67, 209, 124, 0.18)'; ctx.lineWidth = 1; for (let x = 0; x <= w; x += size) { ctx.beginPath(); ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, h); ctx.stroke(); } for (let y = 0; y <= h; y += size) { ctx.beginPath(); ctx.moveTo(0, y + 0.5); ctx.lineTo(w, y + 0.5); ctx.stroke(); } }

这里的x + 0.5和y + 0.5就是半像素修正。还有两个细节:第一,大格25px、小格5px是心电图纸常见近似比例,如果你的画布不是25的整数倍,最后一格会留一条半截线,我一般用Math.ceil让网格铺满;第二,网格每帧都重绘非常浪费,尤其线多时成本不低。常见做法是把网格画到一个离屏Canvas上,每帧只要drawImage一次:

// 初始化时执行一次 const gridCanvas = document.createElement('canvas'); gridCanvas.width = cssWidth; gridCanvas.height = cssHeight; drawGrid(gridCanvas.getContext('2d'), cssWidth, cssHeight); // 每帧渲染时 ctx.drawImage(gridCanvas, 0, 0);

离屏Canvas的大小必须是CSS像素尺寸,而波形Canvas的物理尺寸通常要按devicePixelRatio放大,两者分离反而让逻辑更清晰。

4.2 devicePixelRatio适配:为什么在Retina屏上总是模糊

Canvas画布有“物理像素”和“CSS像素”两个概念。如果canvas.width = cssWidth而不做处理,在2倍屏上画出来的所有线条都会被拉伸成2px的模糊带,心电图波纹边缘会发虚,绿色波形看起来像在发光。我处理这套问题的固定写法:

const dpr = Math.max(window.devicePixelRatio || 1, 1); function resizeCanvas(canvas, cssW, cssH) { // 重置物理尺寸,这一步会让画布状态丢失,所以要重新设置ctx参数 canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); canvas.style.width = cssW + 'px'; canvas.style.height = cssH + 'px'; // 关键:把坐标系缩放成CSS像素,后续绘图逻辑不用再管dpr const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }

setTransform(dpr, 0, 0, dpr, 0, 0)这行是重点,它把Canvas的绘图坐标系整体放大,之后的clientWidth、clientHeight、线宽这些完全按CSS像素写,不需要在业务代码里乘dpr。副作用是:Canvas重置后坐标变换会回到默认值,所以每次resize之后都要重新设置setTransform和strokeStyle、lineWidth。窗口变化时还要同步重建visible数组和离屏网格,否则尺寸变了波形和网格对不上。

5. 动态心电图绘制常见问题排查:五个踩坑与修复

这一章是血泪经验汇总。Canvas动画的坑往往不在“画不出来”,而在“画出来但不对”,我把做模拟项目时踩过的、以及在社区里被反复问到的典型问题按现象、原因、解决整理出来,直接对号入座。

5.1 波形拖影严重,旧线不消失

现象:波形滚动后,原来画过的地方残留阴影轨迹,像是图片没擦干净。原因:最常见的写法是fillRect或clearRect清了但清的范围不够,或者直接用moveTo/lineTo画完没有stroke结束又叠了下一帧。还有一个隐蔽原因是滚动时ctx.translate平移了坐标系,但清屏用的clearRect(0, 0, w, h)是在平移后的坐标系里执行,只清了局部。解决:所有清屏逻辑放到ctx.save()/ctx.restore()外层,或者在滚动操作前先ctx.setTransform(1, 0, 0, 1, 0, 0)重置,再clearRect(0, 0, canvas.width, canvas.height)。简单说,清屏和绘制的坐标系必须一致。

5.2 波形在Retina屏上发虚

现象:同一套代码在普通屏上清晰,在高分屏上整个页面都像蒙了一层纱布。原因:没有适配devicePixelRatio,画布物理尺寸小于CSS尺寸,浏览器做了拉伸。解决:按4.2节的resizeCanvas处理。但这里有一个额外的坑:canvas.width重置后,之前设置的上下文状态全部清空,如果不清除缓存就继续绘制,波形可能变成默认黑色。所以resize函数要返回ctx,绘制循环每次拿到最新ctx再设置线宽和颜色。

5.3 动画卡顿,CPU占用高

现象:页面滚动波形时帧率明显下降,电脑风扇开始转,检查Performance面板发现每帧耗时超过20ms。原因:90%的情况是每帧都重画了网格、波形、文字等多层内容;剩下10%是Canvas尺寸设置得过大(比如直接canvas.width = window.innerWidth * dpr,在4K屏上就是八千多像素宽的绘图表面)。解决:网格部分全部走离屏Canvas缓存;波形可见区长度限制在CSS像素宽度范围内,不要无脑放大;阴影、模糊滤镜这类效果在心率这类高频刷新场景下千万别用,一用就会触发慢路径。

5.4 从后台标签页切换回来,波形跳变或假死

现象:页面挂在后台几分钟,再切回来时波形已经跑飞了,或者卡住不动。原因:浏览器为了省电会暂停requestAnimationFrame,标签页切回来后回调恢复但lastTime还是旧的,导致dt变成几千毫秒,波形一步越过了大半个屏幕。解决:状态机判断,如果dt超过500ms就把它截断为一个正常帧时长:

function loop(timestamp) { let dt = timestamp - lastTime; if (dt > 500) dt = 16; // 后台恢复时截断跳跃 lastTime = timestamp; monitor.advance(dt); requestAnimationFrame(loop); }

这个值我试过500ms比较合理,既能防止切换回来时大跳,又不会完全忽略长时间停留导致的心率时间误差。真要追求绝对准确的时间基准,就改用Date.now()记录真实时间戳,但网页端展示场景下500ms截断已经足够。

5.5 波形太平或太圆,看不出QRS波

现象:整条波形看起来像一条波浪线,QRS主峰没有突出感,或者P波T波直接消失。原因:amp幅度系数太大导致主峰削顶,或者太小让细节淹没;另一个原因是lineWidth太粗,波形细节糊在一起。解决:幅度系数取画布高度的30%-40%,线宽在1-2px之间。如果想让QRS更有冲击力,可以单独给R峰画一次加粗描边,但要注意开销,我一般只在静态展示时这么做,动态滚动时保持统一线宽。

6. 进阶:数据驱动重构与性能验证三板斧

如果你已经不满足于“能跑”,想把这个动态心电图方案做成别人愿意维护的组件,那最后一公里的工作就是把代码拆成分层架构,并用数据确认性能真的达标,而不是凭感觉觉得“挺流畅的”。

6.1 将绘制与数据解耦:分层结构与自拷屏优化

我最后落地到生产级看板Demo时用的结构是这样的:底层离屏Canvas负责网格,静态不更新;中层是波形Canvas,每帧重绘;顶层铺一个透明Canvas,用来写实时心率数值、报警闪烁这类高频变化内容。波形滚动如果还想再省一点,可以放弃“数据左移+整窗重绘”,改用drawImage自拷屏法:先把当前Canvas整体向左平移1px,然后把最右侧的窄条区域单独绘制新数据。这个方案重绘面积最小,但代码复杂度更高,需要在缩放下自行处理边界,推荐在波形数据宽度极大时才用。

6.2 性能验证三板斧

验证方法比优化技巧更值钱。我每次改完动画都会做三件事:第一,打开浏览器Performance面板,录制10秒,看每一帧的绘制耗时是否稳定在16ms内,如果某帧耗时和前后帧落差超过5ms,优先怀疑GC或者离屏Canvas误用了滤镜;第二,在循环里加上FPS计数:

let frameCount = 0; let timeAcc = 0; function loop(timestamp) { const dt = timestamp - lastTime; lastTime = timestamp; timeAcc += dt; frameCount++; if (timeAcc >= 1000) { console.log('FPS:', frameCount); frameCount = 0; timeAcc = 0; } monitor.advance(dt); requestAnimationFrame(loop); }

第三,也是最容易被忽略的,把advance和render分别计时,确认瓶颈在哪一层。我之前遇到一次波形抖动,代码改成按帧统计后才发现问题出在采样数组的Math.floor取整上,而不是Canvas绘制。

这套方案的使用体验和我最初预期的完全不同——原本以为难点是绘制,实际上难点是数据窗口的边界条件和时间推进策略。我最大的教训就是“不要为了动画效果提前优化”,先按官方推荐的分层方案写一份可工作的版本,再把性能数据摆出来决定要不要上自拷屏。希望上面这些建模参数、滚动逻辑和踩坑记录能帮到你,让你少走几趟我走过的弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进