本文將闡述如何通過 Web 技術實現簡易的運動監測效果,並附上一些有意思的案例。
移動偵測,英文翻譯為“Motion detection technology”,一般也叫運動檢測,常用於無人值守監控錄影和自動報警。通過攝像頭按照不同幀率採集得到的影像會被 CPU 按照一定演算法進行計算和比較,當畫面有變化時,如有人走過,鏡頭被移動,計算比較結果得出的數字會超過閾值並指示系統能自動作出相應的處理。——百度百科
由上述引用語句可得出“移動監測”需要以下要素:
- 一個擁有美拍功能的“攝像頭”(啪啪啪)
- 用於判斷移動的演算法(這部分交給我)
- 移動後的處理(你說了算)
注:本文涉及的所有案例均基於 PC/Mac 較新版本的 Chrome / Firefox 瀏覽器。
對方不想和你說話,並向你扔來一個連結:
該案例有以下兩個功能:
- 拍好 POST 後的 1 秒會進行拍照
- 靜止 1 秒後音樂會停止,產生移動會恢復播放狀態
上述案例也許並不能直接體現出『移動監測』的實際效果和原理,下面再看看這個案例。
案例的左側是視訊源,而右側則是移動後的畫素處理(畫素化、判斷移動和只保留綠色等)。
因為是基於 Web 技術,所以視訊源採用 WebRTC,畫素處理則採用 Canvas。
視訊源
不依賴 Flash 或 Silverlight,我們使用 WebRTC (Web Real-Time Communications) 中的 navigator.getUserMedia() API,該 API 允許 Web 應用獲取使用者的攝像頭與麥克風流(stream)。
示例程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<!-- 若不加 autoplay,則會停留在第一幀 --> <video id="video" autoplay></video> // 具體引數含義可看相關文件。 const constraints = { audio: false, video: { width: 640, height: 480 } } navigator.mediaDevices.getUserMedia(constraints) .then(stream => { // 將視訊源展示在 video 中 video.srcObject = stream }) .catch(err => { console.log(err) }) |
對於相容性問題,Safari 11 開始支援 WebRTC 了。具體可檢視 caniuse。
畫素處理
在得到視訊源後,我們就有了判斷物體是否移動的素材。當然,這裡並沒有採用什麼高深的識別演算法,只是利用連續兩幀截圖的畫素差異來判斷物體是否發生移動(嚴格來說,是畫面的變化)。
截圖
獲取視訊源截圖的示例程式碼:
1 2 3 4 5 6 7 8 9 10 |
const video = document.getElementById('video') const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') canvas.width = 640 canvas.height = 480 // 獲取視訊中的一幀 function capture () { ctx.drawImage(video, 0, 0, canvas.width, canvas.height) // ...其它操作 } |
得出截圖間的差異
對於兩張圖的畫素差異,在 凹凸實驗室 的 《“等一下,我碰!”——常見的2D碰撞檢測》 這篇博文中所提及的“畫素檢測”碰撞演算法是解決辦法之一。該演算法是通過遍歷兩個離屏畫布(offscreen canvas)同一位置的畫素點的透明度是否同時大於 0,來判斷碰撞與否。當然,這裡要改為『同一位置的畫素點是否不同(或差異小於某閾值)』來判斷移動與否。
但上述方式稍顯麻煩和低效,這裡我們採用 ctx.globalCompositeOperation = 'difference'
指定畫布新增元素(即第二張截圖與第一張截圖)的合成方式,得出兩張截圖的差異部分。
示例程式碼:
1 2 3 4 5 6 7 8 9 10 11 |
function diffTwoImage () { // 設定新增元素的合成方式 ctx.globalCompositeOperation = 'difference' // 清除畫布 ctx.clearRect(0, 0, canvas.width, canvas.height) // 假設兩張影像尺寸相等 ctx.drawImage(firstImg, 0, 0) ctx.drawImage(secondImg, 0, 0) } |
體驗上述案例後,是否有種當年玩“QQ遊戲《大家來找茬》”的感覺。另外,這個案例可能還適用於以下兩種情況:
- 當你不知道設計師前後兩次給你的設計稿有何差異時
- 想檢視兩個瀏覽器對同一個網頁的渲染有何差異時
何時為一個“動作”
由上述“兩張影像差異”的案例中可得:黑色代表該位置上的畫素未發生改變,而畫素越明亮則代表該點的“動作”越大。因此,當連續兩幀截圖合成後有明亮的畫素存在時,即為一個“動作”的產生。但為了讓程式不那麼“敏感”,我們可以設定一個閾值。當明亮畫素的個數大於該閾值時,才認為產生了一個“動作”。當然,我們也可以剔除“不足夠明亮”的畫素,以儘可能避免外界環境(如燈光等)的影響。
想要獲取 Canvas 的畫素資訊,需要通過 ctx.getImageData(sx, sy, sw, sh)
,該 API 會返回你所指定畫布區域的畫素物件。該物件包含 data
、width
、height
。其中 data
是一個含有每個畫素點 rgba 資訊的一維陣列,如下圖所示。
獲取到特定區域的畫素後,我們就能對每個畫素進行處理(如各種濾鏡效果)。處理完後,則可通過 ctx.putImageData()
將其渲染在指定的 Canvas 上。
擴充套件:由於 Canvas 目前沒有提供“歷史記錄”的功能,如需實現“返回上一步”操作,則可通過 getImageData 儲存上一步操作,當需要時則可通過 putImageData 進行復原。
示例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
let imageScore = 0 const rgba = imageData.data for (let i = 0; i < rgba.length; i += 4) { const r = rgba[i] / 3 const g = rgba[i + 1] / 3 const b = rgba[i + 2] / 3 const pixelScore = r + g + b // 如果該畫素足夠明亮 if (pixelScore >= PIXEL_SCORE_THRESHOLD) { imageScore++ } } // 如果明亮的畫素數量滿足一定條件 if (imageScore >= IMAGE_SCORE_THRESHOLD) { // 產生了移動 } |
在上述案例中,你也許會注意到畫面是『綠色』的。其實,我們只需將每個畫素的紅和藍設定為 0,即將 rgba 的 r = 0; b = 0
即可。這樣就會像電影的某些鏡頭一樣,增加了科技感和神祕感。
示例程式碼:
1 2 3 4 5 6 |
const rgba = imageData.data for (let i = 0; i < rgba.length; i += 4) { rgba[i] = 0 // red rgba[i + 2] = 0 // blue } ctx.putImageData(imageData, 0, 0) |
將 rgba 中的 r 和 b 置為 0
跟蹤“移動物體”
有了明亮的畫素後,我們可以取其最左上角和最右下角的兩點,繪製成一個能包圍所有明亮畫素的矩形。這樣就可以實現跟蹤移動物體的效果了。
示例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
function processDiff (imageData) { const rgba = imageData.data let score = 0 let pixelScore = 0 let motionBox = 0 // 遍歷整個 canvas 的畫素,以找出明亮的點 for (let i = 0; i < rgba.length; i += 4) { pixelScore = (rgba[i] + rgba[i+1] + rgba[i+2]) / 3 // 若該畫素足夠明亮 if (pixelScore >= 80) { score++ coord = calcCoord(i) motionBox = calcMotionBox(montionBox, coord.x, coord.y) } } return { score, motionBox } } // 得到左上角和右下角兩個座標值 function calcMotionBox (curMotionBox, x, y) { const motionBox = curMotionBox || { x: { min: coord.x, max: x }, y: { min: coord.y, max: y } } motionBox.x.min = Math.min(motionBox.x.min, x) motionBox.x.max = Math.max(motionBox.x.max, x) motionBox.y.min = Math.min(motionBox.y.min, y) motionBox.y.max = Math.max(motionBox.y.max, y) return motionBox } // imageData.data 是一個含有每個畫素點 rgba 資訊的一維陣列。 // 該函式是將上述一維陣列的任意下標轉為 (x,y) 二維座標。 function calcCoord(i) { return { x: (i / 4) % diffWidth, y: Math.floor((i / 4) / diffWidth) } } |
在得到左上角和右下角的座標值後,通過 ctx.strokeRect(x, y, width, height)
API。 繪製出矩形即可。
1 2 3 4 5 6 7 |
ctx.lineWidth = 6 ctx.strokeRect( diff.motionBox.x.min + 0.5, diff.motionBox.y.min + 0.5, diff.motionBox.x.max - diff.motionBox.x.min, diff.motionBox.y.max - diff.motionBox.y.min ) |
這是理想效果,實際效果請開啟 體驗連結
擴充套件:為什麼上述繪製矩形的程式碼中的
x、y
要加0.5
呢?一圖勝千言:
效能
縮小尺寸
在上一個章節提到,我們需要通過對 Canvas 每個畫素進行處理,假設 Canvas 的寬為 640
,高為 480
,那麼就需要遍歷 640 * 480 = 307200
個畫素。而在監測效果可接受的前提下,我們可以將需要進行畫素處理的 Canvas 縮小尺寸,如縮小 10 倍。這樣需要遍歷的畫素數量就降低 100
倍,從而提升效能。
示例程式碼:
1 2 3 4 5 6 |
const motionCanvas // 展示給使用者看 const backgroundCanvas // offscreen canvas 背後處理資料 motionCanvas.width = 640 motionCanvas.height = 48 backgroundCanvas.width = 64 backgroundCanvas.height = 48 |
尺寸縮小 10 倍
定時器
我們都知道,當遊戲以『每秒60幀』執行時才能保證一定的體驗。但對於我們目前的案例來說,幀率並不是我們追求的第一位。因此,每 100 毫秒(具體數值取決於實際情況)取當前幀與前一幀進行比較即可。
另外,因為我們的動作一般具有連貫性,所以可取該連貫動作中幅度最大的(即“分數”最高)或最後一幀動作進行處理即可(如儲存到本地或分享到朋友圈)。
延伸
至此,用 Web 技術實現簡易的“移動監測”效果已基本講述完畢。由於演算法、裝置等因素的限制,該效果只能以 2D 畫面為基礎來判斷物體是否發生“移動”。而微軟的 Xbox、索尼的 PS、任天堂的 Wii 等遊戲裝置上的體感遊戲則依賴於硬體。以微軟的 Kinect 為例,它為開發者提供了可跟蹤最多六個完整骨骼和每人 25 個關節等強大功能。利用這些詳細的人體引數,我們就能實現各種隔空的『手勢操作』,如畫圈圈詛咒某人。
下面幾個是通過 Web 使用 Kinect 的庫:
- DepthJS:以瀏覽器外掛形式提供資料訪問。
- Node-Kinect2: 以 Nodejs 搭建伺服器端,提供資料比較完整,例項較多。
- ZigFu:支援 H5、U3D、Flash,API較為完整。
- Kinect-HTML5:Kinect-HTML5 用 C# 搭建服務端,提供色彩資料、深度資料和骨骼資料。
通過 Node-Kinect2 獲取骨骼資料
文章至此就真的要結束了,如果你想知道更多玩法,請關注 凹凸實驗室。同時,也希望大家發掘更多玩法。