前端Canvas实现手势识别:从绘制到标准化比较的完整流程

作者:交互手记

时间:2026-10-26

浏览:192

本文详解如何在纯前端环境下利用Canvas实现手势识别。通过监听鼠标事件绘制轨迹,经重新取样、平移、旋转和缩放进行标准化处理,最终利用余弦相似度算法计算手势间的相似性。文章涵盖核心算法原理、代码实现及多笔画识别思路,不涉及AI深度学习,适合前端开发者参考。

文章目录

前言

近期探索图形学相关技术,开发了一个单笔画手势识别的示例程序。其运行效果大致如下所示:

前言

初次接触此类效果的用户通常会感到新奇。接下来直接进入技术实现环节。

在深入细节前,建议读者先思考实现思路,特别是识别与对比的核心逻辑。项目示例代码已提供,供参考使用。需说明的是,本方案未采用人工智能、AI识别或深度学习技术,仅基于基础数学与图形处理原理。

具体步骤

开始实施。

第一步:手势绘制

识别的前提是获取手势数据,因此首要任务是实现手势的绘制。这一步骤相对基础,熟悉Canvas绘制的开发者应能理解。核心逻辑是监听Canvas上的鼠标事件,在鼠标移动过程中,将获取的坐标点用线段连接起来。与常规画板不同的是,本例在绘制线段的同时,也会将每个坐标点可视化。核心代码逻辑如下(可选读):

handleMousemove(e: MouseEvent) {
    if (!this.isMove) return;
    const curPoint = this.getCanvasPos(e);
    const lastPoint = this.inputPoints[this.inputPoints.length - 1];
    // 画线段
    CanvasUtils.drawLine(this.ctx2d, lastPoint[0], lastPoint[1], curPoint[0], curPoint[1], 'blue', 3);
    // 画坐标点
    CanvasUtils.drawCircle(this.ctx2d, curPoint[0], curPoint[1], 5);
    // 如果觉得原始点的数量太多,可以节流
    this.inputPoints.push(curPoint);
}

绘制完成后的视觉效果大致如下:

前言

观察上图可知,绘制的红点分布并不均匀。这是由于手绘过程中手速变化导致点与点之间的疏密程度不同。为了消除这种因采样密度不均带来的影响,我们需要对数据进行重新采样。

第二步:重新取样

不同应用场景下的采样策略各异。此处采用简单的等分线条采样法:首先计算整个手势轨迹的总长度(即所有线段长度之和),然后将其n等分并取点。等分数量n可根据实际效果调整,无需过度纠结具体数值。

需要注意的是,原始坐标点信息保持不变,绘制仍基于原始点进行。因此,需引入新变量存储重新采样后的点集,后续所有计算均基于这组新数据。具体计算逻辑较为繁琐,此处配有一张示意图以辅助理解:

前言

具体代码实现如下(若已理解原理可选读):

export type Point = [number, number];
static resample(inputPoints: Point[], sampleCount: number): Point[] {
    const len = GeoUtils.getLength(inputPoints);
    const unit = len / (sampleCount - 1);
    const outputPoints: Point[] = [[...inputPoints[0]]];
    let curLen = 0;
    let prevPoint = inputPoints[0];
    for (let i = 1; i < inputPoints.length; i++) {
        const curPoint = inputPoints[i];
        let dx = curPoint[0] - prevPoint[0];
        let dy = curPoint[1] - prevPoint[1];
        let tempLen = GeoUtils.getLength([prevPoint, curPoint]);
        while (curLen + tempLen >= unit) {
            const ds = unit - curLen;
            const ratio = ds / tempLen;
            const newPoint: Point = [prevPoint[0] + dx * ratio, prevPoint[1] + dy * ratio];
            outputPoints.push(newPoint);
            curLen = 0;
            prevPoint = newPoint;
            dx = curPoint[0] - prevPoint[0];
            dy = curPoint[1] - prevPoint[1];
            tempLen = GeoUtils.getLength([prevPoint, curPoint]);
        }
        prevPoint = curPoint;
        curLen += tempLen;
    }
    while (outputPoints.length < sampleCount) {
        outputPoints.push([...prevPoint]);
    }
    return outputPoints;
}

重新采样后的效果大致如下:

前言

关键点在于,一旦确定采用n等分采样,所有手势必须统一使用该等分数,否则无法进行有效比较。此外,我们计算了手势的中心点(即所有采样点坐标的平均值),并将手势起始点(或终点)与中心点相连。该连线可粗略表示手势的大致方向,若暂不理解可跳过,后续步骤将详细阐述。

第三步:平移

比较任何对象时,均需将其量化为数值进行对比,而非依赖主观感觉。若仅凭“长得像”就判定相似,缺乏客观标准。因此,我们需要建立统一的标准,使所有手势在相同的基准下进行对比(如同择偶需有衡量标准),例如统一大小和方向。

试想,若竖写一个大的数字“3”与横写一个小的数字“3”,二者如何比较?为此,我们需要对数据进行标准化处理(示例图中虚线框即为标准化框架),为后续比较奠定基础。这涉及平移、旋转和缩放三个步骤。

关于平移操作,前文已计算出手势中心点。只需将该中心点移动至画布中心,计算平移距离后,对所有新采样点执行平移操作即可。示例代码如下:

// 对每个坐标点进行平移
static translate(points: Point[], dx: number, dy: number) {
    points.forEach((p) => {
        p[0] += dx;
        p[1] += dy;
    });
}

效果如下:

前言

需注意,绘制时需将画布左上角原点移至画布中心。此举能极大简化后续计算,包括接下来的旋转和缩放操作,均基于平移后的坐标系进行。

第四步:旋转

细心的读者可能注意到,除了中间的虚线框,画布还被八等分。这是为何?前文提及手势具有方向性,例如竖线“|”和斜线“/”,二者虽形态相近,但方向不同,因此需进行旋转处理。

图中的八条等分线即为目标方向(等分数可自定义)。我们可以计算手势方向(图中绿线)与哪条等分线最近,然后将所有点向该方向旋转,并对所有点进行旋转变换。代码如下(可选读):

// 计算需要旋转到最近辅助线的弧度,center 为中心点,startPoint 为手势起始点,sublineCount 为坐标等分数量
static computeRadianToSubline(center: Point, startPoint: Point, sublineCount: number): number {
    const dy = startPoint[1] - center[1];
    const dx = startPoint[0] - center[0];
    let radian = Math.atan2(dy, dx);
    if (radian < 0) radian += TWO_PI;
    const unitRadian = TWO_PI / sublineCount;
    const targetRadian = Math.round(radian / unitRadian) * unitRadian;
    radian -= targetRadian;
    return radian;
}
// 对每个坐标点进行旋转
static rotate(points: Point[], radian: number) {
    const sin = Math.sin(radian);
    const cos = Math.cos(radian);
    points.forEach((p) => {
        let [x, y] = p;
        p[0] = cos * x - sin * y;
        p[1] = sin * x + cos * y;
    });
}

部分读者可能认为旋转比平移复杂,实则不然。只需理解单个点的旋转逻辑(线段旋转即两端点旋转,多边形旋转即多个顶点旋转)。此处提供推导图供参考(可选读):

前言

此步骤的效果图如下:

前言

第五步:缩放

每次绘制的手势尺寸各异,因此需统一尺寸,即执行缩放操作。

例如,若要将一个 600*600 的手势放入 100*100 的容器中(即图中虚线框),需缩小6倍。

具体求法如下:首先计算手势的包围盒大小,此处采用AABB(Axis-Aligned Bounding Box,轴对齐包围盒)模型(另有OBB、球模型等)。

AABB包围盒的概念很简单,即找出所有采样点的最大和最小x、y值,如下所示:

前言

随后,用容器长度除以AABB的最长边,即可得到缩放倍数。接着遍历所有点进行缩放操作,具体代码如下:

// 再次提醒下因为我们已经把坐标系移到了画布中央,画布中心和手势中心是重合的,所以直接乘以缩放倍速就可以了
static scale(points: Point[], scale: number) {
    points.forEach((p) => {
        let [x, y] = p;
        p[0] = x * scale;
        p[1] = y * scale;
    });
}

效果图如下:

前言

需注意,缩放后的图形无需严格位于虚线框内,只要其大小与虚线框相当即可。

第六步:手势录入

此步骤涉及数据保存,主要分为两步:

  • 缩略图生成:动态创建Canvas绘制手势,再通过drawImage方法绘制到主画布。此过程与第一步类似,仅图像尺寸缩小。使用原始点或采样点均可(原始点更精确),因缩略图视觉差异不大。
  • 数据保存:必须保存采样坐标点,毕竟经过标准化处理。其他所需数据可根据需求保存。

第七步:比较(重点)

假设已获取两个标准化后的手势,如何判断其相似性?若无相关知识背景,这可能较难理解。此处可暂停思考片刻。实际上,手势相似性可转化为两组采样点是否足够接近的问题。一种直观解法是计算两组采样点间的距离,判断是否小于特定阈值,逻辑如下:

前言

若难以理解,可简化为单个采样点的比较(即求两点距离)。具体代码如下:

static squaredEuclideanDistance(points1, points2) {
    let squaredDistance = 0;
    const count = points1.length;
    for (let i = 0; i < count; i++) {
        const p1 = points1[i];
        const p2 = points2[i];
        const dx = p1[0] - p2[0];
        const dy = p1[1] - p2[1];
        squaredDistance += dx * dx + dy * dy;
    }
    return squaredDistance;
}

上述方法在学术上称为欧氏距离(Euclidean Distance)。

然而,针对本场景,存在更优的相似度算法。接下来介绍余弦相似度(Cosine Similarity)概念(并不复杂,已配图说明):

前言

若上图采用欧氏距离比较,显然AC距离更近,相似度更高。

若采用余弦值比较,则AB更相似。

原因在于,欧氏距离反映的是绝对差异,而余弦相似度比较的是相对差异(请仔细体会)。

为何夹角大小能判定两点相似度?

该方法主要判定向量方向的相似度。如图所示,即使向量B较长,也不影响其方向朝向,因此B的目标朝向与A更相近。利用力学知识理解更为直观,见下图:

前言

夹角越小,发力方向越一致,越能拉动同一物体(即具有相同目标的一类人,也就是相似)。那么,如何计算多个点的余弦相似度?

回顾求夹角公式,既然仅与方向相关,而与向量A、B长度无关,我们可将A、B归一化为单位向量(即向量除以其自身长度),使模长均为1。此时余弦值计算简化为:

前言

是否感觉简单了许多?接下来只需将手势转换为向量(即长数组)。见下图理解:

前言

转换后的一维数组可视为该手势的特征,并作为数据保存。下次比较时,直接提取该数组计算余弦值即可。

// 计算余弦相似度
static calcCosDistance(vector1: number[], vector2: number[]): number {
    let similarity = 0;
    vector1.forEach((v1, i) => {
        const v2 = vector2[i];
        similarity += v1 * v2;
    });
    return similarity; // 相似度介于 -1~1
}

余弦相似度广泛应用于诸多场景,如文章相似度中的词向量应用(此处略过)。简要回顾其具体思路:

  • 将原始数据转换为长度相同的一维数组 [a, b, c, ..., n](虽为一维数组,实为n维向量,不理解亦可)。
  • 遍历现有数据,分别计算对应的余弦值,找出相似度最高的结果。

注意事项

  • 手势具有方向性:我们可以识别 | 和 /,因为它们经旋转后均靠近 y 轴,但 | 和 一 则不行,分别对应y轴和x轴。因此,若要将 | 和 一 识别为同一类,可将 | 旋转多个角度,并在每个角度判断相似性。
  • 手势宽高比影响结果:例如,正方形与长扁矩形不相似。
  • 采样点数量:过多或过少均不利。过多导致效率低下且对图形一致性要求高;反之则精度不足。
  • 手势复杂度:图形识别率与复杂性无直接关系。简单图形因特征不明显易出错(如多边形和圆);复杂图形采样点易被稀释,导致特征粗糙。
  • 应用场景:除手势识别外,还可用于其他领域。例如,数学老师远程授课画板书时,常需徒手画圆或正方形。系统可自动校正,若画得像圆,则自动生成正圆。描述可能苍白,请自行脑补画面。

比较的基本套路(可选读)

简要补充比较两个对象是否相似的一般套路,主要分两大步:

特征提取(数据处理过程):

无论何种对象,均有对应原始数据。需将其(经层层处理)转换至同一框架维度下(即标准化),通常是将原始数据转换为长度相同的一维数组(再次强调,虽为一维数组,实为n维向量)。

算法识别(数据比较过程):

  • 通过特定算法(如前述欧氏距离和余弦相似度)进行逐一比对。
  • 类似应用还包括网格识别(将图片马赛克化,降低像素粒度,根据像素颜色差值比较,适用于以缩略图找原图)、方向识别(如手势顺序为右-下-左-上,则判定为矩形)等。
  • 显然,不同的特征提取与算法导致结果千差万别(涉及效率、准确率等,甚至薪资待遇?)。优化手段百花齐放,故无通用算法,只有适合特定场景的算法,需因地制宜。

以极简推荐算法为例,推荐算法的问题在某种程度上可转化为两人喜好相似度的比较:

喜好干饭摸鱼睡觉就是玩...
甲(咸鱼)✅✅✅✅...
乙(翻身)❌❌❌❌...
我✅?✅?...

这与我们的手势识别原理高度一致。在已有手势(甲、乙)中,寻找与“我”(喜好)相似度较高的对象。每一行代表一系列采样点,最终可简单推断“我”可能是一条咸鱼(喜欢摸鱼)。

又如购买电脑,人们常观察周围人的选择然后跟风购买。从众本质也是一种相似(大众选择即方向),近朱者赤近墨者黑。若追求独立自主,自行决定购买内容,亦无不可,毕竟无法达到100%完美。

关于多笔画(可选读)

本文主要学习单笔画。现请思考,多笔画应如何处理?此处可短暂思考片刻。

  • 对于简单单笔画,上述识别效果良好,兼顾效率与准确率。但多笔画情况复杂,如汉字识别(令人头疼)。
  • 此处介绍一种简单识别方法:将多笔画拆分为单笔画。通过本文学习,可求出每个单笔画的相似度,然后简单求和得到整个字体的相似度,最后取相似度最高者即可(如此简单?)。

举例说明,如 十 这个字(仅作示例,非完全一致):

提取每个汉字的笔画特征,通常采集起始点、终点和中间转折点。数据大致如下:

前言

  • 处理数据(标准化过程,如将每个字移至画布中心,缩放至统一大小)。
  • 比较数据(选择算法,此处先判断笔画数,再将单笔相似度相加求和)。这就完了?当然远未结束,问题众多。例如:
  • 由于存在连笔情况,一笔可能写成两笔,因此应允许笔画误差在2左右,但在最终排序时,笔画数越接近的优先级越高。
  • 每一笔至少包含起点和终点,中间可能有几个拐点。若比较时单笔坐标点数量不同,如何处理?一种方式是插值计算,另一种方式是取初始采样点信息。

采用上述方式,若书写 丁 字,似乎也能识别,因大体均为一横一竖。有何办法避免?当然有。现在每一笔保存的不再是点的坐标,而是该点与前一点连线的角度。若为每一笔的起始点,则以上一笔终点作为前一点。描述较抽象,故再绘图说明(很简单,勿被吓到):

前言

试想,若是 十 字,在上图第二个角度(绿2)中是否可明显区分?此外,仅保存两点间角度,还节省了不少空间。

看似没问题?不,仍差得远。若笔画顺序不对怎么办?仍以 十 为例,若先写竖再写横如何处理?这涉及其他识别方法,如将文字按坐标轴切分为四块分段校验,此处不深入,点到为止(仅懂皮毛)。

小结

以上为手势识别的大致思路。虽看似高大上,但读完应觉得不难。有些东西并非不会,只是未知或未尝试。最后,再次送上项目地址传送门,并附上Canvas专栏另两篇实战文章:

html2canvas使用有问题?手写实现以知其原理💥

🔥用Canvas绘制函数曲线,纵享丝滑

更多关于JS前端Canvas手势识别的资料请关注本站其它相关文章!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章