薄板样条(Thin Plate Splines,简称TPS)是一种基于径向基函数的插值方法,常用于二维坐标变换、图像配准和三维曲面重建。它的核心思想是找到一个光滑曲面,穿过所有给定控制点,同时让曲面的弯曲能量尽可能小。Node.js本身没有内置的TPS库,npm上相关包也很少,所以自己实现一个轻量级TPS插值器反而更灵活。本文会把数学公式转成可直接运行的JavaScript代码,并讨论正则化参数对结果的影响。

TPS的数学基础:从径向基函数到线性方程组
TPS插值函数在二维空间中通常写成f(x,y) = a0 + a1*x + a2*y + Σ wi * U(ri),其中U(r) = r^2 * log(r),当r为0时U(0)=0。ri是待求点到第i个控制点的欧氏距离。这个函数的第一部分a0 + a1*x + a2*y是一个仿射变换,用来描述全局趋势;第二部分是所有控制点径向基函数的加权和,用来描述局部细节。
为了保证曲面唯一且不会出现多余的自由度,TPS要求权重wi满足三个约束:所有wi之和为零,所有wi*xi之和为零,所有wi*yi之和为零。结合n个插值条件f(xi,yi)=zi,可以得到一个(n+3)×(n+3)的线性方程组。矩阵左上角是核矩阵K,其中K[i][j] = U(||Pi - Pj||);右边和下方分别是控制点坐标组成的矩阵P及其转置;右下角是3×3的零矩阵。解出这个方程组,就得到了权重向量和仿射系数。
这个方程组的结构非常关键。如果没有正则化,核矩阵K在控制点较多或分布较密时可能接近奇异,直接求解会出现数值不稳定。加入正则化参数λ,让K[i][i] += λ,可以显著改善矩阵条件数,让解更平滑。下面的实现就从这个方程组展开。
Node.js核心实现:矩阵组装与方程求解
先实现径向基核函数tpsKernel,它对r=0的情况单独返回0,避免Math.log(0)得到负无穷。
function tpsKernel(r) {
if (r === 0) return 0;
return r * r * Math.log(r);
}
接下来组装线性系统。输入points数组,每个点包含x、y和value,其中value是插值目标。函数返回矩阵A和右端向量b,结构如前所述。这里用Array.from创建全零矩阵,然后填充K矩阵和P矩阵。
function buildSystem(points, lambda = 0) {
const n = points.length;
const size = n + 3;
const A = Array.from({ length: size }, () => new Array(size).fill(0));
const b = new Array(size).fill(0);
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const dx = points[i].x - points[j].x;
const dy = points[i].y - points[j].y;
const r = Math.sqrt(dx * dx + dy * dy);
A[i][j] = tpsKernel(r);
}
A[i][i] += lambda;
A[i][n] = 1;
A[i][n + 1] = points[i].x;
A[i][n + 2] = points[i].y;
A[n][i] = 1;
A[n + 1][i] = points[i].x;
A[n + 2][i] = points[i].y;
b[i] = points[i].value;
}
return { A, b, n };
}
求解线性方程组可以使用高斯消元或Cholesky分解。高斯消元实现简单,适合控制点数量在几百个以下的场景。下面这个函数带部分主元选取,能提高数值稳定性。它原地修改A和b,最后回代得到解。
function solveLinearSystem(A, b) {
const n = A.length;
for (let col = 0; col < n; col++) {
let pivot = col;
for (let row = col + 1; row < n; row++) {
if (Math.abs(A[row][col]) > Math.abs(A[pivot][col])) pivot = row;
}
if (pivot !== col) {
[A[col], A[pivot]] = [A[pivot], A[col]];
[b[col], b[pivot]] = [b[pivot], b[col]];
}
const div = A[col][col];
for (let row = col + 1; row < n; row++) {
const factor = A[row][col] / div;
for (let k = col; k < n; k++) {
A[row][k] -= factor * A[col][k];
}
b[row] -= factor * b[col];
}
}
const x = new Array(n).fill(0);
for (let row = n - 1; row >= 0; row--) {
let sum = b[row];
for (let col = row + 1; col < n; col++) {
sum -= A[row][col] * x[col];
}
x[row] = sum / A[row][row];
}
return x;
}
有了系数向量coeffs,就可以对任意点进行插值。插值函数先加上仿射部分,再累加每个控制点的核函数贡献。注意这里的coeffs前n个是权重w,后三个是a0、a1、a2。
function tpsInterpolate(coeffs, points, target) {
const n = points.length;
let result = coeffs[n] + coeffs[n + 1] * target.x + coeffs[n + 2] * target.y;
for (let i = 0; i < n; i++) {
const dx = target.x - points[i].x;
const dy = target.y - points[i].y;
result += coeffs[i] * tpsKernel(Math.sqrt(dx * dx + dy * dy));
}
return result;
}
正则化参数与数值稳定性调优
正则化参数λ直接加到核矩阵的对角线上,相当于给权重向量增加一个二次惩罚项。这个惩罚项会迫使权重尽量小,从而让曲面更平滑。当λ=0时,TPS精确穿过所有控制点,但在控制点密集或存在噪声时容易产生剧烈起伏;当λ较大时,曲面偏向一个仿射平面,拟合误差增大但抗噪能力更强。
选择合适的λ通常需要根据数据规模和噪声水平做实验。下面这段代码对不同λ值分别求解并计算平均绝对误差,帮助快速判断哪个参数更适合当前数据。
const lambdas = [0, 1e-6, 1e-4, 1e-2];
for (const lambda of lambdas) {
const system = buildSystem(points, lambda);
const coeffs = solveLinearSystem(system.A, system.b);
const error = points.reduce((sum, p) => sum + Math.abs(tpsInterpolate(coeffs, points, p) - p.value), 0);
console.log(`lambda=${lambda}, error=${error}`);
}
从数值稳定性的角度看,直接对(n+3)×(n+3)矩阵做高斯消元的时间复杂度是O(n³)。当控制点数量超过几百个时,可以考虑使用Cholesky分解,因为加入λ后的矩阵是对称正定的,Cholesky分解速度更快且内存占用更低。对于更大规模的问题,还可以使用迭代法,例如共轭梯度法,避免显式存储完整矩阵,只维护矩阵向量乘法操作。
另一个需要注意的点是矩阵条件数。如果控制点之间距离非常近,核矩阵的列会高度相关,导致条件数急剧上升。此时除了增加λ,还可以对控制点坐标做归一化,把坐标缩放到单位范围内,再在求解后反变换回原尺度。这样做能显著提升求解器的稳定性。
应用于二维坐标映射与点云平滑
TPS最常见的用途之一是图像变形中的控制点映射。给定一组源控制点和对应的目标控制点,可以分别对x分量和y分量建立两个TPS插值器。变换时对源图像中的每个像素坐标调用这两个插值器,得到目标坐标。下面代码展示了如何构建这种映射。
function createTPSTransform(sourcePoints, targetPoints) {
const sourceX = sourcePoints.map((p, i) => ({ x: p.x, y: p.y, value: targetPoints[i].x }));
const sourceY = sourcePoints.map((p, i) => ({ x: p.x, y: p.y, value: targetPoints[i].y }));
const sysX = buildSystem(sourceX, 1e-5);
const sysY = buildSystem(sourceY, 1e-5);
const coeffX = solveLinearSystem(sysX.A, sysX.b);
const coeffY = solveLinearSystem(sysY.A, sysY.b);
return function (x, y) {
return {
x: tpsInterpolate(coeffX, sourcePoints, { x, y }),
y: tpsInterpolate(coeffY, sourcePoints, { x, y })
};
};
}
在点云平滑场景中,TPS可以用来把散乱的三维点插值到规则网格上。假设已经通过激光雷达采集了一批地面高程点,想要生成数字高程模型,可以先剔除明显异常点,再用TPS对高程值建插值器,最后在每个网格点上计算高度。与克里金插值等方法相比,TPS实现更简单,且不需要预先估计变异函数。
性能优化方面,如果要对大量目标点做插值,可以先完成矩阵分解并缓存系数,然后循环调用tpsInterpolate。循环内部的核函数计算是主要开销,可以通过预先计算控制点之间的核矩阵并在插值时复用,但TPS的核函数需要对每个目标点重新计算距离,无法完全避免。对于实时性要求较高的场景,建议使用Web Worker或分批处理。
Node.jsThin Plate Splines薄板样条插值修改时间:2026-09-25 17:38:59