├── .gitignore ├── .jscsrc ├── README.md ├── app ├── components │ ├── GridDetect.js │ ├── GridDetectWorker.js │ ├── MotionDetect.js │ └── Util.js └── index.js ├── package.json ├── public ├── bundle.js ├── index.html └── styles.css ├── resources └── styles │ ├── _variables.scss │ ├── bootstrap.scss │ └── style.scss ├── webpack.config.js └── webpack.config.prod.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.orig 3 | /*.log 4 | /node_modules 5 | /dist 6 | /bower_components 7 | /*.sublime-workspace 8 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "airbnb", 3 | "requireCurlyBraces": false, 4 | "validateIndentation": 4 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Motion Detector 2 | 3 | ![motion detector](http://i.imgur.com/db4bQPw.gif) 4 | 5 | A simple motion detection library in JavaScript. 6 | 7 | ## Usage 8 | 9 | ```javascript 10 | 11 | // defaults 12 | var options = { 13 | fps: 30, // how many frames per second to capture 14 | gridSize: { // size of grid to detect on frame 15 | x: 6, // rows 16 | y: 4 // cols 17 | }, 18 | pixelDiffThreshold: 0.4, // 0-1. how different pixels have to be to be considered a change 19 | movementThreshold: 0.001, // 0-1. how much of a difference must there be in a 20 | // grid cell for it to be considered movement 21 | debug: false // enable pausing on keypress for debugging 22 | canvasOutputElem: // canvas element to show video frames to, if desired 23 | } 24 | 25 | // video element to stream webcam input into 26 | var videoElemSrcId = document.getElementById('video-src'); 27 | 28 | var md = new MotionDetect(videoElemSrcId, options); 29 | 30 | // ctx is context of canvas frames were drawn on 31 | md.onDetect(function(ctx, data){ 32 | // data.motions is a 1D array of grid by row, with 33 | // intensity of movement for each grid cell from 0-255 34 | // e.g. a frame with a lot of movement on 35 | // the leftmost column would look something like this 36 | // [0, 0, 0, 0, 138, 1, 37 | // 0, 0, 0, 0, 0, 7, 38 | // 0, 0, 2, 0, 0, 186, 39 | // 0, 0, 0, 0, 0, 0] 40 | 41 | console.log(data.motions); 42 | } 43 | 44 | ``` 45 | 46 | See `app/index.js` for use of drawing the grid on frames 47 | 48 | ## Development 49 | ### Install 50 | ``` 51 | npm install 52 | npm install webpack-dev-server webpack -g 53 | ``` 54 | 55 | ### Serve 56 | 57 | To serve at http://localhost:8080/: 58 | 59 | ``` 60 | webpack-dev-server --inline --content-base public/ 61 | ``` 62 | 63 | ### Build 64 | 65 | To compile HTML/CSS and JavaScript files for production: 66 | 67 | ``` 68 | webpack --config webpack.config.js 69 | ``` 70 | -------------------------------------------------------------------------------- /app/components/GridDetect.js: -------------------------------------------------------------------------------- 1 | import Util from './Util'; 2 | 3 | export default class GridDetect{ 4 | constructor(options) { 5 | this.size = options.gridSize; 6 | this.imageSize = options.imageSize; 7 | this.workingSize = options.workingSize; 8 | this.cellSize = { 9 | x: (this.workingSize.x / this.size.x), 10 | y: (this.workingSize.y / this.size.y), 11 | }; 12 | 13 | this.pixelDiffThreshold = options.pixelDiffThreshold; 14 | this.movementThreshold = options.movementThreshold; 15 | 16 | // this.frameDiff = Util.time(this.frameDiff, this); 17 | } 18 | 19 | detect(frames) { 20 | // diff frames 21 | const diff = this.frameDiff(frames.prev, frames.curr); 22 | 23 | // if no valid diff 24 | if (!diff) {return; }; 25 | 26 | // total pixels in frame 27 | const totalPix = diff.imageData.data.length / 4; 28 | 29 | // if not enough movement 30 | if (diff.count / totalPix < this.movementThreshold) { 31 | return false; 32 | } 33 | 34 | // else return movement in grid 35 | return this.detectGrid(diff.imageData); 36 | } 37 | 38 | // given pixels of diff, bucket num of pixels diff into cells in grid 39 | detectGrid(imageData) { 40 | 41 | const pixels = imageData.data; 42 | const results = new Int32Array(this.size.x * this.size.y); 43 | 44 | // for each pixel, determine which quadrant it belongs to 45 | let i = 0; 46 | let j, px, py, gx, gy, exists; 47 | while (i < pixels.length / 4) { 48 | px = i % this.workingSize.x; 49 | py = Math.floor(i / this.workingSize.x); 50 | 51 | gy = Math.floor(px / this.cellSize.x); 52 | gx = Math.floor(py / this.cellSize.y); 53 | 54 | if (pixels[i * 4] == 255) { 55 | let ri = gx * this.size.x + gy; 56 | results[ri] += 1; 57 | } 58 | 59 | i++; 60 | } 61 | 62 | return results; 63 | } 64 | 65 | // bitwise absolute and threshold 66 | // from https://www.adobe.com/devnet/archive/html5/articles/javascript-motion-detection.html 67 | makeThresh(min) { 68 | return function(value) { 69 | return (value ^ (value >> 31)) - (value >> 31) > min ? 255 : 0; 70 | }; 71 | } 72 | 73 | // diff two frames, return pixel diff data, boudning box of movement and count 74 | frameDiff(prev, curr) { 75 | if (prev == null || curr == null) { return false;}; 76 | 77 | let avgP, avgC, diff, j, i; 78 | const p = prev.data; 79 | const c = curr.data; 80 | const thresh = this.makeThresh(this.pixelDiffThreshold); 81 | 82 | // thresholding function 83 | const pixels = new Uint8ClampedArray(p.length); 84 | 85 | let count = 0; 86 | 87 | // for each pixel, find if average excees thresh 88 | i = 0; 89 | while (i < p.length / 4) { 90 | j = i * 4; 91 | 92 | avgC = 0.2126 * c[j] + 0.7152 * c[j + 1] + 0.0722 * c[j + 2]; 93 | avgP = 0.2126 * p[j] + 0.7152 * p[j + 1] + 0.0722 * p[j + 2]; 94 | 95 | diff = thresh(avgC - avgP); 96 | 97 | pixels[j + 3] = diff; 98 | 99 | // if there is a difference, update bounds 100 | if (diff) { 101 | pixels[j] = diff; 102 | 103 | // count pix movement 104 | count++; 105 | } 106 | 107 | i++; 108 | } 109 | 110 | return { 111 | count: count, 112 | imageData: new ImageData(pixels, this.workingSize.x), }; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /app/components/GridDetectWorker.js: -------------------------------------------------------------------------------- 1 | import GridDetect from './GridDetect'; 2 | onmessage = function(e) { 3 | const d = e.data; 4 | 5 | // create detector 6 | const gd = new GridDetect({ 7 | gridSize: d.gdSize, 8 | imageSize: d.imageSize, 9 | workingSize: d.workingSize, 10 | pixelDiffThreshold: d.pixelDiffThreshold, 11 | movementThreshold: d.movementThreshold, 12 | }); 13 | 14 | // get result 15 | const detected = gd.detect(d.frames); 16 | let msg = detected ? { 17 | motions: detected, 18 | gd: { 19 | size: gd.size, 20 | cellSize: gd.cellSize, 21 | actualCellSizeRatio: gd.imageSize.x / gd.workingSize.x, 22 | }, } : false; 23 | 24 | // send response 25 | postMessage(msg); 26 | close(); 27 | }; 28 | -------------------------------------------------------------------------------- /app/components/MotionDetect.js: -------------------------------------------------------------------------------- 1 | import GridDetectWorker from 'worker!./GridDetectWorker'; 2 | import GridDetect from './GridDetect'; 3 | import Util from './Util'; 4 | 5 | export default class MotionDetect{ 6 | 7 | constructor(srcId, options) { 8 | // constants 9 | this.MAX_PIX_VAL = 255; 10 | 11 | // defaults for options 12 | this.defaults = { 13 | fps: 30, 14 | gridSize: { 15 | x: 6, 16 | y: 4 17 | }, 18 | pixelDiffThreshold: 0.4, 19 | movementThreshold: 0.001, 20 | debug: false, 21 | canvasOutputElem: document.createElement('canvas') 22 | } 23 | 24 | // setup video 25 | this.video = document.getElementById(srcId); 26 | this.fps = options.fps || this.defaults.fps; 27 | 28 | // setup canvas 29 | this.canvas = options.canvasOutputElem || this.defaults.canvasOutputElem; 30 | this.ctx = this.canvas.getContext('2d'); 31 | 32 | // shadow canvas to draw video frames before processing 33 | const shadowCanvas = document.createElement('canvas'); 34 | this.shadow = shadowCanvas.getContext('2d'); 35 | 36 | // document.body.appendChild(this.shadow.canvas); 37 | 38 | // scratchpad 39 | const scratchpad = document.createElement('canvas'); 40 | this.scratch = scratchpad.getContext('2d'); 41 | 42 | // document.body.appendChild(this.scratch.canvas); 43 | 44 | // scale canvas 45 | this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); 46 | this.ctx.scale(-1, 1); 47 | 48 | // actual canvas size 49 | this.size = { 50 | x: window.innerWidth, 51 | y: window.innerHeight, 52 | }; 53 | 54 | // size to work with image on (scale down to reduce work) 55 | this.workingSize = { 56 | x: 300, 57 | y: 300, 58 | }; 59 | 60 | // griddetector size 61 | this.gdSize = options.gridSize || this.defaults.gridSize; 62 | 63 | // size canvas 64 | this.resize(this.size.x, this.size.y); 65 | 66 | // start yo engines 67 | this.init(); 68 | 69 | this.frames = { 70 | prev: null, 71 | curr: null, 72 | }; 73 | 74 | // set difference threshold 75 | this.pixelDiffThreshold = 255 * (options.pixelDiffThreshold || this.defaults.pixelDiffThreshold); 76 | 77 | // how much of ratio of movement to be not negligible 78 | this.movementThreshold = options.movementThreshold || this.movementThreshold; 79 | 80 | this.spawnGridDetector = Util.time(this.spawnGridDetector, this); 81 | if (options.debug) this.debug(); 82 | this.pause = false; 83 | } 84 | 85 | init() { 86 | 87 | // success callback 88 | const onGetUserMediaSuccess = (stream) => { 89 | this.video.src = window.URL.createObjectURL(stream); 90 | this.video.addEventListener('play', () => { 91 | // start tick 92 | this.tick(); 93 | 94 | // resize canvas to video ratio 95 | const videoBounds = this.video.getBoundingClientRect(); 96 | const heightToWidthRatio = videoBounds.height / videoBounds.width; 97 | this.resize(this.size.x, this.size.x * heightToWidthRatio); 98 | }, false); 99 | 100 | }; 101 | 102 | // error callback 103 | const onGetUserMediaError = (e) => { console.error(e); }; 104 | 105 | // configure getusermedia 106 | navigator.getUserMedia = navigator.getUserMedia || navigator.mozGetUserMedia || 107 | navigator.webkitGetUserMedia || navigator.msGetUserMedia; 108 | 109 | const options = { 110 | video: { 111 | width: { 112 | min: 1024, 113 | deal: 1280, 114 | max: 1920, }, 115 | height: { 116 | min: 600, 117 | ideal: 720, 118 | max: 1080, }, 119 | }, 120 | }; 121 | 122 | // do it! 123 | navigator.getUserMedia(options, onGetUserMediaSuccess, onGetUserMediaError); 124 | } 125 | 126 | resize(x, y) { 127 | this.size = { 128 | x: Math.floor(x), 129 | y: Math.floor(y), 130 | }; 131 | 132 | // scale working size 133 | const shadowY = Math.floor(this.size.y / this.size.x * this.workingSize.x); 134 | this.workingSize = { 135 | x: this.workingSize.x, 136 | y: shadowY, 137 | }; 138 | 139 | // resize canvases 140 | this.canvas.width = this.size.x; 141 | this.canvas.height = this.size.y; 142 | this.shadow.canvas.width = this.workingSize.x; 143 | this.shadow.canvas.height = this.workingSize.y; 144 | this.scratch.canvas.width = this.size.x; 145 | this.scratch.canvas.height = this.size.y; 146 | } 147 | 148 | // main loop 149 | tick() { 150 | if (!this.pause) { 151 | this.update(); 152 | this.detect(); 153 | } 154 | 155 | setTimeout(()=> { 156 | requestAnimationFrame(this.tick.bind(this)); 157 | }, 1000 / this.fps); 158 | } 159 | 160 | // update and save frame data 161 | update() { 162 | // draw frame on shadow and canvas 163 | const sw = this.workingSize.x; 164 | const sh = this.workingSize.y; 165 | 166 | this.shadow.save(); 167 | this.shadow.scale(-1, 1); 168 | this.shadow.drawImage(this.video, 0, 0, -sw, sh); 169 | this.shadow.restore(); 170 | 171 | this.ctx.save(); 172 | this.ctx.scale(-1, 1); 173 | this.ctx.drawImage(this.video, 0, 0, -this.size.x, this.size.y); 174 | this.ctx.restore(); 175 | 176 | // update data 177 | this.frames.prev = this.frames.curr; 178 | this.frames.curr = this.shadow.getImageData(0, 0, sw, sh); 179 | } 180 | 181 | // do detection 182 | detect() { 183 | this.spawnGridDetector(); 184 | } 185 | 186 | // set callback 187 | onDetect(fn) { 188 | this.onDetectCallback = fn; 189 | } 190 | 191 | // spawn worker thread to do detection 192 | spawnGridDetector(imageData) { 193 | // do nothing if no prev frame 194 | if (!this.frames.prev) {return; } 195 | 196 | const worker = new GridDetectWorker(); 197 | 198 | // create worker thread 199 | worker.postMessage({ 200 | // frames to diff 201 | frames: this.frames, 202 | 203 | // thresholds 204 | pixelDiffThreshold: this.pixelDiffThreshold, 205 | movementThreshold: this.movementThreshold, 206 | 207 | // grid size x cells by y cells 208 | gdSize: this.gdSize, 209 | 210 | // sizes for math 211 | imageSize: this.size, 212 | workingSize: this.workingSize, 213 | }); 214 | 215 | worker.onmessage = (e) => { 216 | // if has data to return, fire callback 217 | if (e.data) { 218 | this.onDetectCallback(this.ctx, e.data); 219 | } 220 | }; 221 | } 222 | 223 | // activate pausing mechanism 224 | debug() { 225 | document.addEventListener('keydown', ()=> { 226 | console.log('paused'); 227 | this.pause = !this.pause; 228 | }, false); 229 | } 230 | 231 | } 232 | 233 | -------------------------------------------------------------------------------- /app/components/Util.js: -------------------------------------------------------------------------------- 1 | export default class Util{ 2 | // returns function that times it's execution 3 | static time(f, scope) { 4 | let start, end; 5 | 6 | return function() { 7 | start = new Date(); 8 | const res = f.apply(this, arguments); 9 | end = new Date(); 10 | console.log('time', end - start); 11 | 12 | return res; 13 | }.bind(scope); 14 | 15 | } 16 | } -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import MotionDetect from './components/MotionDetect' 2 | import 'styles/style.scss' 3 | 4 | const options = { 5 | gridSize: { 6 | x: 16*2, 7 | y: 12*2, 8 | }, 9 | debug: true, 10 | pixelDiffThreshold: 0.3, 11 | movementThreshold: 0.0012, 12 | fps: 30, 13 | canvasOutputElem: document.getElementById('dest') 14 | } 15 | 16 | var overlay = document.getElementById('overlay'); 17 | const ctx = overlay.getContext('2d'); 18 | let timeoutClear; 19 | 20 | const md = new MotionDetect('src', options); 21 | 22 | // on motion detected, draw grid 23 | md.onDetect((other, data) => { 24 | clearTimeout(timeoutClear); 25 | 26 | const canvas = ctx.canvas; 27 | canvas.width = other.canvas.width; 28 | canvas.height = other.canvas.height; 29 | 30 | ctx.save(); 31 | const grid = data.motions; 32 | const gs = data.gd.size; 33 | const cs = data.gd.cellSize; 34 | const csActualRatio = data.gd.actualCellSizeRatio; 35 | 36 | // scale up cell size 37 | const cellArea = cs.x * cs.y; 38 | cs.x *= csActualRatio; 39 | cs.y *= csActualRatio; 40 | 41 | ctx.strokeStyle = 'rgba(0, 80, 200, 0.2)'; 42 | 43 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 44 | grid.forEach((cell, i) => { 45 | const x = i % gs.x; 46 | const y = Math.floor(i / gs.x); 47 | let intensity = cell / cellArea; 48 | // higher opacity for cells with more movement 49 | ctx.fillStyle = intensity > options.movementThreshold ? `rgba(0, 80, 200, ${0.1 + intensity})` : 'transparent'; 50 | 51 | ctx.beginPath(); 52 | ctx.rect(x * cs.x, y * cs.y, cs.x, cs.y); 53 | ctx.closePath(); 54 | ctx.stroke(); 55 | ctx.fill(); 56 | }); 57 | 58 | ctx.restore(); 59 | 60 | timeoutClear = setTimeout(()=>{ 61 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 62 | }, 1000); 63 | 64 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-base", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "babel-core": "^5.8.25", 13 | "babel-loader": "^5.3.2", 14 | "babel-runtime": "^5.8.25", 15 | "bootstrap-sass": "^3.3.5", 16 | "css-loader": "^0.19.0", 17 | "extract-text-webpack-plugin": "^0.8.2", 18 | "file-loader": "^0.8.5", 19 | "less": "^2.5.3", 20 | "less-loader": "^2.2.1", 21 | "node-sass": "^3.4.2", 22 | "sass-loader": "^3.0.0", 23 | "style-loader": "^0.12.4", 24 | "url-loader": "^0.5.6", 25 | "webpack": "^1.12.2", 26 | "worker-loader": "^0.7.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /public/bundle.js: -------------------------------------------------------------------------------- 1 | /******/ (function(modules) { // webpackBootstrap 2 | /******/ // The module cache 3 | /******/ var installedModules = {}; 4 | 5 | /******/ // The require function 6 | /******/ function __webpack_require__(moduleId) { 7 | 8 | /******/ // Check if module is in cache 9 | /******/ if(installedModules[moduleId]) 10 | /******/ return installedModules[moduleId].exports; 11 | 12 | /******/ // Create a new module (and put it into the cache) 13 | /******/ var module = installedModules[moduleId] = { 14 | /******/ exports: {}, 15 | /******/ id: moduleId, 16 | /******/ loaded: false 17 | /******/ }; 18 | 19 | /******/ // Execute the module function 20 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 21 | 22 | /******/ // Flag the module as loaded 23 | /******/ module.loaded = true; 24 | 25 | /******/ // Return the exports of the module 26 | /******/ return module.exports; 27 | /******/ } 28 | 29 | 30 | /******/ // expose the modules object (__webpack_modules__) 31 | /******/ __webpack_require__.m = modules; 32 | 33 | /******/ // expose the module cache 34 | /******/ __webpack_require__.c = installedModules; 35 | 36 | /******/ // __webpack_public_path__ 37 | /******/ __webpack_require__.p = ""; 38 | 39 | /******/ // Load entry module and return exports 40 | /******/ return __webpack_require__(0); 41 | /******/ }) 42 | /************************************************************************/ 43 | /******/ ([ 44 | /* 0 */ 45 | /***/ function(module, exports, __webpack_require__) { 46 | 47 | module.exports = __webpack_require__(1); 48 | 49 | 50 | /***/ }, 51 | /* 1 */ 52 | /***/ function(module, exports, __webpack_require__) { 53 | 54 | 'use strict'; 55 | 56 | var _interopRequireDefault = __webpack_require__(2)['default']; 57 | 58 | var _test = __webpack_require__(3); 59 | 60 | var _test2 = _interopRequireDefault(_test); 61 | 62 | __webpack_require__(4); 63 | 64 | (0, _test2['default'])(); 65 | 66 | /***/ }, 67 | /* 2 */ 68 | /***/ function(module, exports) { 69 | 70 | "use strict"; 71 | 72 | exports["default"] = function (obj) { 73 | return obj && obj.__esModule ? obj : { 74 | "default": obj 75 | }; 76 | }; 77 | 78 | exports.__esModule = true; 79 | 80 | /***/ }, 81 | /* 3 */ 82 | /***/ function(module, exports) { 83 | 84 | "use strict"; 85 | 86 | Object.defineProperty(exports, "__esModule", { 87 | value: true 88 | }); 89 | exports["default"] = test; 90 | 91 | function test() { 92 | var a = [1, 2, 3, 4, 5, 6]; 93 | a.forEach(function (m) { 94 | return console.log(m); 95 | }); 96 | } 97 | 98 | module.exports = exports["default"]; 99 | 100 | /***/ }, 101 | /* 4 */ 102 | /***/ function(module, exports, __webpack_require__) { 103 | 104 | // style-loader: Adds some css to the DOM by adding a