简介:面向需要处理柔性压力传感器阵列数据的开发者与研究人员,以足部压力热力图绘制为例,演示如何将离散传感器点转换为直观的压力分布图。压缩包内共3个文件,包含Python代码脚本、最终效果图与测试图,整体仅68KB,便于快速参考或复用。已有229人学习。借助代码与输出图片,可系统掌握数据筛选、插值、归一化以及基于matplotlib的颜色映射等关键环节,理解二维坐标映射到鞋垫平面的处理思路,进而复现出不规则传感器布局下的连续热力分布。这类方法对可穿戴设备压力分析、运动医学研究以及生物力学评估均有直接帮助,尤其适合需要处理非规则采样点的数据可视化场景,并可迁移到其他柔性传感器阵列的压力分布展示中。适合具备Python基础、希望快速上手传感器数据可视化的学习与工程人员。
1. 异形热力图:鞋垫上几十个离散点,怎么画出一张能直接看受力分布的热力图
柔性压力传感器阵列贴在鞋垫上之后,采集到的数据往往只是一行行“通道号、坐标、压力值”,总共几十个离散点。这些点直接画成散点图,只能看到稀疏的圆点;直接调 imshow 画热力图,又是一张方方正正的矩形图,和鞋垫的异形轮廓完全对不上。异形热力图的本质,是把离散点插值成连续网格,再把网格裁剪进鞋垫轮廓,最终得到一张贴合外形的受力分布图。这篇笔记适合做穿戴式设备、足底压力检测、矫形鞋垫分析,以及任何需要把贴在不规则表面上的传感器阵列数据可视化成热力图的从业者。
2. 数据准备与坐标归一化:先把传感器通道位置变成可插值的二维坐标
拿到手的数据很少是能直接开画的。柔性压力传感器阵列的输出经过采集板之后,通常会打包成一个 CSV,里面至少要有三列:物理横坐标、物理纵坐标、压力值。热力图的一切都建立在坐标上,坐标一旦错,整张图就跟着错。我一般会先开一个空白的 matplotlib 图,把散点和轮廓叠在一起看一遍,确认所有通道点都落在轮廓内部、分布符合鞋垫形状,再往后走。这一步多花五分钟,能省掉后面绝大多数“图很奇怪”的排查时间。
2.1 通道布局与数据格式:先搞清 8×8 排布还是足底分区排布
柔性压力传感器阵列常见两种布局:一种是等间距网格,比如 8×8=64 通道,通道按行列均匀铺在鞋垫上;另一种是按足底关键区域稀疏排布,足跟、第一跖骨、拇趾这些承重区放得密,足弓区域少放甚至不放。等间距网格的坐标可以由行列号直接换算,但通道编号往往是蛇形扫描或者按排线物理顺序来的,不能拿编号直接当坐标。分区排布就更直接,每个通道的位置基本要靠硬件标定表或 CAD 组装图读出来。
CSV 数据长这样:
x_mm,y_mm,kpa 0.0,0.0,12.3 3.0,0.0,18.7 6.0,0.0,15.2如果文件里存的不是 kPa 而是 ADC 码,必须先把线性标定做完再进这条管线。单位不统一是后面各种翻车的根源。我自己的习惯是:所有原始文件统一用 mm 和 kPa,ADC 码只出现在中间过程文件里,绝不混进热力图绘制脚本。
2.2 读取、单位确认与轮廓预处理:从 CSV 到 numpy 数组
读 CSV 这一步看起来简单,唯一的坑是通道顺序。硬件按排线顺序输出数据,不代表空间顺序也是从左到右、从上到下。我习惯用 lexsort 重新排序,保证后续所有函数拿到的输入都是“按坐标排列好”的:
import numpy as np import pandas as pd raw = pd.read_csv("foot_pressure.csv") # 列名: x_mm, y_mm, kpa x = raw["x_mm"].to_numpy(dtype=float) y = raw["y_mm"].to_numpy(dtype=float) v = raw["kpa"].to_numpy(dtype=float) # 通道顺序不等于空间顺序,按 (x, y) 排序 idx = np.lexsort((y, x)) x, y, v = x[idx], y[idx], v[idx] print("x range:", x.min(), x.max()) print("y range:", y.min(), y.max())这里 np.lexsort((y, x)) 的意思是先按 x 排序,再按 y 排序,排序结果应用到三个数组上,保持坐标和压力值的对应关系不变。打印坐标范围是为了确认单位,如果 x 跑到 280 而 y 只有 100,那单位基本是 mm;如果 x 最大值是 0.28,说明文件里存的是米,要先乘 1000 再进后续流程。
轮廓文件来自鞋垫 CAD 或实物描点。常见做法是把 CAD 里的鞋垫外形炸开、离散成多段线,导出几百个点;也可以拍一张带标尺的鞋垫照片,手动描出外轮廓。轮廓文件就是 N 行两列的 CSV,读进来之后先检查是否闭合:
contour = np.loadtxt("insole_contour.csv", delimiter=",") # 单位 mm # 首尾不重合则主动补齐闭合点 if np.linalg.norm(contour[0] - contour[-1]) > 1e-6: contour = np.vstack([contour, contour[:1]])闭合之后才能交给 matplotlib.path.Path 做“点是否在轮廓内”的判断。这里我一般会顺手算一下轮廓多边形面积,和鞋垫实际面积对比,误差超过 5% 就停下来查单位或查轮廓点顺序,避免把 cm 当 mm 用。
3. 离散点插值:从稀疏通道到连续热力场的三种方案与参数选择
有了坐标和压力值,下一步是把几十个离散点变成连续的热力场。不要指望直接画散点能看出受力分布——64 个点最多只能画成 64 个小方块的马赛克,人眼对离散符号不敏感,必须做空间插值。插值是整个流程里最玄学的一步:它只能做空间重建,不能做测量修正。传感器零漂和坏点如果混进来,插值只会把这些错误“光滑化”,看起来更顺眼,实际更误导。
3.1 为什么必须插值:64 个离散点直接画只能得到一张马赛克
像 matplotlib 的 imshow 这类热力图接口,本身要求规则网格输入。把每个传感器通道画成一个小色块,得到的就是马赛克,细节全丢。插值要做的是在“已测到的点之间”补出连续变化:两个通道之间压力值从 20 kPa 过渡到 32 kPa,中间到底怎么变,只能靠邻近通道趋势去猜。注意“猜”这个字——插值结果是重建出来的估计,不是实测。所以我会反复强调:插值前必须把数据清洗干净,否则错误也会被平滑成一块“看起来很合理的彩色区域”。
3.2 griddata 三种方法对比:nearest、linear、cubic
SciPy 的 griddata 是最快能跑通的一站式方案。核心代码就一段:
from scipy.interpolate import griddata # 在鞋垫包围盒内生成 1mm 网格 x0, xw = x.min(), x.max() - x.min() y0, yw = y.min(), y.max() - y.min() gx = np.arange(x0, x0 + xw + 1e-6, 1.0) gy = np.arange(y0, y0 + yw + 1e-6, 1.0) gxx, gyy = np.meshgrid(gx, gy) grid_v = griddata( (x, y), v, (gxx, gyy), method="linear", fill_value=np.nan, )参数里最关键的是 method 和 fill_value。method 有三个选项,行为差异很大:
| 插值方法 | 平滑度 | 稳定度 | 适合场景 |
|---|---|---|---|
| nearest | 低,呈瓦片状 | 最高,不产生新值 | 只想快速看落点区域 |
| linear | 中等,连续 | 中等,凸包外无值 | 通道密度适中时最推荐 |
| cubic | 高,曲线连续 | 低,容易过冲负值 | 通道排列规整、间距均匀 |
fill_value 我坚持设为 np.nan,而不是 0。因为鞋垫包围盒是矩形,轮廓外有大量区域落在插值凸包之外,填 0 会让这些区域显示成“零压力”的纯色块,而 NaN 可以在后面被轮廓 mask 干净地过滤掉。cubic 在通道间距不均匀的鞋垫阵列上经常画出“波浪状”伪影,甚至出现负压强,这一点会在第 5 章展开说。
3.3 反距离加权(IDW):阵列数据更稳的保底方案
griddata 快,但遇到传感器噪声大、通道分布不均匀、轮廓形状怪异的情况,我不太放心。更稳的保底方案是反距离加权插值(IDW),原理是:目标点的值由周围通道按距离倒数加权得到,距离越近权重越大。它不需要三角剖分,不存在凸包外填值的问题,而且天然不会产生负压力值。实现如下:
def idw_interp(x, y, v, grid_x, grid_y, p=2.0, radius=40.0): """反距离加权插值,返回与 grid_x 同形状的 2D 数组。""" gx = grid_x.ravel() gy = grid_y.ravel() out = np.full(gx.size, np.nan) n_pts = x.size # 分块处理,避免一次建出 (网格点数 * 通道数) 的大距离矩阵 for i in range(0, gx.size, 4096): sl = slice(i, i + 4096) dx = gx[sl, None] - x[None, :] dy = gy[sl, None] - y[None, :] d = np.hypot(dx, dy) d[d < 0.05] = 0.05 # 防止除零 mask = d <= radius if not mask.any(): continue w = 1.0 / d ** p w[~mask] = 0.0 out[sl] = (w * v[None, :]).sum(axis=1) / w.sum(axis=1) return out.reshape(grid_x.shape)两个参数决定效果:p 是距离衰减指数,p=2 是最常见起点,p 越大,离通道近的地方越突出,图面越“硬”,容易在每个通道附近形成小亮点;p 越小,整体越平滑,但细节模糊。radius 是搜索半径,一般取通道平均间距的 1.5~2 倍,鞋垫场景从 30~50 mm 开始调。radius 太小,稀疏区域会出现没有数据的气泡;radius 太大,边缘会被远处通道“拉”得没有梯度。这个函数在 64 个通道、2 万多个网格点的规模下单帧耗时几十毫秒,做连续帧批处理完全够用。
3.4 网格分辨率与边界外处理:mask 应该在插值之后做
网格步长 step_mm 的选择直接影响文件大小和运算时间。鞋垫典型长度 260~290 mm,宽度 80~110 mm,用 1 mm 网格大约是 2.5 万个点,画一张图分辨率足够,再细到 0.5 mm 也看不出更多信息,反而让 pcolormesh 渲染变慢。我一般固定 1 mm,既能满足足底压力分析的精度,又不会让批处理太吃力。
轮廓裁剪用 matplotlib.path.Path 判断每个网格点是否在鞋垫内:
from matplotlib.path import Path poly = Path(contour) inside = poly.contains_points(np.column_stack((gxx.ravel(), gyy.ravel()))) inside = inside.reshape(gyy.shape) grid_v = np.where(inside, grid_v, np.nan)顺序必须是“先插值,再 mask”。如果反过来,先把鞋垫外的网格点删掉再插值,边界内部的插值也会因为缺少边界附近的采样点而出现空洞和锯齿。先插值再 mask,只是把轮廓外的显示值变成 NaN,pcolormesh 不会绘制 NaN 单元,热力图边缘自然贴合鞋垫。contains_points 对凹多边形同样有效,前提是轮廓点顺序一致且不自交,自交多边形会有错误的包含判断,这一点排查时最容易忽略。
4. 异形裁剪与热力图绘制:把方方正正的网格套进鞋垫轮廓
插值和 mask 完成之后,绘制本身并不复杂,真正的难点是像素坐标和物理坐标严格对齐。直接用 imshow 要同时处理 extent 和 origin 两个参数,很容易上下翻转。我之前在鞋垫项目里被 origin 的默认值坑过一次,排查了快两个小时才发现整张图是镜像的。后来统一改用 pcolormesh,它直接接收 gxx/gyy 坐标网格,坐标在哪,像素就画在哪。
4.1 用 pcolormesh 绘制网格热力图并叠加鞋垫轮廓
一段可以直接跑的绘制脚本:
import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(6, 3)) mesh = ax.pcolormesh( gxx, gyy, grid_v, cmap="jet", vmin=0.0, vmax=v_hi, # 由 4.2 中的分位数截断得到 shading="auto", ) ax.plot(contour[:, 0], contour[:, 1], color="black", lw=1.2) ax.set_aspect("equal") # 保证 x/y 等比例,否则鞋垫会被拉变形 ax.axis("off") cb = fig.colorbar(mesh, ax=ax, fraction=0.045) cb.set_label("Pressure (kPa)") fig.savefig("insole_heatmap.png", dpi=300, transparent=True) plt.close(fig)pcolormesh 的第一个优势是直接把物理坐标网格传进去,不需要像 imshow 那样通过 extent 反推坐标范围。第二个优势是它天然接受 NaN,轮廓外的网格单元不会被渲染。注意 shading="auto" 在 gxx/gyy 与 grid_v 形状一致时按最近邻着色,不会出现半格偏移;如果你换用 shading="flat",需要对 C 做切边处理,否则图会往右上角偏移半格。
4.2 三条绘制规则:vmin/vmax、色标截断、等轴与透明导出
第一规则是 vmax 别用最大值。足底压力数据里经常混入单个通道的毛刺,比如某帧某通道突然跳到 300 kPa,用最大值当 vmax,整张图的低压力区全部变成深蓝色,细节全丢。我一般用 98% 分位数截断:
v_hi = np.nanpercentile(grid_v, 98) v_lo = 0.0第二规则是 cmap 按用途选。给工程分析看,jet 的对比最强烈,“哪里压强大”一眼就能看出来;写论文或者正式报告,我会换成 viridis,避免 jet 两端暗色带来的视觉误导。两条规则不冲突:两个版本的 PNG 各导出一张,一张自己排查用,一张对外用。
第三规则是等轴比例和透明背景。set_aspect("equal") 必须加,否则鞋垫会被拉伸得又瘦又长。dpi=300 保证放到论文里不会发糊,transparent=True 导出的透明 PNG 可以直接拖进 CAD 或排版软件里叠加,不需要再抠图。
4.3 把整条管线封成三个函数
项目一旦涉及多只鞋码、左右脚、多帧连续数据,就不能每次重写脚本。我习惯把流程拆成三个函数:
def prepare_data(csv_path, contour_path): """返回 x, y, v, contour,完成排序和闭合检查。""" ... def pressure_grid(x, y, v, contour, step_mm=1.0, method="idw"): """插值 + mask,返回 gxx, gyy, grid_v。""" ... def draw_insole_heatmap(gxx, gyy, grid_v, contour, out_path, v_hi): """绘制异形热力图并保存 PNG。""" ...调用端只要三行:
x, y, v, contour = prepare_data("data/41_left.csv", "contour/41_insole.csv") gxx, gyy, grid_v = pressure_grid(x, y, v, contour, step_mm=1.0, method="idw") draw_insole_heatmap(gxx, gyy, grid_v, contour, "out/41_left.png", v_hi=120.0)这样换鞋码只需要换数据文件和轮廓文件,换传感器阵列布局只需要换 CSV,绘制代码一行都不用动。文件名里带上鞋码和左右脚信息,批量生成时不会被覆盖。
5. 避坑与排查:五条高频翻车现场,从根源到解决
这一章都是真实踩过的坑,按“现象→原因→解决”的顺序写,方便你照着判断。
5.1 热力图超出鞋垫轮廓,边缘出现月牙状深色斑块
现象:裁剪后的热力图没有贴着鞋垫边缘,反而在轮廓外多出一圈彩色区域,边缘还常常是深蓝色。原因有两种:第一是 mask 压根没做,或者只用插值结果直接绘图;第二是轮廓坐标和传感器坐标单位不一致,比如传感器是 mm、轮廓是从 CAD 导出的 cm,结果轮廓整体放大十倍,判断点在轮廓内时几乎把整个矩形都包含了。解决:先统一单位,再在插值后加 mask 那一段代码。排查时把轮廓点和传感器散点画在一张图上,如果散点超出轮廓或者轮廓明显比鞋垫大,立刻就能看出来。
5.2 足跟压力显示在脚趾区:坐标翻转与原点约定
现象:足底热力图上下镜像,后跟的红色高亮出现在图的上方,看起来像脚趾。原因:数据文件里 y 轴方向约定和绘制方向不一致。比如硬件采集时 y 从后跟向脚趾递增,而 imshow 默认的 origin=“upper” 会把数组第一行画在图像顶部,如果数组第一行是脚趾,图就直接反转了。解决:用 pcolormesh 传坐标网格,这个问题基本不会出现;如果坚持用 imshow,origin="lower" 才能让坐标递增方向朝上。还有一个更直接的习惯检查:把压力最大的三个通道单独画成散点,看它们是否落在足跟和第一跖骨处。如果不在,一定是坐标映射错了,处理完再看热力图。
5.3 整张图只有两个红点,其余一片蓝
现象:热力图显示出的色彩层次很少,两只脚上只有一两个红色亮点,其余全是深蓝色。原因:vmax 被单通道毛刺拉高了。传感器在受力瞬间可能产生几百 kPa 的尖峰,这个尖峰如果参与配色,其余正常的 20~60 kPa 区域就会全部压到色标底部。解决:先对连续帧做时间中值滤波,再按 98% 分位数取 vmax:
v_frames = np.column_stack([v_f1, v_f2, v_f3]) # 同一通道连续 3 帧 v_med = np.median(v_frames, axis=1) # 取中值消除单帧毛刺注意不要用均值代替中值,均值对尖峰依然敏感。vmax 固定之后,整批帧的配色保持同一基准,图与图之间才有可比性。
5.4 cubic 插值出现负压力或条纹状伪影
现象:热力图在高梯度区域出现一条条“波浪纹”,甚至色标下方出现负值。原因:cubic 插值是全局多项式拟合,在通道间距差异大、数据点分布不均匀时,会产生明显的过冲振荡,明明所有传感器读数都是正的,插值结果却算出负压强。解决:vmin=0 和把负值截断为 0 只是掩盖症状,根源是插值方法选错。通道稀疏或者间距不均匀时,用 linear 或 IDW 都比 cubic 稳得多。血泪经验是:不要为了“更平滑”硬上 cubic,表面平滑换来的是假信息。
5.5 个别通道 NaN,热力图出现圆形蓝洞
现象:某一片区域热力图出现一个规则的圆形空洞,周围的压力传不进去。原因:某个通道数据是 NaN,或者坏道输出恒为 0,插值把这个异常值当成真实测量值参与计算。把 NaN 直接替换成 0 也不行——0 是有效物理量,代表“该处压力为零”,插值会在这附近拉出一个假蓝洞。解决:插值前按物理有效性过滤:
valid = np.isfinite(v) & (v >= 0.0) x, y, v = x[valid], y[valid], v[valid]这样坏道完全不参与插值,周围通道的值会自然补进这个区域,图面不会出现突兀的洞。
6. 画完别急着发:总载荷校验、多帧批处理与三维验证
形状画对了,不等于数值可信。没有校验之前,热力图只是“好看的估算”。我最常用的校验方法是总载荷校验:每个网格单元代表一个微面积,压力值乘以单元面积求和,应该约等于体重秤读数。代码很短:
step_mm = 1.0 cell_area_m2 = (step_mm * 1e-3) ** 2 total_load_n = np.nansum(grid_v) * cell_area_m2 * 1e3 ref_n = 700.0 # 70 kg 体重静止站立约 700 N assert 0.9 < total_load_n / ref_n < 1.1注意单位换算:kPa 是 kN/m²,mm² 转 m² 后乘 1000 才得到 N。如果偏差超过 10%,优先查传感器标定和坏道,而不是怀疑插值——插值只重建空间分布,不修正系统误差。
连续帧批处理时,vmin/vmax 必须固定在同一套分位数上,不能每帧单独取。否则动画播起来会一明一暗地闪烁。我一般把这些参数集中放在脚本开头的 config 区域,所有帧共用。如果要把结果搬到 Web 端用 echarts 做交互展示,也建议后端完成插值和异形裁剪,把轮廓内网格直接作为数据传给前端;不要在浏览器里再做一次矩形插值和遮罩,跨端实现不同,结果一个红一个蓝,解释权就不在你手里了。想更直观确认峰值位置,可以用 plot_surface 把同一份 grid_v 拉成三维曲面,峰值一目了然。我习惯每次出图都留一张带坐标轴的“散点+轮廓+原始插值”排查图,另一张才是裁剪后的展示图。配色是给别人看的,坐标是给自己校对的。希望帮到你。
本文还有配套的精品资源,点击获取