我的编程空间,编程开发者的网络收藏夹
学习永远不晚

JS前端使用canvas搞一个手势识别

短信预约 -IT技能 免费直播动态提醒
省份

北京

  • 北京
  • 上海
  • 天津
  • 重庆
  • 河北
  • 山东
  • 辽宁
  • 黑龙江
  • 吉林
  • 甘肃
  • 青海
  • 河南
  • 江苏
  • 湖北
  • 湖南
  • 江西
  • 浙江
  • 广东
  • 云南
  • 福建
  • 海南
  • 山西
  • 四川
  • 陕西
  • 贵州
  • 安徽
  • 广西
  • 内蒙
  • 西藏
  • 新疆
  • 宁夏
  • 兵团
手机号立即预约

请填写图片验证码后获取短信验证码

看不清楚,换张图片

免费获取短信验证码

JS前端使用canvas搞一个手势识别

前言

最近在看一些关于图形学的东西,写了个一笔画手势识别的小 demo,效果大概是下面这个样子:

如果你是初次看过肯定会觉得很有意思?。哈哈,话不多说,让我们直接开撸吧。

这里可以先花几秒钟想一下你会怎么做??带着问题往下看能够记得更牢固,比如你可能最关心的就是怎么识别怎么对比。这里先提前贴上项目 demo 地址,有需要的自取。另外这里并不会涉及什么人工智能、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 等分取点(随便几等分,看效果调节,不用纠结)。

注意我们并没有改变原始坐标点的信息,手势的绘制还是要按照原来的点绘制,所以需要加一个变量来存储新采样的点(后面的计算全都是用新的取样点来计算)。这个计算还是有点小麻烦的,所以我准备了一张图方便大家理解??:

然后就是具体的代码实现(大概懂了可跳过):

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 等分,那么所有的手势都应该是 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模型(还有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 绘制到画布上,这个其实和第一步是一样的,只不过图变小了。用原始点或采样点画都可以(原始点比较精确),毕竟是缩略图,看不出来太大差别。
  • 保存数据:采样坐标点肯定是要保存的,毕竟我们辛辛苦苦标准化了这么久,其它的想保存啥就保存啥。

第六步:比较(重点)

假设我们已经有了两个标准化后的手势,那怎样才能知道他们相似呢?如果你没看过相关知识,大概率是不懂的,我。也是?。。。同样的,这里也可以停下来思考几秒种?。。。 ok,其实手势相似与否可以转成两组采样点是否足够靠近的问题,一种直观的解法就是计算两组采样点之间的距离,看是否小于某个阈值,类似下面这样:

不懂的话想成一个采样点就好理解了(就变成了求两点距离?),具体代码如下:

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;
}

其实上面这种方法有个高大上的名字,叫欧氏距离(好了好了,别装了?)。

但是对于我们这种场景有个更好的相似度算法(算法?溜了溜了!),所以接下来我们来介绍一个余弦相似度的概念(不难的,我都画了图的,包看包会):

如果上图采用的是欧氏距离比较,显然 AC 距离更近更相似。

如果用余弦值比较,那显然是 AB 更相似。

这是因为欧氏距离得到的是绝对差异,余弦相似度比较的是相对差异(仔细品品?)。

那为什么夹角的大小可以判定两点的相似度呢?

其实这个方法主要判定的是两点方向的相似度,你可以看到即便向量B很长,但是不影响它的方向朝向,所以B的目标朝向和A更相近,这个用力学的知识会比较好理解一点,看下下面这张图:

夹角越小,发力的方向才越一致,我们才能拉动一个物体(我们就是有相同目标的一类人,也就是相似)。那这么多个点我们怎么算余弦相似度呢?

回头看看刚才求夹角的公式,既然只和方向相关,而和向量A、向量B长度无关,那么我们一般可以把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手势识别的资料请关注编程网其它相关文章!

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

JS前端使用canvas搞一个手势识别

下载Word文档到电脑,方便收藏和打印~

下载Word文档

猜你喜欢

JS前端使用canvas搞一个手势识别

这篇文章主要为大家介绍了JS前端使用canvas搞一个手势识别的实现示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
2022-11-13

JS前端使用canvas编写一个签名板

这篇文章主要为大家介绍了JS前端使用canvas编写一个签名板实现示例,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
2022-11-13

使用canvas怎么实现一个手势解锁功能

使用canvas怎么实现一个手势解锁功能?很多新手对此不是很清楚,为了帮助大家解决这个难题,下面小编将为大家详细讲解,有这方面需求的人可以来学习下,希望你能有所收获。手势解锁通过手指将屏幕上的九个点依次连接起来形成一个图案,所以叫图案解锁。
2023-06-09

使用Canvas怎么实现一个手势控制功能

使用Canvas怎么实现一个手势控制功能?针对这个问题,这篇文章详细介绍了相对应的分析和解答,希望可以帮助更多想解决这个问题的小伙伴找到更简单易行的方法。首先讲一下思路:首先跟图需求可以知道,作出这样的效果需要一组对象,每个对象有颜色,所占
2023-06-09

探索手势识别模型的算法和原理(用Python创建一个简单的手势识别训练模型)

手势识别是计算机视觉领域的一个重要研究领域。它的目的是通过解析视频流或图像序列中的人手部动作来确定手势的含义。手势识别具有广泛的应用,例如手势控制的智能家居,虚拟现实和游戏,安防监控等领域。本文将介绍手势识别模型使用的算法和原理,并使用Py
探索手势识别模型的算法和原理(用Python创建一个简单的手势识别训练模型)
2024-01-24

怎么在Android应用中实现一个手势操作识别功能

今天就跟大家聊聊有关怎么在Android应用中实现一个手势操作识别功能,可能很多人都不太了解,为了让大家更加了解,小编给大家总结了以下内容,希望大家根据这篇文章可以有所收获。首先,在Android系统中,每一次手势交互都会依照以下顺序执行。
2023-05-31

编程热搜

目录