3、校准数学基础:线性变换与仿射变换、最小二乘法原理、坐标映射矩阵推导
做触摸校准,说白了就是解决一个问题:你点的是A点,系统却以为你点的是B点。为什么会这样?因为触摸屏的物理坐标和屏幕的显示坐标之间,存在一个固定的偏移和旋转关系。我们要做的,就是找到这个关系的数学表达。
这一章,我会带你从最基础的线性变换讲起,一直推到最终的坐标映射矩阵。别怕数学,我会用项目里的实际例子来讲。
3.1 线性变换与仿射变换
先问个问题:触摸屏的坐标和屏幕坐标之间,是什么关系?
我早年做第一个触摸项目时,天真地以为就是简单的比例缩放。结果发现,屏幕上的按钮怎么都对不准。后来才明白,这中间不仅有缩放,还有旋转、平移,甚至可能还有剪切变形。
这些变换,在数学上可以分为两类:
| 变换类型 | 特点 | 能做什么 | 不能做什么 |
|---|---|---|---|
| 线性变换 | 原点不变,直线仍是直线 | 缩放、旋转、剪切 | 平移(原点会动) |
| 仿射变换 | 直线仍是直线,平行线仍是平行线 | 缩放、旋转、剪切、平移 | 透视变形 |
触摸校准用到的就是仿射变换。因为触摸屏的原点和屏幕的原点通常不重合,必须把平移也考虑进去。
仿射变换的数学形式是这样的:
x' = a*x + b*y + c
y' = d*x + e*y + f
其中 (x, y) 是触摸屏的原始坐标,(x', y') 是校准后的屏幕坐标。a、b、c、d、e、f 就是我们要找的六个参数。
核心理解:六个参数,对应六个自由度——水平缩放、垂直缩放、水平旋转、垂直旋转、水平平移、垂直平移。这就是为什么至少需要三个校准点(六个方程)才能解出它们。
3.2 最小二乘法原理
好,现在我们知道要解六个参数。但问题来了:实际采集的触摸点是有噪声的。
我记得有一次调试一款电容屏,明明点了校准十字的中心,采集到的坐标却总是抖动的。如果用三个点硬解方程,结果会非常敏感——稍微偏一点,校准就全歪了。
这时候就需要最小二乘法出场了。
它的核心思想很简单:不求每个点都完美匹配,而是让所有点的误差平方和最小。
举个例子。假设我们采集了 N 个校准点(N > 3),每个点都有一个理论坐标和实际采集坐标。对于第 i 个点,误差是:
error_x_i = (a*x_i + b*y_i + c) - x'_i
error_y_i = (d*x_i + e*y_i + f) - y'_i
我们要最小化的目标是:
S = Σ(error_x_i² + error_y_i²)
怎么最小化?对每个参数求偏导,令其等于零。这就是最小二乘法的标准解法。
我的经验:实际项目中,我一般采集 5 到 9 个校准点。点太少,抗噪声能力差;点太多,用户操作麻烦,而且边缘区域的点权重会被稀释。5 个点是个不错的折中。
3.3 坐标映射矩阵推导
有了前面的基础,我们来推导最终的矩阵形式。
把仿射变换写成矩阵:
[x'] [a b c] [x]
[y'] = [d e f] * [y]
[1 ] [0 0 1] [1]
这个 3x3 矩阵就是坐标映射矩阵。注意最后一行是固定的 [0, 0, 1],这是齐次坐标的写法,方便做矩阵乘法。
现在,假设我们有 N 个校准点,可以构建超定方程组:
[x1 y1 1 0 0 0] [a] [x'1]
[0 0 0 x1 y1 1] [b] [y'1]
[x2 y2 1 0 0 0] [c] [x'2]
[0 0 0 x2 y2 1] * [d] = [y'2]
[... ] [e] [...]
[ ] [f] [ ]
记作:A * P = B
其中 A 是 2N×6 的矩阵,P 是 6×1 的参数向量,B 是 2N×1 的目标向量。
最小二乘解为:
P = (Aᵀ * A)⁻¹ * Aᵀ * B
这就是传说中的伪逆解法。
曾经踩过的坑:有一次我在一个没有浮点运算单元的 MCU 上做校准,直接套用这个公式,结果矩阵求逆算到死机。后来我改用定点数 + 查表法做矩阵运算,才把时间压到 10ms 以内。如果你的平台资源紧张,记得做算法裁剪。
3.4 知识体系总览
下面这张图,把这一章的核心逻辑串起来了:
3.5 代码实现要点
最后,给出一段伪代码,展示如何在嵌入式平台上实现最小二乘求解:
// 输入:cal_points[N] 结构体数组,包含 (touch_x, touch_y, screen_x, screen_y)
// 输出:矩阵参数 a, b, c, d, e, f
void calc_calibration_matrix(CalPoint *pts, int n, float *params) {
// 1. 构建矩阵 A (2n x 6) 和向量 B (2n)
float A[2*n][6], B[2*n];
for (int i = 0; i < n; i++) {
// 第 i 个点的 x 方程
A[2*i][0] = pts[i].touch_x;
A[2*i][1] = pts[i].touch_y;
A[2*i][2] = 1.0f;
A[2*i][3] = 0.0f;
A[2*i][4] = 0.0f;
A[2*i][5] = 0.0f;
B[2*i] = pts[i].screen_x;
// 第 i 个点的 y 方程
A[2*i+1][0] = 0.0f;
A[2*i+1][1] = 0.0f;
A[2*i+1][2] = 0.0f;
A[2*i+1][3] = pts[i].touch_x;
A[2*i+1][4] = pts[i].touch_y;
A[2*i+1][5] = 1.0f;
B[2*i+1] = pts[i].screen_y;
}
// 2. 计算 Aᵀ * A 和 Aᵀ * B
float ATA[6][6] = {0};
float ATB[6] = {0};
for (int i = 0; i < 2*n; i++) {
for (int j = 0; j < 6; j++) {
ATB[j] += A[i][j] * B[i];
for (int k = 0; k < 6; k++) {
ATA[j][k] += A[i][j] * A[i][k];
}
}
}
// 3. 求解 (AᵀA) * P = AᵀB,用高斯消元法
gauss_elimination(ATA, ATB, params, 6);
}
工程建议:在实际项目中,我通常会把高斯消元法换成 LU 分解,数值稳定性更好。另外,如果 MCU 没有硬件浮点单元,记得把所有 float 换成定点数,用 Q15 或 Q31 格式做运算。
嗯,这一章的内容就到这里。数学基础打牢了,后面讲具体的校准算法和调试技巧时,你就能理解每一步为什么这么做了。