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 专栏的另外两篇实战文章:
更多关于JS前端canvas手势识别的资料请关注脚本之家其它相关文章!