src_image_image.js

import {Index} from '../math/index';
import {Point3D} from '../math/point';
import {logger} from '../utils/logger';
import {arrayContains} from '../utils/array';
import {getTypedArray} from '../dicom/dicomParser';
import {ListenerHandler} from '../utils/listen';
import {colourRange} from './iterator';
import {RescaleSlopeAndIntercept} from './rsi';
import {ImageFactory} from './imageFactory';
import {MaskFactory} from './maskFactory';

// doc imports
/* eslint-disable no-unused-vars */
import {Geometry} from './geometry';
import {Matrix33} from '../math/matrix';
import {NumberRange} from '../math/stats';
import {DataElement} from '../dicom/dataElement';
import {RGB} from '../utils/colour';
/* eslint-enable no-unused-vars */

/**
 * Get the slice index of an input slice into a volume geometry.
 *
 * @param {Geometry} volumeGeometry The volume geometry.
 * @param {Geometry} sliceGeometry The slice geometry.
 * @returns {Index} The index of the slice in the volume geomtry.
 */
function getSliceIndex(volumeGeometry, sliceGeometry) {
  // possible time
  const timeId = sliceGeometry.getInitialTime();
  // index values
  const values = [];
  // x, y
  values.push(0);
  values.push(0);
  // z
  values.push(volumeGeometry.getSliceIndex(sliceGeometry.getOrigin(), timeId));
  // time
  if (typeof timeId !== 'undefined') {
    values.push(timeId);
  }
  // return index
  return new Index(values);
}

/**
 * Create an Image from DICOM elements.
 *
 * @param {Object<string, DataElement>} elements The DICOM elements.
 * @returns {Image} The Image object.
 */
export function createImage(elements) {
  const factory = new ImageFactory();
  return factory.create(
    elements,
    elements['7FE00010'].value[0],
    1
  );
}

/**
 * Create a mask Image from DICOM elements.
 *
 * @param {Object<string, DataElement>} elements The DICOM elements.
 * @returns {Image} The mask Image object.
 */
export function createMaskImage(elements) {
  const factory = new MaskFactory();
  return factory.create(
    elements,
    elements['7FE00010'].value[0]
  );
}

/**
 * Image class.
 * Usable once created, optional are:
 * - rescale slope and intercept (default 1:0),
 * - photometric interpretation (default MONOCHROME2),
 * - planar configuration (default RGBRGB...).
 *
 * @example
 * // XMLHttpRequest onload callback
 * const onload = function (event) {
 *   // parse the dicom buffer
 *   const dicomParser = new dwv.DicomParser();
 *   dicomParser.parse(event.target.response);
 *   // create the image object
 *   const image = dwv.createImage(dicomParser.getDicomElements());
 *   // result div
 *   const div = document.getElementById('dwv');
 *   // display the image size
 *   const size = image.getGeometry().getSize();
 *   div.appendChild(document.createTextNode(
 *     'Size: ' + size.toString() +
 *     ' (should be 256,256,1)'));
 *   // break line
 *   div.appendChild(document.createElement('br'));
 *   // display a pixel value
 *   div.appendChild(document.createTextNode(
 *     'Pixel @ [128,40,0]: ' +
 *     image.getRescaledValue(128,40,0) +
 *     ' (should be 101)'));
 * };
 * // DICOM file request
 * const request = new XMLHttpRequest();
 * const url = 'https://raw.githubusercontent.com/ivmartel/dwv/master/tests/data/bbmri-53323851.dcm';
 * request.open('GET', url);
 * request.responseType = 'arraybuffer';
 * request.onload = onload;
 * request.send();
 */
export class Image {

  /**
   * Data geometry.
   *
   * @type {Geometry}
   */
  #geometry;

  /**
   * List of compatible typed arrays.
   *
   * @typedef {(
   *   Uint8Array | Int8Array |
   *   Uint16Array | Int16Array |
   *   Uint32Array | Int32Array
   * )} TypedArray
   */

  /**
   * Data buffer.
   *
   * @type {TypedArray}
   */
  #buffer;

  /**
   * Image UIDs.
   *
   * @type {string[]}
   */
  #imageUids;

  /**
   * Constant rescale slope and intercept (default).
   *
   * @type {RescaleSlopeAndIntercept}
   */
  #rsi = new RescaleSlopeAndIntercept(1, 0);

  /**
   * Varying rescale slope and intercept.
   *
   * @type {RescaleSlopeAndIntercept[]}
   */
  #rsis = null;

  /**
   * Flag to know if the RSIs are all identity (1,0).
   *
   * @type {boolean}
   */
  #isIdentityRSI = true;

  /**
   * Flag to know if the RSIs are all equals.
   *
   * @type {boolean}
   */
  #isConstantRSI = true;

  /**
   * Photometric interpretation (MONOCHROME, RGB...).
   *
   * @type {string}
   */
  #photometricInterpretation = 'MONOCHROME2';

  /**
   * Planar configuration for RGB data (`0:RGBRGBRGBRGB...` or
   *   `1:RRR...GGG...BBB...`).
   *
   * @type {number}
   */
  #planarConfiguration = 0;

  /**
   * Number of components.
   *
   * @type {number}
   */
  #numberOfComponents;

  /**
   * Meta information.
   *
   * @type {Object<string, any>}
   */
  #meta = {};

  /**
   * Data range.
   *
   * @type {NumberRange}
   */
  #dataRange = null;

  /**
   * Rescaled data range.
   *
   * @type {NumberRange}
   */
  #rescaledDataRange = null;

  /**
   * Histogram.
   *
   * @type {Array}
   */
  #histogram = null;

  /**
   * Listener handler.
   *
   * @type {ListenerHandler}
   */
  #listenerHandler = new ListenerHandler();

  /**
   * @param {Geometry} geometry The geometry of the image.
   * @param {TypedArray} buffer The image data as a one dimensional buffer.
   * @param {string[]} [imageUids] An array of Uids indexed to slice number.
   */
  constructor(geometry, buffer, imageUids) {
    this.#geometry = geometry;
    this.#buffer = buffer;
    this.#imageUids = imageUids;

    this.#numberOfComponents = this.#buffer.length / (
      this.#geometry.getSize().getTotalSize());
  }

  /**
   * Get the image UID at a given index.
   *
   * @param {Index} [index] The index at which to get the id.
   * @returns {string} The UID.
   */
  getImageUid(index) {
    let uid = this.#imageUids[0];
    if (this.#imageUids.length !== 1 && typeof index !== 'undefined') {
      uid = this.#imageUids[this.getSecondaryOffset(index)];
    }
    return uid;
  }

  /**
   * Get the image origin for a image UID.
   *
   * @param {string} uid The UID.
   * @returns {Point3D|undefined} The origin.
   */
  getOriginForImageUid(uid) {
    let origin;
    const uidIndex = this.#imageUids.indexOf(uid);
    if (uidIndex !== -1) {
      const origins = this.getGeometry().getOrigins();
      origin = origins[uidIndex];
    }
    return origin;
  }

  /**
   * Check if the image includes an UID.
   *
   * @param {string} uid The UID.
   * @returns {boolean} True if present.
   */
  includesImageUid(uid) {
    return this.#imageUids.includes(uid);
  }

  /**
   * Check if this image includes the input uids.
   *
   * @param {string[]} uids UIDs to test for presence.
   * @returns {boolean} True if all uids are in this image uids.
   */
  containsImageUids(uids) {
    return arrayContains(this.#imageUids, uids);
  }

  /**
   * Get the geometry of the image.
   *
   * @returns {Geometry} The geometry.
   */
  getGeometry() {
    return this.#geometry;
  }

  /**
   * Get the data buffer of the image.
   *
   * @todo Dangerous...
   * @returns {TypedArray} The data buffer of the image.
   */
  getBuffer() {
    return this.#buffer;
  }

  /**
   * Can the image values be quantified?
   *
   * @returns {boolean} True if only one component.
   */
  canQuantify() {
    return this.getNumberOfComponents() === 1;
  }

  /**
   * Can window and level be applied to the data?
   *
   * @returns {boolean} True if the data is monochrome.
   * @deprecated Since v0.33, please use isMonochrome instead.
   */
  canWindowLevel() {
    return this.isMonochrome();
  }

  /**
   * Is the data monochrome.
   *
   * @returns {boolean} True if the data is monochrome.
   */
  isMonochrome() {
    return this.getPhotometricInterpretation()
      .match(/MONOCHROME/) !== null;
  }

  /**
   * Can the data be scrolled?
   *
   * @param {Matrix33} viewOrientation The view orientation.
   * @returns {boolean} True if the data has a third dimension greater than one
   *   after applying the view orientation.
   */
  canScroll(viewOrientation) {
    const size = this.getGeometry().getSize();
    // also check the numberOfFiles in case we are in the middle of a load
    let nFiles = 1;
    if (typeof this.#meta.numberOfFiles !== 'undefined') {
      nFiles = this.#meta.numberOfFiles;
    }
    return size.canScroll(viewOrientation) || nFiles !== 1;
  }

  /**
   * Get the secondary offset max.
   *
   * @returns {number} The maximum offset.
   */
  #getSecondaryOffsetMax() {
    return this.#geometry.getSize().getTotalSize(2);
  }

  /**
   * Get the secondary offset: an offset that takes into account
   *   the slice and above dimension numbers.
   *
   * @param {Index} index The index.
   * @returns {number} The offset.
   */
  getSecondaryOffset(index) {
    return this.#geometry.getSize().indexToOffset(index, 2);
  }

  /**
   * Get the rescale slope and intercept.
   *
   * @param {Index} [index] The index (only needed for non constant rsi).
   * @returns {RescaleSlopeAndIntercept} The rescale slope and intercept.
   */
  getRescaleSlopeAndIntercept(index) {
    let res = this.#rsi;
    if (!this.isConstantRSI()) {
      if (typeof index === 'undefined') {
        throw new Error('Cannot get non constant RSI with empty slice index.');
      }
      const offset = this.getSecondaryOffset(index);
      if (typeof this.#rsis[offset] !== 'undefined') {
        res = this.#rsis[offset];
      } else {
        logger.warn('undefined non constant rsi at ' + offset);
      }
    }
    return res;
  }

  /**
   * Get the rsi at a specified (secondary) offset.
   *
   * @param {number} offset The desired (secondary) offset.
   * @returns {RescaleSlopeAndIntercept} The coresponding rsi.
   */
  #getRescaleSlopeAndInterceptAtOffset(offset) {
    return this.#rsis[offset];
  }

  /**
   * Set the rescale slope and intercept.
   *
   * @param {RescaleSlopeAndIntercept} inRsi The input rescale
   *   slope and intercept.
   * @param {number} [offset] The rsi offset (only needed for non constant rsi).
   */
  setRescaleSlopeAndIntercept(inRsi, offset) {
    // update identity flag
    this.#isIdentityRSI = this.#isIdentityRSI && inRsi.isID();
    // update constant flag
    if (!this.#isConstantRSI) {
      if (typeof offset === 'undefined') {
        throw new Error(
          'Cannot store non constant RSI with empty slice index.');
      }
      this.#rsis.splice(offset, 0, inRsi);
    } else {
      if (!this.#rsi.equals(inRsi)) {
        if (typeof offset === 'undefined') {
          // no slice index, replace existing
          this.#rsi = inRsi;
        } else {
          // first non constant rsi
          this.#isConstantRSI = false;
          // switch to non constant mode
          this.#rsis = [];
          // initialise RSIs
          for (let i = 0, leni = this.#getSecondaryOffsetMax(); i < leni; ++i) {
            this.#rsis.push(this.#rsi);
          }
          // store
          this.#rsi = null;
          this.#rsis.splice(offset, 0, inRsi);
        }
      }
    }
  }

  /**
   * Are all the RSIs identity (1,0).
   *
   * @returns {boolean} True if they are.
   */
  isIdentityRSI() {
    return this.#isIdentityRSI;
  }

  /**
   * Are all the RSIs equal.
   *
   * @returns {boolean} True if they are.
   */
  isConstantRSI() {
    return this.#isConstantRSI;
  }

  /**
   * Get the photometricInterpretation of the image.
   *
   * @returns {string} The photometricInterpretation of the image.
   */
  getPhotometricInterpretation() {
    return this.#photometricInterpretation;
  }

  /**
   * Set the photometricInterpretation of the image.
   *
   * @param {string} interp The photometricInterpretation of the image.
   */
  setPhotometricInterpretation(interp) {
    this.#photometricInterpretation = interp;
  }

  /**
   * Get the planarConfiguration of the image.
   *
   * @returns {number} The planarConfiguration of the image.
   */
  getPlanarConfiguration() {
    return this.#planarConfiguration;
  }

  /**
   * Set the planarConfiguration of the image.
   *
   * @param {number} config The planarConfiguration of the image.
   */
  setPlanarConfiguration(config) {
    this.#planarConfiguration = config;
  }

  /**
   * Get the numberOfComponents of the image.
   *
   * @returns {number} The numberOfComponents of the image.
   */
  getNumberOfComponents() {
    return this.#numberOfComponents;
  }

  /**
   * Get the meta information of the image.
   *
   * @returns {Object<string, any>} The meta information of the image.
   */
  getMeta() {
    return this.#meta;
  }

  /**
   * Set the meta information of the image.
   *
   * @param {Object<string, any>} rhs The meta information of the image.
   */
  setMeta(rhs) {
    this.#meta = rhs;
  }

  /**
   * Get value at offset. Warning: No size check...
   *
   * @param {number} offset The desired offset.
   * @returns {number} The value at offset.
   */
  getValueAtOffset(offset) {
    return this.#buffer[offset];
  }

  /**
   * Get the offsets where the buffer equals the input value.
   * Loops through the whole volume, can get long for big data...
   *
   * @param {number|RGB} value The value to check.
   * @returns {number[]} The list of offsets.
   */
  getOffsets(value) {
    // value to array
    let bufferValue;
    if (typeof value === 'number') {
      if (this.#numberOfComponents !== 1) {
        throw new Error(
          'Number of components is not 1 for getting single value.');
      }
      bufferValue = [value];
    } else if (typeof value.r !== 'undefined' &&
      typeof value.g !== 'undefined' &&
      typeof value.b !== 'undefined') {
      if (this.#numberOfComponents !== 3) {
        throw new Error(
          'Number of components is not 3 for getting RGB value.');
      }
      bufferValue = [value.r, value.g, value.b];
    }

    // main loop
    const offsets = [];
    let equal;
    for (let i = 0; i < this.#buffer.length; i = i + this.#numberOfComponents) {
      equal = true;
      for (let j = 0; j < this.#numberOfComponents; ++j) {
        if (this.#buffer[i + j] !== bufferValue[j]) {
          equal = false;
          break;
        }
      }
      if (equal) {
        offsets.push(i);
      }
    }
    return offsets;
  }

  /**
   * Check if the input values are in the buffer.
   * Could loop through the whole volume, can get long for big data...
   *
   * @param {Array} values The values to check.
   * @returns {boolean[]} A list of booleans for each input value,
   *   set to true if the value is present in the buffer.
   */
  hasValues(values) {
    // check input
    if (typeof values === 'undefined' ||
      values.length === 0) {
      return [];
    }
    // final array value
    const finalValues = [];
    for (let v1 = 0; v1 < values.length; ++v1) {
      if (this.#numberOfComponents === 1) {
        finalValues.push([values[v1]]);
      } else if (this.#numberOfComponents === 3) {
        finalValues.push([
          values[v1].r,
          values[v1].g,
          values[v1].b
        ]);
      }
    }
    // find callback
    let equalFunc;
    if (this.#numberOfComponents === 1) {
      equalFunc = function (a, b) {
        return a[0] === b[0];
      };
    } else if (this.#numberOfComponents === 3) {
      equalFunc = function (a, b) {
        return a[0] === b[0] &&
          a[1] === b[1] &&
          a[2] === b[2];
      };
    }
    const getEqualCallback = function (value) {
      return function (item) {
        return equalFunc(item, value);
      };
    };
    // main loop
    const res = new Array(values.length);
    res.fill(false);
    const valuesToFind = finalValues.slice();
    let equal;
    let indicesToRemove;
    for (let i = 0, leni = this.#buffer.length;
      i < leni; i = i + this.#numberOfComponents) {
      indicesToRemove = [];
      for (let v = 0; v < valuesToFind.length; ++v) {
        equal = true;
        // check value(s)
        for (let j = 0; j < this.#numberOfComponents; ++j) {
          if (this.#buffer[i + j] !== valuesToFind[v][j]) {
            equal = false;
            break;
          }
        }
        // if found, store answer and add to indices to remove
        if (equal) {
          const valIndex = finalValues.findIndex(
            getEqualCallback(valuesToFind[v]));
          res[valIndex] = true;
          indicesToRemove.push(v);
        }
      }
      // remove found values
      for (let r = 0; r < indicesToRemove.length; ++r) {
        valuesToFind.splice(indicesToRemove[r], 1);
      }
      // exit if no values to find
      if (valuesToFind.length === 0) {
        break;
      }
    }
    // return
    return res;
  }

  /**
   * Clone the image.
   *
   * @returns {Image} A clone of this image.
   */
  clone() {
    // clone the image buffer
    const clonedBuffer = this.#buffer.slice(0);
    // create the image copy
    const copy = new Image(this.getGeometry(), clonedBuffer, this.#imageUids);
    // copy the RSI(s)
    if (this.isConstantRSI()) {
      copy.setRescaleSlopeAndIntercept(this.getRescaleSlopeAndIntercept());
    } else {
      for (let i = 0; i < this.#getSecondaryOffsetMax(); ++i) {
        copy.setRescaleSlopeAndIntercept(
          this.#getRescaleSlopeAndInterceptAtOffset(i), i);
      }
    }
    // copy extras
    copy.setPhotometricInterpretation(this.getPhotometricInterpretation());
    copy.setPlanarConfiguration(this.getPlanarConfiguration());
    copy.setMeta(this.getMeta());
    // return
    return copy;
  }

  /**
   * Re-allocate buffer memory to an input size.
   *
   * @param {number} size The new size.
   */
  #realloc(size) {
    // save buffer
    let tmpBuffer = this.#buffer;
    // create new
    this.#buffer = getTypedArray(
      this.#buffer.BYTES_PER_ELEMENT * 8,
      this.#meta.IsSigned ? 1 : 0,
      size);
    if (this.#buffer === null) {
      throw new Error('Cannot reallocate data for image.');
    }
    // put old in new
    this.#buffer.set(tmpBuffer);
    // clean
    tmpBuffer = null;
  }

  /**
   * Append a slice to the image.
   *
   * @param {Image} rhs The slice to append.
   * @fires Image#imagegeometrychange
   */
  appendSlice(rhs) {
    // check input
    if (rhs === null) {
      throw new Error('Cannot append null slice');
    }
    const rhsSize = rhs.getGeometry().getSize();
    let size = this.#geometry.getSize();
    if (rhsSize.get(2) !== 1) {
      throw new Error('Cannot append more than one slice');
    }
    if (size.get(0) !== rhsSize.get(0)) {
      throw new Error('Cannot append a slice with different number of columns');
    }
    if (size.get(1) !== rhsSize.get(1)) {
      throw new Error('Cannot append a slice with different number of rows');
    }
    if (!this.#geometry.getOrientation().equals(
      rhs.getGeometry().getOrientation(), 0.0001)) {
      throw new Error('Cannot append a slice with different orientation');
    }
    if (this.#photometricInterpretation !==
      rhs.getPhotometricInterpretation()) {
      throw new Error(
        'Cannot append a slice with different photometric interpretation');
    }
    // all meta should be equal
    for (const key in this.#meta) {
      if (key === 'windowPresets' || key === 'numberOfFiles' ||
        key === 'custom') {
        continue;
      }
      if (this.#meta[key] !== rhs.getMeta()[key]) {
        throw new Error('Cannot append a slice with different ' + key +
          ': ' + this.#meta[key] + ' != ' + rhs.getMeta()[key]);
      }
    }

    // update ranges
    const rhsRange = rhs.getDataRange();
    const range = this.getDataRange();
    this.#dataRange = {
      min: Math.min(rhsRange.min, range.min),
      max: Math.max(rhsRange.max, range.max),
    };
    const rhsResRange = rhs.getRescaledDataRange();
    const resRange = this.getRescaledDataRange();
    this.#rescaledDataRange = {
      min: Math.min(rhsResRange.min, resRange.min),
      max: Math.max(rhsResRange.max, resRange.max),
    };

    // possible time
    const timeId = rhs.getGeometry().getInitialTime();

    // append frame if needed
    let isNewFrame = false;
    if (typeof timeId !== 'undefined' &&
      !this.#geometry.hasSlicesAtTime(timeId)) {
      // update grometry
      this.appendFrame(timeId, rhs.getGeometry().getOrigin());
      // update size
      size = this.#geometry.getSize();
      // update flag
      isNewFrame = true;
    }

    // get slice index
    const index = getSliceIndex(this.#geometry, rhs.getGeometry());

    // calculate slice size
    const sliceSize = this.#numberOfComponents * size.getDimSize(2);

    // create full buffer if not done yet
    if (typeof this.#meta.numberOfFiles === 'undefined') {
      throw new Error('Missing number of files for buffer manipulation.');
    }
    const fullBufferSize = sliceSize * this.#meta.numberOfFiles;
    if (this.#buffer.length !== fullBufferSize) {
      this.#realloc(fullBufferSize);
    }

    // slice index
    const sliceIndex = index.get(2);

    // slice index including possible 4D
    let fullSliceIndex = sliceIndex;
    if (typeof timeId !== 'undefined') {
      fullSliceIndex +=
        this.#geometry.getCurrentNumberOfSlicesBeforeTime(timeId);
    }
    // offset of the input slice
    const indexOffset = fullSliceIndex * sliceSize;
    const maxOffset =
      this.#geometry.getCurrentTotalNumberOfSlices() * sliceSize;
    // move content if needed
    if (indexOffset < maxOffset) {
      this.#buffer.set(
        this.#buffer.subarray(indexOffset, maxOffset),
        indexOffset + sliceSize
      );
    }
    // add new slice content
    this.#buffer.set(rhs.getBuffer(), indexOffset);

    // update geometry
    if (!isNewFrame) {
      this.#geometry.appendOrigin(
        rhs.getGeometry().getOrigin(), sliceIndex, timeId);
    }
    // update rsi
    // (rhs should just have one rsi)
    this.setRescaleSlopeAndIntercept(
      rhs.getRescaleSlopeAndIntercept(), fullSliceIndex);

    // current number of images
    const numberOfImages = this.#imageUids.length;

    // insert sop instance UIDs
    this.#imageUids.splice(fullSliceIndex, 0, rhs.getImageUid());

    // update window presets
    if (typeof this.#meta.windowPresets !== 'undefined') {
      const windowPresets = this.#meta.windowPresets;
      const rhsPresets = rhs.getMeta().windowPresets;
      const keys = Object.keys(rhsPresets);
      let pkey = null;
      for (let i = 0; i < keys.length; ++i) {
        pkey = keys[i];
        const rhsPreset = rhsPresets[pkey];
        const windowPreset = windowPresets[pkey];
        if (typeof windowPreset !== 'undefined') {
          // if not set or false, check perslice
          if (typeof windowPreset.perslice === 'undefined' ||
            windowPreset.perslice === false) {
            // if different preset.wl, mark it as perslice
            if (!windowPreset.wl[0].equals(rhsPreset.wl[0])) {
              windowPreset.perslice = true;
              // fill wl array with copy of wl[0]
              // (loop on number of images minus the existing one)
              for (let j = 0; j < numberOfImages - 1; ++j) {
                windowPreset.wl.push(windowPreset.wl[0]);
              }
            }
          }
          // store (first) rhs preset.wl if needed
          if (typeof windowPreset.perslice !== 'undefined' &&
            windowPreset.perslice === true) {
            windowPresets[pkey].wl.splice(
              fullSliceIndex, 0, rhsPreset.wl[0]);
          }
        } else {
          // if not defined (it should be), store all
          windowPresets[pkey] = rhsPresets[pkey];
        }
      }
    }
    /**
     * Image geometry change event.
     *
     * @event Image#imagegeometrychange
     * @type {object}
     * @property {string} type The event type.
     */
    this.#fireEvent({
      type: 'imagegeometrychange'
    });
  }

  /**
   * Append a frame buffer to the image.
   *
   * @param {object} frameBuffer The frame buffer to append.
   * @param {number} frameIndex The frame index.
   */
  appendFrameBuffer(frameBuffer, frameIndex) {
    // create full buffer if not done yet
    const size = this.#geometry.getSize();
    const frameSize = this.#numberOfComponents * size.getDimSize(2);
    if (typeof this.#meta.numberOfFiles === 'undefined') {
      throw new Error('Missing number of files for frame buffer manipulation.');
    }
    const fullBufferSize = frameSize * this.#meta.numberOfFiles;
    if (this.#buffer.length !== fullBufferSize) {
      this.#realloc(fullBufferSize);
    }
    // check index
    if (frameIndex >= this.#meta.numberOfFiles) {
      logger.warn('Ignoring frame at index ' + frameIndex +
        ' (size: ' + this.#meta.numberOfFiles + ')');
      return;
    }
    // append
    this.#buffer.set(frameBuffer, frameSize * frameIndex);
    // update geometry
    this.appendFrame(frameIndex, new Point3D(0, 0, 0));
  }

  /**
   * Append a frame to the image.
   *
   * @param {number} time The frame time value.
   * @param {Point3D} origin The origin of the frame.
   */
  appendFrame(time, origin) {
    this.#geometry.appendFrame(origin, time);
    /**
     * Append frame event.
     *
     * @event Image#appendframe
     * @type {object}
     * @property {string} type The event type.
     */
    this.#fireEvent({
      type: 'appendframe'
    });
    // memory will be updated at the first appendSlice or appendFrameBuffer
  }

  /**
   * Get the data range.
   *
   * @returns {NumberRange} The data range.
   */
  getDataRange() {
    if (!this.#dataRange) {
      this.#dataRange = this.calculateDataRange();
    }
    return this.#dataRange;
  }

  /**
   * Get the rescaled data range.
   *
   * @returns {NumberRange} The rescaled data range.
   */
  getRescaledDataRange() {
    if (!this.#rescaledDataRange) {
      this.#rescaledDataRange = this.calculateRescaledDataRange();
    }
    return this.#rescaledDataRange;
  }

  /**
   * Get the histogram.
   *
   * @returns {Array} The histogram.
   */
  getHistogram() {
    if (!this.#histogram) {
      const res = this.calculateHistogram();
      this.#dataRange = res.dataRange;
      this.#rescaledDataRange = res.rescaledDataRange;
      this.#histogram = res.histogram;
    }
    return this.#histogram;
  }

  /**
   * Add an event listener to this class.
   *
   * @param {string} type The event type.
   * @param {Function} callback The function associated with the provided
   *   event type, will be called with the fired event.
   */
  addEventListener(type, callback) {
    this.#listenerHandler.add(type, callback);
  }

  /**
   * Remove an event listener from this class.
   *
   * @param {string} type The event type.
   * @param {Function} callback The function associated with the provided
   *   event type.
   */
  removeEventListener(type, callback) {
    this.#listenerHandler.remove(type, callback);
  }

  /**
   * Fire an event: call all associated listeners with the input event object.
   *
   * @param {object} event The event to fire.
   */
  #fireEvent = (event) => {
    this.#listenerHandler.fireEvent(event);
  };

  // ****************************************
  // image data modifiers... carefull...
  // ****************************************

  /**
   * Set the inner buffer values at given offsets.
   *
   * @param {number[]} offsets List of offsets where to set the data.
   * @param {number|RGB} value The value to set at the given offsets.
   * @fires Image#imagecontentchange
   */
  setAtOffsets(offsets, value) {
    // value to array
    let bufferValue;
    if (typeof value === 'number') {
      if (this.#numberOfComponents !== 1) {
        throw new Error(
          'Number of components is not 1 for setting single value.');
      }
      bufferValue = [value];
    } else if (typeof value.r !== 'undefined' &&
      typeof value.g !== 'undefined' &&
      typeof value.b !== 'undefined') {
      if (this.#numberOfComponents !== 3) {
        throw new Error(
          'Number of components is not 3 for setting RGB value.');
      }
      bufferValue = [value.r, value.g, value.b];
    }

    let offset;
    for (let i = 0, leni = offsets.length; i < leni; ++i) {
      offset = offsets[i];
      for (let j = 0; j < this.#numberOfComponents; ++j) {
        this.#buffer[offset + j] = bufferValue[j];
      }
    }
    // fire imagecontentchange
    this.#fireEvent({type: 'imagecontentchange'});
  }

  /**
   * Set the inner buffer values at given offsets.
   *
   * @param {number[][]} offsetsLists List of offset lists where
   *   to set the data.
   * @param {RGB} value The value to set at the given offsets.
   * @returns {Array} A list of objects representing the original values before
   *  replacing them.
   * @fires Image#imagecontentchange
   */
  setAtOffsetsAndGetOriginals(offsetsLists, value) {
    const originalColoursLists = [];

    // update and store
    for (let j = 0; j < offsetsLists.length; ++j) {
      const offsets = offsetsLists[j];
      // first colour
      let offset = offsets[0] * 3;
      let previousColour = {
        r: this.#buffer[offset],
        g: this.#buffer[offset + 1],
        b: this.#buffer[offset + 2]
      };
      // original value storage
      const originalColours = [];
      originalColours.push({
        index: 0,
        colour: previousColour
      });
      for (let i = 0; i < offsets.length; ++i) {
        offset = offsets[i] * 3;
        const currentColour = {
          r: this.#buffer[offset],
          g: this.#buffer[offset + 1],
          b: this.#buffer[offset + 2]
        };
        // check if new colour
        if (previousColour.r !== currentColour.r ||
          previousColour.g !== currentColour.g ||
          previousColour.b !== currentColour.b) {
          // store new colour
          originalColours.push({
            index: i,
            colour: currentColour
          });
          previousColour = currentColour;
        }
        // write update colour
        this.#buffer[offset] = value.r;
        this.#buffer[offset + 1] = value.g;
        this.#buffer[offset + 2] = value.b;
      }
      originalColoursLists.push(originalColours);
    }
    // fire imagecontentchange
    this.#fireEvent({type: 'imagecontentchange'});
    return originalColoursLists;
  }

  /**
   * Set the inner buffer values at given offsets.
   *
   * @param {number[][]} offsetsLists List of offset lists
   *   where to set the data.
   * @param {RGB|Array} value The value to set at the given offsets.
   * @fires Image#imagecontentchange
   */
  setAtOffsetsWithIterator(offsetsLists, value) {
    for (let j = 0; j < offsetsLists.length; ++j) {
      const offsets = offsetsLists[j];
      let iterator;
      if (Array.isArray(value)) {
        // input value is a list of iterators
        // created by setAtOffsetsAndGetOriginals
        iterator = colourRange(
          value[j], offsets.length);
      } else if (typeof value.r !== 'undefined' &&
        typeof value.g !== 'undefined' &&
        typeof value.b !== 'undefined') {
        // input value is a simple color
        iterator = colourRange(
          [{index: 0, colour: value}], offsets.length);
      }

      // set values
      let ival = iterator.next();
      while (!ival.done) {
        const offset = offsets[ival.index] * 3;
        this.#buffer[offset] = ival.value.r;
        this.#buffer[offset + 1] = ival.value.g;
        this.#buffer[offset + 2] = ival.value.b;
        ival = iterator.next();
      }
    }
    /**
     * Image content change event.
     *
     * @event Image#imagecontentchange
     * @type {object}
     * @property {string} type The event type.
     */
    this.#fireEvent({type: 'imagecontentchange'});
  }

  /**
   * Get the value of the image at a specific coordinate.
   *
   * @param {number} i The X index.
   * @param {number} j The Y index.
   * @param {number} k The Z index.
   * @param {number} f The frame number.
   * @returns {number} The value at the desired position.
   * Warning: No size check...
   */
  getValue(i, j, k, f) {
    const frame = (f || 0);
    const index = new Index([i, j, k, frame]);
    return this.getValueAtOffset(
      this.getGeometry().getSize().indexToOffset(index));
  }

  /**
   * Get the value of the image at a specific index.
   *
   * @param {Index} index The index.
   * @returns {number} The value at the desired position.
   * Warning: No size check...
   */
  getValueAtIndex(index) {
    return this.getValueAtOffset(
      this.getGeometry().getSize().indexToOffset(index));
  }

  /**
   * Get the rescaled value of the image at a specific position.
   *
   * @param {number} i The X index.
   * @param {number} j The Y index.
   * @param {number} k The Z index.
   * @param {number} f The frame number.
   * @returns {number} The rescaled value at the desired position.
   * Warning: No size check...
   */
  getRescaledValue(i, j, k, f) {
    if (typeof f === 'undefined') {
      f = 0;
    }
    let val = this.getValue(i, j, k, f);
    if (!this.isIdentityRSI()) {
      if (this.isConstantRSI()) {
        val = this.getRescaleSlopeAndIntercept().apply(val);
      } else {
        const values = [i, j, k, f];
        const index = new Index(values);
        val = this.getRescaleSlopeAndIntercept(index).apply(val);
      }
    }
    return val;
  }

  /**
   * Get the rescaled value of the image at a specific index.
   *
   * @param {Index} index The index.
   * @returns {number} The rescaled value at the desired position.
   * Warning: No size check...
   */
  getRescaledValueAtIndex(index) {
    return this.getRescaledValueAtOffset(
      this.getGeometry().getSize().indexToOffset(index)
    );
  }

  /**
   * Get the rescaled value of the image at a specific offset.
   *
   * @param {number} offset The desired offset.
   * @returns {number} The rescaled value at the desired offset.
   * Warning: No size check...
   */
  getRescaledValueAtOffset(offset) {
    let val = this.getValueAtOffset(offset);
    if (!this.isIdentityRSI()) {
      if (this.isConstantRSI()) {
        val = this.getRescaleSlopeAndIntercept().apply(val);
      } else {
        const index = this.getGeometry().getSize().offsetToIndex(offset);
        val = this.getRescaleSlopeAndIntercept(index).apply(val);
      }
    }
    return val;
  }

  /**
   * Calculate the data range of the image.
   * WARNING: for speed reasons, only calculated on the first frame...
   *
   * @returns {object} The range {min, max}.
   */
  calculateDataRange() {
    let min = this.getValueAtOffset(0);
    let max = min;
    let value = 0;
    const size = this.getGeometry().getSize();
    let leni = size.getTotalSize();
    // max to 3D
    if (size.length() >= 3) {
      leni = size.getDimSize(3);
    }
    for (let i = 0; i < leni; ++i) {
      value = this.getValueAtOffset(i);
      if (value > max) {
        max = value;
      }
      if (value < min) {
        min = value;
      }
    }
    // return
    return {min: min, max: max};
  }

  /**
   * Calculate the rescaled data range of the image.
   * WARNING: for speed reasons, only calculated on the first frame...
   *
   * @returns {object} The range {min, max}.
   */
  calculateRescaledDataRange() {
    if (this.isIdentityRSI()) {
      return this.getDataRange();
    } else if (this.isConstantRSI()) {
      const range = this.getDataRange();
      const resmin = this.getRescaleSlopeAndIntercept().apply(range.min);
      const resmax = this.getRescaleSlopeAndIntercept().apply(range.max);
      return {
        min: ((resmin < resmax) ? resmin : resmax),
        max: ((resmin > resmax) ? resmin : resmax)
      };
    } else {
      let rmin = this.getRescaledValueAtOffset(0);
      let rmax = rmin;
      let rvalue = 0;
      const size = this.getGeometry().getSize();
      let leni = size.getTotalSize();
      // max to 3D
      if (size.length() === 3) {
        leni = size.getDimSize(3);
      }
      for (let i = 0; i < leni; ++i) {
        rvalue = this.getRescaledValueAtOffset(i);
        if (rvalue > rmax) {
          rmax = rvalue;
        }
        if (rvalue < rmin) {
          rmin = rvalue;
        }
      }
      // return
      return {min: rmin, max: rmax};
    }
  }

  /**
   * Calculate the histogram of the image.
   *
   * @returns {object} The histogram, data range and rescaled data range.
   */
  calculateHistogram() {
    const size = this.getGeometry().getSize();
    const histo = [];
    let min = this.getValueAtOffset(0);
    let max = min;
    let value = 0;
    let rmin = this.getRescaledValueAtOffset(0);
    let rmax = rmin;
    let rvalue = 0;
    for (let i = 0, leni = size.getTotalSize(); i < leni; ++i) {
      value = this.getValueAtOffset(i);
      if (value > max) {
        max = value;
      }
      if (value < min) {
        min = value;
      }
      rvalue = this.getRescaledValueAtOffset(i);
      if (rvalue > rmax) {
        rmax = rvalue;
      }
      if (rvalue < rmin) {
        rmin = rvalue;
      }
      histo[rvalue] = (histo[rvalue] || 0) + 1;
    }
    // set data range
    const dataRange = {min: min, max: max};
    const rescaledDataRange = {min: rmin, max: rmax};
    // generate data for plotting
    const histogram = [];
    for (let b = rmin; b <= rmax; ++b) {
      histogram.push([b, (histo[b] || 0)]);
    }
    // return
    return {
      dataRange: dataRange,
      rescaledDataRange: rescaledDataRange,
      histogram: histogram
    };
  }

  /**
   * Convolute the image with a given 2D kernel.
   *
   * Note: Uses raw buffer values.
   *
   * @param {number[]} weights The weights of the 2D kernel as a 3x3 matrix.
   * @returns {Image} The convoluted image.
   */
  convolute2D(weights) {
    if (weights.length !== 9) {
      throw new Error(
        'The convolution matrix does not have a length of 9; it has ' +
        weights.length);
    }

    const newImage = this.clone();
    const newBuffer = newImage.getBuffer();

    const imgSize = this.getGeometry().getSize();
    const dimOffset = imgSize.getDimSize(2) * this.getNumberOfComponents();
    for (let k = 0; k < imgSize.get(2); ++k) {
      this.convoluteBuffer(weights, newBuffer, k * dimOffset);
    }

    return newImage;
  }

  /**
   * Convolute an image buffer with a given 2D kernel.
   *
   * Note: Uses raw buffer values.
   *
   * @param {number[]} weights The weights of the 2D kernel as a 3x3 matrix.
   * @param {TypedArray} buffer The buffer to convolute.
   * @param {number} startOffset The index to start at.
   */
  convoluteBuffer(
    weights, buffer, startOffset) {
    const imgSize = this.getGeometry().getSize();
    const ncols = imgSize.get(0);
    const nrows = imgSize.get(1);
    const ncomp = this.getNumberOfComponents();

    // number of component and planar configuration vars
    let factor = 1;
    let componentOffset = 1;
    if (ncomp === 3) {
      if (this.getPlanarConfiguration() === 0) {
        factor = 3;
      } else {
        componentOffset = imgSize.getDimSize(2);
      }
    }

    // allow special indent for matrices
    /*jshint indent:false */

    // default weight offset matrix
    const wOff = [];
    wOff[0] = (-ncols - 1) * factor;
    wOff[1] = (-ncols) * factor;
    wOff[2] = (-ncols + 1) * factor;
    wOff[3] = -factor;
    wOff[4] = 0;
    wOff[5] = 1 * factor;
    wOff[6] = (ncols - 1) * factor;
    wOff[7] = (ncols) * factor;
    wOff[8] = (ncols + 1) * factor;

    // border weight offset matrices
    // borders are extended (see http://en.wikipedia.org/wiki/Kernel_%28image_processing%29)

    // i=0, j=0
    const wOff00 = [];
    wOff00[0] = wOff[4]; wOff00[1] = wOff[4]; wOff00[2] = wOff[5];
    wOff00[3] = wOff[4]; wOff00[4] = wOff[4]; wOff00[5] = wOff[5];
    wOff00[6] = wOff[7]; wOff00[7] = wOff[7]; wOff00[8] = wOff[8];
    // i=0, j=*
    const wOff0x = [];
    wOff0x[0] = wOff[1]; wOff0x[1] = wOff[1]; wOff0x[2] = wOff[2];
    wOff0x[3] = wOff[4]; wOff0x[4] = wOff[4]; wOff0x[5] = wOff[5];
    wOff0x[6] = wOff[7]; wOff0x[7] = wOff[7]; wOff0x[8] = wOff[8];
    // i=0, j=nrows
    const wOff0n = [];
    wOff0n[0] = wOff[1]; wOff0n[1] = wOff[1]; wOff0n[2] = wOff[2];
    wOff0n[3] = wOff[4]; wOff0n[4] = wOff[4]; wOff0n[5] = wOff[5];
    wOff0n[6] = wOff[4]; wOff0n[7] = wOff[4]; wOff0n[8] = wOff[5];

    // i=*, j=0
    const wOffx0 = [];
    wOffx0[0] = wOff[3]; wOffx0[1] = wOff[4]; wOffx0[2] = wOff[5];
    wOffx0[3] = wOff[3]; wOffx0[4] = wOff[4]; wOffx0[5] = wOff[5];
    wOffx0[6] = wOff[6]; wOffx0[7] = wOff[7]; wOffx0[8] = wOff[8];
    // i=*, j=* -> wOff
    // i=*, j=nrows
    const wOffxn = [];
    wOffxn[0] = wOff[0]; wOffxn[1] = wOff[1]; wOffxn[2] = wOff[2];
    wOffxn[3] = wOff[3]; wOffxn[4] = wOff[4]; wOffxn[5] = wOff[5];
    wOffxn[6] = wOff[3]; wOffxn[7] = wOff[4]; wOffxn[8] = wOff[5];

    // i=ncols, j=0
    const wOffn0 = [];
    wOffn0[0] = wOff[3]; wOffn0[1] = wOff[4]; wOffn0[2] = wOff[4];
    wOffn0[3] = wOff[3]; wOffn0[4] = wOff[4]; wOffn0[5] = wOff[4];
    wOffn0[6] = wOff[6]; wOffn0[7] = wOff[7]; wOffn0[8] = wOff[7];
    // i=ncols, j=*
    const wOffnx = [];
    wOffnx[0] = wOff[0]; wOffnx[1] = wOff[1]; wOffnx[2] = wOff[1];
    wOffnx[3] = wOff[3]; wOffnx[4] = wOff[4]; wOffnx[5] = wOff[4];
    wOffnx[6] = wOff[6]; wOffnx[7] = wOff[7]; wOffnx[8] = wOff[7];
    // i=ncols, j=nrows
    const wOffnn = [];
    wOffnn[0] = wOff[0]; wOffnn[1] = wOff[1]; wOffnn[2] = wOff[1];
    wOffnn[3] = wOff[3]; wOffnn[4] = wOff[4]; wOffnn[5] = wOff[4];
    wOffnn[6] = wOff[3]; wOffnn[7] = wOff[4]; wOffnn[8] = wOff[4];

    // restore indent for rest of method
    /*jshint indent:4 */

    // loop vars
    let pixelOffset = startOffset;
    let newValue = 0;
    let wOffFinal = [];
    for (let c = 0; c < ncomp; ++c) {
      // component offset
      pixelOffset += c * componentOffset;
      for (let j = 0; j < nrows; ++j) {
        for (let i = 0; i < ncols; ++i) {
          wOffFinal = wOff;
          // special border cases
          if (i === 0 && j === 0) {
            wOffFinal = wOff00;
          } else if (i === 0 && j === (nrows - 1)) {
            wOffFinal = wOff0n;
          } else if (i === (ncols - 1) && j === 0) {
            wOffFinal = wOffn0;
          } else if (i === (ncols - 1) && j === (nrows - 1)) {
            wOffFinal = wOffnn;
          } else if (i === 0 && j !== (nrows - 1) && j !== 0) {
            wOffFinal = wOff0x;
          } else if (i === (ncols - 1) && j !== (nrows - 1) && j !== 0) {
            wOffFinal = wOffnx;
          } else if (i !== 0 && i !== (ncols - 1) && j === 0) {
            wOffFinal = wOffx0;
          } else if (i !== 0 && i !== (ncols - 1) && j === (nrows - 1)) {
            wOffFinal = wOffxn;
          }
          // calculate the weighed sum of the source image pixels that
          // fall under the convolution matrix
          newValue = 0;
          for (let wi = 0; wi < 9; ++wi) {
            newValue += this.getValueAtOffset(
              pixelOffset + wOffFinal[wi]) * weights[wi];
          }
          buffer[pixelOffset] = newValue;
          // increment pixel offset
          pixelOffset += factor;
        }
      }
    }
  }

  /**
   * Transform an image using a specific operator.
   * WARNING: no size check!
   *
   * @param {Function} operator The operator to use when transforming.
   * @returns {Image} The transformed image.
   * Note: Uses the raw buffer values.
   */
  transform(operator) {
    const newImage = this.clone();
    const newBuffer = newImage.getBuffer();
    for (let i = 0, leni = newBuffer.length; i < leni; ++i) {
      newBuffer[i] = operator(newImage.getValueAtOffset(i));
    }
    return newImage;
  }

  /**
   * Compose this image with another one and using a specific operator.
   * WARNING: no size check!
   *
   * @param {Image} rhs The image to compose with.
   * @param {Function} operator The operator to use when composing.
   * @returns {Image} The composed image.
   * Note: Uses the raw buffer values.
   */
  compose(rhs, operator) {
    const newImage = this.clone();
    const newBuffer = newImage.getBuffer();
    for (let i = 0, leni = newBuffer.length; i < leni; ++i) {
      // using the operator on the local buffer, i.e. the
      // latest (not original) data
      newBuffer[i] = Math.floor(
        operator(this.getValueAtOffset(i), rhs.getValueAtOffset(i))
      );
    }
    return newImage;
  }

} // class Image