三分鐘訓練眼球追蹤術,AI就知道你在看哪個妹子
圓栗子 編譯整理
量子位 出品 | 公眾號 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/[email protected]"></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)對話界面,回復「招聘」兩個字。
量子位 QbitAI· 頭條號簽約作者
?? ? 追蹤AI技術和產品新動態
推薦閱讀:
TAG:機器學習 | TensorFlow | 眼球追蹤 |
