news 2026/10/27 6:28:36

Canvas实时心电图波形渲染:坐标换算、环形缓冲与滤波

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Canvas实时心电图波形渲染:坐标换算、环形缓冲与滤波

简介:这份代码包面向前端开发者和医疗健康数据可视化学习者,围绕HTML5 Canvas实时绘制动态心电图展开,可用于生理数据监测界面的原型参考与课堂示例。压缩包共7个文件、约41KB,以HTML页面和JavaScript绘图脚本为主体,配合CSS样式、说明文本及网址快捷方式,结构轻量,便于直接打开页面阅读和调试。已有3005人学习,说明其在Canvas动态绘图入门与心电图场景结合上具有一定参考价值。内容涉及定时刷新与清屏重绘、电压值到画布坐标的映射、波形线条颜色与粗细设置,以及网格、时间轴和电压刻度等标注方式,同时也提示了数据缺失、漂移和性能优化等工程问题。读者可据此理解监护仪式波形从数据到画面的完整链路,并迁移到其他实时曲线可视化需求中。

1. 凌晨两点的横线:Canvas 心电图到底在解决什么

凌晨两点,模拟项目X的调试页上,一条绿色波形突然从屏幕左侧横穿到右侧,像有人拿尺子画了一笔。那不是 Canvas 心电图 的绘制 API 有问题,而是时间戳断流后仍把两个不相邻的点连成了线。这份资源把动态心电图拆成坐标换算、环形缓冲、逐帧绘制和滤波接入几块,目标是在浏览器里稳定跑出可缩放、可暂停、可回放的实时波形。适合做设备调试面板、教学演示和上位机原型的从业者,也适合前端想补实时可视化这一课的人。它不替代诊断设备,但能把“波形为什么飘、为什么糊、为什么卡”讲清楚。

2. 坐标先对齐:25 mm/s、10 mm/mV 与 Canvas 分层渲染

2.1 先定物理刻度:25 mm/s、10 mm/mV 不是装饰

心电图纸上的小格是 1 mm,大格是 5 mm,走纸速度 25 mm/s,增益 10 mm/mV。到了屏幕上,这三个数字决定了波形的时间轴和电压轴怎么落到像素。很多人拿到 Canvas 心电图 代码,先改颜色和线宽,结果波形要么被压扁,要么 R 波顶出画布,本质是像素与毫米的映射没有跟着画布宽度走。正确顺序是先定显示时间窗口,再由画布逻辑宽度反推每毫米多少像素,最后把采样点的时间换算成 x、电压换算成 y。

假设画布逻辑宽度 960 px,显示 8 秒,那么总走纸长度是 25 × 8 = 200 mm,pxPerMm = 960 / 200 = 4.8。此时 1 mV 对应 10 mm,也就是 48 px;一个 1.2 mV 的 R 波从基线向上占 57.6 px。如果画布高 240 px,基线放在 55% 处即 132 px,R 波峰值到 74.4 px,顶部还有余量;换成 2 mV 的校准方波就会顶到 36 px,所以增益和基线要一起调,不能只改增益。

// 把物理刻度换算成 Canvas 逻辑像素,所有绘制都复用这一套比例 function createScale(cssWidth, timeWindowSec) { const MM_PER_SEC = 25; // 标准走纸速度 const MM_PER_MV = 10; // 标准增益 const totalMm = MM_PER_SEC * timeWindowSec; const pxPerMm = cssWidth / totalMm; return { pxPerMm, xPerSec: MM_PER_SEC * pxPerMm, // 每秒占多少 x 像素 yPerMv: MM_PER_MV * pxPerMm, // 每毫伏占多少 y 像素 xOf(t, t0) { return (t - t0) * this.xPerSec; }, yOf(mv, baseline) { return baseline - mv * this.yPerMv; } }; }

这段换算的逻辑很直白:时间差乘 xPerSec 得到横坐标,电压乘 yPerMv 后从基线向上减得到纵坐标。参数里最容易被忽略的是 timeWindowSec,它一变,pxPerMm 就变,网格和波形必须同时拿到新的 scale 对象,否则网格是 8 秒、波形是 10 秒,视觉上就会错位。我一般把 8 秒作为实时监护窗口,30 秒只用于趋势回放;30 秒时 960 px 宽下 pxPerMm 只有 1.28,1 mm 小格不到 2 px,再画细网格就是一片红底,这时要降级成只画 5 mm 大格。

参数含义示例值调整后的影响
cssWidth画布逻辑宽度960 px变宽通常像素更充裕,但要同步重算 scale
timeWindowSec显示时间窗口8 s窗口越长,pxPerMm 越小,波形越挤
pxPerMm每毫米像素数4.8网格密度和波形幅度都依赖它
xPerSec时间轴比例120 px/s直接决定波形横向拉伸程度
yPerMv电压轴比例48 px/mV决定 R 波高度和校准方波高度
baseline基线 y 坐标高 × 0.55太低会切 S 波,太高会切 R 波

注意:这里的 px 是 Canvas 逻辑像素,不是物理像素。高 DPR 屏上还要乘 devicePixelRatio,否则换算公式对上了,画面仍然是糊的。

2.2 为什么网格层和波形层要分开

动态心电图每一帧都在变,但网格几乎不动。把网格和波形画在同一张 canvas 上,常见的写法是每帧 clearRect 之后先画几百条网格线,再画波形。桌面浏览器可能看不出问题,到了中低端手机或工控平板,CPU 占用会明显上去,掉帧时还会出现“先白一下再出波形”的闪烁。血泪经验是:只要网格线超过 100 条,就不要把它放进逐帧循环。

分层方案很轻:一个 canvas 专门画网格和基线,只在容器尺寸变化、主题切换、缩放级别变化时重绘;另一个 canvas 透明叠在上面,每帧只 clearRect 波形层,再画一条折线。这样波形层每帧的绘制量从“几百条线 + 一条折线”降到“一条折线”,移动端会稳很多。选 Canvas 而不是 SVG 的理由也在这里:SVG 适合节点少、可交互的图形,但实时心电图每帧要更新成百上千个点,DOM 节点和属性更新开销不划算;Canvas 是立即模式,擦掉重画更直接。

<div class="ecg-wrap" id="ecgWrap"> <canvas id="gridCanvas"></canvas> <canvas id="waveCanvas"></canvas> </div> <style> .ecg-wrap { position: relative; width: 100%; max-width: 960px; aspect-ratio: 4 / 1.2; background: #070b0a; overflow: hidden; } .ecg-wrap canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } #gridCanvas { z-index: 1; } #waveCanvas { z-index: 2; } </style>
// 位图尺寸按 DPR 放大,逻辑坐标仍保持 CSS 像素,绘制代码不用到处乘 DPR function fitCanvas(canvas, cssW, cssH, dpr) { canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); canvas.style.width = `${cssW}px`; canvas.style.height = `${cssH}px`; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; } const wrap = document.getElementById('ecgWrap'); const gridCanvas = document.getElementById('gridCanvas'); const waveCanvas = document.getElementById('waveCanvas'); let gridCtx; let waveCtx; let view = null; function layout() { const rect = wrap.getBoundingClientRect(); // 移动端把 DPR 限制到 2,避免 3x 屏上无意义地多画一倍像素 const dpr = Math.min(window.devicePixelRatio || 1, 2); const scale = createScale(rect.width, 8); view = { w: rect.width, h: rect.height, dpr, baseline: rect.height * 0.55, scale }; gridCtx = fitCanvas(gridCanvas, rect.width, rect.height, dpr); waveCtx = fitCanvas(waveCanvas, rect.width, rect.height, dpr); drawGrid(gridCtx, view); } const resizeObserver = new ResizeObserver(layout); resizeObserver.observe(wrap); layout();

fitCanvas 的逻辑是把 canvas 的位图宽高设成 CSS 尺寸乘 DPR,再用 setTransform 把绘制坐标系缩回 CSS 像素。这样后面写 x=100 就是视觉上的 100 px,不用在每行绘制代码里乘 DPR。参数里 dpr 限制到 2 是性能取舍:3x 屏上波形线宽只有 1.8 逻辑像素,再高的物理分辨率肉眼看不出明显收益,但像素填充量会多出不少。ResizeObserver 负责容器尺寸变化,注意每次重设 canvas.width 都会清空画布,所以 layout 里必须重新 drawGrid,不能只改 CSS。

2.3 网格怎么画才不糊:小格、大格与基线对齐

网格绘制有两个细节:一是线宽和颜色要分两级,小格淡、大格稍重,否则远看像一块红布;二是横线要从基线向上下两个方向铺,保证基线和网格对齐。如果从画布顶部按固定步长往下画,基线可能落在两条横线之间,波形看起来会“浮”着。基线本身用一条独立的线画,颜色比大格再亮一点,方便判断零点。

function drawGrid(ctx, { w, h, baseline, scale }) { ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#070b0a'; ctx.fillRect(0, 0, w, h); const small = scale.pxPerMm; // 1 mm 小格 const large = small * 5; // 5 mm 大格 const drawLines = (step, color, lineWidth) => { ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.beginPath(); for (let x = 0; x <= w + 0.5; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, h); } for (let y = baseline; y <= h; y += step) { ctx.moveTo(0, y); ctx.lineTo(w, y); } for (let y = baseline - step; y >= 0; y -= step) { ctx.moveTo(0, y); ctx.lineTo(w, y); } ctx.stroke(); }; drawLines(small, 'rgba(255, 74, 74, 0.11)', 0.5); drawLines(large, 'rgba(255, 74, 74, 0.26)', 1); ctx.strokeStyle = 'rgba(255, 120, 120, 0.45)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, baseline + 0.5); ctx.lineTo(w, baseline + 0.5); ctx.stroke(); }

drawLines 把横线和竖线放在同一个 beginPath 里一次 stroke,减少状态切换。small 取 scale.pxPerMm,large 取 5 倍,颜色透明度分别给 0.11 和 0.26,线宽 0.5 和 1。基线加 0.5 是为了在 1x 屏上减少半像素模糊,DPR=2 时影响很小。参数里最容易翻车的是 step 小于 1:当 pxPerMm 低于 1 时,for 循环会画大量几乎重合的线,既慢又糊,这时应该把 small 网格关掉,只保留 large。这个判断可以写成if (small >= 1.5) drawLines(small, ...),不要硬画。

3. 把波形跑起来:PQRST 合成、环形缓冲与 requestAnimationFrame 节拍

3.1 用高斯叠加合成可复现的 PQRST

没有真实设备时,先用合成波形把渲染链路跑通。PQRST 不需要多么生理精确,但时间关系和幅度关系要合理:P 波在 R 波前约 0.12 到 0.2 秒,Q 波是 R 波前的小负向,R 波窄而高,S 波是 R 波后的小负向,T 波在 QRS 后约 0.2 到 0.3 秒且更宽。用几个高斯函数叠加,每个波给中心时间、幅值和宽度,就能得到一条可重复的波形。R 波的 sigma 要小,T 波的 sigma 要大,否则 R 波会变成圆顶,T 波会变成尖刺。

// 每个分量:中心相位(秒)、幅值(mV)、宽度 sigma(秒) const ECG_COMPONENTS = [ { t: 0.16, amp: 0.15, sigma: 0.018 }, // P { t: 0.24, amp: -0.12, sigma: 0.012 }, // Q { t: 0.28, amp: 1.10, sigma: 0.010 }, // R { t: 0.31, amp: -0.25, sigma: 0.014 }, // S { t: 0.50, amp: 0.30, sigma: 0.040 } // T ]; function ecgAt(t, heartRate = 72) { const period = 60 / heartRate; const phase = ((t % period) + period) % period; let mv = 0; for (const c of ECG_COMPONENTS) { const d = (phase - c.t) / c.sigma; mv += c.amp * Math.exp(-0.5 * d * d); } return mv; }

ecgAt 的逻辑是先把绝对时间折到当前心动周期内,再对每个分量算高斯值并累加。heartRate 控制周期长度,72 bpm 时周期约 0.833 秒;幅值单位是 mV,R 波给 1.10 mV 是为了在 10 mm/mV 下占 11 mm。sigma 的单位是秒,R 波 0.010 秒意味着在 250 Hz 采样率下只有两三个点落在峰顶附近,所以采样率不能太低,否则 R 波会被采样点“削平”。合成波形只用于验证坐标、缓冲和绘制节拍,不能拿来做任何临床判断。

3.2 环形缓冲:别再用 shift 挪数组

实时数据进入画布前要有一个缓冲。常见写法是每来一个点就array.push(point),窗口满了就array.shift()。短时间看不出问题,跑十几分钟之后,shift 的 O(n) 搬移和频繁对象分配会让 GC 抖动,波形开始一卡一卡。环形缓冲用固定长度数组,head 指针写,size 记录有效数量,写入是 O(1),窗口读取按索引回绕,不会搬移整个数组。容量按“采样率 × 需要保留的秒数”算,比如 250 Hz 保留 30 秒就是 7500 点,取 8192 留余量。

class RingBuffer { constructor(capacity) { this.capacity = capacity; this.buf = new Array(capacity); this.head = 0; // 下一个写入位置 this.size = 0; // 当前有效数量 } push(item) { this.buf[this.head] = item; this.head = (this.head + 1) % this.capacity; if (this.size < this.capacity) this.size++; } // 返回 [startT, endT] 内的点;演示用数组复制,数据量大时可改成视图 window(startT, endT) { const out = []; for (let i = 0; i < this.size; i++) { const idx = (this.head - this.size + i + this.capacity) % this.capacity; const item = this.buf[idx]; if (item.t >= startT && item.t <= endT) out.push(item); } return out; } }

push 只改 head 和 size,不搬数据;window 从最旧的有效位置开始绕一圈,把时间范围内的点挑出来。参数 capacity 要大于“采样率 × 最长窗口”,否则窗口左边界的数据已经被覆盖,波形会缺一段。这个实现每帧会创建一个 out 数组,8 秒 250 Hz 是 2000 个点,问题不大;如果做到 30 秒 500 Hz,建议把 buf 换成 Float32Array,存 t 和 mv 两个数组,window 返回索引范围而不是复制对象,否则 GC 压力会重新回来。

3.3 逐帧绘制与扫描、滚动两种模式

浏览器的 requestAnimationFrame 通常 60 Hz,而心电采样率是 250 Hz 或 500 Hz,所以每帧会补进 4 到 8 个点,不是一帧一个点。渲染循环里用 dt 推进模拟时间,按Math.floor(simT * SAMPLE_RATE)补齐采样点,再从环形缓冲取当前窗口。dt 要 clamp,因为切到后台标签再回来时,rAF 可能间隔好几秒,不 clamp 会一次性补几千个点,画面直接卡死。

const SAMPLE_RATE = 250; const WINDOW_SEC = 8; const ring = new RingBuffer(SAMPLE_RATE * 30); let simT = 0; let nextSampleIndex = 0; let last = performance.now(); function frame(now) { const dt = Math.min((now - last) / 1000, 0.1); // 最多补 0.1 秒 last = now; simT += dt; const want = Math.floor(simT * SAMPLE_RATE); while (nextSampleIndex < want) { const t = nextSampleIndex / SAMPLE_RATE; ring.push({ t, mv: ecgAt(t, 72) }); nextSampleIndex++; } const t0 = simT - WINDOW_SEC; const points = ring.window(t0, simT); drawWave(waveCtx, points, { w: view.w, h: view.h, baseline: view.baseline, xPerSec: view.scale.xPerSec, yPerMv: view.scale.yPerMv, t0 }); requestAnimationFrame(frame); } requestAnimationFrame(frame);
function drawWave(ctx, points, { w, h, baseline, xPerSec, yPerMv, t0 }) { ctx.clearRect(0, 0, w, h); ctx.lineWidth = 1.8; ctx.strokeStyle = '#35e08a'; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); let hasPoint = false; for (const p of points) { const x = (p.t - t0) * xPerSec; if (x < -2 || x > w + 2) continue; const y = baseline - p.mv * yPerMv; if (!hasPoint) { ctx.moveTo(x, y); hasPoint = true; } else { ctx.lineTo(x, y); } } ctx.stroke(); }

drawWave 只做一次 beginPath 和一次 stroke,避免每个点都切状态。x 用(p.t - t0) * xPerSec,t0 是窗口左边界,所以最旧的点在 x=0 附近,最新的点在 x=w 附近。超界的点直接跳过,减少无效路径。这个循环是滚动模式:波形整体向左走,右边缘不断出现新点,适合实时监护。另一种是扫描模式:x 用((p.t - start) % WINDOW_SEC) * xPerSec,画笔到右边缘后从左侧覆盖,视觉上像老式监护仪。扫描模式要注意覆盖旧点,不能只 clearRect 整屏,否则每次回扫都会闪;更稳的做法是双缓冲或按列覆盖,先用滚动模式把链路跑通再说。

4. 接真实数据:采样率对齐、时间戳清洗与轻量滤波

4.1 二进制帧解析与时间戳单调性

真实设备的数据通常不是 JSON,而是二进制帧。常见做法是帧头带一个 float64 的基准时间、uint16 的采样率、uint16 的点数,后面跟一串 int16 的电压值,1 LSB 对应 1 µV。浏览器端用 WebSocket 的 arraybuffer 接收,DataView 按小端读取。下面的协议只是示例,接自己的设备时按实际帧格式改偏移和缩放,但“先解出时间戳和采样率,再逐点入缓冲”的顺序不要变。

const ws = new WebSocket('ws://localhost:8080/ecg'); ws.binaryType = 'arraybuffer'; ws.onmessage = (event) => { const buf = event.data; if (!(buf instanceof ArrayBuffer)) return; const view = new DataView(buf); const baseT = view.getFloat64(0, true); // 帧起始时间,秒 const fs = view.getUint16(8, true); // 采样率,Hz let offset = 10; const count = view.getUint16(offset, true); offset += 2; for (let i = 0; i < count; i++) { const raw = view.getInt16(offset, true); offset += 2; const t = baseT + i / fs; const mv = raw / 1000; // 假设 1 LSB = 1 µV // 丢弃时间戳回退的点,避免折线倒退 if (ring.lastT !== undefined && t <= ring.lastT) continue; ring.lastT = t; ring.push({ t, mv }); } };

这段解析先读基准时间、采样率和点数,再按baseT + i / fs给每个点补时间。偏移量 0、8、10 对应示例协议,换设备时用十六进制抓一帧,对着字段表改。raw / 1000把 µV 换成 mV,如果设备给的是 0.1 µV/LSB,就改成除以 10000。时间戳过滤很重要:网络抖动、设备重发、多路数据拼接都可能让 t 回退,回退点进入缓冲后,drawWave 会从右往左画线,屏幕上就是那条“横穿屏幕的鬼影线”。断流超过 500 ms 时,绘制层要分段,不能把断点前后的两个点直接连起来。

4.2 基线漂移和工频干扰怎么处理

原始波形常见三类干扰:基线漂移、工频干扰、肌电噪声。基线漂移频率很低,呼吸和电极接触变化都会带来,显示前用一阶高通压掉,截止频率 0.5 Hz 左右;工频干扰在 50 Hz 或 60 Hz,采样率 250 Hz 时可以用 5 点滑动平均做出 50 Hz 零点,但它会钝化 QRS,只适合演示;正式产品用二阶 IIR 陷波,系数按实际采样率算。肌电噪声频率高,显示用 40 Hz 低通就够了,别把诊断带宽和显示带宽混为一谈。

class DcBlocker { constructor(fs, fc = 0.5) { const dt = 1 / fs; const rc = 1 / (2 * Math.PI * fc); this.alpha = rc / (rc + dt); this.prevIn = 0; this.prevOut = 0; } process(x) { const y = this.alpha * (this.prevOut + x - this.prevIn); this.prevIn = x; this.prevOut = y; return y; } } // 演示级:fs=250 时 5 点平均在 50 Hz 处有零点,但会钝化 R 波 class MovingAverage { constructor(n) { this.n = n; this.buf = new Float32Array(n); this.idx = 0; this.sum = 0; this.count = 0; } process(x) { if (this.count < this.n) { this.count++; } else { this.sum -= this.buf[ <p> <a href="https://download.csdn.net/download/lqllyz/12399525" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 6:27:30

AI时代品牌营销的极域AI破局策略

1. 品牌营销的AI时代生存法则当ChatGPT每分钟处理数百万条查询&#xff0c;当Midjourney每天生成数千万张图片&#xff0c;我们正经历一场前所未有的AI内容海啸。作为品牌方&#xff0c;最可怕的不是被竞争对手超越&#xff0c;而是被AI生成的内容彻底淹没——当消费者询问任何…

作者头像 李华
网站建设 2026/10/27 6:27:09

AI视觉检测在制造业的应用与优化

1. 项目概述&#xff1a;当AI成为制造业的"火眼金睛"在一条高速运转的汽车零部件生产线上&#xff0c;每隔17秒就有一个发动机缸体完成加工。传统质检需要工人用肉眼检查数十个关键尺寸和表面缺陷&#xff0c;每个工件耗时近3分钟——这直接导致产线必须降速60%以适应…

作者头像 李华
网站建设 2026/10/27 6:26:28

YOLO交通标志检测系统:从模型选型到PyQt5部署

1. 项目概述&#xff1a;当YOLO遇上交通标志检测去年在德国自驾时&#xff0c;我差点因为没看清限速标志吃到罚单。这件事让我意识到&#xff0c;即便对老司机来说&#xff0c;实时准确的交通标志识别也是个刚需。这正是我们今天要讨论的"基于深度学习的交通标志检测系统&…

作者头像 李华
网站建设 2026/10/27 6:26:28

WorkBuddy+MCP实战:从数据获取到回测的一站式量化流水线

刚开始接触WorkBuddy MCP这个组合时&#xff0c;我其实没太当回事&#xff0c;觉得不过是又一轮AI工具炒作。直到我自己把手上的量化小项目折腾了一遍——从写代码拉数据、清洗、写策略、跑回测拖了好几天&#xff0c;切换到WorkBuddy调MCP之后&#xff0c;真的可以用一句话把…

作者头像 李华
网站建设 2026/10/27 6:26:13

R语言实战:主成分分析与因子分析降维方法详解

降维这件事&#xff0c;我最早是被逼着做的。手头一份问卷数据&#xff0c;三十多个观测变量&#xff0c;样本量刚过两百&#xff0c;做回归时共线性指标直接爆表&#xff0c;VIF值高得没法看。当时第一反应是删变量&#xff0c;但删来删去发现每个变量都有业务含义&#xff0c…

作者头像 李华
网站建设 2026/10/27 6:26:06

基于PyTorch的核桃品质智能分拣系统设计与实现

1. 项目背景与核心价值核桃品质识别是农产品分拣领域的重要课题。传统人工分拣方式效率低下且主观性强&#xff0c;而基于深度学习的自动化识别技术正在改变这一现状。这个毕业设计项目采用PyTorch框架构建CNN模型&#xff0c;实现了核桃品质的智能分类&#xff0c;为农业自动化…

作者头像 李华