Source: index.js

'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;