
拿到一批带高程的散点坐标第一反应往往是画个散点图或者拧成一张网格热力图。可散点图看不出趋势普通热力图又必须把不规则点强制塞进规则网格插值误差和边缘畸变都在所难免。我这两年做地形采样数据的可视化时越用越顺手的方案是先用德劳内三角剖分还原点与点之间的拓扑关系再用它生成轮廓。名字听起来唬人实际就是Python里matplotlib.tri那套工具链的活十几行代码就能出图。这篇文章我会围绕“三重轮廓”这个核心拆开讲透为什么德劳内剖分适合做轮廓底图、怎么把散点层/三角骨架层/平滑等值线层叠到同一张画布上、以及平滑到底有哪几条靠谱路线。想抄作业的可以直接跳到最后的完整模板想弄明白原理的可以从头看。1. 为什么散点图不够用德劳内剖分到底解决了什么问题1.1 从“点”到“拓扑关系”的一步假设手里有200个高程采样点分布在100×100的区域内。直接画散点只能看到“哪里密哪里疏”画热力图则必须把整个区域切成均匀格子再把落在格子里和格子外的点揉在一起估值。这两步都会引入一个问题原始数据里其实藏着“哪些点挨着哪些点”这个拓扑信息散点图和规则网格都没把它用起来。德劳内三角剖分做的事情很朴素把平面上的点全部连成三角形且保证任何一个三角形的外接圆内部不包含其他点。满足这个条件的三角网是唯一的在退化情况之外它会自然地让每个点只和自己“真正邻近”的点连线不会出现跨越一大片空白去连远亲这种局面。这个性质对轮廓绘制特别重要。轮廓线的本质是“在三角形内部做线性插值找到等值点再连起来”。如果三角剖分不合理比如某些三角形特别瘦长插值出来的等值线就会扭曲、断裂。德劳内三角形天生规避了这种畸形所以用它的剖分结果去画轮廓线条质量最稳。1.2 空圆性质与最大最小角德劳内三角形为什么“好看”德劳内剖分有一条核心性质空圆准则。每个三角形的外接圆内不包含任何其他顶点。这条准则推到极致等价于另一个性质——最大化最小角。也就是说在所有可能的三角剖分方案里德劳内剖分能让最“尖”的那个三角形尽量不尖整体形状最均衡。我在实际项目里见过反例。有个朋友直接拿scipy.spatial.Delaunay剖分一批不规则采样点然后逐三角形上色结果图面上全是细长条色块碎得像马赛克。原因就是他的输入数据里有些点距离极近普通剖分算法不会考虑三角形形状很容易生成“针状三角形”。换成德劳内之后虽然不能完全消灭狭长三角形但整体观感好了一个量级。1.3 Voronoi图德劳内三角网的“另一半”如果说德劳内三角网是“点与点之间的邻接关系”那Voronoi图就是它严格对偶的“区域分割关系”。每一个德劳内三角形的外心连接起来就得到一张Voronoi图。反过来Voronoi图的边界顶点连线就是德劳内三角形。实际做可视化时很多人只需要三角网不需要Voronoi图。但理解对偶关系能帮你调试问题如果发现三角剖分把某些不该连的点连起来了看一下对应区域的Voronoi单元边界是否异常往往能定位到数据源里存在重复点或近似重合点。2. 画出第一版用Python完成三角网格与三重轮廓叠加2.1 造一份符合真实场景的模拟数据先准备一份模拟数据。设某区域有250个随机采样点坐标在0到100之间高程由一个带噪声的二元函数生成。这样的数据既保留了真实地形采样的随机性又自带宏观趋势非常适合用来展示轮廓效果。import numpy as np import matplotlib.pyplot as plt import matplotlib.tri as mtri rng np.random.default_rng(42) x rng.uniform(0, 100, 250) y rng.uniform(0, 100, 250) z (30 * np.sin(x / 18) * np.cos(y / 25) 12 * np.cos(x / 35 y / 28) rng.normal(0, 1.5, 250))这里z的高频噪声控制在1.5左右比主信号小一个量级所以轮廓线不会碎掉又保留了真实采样的毛刺感。如果噪声调大到5以上后续画出来的等值线就会出现大量小闭合圈视觉上非常乱。2.2 基于matplotlib.tri直接剖分matplotlib.tri有一整套高层API入口是一个Triangulation对象tri mtri.Triangulation(x, y)它内部使用的是scipy.spatial.Delaunay的剖分结果但做了不少面向绘图的封装。最常用的三个接口是triplot()画出三角网格骨架tricontourf()在三角形上做线性插值后填充等值区域tricontour()绘制等值线。这三兄弟配合ax.scatter()恰好能构成“散点层 骨架层 轮廓层”的三重叠加。这也是“三重轮廓”在工程上最常见的落地方案第一重是原始采样点第二重是德劳内三角形骨架第三重是平滑等值线。2.3 三重轮廓的叠加逻辑散点、骨架、等值线三层一图下面的代码把三层画在同一张画布上fig, ax plt.subplots(figsize(10, 8)) # 第一重原始采样点白底黑边保证在色块上清晰 ax.scatter(x, y, s8, cwhite, edgecolorsk, linewidths0.4, zorder3, label采样点) # 第二重德劳内三角骨架灰色细线避免喧宾夺主 ax.triplot(tri, linewidth0.3, colorgray, alpha0.5, zorder1, label德劳内三角网) # 第三重填充等值面 三条平滑等值线 cf ax.tricontourf(tri, z, levels8, cmapterrain, alpha0.75) cs ax.tricontour(tri, z, levels3, linewidths1.6, colorsdarkgreen, zorder2, label等值线) ax.clabel(cs, fmt%.0f, fontsize9) fig.colorbar(cf, axax, label高程) ax.set_aspect(equal) ax.legend(locupper right)这里有几处细节值得说明。第一levels3的等值线图会按数据范围均匀切成三段画出的其实是“三重高度层”的轮廓这也对应标题里“三重轮廓”的另一层理解低、中、高三档高程的等值线。levels8的填充面则负责提供更细腻的等高着色让轮廓线更有层次。第二zorder必须显式指定。散点要浮在最上面否则会被填充面吃掉骨架线要压在最下面否则等值线会显得杂乱。这个顺序我第一次画的时候就搞反了结果三角网把等值线切得一截一截换了zorder才解决。第三ax.set_aspect(equal)在这类空间图上建议加上。如果x和y的量纲相同但取值范围差异大不强制等比例会导致三角形看起来被拉伸误判地形坡度。3. 平滑轮廓的硬核手段从网格重采样到等高线后处理3.1 思路A把三角网格变成高分辨率规则网格直接调用tricontour画出来的等值线仔细看会发现有轻微的折线感。因为它在每个三角形内部做的是线性插值线段在公共边上会拐弯三角形越少折线感越明显。最直接的平滑思路是先把原始三角网格插值到一张高分辨率规则网格上再用普通contour画等值线。matplotlib.tri自带LinearTriInterpolator可以一步完成插值gx, gy np.meshgrid(np.linspace(0, 100, 400), np.linspace(0, 100, 400)) linerp mtri.LinearTriInterpolator(tri, z) gz linerp(gx, gy) # 可选的进阶写法用UniformTriRefiner一步到位 refiner mtri.UniformTriRefiner(tri) tri_refi, z_refi refiner.refine_field(z, subdiv3)UniformTriRefiner.refine_field会在每个三角形内部递归插入子三角形subdiv3意味着每个原始三角形变成4^364个小三角形。加密之后再用triplot或者tricontour折线感就明显减弱了。这个办法的好处是可以让网格自动适配剖分密度不需要手动指定规则网格的范围和步长缺点则是三角形数量按指数膨胀点一多内存就吃紧。3.2 思路B径向基函数插值真正意义上的“平滑”线性插值只能保证连续不能保证光滑。如果你想要的轮廓像等高线地形图那样圆润可以用径向基函数RBF插值先拟合出一个光滑曲面再提取等值线。SciPy从1.9版本起提供了RBFInterpolator使用简单from scipy.interpolate import RBFInterpolator rbf RBFInterpolator(np.column_stack([x, y]), z, kernelthin_plate_spline, smoothing0.2) gz_rbf rbf(np.column_stack([gx.ravel(), gy.ravel()])).reshape(gx.shape)这里有个大坑RBFInterpolator不像LinearTriInterpolator自带“只对凸包内有效”的掩膜它会对全平面外推。对于凸包之外的区域外推值经常出现离谱的震荡。所以我每次用RBF都强制加一道掩膜trifinder tri.get_trifinder() inside trifinder(gx, gy) gz_rbf[inside 0] np.nantrifinder(gx, gy)返回每个格点所在的三角形编号凸包外返回-1。把这部分格点设成np.nan后matplotlib.contour会照着nan把线断开图面就干净了。对比过三种常见kernel后我个人推荐thin_plate_spline。它产生的曲面在数据点之间过渡平缓不会像multiquadric那样过度冲高也不会像linear那样仍然带有折角。smoothing参数我一般取0.1到0.5取值越大曲线越圆滑但偏差也会变大当事人数据噪声很小时可以直接设为0。3.3 思路C对已生成的等值线做后处理平滑还有一个更“投机取巧”的思路先用tricontour拿到LineCollection里的坐标然后用贝塞尔拟合或卷积核把折线磨圆。我在自己写的工具脚本里放过一个轻量版用scipy.ndimage.uniform_filter1d对坐标序列做滑动平均from scipy.ndimage import uniform_filter1d def smooth_contour(xs, ys, ksize5): xs_s uniform_filter1d(xs, sizeksize, modewrap) ys_s uniform_filter1d(ys, sizeksize, modewrap) return xs_s, ys_s注意modewrap只适合闭合的等高线对于开放线要用modenearest或干脆只在两端做特殊处理。这种方案的优点是快但缺点也很明显滑动平均会让轮廓线整体收缩闭合圈面积变小。所以它只能用于演示或初筛真正出图不建议用。3.4 三条路线的适用场景对比方案效果性能适用场景UniformTriRefiner细分折线感减弱仍有小棱角中等三角形指数增长点数几千级别出图精度要求一般规则网格RBF插值曲线圆润观感最好较慢RBF训练O(n²)级点数几百到几千追求高质感出图等值线坐标后处理快速但会收缩闭合圈最快快速预览不推荐最终出图我现在的习惯做法是先UniformTriRefiner快速看效果确认轮廓没有异常断裂后再上RBF插值出最终图。这样既不会浪费太多时间也能保证最终图的光滑度。4. 三重轮廓实战中的翻车现场边界异常与数值抖动4.1 插值越界规则网格跑到凸包外面去了我最初用规则网格RBF插值时出图后画面的四个角落全部变成深红色——高程被外推到了不可思议的巨大值。原因就是前面提到的RBF会外推而凸包之外的区域根本没有采样点约束。排查这种问题有个技巧先把triplot设成alpha1单独画一张边界图画出凸包轮廓再用同一套坐标范围去画插值网格。如果插值范围明显大于凸包范围那np.nan掩膜必须跟上。还有一个小细节np.meshgrid生成的范围并不天然等于三角剖分的凸包所以即便你设置了xlim/ylim角块区域依然可能被外推值污染。正确做法就是始终用trifinder判断是否在三角网内部。4.2 轮廓断裂与锯齿轻量级解决方案某次拿到一批沿海采样数据点云内部有一大块区域完全空白比如水面。德劳内三角剖分可不管你这里是不是湖照样会把湖岸两边的点连成三角形导致轮廓线横穿空白区视觉上非常误导。处理方案其实不只一种。最省事的是直接屏蔽三角形找出所有边长超过阈值的三角形把它们从Triangulation里剔除。matplotlib.tri.Triangulation允许传入mask字段tri_mask tri_edges.max_length 25 # 阈值按数据尺度定 tri mtri.Triangulation(x, y, trianglestri.triangles) tri.set_mask(tri_mask)这里的max_length需要自己算可以取三角形三条边中最大值的平均值。阈值定得太小会把正常连接的三角形误删定得太大又没用。我通常先画一次不带掩膜的图用交互式调试框选几个异常三角形量一下边长作为参考。4.3 数据量级悬殊时的色带失真当z值分布严重偏斜时比如大部分点在5到8之间但有个别点冲到80terraincolormap 会把它近90%的颜色都分配给高值的零星几个三角形低值区糊成一片。这时不要只调色带最简单的办法是分位数截断。把z中超过95%分位数的值压到该分位数上或者干脆用vmin/vmaxp5, p95 np.percentile(z, [5, 95]) cf ax.tricontourf(tri, z, levels8, cmapterrain, alpha0.75, vminp5, vmaxp95)另外等值线数量也不要机械地固定成3。如果数据范围跨度大先按分位数切分再取其中3到5条线作为高亮轮廓效果比均匀切三段稳得多。我常用levelsnp.percentile(z, [20, 50, 80])这种写法保证三条线覆盖的样本量均衡。5. 可直接复用的完整模板与关键参数解读把上面所有内容收拢成一份能直接跑的模板。数据用随机生成的高程采样产出三重轮廓平滑德劳内图坐标范围、颜色、层级全部留成变量方便改到自己项目里。import numpy as np import matplotlib.pyplot as plt import matplotlib.tri as mtri from scipy.interpolate import RBFInterpolator # ---------- 1. 模拟数据 ---------- rng np.random.default_rng(42) n_pts 250 x rng.uniform(0, 100, n_pts) y rng.uniform(0, 100, n_pts) z (30 * np.sin(x / 18) * np.cos(y / 25) 12 * np.cos(x / 35 y / 28) rng.normal(0, 1.5, n_pts)) # ---------- 2. 德劳内剖分 ---------- tri mtri.Triangulation(x, y) # ---------- 3. 规则网格 RBF 平滑插值 ---------- gx, gy np.meshgrid(np.linspace(0, 100, 400), np.linspace(0, 100, 400)) rbf RBFInterpolator(np.column_stack([x, y]), z, kernelthin_plate_spline, smoothing0.2) gz rbf(np.column_stack([gx.ravel(), gy.ravel()])).reshape(gx.shape) # 掩膜只保留三角网内部的点 trifinder tri.get_trifinder() gz[trifinder(gx, gy) 0] np.nan # ---------- 4. 绘制三重轮廓 ---------- fig, ax plt.subplots(figsize(10, 8)) # 第一重采样点 ax.scatter(x, y, s8, cwhite, edgecolorsk, linewidths0.4, zorder3, label采样点) # 第二重三角骨架 ax.triplot(tri, linewidth0.3, colorgray, alpha0.5, zorder1, label德劳内三角网) # 第三重平滑等值面与三重轮廓线 levels_fill 8 levels_line np.percentile(z, [20, 50, 80]) # 按分位数切三段 cf ax.contourf(gx, gy, gz, levelslevels_fill, cmapterrain, alpha0.75) cs ax.contour(gx, gy, gz, levelslevels_line, linewidths1.6, colorsdarkgreen, zorder2) ax.clabel(cs, fmt%.0f, fontsize9) fig.colorbar(cf, axax, label高程) ax.set_aspect(equal) ax.set_xlabel(X / m) ax.set_ylabel(Y / m) ax.legend(locupper right) plt.show()几个参数传进场后最值得调的点smoothing噪声大就调高数据平滑就调低到0.05。调高会牺牲采样点附近的贴合度调太低曲线会拐出小波浪。levels_line np.percentile(z, [20, 50, 80])比等间距更有代表性尤其当Z值分布不均时三条线大致对应低/中/高三档。np.linspace(0, 100, 400)中的400分辨率。我的经验是400×400已经足够出高清图再高只会让RBF插值变慢视觉提升非常有限。实测过程中积累的几点体会最后分享几条个人经验都是文档里不会写的东西。第一用RBFInterpolator时smoothing参数的表现和数据量强相关。250个点和2500个点时同样设0.1前者几乎没平滑效果后者会明显“磨平”。我的做法是先画一张不设平滑的图看轮廓的锯齿密度再决定是加smoothing还是加网格分辨率。第二“三重轮廓”有不同的理解方式出图前先和需求方对齐。有人要的是“散点网格等值线”三层结构图有人要的是“低中高三条等值线”还有人想的是“同一张图里三种不同的三角剖分参数做对比”。文章的代码可以覆盖前两种第三种则需要改成三个子图。第三颜色方案上terrain虽然经典但发布到期刊或PPT上时高对比度黑白打印的效果往往更好。可以准备一个备选方案cmap plt.get_cmap(cividis)cividis对色觉障碍者也友好而且视觉上比默认的viridis更耐看。第四不要小看ax.set_aspect(equal)这一行。凡是x、y都带空间量纲的图少了它三角形会被拉成长条等值线也跟着变形。第一次画这类图的人最容易漏掉它漏掉之后又很难找出哪里不对。如果你手里的数据量级在万点以上RBF方案会明显变慢。此时建议先用UniformTriRefiner处理或者干脆降采样后再插值。毕竟等高线图是为趋势服务的采样点过密时肉眼根本分辨不出细节差异白白浪费算力。这套流程我反复用了多个项目从最初的手忙脚乱到现在的十分钟出图整合成模板后确实省了不少事。代码可以直接复制数据换掉、参数微调就能应付大部分散点轮廓可视化需求。