您好,欢迎来到三六零分类信息网!老站,搜索引擎当天收录,欢迎发信息

创造属于你自己的交互事件-屏幕手势识别

2024/2/18 8:34:01发布19次查看
本文为来自 字节教育-成人与创新前端团队 成员的文章,已授权 elab 发布。
tldr本文使用机器学习、余弦相似度判定法等方法,设计与验证实现了鼠标手势的识别方案,并尝试将方案推广到三维空间。
背景端技术的核心内容是直接响应用户的交互。基本的逻辑是,在特定的平台下均存在一些预定义的交互事件,用户特定的交互动作会触发对应的交互事件。整个用户产品的交互设计也都是基于此去开展的。为了达到良好的用户体验,便捷的交互是必要的。
在pc端场景,鼠标(触控板)是除了键盘外最重要的输入设备,常见的鼠标操作就是鼠标上自带的按键与滚轮,于是相应的常用交互事件也就是点击、滚动、拖动,而这些交互都需要有一个对象(比如点击某个button,滚动某个内容区或整个视区、拖动某个图片),不如快捷键那么便捷。然而在特定场景下,快捷键也没有鼠标那么便捷,所以期望鼠标也有快捷操作。鼠标手势就是一个相对小众但方便好用的快捷操作。常见的鼠标手势有划直线、打钩、画圆圈等。在早期浏览器百家齐放的年代,为了差异化竞争优势,许多国产浏览器将便捷的鼠标手势操作作为一大卖点,那时手势操作就逐渐得到了广泛的支持与应用,默默培养了市场与用户习惯。
在移动端触摸屏场景,手势操作的优势更加明显,手势操作就演变成了经典的「左滑后退」、「右划前进」、「上划返回首页」、「下划刷新/唤起通知/唤起控制中心」。
最近vr/ar/mr兴起后,三维空间里的手势操作得到进一步推广应用。
因此,我们以pc端为例,实现鼠标手势的识别,讲清楚交互手势的核心实现逻辑,并以此类推,尝试将方案推广到更多端场景。
目标核心逻辑实现:实现鼠标手势的记录与识别。对于鼠标手势,我们规定一些前提条件:平移和缩放不变形,也就是手势路径整体的位置和大小不重要。
对用户的重复手势有一定的包容度。
工程化封装:将其固化成一个自定义事件,可以通过addeventlistener的方式去监听,从而扩展交互的多样性、提升开发的便捷性。产品化体验:允许用户添加自己的自定义手势。解决方案升维:将当前探索的方案扩展到三维空间。问题分析该问题特殊的地方在于对不确定性的处理,用户划出的鼠标手势存在不确定性。
对于预设了标准路径的情况,问题就转化成了检测「预设的确定性路径」与「用户输入的不确定性的路径」的相似性。
对于用户自定义路径的情况,问题就转化成了检测「用户设定的不确定性的路径」与「用户输入的不确定性的路径」的相似性。
如果按照传统的编程模型,那必须要求程序逻辑缜密,对条件判断定下清晰的规则、去精确衡量这种不确定性。也就是需要一种“魔法运算”,把两条路径代入,就能得到它们是否相似的结果。
针对手势本身,我们可以把它看成一张普通的栅格图像,也可以把它看成一个矢量图形。对于栅格图像,我们可以利用经典机器学习的方法去判断图像的分类而不必去理解图片的内容是什么。对于矢量图形,我们需要为此定义特殊的数据结构,并深入研究图形的相似性的表征量。我们接下来的实现就从这两个思路分别展开。
实现方案​利用机器学习​基本思路首先需要转变思维方式。机器学习的编程和传统编程的思维方式完全不同。刚才提到,传统编程要求程序逻辑比如条件判断、循环等流程都做出精确地人为规定和编码。机器学习编程不再拘泥于制定和编写细致的逻辑规则,而是构建神经网络让计算机进行特征的学习。
机器学习的关键是大量且可靠的数据集,这个label工作非常耗时,为了验证可行性,我们使用相似的手写数字数据集mnist来代替真实的手势场景。
因此,接下来我们的步骤就是:
根据问题的特点,选择合适的机器学习模型。根据使用便捷性,选择一种机器学习框架。训练模型,得到模型文件。部署、运行模型,得出判断结论。模型选择机器学习的算法和模型众多,需要针对不同领域选择。tensorflow.js官方提供了一系列预训练好的模型[1],可以直接使用或者重新训练并使用。
卷积神经网络cnn(convolutional neural networks) 是应用非常广泛的机器学习模型,尤其在处理图片或其他具有栅格特征的数据时具有非常好的表现。在信息处理时,cnn将像素的行列空间结构作为输入,通过多个数学计算层来进行特征提取,然后再将信号转换为特征向量将其接入传统神经网络的结构中,经过特征提取的图像所对应的特征向量在提供给传统神经网络时体积更小,需要训练的参数数量也会相应减少。卷积神经网络的基本工作原理图如下(图中各个层的数量可以按需设计):
框架选择tensorflow.js框架之所以成为我们的首选框架,是因为如下优势:
可移植性好:tensorflow.js 并不是最热门最高效的机器学习框架,但是由于它是基于js的,以及开箱即用的 api,所以方便在各种支持js的端运行和部署。
低延迟、高私密性:得益于可完全在端上运行,不必将验证数据发往服务器等待服务器响应,从而具备了低延迟、高安全性优势。
学习/调试成本低:对于web 开发者们的上手成本较低,同时浏览器可以很好可视化机器训练过程。
tfjs的环境搭建[2]非常简易,此处略。
模型训练可通过这个简单例子体验机器学习编程思想,初识 tensorflow.js 的 api。
数据集
/** * @license* copyright 2018 google llc. all rights reserved. * licensed under the apache license, version 2.0 (the license); * you may not use this file except in compliance with the license. * you may obtain a copy of the license at * * http://www.apache.org/licenses/license-2.0 * * unless required by applicable law or agreed to in writing, software * distributed under the license is distributed on an as is basis, * without warranties or conditions of any kind, either express or implied. * see the license for the specific language governing permissions and * limitations under the license. * ============================================================================= */ const image_size = 784; const num_classes = 10; const num_dataset_elements = 65000; const num_train_elements = 55000; const num_test_elements = num_dataset_elements - num_train_elements; const mnist_images_sprite_path = 'https://storage.googleapis.com/learnjs-data/model-builder/mnist_images.png'; const mnist_labels_path = 'https://storage.googleapis.com/learnjs-data/model-builder/mnist_labels_uint8'; /** * a class that fetches the sprited mnist dataset and returns shuffled batches. * * note: this will get much easier. for now, we do data fetching and * manipulation manually. */export class mnistdata { constructor() { this.shuffledtrainindex = 0; this.shuffledtestindex = 0; } async load() { // make a request for the mnist sprited image.const img = new image(); const canvas = document.createelement('canvas'); const ctx = canvas.getcontext('2d'); const imgrequest = new promise((resolve, reject) => { img.crossorigin = ''; img.onload = () => { img.width = img.naturalwidth; img.height = img.naturalheight; const datasetbytesbuffer = new arraybuffer(num_dataset_elements * image_size * 4); const chunksize = 5000; canvas.width = img.width; canvas.height = chunksize; for (let i = 0; i { this.shuffledtestindex = (this.shuffledtestindex + 1) % this.testindices.length; return this.testindices[this.shuffledtestindex]; }); } nextbatch(batchsize, data, index) { const batchimagesarray = new float32array(batchsize * image_size); const batchlabelsarray = new uint8array(batchsize * num_classes); for (let i = 0; i { const d = data.nexttrainbatch(train_data_size); return [ d.xs.reshape([train_data_size, 28, 28, 1]), d.labels ]; }); const [testxs, testys] = tf.tidy(() => { const d = data.nexttestbatch(test_data_size); return [ d.xs.reshape([test_data_size, 28, 28, 1]), d.labels ]; }); // 设置特征和标签 return model.fit(trainxs, trainys, { batchsize: batch_size, validationdata: [testxs, testys], epochs: 10, //训练轮次 shuffle: true, callbacks: fitcallbacks }); }
模型部署与运行 // 预测canvas上画的图形属于哪个分类 function predict(){ const input = tf.tidy(() => { return tf.image .resizebilinear(tf.browser.frompixels(canvas), [28, 28], true) .slice([0, 0, 0], [28, 28, 1]) .tofloat() .div(255) .reshape([1, 28, 28, 1]); }); const pred = cnnmodel.predict(input).argmax(1); console.log('预测结果为', pred.datasync()) alert(`预测结果为 ${pred.datasync()[0]}`); }; document.getelementbyid('predict-btn').addeventlistener('click', predict) document.getelementbyid('clear-btn').addeventlistener('click', clear) document.addeventlistener('domcontentloaded', run); const canvas = document.queryselector('canvas'); canvas.addeventlistener('mousemove', (e) => { if (e.buttons === 1) { const ctx = canvas.getcontext('2d'); ctx.fillstyle = 'rgb(255,255,255)'; ctx.fillrect(e.offsetx, e.offsety, 10, 10); } }); function clear(){ const ctx = canvas.getcontext('2d'); ctx.fillstyle = 'rgb(0,0,0)'; ctx.fillrect(0, 0, 300, 300); }; clear();
方案评价该方案的优势在于,参与训练的数据集越庞大,预测效果也越好。其劣势也很明显,首先,训练数据集和验证数据集的构造是个巨量的工作,再者虽然可以在浏览器运行时进行训练,但依旧比较耗时。综上,该方案可以实现对预定义的几种手势进行检测,但难以通过用户几次的手势录入就训练出能识别用户特定手势的模型。
其他问题识别手写体的ocr方法可以用在识别用户自定义手势吗?
由于用户输入的不确定性,用户输入的手势并不一定对应着某个特定的预先定义好的类别,图像分类能判断出“其他”类别吗?
tensorflow model的格式,与框架语言有关吗?比如python训出的模型在tensorflow.js里还能使用吗?
​利用几何分析法​基本思路从鼠标交互中拾取到的是路径信息,通过这个路径信息我们能提取出位置、形状、方向等更具体的信息。从而,我们可以通过记录鼠标时空轨迹,再使用规则归纳出手势路径的特点,固化成可以很大概率上唯一标识手势的模式,再去对比手势模式与普通手势的相似度。注意,在定义路径数据结构时,需要考虑尽量避免大小与轻微形变的影响。
路径特征的提取和记录首先,要将用户划出的手势路径表示出来。我们明确一个基本原则,形状相同但大小不同的应该被认为是同一手势。手势路径需要用一组单位向量来表示。在 stroke中,将手势图形平分成 128 个向量,再将每个向量换算成单位向量。这样一来,即便手势路径的大小和长短不同,只要它们在结构上是一样的,那么表示它们的数据也是一样的。从而消除了路径大小与轻微形变对于判断结果的影响。
路径特征相似度的表征然后,度量路径的相似度就转换成了度量向量数据的相似度。通过一个几何量的具体数值来判定路径之间的相似度,于是我们从计算向量相似度的经典方法里找到了余弦相似度。
向量的相似度通常使用余弦相似度来度量,即计算向量夹角的余弦值。将两组数据两两对应,分成128组向量,每组2个,计算每组向量的余弦值并累加。最终得到的结果应该会在 [-128, 128] 之间,数值越大也就表示相似度越高。我们只需设置一个阈值,超过这个阈值的就认为匹配成功。
为了计算两个向量夹角的余弦值,引入向量的点乘,根据向量点乘公式(推导过程[3]):
这里|a|表示向量a的模(长度),θ表示两个向量之间的夹角。
两个互相垂直的向量的点积总是零。若向量a和b都是单位向量(长度为1),它们的点积就是它们的夹角的余弦。那么,给定两个向量,它们之间的夹角可以通过下列公式得到:
这个运算可以简单地理解为:在点积运算中,第一个向量投影到第二个向量上(这里,向量的顺序是不重要的,点积运算是可交换的),然后通过除以它们的标量长度来“标准化”。这样,这个分数一定是小于等于1的,可以简单地转化成一个角度值。
对于二维向量,我们用一个[number, number]元组来表示。
核心实现逻辑:
import { useeffect, usestate, useref, usememo } from 'react' import throttle from lodash/throttle type position = {x:number, y:number}; type vector = [number, number]; // 预先定义特殊v字型的手势路径,便于调试。 const shapevectors_v: vector[] = [[5,16],[13,29],[4,9],[6,9],[8,8],[1,0],[1,0],[1,-2],[0,-3],[7,-11],[21,-34],[10,-19]]; const shapevectors_l: vector[] = [[0,15],[0,33],[0,19],[0,4],[0,3],[0,8],[2,6],[11,0],[28,0],[18,0],[5,0],[1,0]] const shapevectors_6: vector[] = [[-41,18],[-40,33],[-30,39],[-24,62],[1,53],[40,27],[38,2],[30,-34],[7,-41],[-31,-21],[-38,-4],[-19,0]]; const shapevectors: {[key:string]: vector[]} = { v: shapevectors_v, l: shapevectors_l, 6: shapevectors_6 } function gesture(){ const pointsref = useref([]); const sparsedpointsref = useref([]); const vectorsref = useref([]); const canvascontextref = useref() const containerref = useref(null) const [predictresults, setpredictresults] = usestate([]) // 按一定的时间间隔采集点 const handlemousemovethrottled = usememo(()=>{return throttle(handlemousemove, 16)}, [canvascontextref.current]) useeffect(()=>{ const canvasele = document.getelementbyid('canvas-ele') as htmlcanvaselement; const ctx = canvasele.getcontext('2d')!; canvascontextref.current=ctx; handleclear(); }, []) function handlemousedown(){ containerref?.current?.addeventlistener('mousemove', handlemousemovethrottled); } function handlemouseup(){ console.log('up') containerref?.current?.removeeventlistener('mousemove', handlemousemovethrottled); console.log('points', sparsedpointsref.current) console.log('vectors', json.stringify(vectorsref.current)) pointsref.current=[] } // 为了方便示意,我们把鼠标路径可视化出来。 function drawpoint(x:number,y:number){ // console.log(x, y) // canvascontext?.arc(x, y, 5, 0, math.pi*2); (canvascontextref.current!).fillstyle = 'red'; canvascontextref.current?.fillrect(x, y, 10,10) } // 鼠标滑过时,记录下一串间隔的点。 function handlemousemove(e: any){ const x:number = e.offsetx, y:number = e.offsety; drawpoint(x, y) const newpoints = [...pointsref.current, {x,y}]; pointsref.current = newpoints; const sparsednewpoints = sparsepoints(newpoints); sparsedpointsref.current=sparsednewpoints; const vectors = points2vectors(sparsednewpoints) vectorsref.current = vectors; console.log('points', x, y) // const angles = vectors.map(vector2polarangle) // console.log('angles', angles[angles.length-1]) } // 如果点太多,处理起来性能不佳,除了节流之外,我们始终将点抽稀到13个(我们假设每个手势的持续时间都不低于200ms,能保证在节流16ms的情况下,至少收集到13个原始点,这样抽稀才有意义) // 抽稀的策略是以固定的间隔平均抽,这样有个潜在问题:如果用户划手势时速度不够均匀,比如在同一个手势路径中某段时间划的速度比较快(点会比较密集),在某段时间的速度比较慢(点会比较稀疏),那由抽稀后的点构造出的路径向量就会比较失真,影响最终判断的准确性。 // 优化的方案是在空间上采用分区抽稀的策略,避免用户手速不均匀导致的问题,但分区逻辑比较复杂,我们暂且按下不做深入研究。 // todo: 抽稀后,相邻的点不能重复,否则会有0向量、对运算和判断造成干扰。 function sparsepoints(points: position[]){ const sparsedlength = 13; if(points.length=finalthreshold } // 两组向量的余弦相似度,保证组成两条路径的向量数相同,然后计算两条路径对应向量的余弦值,累加取均值.取值在-1~1之间,越接近-1或者1,越相似. function cosinesimilarity(vec1: vector[], vec2: vector[]){ if(vec1.length!==vec2.length){ console.warn('进行比较的两个路径长度(路径内的向量数)必须一致') return 0; }else{ let cosvaluesum = 0; vec1.foreach((v1, i)=>{ cosvaluesum+=vectorscos(v1, vec2[i]) }) // 取余弦值的绝对值,绝对值越接近1,相似度越高。 const cosvaluerate = math.abs(cosvaluesum/vec1.length); console.log('cosvaluerate', cosvaluerate) return cosvaluerate; } } // 两个向量的余弦值 function vectorscos(v1:vector, v2:vector){ // 特殊情况,0向量的余弦值我们认为是1 if(vectorlength(v1)*vectorlength(v2)===0){ return 1; } return vectorsdotproduct(v1, v2)/(vectorlength(v1)*vectorlength(v2)); } // 向量的点乘 function vectorsdotproduct(v1:vector, v2:vector){ return v1[0]*v2[0]+v1[1]*v2[1]; } // 向量的长度 function vectorlength(v:vector){ return math.sqrt(math.pow(v[0], 2)+math.pow(v[1], 2)) } // 向量归一化,消除向量在长度上的差异,控制变量,方便训练机器学习模型(https://zhuanlan.zhihu.com/p/424518359) function normalizevector(vec:vector){ const length = math.sqrt(math.pow(vec[0],2)+math.pow(vec[1], 2)) return [vec[0]/length, vec[1]/length] } function handlepredict(){ const results = object.keys(shapevectors).map(key=>({ label: key, similarity: cosinesimilarity(shapevectors[key], vectorsref.current), })) setpredictresults(results); console.log('results', results) } function handleclear(){ pointsref.current=[]; sparsedpointsref.current=[]; vectorsref.current=[]; (canvascontextref.current!).fillstyle = 'rgb(0,0,0)'; (canvascontextref.current!).fillrect(0, 0, 500, 500); setpredictresults([]); } // 工程化封装,为某个dom元素增加自定义手势事件 function addcustomevent(ele: htmlelement, eventname: string, eventlisener:(...args:any[])=>any){ let points = [], sparsedpoints=[],vecs:vector[]=[]; const customevent = new event(eventname); function handlemousemove(e: any){ const x:number = e.offsetx, y:number = e.offsety; const newpoints = [...pointsref.current, {x,y}]; points = newpoints; const sparsednewpoints = sparsepoints(newpoints); sparsedpoints=sparsednewpoints; const newvectors = points2vectors(sparsednewpoints) vecs = newvectors; console.log('points', x, y) } const handlemousemovethrottled = throttle(handlemousemove, 16) function handlemousedown(){ ele.addeventlistener('mousemove', handlemousemovethrottled); } function handlemouseup(){ console.log('up') ele.removeeventlistener('mousemove', handlemousemovethrottled); console.log('points', sparsedpointsref.current) console.log('vectors', json.stringify(vectorsref.current)) if(judge(vecs, shapevectors['l'], 0.6)){ ele.dispatchevent(customevent) } points=[], sparsedpoints=[], vecs=[]; } ele.addeventlistener(eventname, eventlisener) ele.addeventlistener('mousedown', handlemousedown); ele.addeventlistener('mouseup', handlemouseup); return function distroyeventlistener(){ ele.removeeventlistener(eventname, eventlisener) } } return 预测 清空 {predictresults.map(e=>( {`与 ${e.label}的相似度:${e.similarity}`} ))} } export default gesture
性能优化点和向量的计算属于计算密集型任务,且其需要与主线程通信的数据量不大,考虑将其搬进webworker。此外,canvas的渲染性能也可以使用requestanimationframe和硬件加速来优化。属于常见的工程层面优化,此处略。
方案评价余弦相似度的方法,优势在于计算量不大,可以在运行时由用户自定义手势,且所需保存的数据量不大,也适合网络传输。劣势在于难以衡量复杂多笔画、没有严格笔顺的图形的相似度。
​扩展到三维空间​针对二维平面内的手势识别方案如何扩展到三维空间呢?比如在vr/mr场景内,手势路径会是一组三维向量,如果我们能将余弦相似度的适用范围扩展到三维向量,也就顺理成章地解决了这个问题。
基本思路就是分别分析两个三维向量在xoy平面上的投影之间的夹角以及在yoz平面上的投影之间的夹角的余弦相似度,将两者的乘积作为两个三维向量之间的余弦相似度。判断逻辑与二维向量的一致。
​综合方案​综合考虑机器学习的方案和几何分析方案的优劣势,我们做如下设计。对于预设的手势,我们构造数据集、离线训练模型,然后将模型内置在产品内。对于自定义的手势,我们采用几何分析方案,让用户连续输入3次,先计算每次输入的路径的两两之间的相似度,且选出相似度的最小值n,如果最小值n大于某个阈值m,且每次输入的路径与其他已有路径的相似度均小于m时,我们就将距离其余两条路径的相似度之和最小的那条路径作为用户自定义的新路径,n作为其相似度判断的阈值。
参考资料
[1]预训练好的模型: https://github.com/tensorflow/tfjs-models
[2]环境搭建: https://github.com/tensorflow/tfjs#getting-started
[3]推导过程: https://blog.csdn.net/dcrmg/article/details/52416832
[4]复杂鼠标手势的识别是如何实现的? - 知乎: https://www.zhihu.com/question/20607813
[5]点积相似度、余弦相似度、欧几里得相似度: https://zhuanlan.zhihu.com/p/159244903
[6]机器学习并没有那么深奥,它还很有趣(1)-36氪: https://m.36kr.com/p/1721248956417
[7]计算向量间相似度的常用方法: https://cloud.tencent.com/developer/article/1668762
[8]c#手势库的核心逻辑实现: https://github.com/poerin/stroke/blob/master/stroke/gesture.cs
[9]什么是张量 (tensor)? - 知乎: https://www.zhihu.com/question/20695804
[10]使用 cnn 识别手写数字: https://codelabs.developers.google.com/codelabs/tfjs-training-classfication?hl=zh-cn#0
[11]机器学习: https://zh.m.wikipedia.org/zh/%e6%9c%ba%e5%99%a8%e5%ad%a6%e4%b9%a0
[12]文字识别方法整理(2015~2019): https://zhuanlan.zhihu.com/p/65707543
以上就是创造属于你自己的交互事件-屏幕手势识别的详细内容。
该用户其它信息

VIP推荐

免费发布信息,免费发布B2B信息网站平台 - 三六零分类信息网 沪ICP备09012988号-2
企业名录 Product