>  기사  >  기술 주변기기  >  나만의 대화형 이벤트 만들기 - 화면 동작 인식

나만의 대화형 이벤트 만들기 - 화면 동작 인식

WBOY
WBOY앞으로
2023-04-14 19:37:011261검색

이 기사는 Byte Education-Adult and Innovation Front-end Team 구성원의 기사이며 ELab의 승인을 받아 게시되었습니다.

TLDR

이 글에서는 기계 학습, 코사인 유사성 결정 방법 및 기타 방법을 사용하여 마우스 제스처 인식 체계를 설계 및 검증하고 이 체계를 3차원 공간으로 확장하려고 시도합니다.

Background

단말 기술의 핵심 내용은 사용자 상호작용에 직접적으로 반응하는 것입니다. 기본 논리는 특정 플랫폼 아래에 미리 정의된 상호 작용 이벤트가 있고 사용자의 특정 상호 작용 작업이 해당 상호 작용 이벤트를 트리거한다는 것입니다. 전체 사용자 제품 상호 작용 디자인도 이를 기반으로 합니다. 좋은 사용자 경험을 달성하기 위해서는 편리한 상호작용이 필요합니다.

PC 시나리오에서 마우스(트랙패드)는 키보드 외에 가장 중요한 입력 장치입니다. 일반적인 마우스 조작은 마우스에 있는 버튼과 휠이므로 해당 일반적인 상호 작용 이벤트는 클릭, 스크롤, 드래그입니다. 이러한 상호 작용에는 개체(예: 버튼 클릭, 콘텐츠 영역 또는 전체 뷰포트 스크롤, 그림 끌기)가 필요하며 이는 바로 가기 키만큼 편리하지 않습니다. 그러나 특정 시나리오에서는 바로 가기 키가 마우스만큼 편리하지 않으므로 마우스에도 바로 가기 작업이 있을 것으로 예상합니다. 마우스 제스처는 상대적으로 틈새시장이지만 편리하고 사용하기 쉬운 바로가기 작업입니다. 일반적인 마우스 동작에는 직선 그리기, 틱, 원 그리기 등이 있습니다. 수백 개의 브라우저가 번성했던 초기에는 많은 국내 브라우저가 경쟁 우위를 차별화하기 위해 편리한 마우스 제스처 조작을 주요 판매 포인트로 활용했습니다. 당시 제스처 조작은 점차 광범위한 지원과 적용을 얻어 조용히 시장을 개척했습니다. 그리고 사용자 습관.

모바일 터치 스크린 시나리오에서 제스처 작업의 장점은 더욱 분명해졌습니다. 제스처 작업은 고전적인 "왼쪽으로 스와이프하여 뒤로 이동", "오른쪽으로 스와이프하여 앞으로 이동", "위로 스와이프하여 홈페이지로 돌아가기"로 발전했습니다. "아래로 스와이프하여 새로고침/알림 불러오기/"제어 센터 활성화".

최근 VR/AR/MR의 등장으로 3차원 공간에서의 제스처 조작이 더욱 촉진되고 적용되고 있습니다.

따라서 우리는 마우스 제스처 인식을 실현하고 대화형 제스처의 핵심 구현 논리를 명확하게 하기 위해 PC 측면을 예로 들어 비유를 통해 솔루션을 더 많은 터미널 시나리오로 확장하려고 합니다.

Goal

  • 핵심 로직 구현: 마우스 제스처의 기록 및 인식을 실현합니다. 마우스 제스처의 경우 몇 가지 전제 조건을 규정합니다.

이동 및 확대/축소가 변형되지 않습니다. 즉, 제스처 경로의 전체 위치와 크기가 중요하지 않습니다.

사용자의 반복적인 제스처에 대한 특정 허용 오차가 있습니다.

  • 엔지니어링 캡슐화: addEventListener를 통해 모니터링할 수 있는 맞춤 이벤트로 구체화하여 상호 작용의 다양성을 확장하고 개발 편의성을 향상시킵니다.
  • 제품화된 경험: 사용자가 자신만의 맞춤 동작을 추가할 수 있습니다.
  • 솔루션 차원 업그레이드: 현재 탐색 중인 솔루션을 3차원 공간으로 확장합니다.

문제 분석

이 문제의 특별한 점은 사용자가 그리는 마우스 제스처에 불확실성이 있다는 것입니다.

표준 경로가 미리 설정된 경우 문제는 "미리 설정된 결정적 경로"와 "사용자 입력 불확실한 경로" 간의 유사성을 감지하는 것으로 변환됩니다.

사용자 정의 경로의 경우 문제는 "사용자가 설정한 불확실성의 경로"와 "사용자가 입력한 불확실성의 경로" 간의 유사성을 탐지하는 것으로 변환됩니다.

기존 프로그래밍 모델을 따르는 경우 엄격한 프로그램 논리를 요구하고, 조건부 판단에 대한 명확한 규칙을 설정하고, 이러한 불확실성을 정확하게 측정해야 합니다. 즉, 두 경로를 대체하는 "마법의 연산"이 필요하며, 유사 여부에 대한 결과를 얻을 수 있습니다.

제스처 자체는 일반적인 래스터 이미지나 벡터 그래픽이라고 생각하면 됩니다. 래스터 이미지의 경우, 이미지의 내용을 이해하지 않고도 이미지의 분류를 결정하기 위해 전통적인 기계 학습 방법을 사용할 수 있습니다. 벡터 그래픽의 경우 이를 위한 특별한 데이터 구조를 정의하고 그래픽의 유사성 표현을 탐구해야 합니다. 다음 구현은 각각 이 두 가지 아이디어에서 시작됩니다.

구현 계획

​머신러닝 활용하기​

기본 아이디어

우선, 사고방식을 바꿔야 합니다. 기계 학습 프로그래밍은 전통적인 프로그래밍 사고와 완전히 다릅니다. 방금 언급했듯이 기존 프로그래밍에서는 조건부 판단, 루프 및 기타 프로세스와 같은 프로그램 논리를 정확하게 지정하고 수동으로 코딩해야 합니다. 기계 학습 프로그래밍은 더 이상 상세한 논리 규칙을 공식화하고 작성하는 데 집착하지 않고 컴퓨터가 기능을 학습할 수 있도록 신경망을 구축합니다.

머신 러닝의 핵심은 크고 신뢰할 수 있는 데이터 세트입니다. 이 라벨링 작업은 타당성을 확인하기 위해 유사한 필기 숫자 데이터 세트 mnist를 사용하여 실제 제스처 장면을 대체합니다.

다음 단계는 다음과 같습니다.

  • 문제의 특성에 따라 적절한 머신러닝 모델을 선택하세요.
  • 사용 편의성을 기반으로 기계 학습 프레임워크를 선택하세요.
  • 모델을 훈련하고 모델 파일을 받으세요.
  • 모델을 배포 및 실행하고 결론을 도출합니다.

모델 선택

머신러닝에는 수많은 알고리즘과 모델이 있으며, 다양한 분야에 맞게 선택해야 합니다. Tensorflow.js는 직접 사용하거나 재학습하여 사용할 수 있는 일련의 사전 학습된 모델[1]을 공식적으로 제공합니다.

나만의 대화형 이벤트 만들기 - 화면 동작 인식

CNN(Convolutional Neural Networks)은 매우 널리 사용되는 기계 학습 모델로, 특히 래스터 특성을 가진 사진이나 기타 데이터를 처리할 때 성능이 매우 좋습니다. 정보 처리 과정에서 CNN은 픽셀의 행과 열의 공간 구조를 입력으로 받아 여러 수학적 계산 레이어를 통해 특징을 추출한 후 신호를 특징 벡터로 변환하고 이를 기존 신경망의 구조에 연결합니다. , 이미지 해당 특징 벡터는 기존 신경망에 제공될 때 더 작아지고 이에 따라 학습해야 하는 매개변수의 수도 줄어듭니다. 컨벌루션 신경망의 기본 작동 원리 다이어그램은 다음과 같습니다(다이어그램의 각 레이어 수는 필요에 따라 설계할 수 있습니다).

나만의 대화형 이벤트 만들기 - 화면 동작 인식

프레임워크 선택

Tensorflow.js 프레임워크가 우리가 선호하는 이유 프레임워크의 장점은 다음과 같습니다.

좋은 이식성: Tensorflow.js는 가장 널리 사용되고 효율적인 기계 학습 프레임워크는 아니지만 JS를 기반으로 하고 즉시 사용 가능한 API를 갖추고 있기 때문에 쉽게 사용할 수 있습니다. JS를 지원하는 다양한 터미널에서 실행하고 배포하세요.

낮은 지연 시간과 높은 개인 정보 보호: 최종적으로 완전히 실행될 수 있기 때문에 서버에 검증 데이터를 보내고 서버의 응답을 기다릴 필요가 없으므로 낮은 지연 시간과 높은 보안의 장점이 있습니다. .

낮은 학습/디버깅 비용: 웹 개발자가 시작하는 데 드는 비용이 낮고 브라우저는 머신 트레이닝 프로세스를 잘 시각화할 수 있습니다.

TFJS[2]의 환경 설정은 매우 간단하므로 여기서는 생략하겠습니다.

모델 트레이닝

이 간단한 예제를 통해 머신러닝 프로그래밍 아이디어를 경험하고 Tensorflow.js의 API에 익숙해질 수 있습니다.

Dataset

 /** * @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 방식을 사용하여 사용자 정의 제스처를 인식할 수 있나요?

사용자 입력의 불확실성으로 인해 사용자가 입력한 제스처가 반드시 사전 정의된 특정 카테고리와 일치할 필요는 없습니다. 이미지 분류가 "기타" 카테고리를 결정할 수 있나요?

텐서플로우 모델의 형식이 프레임워크 언어와 관련이 있나요? 예를 들어 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 
<canvas  width='500' height="500"></canvas>
<section>
<button notallow={handlePredict}>预测</button>
<button notallow={handleClear}>清空</button>
</section>
<ul>
{predictResults.map(e=>(
<li key={e.label}>
{`与 ${e.label}的相似度:${e.similarity}`}
</li>
))}
</ul>

}
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

위 내용은 나만의 대화형 이벤트 만들기 - 화면 동작 인식의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
이 기사는 51cto.com에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제