很多開發的童鞋都是只身混江湖、夜宿城中村,如果居住的地方安保欠缺,那么出門在外難免擔心屋里的財產安全。
事實上世面上有很多高大上的防盜設備,但對於機智的前端童鞋來說,只要有一台附帶攝像頭的電腦,就可以簡單地實現一個防盜監控系統~
純 JS 的“防盜”能力很大程度借助於 H5 canvas 的力量,且非常有意思。如果你對 canvas 還不熟悉,可以先點這里閱讀我的系列教程。
step1. 調用攝像頭
我們需要先在瀏覽器上訪問和調用攝像頭,用來監控屋子里的一舉一動。不同瀏覽器中調用攝像頭的 API 都略有出入,在這里我們以 chrome 做示例:
<video width="640" height="480" autoplay></video> <script> var video = document.querySelector('video'); navigator.webkitGetUserMedia({ video: true }, success, error); function success(stream) { video.src = window.webkitURL.createObjectURL(stream); video.play(); } function error(err) { alert('video error: ' + err) } </script>
運行頁面后,瀏覽器出於安全性考慮,會詢問是否允許當前頁面訪問你的攝像頭設備,點擊“允許”后便能直接在 <video> 上看到攝像頭捕獲到的畫面了:
step2. 捕獲 video 幀畫面
光是開着攝像頭監視房間可沒有任何意義,瀏覽器不會幫你對監控畫面進行分析。所以這里我們得手動用腳本捕獲 video 上的幀畫面,用於在后續進行數據分析。
從這里開始咱們就要借助 canvas 力量了。在 Canvas入門(五)一文我們介紹過 ctx.drawImage() 方法,通過它可以捕獲 video 幀畫面並渲染到畫布上。
我們需要創建一個畫布,然后這么寫:
<video width="640" height="480" autoplay></video> <canvas width="640" height="480"></canvas> <script> var video = document.querySelector('video'); var canvas = document.querySelector('canvas'); // video捕獲攝像頭畫面 navigator.webkitGetUserMedia({ video: true }, success, error); function success(stream) { video.src = window.webkitURL.createObjectURL(stream); video.play(); } function error(err) { alert('video error: ' + err) } //canvas var context = canvas.getContext('2d'); setTimeout(function(){ //把當前視頻幀內容渲染到畫布上 context.drawImage(video, 0, 0, 640, 480); }, 5000); </script>
如上代碼所示,5秒后把視頻幀內容渲染到畫布上(下方右圖):
step3. 對捕獲的兩個幀畫面執行差異混合
在上面我們提到過,要有效地識別某個場景,需要對視頻畫面進行數據分析。
那么要怎么識別咱們的房子是否有人突然闖入了呢?答案很簡單 —— 定時地捕獲 video 畫面,然后對比前后兩幀內容是否存在較大變化。
我們先簡單地寫一個定時捕獲的方法,並將捕獲到的幀數據存起來:
//canvas var context = canvas.getContext('2d'); var preFrame, //前一幀 curFrame; //當前幀 //捕獲並保存幀內容 function captureAndSaveFrame(){ console.log(context); preFrame = curFrame; context.drawImage(video, 0, 0, 640, 480); curFrame = canvas.toDataURL; //轉為base64並保存 } //定時捕獲 function timer(delta){ setTimeout(function(){ captureAndSaveFrame(); timer(delta) }, delta || 500); } timer();
如上代碼所示,畫布會每隔500毫秒捕獲並渲染一次 video 的幀內容(夭壽哇,做完這個動作不小心把餅干灑了一地。。。\("▔□▔)/):
留意這里我們使用了 canvas.toDataURL 方法來保存幀畫面。
接着就是數據分析處理了,我們可以通過對比前后捕獲的幀畫面來判斷攝像頭是否監控到變化,那么怎么做呢?
熟悉設計的同學肯定常常使用一個圖層功能 —— 混合模式:
當有兩個圖層時,對頂層圖層設置“差值/Difference”的混合模式,可以一目了然地看到兩個圖層的差異:
“圖A”是我去年在公司樓下拍的照片,然后我把它稍微調亮了一點點,並在上面畫了一個 X 和 O 得到“圖B”。接着我把它們以“差值”模式混合在一起,得到了最右的這張圖。
“差值”模式原理:要混合圖層雙方的RGB值中每個值分別進行比較,用高值減去低值作為合成后的顏色,通常用白色圖層合成一圖像時,可以得到負片效果的反相圖像。用黑色的話不發生任何變化(黑色亮度最低,下層顏色減去最小顏色值0,結果和原來一樣),而用白色會得到反相效果(下層顏色被減去,得到補值),其它顏色則基於它們的亮度水平
在CSS3中,已經有 blend-mode 特性來支持這個有趣的混合模式,不過我們發現,在主流瀏覽器上,canvas 的 globalCompositeOperation 接口也已經良好支持了圖像混合模式:
於是我們再建多一個畫布來展示前后兩幀差異:
<video width="640" height="480" autoplay></video> <canvas width="640" height="480"></canvas> <canvas width="640" height="480"></canvas> <script> var video = document.querySelector('video'); var canvas = document.querySelectorAll('canvas')[0]; var canvasForDiff = document.querySelectorAll('canvas')[1]; // video捕獲攝像頭畫面 navigator.webkitGetUserMedia({ video: true }, success, error); function success(stream) { video.src = window.URL.createObjectURL(stream); video.play(); } function error(err) { alert('video error: ' + err) } //canvas var context = canvas.getContext('2d'), diffCtx = canvasForDiff.getContext('2d'); //將第二個畫布混合模式設為“差異” diffCtx.globalCompositeOperation = 'difference'; var preFrame, //前一幀 curFrame; //當前幀 //捕獲並保存幀內容 function captureAndSaveFrame(){ preFrame = curFrame; context.drawImage(video, 0, 0, 640, 480); curFrame = canvas.toDataURL(); //轉為base64並保存 } //繪制base64圖像到畫布上 function drawImg(src, ctx){ ctx = ctx || diffCtx; var img = new Image(); img.src = src; ctx.drawImage(img, 0, 0, 640, 480); } //渲染前后兩幀差異 function renderDiff(){ if(!preFrame || !curFrame) return; diffCtx.clearRect(0, 0, 640, 480); drawImg(preFrame); drawImg(curFrame); } //定時捕獲 function timer(delta){ setTimeout(function(){ captureAndSaveFrame(); renderDiff(); timer(delta) }, delta || 500); } timer(); </script>
效果如下(夭壽啊,做完這個動作我又把雪碧灑在鍵盤上了。。。(#--)/ ):
可以看到,當前后兩幀差異不大時,第三個畫布幾乎是黑乎乎的一片,只有當攝像頭捕獲到動作了,第三個畫布才有明顯的高亮內容出現。
因此,我們只需要對第三個畫布渲染后的圖像進行像素分析——判斷其高亮閾值是否達到某個指定預期:
var context = canvas.getContext('2d'), diffCtx = canvasForDiff.getContext('2d'); //將第二個畫布混合模式設為“差異” diffCtx.globalCompositeOperation = 'difference'; var preFrame, //前一幀 curFrame; //當前幀 var diffFrame; //存放差異幀的imageData //捕獲並保存幀內容 function captureAndSaveFrame(){ preFrame = curFrame; context.drawImage(video, 0, 0, 640, 480); curFrame = canvas.toDataURL(); //轉為base64並保存 } //繪制base64圖像到畫布上 function drawImg(src, ctx){ ctx = ctx || diffCtx; var img = new Image(); img.src = src; ctx.drawImage(img, 0, 0, 640, 480); } //渲染前后兩幀差異 function renderDiff(){ if(!preFrame || !curFrame) return; diffCtx.clearRect(0, 0, 640, 480); drawImg(preFrame); drawImg(curFrame); diffFrame = diffCtx.getImageData( 0, 0, 640, 480 ); //捕獲差異幀的imageData對象 } //計算差異 function calcDiff(){ if(!diffFrame) return 0; var cache = arguments.callee, count = 0; cache.total = cache.total || 0; //整個畫布都是白色時所有像素的值的總和 for (var i = 0, l = diffFrame.width * diffFrame.height * 4; i < l; i += 4) { count += diffFrame.data[i] + diffFrame.data[i + 1] + diffFrame.data[i + 2]; if(!cache.isLoopEver){ //只需在第一次循環里執行 cache.total += 255 * 3; //單個白色像素值 } } cache.isLoopEver = true; count *= 3; //亮度放大 //返回“差異畫布高亮部分像素總值”占“畫布全亮情況像素總值”的比例 return Number(count/cache.total).toFixed(2); } //定時捕獲 function timer(delta){ setTimeout(function(){ captureAndSaveFrame(); renderDiff(); setTimeout(function(){ console.log(calcDiff()); }, 10); timer(delta) }, delta || 500); } timer();
注意這里我們使用了 count *= 3 來放大差異高亮像素的亮度值,不然得出的數值實在太小了。我們運行下頁面(圖片較大加載會有點慢):
經過試(xia)驗(bai),個人覺得如果 calcDiff() 返回的比值如果大於 0.20,那么就可以定性為“一間空屋子,突然有人闖進來”的情況了。
step4. 上報異常圖片
當上述的計算發現有狀況時,需要有某種途徑通知我們。有錢有精力的話可以部署個郵件服務器,直接發郵件甚至短信通知到自己,but 本文走的吃吐少年路線,就不搞的那么高端了。
那么要如何簡單地實現異常圖片的上報呢?我暫且想到的是 —— 直接把問題圖片發送到某個站點中去。
這里我們選擇博客園的“日記”功能,它可以隨意上傳相關內容。
p.s.,其實這里原本是想直接把圖片傳到博客園相冊上的,可惜POST請求的圖片實體要求走 file 格式,即無法通過腳本更改文件的 input[type=file],轉 Blob 再上傳也沒用,只好作罷。
糾正上述p.s.內容~ 后續發現 formData.append 支持第三個參數作為 filename 屬性,所以其實是可以轉 blob 上傳的。
我們在管理后台創建日記時,通過 Fiddler 抓包可以看到其請求參數非常簡單:
從而可以直接構造一個請求:
//異常圖片上傳處理 function submit(){ //ajax 提交form $.ajax({ url : 'http://i.cnblogs.com/EditDiary.aspx?opt=1', type : "POST", data : { '__VIEWSTATE': '', '__VIEWSTATEGENERATOR': '4773056F', 'Editor$Edit$txbTitle': '告警' + Date.now(), 'Editor$Edit$EditorBody': '<img src="' + curFrame + '" />', 'Editor$Edit$lkbPost': '保存' }, success: function(){ console.log('submit done') } }); }
當然如果請求頁面跟博客園域名不同,是無法發送 cookie 導致請求跨域而失效,不過這個很好解決,直接修改 host 即可(怎么修改就不介紹了,自行百度吧)。
我這邊改完 host,通過 http://i.cnblogs.com/h5monitor/final.html 的地址訪問頁面,發現攝像頭竟然失效了~
通過谷歌的文檔可以得知,這是為了安全性考慮,非 HTTPS 的服務端請求都不能接入攝像頭。不過解決辦法也是有的,以 window 系統為例,打開 cmd 命令行面板並定位到 chrome 安裝文件夾下,然后執行:
chrome --unsafely-treat-insecure-origin-as-secure="http://i.cnblogs.com/h5monitor/final.html" --user-data-dir=C:\testprofile
此舉將以沙箱模式打開一個獨立的 chrome 進程,並對指定的站點去掉安全限制。注意咱們在新開的 chrome 中得重新登錄博客園。
這時候便能正常訪問攝像頭了,我們對代碼做下處理,當差異檢測發現異常時,創建一份日記,最小間隔時間為5秒(不過后來發現沒必要,因為博客園已經有做了時間限制,差不多10秒后才能發布新的日記):
//定時捕獲 function timer(delta){ setTimeout(function(){ captureAndSaveFrame(); renderDiff(); if(calcDiff() > 0.2){ //監控到異常,發日志 submit() } timer(delta) }, delta || 500); } setTimeout(timer, 60000 * 10); //設定打開頁面十分鍾后才開始監控 //異常圖片上傳處理 function submit(){ var cache = arguments.callee, now = Date.now(); if(cache.reqTime && (now - cache.reqTime < 5000)) return; //日記創建最小間隔為5秒 cache.reqTime = now; //ajax 提交form $.ajax({ url : 'http://i.cnblogs.com/EditDiary.aspx?opt=1', type : "POST", timeout : 5000, data : { '__VIEWSTATE': '', '__VIEWSTATEGENERATOR': '4773056F', 'Editor$Edit$txbTitle': '告警' + Date.now(), 'Editor$Edit$EditorBody': '<img src="' + curFrame + '" />', 'Editor$Edit$lkbPost': '保存' }, success: function(){ console.log('submit done') }, error: function(err){ cache.reqTime = 0; console.log('error: ' + err) } }); }
執行效果:
日記也是妥妥的出來了:
點開就能看到異常的那張圖片了:
要留意的是,博客園對日記發布數量是有做每日額度限制來防刷的,達到限額的話會導致當天的隨筆和文章也無法發布,所以得謹慎使用:
不過這種形式僅能上報異常圖片,暫時無法讓我們及時收悉告警,有興趣的童鞋可以試着再寫個 chrome 插件,定時去拉取日記列表做判斷,如果有新增日記則觸發頁面 alert。
另外我們當然希望能直接對闖入者進行警告,這塊比較好辦 —— 搞個警示的音頻,在異常的時候觸發播放即可:
//播放音頻 function fireAlarm(){ audio.play() } //定時捕獲 function timer(delta){ setTimeout(function(){ captureAndSaveFrame(); if(preFrame && curFrame){ renderDiff(); if(calcDiff() > 0.2){ //監控到異常 //發日記 submit(); //播放音頻告警 fireAlarm(); } } timer(delta) }, delta || 500); } setTimeout(timer, 60000 * 10); //設定打開頁面十分鍾后才開始監控
最后說一下,本文代碼均掛在我的github上,有興趣的童鞋可以自助下載。共勉~