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

发表于 2年以前  | 总阅读数:430 次

TLDR

本文使用机器学习、余弦相似度判定法等方法,设计与验证实现了鼠标手势的识别方案,并尝试将方案推广到三维空间。

背景

端技术的核心内容是直接响应用户的交互。基本的逻辑是,在特定的平台下均存在一些预定义的交互事件,用户特定的交互动作会触发对应的交互事件。整个用户产品的交互设计也都是基于此去开展的。为了达到良好的用户体验,便捷的交互是必要的。

在PC端场景,鼠标(触控板)是除了键盘外最重要的输入设备,常见的鼠标操作就是鼠标上自带的按键与滚轮,于是相应的常用交互事件也就是点击、滚动、拖动,而这些交互都需要有一个对象(比如点击某个button,滚动某个内容区或整个视区、拖动某个图片),不如快捷键那么便捷。然而在特定场景下,快捷键也没有鼠标那么便捷,所以期望鼠标也有快捷操作。鼠标手势就是一个相对小众但方便好用的快捷操作。常见的鼠标手势有划直线、打钩、画圆圈等。在早期浏览器百家齐放的年代,为了差异化竞争优势,许多国产浏览器将便捷的鼠标手势操作作为一大卖点,那时手势操作就逐渐得到了广泛的支持与应用,默默培养了市场与用户习惯。

在移动端触摸屏场景,手势操作的优势更加明显,手势操作就演变成了经典的「左滑后退」、「右划前进」、「上划返回首页」、「下划刷新/唤起通知/唤起控制中心」。

最近VR/AR/MR兴起后,三维空间里的手势操作得到进一步推广应用。

因此,我们以PC端为例,实现鼠标手势的识别,讲清楚交互手势的核心实现逻辑,并以此类推,尝试将方案推广到更多端场景。

目标

  • 核心逻辑实现:实现鼠标手势的记录与识别。对于鼠标手势,我们规定一些前提条件:

  • 平移和缩放不变形,也就是手势路径整体的位置和大小不重要。

  • 对用户的重复手势有一定的包容度。

  • 工程化封装:将其固化成一个自定义事件,可以通过addEventListener的方式去监听,从而扩展交互的多样性、提升开发的便捷性。

  • 产品化体验:允许用户添加自己的自定义手势。

  • 解决方案升维:将当前探索的方案扩展到三维空间。

问题分析

该问题特殊的地方在于对不确定性的处理,用户划出的鼠标手势存在不确定性。

对于预设了标准路径的情况,问题就转化成了检测「预设的确定性路径」与「用户输入的不确定性的路径」的相似性。

对于用户自定义路径的情况,问题就转化成了检测「用户设定的不确定性的路径」与「用户输入的不确定性的路径」的相似性。

如果按照传统的编程模型,那必须要求程序逻辑缜密,对条件判断定下清晰的规则、去精确衡量这种不确定性。也就是需要一种“魔法运算”,把两条路径代入,就能得到它们是否相似的结果。

针对手势本身,我们可以把它看成一张普通的栅格图像,也可以把它看成一个矢量图形。对于栅格图像,我们可以利用经典机器学习的方法去判断图像的分类而不必去理解图片的内容是什么。对于矢量图形,我们需要为此定义特殊的数据结构,并深入研究图形的相似性的表征量。我们接下来的实现就从这两个思路分别展开。

实现方案

利用机器学习

基本思路

首先需要转变思维方式。机器学习的编程和传统编程的思维方式完全不同。刚才提到,传统编程要求程序逻辑比如条件判断、循环等流程都做出精确地人为规定和编码。机器学习编程不再拘泥于制定和编写细致的逻辑规则,而是构建神经网络让计算机进行特征的学习。

机器学习的关键是大量且可靠的数据集,这个label工作非常耗时,为了验证可行性,我们使用相似的手写数字数据集mnist来代替真实的手势场景。

因此,接下来我们的步骤就是:

  1. 根据问题的特点,选择合适的机器学习模型。
  2. 根据使用便捷性,选择一种机器学习框架。
  3. 训练模型,得到模型文件。
  4. 部署、运行模型,得出判断结论。

模型选择

机器学习的算法和模型众多,需要针对不同领域选择。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 < NUM_DATASET_ELEMENTS / chunkSize; i++) {
           const datasetBytesView = new Float32Array(
               datasetBytesBuffer, i * IMAGE_SIZE * chunkSize * 4,
               IMAGE_SIZE * chunkSize);
           ctx.drawImage(
               img, 0, i * chunkSize, img.width, chunkSize, 0, 0, img.width,
               chunkSize);

           const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

           for (let j = 0; j < imageData.data.length / 4; j++) {
             // All channels hold an equal value since the image is grayscale, so              // just read the red channel.              datasetBytesView[j] = imageData.data[j * 4] / 255;
           }
         }
         this.datasetImages = new Float32Array(datasetBytesBuffer);

         resolve();
       };
       img.src = MNIST_IMAGES_SPRITE_PATH;
     });

     const labelsRequest = fetch(MNIST_LABELS_PATH);
     const [imgResponse, labelsResponse] =
         await Promise.all([imgRequest, labelsRequest]);

     this.datasetLabels = new Uint8Array(await labelsResponse.arrayBuffer());

     // Create shuffled indices into the train/test set for when we select a      // random dataset element for training / validation.      this.trainIndices = tf.util.createShuffledIndices(NUM_TRAIN_ELEMENTS);
     this.testIndices = tf.util.createShuffledIndices(NUM_TEST_ELEMENTS);

     // Slice the the images and labels into train and test sets.      this.trainImages =
         this.datasetImages.slice(0, IMAGE_SIZE * NUM_TRAIN_ELEMENTS);
     this.testImages = this.datasetImages.slice(IMAGE_SIZE * NUM_TRAIN_ELEMENTS);
     this.trainLabels =
         this.datasetLabels.slice(0, NUM_CLASSES * NUM_TRAIN_ELEMENTS);
     this.testLabels =
         this.datasetLabels.slice(NUM_CLASSES * NUM_TRAIN_ELEMENTS);
   }

   nextTrainBatch(batchSize) {
     return this.nextBatch(
         batchSize, [this.trainImages, this.trainLabels], () => {
           this.shuffledTrainIndex =
               (this.shuffledTrainIndex + 1) % this.trainIndices.length;
           return this.trainIndices[this.shuffledTrainIndex];
         });
   }

   nextTestBatch(batchSize) {
     return this.nextBatch(batchSize, [this.testImages, this.testLabels], () => {
       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 < batchSize; i++) {
       const idx = index();

       const image =
           data[0].slice(idx * IMAGE_SIZE, idx * IMAGE_SIZE + IMAGE_SIZE);
       batchImagesArray.set(image, i * IMAGE_SIZE);

       const label =
           data[1].slice(idx * NUM_CLASSES, idx * NUM_CLASSES + NUM_CLASSES);
       batchLabelsArray.set(label, i * NUM_CLASSES);
     }

     const xs = tf.tensor2d(batchImagesArray, [batchSize, IMAGE_SIZE]);
     const labels = tf.tensor2d(batchLabelsArray, [batchSize, NUM_CLASSES]);

     return {xs, labels};
   }
 }
 // 我们直接使用mnist数据集这个经典的手写数字数据集,节约了收集手写数字的图片集的时间
import {MnistData} from './data.js';

let cnnModel=null;

async function run() {
  // 加载数据集   const data = new MnistData();
  await data.load();
  // 构造模型,设置模型参数   cnnModel = getModel();
  // 训练模型   await train(cnnModel, data);
}

function getModel() {
  const model = tf.sequential();

  const IMAGE_WIDTH = 28;
  const IMAGE_HEIGHT = 28;
  const IMAGE_CHANNELS = 1;

  // 在第一层,指定输入数据的形状,设置卷积参数   model.add(tf.layers.conv2d({
    // 流入模型第一层的数据的形状。在本例中,我们的 MNIST 示例是 28x28 像素的黑白图片。图片数据的规范格式为 [row, column, depth]     inputShape: [IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_CHANNELS],
    // 要应用于输入数据的滑动卷积过滤器窗口的尺寸。在此示例中,我们将kernelSize设置成5,也就是指定 5x5 的卷积窗口。     kernelSize: 5,
    // 尺寸为 kernelSize 的过滤器窗口数量     filters: 8,
    // 滑动窗口的步长,即每次移动图片时过滤器都会移动多少像素。我们指定步长为 1,表示过滤器将以 1 像素为步长在图片上滑动。     strides: 1,
    // 卷积完成后应用于数据的激活函数。在本例中,我们将应用修正线性单元 (ReLU) 函数,这是机器学习模型中非常常见的激活函数。     activation: 'relu',
    // 通常使用 VarianceScaling作为随机初始化模型权重的方法     kernelInitializer: 'varianceScaling'
  }));

  // MaxPooling最大池化层使用区域最大值而不是平均值进行降采样   model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));

  // 重复一遍conv2d + maxPooling   // 注意这次卷积的过滤器窗口数量更多   model.add(tf.layers.conv2d({
    kernelSize: 5,
    filters: 16,
    strides: 1,
    activation: 'relu',
    kernelInitializer: 'varianceScaling'
  }));
  model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));

  // 现在我们将2维滤波器的输出展平为1维向量,作为最后一层的输入。这是将高维数据输入给最后的分类输出层时的常见做法。   // 图片是高维数据,而卷积运算往往会增大传入其中的数据的大小。在将数据传递到最终分类层之前,我们需要将数据展平为一个长数组。密集层(我们会用作最终层)只需要采用 tensor1d,因而此步骤在许多分类任务中很常见。   // 注意:展平层中没有权重。它只是将其输入展开为一个长数组。   model.add(tf.layers.flatten());

  // 计算我们的最终概率分布,我们将使用密集层计算10个可能的类的概率分布,其中得分最高的类将是预测的数字。   const NUM_OUTPUT_CLASSES = 10;
  model.add(tf.layers.dense({
    units: NUM_OUTPUT_CLASSES,
    kernelInitializer: 'varianceScaling',
    activation: 'softmax'
  }));

  // 模型编译,选择优化器,损失函数categoricalCrossentropy,和精度指标accuracy(正确预测在所有预测中所占的百分比),然后编译并返回模型   const optimizer = tf.train.adam();
  model.compile({
    optimizer: optimizer,
    loss: 'categoricalCrossentropy',
    metrics: ['accuracy'],
  });

  return model;
}

// 我们的目标是训练一个模型,该模型会获取一张图片,然后学习预测图片可能所属的 10 个类中每个类的得分(数字 0-9)。 async function train(model, data) {
  const metrics = ['loss', 'val_loss', 'acc', 'val_acc'];
  const container = {
    name: 'Model Training', tab: 'Model', styles: { height: '1000px' }
  };
  const fitCallbacks = tfvis.show.fitCallbacks(container, metrics);

  const BATCH_SIZE = 512;
  const TRAIN_DATA_SIZE = 5500;
  const TEST_DATA_SIZE = 1000;

  const [trainXs, trainYs] = tf.tidy(() => {
    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<Position[]>([]);
    const sparsedPointsRef = useRef<Position[]>([]);
    const vectorsRef = useRef<Vector[]>([]);

    const canvasContextRef = useRef<CanvasRenderingContext2D>()
    const containerRef = useRef<HTMLDivElement>(null)

    const [predictResults, setPredictResults] = useState<{label: string, similarity: number}[]>([])

    // 按一定的时间间隔采集点     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<=sparsedLength){
        return points;
      }else{
        let sparsedPoints = [];
        let step = points.length/sparsedLength;
        for(let i=0; i<sparsedLength; i++){
          const curIndex = Math.round(step*i);
          sparsedPoints.push(points[curIndex])
        }
        return sparsedPoints;
      }
    }

    // 对于非闭合的路径,手势方向会影响判断逻辑,相同的路径可能是由相反的手势方向画出来的。比如L形的手势。     // 对于闭合的路径,手势的方向和起止位置都会影响判断逻辑,相同的路径可能是由相反的手势方向画出来的,也可能是由不同起始位置画出来的。比如圆形的手势。     // 为了消除相同路径不同画法的影响,我们做如下处理 
    function normalizePoints(points:Position[]){
      // if (是闭合路径) 将位置在最左上角的点作为数组的第一位,其余的依次排列,然后返回       // else 原样返回       return points;
    }

    // 相邻的两个点相连,生成一个向量。用这n个点的坐标生成n-1个向量,这n-1个向量组成一段路径,用来表示一个鼠标手势。     function points2Vectors(points: Position[]){
      if(points.length<=1){
        return []
      }else{
        return points.reduce((pre:Vector[], cur, curIdx)=>{
          if(curIdx===0){return []}
          const prePoint = points[curIdx-1];
          const vec:Vector = [cur.x-prePoint.x, cur.y-prePoint.y];
          return [...pre, vec];
        }, [])
      }
    }

    // 判断两条路径是否是相同,保证组成两条路径的向量数相同,然后计算两条路径对应向量的余弦相似度(取值在-1~1之间,越接近-1或者1,越相似)。最后再与定义的阈值比较,超过阈值就认为路径相同。     function judge(vec1:Vector[], vec2: Vector[], threshold?:number){
      // 暂定阈值为0.5       const finalThreshold = threshold||0.5;
      // 为消除路径方向的影响(一个向量与另一个反向相反的向量的余弦值是-1,应该认为它们形状相同),反转路径后再次判断       return cosineSimilarity(vec1, vec2)>=finalThreshold || cosineSimilarity(vec1, vec2.reverse())>=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 <div ref={containerRef} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} style={{width: '500px', height: '500px', background: 'grey'}}>
    <canvas id='canvas-ele' width='500' height="500"></canvas>
    <section>
      <button onClick={handlePredict}>预测</button>
      <button onClick={handleClear}>清空</button>
    </section>
    <ul>
      {predictResults.map(e=>(
        <li key={e.label}>
          {`与 ${e.label}的相似度:${e.similarity}`}
        </li>
        ))}
    </ul>
  </div>  
}
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

  • END -

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/AKtARC8vzCnPddi54KVdFw

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237224次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8059次阅读
 目录