Rumah > Artikel > Peranti teknologi > Cipta acara interaktif anda sendiri - pengecaman gerak isyarat skrin
Artikel ini ialah artikel daripada ahli Pasukan Bahagian Hadapan Pendidikan-Dewasa dan Inovasi Byte dan telah diberi kuasa oleh ELab untuk menerbitkan.
Artikel ini menggunakan pembelajaran mesin, kaedah penentuan kesamaan kosinus dan kaedah lain untuk mereka bentuk dan mengesahkan skema pengecaman gerak isyarat tetikus dan cuba memanjangkan skema kepada ruang tiga dimensi.
Kandungan teras teknologi terminal adalah untuk bertindak balas secara langsung kepada interaksi pengguna. Logik asasnya ialah terdapat beberapa peristiwa interaksi yang dipratentukan di bawah platform tertentu dan tindakan interaksi khusus pengguna akan mencetuskan peristiwa interaksi yang sepadan. Keseluruhan reka bentuk interaksi produk pengguna juga berdasarkan ini. Untuk mencapai pengalaman pengguna yang baik, interaksi yang mudah diperlukan.
Dalam senario PC, tetikus (pad jejak) ialah peranti input yang paling penting selain daripada operasi tetikus biasa ialah butang dan roda pada tetikus, jadi peristiwa interaksi biasa yang sepadan ialah klik , menatal. menyeret, dan interaksi ini memerlukan objek (seperti mengklik butang, menatal kawasan kandungan atau keseluruhan port pandangan, menyeret gambar) dan tidak semudah kekunci pintasan. Walau bagaimanapun, dalam senario tertentu, kekunci pintasan tidak semudah tetikus, jadi kami menjangkakan tetikus juga mempunyai operasi pintasan. Gerak isyarat tetikus ialah operasi pintasan yang agak khusus tetapi mudah dan mudah digunakan. Gerak isyarat tetikus biasa termasuk melukis garis lurus, berdetik dan melukis bulatan. Pada hari-hari awal apabila ratusan penyemak imbas berkembang pesat, banyak penyemak imbas domestik menggunakan operasi gerak isyarat tetikus yang mudah sebagai titik jualan utama untuk membezakan kelebihan daya saing mereka Pada masa itu, operasi isyarat secara beransur-ansur mendapat sokongan dan aplikasi yang meluas, secara senyap-senyap memupuk pasaran. dan tabiat pengguna.
Dalam senario skrin sentuh mudah alih, kelebihan operasi gerak isyarat telah berkembang menjadi "leret ke kiri untuk kembali", "leret ke kanan untuk ke hadapan", "leret ke atas untuk kembali ke. halaman utama", dan "leret ke bawah untuk memuat semula" /Invoke Notifications/Invoke Control Center".
Dengan peningkatan VR/AR/MR baru-baru ini, operasi gerak isyarat dalam ruang tiga dimensi telah terus dipromosikan dan digunakan.
Oleh itu, kami mengambil bahagian PC sebagai contoh untuk merealisasikan pengiktirafan gerak isyarat tetikus, menjelaskan logik pelaksanaan teras gerak isyarat interaktif, dan dengan analogi, cuba meluaskan penyelesaian kepada lebih banyak senario terminal.
Sorot dan zum tidak akan berubah bentuk, iaitu kedudukan keseluruhan dan saiz laluan gerak isyarat tidak penting.
Mempunyai toleransi tertentu terhadap gerak isyarat berulang pengguna.
Apa yang istimewa tentang masalah ini ialah pengendalian ketidakpastian Terdapat ketidakpastian dalam gerak isyarat tetikus yang dilukis oleh pengguna.
Bagi kes di mana laluan standard dipratetap, masalah diubah menjadi mengesan persamaan antara "laluan penentu pratetap" dan "laluan tidak pasti input pengguna".
Bagi kes laluan yang ditentukan pengguna, masalah diubah menjadi mengesan persamaan antara "laluan ketidakpastian yang ditetapkan oleh pengguna" dan "laluan ketidakpastian yang dimasuki oleh pengguna".
Jika anda mengikuti model pengaturcaraan tradisional, anda mesti memerlukan logik program yang ketat, menetapkan peraturan yang jelas untuk pertimbangan bersyarat dan mengukur ketidakpastian ini dengan tepat. Maksudnya, "operasi ajaib" diperlukan Menggantikan dua laluan, anda boleh mendapatkan hasil sama ada ia serupa.
Bagi gerak isyarat itu sendiri, kita boleh menganggapnya sebagai imej raster biasa atau sebagai grafik vektor. Untuk imej raster, kita boleh menggunakan kaedah pembelajaran mesin klasik untuk menentukan klasifikasi imej tanpa perlu memahami kandungan imej. Untuk grafik vektor, kita perlu menentukan struktur data khas untuk ini dan menyelidiki perwakilan persamaan grafik. Pelaksanaan kami seterusnya akan bermula daripada dua idea ini.
Pertama sekali, anda perlu mengubah cara pemikiran anda. Pengaturcaraan pembelajaran mesin sama sekali berbeza daripada pemikiran pengaturcaraan tradisional. Seperti yang dinyatakan sebentar tadi, pengaturcaraan tradisional memerlukan logik program seperti pertimbangan bersyarat, gelung dan proses lain mesti dinyatakan dengan tepat dan dikodkan secara manual. Pengaturcaraan pembelajaran mesin tidak lagi berpegang pada merumus dan menulis peraturan logik terperinci, tetapi membina rangkaian saraf untuk membolehkan komputer mempelajari ciri.
Kunci kepada pembelajaran mesin ialah set data yang besar dan boleh dipercayai. Kerja pelabelan ini sangat memakan masa untuk mengesahkan kebolehlaksanaan, kami menggunakan set data angka tulisan tangan yang serupa untuk menggantikan adegan isyarat sebenar. .
Oleh itu, langkah seterusnya kami ialah:
Terdapat banyak algoritma dan model untuk pembelajaran mesin, dan ia perlu dipilih untuk bidang yang berbeza. Tensorflow.js secara rasmi menyediakan satu siri model pra-latihan [1], yang boleh digunakan secara langsung atau dilatih semula dan digunakan.
Convolutional Neural Networks (CNN) ialah model pembelajaran mesin yang digunakan secara meluas, terutamanya apabila memproses gambar atau data lain dengan prestasi raster. Semasa pemprosesan maklumat, CNN mengambil struktur spatial baris dan lajur piksel sebagai input, mengekstrak ciri melalui berbilang lapisan pengiraan matematik, dan kemudian menukar isyarat kepada vektor ciri dan menyambungkannya kepada struktur rangkaian saraf tradisional Selepas pengekstrakan ciri , imej Vektor ciri yang sepadan adalah lebih kecil apabila diberikan kepada rangkaian saraf tradisional, dan bilangan parameter yang perlu dilatih akan dikurangkan dengan sewajarnya. Gambar rajah prinsip kerja asas rangkaian neural konvolusi adalah seperti berikut (bilangan setiap lapisan dalam rajah boleh direka bentuk mengikut keperluan):
Rangka kerja Tensorflow.js Sebab mengapa ia menjadi rangka kerja pilihan kami adalah kerana kelebihan berikut:
Kemudahalihan yang baik: Tensorflow.js bukanlah rangka kerja pembelajaran mesin yang paling popular dan cekap, tetapi kerana ia berdasarkan JS dan sedia untuk digunakan di luar kotak API digunakan, jadi ia adalah mudah untuk dijalankan dan digunakan pada pelbagai terminal yang menyokong JS.
Kependaman rendah dan privasi tinggi: Terima kasih kepada fakta bahawa ia boleh berjalan sepenuhnya pada akhirnya, tidak perlu menghantar data pengesahan ke pelayan dan menunggu pelayan membalas, sekali gus mempunyai kelebihan rendah kependaman dan keselamatan yang tinggi.
Kos pembelajaran/nyahpepijat yang rendah: Kos permulaan adalah rendah untuk pembangun WEB dan penyemak imbas boleh menggambarkan proses latihan mesin dengan baik.
Persediaan persekitaran TFJS [2] adalah sangat mudah dan tidak akan disebut di sini.
Anda boleh mengalami idea pengaturcaraan pembelajaran mesin melalui contoh mudah ini dan berkenalan dengan API Tensorflow.js.
Set data
/** * @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数据集这个经典的手写数字数据集,节约了收集手写数字的Cipta acara interaktif anda sendiri - pengecaman gerak isyarat skrin集的时间 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 像素的黑白Cipta acara interaktif anda sendiri - pengecaman gerak isyarat skrin。Cipta acara interaktif anda sendiri - pengecaman gerak isyarat skrin数据的规范格式为 [row, column, depth] inputShape: [IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_CHANNELS], // 要应用于输入数据的滑动卷积过滤器窗口的尺寸。在此示例中,我们将kernelSize设置成5,也就是指定 5x5 的卷积窗口。 kernelSize: 5, // 尺寸为 kernelSize 的过滤器窗口数量 filters: 8, // 滑动窗口的步长,即每次移动Cipta acara interaktif anda sendiri - pengecaman gerak isyarat skrin时过滤器都会移动多少像素。我们指定步长为 1,表示过滤器将以 1 像素为步长在Cipta acara interaktif anda sendiri - pengecaman gerak isyarat skrin上滑动。 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维向量,作为最后一层的输入。这是将高维数据输入给最后的分类输出层时的常见做法。 // Cipta acara interaktif anda sendiri - pengecaman gerak isyarat skrin是高维数据,而卷积运算往往会增大传入其中的数据的大小。在将数据传递到最终分类层之前,我们需要将数据展平为一个长数组。密集层(我们会用作最终层)只需要采用 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; } // 我们的目标是训练一个模型,该模型会获取一张Cipta acara interaktif anda sendiri - pengecaman gerak isyarat skrin,然后学习预测Cipta acara interaktif anda sendiri - pengecaman gerak isyarat skrin可能所属的 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();
Penyelesaian Kelebihan ialah lebih besar set data yang terlibat dalam latihan, lebih baik kesan ramalan. Kelemahannya juga jelas Pertama, pembinaan set data latihan dan set data pengesahan adalah banyak kerja Tambahan pula, walaupun latihan boleh dilakukan semasa pelayar sedang berjalan, ia masih memakan masa. Secara ringkasnya, penyelesaian ini boleh mengesan beberapa gerak isyarat yang dipratentukan, tetapi sukar untuk melatih model yang boleh mengecam gerak isyarat khusus pengguna melalui beberapa input gerak isyarat pengguna.
Bolehkah kaedah OCR untuk pengecaman tulisan tangan digunakan untuk mengecam gerak isyarat yang ditentukan pengguna?
Disebabkan oleh ketidakpastian input pengguna, input gerak isyarat oleh pengguna tidak semestinya sepadan dengan kategori tertentu yang dipratentukan bolehkah klasifikasi imej menentukan kategori "lain"?
Adakah format model aliran tensor berkaitan dengan bahasa rangka kerja? Sebagai contoh, bolehkah model yang dilatih oleh python masih digunakan dalam tensorflow.js?
Apa yang diambil daripada interaksi tetikus ialah maklumat laluan Melalui maklumat laluan ini kita boleh mengekstrak kedudukan, bentuk, arah Tunggu untuk maklumat yang lebih spesifik. Oleh itu, kita boleh merekodkan trajektori ruang-masa tetikus, dan kemudian menggunakan peraturan untuk meringkaskan ciri-ciri laluan gerak isyarat, mengukuhkannya menjadi corak yang unik boleh mengenal pasti gerak isyarat dengan kebarangkalian yang tinggi, dan kemudian membandingkan persamaan antara corak isyarat dan gerak isyarat biasa. Ambil perhatian bahawa apabila mentakrifkan struktur data laluan, anda perlu mempertimbangkan untuk mengelakkan kesan saiz dan ubah bentuk sedikit.
Pertama, laluan gerak isyarat yang dilukis oleh pengguna mesti diwakili. Kami menjelaskan prinsip asas bahawa objek dengan bentuk yang sama tetapi saiz yang berbeza harus dianggap sebagai gerak isyarat yang sama. Laluan gerak isyarat perlu diwakili oleh set vektor unit. Dalam Stroke, grafik gerak isyarat dibahagikan kepada 128 vektor, dan setiap vektor ditukar kepada vektor unit. Dengan cara ini, walaupun saiz dan panjang laluan gerak isyarat berbeza, selagi ia berstruktur sama, data yang mewakilinya adalah sama. Ini menghapuskan pengaruh saiz laluan dan sedikit ubah bentuk pada keputusan penghakiman.
Kemudian, kesamaan laluan yang diukur ditukarkan kepada kesamaan data vektor yang diukur. Persamaan antara laluan ditentukan oleh nilai khusus kuantiti geometri, jadi kami mendapati persamaan kosinus daripada kaedah klasik mengira persamaan vektor.
向量的相似度通常使用余弦相似度来度量,即计算向量夹角的余弦值。将两组数据两两对应,分成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
Atas ialah kandungan terperinci Cipta acara interaktif anda sendiri - pengecaman gerak isyarat skrin. Untuk maklumat lanjut, sila ikut artikel berkaitan lain di laman web China PHP!