UNPKG

pam-diff

Version:

Measure differences between pixel arrays extracted from pam images

621 lines (573 loc) 14.5 kB
'use strict'; const { Transform } = require('node:stream'); const { performance } = require('node:perf_hooks'); const PP = require('polygon-points'); const PC = require('pixel-change'); class PamDiff extends Transform { /** * * @param [options] {Object} * @param [options.difference=5] {Number} - Pixel difference value, int 1 to 255 * @param [options.percent=5] {Number} - Percent of pixels or blobs that exceed difference value, float 0.0 to 100.0 * @param [options.response=percent] {String} - Accepted values: percent or bounds or blobs * @param [options.regions] {Array} - Array of region objects * @param options.regions[i].name {String} - Name of region * @param [options.regions[i].difference=options.difference] {Number} - Difference value for region, int 1 to 255 * @param [options.regions[i].percent=options.percent] {Number} - Percent value for region, float 0.0 to 100.0 * @param options.regions[i].polygon {Array} - Array of x y coordinates [{x:0,y:0},{x:0,y:360},{x:160,y:360},{x:160,y:0}] * @param [options.mask=false] {Boolean} - Indicate if regions should be used as masks of pixels to ignore * @param [options.draw=false] {Boolean} - If true and response is 'bounds' or 'blobs', return a pixel buffer with drawn bounding box * @param [options.debug=false] {Boolean} - If true, debug object will be attached to output * @param [callback] {Function} - Function to be called when diff event occurs. Deprecated */ constructor(options, callback) { super({ objectMode: true }); this.config = options; // configuration for pixel change detection this.callback = callback; // callback function to be called when pixel change is detected this._parseChunk = this._parseFirstChunk; // first parsing will be used to configure pixel diff engine } /** * * @param obj {Object} */ set config(obj) { obj = PamDiff._validateObject(obj); this._difference = PamDiff._validateInt(obj.difference, 5, 1, 255); this._percent = PamDiff._validateFloat(obj.percent, 5, 0, 100); this._response = PamDiff._validateString(obj.response, ['percent', 'bounds', 'blobs']); this._regions = PamDiff._validateArray(obj.regions); this._mask = PamDiff._validateBoolean(obj.mask); this._draw = PamDiff._validateBoolean(obj.draw); this._debug = PamDiff._validateBoolean(obj.debug); this._configurePixelDiffEngine(); } /** * * @returns {Object} */ get config() { return { difference: this._difference, percent: this._percent, response: this._response, regions: this._regions, mask: this._mask, draw: this._draw, debug: this._debug, }; } /** * * @param num {Number} */ set difference(num) { this._difference = PamDiff._validateInt(num, 5, 1, 255); this._configurePixelDiffEngine(); } /** * * @return {Number} */ get difference() { return this._difference; } /** * * @param num {Number} * @return {PamDiff} * @deprecated */ setDifference(num) { this.difference = num; return this; } /** * * @param num {Number|String} */ set percent(num) { this._percent = PamDiff._validateFloat(num, 5, 0, 100); this._configurePixelDiffEngine(); } /** * * @return {Number} */ get percent() { return this._percent; } /** * * @param num {Number} * @return {PamDiff} * @deprecated */ setPercent(num) { this.percent = num; return this; } /** * * @param str {String} */ set response(str) { this._response = PamDiff._validateString(str, ['percent', 'bounds', 'blobs']); this._configurePixelDiffEngine(); } /** * * @return {String} */ get response() { return this._response; } /** * * @param str {String} * @return {PamDiff} * @deprecated */ setResponse(str) { this.response = str; return this; } /** * * @param arr {Array} */ set regions(arr) { this._regions = PamDiff._validateArray(arr); this._configurePixelDiffEngine(); } /** * * @return {Array} */ get regions() { return this._regions; } /** * * @param arr {Object[]} * @return {PamDiff} * @deprecated */ setRegions(arr) { this.regions = arr; return this; } /** * * @param bool {Boolean|String|Number} */ set mask(bool) { this._mask = PamDiff._validateBoolean(bool); this._configurePixelDiffEngine(); } /** * * @returns {Boolean} */ get mask() { return this._mask; } /** * * @param bool {Boolean} * @returns {PamDiff} * @deprecated */ setMask(bool) { this.mask = bool; return this; } /** * * @param bool {Boolean} */ set draw(bool) { this._draw = PamDiff._validateBoolean(bool); this._configurePixelDiffEngine(); } /** * * @return {Boolean} */ get draw() { return this._draw; } /** * * @param bool {Boolean} * @return {PamDiff} * @deprecated */ setDraw(bool) { this.draw = bool; return this; } /** * * @param bool {Boolean|String|Number} */ set debug(bool) { this._debug = PamDiff._validateBoolean(bool); this._configurePixelDiffEngine(); } /** * * @return {Boolean} */ get debug() { return this._debug; } /** * * @param bool {Boolean} * @return {PamDiff} * @deprecated */ setDebug(bool) { this.debug = bool; return this; } /** * * @param func {Function} * @deprecated */ set callback(func) { if (!func) { this._callback = undefined; } 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} * @deprecated */ get callback() { return this._callback; } /** * * @param func {Function} * @return {PamDiff} * @deprecated */ setCallback(func) { this.callback = func; return this; } /** * * @return {PamDiff} * @deprecated */ resetCache() { return this.reset(); } /** * * @return {PamDiff} */ reset() { this.emit('reset'); this._debugInfo = undefined; this._engine = undefined; this._oldPix = undefined; this._width = undefined; this._height = undefined; this._depth = undefined; this._tupltype = undefined; this._parseChunk = this._parseFirstChunk; return this; } /** * * @returns {Array|null} * @private */ _processRegions() { if (this._regions) { const regions = []; if (this._mask === true) { // combine all regions to form a single region of flipped 0's and 1's let minX = this._width; let maxX = 0; let minY = this._height; let maxY = 0; const wxh = this._width * this._height; const maskBitset = Buffer.alloc(wxh, 1); for (const region of this._regions) { if (!region.hasOwnProperty('polygon')) { throw new Error('Region must include a polygon property'); } const pp = new PP(region.polygon); const bitset = pp.getBitset(this._width, this._height); if (bitset.count === 0) { throw new Error('Bitset count must be greater than 0.'); } const bitsetBuffer = bitset.buffer; for (let i = 0; i < wxh; ++i) { if (bitsetBuffer[i] === 1) { maskBitset[i] = 0; } } } let maskBitsetCount = 0; for (let i = 0; i < wxh; ++i) { if (maskBitset[i] === 1) { const y = Math.floor(i / this._width); const x = i % this._width; minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); maskBitsetCount++; } } if (maskBitsetCount === 0) { throw new Error('Bitset count must be greater than 0'); } regions.push({ name: 'mask', bitset: maskBitset, bitsetCount: maskBitsetCount, difference: this._difference, percent: this._percent, minX: minX, maxX: maxX, minY: minY, maxY: maxY, }); } else { for (const region of this._regions) { if (!region.hasOwnProperty('name') || !region.hasOwnProperty('polygon')) { throw new Error('Region must include a name and a polygon property'); } const pp = new PP(region.polygon); const bitset = pp.getBitset(this._width, this._height); if (bitset.count === 0) { throw new Error('Bitset count must be greater than 0'); } const difference = PamDiff._validateInt(region.difference, this._difference, 1, 255); const percent = PamDiff._validateFloat(region.percent, this._percent, 0, 100); regions.push({ name: region.name, bitset: bitset.buffer, bitsetCount: bitset.count, difference: difference, percent: percent, minX: bitset.minX, maxX: bitset.maxX, minY: bitset.minY, maxY: bitset.maxY, }); } } return regions; } return null; } /** * * @private */ _configurePixelDiffEngine() { if (!this._tupltype || !this._width || !this._height) { return; } const regions = this._processRegions(); let name = `${this._tupltype}_${this._width}w_${this._height}h_${this._depth}d`; const config = { width: this._width, height: this._height, depth: this._depth, response: this._response }; if (regions) { if (regions.length === 1) { if (this._mask === true) { name += '_mask'; } else { name += '_region'; } } else { name += `_regions`; } config.regions = regions; } else { name += '_all'; config.difference = this._difference; config.percent = this._percent; } name += `_${this._response}`; if ((this._response === 'bounds' || this._response === 'blobs') && this._draw) { config.draw = this._draw; name += '_draw'; } name += '_async'; const pixelChange = PC(config); this._engine = pixelChange.compare.bind(pixelChange); if (this._debug) { this._parseChunk = this._parsePixelsDebug; this._debugInfo = { name, count: 0 }; } else { this._parseChunk = this._parsePixels; } } /** * * @param chunk {Object} * @private */ _parsePixels(chunk) { const oldPix = this._oldPix; const newPix = (this._oldPix = chunk.pixels); this._engine(oldPix, newPix, (err, data) => { if (data) { const { results, pixels } = data; const diff = { trigger: results, pam: chunk.pam, headers: chunk.headers, pixels: pixels || newPix }; this.emit('data', diff); if (results.length) { this.emit('diff', diff); if (this._callback) { this._callback(diff); } } } else { throw new Error(err); } }); } /** * * @param chunk {Object} * @private */ _parsePixelsDebug(chunk) { const oldPix = this._oldPix; const newPix = (this._oldPix = chunk.pixels); const count = ++this._debugInfo.count; const name = this._debugInfo.name; const start = performance.now(); this._engine(oldPix, newPix, (err, data) => { const duration = Math.round((performance.now() - start) * 1000) / 1000; if (data) { const { results, pixels } = data; const diff = { trigger: results, pam: chunk.pam, headers: chunk.headers, pixels: pixels || newPix, debug: { name, count, duration } }; this.emit('data', diff); if (results.length) { this.emit('diff', diff); if (this._callback) { this._callback(diff); } } } else { throw new Error(err); } }); } /** * * @param chunk {Object} * @private */ _parseFirstChunk(chunk) { this._width = Number.parseInt(chunk.width); this._height = Number.parseInt(chunk.height); this._depth = Number.parseInt(chunk.depth); this._oldPix = chunk.pixels; this._tupltype = chunk.tupltype; this._configurePixelDiffEngine(); this.emit('initialized', { width: this._width, height: this._height, depth: this._depth, tupltype: this._tupltype }); } /** * * @param chunk {Object} * @param encoding * @param callback * @private */ _transform(chunk, encoding, callback) { this._parseChunk(chunk); callback(); } /** * * @param callback * @private */ _flush(callback) { this.reset(); callback(); } /** * * @param num {Number|String} * @param def {Number} * @param min {Number} * @param max {Number} * @returns {Number} * @private */ static _validateInt(num, def, min, max) { num = Number.parseInt(num); return Number.isNaN(num) ? def : num < min ? min : num > max ? max : num; } /** * * @param num {Number|String} * @param def {Number} * @param min {Number} * @param max {Number} * @returns {Number} * @private */ static _validateFloat(num, def, min, max) { num = Number.parseFloat(num); return Number.isNaN(num) ? def : num < min ? min : num > max ? max : num; } /** * * @param bool {Boolean|String|Number} * @return {Boolean} * @private */ static _validateBoolean(bool) { return bool === true || bool === 'true' || bool === 1 || bool === '1'; } /** * * @param str {String} * @param arr {String[]} * @returns {String} * @private */ static _validateString(str, arr) { return arr.includes(str) ? str : arr[0]; } /** * * @param arr (Array} * @returns {Array|null} * @private */ static _validateArray(arr) { return Array.isArray(arr) && arr.length ? arr : null; } /** * * @param obj (Object} * @returns {Object} * @private */ static _validateObject(obj) { return obj && typeof obj === 'object' ? obj : {}; } } /** * * @type {PamDiff} */ module.exports = PamDiff;