// // Shinobi - fork of pam-diff // Copyright (C) 2018 Kevin Godell // Author : Kevin Godell, https://github.com/kevinGodell // npmjs : https://www.npmjs.com/package/pam-diff // Github : https://github.com/kevinGodell/pam-diff // 'use strict'; const { Transform } = require('stream'); const PP = require('polygon-points'); /** * * @param chunk * @private */ var _getMatrixFromPoints = function(thisRegion) { var coordinates = [ thisRegion.topLeft, {"x" : thisRegion.bottomRight.x, "y" : thisRegion.topLeft.y}, thisRegion.bottomRight ] var width = Math.sqrt( Math.pow(coordinates[1].x - coordinates[0].x, 2) + Math.pow(coordinates[1].y - coordinates[0].y, 2)); var height = Math.sqrt( Math.pow(coordinates[2].x - coordinates[1].x, 2) + Math.pow(coordinates[2].y - coordinates[1].y, 2)) return { x: coordinates[0].x, y: coordinates[0].y, width: width, height: height, tag: thisRegion.name } } class PamDiff extends Transform { /** * * @param [options] {Object} * @param [callback] {Function} */ constructor(options, callback) { super(options); Transform.call(this, {objectMode: true}); this.difference = PamDiff._parseOptions('difference', options);//global option, can be overridden per region this.percent = PamDiff._parseOptions('percent', options);//global option, can be overridden per region this.regions = PamDiff._parseOptions('regions', options);//can be no regions or a single region or multiple regions. if no regions, all pixels will be compared. this.drawMatrix = PamDiff._parseOptions('drawMatrix', options);//can be no regions or a single region or multiple regions. if no regions, all pixels will be compared. this.callback = callback;//callback function to be called when pixel difference is detected this._parseChunk = this._parseFirstChunk;//first parsing will be reading settings and configuring internal pixel reading } /** * * @param option {String} * @param options {Object} * @return {*} * @private */ static _parseOptions(option, options) { if (options && options.hasOwnProperty(option)) { return options[option]; } return null; } /** * * @param number {Number} * @param def {Number} * @param low {Number} * @param high {Number} * @return {Number} * @private */ static _validateNumber(number, def, low, high) { if (isNaN(number)) { return def; } else if (number < low) { return low; } else if (number > high) { return high; } else { return number; } } /** * * @deprecated * @param string {String} */ setGrayscale(string) { console.warn('grayscale option has been removed, "average" has proven to most accurate and is the default'); } /** * * @param number {Number} */ set difference(number) { this._difference = PamDiff._validateNumber(parseInt(number), 5, 1, 255); } /** * * @return {Number} */ get difference() { return this._difference; } /** * * @param number {Number} * @return {PamDiff} */ setDifference(number) { this.difference = number; return this; } /** * * @param number {Number} */ set percent(number) { this._percent = PamDiff._validateNumber(parseInt(number), 5, 1, 100); } /** * * @return {Number} */ get percent() { return this._percent; } /** * * @param number {Number} * @return {PamDiff} */ setPercent(number) { this.percent = number; return this; } /** * * @param array {Array} */ set regions(array) { if (!array) { if (this._regions) { delete this._regions; delete this._regionsLength; delete this._minDiff; } this._diffs = 0; } else if (!Array.isArray(array) || array.length < 1) { throw new Error(`Regions must be an array of at least 1 region object {name: 'region1', difference: 10, percent: 10, polygon: [[0, 0], [0, 50], [50, 50], [50, 0]]}`); } else { this._regions = []; this._minDiff = 255; for (const region of array) { if (!region.hasOwnProperty('name') || !region.hasOwnProperty('polygon')) { throw new Error('Region must include a name and a polygon property'); } const polygonPoints = new PP(region.polygon); const difference = PamDiff._validateNumber(parseInt(region.difference), this._difference, 1, 255); const percent = PamDiff._validateNumber(parseInt(region.percent), this._percent, 1, 100); this._minDiff = Math.min(this._minDiff, difference); this._regions.push( { name: region.name, polygon: polygonPoints, difference: difference, percent: percent, diffs: 0 } ); } this._regionsLength = this._regions.length; this._createPointsInPolygons(this._regions, this._width, this._height); } } /** * * @return {Array} */ get regions() { return this._regions; } /** * * @param array {Array} * @return {PamDiff} */ setRegions(array) { this.regions = array; return this; } /** * * @param func {Function} */ set callback(func) { if (!func) { delete this._callback; } else if (typeof func === 'function' && func.length === 1) { this._callback = func; } else { throw new Error('Callback must be a function that accepts 1 argument.'); } } /** * * @return {Function} */ get callback() { return this._callback; } /** * * @param func {Function} * @return {PamDiff} */ setCallback(func) { this.callback = func; return this; } /** * * @return {PamDiff} */ resetCache() { //delete this._oldPix; //delete this._newPix; //delete this._width; //delete this._length; this._parseChunk = this._parseFirstChunk; return this; } /** * * @param regions {Array} * @param width {Number} * @param height {Number} * @private */ _createPointsInPolygons(regions, width, height) { if (regions && width && height) { this._pointsInPolygons = []; for (const region of regions) { const bitset = region.polygon.getBitset(this._width, this._height); region.pointsLength = bitset.count; this._pointsInPolygons.push(bitset.buffer); } } } /** * * @param chunk * @private */ _blackAndWhitePixelDiff(chunk) { this._newPix = chunk.pixels; for (let y = 0, i = 0; y < this._height; y++) { for (let x = 0; x < this._width; x++, i++) { const diff = this._oldPix[i] !== this._newPix[i]; if (this._regions && diff === true) { for (let j = 0; j < this._regionsLength; j++) { if (this._pointsInPolygons[j][i]) { this._regions[j].diffs++; } } } else if (diff === true) { this._diffs++; } } } if (this._regions) { const regionDiffArray = []; for (let i = 0; i < this._regionsLength; i++) { const percent = Math.floor(100 * this._regions[i].diffs / this._regions[i].pointsLength); if (percent >= this._regions[i].percent) { regionDiffArray.push({name: this._regions[i].name, percent: percent}); } this._regions[i].diffs = 0; } if (regionDiffArray.length > 0) { const data = {trigger: regionDiffArray, pam: chunk.pam}; if (this._callback) { this._callback(data); } if (this._readableState.pipesCount > 0) { this.push(data); } if (this.listenerCount('diff') > 0) { this.emit('diff', data); } } } else { const percent = Math.floor(100 * this._diffs / this._length); if (percent >= this._percent) { const data = {trigger: [{name: 'percent', percent: percent}], pam: chunk.pam}; if (this._callback) { this._callback(data); } if (this._readableState.pipesCount > 0) { this.push(data); } if (this.listenerCount('diff') > 0) { this.emit('diff', data); } } this._diffs = 0; } this._oldPix = this._newPix; } /** * * @param chunk * @private */ _grayScalePixelDiffWithMatrices(chunk) { this._newPix = chunk.pixels; for (let j = 0; j < this._regionsLength; j++) { this._regions[j].topLeft = { x: this._width, y: this._height } this._regions[j].bottomRight = { x: 0, y: 0 } } this.topLeft = { x: this._width, y: this._height } this.bottomRight = { x: 0, y: 0 } for (let y = 0, i = 0; y < this._height; y++) { for (let x = 0; x < this._width; x++, i++) { if (this._oldPix[i] !== this._newPix[i]) { const diff = Math.abs(this._oldPix[i] - this._newPix[i]); if (this._regions && diff >= this._minDiff) { for (let j = 0; j < this._regionsLength; j++) { if (this._pointsInPolygons[j][i] && diff >= this._regions[j].difference) { var theRegion = this._regions[j] theRegion.diffs++; if(theRegion.topLeft.x > x && theRegion.topLeft.y > y){ theRegion.topLeft.x = x theRegion.topLeft.y = y } if(theRegion.bottomRight.x < x && theRegion.bottomRight.y < y){ theRegion.bottomRight.x = x theRegion.bottomRight.y = y } } } } else if (diff >= this._difference) { this._diffs++; if(this.topLeft.x > x && this.topLeft.y > y){ this.topLeft.x = x this.topLeft.y = y } if(this.bottomRight.x < x && this.bottomRight.y < y){ this.bottomRight.x = x this.bottomRight.y = y } } } } } if (this._regions) { const regionDiffArray = []; for (let i = 0; i < this._regionsLength; i++) { var thisRegion = this._regions[i] const percent = Math.floor(100 * thisRegion.diffs / thisRegion.pointsLength); if (percent >= thisRegion.percent) { // create matrix from points >> thisRegion._matrix = _getMatrixFromPoints(thisRegion) // create matrix from points />> regionDiffArray.push({name: thisRegion.name, percent: percent, matrix: thisRegion._matrix}); } thisRegion.diffs = 0; } if (regionDiffArray.length > 0) { this._matrix = _getMatrixFromPoints(this) const data = {trigger: regionDiffArray, pam: chunk.pam, matrix: this._matrix}; if (this._callback) { this._callback(data); } if (this._readableState.pipesCount > 0) { this.push(data); } if (this.listenerCount('diff') > 0) { this.emit('diff', data); } } } else { const percent = Math.floor(100 * this._diffs / this._length); if (percent >= this._percent) { this._matrix = _getMatrixFromPoints(this) const data = {trigger: [{name: 'percent', percent: percent, matrix: this._matrix}], pam: chunk.pam}; if (this._callback) { this._callback(data); } if (this._readableState.pipesCount > 0) { this.push(data); } if (this.listenerCount('diff') > 0) { this.emit('diff', data); } } this._diffs = 0; } this._oldPix = this._newPix; } /** * * @param chunk * @private */ _grayScalePixelDiff(chunk) { this._newPix = chunk.pixels; for (let y = 0, i = 0; y < this._height; y++) { for (let x = 0; x < this._width; x++, i++) { if (this._oldPix[i] !== this._newPix[i]) { const diff = Math.abs(this._oldPix[i] - this._newPix[i]); if (this._regions && diff >= this._minDiff) { for (let j = 0; j < this._regionsLength; j++) { if (this._pointsInPolygons[j][i] && diff >= this._regions[j].difference) { this._regions[j].diffs++; } } } else { if (diff >= this._difference) { this._diffs++; } } } } } if (this._regions) { const regionDiffArray = []; for (let i = 0; i < this._regionsLength; i++) { const percent = Math.floor(100 * this._regions[i].diffs / this._regions[i].pointsLength); if (percent >= this._regions[i].percent) { regionDiffArray.push({name: this._regions[i].name, percent: percent}); } this._regions[i].diffs = 0; } if (regionDiffArray.length > 0) { const data = {trigger: regionDiffArray, pam: chunk.pam}; if (this._callback) { this._callback(data); } if (this._readableState.pipesCount > 0) { this.push(data); } if (this.listenerCount('diff') > 0) { this.emit('diff', data); } } } else { const percent = Math.floor(100 * this._diffs / this._length); if (percent >= this._percent) { const data = {trigger: [{name: 'percent', percent: percent}], pam: chunk.pam}; if (this._callback) { this._callback(data); } if (this._readableState.pipesCount > 0) { this.push(data); } if (this.listenerCount('diff') > 0) { this.emit('diff', data); } } this._diffs = 0; } this._oldPix = this._newPix; } /** * * @param chunk * @private */ _rgbPixelDiff(chunk) { this._newPix = chunk.pixels; for (let y = 0, i = 0, p = 0; y < this._height; y++) { for (let x = 0; x < this._width; x++, i += 3, p++) { if (this._oldPix[i] !== this._newPix[i] || this._oldPix[i + 1] !== this._newPix[i + 1] || this._oldPix[i + 2] !== this._newPix[i + 2]) { const diff = Math.abs(this._oldPix[i] + this._oldPix[i + 1] + this._oldPix[i + 2] - this._newPix[i] - this._newPix[i + 1] - this._newPix[i + 2])/3; if (this._regions && diff >= this._minDiff) { for (let j = 0; j < this._regionsLength; j++) { if (this._pointsInPolygons[j][p] && diff >= this._regions[j].difference) { this._regions[j].diffs++; } } } else { if (diff >= this._difference) { this._diffs++; } } } } } if (this._regions) { const regionDiffArray = []; for (let i = 0; i < this._regionsLength; i++) { const percent = Math.floor(100 * this._regions[i].diffs / this._regions[i].pointsLength); if (percent >= this._regions[i].percent) { regionDiffArray.push({name: this._regions[i].name, percent: percent}); } this._regions[i].diffs = 0; } if (regionDiffArray.length > 0) { const data = {trigger: regionDiffArray, pam: chunk.pam}; if (this._callback) { this._callback(data); } if (this._readableState.pipesCount > 0) { this.push(data); } if (this.listenerCount('diff') > 0) { this.emit('diff', data); } } } else { const percent = Math.floor(100 * this._diffs / this._length); if (percent >= this._percent) { const data = {trigger: [{name: 'percent', percent: percent}], pam: chunk.pam}; if (this._callback) { this._callback(data); } if (this._readableState.pipesCount > 0) { this.push(data); } if (this.listenerCount('diff') > 0) { this.emit('diff', data); } } this._diffs = 0; } this._oldPix = this._newPix; } /** * * @param chunk * @private */ _rgbAlphaPixelDiff(chunk) { this._newPix = chunk.pixels; for (let y = 0, i = 0, p = 0; y < this._height; y++) { for (let x = 0; x < this._width; x++, i += 4, p++) { if (this._oldPix[i] !== this._newPix[i] || this._oldPix[i + 1] !== this._newPix[i + 1] || this._oldPix[i + 2] !== this._newPix[i + 2]) { const diff = Math.abs(this._oldPix[i] + this._oldPix[i + 1] + this._oldPix[i + 2] - this._newPix[i] - this._newPix[i + 1] - this._newPix[i + 2])/3; if (this._regions && diff >= this._minDiff) { for (let j = 0; j < this._regionsLength; j++) { if (this._pointsInPolygons[j][p] && diff >= this._regions[j].difference) { this._regions[j].diffs++; } } } else { if (diff >= this._difference) { this._diffs++; } } } } } if (this._regions) { const regionDiffArray = []; for (let i = 0; i < this._regionsLength; i++) { const percent = Math.floor(100 * this._regions[i].diffs / this._regions[i].pointsLength); if (percent >= this._regions[i].percent) { regionDiffArray.push({name: this._regions[i].name, percent: percent}); } this._regions[i].diffs = 0; } if (regionDiffArray.length > 0) { const data = {trigger: regionDiffArray, pam: chunk.pam}; if (this._callback) { this._callback(data); } if (this._readableState.pipesCount > 0) { this.push(data); } if (this.listenerCount('diff') > 0) { this.emit('diff', data); } } } else { const percent = Math.floor(100 * this._diffs / this._length); if (percent >= this._percent) { const data = {trigger: [{name: 'percent', percent: percent}], pam: chunk.pam}; if (this._callback) { this._callback(data); } if (this._readableState.pipesCount > 0) { this.push(data); } if (this.listenerCount('diff') > 0) { this.emit('diff', data); } } this._diffs = 0; } this._oldPix = this._newPix; } /** * * @param chunk * @private */ _parseFirstChunk(chunk) { this._width = parseInt(chunk.width); this._height = parseInt(chunk.height); this._oldPix = chunk.pixels; this._length = this._width * this._height; this._createPointsInPolygons(this._regions, this._width, this._height); switch (chunk.tupltype) { case 'blackandwhite' : this._parseChunk = this._blackAndWhitePixelDiff; break; case 'grayscale' : if(this.drawMatrix === "1"){ this._parseChunk = this._grayScalePixelDiffWithMatrices; }else{ this._parseChunk = this._grayScalePixelDiff; } break; case 'rgb' : this._parseChunk = this._rgbPixelDiff; //this._increment = 3;//future use break; case 'rgb_alpha' : this._parseChunk = this._rgbAlphaPixelDiff; //this._increment = 4;//future use break; default : throw Error(`Unsupported tupltype: ${chunk.tupltype}. Supported tupltypes include grayscale(gray), blackandwhite(monob), rgb(rgb24), and rgb_alpha(rgba).`); } } /** * * @param chunk * @param encoding * @param callback * @private */ _transform(chunk, encoding, callback) { this._parseChunk(chunk); callback(); } /** * * @param callback * @private */ _flush(callback) { this.resetCache(); callback(); } } /** * * @type {PamDiff} */ module.exports = PamDiff; //todo get bounding box of all regions combined to exclude some pixels before checking if they exist inside specific regions