├── .gitignore ├── LICENSE ├── README.md ├── app.js ├── assets ├── css │ └── index.scss ├── js │ ├── detectFace.worker.js │ ├── index.js │ ├── opencv.js │ └── opencv_js.js └── wasm │ └── opencv_js.wasm ├── doc └── screenshot.png ├── package-lock.json ├── package.json ├── postcss.config.js ├── static └── data │ └── haarcascade_frontalface_default.xml ├── util.js ├── views └── index.html └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | /builtAssets 2 | /node_modules 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 mecab (https://keybase.io/mecab) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | opencvjs-wasm-webworker-webpack-demo 2 | ==================================== 3 | 4 | A demo of in-browser face detection, using 5 | [OpenCV.js](https://docs.opencv.org/3.4.2/d4/da1/tutorial_js_setup.html) 6 | built as [WebAssembly (WASM)](https://webassembly.org/), running through 7 | [Web Worker](https://developer.mozilla.org/docs/Web/API/Web_Workers_API) and 8 | bundled by [webpack](https://webpack.js.org/). 9 | 10 | Hopefully to be a good scaffold to start any OpenCV.js projects. 11 | 12 |  13 | 14 | Live Demo 15 | --------- 16 | 17 | **https://mecab.github.io/opencvjs-facedetect-livedemo** 18 | 19 | 20 | How to run 21 | ---------- 22 | 23 | ```bash 24 | $ npm install 25 | $ npm run debug 26 | ``` 27 | 28 | (Debug/development purpose. Your modification will show up soon by 29 | `webpack --watch` and `node-dev`) 30 | 31 | or 32 | 33 | ```bash 34 | $ npm install 35 | $ npm run build 36 | $ npm start 37 | ``` 38 | 39 | (Production purpose. Produces minified JS, however you will need to 40 | build the assets to reflect your edit.) 41 | 42 | Where `opencv.js`, `opencv_js.js` and `opencv_js.wasm` come from 43 | ------------------------------------------------------------------ 44 | These compiled js and wasm files are built from [OpenCV v3.4.2](https://docs.opencv.org/3.4.2/index.html) source through [OpenCV.js offical build instruction](https://docs.opencv.org/3.4.2/d4/da1/tutorial_js_setup.html). 45 | 46 | I pushed a [Docker image](https://hub.docker.com/r/mecab/opencv-wasm-builder/) to generate them so that you can easily build them by yourself. [Dockerfile](https://github.com/mecab/docker-opencv-wasm-builder) is also available. 47 | 48 | LICENSE 49 | ------- 50 | This is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 51 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const co = require('co'); 4 | 5 | const koa = require('koa'); 6 | const mount = require('koa-mount'); 7 | const staticServe = require('koa-static'); 8 | const router = require('koa-router')(); 9 | const swig = require('koa-swig'); 10 | 11 | const util = require('./util'); 12 | 13 | const app = new koa(); 14 | 15 | if (util.isProduction()) { 16 | console.log('🏭 App runs in PRODUCTION.'); 17 | } 18 | else { 19 | console.log('👷 App runs in DEVELOPMENT.'); 20 | }; 21 | 22 | const port = process.env.PORT || 3000; 23 | app.context.port = port; 24 | 25 | app.context.render = co.wrap(swig({ 26 | root: path.join(__dirname, 'views'), 27 | autoescape: true, 28 | cache: util.isProduction() ? 'memory' : false, 29 | ext: 'html', 30 | writeBody: false 31 | })); 32 | 33 | router.get('/', async(ctx, next) => { 34 | ctx.body = await ctx.render('index.html'); 35 | }); 36 | 37 | app 38 | .use(mount('/assets', staticServe(path.join(__dirname, 'builtAssets')))) 39 | .use(mount('/static', staticServe(path.join(__dirname, 'static')))) 40 | .use(router.routes()); 41 | 42 | app.listen(port, () => { 43 | console.log(`Koa run on port ${port}`); 44 | }); 45 | -------------------------------------------------------------------------------- /assets/css/index.scss: -------------------------------------------------------------------------------- 1 | @import 'bourbon'; 2 | 3 | body { 4 | font-family: $font-stack-helvetica; 5 | } 6 | 7 | .container { 8 | display: flex; 9 | } 10 | 11 | .videoWrapper { 12 | width: 50%; 13 | position: relative; 14 | 15 | .fps { 16 | position: absolute; 17 | z-index: 10; 18 | top: 0; 19 | left: 0; 20 | width: 4em; 21 | padding: 0.3em; 22 | 23 | text-align:center; 24 | background-color: black; 25 | color: white; 26 | } 27 | } 28 | 29 | .faceRect { 30 | position: absolute; 31 | border: 3px solid red; 32 | background-color: rgba(255, 0, 0, 0.2); 33 | } 34 | 35 | .log { 36 | width: 50%; 37 | p { 38 | margin-top: 0; 39 | margin-bottom: 0; 40 | padding-top: 0.3em; 41 | padding-bottom: 0.3em; 42 | padding-left: 0.5em; 43 | color: #566573; 44 | } 45 | p:nth-child(odd) { 46 | background-color: #EAFAF1; 47 | } 48 | p:nth-child(even) { 49 | background-color: #FEF9E7; 50 | } 51 | } -------------------------------------------------------------------------------- /assets/js/detectFace.worker.js: -------------------------------------------------------------------------------- 1 | // opencv.js tries to locate the wasms from the root by default. 2 | // We changes the location by defining Module object. 3 | // 4 | // See: https://kripken.github.io/emscripten-site/docs/api_reference/module.html#Module.locateFile 5 | 6 | global.Module = { 7 | locateFile: (path) => { 8 | const url = `/assets/wasm/${path}`; 9 | log(`⬇️Downloading wasm from ${url}`); 10 | return url; 11 | } 12 | }; 13 | 14 | const cv = require('./opencv.js'); 15 | let classifier = null; 16 | 17 | cv.onRuntimeInitialized = async () => { 18 | log('📦OpenCV runtime loaded'); 19 | init(); 20 | }; 21 | 22 | async function createFileFromUrl(path, url) { 23 | // Small function to make a remote file visible from emscripten module. 24 | 25 | log(`⬇️ Downloading additional file from ${url}.`); 26 | const res = await self.fetch(url); 27 | if (!res.ok) { 28 | throw new Error(`Response is not OK (${res.status} ${res.statusText} for ${url})`); 29 | } 30 | const buffer = await res.arrayBuffer(); 31 | const data = new Uint8Array(buffer); 32 | cv.FS_createDataFile('/', path, data, true, true, false); 33 | log(`📦${url} downloaded. Mounted on /${path}`); 34 | } 35 | 36 | async function init() { 37 | await createFileFromUrl('haarcascade_frontalface_default.xml', 38 | '/static/data/haarcascade_frontalface_default.xml'); 39 | 40 | classifier = new cv.CascadeClassifier(); 41 | classifier.load('haarcascade_frontalface_default.xml'); 42 | 43 | // Let the UI that the module finished initialization 44 | self.postMessage({ type: 'init' }); 45 | 46 | self.addEventListener('message', ({ data }) => { 47 | if (data.type === 'frame') { 48 | const faces = detectFaces(data.imageData); 49 | self.postMessage({ type: 'detect_faces', faces: faces }); 50 | } 51 | }); 52 | } 53 | 54 | function detectFaces(imageData) { 55 | const img = cv.matFromImageData(imageData); 56 | const imgGray = new cv.Mat(); 57 | 58 | const rect = []; 59 | cv.cvtColor(img, imgGray, cv.COLOR_RGBA2GRAY, 0); 60 | const faces = new cv.RectVector(); 61 | const msize = new cv.Size(0, 0); 62 | classifier.detectMultiScale(imgGray, faces, 1.1, 3, 0, msize, msize); 63 | 64 | for (let i = 0; i < faces.size(); i++) { 65 | rect.push(faces.get(i)); 66 | } 67 | 68 | img.delete(); 69 | faces.delete(); 70 | imgGray.delete(); 71 | 72 | return rect; 73 | } 74 | 75 | function log(args) { 76 | self.postMessage({ type: 'log', args: Array.from(arguments) }); 77 | } 78 | -------------------------------------------------------------------------------- /assets/js/index.js: -------------------------------------------------------------------------------- 1 | require('../css/index.scss'); 2 | 3 | const Worker = require('./detectFace.worker.js'); 4 | 5 | // Scaled (reduced) image resolution for processing. 6 | // Lesser is faster. 7 | const PROCESSING_RESOLUTION_WIDTH = 240; 8 | 9 | let worker = null; 10 | let video = null; 11 | let canvas = null; 12 | let scale = 0; 13 | let lastUpdate = 0; 14 | 15 | function addCanvas(width, height) { 16 | const canvas = document.createElement('canvas'); 17 | canvas.setAttribute('width', width); 18 | canvas.setAttribute('height', height); 19 | canvas.style.display = 'none'; 20 | document.body.appendChild(canvas); 21 | 22 | return canvas; 23 | } 24 | 25 | function px(value) { 26 | return `${value}px`; 27 | } 28 | 29 | function log(...args) { 30 | // We pass the arguments to console.log() directly. Not an "arguments array" 31 | // so that both of log('foo') and log('foo', 'bar') works as we expect. 32 | console.log(...args); 33 | 34 | // Also we will show the logs in the DOM. 35 | const el = document.createElement('p'); 36 | el.textContent = args.join(' '); 37 | 38 | document.querySelector('#log').appendChild(el); 39 | } 40 | 41 | async function init() { 42 | const stream = await navigator.mediaDevices 43 | .getUserMedia({ video: true, audio: false }); 44 | 45 | const settings = stream.getVideoTracks()[0].getSettings(); 46 | scale = PROCESSING_RESOLUTION_WIDTH / settings.width; 47 | 48 | canvas = addCanvas(settings.width * scale, settings.height * scale); 49 | 50 | video = document.querySelector('video'); 51 | video.setAttribute('width', settings.width); 52 | video.setAttribute('height', settings.height); 53 | 54 | video.srcObject = stream; 55 | await video.play(); 56 | 57 | initWorker(); 58 | } 59 | 60 | function initWorker() { 61 | log('Initializing the face detection worker'); 62 | 63 | worker = new Worker(); 64 | worker.addEventListener('message', ({ data }) => { 65 | switch (data.type) { 66 | case 'init': 67 | log('Worker initialization finished. Starting face detection 🚀'); 68 | detectFaces(); 69 | break; 70 | case 'detect_faces': 71 | requestAnimationFrame(() => { 72 | drawFaceFrame(data.faces); 73 | }); 74 | detectFaces(); 75 | updateFps(); 76 | break; 77 | case 'log': 78 | log('worker👷💬', ...data.args); 79 | break; 80 | } 81 | }); 82 | } 83 | 84 | function drawFaceFrame(faces) { 85 | // remove previous faces 86 | const previousFaceRects = document.querySelectorAll('.faceRect'); 87 | previousFaceRects.forEach((el) => { 88 | el.parentNode.removeChild(el); 89 | }); 90 | 91 | const parent = document.querySelector('#videoWrapper'); 92 | faces.forEach((face) => { 93 | const el = document.createElement('div'); 94 | el.classList.add('faceRect'); 95 | 96 | el.style.top = px(face.y / scale); 97 | el.style.left = px(face.x / scale); 98 | el.style.width = px(face.width / scale); 99 | el.style.height = px(face.height / scale); 100 | 101 | parent.appendChild(el); 102 | }); 103 | } 104 | 105 | function detectFaces() { 106 | const ctx = canvas.getContext('2d'); 107 | ctx.drawImage(video, 0, 0, canvas.width, canvas.height); 108 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); 109 | 110 | worker.postMessage({ type: 'frame', imageData }, [ imageData.data.buffer ]); 111 | } 112 | 113 | function updateFps() { 114 | const now = window.performance.now(); 115 | const interval = now - lastUpdate; 116 | lastUpdate = now; 117 | 118 | const fps = Math.round(1000 / interval); 119 | 120 | document.querySelector('#fps').textContent = `${fps}FPS`; 121 | } 122 | 123 | init(); 124 | -------------------------------------------------------------------------------- /assets/wasm/opencv_js.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mecab/opencvjs-wasm-webworker-webpack-demo/4df6ccaec81ca7da0b280c8a1714329599f1cb3c/assets/wasm/opencv_js.wasm -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mecab/opencvjs-wasm-webworker-webpack-demo/4df6ccaec81ca7da0b280c8a1714329599f1cb3c/doc/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opencvjs-wasm-webworker-webpack-demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node app.js", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "build": "NODE_ENV=production webpack -p --config webpack.config.js", 10 | "debug:build": "webpack --config webpack.config.js", 11 | "debug:watch": "webpack --config webpack.config.js --watch", 12 | "debug": "(node-dev app.js & npm run debug:watch)" 13 | }, 14 | "author": "mecab", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "autoprefixer": "^9.0.0", 18 | "babel-core": "^6.26.3", 19 | "babel-loader": "^7.1.5", 20 | "babel-plugin-transform-runtime": "^6.23.0", 21 | "babel-preset-env": "^1.7.0", 22 | "bourbon": "^5.0.1", 23 | "copy-webpack-plugin": "^4.5.2", 24 | "css-loader": "^0.28.11", 25 | "file-loader": "^1.1.11", 26 | "mini-css-extract-plugin": "^0.4.1", 27 | "node-dev": "^3.1.3", 28 | "node-sass": "^4.9.2", 29 | "postcss-loader": "^2.1.6", 30 | "sass-loader": "^6.0.7", 31 | "style-loader": "^0.21.0", 32 | "wasm-loader": "^1.3.0", 33 | "webpack": "^4.16.2", 34 | "webpack-cli": "^3.1.0", 35 | "worker-loader": "^2.0.0" 36 | }, 37 | "dependencies": { 38 | "co": "^4.6.0", 39 | "koa": "^2.5.2", 40 | "koa-mount": "^3.0.0", 41 | "koa-router": "^7.4.0", 42 | "koa-static": "^5.0.0", 43 | "koa-swig": "^2.2.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /util.js: -------------------------------------------------------------------------------- 1 | function isProduction() { 2 | return process.env.NODE_ENV == 'production'; 3 | } 4 | 5 | function isDevelopment() { 6 | return !isProduction(); 7 | } 8 | 9 | module.exports = { 10 | isProduction: isProduction, 11 | isDevelopment: isDevelopment 12 | }; 13 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 |