三分钟训练眼球追踪术,AI就知道你在盯着哪个妹子 | TensorFlow.js代码

圆栗子 编译整理 量子位 出品 | 公众号 QbitAI

啊,老板的眼神飞过来了,还不快切回工作界面?

从前,我们几乎无从躲避来自身后的目光,但现在不一定了。

如果有个眼球追踪AI,加上人脸识别,或许就能在被老板盯上的瞬间,进入奋力工作模式。

戏是有点多。不过眼球追踪这件事,只要有电脑的前置摄像头,再有个浏览器,真的可以做到。

来自慕尼黑的程序猿Max Schumacher,就用TensorFlow.js做了一个模型,你看向屏幕的某一点,它就知道你在看的是哪一点了。

我来训练一把

这个模型叫Lookie Lookie,不用服务器,打开摄像头就可以在浏览器上训练,不出三分钟就能养成一只小AI。

在下试了一试。

摄像头拍到的画面就显示在屏幕左上角,脸上是绿色的轮廓,眼睛被一个红色方框框住。

收集数据的方式很简单,只要四处移动鼠标,眼睛跟着鼠标走,然后随时按下空格键,每按一次就采集一个数据点。

第一波,只要按20次空格,系统就提示,可以点击训练按钮了。

训练好之后,屏幕上出现一个绿圈圈。这时候,我的眼睛看哪里,绿圈圈都应该跟着我走的。

可它似乎有些犹豫。系统又提示:现在数据不太够,可能还没训练好,再取一些数据吧。

那好,再取个二三十张图,训练第二波。

果然,这次绿圈圈跑得自信了一些,左看右看它都驰骋 (比较) 如风。

相比之下,对于上下移动的目光,AI的反应似乎没有那么敏锐。大概是因为,电脑屏幕上下距离不够宽,眼球转动不充分吧。

不过,在训练数据如此贫乏的前提下,神经网络也算是茁壮成长了。

需要注意的是,收集数据的时候,脸不要离屏幕太远 (也不要倒立?) 。

DIY全攻略 (上) :架子搭起来

作为一个不需要任何服务器就能训练的模型,如果要处理整幅整幅的视频截图,负担可能有些重。

所以,还是先检测人脸,再框出眼睛所在的部分。只把这个区域 (上图右一) 交给神经网络的话,任务就轻松了。

德国少年选择了clmtrackr人脸检测模型,它的优点也是跑起来轻快。

那么,先把它下下来:

https://raw.githubusercontent.com/auduno/clmtrackr/dev/build/clmtrackr.js

然后,打开一个空的html文件,导入jQuery, TensorFlow.js,clmtrackr.js,以及main.js。代码如下:

1<!doctype html>
2<html>
3<body>
4    <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
5    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@0.12.0"></script>
6    <script src="clmtrackr.js"></script>
7    <script src="main.js"></script>
8</body>
9</html>

这样,准备活动就做好了。下面正式开始。

导出视频流

第一步,要经过你 (用户) 的同意,才能打开摄像头,渲染视频流,把画面显示在页面上。

先写这行代码 (此处默认用的是最新版本的Chrome) :

1<video id="webcam" width="400" height="300" autoplay></video>

然后从main.js开始:

1$(document).ready(function() {
2  const video = $('#webcam')[0];
3
4  function onStreaming(stream) {
5    video.srcObject = stream;
6  }
7
8  navigator.mediaDevices.getUserMedia({ video: true }).then(onStreaming);
9});

到这里,浏览器就该问你“要不要打开摄像头”了。

找到你的脸

上文提到的clmtrackr.js人脸追踪器,这里就出场。

先在const video=…下面,初始化追踪器:

1const ctrack = new clm.tracker();
2ctrack.init();

然后,在onStreaming() 里面,加下面这句话,就能让追踪器检测视频里的人脸了:

1ctrack.start(video);

写好这几行,它应该已经能看出你的脸。不相信的话,就让它描出来

这里需要一个绘图工具。用html里面的<canvas>标签,在视频上面重叠一张画布

在<video>下面,写上这一串代码:

1<canvas id="overlay" width="400" height="300"></canvas>
2<style>
3    #webcam, #overlay {
4        position: absolute;
5        top: 0;
6        left: 0;
7    }
8</style>

这样,就有了跟视频尺寸一样的画布。CSS能保证画布和视频的位置完全吻合。

浏览器每做一次渲染,我们就要在画布上画点什么了。画之前,要先把之前画过的内容擦掉。

代码长这样,写在ctrack.init() 下面:

 1const overlay = $('#overlay')[0];
 2const overlayCC = overlay.getContext('2d');
 3
 4function trackingLoop() {
 5  // Check if a face is detected, and if so, track it.
 6  requestAnimationFrame(trackingLoop);
 7
 8  let currentPosition = ctrack.getCurrentPosition();
 9  overlayCC.clearRect(0, 0, 400, 300);
10
11  if (currentPosition) {
12    ctrack.draw(overlay);
13  }
14}

现在,在onStreaming() 的ctrack.starg() 后面,调用trackingLoop() 。每一帧里,它都会重新运行。

这个时候,刷新一下浏览器,你的脸上应该有一个绿色又诡异的轮廓了。

眼睛截下来

这一步,是要在眼睛周围画个矩形框

cmltrackr很善良,除了画个轮廓之外,还有70个面部特征,我们可以选择自己需要的部分。

这里,选23、28、24、26就够了,在每个方向上,往外扩大5个像素。

然后,矩形框应该足够覆盖重要面部信息了 (不离太远、不倒立) 。

现在,再拿另外一张画布,来捕捉这个截下来的矩形。这张画布50 x 25像素即可,只要把矩形框的尺寸调一下,就能放进去:

1<canvas id="eyes" width="50" height="25"></canvas>
2<style>
3    #eyes {
4        position: absolute;
5        top: 0;
6        right: 0;
7    }
8</style>

下面这个函数,会返回 (x,y) 坐标,以及矩形的长宽。给它输入的是clmtrackr里面的位置阵列 (Position Array) :

 1function getEyesRectangle(positions) {
 2  const minX = positions[23][0] - 5;
 3  const maxX = positions[28][0] + 5;
 4  const minY = positions[24][1] - 5;
 5  const maxY = positions[26][1] + 5;
 6
 7  const width = maxX - minX;
 8  const height = maxY - minY;
 9
10  return [minX, minY, width, height];
11}

接下来,要把矩形框提取出来。具体方法是,在第一张画布上把它描成红色,再复制到第二张画布上。

替换trackingLoop() 里面的if块:

 1if (currentPosition) {
 2  // Draw facial mask on overlay canvas:
 3  ctrack.draw(overlay);
 4
 5  // Get the eyes rectangle and draw it in red:
 6  const eyesRect = getEyesRectangle(currentPosition);
 7  overlayCC.strokeStyle = 'red';
 8  overlayCC.strokeRect(eyesRect[0], eyesRect[1], eyesRect[2], eyesRect[3]);
 9
10  // The video might internally have a different size, so we need these
11  // factors to rescale the eyes rectangle before cropping:
12  const resizeFactorX = video.videoWidth / video.width;
13  const resizeFactorY = video.videoHeight / video.height;
14
15  // Crop the eyes from the video and paste them in the eyes canvas:
16  const eyesCanvas = $('#eyes')[0];
17  const eyesCC = eyesCanvas.getContext('2d');
18
19  eyesCC.drawImage(
20    video,
21    eyesRect[0] * resizeFactorX, eyesRect[1] * resizeFactorY,
22    eyesRect[2] * resizeFactorX, eyesRect[3] * resizeFactorY,
23    0, 0, eyesCanvas.width, eyesCanvas.height
24  );
25}

现在,应该看得到眼睛周围的红色矩形框了。

DIY全攻略 (下) :训练与测试

收集数据

眼球追踪,收集数据的方法其实有很多种。不过,让眼睛跟着鼠标走,是最简单的,随时按下空格都可以捕获一幅图像。

1 追踪鼠标

想知道鼠标每时每刻都在什么位置,就给document.onmousemove加上一个EventListener。

这样做还可以把坐标归一化 (转化到 [-1, 1] 的范围里) :

 1// Track mouse movement:
 2const mouse = {
 3  x: 0,
 4  y: 0,
 5
 6  handleMouseMove: function(event) {
 7    // Get the mouse position and normalize it to [-1, 1]
 8    mouse.x = (event.clientX / $(window).width()) * 2 - 1;
 9    mouse.y = (event.clientY / $(window).height()) * 2 - 1;
10  },
11}
12
13document.onmousemove = mouse.handleMouseMove;

2 捕捉图像

这里要做的是,按下空格键之后的任务:从画布上捕捉图像,储存为张量。

TensorFlow.js提供了一个助手函数,叫tf.fromPixels() ,只要用它来储存第二张画布里走出的图像,然后归一化:

 1function getImage() {
 2  // Capture the current image in the eyes canvas as a tensor.
 3  return tf.tidy(function() {
 4    const image = tf.fromPixels($('#eyes')[0]);
 5    // Add a batch dimension:
 6    const batchedImage = image.expandDims(0);
 7    // Normalize and return it:
 8    return batchedImage.toFloat().div(tf.scalar(127)).sub(tf.scalar(1));
 9  });
10}

注意注意,虽然把所有数据做成一个大训练集也是可以的,但还是留一部分做验证集比较科学,比如20%。

这样,便与检测模型的性能,以及确认它没有过拟合

以下是添加新数据点用的代码:

 1const dataset = {
 2  train: {
 3    n: 0,
 4    x: null,
 5    y: null,
 6  },
 7  val: {
 8    n: 0,
 9    x: null,
10    y: null,
11  },
12}
13
14function captureExample() {
15  // Take the latest image from the eyes canvas and add it to our dataset.
16  tf.tidy(function() {
17    const image = getImage();
18    const mousePos = tf.tensor1d([mouse.x, mouse.y]).expandDims(0);
19
20    // Choose whether to add it to training (80%) or validation (20%) set:
21    const subset = dataset[Math.random() > 0.2 ? 'train' : 'val'];
22
23    if (subset.x == null) {
24      // Create new tensors
25      subset.x = tf.keep(image);
26      subset.y = tf.keep(mousePos);
27    } else {
28      // Concatenate it to existing tensors
29      const oldX = subset.x;
30      const oldY = subset.y;
31
32      subset.x = tf.keep(oldX.concat(image, 0));
33      subset.y = tf.keep(oldY.concat(mousePos, 0));
34    }
35
36    // Increase counter
37    subset.n += 1;
38  });
39}

最后,把空格键关联进来:

1$('body').keyup(function(event) {
2  // On space key:
3  if (event.keyCode == 32) {
4    captureExample();
5
6    event.preventDefault();
7    return false;
8  }
9});

至此,只要你按下空格,数据集里就会增加一个数据点了。

训练模型

就搭个最简单的CNN吧。

TensorFlow.js里面有一个和Keras很相似的API可以用。

这个网络里,要有一个卷积层,一个最大池化,还要有个密集层,带两个输出值 (坐标) 的那种。

中间,加了一个dropout作为正则化器;还有,用flatten把2D数据降成1D。训练用的是Adam优化器。

模型代码长这样:

 1let currentModel;
 2
 3function createModel() {
 4  const model = tf.sequential();
 5
 6  model.add(tf.layers.conv2d({
 7    kernelSize: 5,
 8    filters: 20,
 9    strides: 1,
10    activation: 'relu',
11    inputShape: [$('#eyes').height(), $('#eyes').width(), 3],
12  }));
13
14  model.add(tf.layers.maxPooling2d({
15    poolSize: [2, 2],
16    strides: [2, 2],
17  }));
18
19  model.add(tf.layers.flatten());
20
21  model.add(tf.layers.dropout(0.2));
22
23  // Two output values x and y
24  model.add(tf.layers.dense({
25    units: 2,
26    activation: 'tanh',
27  }));
28
29  // Use ADAM optimizer with learning rate of 0.0005 and MSE loss
30  model.compile({
31    optimizer: tf.train.adam(0.0005),
32    loss: 'meanSquaredError',
33  });
34
35  return model;
36}

训练开始之前,要先设置一个固定的epoch数,再把批尺寸设成变量 (因为数据集很小) :

 1function fitModel() {
 2  let batchSize = Math.floor(dataset.train.n * 0.1);
 3  if (batchSize < 4) {
 4    batchSize = 4;
 5  } else if (batchSize > 64) {
 6    batchSize = 64;
 7  }
 8
 9  if (currentModel == null) {
10    currentModel = createModel();
11  }
12
13  currentModel.fit(dataset.train.x, dataset.train.y, {
14    batchSize: batchSize,
15    epochs: 20,
16    shuffle: true,
17    validationData: [dataset.val.x, dataset.val.y],
18  });
19}

然后,在页面上做个训练按钮吧:

 1<button id="train">Train!</button>
 2<style>
 3    #train {
 4        position: absolute;
 5        top: 50%;
 6        left: 50%;
 7        transform: translate(-50%, -50%);
 8        font-size: 24pt;
 9    }
10</style>

还有JS:

 1<button id="train">Train!</button>
 2<style>
 3    #train {
 4        position: absolute;
 5        top: 50%;
 6        left: 50%;
 7        transform: translate(-50%, -50%);
 8        font-size: 24pt;
 9    }
10</style>

拉出来遛遛

绿色圈圈终于来了。AI判断你在看哪,它就出现在哪。

先写绿圈圈:

 1<div id="target"></div>
 2<style>
 3    #target {
 4        background-color: lightgreen;
 5        position: absolute;
 6        border-radius: 50%;
 7        height: 40px;
 8        width: 40px;
 9        transition: all 0.1s ease;
10        box-shadow: 0 0 20px 10px white;
11        border: 4px solid rgba(0,0,0,0.5);
12    }
13</style>

然后,想让绿圈圈动起来,就要定期把眼睛图像传给神经网络。问它你在看哪,它就回答一个坐标:

 1function moveTarget() {
 2  if (currentModel == null) {
 3    return;
 4  }
 5  tf.tidy(function() {
 6    const image = getImage();
 7    const prediction = currentModel.predict(image);
 8
 9    // Convert normalized position back to screen position:
10    const targetWidth = $('#target').outerWidth();
11    const targetHeight = $('#target').outerHeight();
12    const x = (prediction.get(0, 0) + 1) / 2 * ($(window).width() - targetWidth);
13    const y = (prediction.get(0, 1) + 1) / 2 * ($(window).height() - targetHeight);
14
15    // Move target there:
16    const $target = $('#target');
17    $target.css('left', x + 'px');
18    $target.css('top', y + 'px');
19  });
20}
21
22setInterval(moveTarget, 100);

间隔设的是100毫秒,不过也可以改的。

总之,大功告成。

鼻孔眼睛分不清?

眼球追踪模型很有意思,不过还是有一些可爱的缺陷。

比如,算法还只能识别正面,脸稍微侧一点AI就会困惑。

比如,有时候会把鼻孔识别成眼睛。

比如,必须整张脸都出现在画面里,才能识别眼睛的所在,捂住嘴也不行。

来自怪异君

Max也说,还有很多可以探索的空间。

自己训练传送门: https://cpury.github.io/lookie-lookie/

代码实现传送门: https://github.com/cpury/lookie-lookie

教程原文传送门: https://cpury.github.io/learning-where-you-are-looking-at/

原文发布于微信公众号 - 量子位(QbitAI)

原文发表时间:2018-09-10

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏拂晓风起

Flash:利用Bitmapdata,ColorTransform,DrawPath,制造绚丽效果

1032
来自专栏逍遥剑客的游戏开发

GDC2016: Animating With Math

2768
来自专栏机器学习养成记

十九大讲话文本分析(R语言)

18日观看了十九大的开幕直播,聆听了习大大的重要讲话,如此重要的讲话,怎能不结合我们的文本挖掘技术来深刻学习一下呢!这次的文章就让我们用R里面的jiebaR包和...

3187
来自专栏阿凯的Excel

帕累托图(主次图)绘制方法(Excel绘制图表系列课程)

有朋友让我快点、马上、立刻、最先分享帕累托图的绘制方法。什么是帕累托图?主要想表达何种含义呢?让我们慢慢聊。 帕累托图(Pareto chart)由来 是以意大...

2995
来自专栏周明礼的专栏

Threejs 快速入门

在什么都是3D,看电影3D,打游戏3D,估计3D打车,很快就会面世。那么作为前端开发的标准语言,JS和3D能不能也搞出点大新闻呢?刚好最近在做一个活动时,就遇到...

3K2
来自专栏天天P图攻城狮

终端图像处理系列 - 图像混合模式的Shader实现

在图像处理应用中,将两张或者多张图片混合显示是非常常见的一种操作,应用场景包括但不限于:加水印、标签,插入画中画,遮盖等等...

1K17
来自专栏算法+

半径无关快速高斯模糊实现(附完整C代码)

之前,俺也发过不少快速高斯模糊算法. 俺一般认为,只要处理一千六百万像素彩色图片,在2.2GHz的CPU上单核单线程超过1秒的算法,都是不快的. 之前发的几个算...

6748
来自专栏WOLFRAM

用Wolfram语言玩转&我的世界&(Minecraft)

不久前,我分享了一个软件包,用于从安装在 Raspberry Pi或另一台计算机上的 Mathematica 控制 Pi 版的 Minecraft。 您可以使用...

2032
来自专栏Renderbus云渲染农场

vray渲染速度慢的影响因素和提升技巧-Renderbus

模型因素 较为复杂的模型(特别是存在较多细小转角的模型),会耗费更多的渲染计算时间。模型的复杂程度对渲染的影响较大,这个问题可以说是“硬件伤”。

2284
来自专栏数据小魔方

数据地图多图层对象的颜色标度重叠问题解决方案

一篇旧文,解决一个困扰已经的小技术问题,权当是学习ggplot2以来的整理回顾与查漏补缺。 ---- 今天这一篇是昨天推送的基础上进行了进一步的深化,主要讲如何...

3605

扫码关注云+社区