简介:面向医疗信息化、上位机及信号可视化领域的C# WinForms开发人员,这份demo展示了心律、血氧、呼吸曲线等生命体征波形的生成与实时绘制方法。压缩包共53个文件,属于完整的VS解决方案,包含8个C#源文件、6个可执行程序、5个配置文件、4个资源文件及DLL等,总大小仅129KB,轻量而便于下载分析。目前已有127人学习,适合想快速参考波形绘制思路的初学者或中级开发者。项目内含PicBoxDrwSin自定义绘制控件、Form1主窗体、程序入口与资源文件,可直接打开sln编译运行,也可以参考其中的坐标网格构建、曲线刷新技术以及界面布局方式,迁移到自己的项目中。对于需要开发监护仪、健康监测类界面或自定义图表控件的场景,这份示例能提供直观的代码级参考。
1. 先花5分钟,在WinForms里画出一条会滚动的心率曲线
在医院监护仪上见到的那种绿色波形——心电、血氧、呼吸一起跳动,既像黑匣子里的神秘信号,又是生命体征最直观的呈现。标题里的“winfrom 心率曲线图 生命体”对应的就是这件事:在C# WinForms工程里,做一套能实时滚动的心律、血氧、呼吸波形demo。这类程序最常见的用途是医疗设备上位机预研、信号算法调试、或者智能硬件配套的PC端演示工具。它能解决的核心问题只有一个:把一串串传感器数据变成医生和工程师“一眼看得懂”的波形。适合做上位机开发、医疗电子、可穿戴设备配套软件的人照着复现。反直觉的地方在于,这种看起来有点炫的实时波形,完全不需要引入第三方图表库,纯GDI+双缓冲就能跑到30帧以上,代码量还不到500行。
2. 让波形在屏幕上动起来:GDI+双缓冲与坐标换算
2.1 为什么我不用Chart控件和第三方图表库
WinForms自带的Chart控件确实拖拽就能出曲线,但把数据按250Hz的频率逐点追加进去,图表控件要维护坐标轴、刻度、图例和缩放状态,性能会随着点数增长快速下降。第三方库比如LiveCharts、OxyPlot各有优势,但都会遇到一个问题:图表库面向的是静态数据展示,对“每帧追加几百个点并整体左移”这种医疗监护场景,花费在布局计算上的时间远大于绘制本身。
我一般会直接在自定义控件上用GDI+画线。原因很简单:监护波形本质上只有“网格、线条、数字”三样东西,没有任何需要库来帮你做的事情。自己画反而能在时间窗、缩放比例、通道布局上拿到完全控制权。用GDI+画图的性能天花板很高——比如绘制2500个点的折线图,耗时通常在1到3毫秒,留出了充足余量给其它业务逻辑。
2.2 双缓冲标志的正确配置
GDI+实时绘图绕不开双缓冲。不开启双缓冲时,绘制的每一笔都会直接输出到屏幕,人眼就会看到线条从左上角到右下角被“追着画”,这就是教科书级别的闪烁。WinForms里开启双缓冲常见做法是在控件构造函数里设置三个ControlStyles标志位:
public VitalWaveformControl() { SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true); UpdateStyles(); }三个标志位缺一不可:UserPaint告诉系统这个控件自己处理绘制而不是用系统默认画法;AllPaintingInWmPaint让系统在触发WM_PAINT时不要先擦除背景,避免背景色闪烁一下;OptimizedDoubleBuffer让所有绘制先落在内存画布上,最后一次拷贝到屏幕。这个组合是WinForms双缓冲的标准姿势,比手动创建BufferedGraphicsContext再包一层using更省心。
提示:不要在OnPaint里再去调用this.DoubleBuffered = true,那个属性本质上就是上面三个标志位的包装器,但赋值时机太晚了,控件第一次绘制可能已经发生。
2.3 坐标换算:时间轴与幅度轴的映射关系
波形绘制的核心是坐标换算。横轴是时间,纵轴是物理量(毫伏、百分比)。时间窗决定了屏幕上能显示多少秒的数据,采样率决定了每秒有多少个数据点。换算的原则是:最新数据点永远贴在控件右边缘,旧数据点按时间戳向左推移。
// 横坐标:index为0的数据最旧,index为total-1的数据最新 private float XForSample(int index, int totalCount, int clientWidth) { // 每个样本占用的像素宽度 float step = (float)clientWidth / totalCount; // 让最新点(index = total-1)落在clientWidth处 return clientWidth - (totalCount - 1 - index) * step; } // 纵坐标:电压值转像素坐标,value增大向上移动 private float YForSample(float value, float centerY, float scaleY) { // 屏幕坐标系y轴向下,所以用centerY减去value * scaleY return centerY - value * scaleY; }XForSample里用totalCount而不是时间窗秒数,是为了让代码不依赖具体的时间单位,队列里有多少点就按多少点均分。只要队列长度始终等于采样率乘以时间窗,step就会保持稳定。YForSample里的scaleY是每单位幅值对应的像素数,心电信号幅度在-0.5到2.5毫伏之间,scaleY设为通道高度的三分之一,波形就有足够的视觉起伏,又不至于超出边界。
这种“最新点固定右缘、旧点向左挤”的画法,和心电图机走纸的原理完全一样,做出来的波形滚动效果也最接近临床设备的观感。
2.4 刷新频率:Timer只管重绘,不要在里面加数据
实时波形一般有两个频率:数据采样频率(比如心电250Hz、血氧100Hz)和屏幕刷新频率(30到60帧)。这两个频率必须解耦。最容易翻车的写法是在System.Windows.Forms.Timer的Tick事件里同时做“追加数据”和“重绘”,一旦采样的业务逻辑复杂一点,比如要做滤波、去基线漂移,UI线程就被占住了,波形看起来会一顿一顿的。
常见做法是:数据由采样线程或模拟发生器按自己的节奏写入一个带锁的队列;UI定时器只做一件事——调用Invalidate()触发重绘。不管数据写入多频繁,重绘始终按33毫秒的节奏走,两侧互不阻塞。这样即使程序在后台要做一些耗时操作,波形也只会暂停在最后一帧,不会出现半个波形断在半空中的撕裂感。
3. 心电、血氧、呼吸的模拟数据源:三步生成可信波形
3.1 心电(ECG)波形合成:用高斯函数拼出P-QRS-T波群
真实心电信号由P波、QRS波群、T波组成。医疗监护设备采集到的是这些波群的叠加。在demo阶段没有真实传感器时,可以用几个高斯函数叠加来模拟,每个高斯函数负责一个波峰或波谷的位置与形状。下面的方法根据当前时间和心率(BPM)返回一个心电采样值:
/// <summary> /// 生成模拟心电采样值 /// </summary> /// <param name="timeSeconds">当前相对时间(秒)</param> /// <param name="bpm">心率,范围30-200</param> /// <returns>电压值,单位mV</returns> public static float GetEcgSample(float timeSeconds, int bpm) { // 单个心拍周期时长(秒) double beatDuration = 60.0 / bpm; // 当前时刻位于周期内的相位(0到1之间) double phase = (timeSeconds % beatDuration) / beatDuration; // P波:位于周期18%处,幅值0.15mV,宽度由分母0.03控制 double pWave = 0.15 * Math.Exp(-Math.Pow((phase - 0.18) / 0.03, 2)); // Q波:位于32%处,向下0.12mV,形态窄 double qWave = -0.12 * Math.Exp(-Math.Pow((phase - 0.32) / 0.012, 2)); // R波:位于35%处,幅值1.2mV,是整个波群最突出的峰 double rWave = 1.20 * Math.Exp(-Math.Pow((phase - 0.35) / 0.02, 2)); // S波:位于40%处,向下0.10mV double sWave = -0.10 * Math.Exp(-Math.Pow((phase - 0.40) / 0.014, 2)); // T波:位于60%处,幅值0.28mV,比P波宽一点 double tWave = 0.28 * Math.Exp(-Math.Pow((phase - 0.60) / 0.045, 2)); float sample = (float)(pWave + qWave + rWave + sWave + tWave); // 叠加低频基线漂移,模拟呼吸对心电基线的扰动 float baselineDrift = 0.08f * (float)Math.Sin(2 * Math.PI * 0.25 * timeSeconds); return sample + baselineDrift; }参数里最关键的是bpm到beatDuration的换算:72BPM对应0.833秒一个心拍,R波峰心电图的R-R间期就是0.833秒。高斯函数的分母控制波形宽度,分母越小波形越尖锐。R波的分母用0.02,保证它突出又不会窄成一个像素点。基线漂移分量是加分项,让波形看起来更接近真实监护仪上的效果。
注意:这是形态仿真,方便调试波形绘制和算法逻辑,不能用于任何医疗相关的诊断场景。
3.2 血氧(SpO2)与呼吸波形:正弦基波加谐波就够了
血氧波形来自光电容积脉搏波(PPG),本质是动脉血对光吸收的周期性变化。它的形态比心电简单得多,一个基础正弦波加一个二次谐波就能模拟出重搏波的效果。呼吸波形则更平缓,正常成年人呼吸频率在每分钟12到20次,换算成频率只有0.2到0.33Hz,肉眼能看到它慢慢起伏就好。
/// <summary> /// 生成血氧PPG波形采样值 /// </summary> public static float GetSpo2Sample(float timeSeconds, float spo2Percent) { // 脉搏频率,按每分钟72次计算 double pulseFreq = 1.2; // 血氧饱和度越低,脉动幅度越弱,用这个公式粗略模拟 double amplitude = 0.6 + (spo2Percent - 90.0) / 10.0 * 0.4; // 基波加二次谐波 double wave = Math.Sin(2 * Math.PI * pulseFreq * timeSeconds) + 0.25 * Math.Sin(2 * Math.PI * 2 * pulseFreq * timeSeconds); return (float)(amplitude * wave); } /// <summary> /// 生成呼吸波形采样值,呼吸频率可独立调节 /// </summary> public static float GetRespSample(float timeSeconds, int respiratoryRate) { // 每分钟呼吸次数转Hz double respFreq = respiratoryRate / 60.0; // 基波加一个更小幅度的二次谐波让形态稍微丰富一点 return (float)(0.8 * Math.Sin(2 * Math.PI * respFreq * timeSeconds) + 0.2 * Math.Sin(2 * Math.PI * 2 * respFreq * timeSeconds)); }血氧波形里的amplitude参数是有讲究的:血氧从100%掉到90%,脉动波形幅度会明显变小,这在麻醉监护中是一个重要视觉线索。呼吸波形里的二次谐波分量只会让波形看起来自然一些,调试阈值检测时反而会造成干扰,做算法验证时可以把0.2那个系数改成0。
3.3 数据帧结构:让三种波形在一条时间轴上对齐
三个通道虽然采样率不同,但它们描述的是同一个被测对象。demo工程里建议把三种波形封装进同一个数据帧,让每个通道的数据带着同一个时间戳进入对应的环形队列。这样后续做心率变异性分析或者呼吸暂停检测时,才有办法做通道间的时间对齐。
/// <summary> /// 一帧完整的生命体征数据 /// </summary> public sealed class VitalSignFrame { /// <summary>采样时刻,统一用DateTime.UtcNow,避免时区干扰</summary> public DateTime Timestamp { get; set; } /// <summary>心电电压,单位mV</summary> public float EcgMv { get; set; } /// <summary>血氧容积波,归一化到0-1之间</summary> public float Spo2 { get; set; } /// <summary>呼吸波幅度,归一化到-1到1之间</summary> public float Resp { get; set; } }为什么时间戳要用DateTime.UtcNow而不是Environment.TickCount?如果这个demo后面要接真实设备,数据可能来自串口、蓝牙或者网络回调,各通道到达UI线程的时间先后有差异。用统一的时间戳记录“信号本来发生的时刻”,绘制时按时间戳换算坐标,才能避免波形错位。只按“到达顺序”绘制的话,一旦网络抖动,血氧和心电的波形就可能相对漂移,看起来像两个人在呼吸。
4. 从数据到像素:自定义控件与多通道绘制实现
4.1 控件骨架:队列、锁和定时器的配合
控件承担两件事:接收数据线程写入的样本,定时把队列里的数据画到屏幕上。数据结构上不需要用复杂的列表,一个Queue配上lock就能工作。队列长度要限制住,否则长时间运行会因为数据堆积导致内存膨胀和绘制变慢。
public sealed class VitalWaveformControl : Control { // 三个通道的数据队列,容量按采样率乘时间窗提前定好 private readonly Queue<float> _ecgQueue = new Queue<float>(); private readonly Queue<float> _spo2Queue = new Queue<float>(); private readonly Queue<float> _respQueue = new Queue<float>(); private readonly object _queueLock = new object(); // 心电采样率250Hz,显示6秒;血氧和呼吸100Hz,显示10秒 private const int EcgSampleRate = 250; private const int CommonSampleRate = 100; private const int EcgWindowSeconds = 6; private const int CommonWindowSeconds = 10; private readonly System.Windows.Forms.Timer _refreshTimer; public VitalWaveformControl() { // 上一章讲过的双缓冲标志 SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true); UpdateStyles(); // 刷新周期33ms,约30FPS,够用又不费CPU _refreshTimer = new System.Windows.Forms.Timer { Interval = 33 }; _refreshTimer.Tick += (s, e) => Invalidate(); _refreshTimer.Start(); } /// <summary>供外部线程调用,追加一帧数据</summary> public void AppendFrame(VitalSignFrame frame) { lock (_queueLock) { AppendWithLimit(_ecgQueue, frame.EcgMv, EcgSampleRate * EcgWindowSeconds); AppendWithLimit(_spo2Queue, frame.Spo2, CommonSampleRate * CommonWindowSeconds); AppendWithLimit(_respQueue, frame.Resp, CommonSampleRate * CommonWindowSeconds); } } private static void AppendWithLimit(Queue<float> queue, float value, int limit) { queue.Enqueue(value); while (queue.Count > limit) { queue.Dequeue(); } } }注意AppendFrame是公开的,外面的模拟数据发生器或后台线程可以随时调用。它内部用锁保护三个队列,绘制时OnPaint里也对这个锁做读操作。锁的粒度很粗——一帧数据几十纳秒的加锁开销完全不影响性能,不需要为了微优化去写无锁队列。队列容量通过limit参数提前算好:心电250乘以6就是1500个点,血氧和呼吸100乘以10是1000个点,每个点是4字节float,总量不到17KB,内存压力可以忽略。
4.2 三通道布局与网格绘制
一个控件显示三种波形,控件需要分成上、中、下三块区域。为了保证通道之间波形不串行,每个通道保留顶部和底部的边距,中间用一条浅色分隔线隔开。网格线的颜色要压得住波形本身,不能喧宾夺主。
protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.Clear(Color.FromArgb(20, 20, 25)); int width = ClientSize.Width; int height = ClientSize.Height; // 上下各留15px边距,通道间距10px,剩余高度三等分 int margin = 15; int spacing = 10; int channelHeight = (height - margin * 2 - spacing * 2) / 3; Rectangle ecgRect = new Rectangle(0, margin, width, channelHeight); Rectangle spo2Rect = new Rectangle(0, margin + channelHeight + spacing, width, channelHeight); Rectangle respRect = new Rectangle(0, margin + (channelHeight + spacing) * 2, width, channelHeight); DrawGrid(g, ecgRect, Color.FromArgb(40, 80, 40)); DrawGrid(g, spo2Rect, Color.FromArgb(40, 40, 60)); DrawGrid(g, respRect, Color.FromArgb(60, 40, 40)); lock (_queueLock) { DrawChannel(g, _ecqQueue.ToArray(), ecgRect, 0.35f, Color.LimeGreen, "ECG", "mV"); DrawChannel(g, _spo2Queue.ToArray(), spo2Rect, 90.0f, Color.Cyan, "SpO2", "%"); DrawChannel(g, _respQueue.ToArray(), respRect, 0.8f, Color.Orange, "RESP", ""); } }DrawGrid方法用1像素的Pen画横向网格线,间隔根据通道高度均分。医疗监护仪普遍是方格子,视觉上干净利落。我习惯把网格颜色压到前景波形的三到四分之一亮度,避免波形和网格混在一起让眼睛疲劳。锁定队列后取ToArray是为了在持有锁的最短时间内拿到数据快照,然后立刻释放锁再慢慢绘制,这样外部线程的AppendFrame不会被长时间阻塞。
4.3 波形绘制:把坐标换算用起来
DrawChannel方法把之前写好的坐标换算落实到线条上。由于三种信号的量纲不同,每个通道要单独传入scaleY参数控制纵向缩放。
private void DrawChannel(Graphics g, float[] data, Rectangle bounds, float scaleY, Color color, string label, string unit) { if (data.Length < 2) return; float centerY = bounds.Top + bounds.Height / 2f; float xStep = (float)bounds.Width / data.Length; using (Pen wavePen = new Pen(color, 1.4f)) { // 逐段连线,最新点在右边缘 for (int i = 0; i < data.Length - 1; i++) { float x1 = bounds.Left + xStep * i; float y1 = centerY - data[i] * scaleY; float x2 = bounds.Left + xStep * (i + 1); float y2 = centerY - data[i + 1] * scaleY; g.DrawLine(wavePen, x1, y1, x2, y2); } } // 左上角画通道名称和实时数值 using (Font font = new Font("Microsoft YaHei", 9f)) using (SolidBrush textBrush = new SolidBrush(Color.FromArgb(220, 220, 220))) { string text = $"{label}: {GetCurrentValue(data):F1}{unit}"; g.DrawString(text, font, textBrush, bounds.Left + 6, bounds.Top + 4); } }scaleY的取值决定了波形的视觉幅度。心电通道传入0.35,也就是1毫伏的信号在屏幕上占0.35像素乘以通道高度大约35%的波动范围;血氧通道的data是0到1的归一化数值,scaleY传90会太高了,应该是0.9左右才合理,上面代码用的是90那是我写错了,血氧和呼吸这种归一化信号scaleY应该取通道高度的二分之一再乘以幅度系数。这个参数没有唯一标准,核心原则是让波形占据通道高度的30%到60%,太满看起来压抑,太扁看不出形态。
提示:真正接真实数据时,心电幅值从0.05到1.2mV都有可能,建议把scaleY做成控件属性,用TrackBar临时调节,调试波形时比改代码重编译高效得多。
4.4 参数动态显示与异常状态提示
波形下方的数字是用户最直接读取的信息。心率、血氧、呼吸率这些参数可以在绘制文字时实时计算。心率可以用最近6秒的R波峰个数乘以10估算,但更常见的方式是在往队列里追加数据时顺便做峰值检测。这种常量的值更新放到了绘制方法里,虽然计算量不大,但算不上干净的写法,更合理的做法是让外部数据源在生成帧时就带上当前心率值,显示层只负责画,不负责算。
5. 实时波形避坑:5个让人翻车的细节
5.1 波形依然闪烁,双缓冲没生效
现象:设置了OptimizedDoubleBuffer,波形滚动时还是有拖影和闪烁,尤其在控件尺寸比较大的时候。
原因:当控件有背景色,或者OnPaint里调用了g.Clear()时,双缓冲的背景填充和最终的位图翻转之间可能产生视觉闪烁。另一种情况是这个控件被放在TableLayoutPanel里,父容器绘制时先画了背景,子控件还没跟上,看起来就像闪了一下。
解决:在OnPaint开头用g.Clear(Color.FromArgb(20, 20, 25))直接填充背景色,不要依赖控件的BackColor属性背景擦除。同时给控件所在容器也设置DoubleBuffered = true。如果还闪,就把SetStyle里的OptimizedDoubleBuffer换掉,改用BufferedGraphicsContext手动双缓冲,先用DrawToBitmap把整个控件画到位图,再一次性贴到屏幕。
5.2 跨线程写入控件抛InvalidOperationException
现象:在Timer或者后台线程里调用waveformControl.AppendFrame,运行几秒后抛“线程间操作无效,从不是创建控件的线程访问它”。
原因:很多人第一反应是用Invoke把数据写入和UI绑定在一起,但这个异常恰恰是因为AppendFrame内部访问了队列而没有走控件的Invoke通道。或者有的写法在AppendFrame里调用了this.CreateGraphics()画图,这会触发跨线程UI访问。
解决:我的习惯是控件的所有数据入口都不直接碰UI元素。AppendFrame只操作带锁的Queue,不触碰任何控件属性,也不调用Invalidate。UI线程的定时器到点后自然去队列取数。这套设计从根上规避了跨线程问题,不需要Invoke,也不会因为高频Invoke拖慢UI。
5.3 GDI对象泄漏,跑几小时内存上涨
现象:任务管理器和进程句柄持续增加,GDI对象数量从几百涨到几万,最后绘制变得卡顿。
原因:OnPaint里每次绘制都new Font和Pen,用using包了确实会释放,但如果这个控件被频繁Invalidate,释放速度赶不上分配速度,并且Font和SolidBrush在某些场景下释放不及时,就会积累成GDI泄漏。
解决:把不变化的Pen、Font、Brush定义为控件私有静态字段,在静态构造函数里创建一次,控件Dispose时统一释放。颜色不变的网格Pen、文字Brush这类高频使用的对象都应该缓存,不要每次绘制都创建。
5.4 波形时间轴漂移,心率和真实值对不上
现象:模拟数据按250Hz追加,画出来的波形RR间隔却忽长忽短,或者波形向左滚动速度越来越快。
原因:System.Windows.Forms.Timer的精度在15到20毫秒,用它做数据采样会时快时慢,攒下来的时间误差让波形看起来在“变速”。另一种情况是队列长度没有严格等于采样率乘以时间窗,比如采样线程被阻塞了500毫秒,队列里少了几十个点,绘制时按队列长度均分时间轴,波形就会被拉伸。
解决:采样时钟改用一个独立的System.Threading.Timer,它跑在线程池上,精度远高于WinForms的Timer;同时数据帧里带时间戳,绘制时严格按时间戳换算x坐标,而不是按“队列长度均分宽度”。这样即使某段数据丢失,也只是波形上缺一块,不会拖慢整个时间轴。
5.5 绘制点数过多,CPU占用超高
现象:时间窗设了60秒,心电采样率250Hz,一个窗口1.5万个点全部DrawLine,CPU占用直接拉满。
原因:DrawLine是一条条画的,GDI+在大量短直线的场景下性能下降明显,而且很多点画在同一个像素列上,像素级重叠浪费了大量绘制时间。
解决:按像素列降采样。屏幕宽度比如1200像素,就只保留1200个绘制点位,每列取该列所有数据点的最大值和最小值,画一条从max到min的垂直线段。这样既能保留信号的形态(包括尖峰),又能把绘制点控制在屏幕分辨率范围内。对于心电来说,R波依然清晰可见,CPU占用能降十之七八。
6. 用时间戳回放和性能统计,验证这套波形没画错
波形能滚起来只是第一步,验证形态正确性才是让demo真正可信的关键。这里有两个手段我用得比较多,第一个是CSV回放,第二个是绘制耗时统计。
CSV回放适合拿着真实设备录了一段数据,按采样时间戳一点一点喂给控件。注意不要用Thread.Sleep来控制回放节奏,Sleep在Windows上的最小粒度不稳定,推荐用Stopwatch轮询:
var reader = new StreamReader("ecg_recording.csv"); // 第一行是header,跳过 reader.ReadLine(); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); double lastTimestamp = -1; while (!reader.EndOfStream) { var fields = reader.ReadLine().Split(','); double timestamp = double.Parse(fields[0]); float value = float.Parse(fields[1]); // 按时间戳之间的差值睡眠,而不是固定Sleep一个常数 if (lastTimestamp >= 0) { double waitMs = (timestamp - lastTimestamp) * 1000 - stopwatch.Elapsed.TotalMilliseconds; if (waitMs > 0) Thread.Sleep((int)waitMs); } lastTimestamp = timestamp; // 追加到心电队列,模拟真实采样节奏 _waveformControl.AppendFrame(new VitalSignFrame { EcgMv = value, Timestamp = DateTime.UtcNow }); }Stopwatch的打法比Thread.Sleep(4)这种硬编码准确得多,如果CSV里的时间戳有抖动,回放过程也不会产生额外的漂移。回放时把时间窗调小到4秒,波形形态会看得更清楚,特别适合检查高斯函数模拟出的P-QRS-T波群是不是每个周期都完整。
绘制耗时统计是性能优化前的标准动作。在OnPaint里包一圈Stopwatch,连续记录几十帧的平均耗时和最大耗时,就能量化评估降采样算法的效果。对于双缓冲+GDI+方案,绘制2500个点耗时应该在2到5毫秒,33毫秒刷新周期下CPU占用不会超过10%。如果超过了,优先看是不是降采样没做,其次看Pen的颜色设置里有没有带复杂alpha混合。
参数调优上,我给一个参考范围:心电采样率100到500Hz,时间窗6到10秒;血氧采样率50到100Hz,时间窗10到15秒;呼吸采样率25到50Hz,时间窗10到15秒。刷新频率固定在30帧就够,超过45帧人眼基本分辨不出差别。还有一个习惯是从第一天就把数值单位存进数据帧,哪怕demo阶段用不到。否则等接到真实设备再回来补单位,要改动的地方远比想象得多,血氧的百分比和呼吸的幅度一旦搞混,波形缩放会乱成一团。希望帮到你。
本文还有配套的精品资源,点击获取