
去年帮朋友处理一批试验田的土壤湿度采样数据领导只看了一眼散点图就摇头“我要的是像气象云图那样有颜色渐变、有等值线的东西。”当时手头只有几百个不规则分布的采样点既不是网格数据也没有地理信息系统那套工具。我盯着屏幕想了几秒反应过来该上Python了——更准确地说该上德劳内三角剖分和轮廓图那套组合了。那次之后我做了个总结用Python把不规则散点数据画成平滑的等值线彩图核心就三件事——先用德劳内三角剖分把散点连成三角形网格再在网格上做插值细化最后把细化后的网格绘成轮廓图。如果只想看一眼数据的大致形态半小时内就能出图。这篇文章把我常用的三重轮廓平滑画法完整拆开从原理到代码到踩坑都写清楚适合刚接触Matplotlib三角模块、或者被不规则数据折磨过的Python使用者参考。1. 为什么散点数据画等值线非要绕一道德劳内三角剖分1.1 规则网格与不规则散点的本质差异如果手里的数据是规则网格比如每隔0.1取一个点、x和y方向各100个点那画等值线可以直接上contourfMatplotlib会把二维数组当成均匀网格处理不需要任何额外的几何运算。规则网格的优势在于数据点之间的邻接关系是隐含的第i行第j列的点邻居天然是上下左右四个方向。但现实里大部分采样数据根本不是规则网格气象站位置是地理条件决定的不是规划出来的土壤采样要考虑地形和作物分布传感器布点更看现场环境。这些数据落在一张图上就是一堆“散点”互相之间没有天然的连接关系。你没法直接告诉contourf哪个点跟哪个点相邻所以需要一种算法把这些离散点组织起来形成一个可以插值的结构。德劳内三角剖分解决的就是这个“组织问题”把平面上的散点连接成互不重叠的三角形并且让所有三角形尽量“饱满”避免出现特别狭长的三角形。这个“饱满”性质非常关键它直接决定了后续插值的稳定性和轮廓线的视觉质量。一个理想的三角剖分应该让每个三角形都接近等边三角形而不是那种又扁又长的“面条”。1.2 德劳内剖分的两个关键性质第一个性质是空外接圆准则任意一个三角形的外接圆内部不能包含其他数据点。这个准则听起来很抽象但它的实际效果特别直观——它保证了三角形不会乱拉对角线不会出现两个三角形交叉重叠的情况。第二个性质是最大化最小角在所有可能的三角剖分方案里德劳内剖分能让最小的那个角尽量大。换句话说它避免生成极端狭长的三角形尽量让三角形形态均匀。这两个性质对后续插值的意义是实打实的。三角形越饱满插值计算时数值越稳定三角形越狭长插值结果越容易在某个方向上产生奇怪的拉伸。我在第一次用普通剖分算法处理数据时就遇到过轮廓线在某条对角线上突然扭曲的情况换成德劳内剖分后问题消失了因为三角形整体变均匀了。1.3 Matplotlib其实已经内置了三角剖分很多人一听德劳内想到的是scipy.spatial.Delaunay这个类确实能用但它返回的是点与点之间的连接关系你还得自己手动把三角形转成Matplotlib能懂的格式。好消息是Matplotlib的matplotlib.tri模块已经集成了一套完整的三角剖分、插值和绘制工具链日常使用根本不需要单独调SciPy。核心入口是matplotlib.tri.Triangulation。你只需要把x、y两个一维数组传进去它内部会自动完成德劳内剖分。之后无论是画三角网格、画轮廓填充、做插值都围绕这个Triangulation对象展开。这篇文章后面所有的代码都是基于这一套内置工具链来写的不需要额外引入SciPy。2. 三重轮廓的定位不是三个数据集而是三种平滑程度2.1 第一重原始三角网格上的等值线粗糙但真实“三重轮廓”这个名字第一次听的人容易误会以为要同时画三个不同数据集的轮廓。实际上这里的三重指的是同一个数据场、三种由粗到细的网格粒度下分别绘制轮廓图。这样处理的好处是能直观对比“原始网格”“线性加密网格”“三次插值加密网格”三者的视觉差异。第一重轮廓最朴素直接把德劳内剖分得到的三角形网格当作绘制基础用tricontourf在原始三角形上填充色块。因为网格密度直接取决于原始数据的分布点密的地方三角形多、色块细点稀的地方三角形大、色块粗所以这重轮廓的视觉感受是“棱角分明”的。第一重的价值在于真实——它没有做任何额外的数学假设完全基于原始数据。缺点是难看轮廓线是折线色块过渡有明显棱角尤其数据点稀少时轮廓看起来很像一张折纸作品。它适合放在第一张子图里作为基准线让后面两重图有个对照。2.2 第二重线性细分在不改变数据的前提下加密网格第二重开始引入网格细化。Matplotlib提供了UniformTriRefiner它的核心方法refine_field可以对已有三角网格做加密。加密逻辑很简单每个三角形每条边的中点插入一个新点一个三角形就变成了四个小三角形。这个操作每做一次三角形数量大约变成原来的四倍。这里有个关键点加密之后每个新插入的顶点都需要一个z值。线性插值的做法是新顶点的z值直接由所在三角形三个顶点的z值加权平均得到。所以这个插值过程不会改变原始数据点的z值只是在原有的三角网上“塞”进更多点让轮廓有了更多细节可画。第二重在实际操作里通常用subdiv2——加密两次三角形数量变为原来的约16倍。这重轮廓已经能看出明显的平滑趋势色块之间的过渡自然了很多但因为插值函数本身是线性的轮廓线仍然有一些轻微的折角。2.3 第三重三次插值把轮廓线彻底“捋顺”第三重是目前我用得最多的方式也是标题里“平滑”两个字的主要来源。它在网格加密的基础上把插值方式从线性升级为三次。Matplotlib里对应的是CubicTriInterpolator它构造的是一个在每个三角形内部都是三次多项式的连续函数。三次插值比线性插值强在连续性上线性插值只保证z值连续但z值的变化率导数在三角形边界上是跳变的三次插值进一步要求导数连续所以颜色过渡更丝滑轮廓线不会出现生硬的折角。这重轮廓再加上subdiv3的加密画出来的图已经可以达到气象云图的视觉效果。实际操作中第三重图我还会叠加一层白色等高线tricontour让颜色之外多一层形状信息汇报演示时更清楚。三者的差异我用一张表总结层级网格来源插值方式subdiv轮廓形态适用场景第一重原始德劳内网格无直接用原始z0折线感强、棱角分明快速预览、数据验证第二重原始网格加密线性插值2过渡自然、轻微折角常规分析、报表插图第三重原始网格加密三次插值3平滑连续、细节丰富对外展示、出版级图3. 环境准备装的东西越少坑越少3.1 需要哪些库每个库负责什么这套代码依赖的库只有两个numpy和matplotlib。NumPy负责生成和计算数据数组Matplotlib负责三角剖分、插值和绘图。如果你的Python环境是直接装的Anaconda这两个库默认就有如果用的是精简版Python执行下面这行命令补上pip install numpy matplotlib这里我不建议再装一堆额外的可视化库。Matplotlib的matplotlib.tri模块是标准库的一部分功能相当完整不需要plotly、seaborn之类的辅助。少一个依赖就少一分版本冲突的风险。3.2 运行前的两个小确认第一件事确认你的Matplotlib版本在3.5以上。这关系到UniformTriRefiner和CubicTriInterpolator的稳定性旧版本这两个类的某些参数的行为有些微妙差异。可以用下面这行查python -c import matplotlib; print(matplotlib.__version__)第二件事如果你在用VS Code或者Jupyter跑建议把图形后端设置为非交互式或者用plt.show()时保持悬浮窗。要是画出来的图一直不刷新大多数情况是Jupyter里没加%matplotlib inline在文件顶部加上即可%matplotlib inline4. 完整代码实现从随机散点到三重轮廓4.1 构造一份有层次的测试数据要演示三重轮廓测试数据得有点“花样”才行——最好有峰值、有鞍部、有平缓区域这样轮廓图看起来才有讨论的价值。我通常用两个高斯函数叠加来造数据形状可控还能复现import numpy as np import matplotlib.tri as mtri import matplotlib.pyplot as plt np.random.seed(42) n 300 x np.random.uniform(-3, 3, n) y np.random.uniform(-3, 3, n) # 两个高斯峰叠加制造有山脊、有鞍部的连续场 z ( 1.4 * np.exp(-((x - 0.5) ** 2 (y - 0.5) ** 2) / 1.2) 1.0 * np.exp(-((x 1.2) ** 2 (y 1.0) ** 2) / 0.8) 0.5 * np.exp(-((x 0.2) ** 2 (y - 1.8) ** 2) / 0.4) )数据点用的是均匀随机分布这样三角剖分后的三角形大小不会差太多网格形态比较健康。实际数据如果某些区域点特别密、某些区域特别稀剖分出来会有些三角形偏大第一重图会略显粗糙后面第6章会专门讨论这个问题。4.2 三重轮廓的完整绘制代码数据准备就绪后核心流程只有几步构建三角剖分、生成统一的分层级别、线性细分一次、三次插值细分一次然后并排绘制三张子图。直接看代码tri mtri.Triangulation(x, y) # 统一分层级别保证三张子图颜色可比 levels np.linspace(z.min(), z.max(), 15) refiner mtri.UniformTriRefiner(tri) # 第二重线性插值加密两次 tri_linear, z_linear refiner.refine_field(z, subdiv2) # 第三重三次插值加密三次 cubic mtri.CubicTriInterpolator(tri, z) tri_cubic, z_cubic refiner.refine_field( z, triinterpolatorcubic, subdiv3 ) fig, axes plt.subplots(1, 3, figsize(17, 5.2)) # 第一重原始三角网格 轮廓填充 ax axes[0] cf0 ax.tricontourf(tri, z, levelslevels, cmapviridis) ax.triplot(tri, colorgray, linewidth0.3, alpha0.5) ax.set_title(Level 1: raw triangulation) # 第二重线性细分轮廓 ax axes[1] ax.tricontourf(tri_linear, z_linear, levelslevels, cmapviridis) ax.set_title(Level 2: linear refine (subdiv2)) # 第三重三次插值加密轮廓 白色等值线 ax axes[2] cf2 ax.tricontourf(tri_cubic, z_cubic, levelslevels, cmapviridis) ax.tricontour(tri_cubic, z_cubic, levelslevels, colorswhite, linewidths0.5) ax.set_title(Level 3: cubic interpolation (subdiv3)) # 统一色条和坐标轴尺度 cbar fig.colorbar(cf2, axaxes, shrink0.85) cbar.set_label(z) for ax in axes: ax.set_aspect(equal) plt.tight_layout() plt.show()这段代码跑完三张子图会在同一个窗口并排出现。第一张能看出明显的三角网格和折线状色块第二张颜色渐变顺滑了一截第三张已经完全是连续色彩地形图的样子。4.3 控制图面细节统一levels是容易忽略的关键代码里有个细节我要单独拎出来强调三层轮廓图用了同一个levels数组而不是让Matplotlib各自计算。如果你分别用默认的自动分层三张图的颜色区间可能对不上——第一张图红色的区域在第三张图里可能是黄色看起来像是数据变了实际上只是分层不一致。我自己早期就吃过这个亏第一重图用自动分层第二重图也自动分层结果两张图放在一起领导直接问“这两个图数据一样吗”。后来统一用np.linspace(z.min(), z.max(), 15)把层级别固定住三张图颜色严格一致对比才有意义。另外subdiv参数的设定也有讲究。subdiv2意味着一分为四地加密两次三角形数量大约是原始的16倍subdiv3是64倍。300个点原始剖分出大概550个三角形加密三次后约35000个三角形绘制完全没压力。但如果数据点已经上万subdiv3直接变成百万级的三角形会明显卡顿这时候需要往下调。5. 平滑背后的原理UniformTriRefiner在做什么数学操作5.1 细分算法每条边中间插一个点1变4subdiv参数在UniformTriRefiner里的含义很多人只看文档不一定有直观感受。它的算法实际上是逐边加密取每个三角形三条边的中点连起来形成四个小的相似三角形。这个操作在计算几何里叫“均匀细分”每做一次三角形的边数变密一倍。这个加密有个非常重要的特性新增加的点都在边上且都会同时属于相邻的几个三角形所以细分后的网格仍然是合法的三角剖分没有裂缝、没有重叠。这一步做完相当于在原始数据点之间插入了一批“虚拟点”给后面的插值提供了更多判定的位置。用数学公式看假设原始三角形数量是T每细分一次三角形数量变成4T细分两次是16T三次是64T。所以subdiv3会得到一个比原始网格密集64倍的新网格。这就是为什么第二、三张图的轮廓细节明显比第一张丰富。5.2 线性插值 vs 三次插值平滑差异的本质网格加密解决的是“点数不够”的问题插值方式解决的是“点与点之间怎么过渡”的问题。两者结合才能做出平滑效果。线性插值的逻辑朴素——新顶点的z值等于所在三角形三个顶点z值的加权平均权重就是面积占比。它的优点是计算快、保证值不会超出原始范围但缺点是导数不连续跨过三角形边时色块变化方向可能突变反映到图上就是轮廓线有折角。三次插值在Matplotlib里有kindgeom和kindmin_E两种模式。geom基于几何方法构造计算量小适合数据量大的情况min_E通过最小化弹性能量获得更平滑的场视觉效果最好但计算开销大。就我实测经验300个点的数据用min_E也就零点几秒完全用得起数据上万再考虑用geom省时间。默认的CubicTriInterpolator(tri, z)会使用min_E模式这也是我推荐用的默认设置。两种插值方式的差异我从实用角度整理成一个表维度线性插值三次插值z值连续性连续连续导数连续性不连续连续轮廓线形态有轻微折角圆弧过渡计算耗时非常快略慢但数据量小时可忽略过冲风险无严格在原始值范围内可能出现轻微过冲适用场景快速预览、大范围数据高精度绘图、对外展示5.3 另辟蹊径先用插值再画普通contourf除了tricontourf直接作用于三角网格Matplotlib还支持先用插值器把数据重采样到规则网格上然后画普通的contourf。这个方案在需要把三角数据和其他规则数据叠加的时候很有用比如把轮廓画在地图底图上。相关代码很简单from matplotlib.tri import LinearTriInterpolator linear mtri.LinearTriInterpolator(tri, z) # 构造规则网格坐标 gx np.linspace(x.min(), x.max(), 200) gy np.linspace(y.min(), y.max(), 200) gx, gy np.meshgrid(gx, gy) gz linear(gx, gy) plt.contourf(gx, gy, gz, levelslevels, cmapviridis)这个做法的缺点是重采样过程中会丢失边界信息数据稀疏区域可能会产生一些插值空洞。所以我个人还是更推荐直接在三重轮廓的场景里用tricontourf少一次转换就少一次失真。6. 踩坑记录退化三角形、边界外延、细分爆炸6.1 数据点太规整轮廓反而出现假锯齿第一类常见的坑是数据点太规整了。比如有人直接把网格数据稍微加个随机扰动就拿来画结果三角形剖分得很不均匀有些共圆或者近似共圆的点被连接成又扁又长的三角形轮廓图里出现锯齿状的伪影。排查思路是这样的先画triplot看三角网格如果图上能看到明显狭长的三角形那就说明剖分质量有问题。常规解法是给原始坐标加一点微小的随机抖动破坏共圆性让德劳内算法能正常工作jitter 0.001 * np.random.uniform(-1, 1, sizex.shape) x_jittered x jitter y_jittered y jitter tri mtri.Triangulation(x_jittered, y_jittered)这个解法的思路是先理解问题根源——共圆导致三角剖分不唯一、出现退化三角形再施加扰动让退化三角形消失。如果不想改数据也可以用TriAnalyzer压缩掉退化三角形但判断条件需要调参不如直接抖动数据来得利索。6.2 凹形数据区域轮廓长得越界了第二类问题更隐蔽当数据本身边界是凹的比如沿着河流、海岸线采样德劳内三角剖分会自动把凹进去的两岸直接连起来形成跨越水域的三角形。这样一来轮廓图里本来没有数据的凹区域会被色块填满看起来像“水上长出了陆地”。这类问题我建议先看三角形重心和原始点分布的关系再做掩膜处理。Matplotlib的Triangulation支持set_mask方法可以把不需要的三角形隐藏掉。一个比较实用的策略是用TriAnalyzer先找到异常大的三角形按照外接圆半径阈值做掩膜tri mtri.Triangulation(x, y) analyzer mtri.TriAnalyzer(tri) masked_tri analyzer.get_compressed_triangulation()get_compressed_triangulation会把极度扁平的三角形从网格里剔除返回一个更健康的剖分结果。之后再用这个masked_tri去做refine_field和三重绘制凹边界外的色块就不会乱长了。不过要注意压缩之后返回的是一个新Triangulation对象插值器要用新对象来构造。6.3 subdiv参数拉太高内存直接爆炸第三个坑是性能问题。我见过有人为了追求“极致平滑”把subdiv设置成6结果一运行电脑风扇狂转等了半天图也没出来。算一下就知道为什么300个点约550个三角形subdiv6意味着加密6次三角形数量变成550 × 4^6 ≈ 225万。这个数量级虽然不算灾难但绘制时间已经足够让交互界面卡死了。我的经验阈值是几千个数据点以内subdiv3完全够用数据上万降到subdiv2数据超过十万直接用第一重原始网格或者先降采样。平滑效果不是靠一次到位而是靠插值质量和显示尺寸的平衡。屏幕上本来就只能呈现那么多个像素网格加密到一定的程度后再密也看不出区别。6.4 z值中间有缺失数据颜色块断层最后一类坑比较隐蔽如果z数组里有NaN或者inftricontourf在计算分层的时候会直接崩掉报错信息还看不出来具体原因。处理方式是在构建三角剖分之前先清洗数据找出包含非有限值的点把它们从x、y、z三个数组里一并剔除。finite_mask np.isfinite(z) x, y, z x[finite_mask], y[finite_mask], z[finite_mask]这个操作一定要在构建Triangulation之前做因为一旦三角形已经建立再删除点会导致网格结构错乱。我习惯把这段清洗代码放在数据的入口位置形成固定习惯就不会再被NaN坑了。7. 换成你自己的数据真实数据替换模板7.1 最简替换只要三个一维数组如果你已经有一套自己的数据替换测试数据非常简单——代码里需要改的只是x、y、z这三个一维NumPy数组。x和y是数据点的平面坐标z是对应点上的测量值。保持三个数组的长度一致其他都不用动。一套比较稳妥的做法是在生成Triangulation之前先打印一下三个数组的基本信息print(x.shape, y.shape, z.shape) print(np.isfinite(x).all(), np.isfinite(y).all(), np.isfinite(z).all())我一般在接新数据的项目里都会先跑这两行确认维度一致、没有缺失值再进入绘图管线。这步虽小但能省掉后面一大半排查时间。7.2 三类常见的数据场景我把平时遇到最多的三种数据形态整理一下供你对号入座。第一类是地理气象类经纬度坐标对应温度、降水量、PM2.5浓度。z值跨度通常比较大建议levels数量设置在12到20之间太少看不出梯度太多颜色分层过碎。颜色映射用RdBu_r这类双色渐变比viridis更容易读出冷暖。第二类是工程检测类二维平面坐标对应应力、应变、误差值。这类数据往往涉及精确的阈值判断比如某个区域是否超限。这种情况下除了tricontourf建议搭配tricontour把关键等高线单独绘制出来并设置levels为关键阈值而不是均匀取值levels [0.5, 0.8, 1.0, 1.2, 1.5] # 自定义关键分层第三类是用波测量或采样点形成的物理场比如声压分布、光强分布。这类数据噪声通常比较大直接画原始数据轮廓图会出现大量小碎块可以在插值之前先对z做一次平滑滤波比如移动平均或用scipy.ndimage.gaussian_filter处理再进入绘制流程。7.3 样式微调的几个实用参数真实项目里三重轮廓图很少直接交差通常要按汇报场景调样式。几个最实用的参数颜色映射的选择上连续物理量用viridis、magma这类感知均匀的色带冷暖对立的物理量用RdBu_r。透明度上如果轮廓图下面还要叠底图或者网格给tricontourf加上alpha0.8既保留颜色信息又不完全遮住底层内容。等高线线宽第三重图里的白色等高线我一般控制在linewidths0.4到0.8之间太粗会糊成一片太细打印出来看不清。最后提一个我固定会用的小设置ax.set_aspect(equal)。如果不设置Matplotlib会拉伸坐标轴让图占满画布圆形区域会被拉成椭圆轮廓形状看起来会变形。加上这行代码之后x和y方向比例保持一致图形才能反映真实几何关系。坐标轴比例失真是很多人容易忽略的细节但对最终成图的影响非常大。我现在做类似的可视化工作已经养成了“先画第一重验证数据、再跑第二重看趋势、最后用第三重出图”的固定流程。第一重图速度快、信息全是排查数据问题的第一道关口第三重图用最少的话说服人是汇报展示的门面。中间那重更像一个过渡但它在对比插值效果的时候特别有用。你拿去用的时候记得先跑通测试数据再替换成自己的遇到边界现象就从第6章那些坑里找答案基本都能解决。