diff --git a/README.md b/README.md index a739f0b6..e3183463 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,23 @@ config = { scoped: false, // enable scoped runs // some models *may* have memory leaks, this wrapps everything in a local scope at a cost of performance // typically not needed + filter: { + enabled: true, // enable image pre-processing filters + return: true, // return processed canvas imagedata in result + brightness: 0, // range: -1 (darken) to 1 (lighten) + contrast: 0, // range: -1 (reduce contrast) to 1 (increase contrast) + sharpness: 0, // range: 0 (no sharpening) to 1 (maximum sharpening) + blur: 0, // range: 0 (no blur) to N (blur radius in pixels) + saturation: 0, // range: -1 (reduce saturation) to 1 (increase saturation) + hue: 0, // range: 0 (no change) to 360 (hue rotation in degrees) + negative: false, // image negative + sepia: false, // image sepia colors + vintage: false, // image vintage colors + kodachrome: false, // image kodachrome colors + technicolor: false, // image technicolor colors + polaroid: false, // image polaroid camera effect + pixelate: 0, // range: 0 (no pixelate) to N (number of pixels to pixelate) + }, face: { enabled: true, // controls if specified modul is enabled // face.enabled is required for all face models: detector, mesh, iris, age, gender, emotion @@ -352,6 +369,7 @@ result = { backend, // time to initialize tf backend load, // time to load models sanity, // time for input verification + image, // time for image processing body, // model time hand, // model time face, // model time @@ -416,5 +434,6 @@ Library can also be used on mobile devices - Body Pose Detection: [**PoseNet**](https://medium.com/tensorflow/real-time-human-pose-estimation-in-the-browser-with-tensorflow-js-7dd0bc881cd5) - Age & Gender Prediction: [**SSR-Net**](https://github.com/shamangary/SSR-Net) - Emotion Prediction: [**Oarriaga**](https://github.com/oarriaga/face_classification) +- Image Filters: [**WebGLImageFilter**](https://github.com/phoboslab/WebGLImageFilter)
diff --git a/config.js b/config.js index a4fe4057..f2557433 100644 --- a/config.js +++ b/config.js @@ -7,6 +7,23 @@ export default { scoped: false, // enable scoped runs // some models *may* have memory leaks, this wrapps everything in a local scope at a cost of performance // typically not needed + filter: { + enabled: true, // enable image pre-processing filters + return: true, // return processed canvas imagedata in result + brightness: 0, // range: -1 (darken) to 1 (lighten) + contrast: 0, // range: -1 (reduce contrast) to 1 (increase contrast) + sharpness: 0, // range: 0 (no sharpening) to 1 (maximum sharpening) + blur: 0, // range: 0 (no blur) to N (blur radius in pixels) + saturation: 0, // range: -1 (reduce saturation) to 1 (increase saturation) + hue: 0, // range: 0 (no change) to 360 (hue rotation in degrees) + negative: false, // image negative + sepia: false, // image sepia colors + vintage: false, // image vintage colors + kodachrome: false, // image kodachrome colors + technicolor: false, // image technicolor colors + polaroid: false, // image polaroid camera effect + pixelate: 0, // range: 0 (no pixelate) to N (number of pixels to pixelate) + }, face: { enabled: true, // controls if specified modul is enabled // face.enabled is required for all face models: detector, mesh, iris, age, gender, emotion diff --git a/demo/browser.js b/demo/browser.js index 4e2a0d39..dbb6c158 100644 --- a/demo/browser.js +++ b/demo/browser.js @@ -27,6 +27,7 @@ const ui = { // configuration overrides const config = { backend: 'webgl', // if you want to use 'wasm' backend, enable script load of tf and tf-backend-wasm in index.html + filter: { enabled: true, brightness: 0, contrast: 0, sharpness: 0, blur: 0, saturation: 0, hue: 0, negative: false, sepia: false, vintage: false, kodachrome: false, technicolor: false, polaroid: false, pixelate: 0 }, face: { enabled: true, detector: { maxFaces: 10, skipFrames: 10, minConfidence: 0.5, iouThreshold: 0.3, scoreThreshold: 0.7 }, @@ -42,6 +43,7 @@ const config = { // global variables let menu; +let menuFX; let worker; let timeStamp; const fps = []; @@ -75,7 +77,8 @@ function drawResults(input, result, canvas) { // draw image from video const ctx = canvas.getContext('2d'); - ctx.drawImage(input, 0, 0, input.width, input.height, 0, 0, canvas.width, canvas.height); + if (result.canvas) ctx.drawImage(result.canvas, 0, 0, result.canvas.width, result.canvas.height, 0, 0, canvas.width, canvas.height); + else ctx.drawImage(input, 0, 0, input.width, input.height, 0, 0, canvas.width, canvas.height); // draw all results draw.face(result.face, canvas, ui, human.facemesh.triangulation); draw.body(result.body, canvas, ui); @@ -193,7 +196,7 @@ async function processImage(input) { const result = await human.detect(image, config); drawResults(image, result, canvas); const thumb = document.createElement('canvas'); - thumb.width = (window.innerWidth - menu.width) / (ui.columns + 0.1); + thumb.width = window.innerWidth / (ui.columns + 0.1); thumb.height = canvas.height / (window.innerWidth / thumb.width); thumb.style.margin = '8px'; thumb.style.boxShadow = '4px 4px 4px 0 dimgrey'; @@ -237,11 +240,12 @@ async function detectSampleImages() { } function setupMenu() { - menu = new Menu(document.body); - menu.addTitle('...'); + menu = new Menu(document.body, '...', { top: '1rem', right: '1rem' }); menu.addButton('Start Video', 'Pause Video', (evt) => detectVideo(evt)); menu.addButton('Process Images', 'Process Images', () => detectSampleImages()); + menu.addHTML('
'); + menu.addBool('Use Web Worker', ui, 'useWorker'); menu.addHTML('
'); menu.addLabel('Enabled Models'); menu.addBool('Face Detect', config.face, 'enabled'); @@ -281,18 +285,33 @@ function setupMenu() { config.hand.iouThreshold = parseFloat(val); }); - menu.addHTML('
'); - menu.addLabel('UI Options'); - menu.addBool('Use Web Worker', ui, 'useWorker'); - menu.addBool('Camera Front/Back', ui, 'facing', () => setupCamera()); - menu.addBool('Use 3D Depth', ui, 'useDepth'); - menu.addBool('Draw Boxes', ui, 'drawBoxes'); - menu.addBool('Draw Points', ui, 'drawPoints'); - menu.addBool('Draw Polygons', ui, 'drawPolygons'); - menu.addBool('Fill Polygons', ui, 'fillPolygons'); - menu.addHTML('
'); menu.addChart('FPS', 'FPS'); + + menuFX = new Menu(document.body, '...', { top: '1rem', right: '18rem' }); + menuFX.addLabel('UI Options'); + menuFX.addBool('Camera Front/Back', ui, 'facing', () => setupCamera()); + menuFX.addBool('Use 3D Depth', ui, 'useDepth'); + menuFX.addBool('Draw Boxes', ui, 'drawBoxes'); + menuFX.addBool('Draw Points', ui, 'drawPoints'); + menuFX.addBool('Draw Polygons', ui, 'drawPolygons'); + menuFX.addBool('Fill Polygons', ui, 'fillPolygons'); + menuFX.addHTML('
'); + menuFX.addLabel('Image Filters'); + menuFX.addBool('Enabled', config.filter, 'enabled'); + menuFX.addRange('Brightness', config.filter, 'brightness', -1.0, 1.0, 0.05, (val) => config.filter.brightness = parseFloat(val)); + menuFX.addRange('Contrast', config.filter, 'contrast', -1.0, 1.0, 0.05, (val) => config.filter.contrast = parseFloat(val)); + menuFX.addRange('Sharpness', config.filter, 'sharpness', 0, 1.0, 0.05, (val) => config.filter.sharpness = parseFloat(val)); + menuFX.addRange('Blur', config.filter, 'blur', 0, 20, 1, (val) => config.filter.blur = parseInt(val)); + menuFX.addRange('Saturation', config.filter, 'saturation', -1.0, 1.0, 0.05, (val) => config.filter.saturation = parseFloat(val)); + menuFX.addRange('Hue', config.filter, 'hue', 0, 360, 5, (val) => config.filter.hue = parseInt(val)); + menuFX.addRange('Pixelate', config.filter, 'pixelate', 0, 32, 1, (val) => config.filter.pixelate = parseInt(val)); + menuFX.addBool('Negative', config.filter, 'negative'); + menuFX.addBool('Sepia', config.filter, 'sepia'); + menuFX.addBool('Vintage', config.filter, 'vintage'); + menuFX.addBool('Kodachrome', config.filter, 'kodachrome'); + menuFX.addBool('Technicolor', config.filter, 'technicolor'); + menuFX.addBool('Polaroid', config.filter, 'polaroid'); } async function main() { diff --git a/demo/menu.js b/demo/menu.js index f6274a45..493efc06 100644 --- a/demo/menu.js +++ b/demo/menu.js @@ -1,7 +1,12 @@ +let instance = 0; + const css = ` - .menu-container { display: block; background: darkslategray; position: fixed; top: 0rem; right: 0; width: fit-content; padding: 0 0.8rem 0 0.8rem; line-height: 1.8rem; z-index: 10; max-height: calc(100% - 4rem); box-shadow: 0 0 8px dimgrey; } - .menu-container:hover { box-shadow: 0 0 8px lightgrey; } - .menu { display: flex; white-space: nowrap; background: darkslategray; padding: 0.2rem; width: max-content; } + .menu { position: fixed; top: 0rem; right: 0; width: fit-content; padding: 0 0.8rem 0 0.8rem; line-height: 1.8rem; z-index: 10; max-height: calc(100% - 4rem); box-shadow: 0 0 8px dimgrey; background: darkslategray; } + .menu:hover { box-shadow: 0 0 8px lightgrey; } + .menu-container { display: block; max-height: 100vh; } + .menu-container-fadeout { max-height: 0; overflow: hidden; transition: max-height, 0.5s ease; } + .menu-container-fadein { max-height: 100vh; overflow: hidden; transition: max-height, 0.5s ease; } + .menu-item { display: flex; white-space: nowrap; background: darkslategray; padding: 0.2rem; width: max-content; } .menu-title { text-align: right; cursor: pointer; } .menu-hr { margin: 0.2rem; border: 1px solid rgba(0, 0, 0, 0.5) } .menu-label { padding: 0; } @@ -33,31 +38,54 @@ function createCSS() { document.getElementsByTagName('head')[0].appendChild(el); } -function createElem(parent) { +function createMenu(parent, title, position = { top: null, left: null, bottom: null, right: null }) { const el = document.createElement('div'); - el.id = 'menu'; - el.className = 'menu-container'; + el.id = `menu-${instance}`; + el.className = 'menu'; + if (position) { + if (position.top) el.style.top = position.top; + if (position.bottom) el.style.bottom = position.bottom; + if (position.left) el.style.left = position.left; + if (position.right) el.style.right = position.right; + } + + const elContainer = document.createElement('div'); + elContainer.id = `menu-container-${instance}`; + elContainer.className = 'menu-container menu-container-fadein'; + + const elTitle = document.createElement('div'); + elTitle.className = 'menu-title'; + elTitle.id = `menu-title-${instance}`; + elTitle.innerHTML = title; + el.appendChild(elTitle); + elTitle.addEventListener('click', () => { + elContainer.classList.toggle('menu-container-fadeout'); + elContainer.classList.toggle('menu-container-fadein'); + }); + el.appendChild(elContainer); if (typeof parent === 'object') parent.appendChild(el); else document.getElementById(parent).appendChild(el); - return el; + return [el, elContainer]; } class Menu { - constructor(parent) { + constructor(parent, title, position) { createCSS(); - this.menu = createElem(parent); - this._id = 0; + [this.menu, this.container] = createMenu(parent, title, position); + this.id = 0; + this.instance = instance; + instance++; this._maxFPS = 0; this.hidden = 0; } get newID() { - this._id++; - return `menu-${this._id}`; + this.id++; + return `menu-${this.instance}-${this.id}`; } get ID() { - return `menu-${this._id}`; + return `menu-${this.instance}-${this.id}`; } get width() { @@ -77,23 +105,25 @@ class Menu { el.addEventListener('click', () => { this.hidden = !this.hidden; const all = document.getElementsByClassName('menu'); - for (const item of all) item.style.display = this.hidden ? 'none' : 'flex'; + for (const item of all) { + item.style.display = this.hidden ? 'none' : 'flex'; + } }); } async addLabel(title) { const el = document.createElement('div'); - el.className = 'menu menu-label'; + el.className = 'menu-item menu-label'; el.id = this.newID; el.innerHTML = title; - this.menu.appendChild(el); + this.container.appendChild(el); } async addBool(title, object, variable, callback) { const el = document.createElement('div'); - el.className = 'menu'; + el.className = 'menu-item'; el.innerHTML = `${title}`; - this.menu.appendChild(el); + this.container.appendChild(el); el.addEventListener('change', (evt) => { object[variable] = evt.target.checked; if (callback) callback(evt.target.checked); @@ -102,9 +132,9 @@ class Menu { async addRange(title, object, variable, min, max, step, callback) { const el = document.createElement('div'); - el.className = 'menu'; + el.className = 'menu-item'; el.innerHTML = `${title}`; - this.menu.appendChild(el); + this.container.appendChild(el); el.addEventListener('change', (evt) => { object[variable] = evt.target.value; evt.target.setAttribute('value', evt.target.value); @@ -114,22 +144,22 @@ class Menu { async addHTML(html) { const el = document.createElement('div'); - el.className = 'menu'; + el.className = 'menu-item'; el.id = this.newID; if (html) el.innerHTML = html; - this.menu.appendChild(el); + this.container.appendChild(el); } async addButton(titleOn, titleOff, callback) { const el = document.createElement('button'); - el.className = 'menu menu-button'; + el.className = 'menu-item menu-button'; el.style.fontFamily = document.body.style.fontFamily; el.style.fontSize = document.body.style.fontSize; el.style.fontVariant = document.body.style.fontVariant; el.type = 'button'; el.id = this.newID; el.innerText = titleOn; - this.menu.appendChild(el); + this.container.appendChild(el); el.addEventListener('click', () => { if (el.innerText === titleOn) el.innerText = titleOff; else el.innerText = titleOn; @@ -139,10 +169,10 @@ class Menu { async addValue(title, val) { const el = document.createElement('div'); - el.className = 'menu'; + el.className = 'menu-item'; el.id = title; el.innerText = `${title}: ${val}`; - this.menu.appendChild(el); + this.contaner.appendChild(el); } // eslint-disable-next-line class-methods-use-this @@ -153,10 +183,10 @@ class Menu { async addChart(title, id) { const el = document.createElement('div'); - el.className = 'menu menu-chart-title'; + el.className = 'menu-item menu-chart-title'; el.id = this.newID; el.innerHTML = `${title}`; - this.menu.appendChild(el); + this.container.appendChild(el); } // eslint-disable-next-line class-methods-use-this diff --git a/package.json b/package.json index a0ef0955..44af1a76 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "rimraf": "^3.0.2" }, "scripts": { - "start": "node --trace-warnings --unhandled-rejections=strict --trace-uncaught --no-deprecation demo/node.js", + "start": "node --trace-warnings --unhandled-rejections=strict --trace-uncaught --no-deprecation src/node.js", "lint": "eslint src/*.js demo/*.js", "build-iife": "esbuild --bundle --platform=browser --sourcemap --target=esnext --format=iife --minify --external:fs --global-name=human --metafile=dist/human.json --outfile=dist/human.js src/human.js", "build-esm-bundle": "esbuild --bundle --platform=browser --sourcemap --target=esnext --format=esm --minify --external:fs --metafile=dist/human.esm.json --outfile=dist/human.esm.js src/human.js", diff --git a/src/handpose/handdetector.js b/src/handpose/handdetector.js index 73b1524d..4642501f 100644 --- a/src/handpose/handdetector.js +++ b/src/handpose/handdetector.js @@ -34,7 +34,6 @@ class HandDetector { async getBoundingBoxes(input) { const batchedPrediction = this.model.predict(input); const prediction = batchedPrediction.squeeze(); - console.log(prediction); // Regression score for each anchor point. const scores = tf.tidy(() => tf.sigmoid(tf.slice(prediction, [0, 0], [-1, 1])).squeeze()); // Bounding box for each anchor point. diff --git a/src/human.js b/src/human.js index 5de59902..1d0cee28 100644 --- a/src/human.js +++ b/src/human.js @@ -4,11 +4,14 @@ const ssrnet = require('./ssrnet/ssrnet.js'); const emotion = require('./emotion/emotion.js'); const posenet = require('./posenet/posenet.js'); const handpose = require('./handpose/handpose.js'); +const fxImage = require('./imagefx.js'); const defaults = require('../config.js').default; const app = require('../package.json'); let config; +let fx; let state = 'idle'; +let offscreenCanvas; // object that contains all initialized models const models = { @@ -93,26 +96,75 @@ function sanity(input) { async function load(userConfig) { if (userConfig) config = mergeDeep(defaults, userConfig); - if (config.face.enabled && !models.facemesh) models.facemesh = await facemesh.load(config.face); - if (config.body.enabled && !models.posenet) models.posenet = await posenet.load(config.body); - if (config.hand.enabled && !models.handpose) models.handpose = await handpose.load(config.hand); - if (config.face.enabled && config.face.age.enabled && !models.age) models.age = await ssrnet.loadAge(config); - if (config.face.enabled && config.face.gender.enabled && !models.gender) models.gender = await ssrnet.loadGender(config); - if (config.face.enabled && config.face.emotion.enabled && !models.emotion) models.emotion = await emotion.load(config); + if (config.face.enabled && !models.facemesh) { + log('Load model: Face'); + models.facemesh = await facemesh.load(config.face); + } + if (config.body.enabled && !models.posenet) { + log('Load model: Body'); + models.posenet = await posenet.load(config.body); + } + if (config.hand.enabled && !models.handpose) { + log('Load model: Hand'); + models.handpose = await handpose.load(config.hand); + } + if (config.face.enabled && config.face.age.enabled && !models.age) { + log('Load model: Age'); + models.age = await ssrnet.loadAge(config); + } + if (config.face.enabled && config.face.gender.enabled && !models.gender) { + log('Load model: Gender'); + models.gender = await ssrnet.loadGender(config); + } + if (config.face.enabled && config.face.emotion.enabled && !models.emotion) { + log('Load model: Emotion'); + models.emotion = await emotion.load(config); + } } function tfImage(input) { - let image; + // let imageData; + let filtered; + if (tf.ENV.flags.IS_BROWSER && config.filter.enabled && !(input instanceof tf.Tensor)) { + const width = input.naturalWidth || input.videoWidth || input.width || (input.shape && (input.shape[1] > 0)); + const height = input.naturalHeight || input.videoHeight || input.Height || (input.shape && (input.shape[2] > 0)); + // if (!offscreenCanvas) offscreenCanvas = new OffscreenCanvas(width, height); + if (!offscreenCanvas) { + offscreenCanvas = document.createElement('canvas'); + offscreenCanvas.width = width; + offscreenCanvas.height = height; + } + const ctx = offscreenCanvas.getContext('2d'); + ctx.drawImage(input, 0, 0, width, height, 0, 0, offscreenCanvas.width, offscreenCanvas.height); + if (!fx) fx = new fxImage.Canvas(); + else fx.reset(); + fx.addFilter('brightness', config.filter.brightness); // must have at least one filter enabled + if (config.filter.contrast !== 0) fx.addFilter('contrast', config.filter.contrast); + if (config.filter.sharpness !== 0) fx.addFilter('sharpen', config.filter.sharpness); + if (config.filter.blur !== 0) fx.addFilter('blur', config.filter.blur); + if (config.filter.saturation !== 0) fx.addFilter('saturation', config.filter.saturation); + if (config.filter.hue !== 0) fx.addFilter('hue', config.filter.hue); + if (config.filter.negative) fx.addFilter('negative'); + if (config.filter.sepia) fx.addFilter('sepia'); + if (config.filter.vintage) fx.addFilter('brownie'); + if (config.filter.sepia) fx.addFilter('sepia'); + if (config.filter.kodachrome) fx.addFilter('kodachrome'); + if (config.filter.technicolor) fx.addFilter('technicolor'); + if (config.filter.polaroid) fx.addFilter('polaroid'); + if (config.filter.pixelate !== 0) fx.addFilter('pixelate', config.filter.pixelate); + filtered = fx.apply(offscreenCanvas); + } + let tensor; if (input instanceof tf.Tensor) { - image = tf.clone(input); + tensor = tf.clone(input); } else { - const pixels = tf.browser.fromPixels(input); + const pixels = tf.browser.fromPixels(filtered || input); const casted = pixels.toFloat(); - image = casted.expandDims(0); + tensor = casted.expandDims(0); pixels.dispose(); casted.dispose(); } - return image; + return { tensor, canvas: config.filter.return ? filtered : null }; } async function detect(input, userConfig = {}) { @@ -167,7 +219,10 @@ async function detect(input, userConfig = {}) { analyze('Start Detect:'); - const imageTensor = tfImage(input); + timeStamp = now(); + const image = tfImage(input); + perf.image = Math.trunc(now() - timeStamp); + const imageTensor = image.tensor; // run posenet state = 'run:body'; @@ -239,7 +294,7 @@ async function detect(input, userConfig = {}) { analyze('End Scope:'); perf.total = Math.trunc(now() - timeStart); - resolve({ face: faceRes, body: poseRes, hand: handRes, performance: perf }); + resolve({ face: faceRes, body: poseRes, hand: handRes, performance: perf, canvas: image.canvas }); }); } diff --git a/src/imagefx.js b/src/imagefx.js new file mode 100644 index 00000000..7ec025bd --- /dev/null +++ b/src/imagefx.js @@ -0,0 +1,608 @@ +/* eslint-disable no-shadow */ +/* eslint-disable prefer-rest-params */ +/* eslint-disable no-sequences */ +/* eslint-disable no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-multi-assign */ +/* eslint-disable no-use-before-define */ +/* +WebGLImageFilter - MIT Licensed +2013, Dominic Szablewski - phoboslab.org +*/ + +const WebGLProgram = function (gl, vertexSource, fragmentSource) { + const _collect = function (source, prefix, collection) { + const r = new RegExp('\\b' + prefix + ' \\w+ (\\w+)', 'ig'); + source.replace(r, (match, name) => { + collection[name] = 0; + return match; + }); + }; + + const _compile = function (gl, source, type) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error('Filter: GL compile failed', gl.getShaderInfoLog(shader)); + } + return shader; + }; + + this.uniform = {}; + this.attribute = {}; + + const _vsh = _compile(gl, vertexSource, gl.VERTEX_SHADER); + const _fsh = _compile(gl, fragmentSource, gl.FRAGMENT_SHADER); + + this.id = gl.createProgram(); + gl.attachShader(this.id, _vsh); + gl.attachShader(this.id, _fsh); + gl.linkProgram(this.id); + + if (!gl.getProgramParameter(this.id, gl.LINK_STATUS)) { + throw new Error('Filter: GL link failed', gl.getProgramInfoLog(this.id)); + } + + gl.useProgram(this.id); + + // Collect attributes + _collect(vertexSource, 'attribute', this.attribute); + for (const a in this.attribute) { + this.attribute[a] = gl.getAttribLocation(this.id, a); + } + + // Collect uniforms + _collect(vertexSource, 'uniform', this.uniform); + _collect(fragmentSource, 'uniform', this.uniform); + for (const u in this.uniform) { + this.uniform[u] = gl.getUniformLocation(this.id, u); + } +}; + +const WebGLImageFilter = function (params) { + if (!params) params = { }; + let _drawCount = 0; + let _sourceTexture = null; + let _lastInChain = false; + let _currentFramebufferIndex = -1; + let _tempFramebuffers = [null, null]; + let _filterChain = []; + let _width = -1; + let _height = -1; + let _vertexBuffer = null; + let _currentProgram = null; + const _canvas = params.canvas || document.createElement('canvas'); + + // key is the shader program source, value is the compiled program + const _shaderProgramCache = { }; + + const gl = _canvas.getContext('webgl') || _canvas.getContext('experimental-webgl'); + if (!gl) throw new Error('Filter: getContext() failed'); + + this.addFilter = function (name) { + const args = Array.prototype.slice.call(arguments, 1); + const filter = _filter[name]; + + _filterChain.push({ func: filter, args }); + }; + + this.reset = function () { + _filterChain = []; + }; + + this.apply = function (image) { + _resize(image.width, image.height); + _drawCount = 0; + + // Create the texture for the input image if we haven't yet + if (!_sourceTexture) _sourceTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, _sourceTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); + + // No filters? Just draw + if (_filterChain.length === 0) { + const program = _compileShader(SHADER.FRAGMENT_IDENTITY); + _draw(); + return _canvas; + } + + for (let i = 0; i < _filterChain.length; i++) { + _lastInChain = (i === _filterChain.length - 1); + const f = _filterChain[i]; + f.func.apply(this, f.args || []); + } + + return _canvas; + }; + + const _resize = function (width, height) { + // Same width/height? Nothing to do here + if (width === _width && height === _height) { return; } + + _canvas.width = _width = width; + _canvas.height = _height = height; + + // Create the context if we don't have it yet + if (!_vertexBuffer) { + // Create the vertex buffer for the two triangles [x, y, u, v] * 6 + const vertices = new Float32Array([ + -1, -1, 0, 1, 1, -1, 1, 1, -1, 1, 0, 0, + -1, 1, 0, 0, 1, -1, 1, 1, 1, 1, 1, 0, + ]); + _vertexBuffer = gl.createBuffer(), + gl.bindBuffer(gl.ARRAY_BUFFER, _vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + + // Note sure if this is a good idea; at least it makes texture loading + // in Ejecta instant. + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); + } + + gl.viewport(0, 0, _width, _height); + + // Delete old temp framebuffers + _tempFramebuffers = [null, null]; + }; + + const _getTempFramebuffer = function (index) { + _tempFramebuffers[index] = _tempFramebuffers[index] + || _createFramebufferTexture(_width, _height); + + return _tempFramebuffers[index]; + }; + + const _createFramebufferTexture = function (width, height) { + const fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + + const renderbuffer = gl.createRenderbuffer(); + gl.bindRenderbuffer(gl.RENDERBUFFER, renderbuffer); + + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + + gl.bindTexture(gl.TEXTURE_2D, null); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + return { fbo, texture }; + }; + + const _draw = function (flags) { + let source = null; + let target = null; + let flipY = false; + + // Set up the source + if (_drawCount === 0) { + // First draw call - use the source texture + source = _sourceTexture; + } else { + // All following draw calls use the temp buffer last drawn to + source = _getTempFramebuffer(_currentFramebufferIndex).texture; + } + _drawCount++; + + // Set up the target + if (_lastInChain && !(flags & DRAW.INTERMEDIATE)) { + // Last filter in our chain - draw directly to the WebGL Canvas. We may + // also have to flip the image vertically now + target = null; + flipY = _drawCount % 2 === 0; + } else { + // Intermediate draw call - get a temp buffer to draw to + _currentFramebufferIndex = (_currentFramebufferIndex + 1) % 2; + target = _getTempFramebuffer(_currentFramebufferIndex).fbo; + } + + // Bind the source and target and draw the two triangles + gl.bindTexture(gl.TEXTURE_2D, source); + gl.bindFramebuffer(gl.FRAMEBUFFER, target); + + gl.uniform1f(_currentProgram.uniform.flipY, (flipY ? -1 : 1)); + gl.drawArrays(gl.TRIANGLES, 0, 6); + }; + + const _compileShader = function (fragmentSource) { + if (_shaderProgramCache[fragmentSource]) { + _currentProgram = _shaderProgramCache[fragmentSource]; + gl.useProgram(_currentProgram.id); + return _currentProgram; + } + + // Compile shaders + _currentProgram = new WebGLProgram(gl, SHADER.VERTEX_IDENTITY, fragmentSource); + + const floatSize = Float32Array.BYTES_PER_ELEMENT; + const vertSize = 4 * floatSize; + gl.enableVertexAttribArray(_currentProgram.attribute.pos); + gl.vertexAttribPointer(_currentProgram.attribute.pos, 2, gl.FLOAT, false, vertSize, 0 * floatSize); + gl.enableVertexAttribArray(_currentProgram.attribute.uv); + gl.vertexAttribPointer(_currentProgram.attribute.uv, 2, gl.FLOAT, false, vertSize, 2 * floatSize); + + _shaderProgramCache[fragmentSource] = _currentProgram; + return _currentProgram; + }; + + let DRAW = { INTERMEDIATE: 1 }; + + let SHADER = {}; + SHADER.VERTEX_IDENTITY = [ + 'precision highp float;', + 'attribute vec2 pos;', + 'attribute vec2 uv;', + 'varying vec2 vUv;', + 'uniform float flipY;', + + 'void main(void) {', + 'vUv = uv;', + 'gl_Position = vec4(pos.x, pos.y*flipY, 0.0, 1.);', + '}', + ].join('\n'); + + SHADER.FRAGMENT_IDENTITY = [ + 'precision highp float;', + 'varying vec2 vUv;', + 'uniform sampler2D texture;', + + 'void main(void) {', + 'gl_FragColor = texture2D(texture, vUv);', + '}', + ].join('\n'); + + let _filter = {}; + + // ------------------------------------------------------------------------- + // Color Matrix Filter + + _filter.colorMatrix = function (matrix) { + // Create a Float32 Array and normalize the offset component to 0-1 + const m = new Float32Array(matrix); + m[4] /= 255; + m[9] /= 255; + m[14] /= 255; + m[19] /= 255; + + // Can we ignore the alpha value? Makes things a bit faster. + const shader = (m[18] === 1 && m[3] === 0 && m[8] === 0 && m[13] === 0 && m[15] === 0 && m[16] === 0 && m[17] === 0 && m[19] === 0) + ? _filter.colorMatrix.SHADER.WITHOUT_ALPHA + : _filter.colorMatrix.SHADER.WITH_ALPHA; + + const program = _compileShader(shader); + gl.uniform1fv(program.uniform.m, m); + _draw(); + }; + + _filter.colorMatrix.SHADER = {}; + _filter.colorMatrix.SHADER.WITH_ALPHA = [ + 'precision highp float;', + 'varying vec2 vUv;', + 'uniform sampler2D texture;', + 'uniform float m[20];', + + 'void main(void) {', + 'vec4 c = texture2D(texture, vUv);', + 'gl_FragColor.r = m[0] * c.r + m[1] * c.g + m[2] * c.b + m[3] * c.a + m[4];', + 'gl_FragColor.g = m[5] * c.r + m[6] * c.g + m[7] * c.b + m[8] * c.a + m[9];', + 'gl_FragColor.b = m[10] * c.r + m[11] * c.g + m[12] * c.b + m[13] * c.a + m[14];', + 'gl_FragColor.a = m[15] * c.r + m[16] * c.g + m[17] * c.b + m[18] * c.a + m[19];', + '}', + ].join('\n'); + _filter.colorMatrix.SHADER.WITHOUT_ALPHA = [ + 'precision highp float;', + 'varying vec2 vUv;', + 'uniform sampler2D texture;', + 'uniform float m[20];', + + 'void main(void) {', + 'vec4 c = texture2D(texture, vUv);', + 'gl_FragColor.r = m[0] * c.r + m[1] * c.g + m[2] * c.b + m[4];', + 'gl_FragColor.g = m[5] * c.r + m[6] * c.g + m[7] * c.b + m[9];', + 'gl_FragColor.b = m[10] * c.r + m[11] * c.g + m[12] * c.b + m[14];', + 'gl_FragColor.a = c.a;', + '}', + ].join('\n'); + + _filter.brightness = function (brightness) { + const b = (brightness || 0) + 1; + _filter.colorMatrix([ + b, 0, 0, 0, 0, + 0, b, 0, 0, 0, + 0, 0, b, 0, 0, + 0, 0, 0, 1, 0, + ]); + }; + + _filter.saturation = function (amount) { + const x = (amount || 0) * 2 / 3 + 1; + const y = ((x - 1) * -0.5); + _filter.colorMatrix([ + x, y, y, 0, 0, + y, x, y, 0, 0, + y, y, x, 0, 0, + 0, 0, 0, 1, 0, + ]); + }; + + _filter.desaturate = function () { + _filter.saturation(-1); + }; + + _filter.contrast = function (amount) { + const v = (amount || 0) + 1; + const o = -128 * (v - 1); + + _filter.colorMatrix([ + v, 0, 0, 0, o, + 0, v, 0, 0, o, + 0, 0, v, 0, o, + 0, 0, 0, 1, 0, + ]); + }; + + _filter.negative = function () { + _filter.contrast(-2); + }; + + _filter.hue = function (rotation) { + rotation = (rotation || 0) / 180 * Math.PI; + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); + const lumR = 0.213; + const lumG = 0.715; + const lumB = 0.072; + + _filter.colorMatrix([ + lumR + cos * (1 - lumR) + sin * (-lumR), lumG + cos * (-lumG) + sin * (-lumG), lumB + cos * (-lumB) + sin * (1 - lumB), 0, 0, + lumR + cos * (-lumR) + sin * (0.143), lumG + cos * (1 - lumG) + sin * (0.140), lumB + cos * (-lumB) + sin * (-0.283), 0, 0, + lumR + cos * (-lumR) + sin * (-(1 - lumR)), lumG + cos * (-lumG) + sin * (lumG), lumB + cos * (1 - lumB) + sin * (lumB), 0, 0, + 0, 0, 0, 1, 0, + ]); + }; + + _filter.desaturateLuminance = function () { + _filter.colorMatrix([ + 0.2764723, 0.9297080, 0.0938197, 0, -37.1, + 0.2764723, 0.9297080, 0.0938197, 0, -37.1, + 0.2764723, 0.9297080, 0.0938197, 0, -37.1, + 0, 0, 0, 1, 0, + ]); + }; + + _filter.sepia = function () { + _filter.colorMatrix([ + 0.393, 0.7689999, 0.18899999, 0, 0, + 0.349, 0.6859999, 0.16799999, 0, 0, + 0.272, 0.5339999, 0.13099999, 0, 0, + 0, 0, 0, 1, 0, + ]); + }; + + _filter.brownie = function () { + _filter.colorMatrix([ + 0.5997023498159715, 0.34553243048391263, -0.2708298674538042, 0, 47.43192855600873, + -0.037703249837783157, 0.8609577587992641, 0.15059552388459913, 0, -36.96841498319127, + 0.24113635128153335, -0.07441037908422492, 0.44972182064877153, 0, -7.562075277591283, + 0, 0, 0, 1, 0, + ]); + }; + + _filter.vintagePinhole = function () { + _filter.colorMatrix([ + 0.6279345635605994, 0.3202183420819367, -0.03965408211312453, 0, 9.651285835294123, + 0.02578397704808868, 0.6441188644374771, 0.03259127616149294, 0, 7.462829176470591, + 0.0466055556782719, -0.0851232987247891, 0.5241648018700465, 0, 5.159190588235296, + 0, 0, 0, 1, 0, + ]); + }; + + _filter.kodachrome = function () { + _filter.colorMatrix([ + 1.1285582396593525, -0.3967382283601348, -0.03992559172921793, 0, 63.72958762196502, + -0.16404339962244616, 1.0835251566291304, -0.05498805115633132, 0, 24.732407896706203, + -0.16786010706155763, -0.5603416277695248, 1.6014850761964943, 0, 35.62982807460946, + 0, 0, 0, 1, 0, + ]); + }; + + _filter.technicolor = function () { + _filter.colorMatrix([ + 1.9125277891456083, -0.8545344976951645, -0.09155508482755585, 0, 11.793603434377337, + -0.3087833385928097, 1.7658908555458428, -0.10601743074722245, 0, -70.35205161461398, + -0.231103377548616, -0.7501899197440212, 1.847597816108189, 0, 30.950940869491138, + 0, 0, 0, 1, 0, + ]); + }; + + _filter.polaroid = function () { + _filter.colorMatrix([ + 1.438, -0.062, -0.062, 0, 0, + -0.122, 1.378, -0.122, 0, 0, + -0.016, -0.016, 1.483, 0, 0, + 0, 0, 0, 1, 0, + ]); + }; + + _filter.shiftToBGR = function () { + _filter.colorMatrix([ + 0, 0, 1, 0, 0, + 0, 1, 0, 0, 0, + 1, 0, 0, 0, 0, + 0, 0, 0, 1, 0, + ]); + }; + + // ------------------------------------------------------------------------- + // Convolution Filter + + _filter.convolution = function (matrix) { + const m = new Float32Array(matrix); + const pixelSizeX = 1 / _width; + const pixelSizeY = 1 / _height; + + const program = _compileShader(_filter.convolution.SHADER); + gl.uniform1fv(program.uniform.m, m); + gl.uniform2f(program.uniform.px, pixelSizeX, pixelSizeY); + _draw(); + }; + + _filter.convolution.SHADER = [ + 'precision highp float;', + 'varying vec2 vUv;', + 'uniform sampler2D texture;', + 'uniform vec2 px;', + 'uniform float m[9];', + + 'void main(void) {', + 'vec4 c11 = texture2D(texture, vUv - px);', // top left + 'vec4 c12 = texture2D(texture, vec2(vUv.x, vUv.y - px.y));', // top center + 'vec4 c13 = texture2D(texture, vec2(vUv.x + px.x, vUv.y - px.y));', // top right + + 'vec4 c21 = texture2D(texture, vec2(vUv.x - px.x, vUv.y) );', // mid left + 'vec4 c22 = texture2D(texture, vUv);', // mid center + 'vec4 c23 = texture2D(texture, vec2(vUv.x + px.x, vUv.y) );', // mid right + + 'vec4 c31 = texture2D(texture, vec2(vUv.x - px.x, vUv.y + px.y) );', // bottom left + 'vec4 c32 = texture2D(texture, vec2(vUv.x, vUv.y + px.y) );', // bottom center + 'vec4 c33 = texture2D(texture, vUv + px );', // bottom right + + 'gl_FragColor = ', + 'c11 * m[0] + c12 * m[1] + c22 * m[2] +', + 'c21 * m[3] + c22 * m[4] + c23 * m[5] +', + 'c31 * m[6] + c32 * m[7] + c33 * m[8];', + 'gl_FragColor.a = c22.a;', + '}', + ].join('\n'); + + _filter.detectEdges = function () { + _filter.convolution.call(this, [ + 0, 1, 0, + 1, -4, 1, + 0, 1, 0, + ]); + }; + + _filter.sobelX = function () { + _filter.convolution.call(this, [ + -1, 0, 1, + -2, 0, 2, + -1, 0, 1, + ]); + }; + + _filter.sobelY = function () { + _filter.convolution.call(this, [ + -1, -2, -1, + 0, 0, 0, + 1, 2, 1, + ]); + }; + + _filter.sharpen = function (amount) { + const a = amount || 1; + _filter.convolution.call(this, [ + 0, -1 * a, 0, + -1 * a, 1 + 4 * a, -1 * a, + 0, -1 * a, 0, + ]); + }; + + _filter.emboss = function (size) { + const s = size || 1; + _filter.convolution.call(this, [ + -2 * s, -1 * s, 0, + -1 * s, 1, 1 * s, + 0, 1 * s, 2 * s, + ]); + }; + + // ------------------------------------------------------------------------- + // Blur Filter + + _filter.blur = function (size) { + const blurSizeX = (size / 7) / _width; + const blurSizeY = (size / 7) / _height; + + const program = _compileShader(_filter.blur.SHADER); + + // Vertical + gl.uniform2f(program.uniform.px, 0, blurSizeY); + _draw(DRAW.INTERMEDIATE); + + // Horizontal + gl.uniform2f(program.uniform.px, blurSizeX, 0); + _draw(); + }; + + _filter.blur.SHADER = [ + 'precision highp float;', + 'varying vec2 vUv;', + 'uniform sampler2D texture;', + 'uniform vec2 px;', + + 'void main(void) {', + 'gl_FragColor = vec4(0.0);', + 'gl_FragColor += texture2D(texture, vUv + vec2(-7.0*px.x, -7.0*px.y))*0.0044299121055113265;', + 'gl_FragColor += texture2D(texture, vUv + vec2(-6.0*px.x, -6.0*px.y))*0.00895781211794;', + 'gl_FragColor += texture2D(texture, vUv + vec2(-5.0*px.x, -5.0*px.y))*0.0215963866053;', + 'gl_FragColor += texture2D(texture, vUv + vec2(-4.0*px.x, -4.0*px.y))*0.0443683338718;', + 'gl_FragColor += texture2D(texture, vUv + vec2(-3.0*px.x, -3.0*px.y))*0.0776744219933;', + 'gl_FragColor += texture2D(texture, vUv + vec2(-2.0*px.x, -2.0*px.y))*0.115876621105;', + 'gl_FragColor += texture2D(texture, vUv + vec2(-1.0*px.x, -1.0*px.y))*0.147308056121;', + 'gl_FragColor += texture2D(texture, vUv )*0.159576912161;', + 'gl_FragColor += texture2D(texture, vUv + vec2( 1.0*px.x, 1.0*px.y))*0.147308056121;', + 'gl_FragColor += texture2D(texture, vUv + vec2( 2.0*px.x, 2.0*px.y))*0.115876621105;', + 'gl_FragColor += texture2D(texture, vUv + vec2( 3.0*px.x, 3.0*px.y))*0.0776744219933;', + 'gl_FragColor += texture2D(texture, vUv + vec2( 4.0*px.x, 4.0*px.y))*0.0443683338718;', + 'gl_FragColor += texture2D(texture, vUv + vec2( 5.0*px.x, 5.0*px.y))*0.0215963866053;', + 'gl_FragColor += texture2D(texture, vUv + vec2( 6.0*px.x, 6.0*px.y))*0.00895781211794;', + 'gl_FragColor += texture2D(texture, vUv + vec2( 7.0*px.x, 7.0*px.y))*0.0044299121055113265;', + '}', + ].join('\n'); + + // ------------------------------------------------------------------------- + // Pixelate Filter + + _filter.pixelate = function (size) { + const blurSizeX = (size) / _width; + const blurSizeY = (size) / _height; + + const program = _compileShader(_filter.pixelate.SHADER); + + // Horizontal + gl.uniform2f(program.uniform.size, blurSizeX, blurSizeY); + _draw(); + }; + + _filter.pixelate.SHADER = [ + 'precision highp float;', + 'varying vec2 vUv;', + 'uniform vec2 size;', + 'uniform sampler2D texture;', + + 'vec2 pixelate(vec2 coord, vec2 size) {', + 'return floor( coord / size ) * size;', + '}', + + 'void main(void) {', + 'gl_FragColor = vec4(0.0);', + 'vec2 coord = pixelate(vUv, size);', + 'gl_FragColor += texture2D(texture, coord);', + '}', + ].join('\n'); +}; + +exports.Canvas = WebGLImageFilter;