JS前端使用canvas搞一個手勢識別
前言
最近在看一些關(guān)于圖形學(xué)的東西,寫了個一筆畫手勢識別的小 demo,效果大概是下面這個樣子:
如果你是初次看過肯定會覺得很有意思??。哈哈,話不多說,讓我們直接開擼吧。
這里可以先花幾秒鐘想一下你會怎么做???帶著問題往下看能夠記得更牢固,比如你可能最關(guān)心的就是怎么識別怎么對比。這里先提前貼上項目 demo 地址,有需要的自取。另外這里并不會涉及什么人工智能、AI識別、深度學(xué)習(xí)啥的,所以請放心食用??。
具體步驟
發(fā)車?yán)?????????
第一步:手勢繪制
既然要識別那肯定得先有手勢啦,所以第一步要做的就是手勢繪制,這一步相對來說比較簡單,學(xué)習(xí)過 canvas 的同學(xué)應(yīng)該有看過畫板的實現(xiàn),這個也是一樣的,監(jiān)聽 canvas 上的鼠標(biāo)事件,然后在移動的時候?qū)⑹髽?biāo)坐標(biāo)點用線段相連即可,不同的是我們在繪制過程中還順便把每個坐標(biāo)點都畫了一下,核心代碼如下(可跳過):
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); // 畫坐標(biāo)點 CanvasUtils.drawCircle(this.ctx2d, curPoint[0], curPoint[1], 5); // 如果覺得原始點的數(shù)量太多,可以節(jié)流 this.inputPoints.push(curPoint); }
畫完之后大概是下面這個樣子:
從上圖可以看到繪制出來的紅點并不均勻,因為一筆畫過程中的手速不一樣,疏密程度也就不一樣,所以為了避免這個因素的影響,我們需要重新取個樣。
第二步:重新取樣
不同場景的取樣方式也有所不同。這里我們簡單的選擇等分線條取樣即可,也就是先計算出整個手勢的長度(所有線段長度相加),然后 n 等分取點(隨便幾等分,看效果調(diào)節(jié),不用糾結(jié))。
注意我們并沒有改變原始坐標(biāo)點的信息,手勢的繪制還是要按照原來的點繪制,所以需要加一個變量來存儲新采樣的點(后面的計算全都是用新的取樣點來計算)。這個計算還是有點小麻煩的,所以我準(zhǔn)備了一張圖方便大家理解????:
然后就是具體的代碼實現(xià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 等分,那么所有的手勢都應(yīng)該是 n 等分的,不能改變,否則難以比較。另外我們順便把手勢的中心點算了出來(就是簡單的把每個采樣點坐標(biāo)相加取平均值),并且將手勢的起始點(最后一個點也行)與中心點相連,這個你可以粗淺的認(rèn)為它表示的是這個手勢的大致方向,不理解可以先跳過,后續(xù)會講到。
第二步:平移
其實你要比較任何東西,都是要量化成數(shù)字來比較的,而不是通過感覺。 不能說我覺的兩個手勢長得像它就像,那只是人工沒有智能,所以我們要怎么解決這個問題呢?我們需要定一個標(biāo)準(zhǔn),讓所有手勢都在同一個模子下進行比較(就好像你要找個對象,不得有個衡量標(biāo)準(zhǔn)嗎),比如都變成同樣的大小、同樣的方向。
不然你想想如果我豎著寫了一個很大的3和橫著寫了一個很小的3,它們要怎么比較。所以接下來我們要做的就是把手勢標(biāo)準(zhǔn)化(其實每幅示例圖中的虛線框就是我們的架子),為后續(xù)的比較打好基礎(chǔ),為此就需要經(jīng)歷平移、旋轉(zhuǎn)、縮放這幾個步驟。
關(guān)于平移,剛才我們已經(jīng)計算過手勢的中心點,現(xiàn)在只需要把它移動到畫布中心即可,簡單算下平移距離,然后對所有新的采樣點做平移操作即可,示例代碼如下:
// 對每個坐標(biāo)點進行平移 static translate(points: Point[], dx: number, dy: number) { points.forEach((p) => { p[0] += dx; p[1] += dy; }); }
效果如下:
要注意我們在繪制的時候需要將畫布左上角的原點移到到畫布中間,這樣做能夠極大的方便計算,包括接下來的旋轉(zhuǎn)和縮放也是在平移坐標(biāo)系的基礎(chǔ)上。
第三步:旋轉(zhuǎn)
細心的同學(xué)會發(fā)現(xiàn)除了中間的虛線框,我們還把整個畫布八等分了,這是為什么呢?其實上文中有提到,是因為手勢具有方向性,比如 丨和 /,這兩種手勢本應(yīng)該很相近,但是方向不同,所以就需要進行一定的旋轉(zhuǎn)。
而這里的八條等分線就是我們要靠近的方向(幾等分也是你自己隨意取的),于是乎我們可以簡單地算下手勢方向(圖中的綠線)離哪條等分線近就往哪邊旋轉(zhuǎn),然后把所有的點都進行旋轉(zhuǎn)變換即可,代碼如下(可跳過):
// 計算需要旋轉(zhuǎn)到最近輔助線的弧度,center 為中心點,startPoint 為手勢起始點,sublineCount 為坐標(biāo)等分?jǐn)?shù)量 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; } // 對每個坐標(biāo)點進行旋轉(zhuǎn) 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; }); }
很多同學(xué)可能會覺得旋轉(zhuǎn)比平移難,其實很簡單的,你只需要知道一個點是怎么旋轉(zhuǎn)的就行了(線段的旋轉(zhuǎn)就是兩個端點的旋轉(zhuǎn),多邊形的旋轉(zhuǎn)就是多個頂點的旋轉(zhuǎn)),這里我畫了張推導(dǎo)圖方便大家理解(不感興趣也可以跳過):
然后看下這步的效果圖:
第四步:縮放
我們每次繪制的手勢是有大有小的,所以這里需要統(tǒng)一成一個大小,也就是做個縮放。
比如我們要把一個 600*600
的手勢放進一個 100*100
的容器中(也就是圖中的虛線框),那就要縮小 6 倍。
那具體要怎么求呢?首先我們要求出手勢的包圍盒大小,這里采用AABB模型(還有OBB、球模型等)。
那什么是 AABB 包圍盒呢,這個賊簡單,就是找出所有采樣點的最大最小 x、y 值即可,就像下面這樣:
現(xiàn)在只要用容器長度除以 AABB 的最長邊,得到的就是縮放倍數(shù)。然后同樣的,遍歷所有點進行縮放操作,具體代碼如下:
// 再次提醒下因為我們已經(jīng)把坐標(biāo)系移到了畫布中央,畫布中心和手勢中心是重合的,所以直接乘以縮放倍速就可以了 static scale(points: Point[], scale: number) { points.forEach((p) => { let [x, y] = p; p[0] = x * scale; p[1] = y * scale; }); }
效果圖如下:
注意不是說縮放之后的圖形一定要在虛線框里面,而是縮放之后的圖形大小和虛線框差不多。
第五步:手勢錄入
這個就是簡單的保存數(shù)據(jù),一共可分為兩步:
- 縮略圖:動態(tài)地創(chuàng)建一個 canvas 來繪制手勢,再通過 drawImage 繪制到畫布上,這個其實和第一步是一樣的,只不過圖變小了。用原始點或采樣點畫都可以(原始點比較精確),畢竟是縮略圖,看不出來太大差別。
- 保存數(shù)據(jù):采樣坐標(biāo)點肯定是要保存的,畢竟我們辛辛苦苦標(biāo)準(zhǔn)化了這么久,其它的想保存啥就保存啥。
第六步:比較(重點)
假設(shè)我們已經(jīng)有了兩個標(biāo)準(zhǔn)化后的手勢,那怎樣才能知道他們相似呢?如果你沒看過相關(guān)知識,大概率是不懂的,我。也是??。。。同樣的,這里也可以停下來思考幾秒種??。。。 ok,其實手勢相似與否可以轉(zhuǎn)成兩組采樣點是否足夠靠近的問題,一種直觀的解法就是計算兩組采樣點之間的距離,看是否小于某個閾值,類似下面這樣:
不懂的話想成一個采樣點就好理解了(就變成了求兩點距離??),具體代碼如下:
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的目標(biāo)朝向和A更相近,這個用力學(xué)的知識會比較好理解一點,看下下面這張圖:
夾角越小,發(fā)力的方向才越一致,我們才能拉動一個物體(我們就是有相同目標(biāo)的一類人,也就是相似)。那這么多個點我們怎么算余弦相似度呢?
回頭看看剛才求夾角的公式,既然只和方向相關(guān),而和向量A、向量B長度無關(guān),那么我們一般可以把A、B變成單位向量(就是向量除以它們自身的長度),這樣A、B的模長就為1,于是余弦值就可以變成這樣:
是不是突然簡單了不少,接下來我們就想辦法把手勢變成向量就行(就是變成一個很長的數(shù)組),這里看圖理解會方便些:
我們可以把轉(zhuǎn)變后的一維數(shù)組叫做這個手勢的特征,并當(dāng)做數(shù)據(jù)保存下來,下次比較的時候直接把這個數(shù)組拿出來算余弦值即可。
// 計算余弦相似度 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 }
余弦相似度在很多場合都是有用到的,比如文章相似度中詞向量的應(yīng)用(扯遠了),所以這里簡要回顧一下它的具體思路:
- 想辦法把原始數(shù)據(jù)轉(zhuǎn)換成長度相同的一維數(shù)組
[a, b, c, ..., n]
,(雖然是一維數(shù)組,但是是 n 維向量,不理解沒關(guān)系)。 - 遍歷現(xiàn)有數(shù)據(jù),分別求出對應(yīng)的余弦值,找出相似度值最高的那一個。
注意事項
- 手勢具有方向性:我們可以識別
|
和/
,因為他們經(jīng)過旋轉(zhuǎn)都靠近y
軸,但是|
和一
就不行了,一個是 y 軸一個是 x 軸。所以如果我們要想把|
和一
識別成一個東西可以這樣子搞,把|
多旋轉(zhuǎn)幾個角度,在每個角度都判斷一下是否相似。 - 手勢的寬高比會影響結(jié)果:比如你畫一個正方形和一個長長扁扁的矩形是不相似的。
- 采樣點的數(shù)量:過多過少都不行,過多效率低,對圖形一致性要求也高,反之同理。
- 手勢的復(fù)雜度:圖形的識別率和圖形的復(fù)雜性沒有太大關(guān)系。簡單的圖形由于特征不明顯,容易出錯,比如多邊型和圓。復(fù)雜的圖形,采樣點就容易被稀釋,得到的特征比較粗。
- 應(yīng)用場景:大家可以自己想想這個東西除了用在手勢還能用在那里?這里舉個例子,比如數(shù)學(xué)老師在遠程上課、寫板書的時候,經(jīng)常需要徒手畫圓或者畫正方形,這里我們就可以幫其自動校正,如果畫的像一個圓就自動重新生成一個正圓,也許描述的比較蒼白,所以大家可以自行腦補一下畫面??。
比較的基本套路(可跳過)
這里簡單補充下比較兩個東西是否相似的一般套路,也就兩大步:
特征提?。ň褪翘幚頂?shù)據(jù)的過程):
不管是什么東西,都有對應(yīng)的原始數(shù)據(jù),我們要做的就是將其(經(jīng)過層層處理)轉(zhuǎn)換成同一個框架維度下(也就是標(biāo)準(zhǔn)化),通常就是將原始數(shù)據(jù)轉(zhuǎn)換成長度相同的一維數(shù)組(再次強調(diào)雖然是一維數(shù)組,但其實是 n 維向量)。
算法識別(就是比較數(shù)據(jù)的過程):
- 通過某種算法(比如上面提到的歐氏距離和余弦相似度)進行逐一對比。
- 類似的還有網(wǎng)格識別(先把圖片馬賽克化,像素粒度就變粗了,然后根據(jù)像素顏色差值進行比較,這個方法是適用于以縮略圖找原圖)、方向識別(比如只要手勢順序是先向右再向下再向左再向上就認(rèn)為是矩形)等。
- 顯然不同的特征和算法就造成了結(jié)果的千差萬別(效率啊、準(zhǔn)確率等,還有薪資待遇???),優(yōu)化的手段也是百花齊放,所以也就沒有通用的算法,只有適合的算法,因地制宜。
我們以一個極其簡單的推薦算法為例,推薦算法的問題在某種程度上可以轉(zhuǎn)換成兩個人的喜好相似程度:
喜好 | 干飯 | 摸魚 | 睡覺 | 就是玩 | ... |
---|---|---|---|---|---|
甲(咸魚) | ? | ? | ? | ? | ... |
乙(翻身) | ? | ? | ? | ? | ... |
我 | ? | ? | ? | ? | ... |
這個和我們的手勢識別不能說是很像,只能說是一模一樣,在已有的手勢中(甲乙)找一個和我(喜好)相似度較高的,每一行其實就是一系列采樣點,最終可以簡單的推斷出我(可能)是條咸魚??,還喜歡摸魚。
又比如你打算買一臺電腦,那大概率是先看下周圍的人用什么,然后你就買什么,從眾本質(zhì)上也是一種相似(大眾的選擇就是方向),近朱者赤近墨者黑嘛。如果你說很獨立自主,自己想買啥買啥,那也是對的,畢竟這玩意怎么搞都搞不到 100%。
關(guān)于多筆畫(可跳過)
我們本文學(xué)的是單筆畫,現(xiàn)在你可以稍微想下,如果是多筆畫應(yīng)該怎么搞?這里還是可以短暫的思考幾秒種??。。。
- 對于簡單的一筆畫來說上面的識別效果是很不錯的,不論是效率和準(zhǔn)確率,但如果是多筆畫,那就復(fù)雜起來了,比如漢字的識別(想想就頭大??)。
- 這里就介紹一個簡單的識別方法,就是把多筆畫拆成單筆畫,通過本文的學(xué)習(xí)你可以求出每個單筆畫的相似度,然后簡單求和就可以得到整個字體的相似度,最后取相似度最高的即可(就這??)。
舉個具象點的例子,比如十
這個字(這里僅僅是例子哈,不完全是這樣):
提取每個漢字的筆畫特征,一般可以采集起始點、終點和中間的轉(zhuǎn)折點。數(shù)據(jù)大概長下面這個樣子:
- 處理數(shù)據(jù)(標(biāo)準(zhǔn)化的過程,比如把每個字移到畫布中心,縮放成一樣的大?。?/li>
- 比較數(shù)據(jù)(選個算法,這里就是先判斷下筆畫數(shù),再簡單的將單筆相似度相加求和) 這就完了?當(dāng)然還差得遠呢,問題一抓一大把。比如:
- 由于存在連筆的情況,一筆可能寫成兩筆,所以我們應(yīng)該允許筆畫的誤差在 2 左右,但是在最終排序時,筆畫數(shù)越接近的,優(yōu)先級越高。
- 每一筆當(dāng)中至少包含起點和終點,中間可能有幾個拐點,如果比較的時候單筆的坐標(biāo)點數(shù)量不同該怎么處理?一種方式是進行插值計算,另一種方式是取最初的采樣點信息。
采用上述的方式如果我寫了個丁
字是不是好像也能識別出來,大體都是一橫一豎,有沒有什么辦法可以避免呢?當(dāng)然是有的,現(xiàn)在我們每一筆保存的不再是點的坐標(biāo),而是該點與前一個點連線的角度,如果是每一筆的起始點,就拿上一筆的終點作為前一個點,說起來比較抽象,所以我又畫了張圖????(很簡單的一張圖,不要被嚇到??):
大家想想如果是十
字,在上圖的第二個角度(綠2)中是不是就可以明顯區(qū)分開了。另外我們只保存了兩兩點之間的角度,還省了不少空間呢。
看起來好像沒問題了?不,還是差得遠呢。你想想要是筆畫順序不對咋整。還是以十
為例,我先寫豎再寫橫咋整。啊這。。。其實還有其他識別方法,比如把文字按坐標(biāo)軸切分成四塊,分四段校驗,這就不深入了,點到即止(畢竟就懂點皮毛)。
小結(jié)
以上就是手勢識別的大致思路,雖然看起來是挺高大上的一個東西,但是讀完之后應(yīng)該覺得。不。。算難吧。。。有些東西不是你不會只是你不知道也沒去嘗試下,嘿嘿。最后,再次送上項目地址傳送門,順便附上我 canvas 專欄的另外兩篇實戰(zhàn)文章:
html2canvas 用著有問題?手寫一個就知道為啥了??
更多關(guān)于JS前端canvas手勢識別的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
微信小程序使用navigateTo數(shù)據(jù)傳遞的實例
這篇文章主要介紹了微信小程序使用navigateTo數(shù)據(jù)傳遞的實例的相關(guān)資料,希望通過本文能幫助到大家,需要的朋友可以參考下2017-09-09