Source: utils.js

import { inv, multiply } from 'mathjs'
import { getPointResolution } from 'ol/proj'
import { v4 as createUUIDv4, v5 as createUUIDv5 } from 'uuid'

const _UUID_NAMESPACE = 'c4f09b11-bac0-4f3a-8dc1-9f0046637383'

/**
 * Generates a UUID-derived DICOM UID with root `2.25`.
 *
 * @returns {string} Unique identifier
 *
 * @private
 */
function _generateUID ({ value } = {}) {
  /**
   * A UUID can be represented as a single integer value.
   * http://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_B.2.html
   * https://www.itu.int/rec/T-REC-X.667-201210-I/en
   * To obtain the single integer value of the UUID, the 16 octets of the
   * binary representation shall be treated as an unsigned integer encoding
   * with the most significant bit of the integer encoding as the most
   * significant bit (bit 7) of the first of the sixteen octets (octet 15) and
   * the least significant bit as the least significant bit (bit 0) of the last
   * of the sixteen octets (octet 0).
   */
  let uuid
  if (value != null) {
    uuid = createUUIDv5(value, _UUID_NAMESPACE)
  } else {
    uuid = createUUIDv4()
  }
  const hex = '0x' + uuid.replace(/-/g, '')
  const decimal = BigInt(hex)
  return '2.25.' + decimal.toString()
}

/**
 * Create a rotation matrix.
 *
 * @param {Object} options - Options
 * @param {number[]} options.orientation - Direction cosines along the row and column direction of the Total Pixel Matrix for each of the three axis of the slide coordinate system
 *
 * @returns {number[][]} 2x2 rotation matrix
 *
 * @memberof utils
 */
function createRotationMatrix (options) {
  if (!('orientation' in options)) {
    throw new Error('Option "orientation" is required.')
  }
  const orientation = options.orientation
  const rowDirection = orientation.slice(0, 3)
  const columnDirection = orientation.slice(3, 6)
  return [
    [rowDirection[0], columnDirection[0]],
    [rowDirection[1], columnDirection[1]],
    [rowDirection[2], columnDirection[3]]
  ]
}

/**
 * Rescale intensity from [minInput, maxInput] to [minOutput, maxOutput].
 *
 * @param {number} value - Input value that should be rescaled
 * @param {number} minInput - Lower bound of the full input value range
 * @param {number} maxInput - Upper bound of the full input value range
 * @param {number} minOutput - Lower bound of the full output value range
 * @param {number} maxOutput - Upper bound of the full output value range
 *
 * @returns {number} Rescaled value
 *
 * @memberof utils
 */
function rescale (
  value,
  minInput,
  maxInput,
  minOutput,
  maxOutput
) {
  return (
    (value - minInput) * (maxOutput - minOutput) /
    (maxInput - minInput) +
    minOutput
  )
}

/**
 * Create window.
 *
 * @param {number} lowerBound - Lower bound of the window
 * @param {number} upperBound - Upper bound of the window
 *
 * @returns {number[]} Window center and width
 *
 * @memberof utils
 */
function createWindow (lowerBound, upperBound) {
  const windowCenter = (lowerBound + upperBound) / 2
  const windowWidth = upperBound - lowerBound
  return [windowCenter, windowWidth]
}

/**
 * Compute the rotation of the image with respect to the frame of reference.
 *
 * @param {Object} options - Options
 * @param {number[]} options.orientation - Direction cosines along the row and column direction of the Total Pixel Matrix for each of the three axis of the slide coordinate system
 * @param {boolean} options.inDegrees - Whether angle should be returned in degrees instead of radians
 *
 * @returns {number} Angle
 *
 * @memberof utils
 */
function computeRotation (options) {
  const rot = createRotationMatrix({ orientation: options.orientation })
  const angle = Math.atan2(-rot[0][1], rot[0][0])
  let inDegrees = false
  if ('inDegrees' in options) {
    inDegrees = true
  }
  if (inDegrees) {
    return angle / (Math.PI / 180)
  } else {
    return angle
  }
}

/**
 * Build an affine transformation matrix to map coordinates in the Total
 * Pixel Matrix into the slide coordinate system.
 *
 * @param {Object} options - Options
 * @param {number[]} options.offset - X and Y offset of the image in the slide coordinate system
 * @param {number[]} options.orientation - Direction cosines along the row and column direction of the Total Pixel Matrix for each of the three axis of the slide coordinate system
 * @param {number[]} options.spacing - Spacing between pixel rows and columns of the Total Pixel Matrix
 *
 * @returns {number[][]} 3x3 affine transformation matrix
 *
 * @memberof utils
 */
function buildTransform ({ offset, orientation, spacing }) {
  // X and Y Offset in Slide Coordinate System
  if (offset == null) {
    throw new Error('Option "offset" is required.')
  }
  if (!Array.isArray(offset)) {
    throw new Error('Option "offset" must be an array.')
  }
  if (offset.length !== 2) {
    throw new Error('Option "offset" must be an array with 2 elements.')
  }

  // Image Orientation Slide with direction cosines for Row and Column direction
  if (orientation == null) {
    throw new Error('Option "orientation" is required.')
  }
  if (!Array.isArray(orientation)) {
    throw new Error('Option "orientation" must be an array.')
  }
  if (orientation.length !== 6) {
    throw new Error('Option "orientation" must be an array with 6 elements.')
  }

  // Pixel Spacing along the Row and Column direction
  if (spacing == null) {
    throw new Error('Option "spacing" is required.')
  }
  if (!Array.isArray(spacing)) {
    throw new Error('Option "spacing" must be an array.')
  }
  if (spacing.length !== 2) {
    throw new Error('Option "spacing" must be an array with 2 elements.')
  }

  const affine = [
    [orientation[0] * spacing[1], orientation[3] * spacing[0], offset[0]],
    [orientation[1] * spacing[1], orientation[4] * spacing[0], offset[1]],
    [0, 0, 1]
  ]
  const correction = [
    [1.0, 0.0, -0.5],
    [0.0, 1.0, -0.5],
    [0.0, 0.0, 1.0]
  ]
  return multiply(affine, correction)
}

/**
 * Apply an affine transformation to an image coordinate in the total pixel
 * matrix to map it into the slide coordinate system.
 *
 * @param {Object} options - Options
 * @param {number[]} options.coordinate - (column, row) image coordinate
 * @param {number[][]} options.affine - 3x3 affine transformation matrix
 *
 * @returns {number[]} (x, y) reference coordinate
 *
 * @memberof utils
 */
function applyTransform ({ coordinate, affine }) {
  if (coordinate == null) {
    throw new Error('Option "coordinate" is required.')
  }
  if (!Array.isArray(coordinate)) {
    throw new Error('Option "coordinate" must be an array.')
  }
  if (coordinate.length !== 2) {
    throw new Error('Option "coordinate" must be an array with 2 elements.')
  }

  if (affine == null) {
    throw new Error('Option "affine" is required.')
  }
  if (!Array.isArray(affine)) {
    throw new Error('Option "affine" must be an array.')
  }
  if (affine.length !== 3) {
    throw new Error('Option "affine" must be a 3x3 array.')
  }
  if (!Array.isArray(affine[0])) {
    throw new Error('Option "affine" must be a 3x3 array.')
  }
  if (affine[0].length !== 3 || affine[1].length !== 3) {
    throw new Error('Option "affine" must be a 3x3 array.')
  }

  const imageCoordinate = [[coordinate[0]], [coordinate[1]], [1]]

  const slideCoordinate = multiply(affine, imageCoordinate)

  const x = Number(slideCoordinate[0][0].toFixed(4))
  const y = Number(slideCoordinate[1][0].toFixed(4))
  return [x, y]
}

/**
 * Build an affine transformation matrix to map coordinates in the slide
 * coordinate system into the Total Pixel Matrix.
 *
 * @param {number[]} options.offset - X and Y offset of the image in the slide coordinate system
 * @param {number[]} options.orientation - Direction cosines along the row and column direction of the Total Pixel Matrix for each of the three axis of the slide coordinate system
 * @param {number[]} options.spacing - Spacing between pixel rows and columns of the Total Pixel Matrix
 *
 * @returns {number[][]} 3x3 affine transformation matrix
 *
 * @memberof utils
 */
function buildInverseTransform ({ offset, orientation, spacing }) {
  // X and Y Offset in Slide Coordinate System
  if (offset == null) {
    throw new Error('Option "offset" is required.')
  }
  if (!Array.isArray(offset)) {
    throw new Error('Option "offset" must be an array.')
  }
  if (offset.length !== 2) {
    throw new Error('Option "offset" must be an array with 2 elements.')
  }

  // Image Orientation Slide with direction cosines for Row and Column direction
  if (orientation == null) {
    throw new Error('Option "orientation" is required.')
  }
  if (!Array.isArray(orientation)) {
    throw new Error('Option "orientation" must be an array.')
  }
  if (orientation.length !== 6) {
    throw new Error('Option "orientation" must be an array with 6 elements.')
  }

  // Pixel Spacing along the Row and Column direction
  if (spacing == null) {
    throw new Error('Option "spacing" is required.')
  }
  if (!Array.isArray(spacing)) {
    throw new Error('Option "spacing" must be an array.')
  }
  if (spacing.length !== 2) {
    throw new Error('Option "spacing" must be an array with 2 elements.')
  }

  const affine = inv([
    [orientation[0] * spacing[1], orientation[3] * spacing[0], offset[0]],
    [orientation[1] * spacing[1], orientation[4] * spacing[0], offset[1]],
    [0, 0, 1]
  ])
  const correction = [
    [1.0, 0.0, 0.5],
    [0.0, 1.0, 0.5],
    [0.0, 0.0, 1.0]
  ]
  return multiply(correction, affine)
}

/**
 * Apply an affine transformation to a reference coordinate in the slide
 * coordinate system to map it into the total pixel matrix.
 *
 * @param {Object} options - Options
 * @param {number[]} options.coordinate - (x, y) reference coordinate
 * @param {number[][]} options.affine - 3x3 affine transformation matrix
 *
 * @returns {number[]} (column, row) image coordinate
 *
 * @memberof utils
 */
function applyInverseTransform ({ coordinate, affine }) {
  if (coordinate == null) {
    throw new Error('Option "coordinate" is required.')
  }
  if (!Array.isArray(coordinate)) {
    throw new Error('Option "coordinate" must be an array.')
  }
  if (coordinate.length !== 2) {
    throw new Error('Option "coordinate" must be an array with 2 elements.')
  }

  if (affine == null) {
    throw new Error('Option "affine" is required.')
  }
  if (!Array.isArray(affine)) {
    throw new Error('Option "affine" must be an array.')
  }
  if (affine.length !== 3) {
    throw new Error('Option "affine" must be a 3x3 array.')
  }
  if (!Array.isArray(affine[0])) {
    throw new Error('Option "affine" must be a 3x3 array.')
  }
  if (affine[0].length !== 3 || affine[1].length !== 3) {
    throw new Error('Option "affine" must be a 3x3 array.')
  }

  const slideCoordinate = [[coordinate[0]], [coordinate[1]], [1]]

  const pixelCoordinate = multiply(affine, slideCoordinate)

  const col = Number(pixelCoordinate[0][0].toFixed(4))
  const row = Number(pixelCoordinate[1][0].toFixed(4))
  return [col, row]
}

/**
 * Map 2D (column, row) image coordinates in the Total Pixel Matrix
 * to 3D (x, y, z) slide coordinates in the Frame of Reference.
 *
 * @param {Object} options - Options
 * @param {number[]} options.offset - X and Y offset in the slide coordinate system
 * @param {number[]} options.orientation - Direction cosines along the row and column direction of the Total Pixel Matrix for each of the three axis of the slide coordinate system
 * @param {number[]} options.spacing - Spacing between pixels along the Column and Row direction of the Total Pixel Matrix
 * @param {number[]} options.point - (colum, row) image coordinates
 *
 * @returns {number[]} (x, y, z) slide coordinates
 *
 * @memberof utils
 */
function mapPixelCoordToSlideCoord ({ point, offset, orientation, spacing }) {
  if (point == null) {
    throw new Error('Option "point" is required.')
  }
  if (!Array.isArray(point)) {
    throw new Error('Option "point" must be an array.')
  }
  if (point.length !== 2) {
    throw new Error('Option "point" must be an array with 2 elements.')
  }

  const affine = buildTransform({
    orientation,
    offset,
    spacing
  })
  return applyTransform({ coordinate: point, affine })
}

/**
 * Map 3D (x, y, z) slide coordinates in the Frame of Reference to
 * 2D (column, row) image coordinates in the Total Pixel Matrix.
 *
 * @param {Object} options - Options
 * @param {number[]} options.offset - X and Y offset in the slide coordinate system
 * @param {number[]} options.orientation - Direction cosines along the row and column direction of the Total Pixel Matrix for each of the three axis of the slide coordinate system
 * @param {number[]} options.spacing - Spacing between pixels along the Column and Row direction of the Total Pixel Matrix
 * @param {number[]} options.point - (x, y, z) slide coordinates
 *
 * @returns {number[]} (row, column) image coordinates
 *
 * @memberof utils
 */
function mapSlideCoordToPixelCoord ({ point, offset, orientation, spacing }) {
  if (point == null) {
    throw new Error('Option "point" is required.')
  }
  if (!Array.isArray(point)) {
    throw new Error('Option "point" must be an array.')
  }
  if (point.length !== 2) {
    throw new Error('Option "point" must be an array with 2 elements.')
  }
  const affine = buildInverseTransform({
    orientation,
    offset,
    spacing
  })

  return applyInverseTransform({ coordinate: point, affine })
}

/**
 * Check if 2D arrays are equal.
 *
 * @param {number[]} array a
 * @param {number[]} array b
 * @param {number} eps
 *
 * @returns {boolean} yes/no answer
 *
 * @memberof utils
 */
function are2DArraysAlmostEqual (a, b, eps = 1.e-6) {
  if (a === b) return true
  if (a == null || b == null) return false
  if (a.length !== b.length) return false

  for (let i = 0; i < a.length; ++i) {
    if (a[i].length !== b[i].length) return false
    for (let j = 0; j < a[i].length; ++j) {
      if (!areNumbersAlmostEqual(a[i][j], b[i][j], eps)) {
        return false
      }
    }
  }
  return true
}

/**
 * Check if 1D arrays are equal.
 *
 * @param {number[]} array a
 * @param {number[]} array b
 * @param {number} eps
 *
 * @returns {boolean} yes/no answer
 *
 * @memberof utils
 */
function are1DArraysAlmostEqual (a, b, eps = 1.e-6) {
  if (a == null || b == null) return false
  if (a.length !== b.length) return false

  for (let i = 0; i < a.length; ++i) {
    if (!areNumbersAlmostEqual(a[i], b[i], eps)) {
      return false
    }
  }
  return true
}

/**
 * Check if two numbers are equal.
 *
 * @param {number} a
 * @param {number} b
 * @param {number} eps
 *
 * @returns {boolean} yes/no answer
 *
 * @memberof utils
 */
function areNumbersAlmostEqual (a, b, eps = 1.e-6) {
  return Math.abs(a - b) < eps
}

/**
 * Get view unit suffix.
 *
 * @param {object} view Map view
 *
 * @returns {string} unit suffix
 *
 * @private
 */
function _getUnitSuffix (view) {
  const UnitsEnum = { METERS: 'm' }
  const DEFAULT_DPI = 25.4 / 0.28

  const center = view.getCenter()
  const projection = view.getProjection()
  const resolution = view.getResolution()

  const pointResolutionUnits = UnitsEnum.METERS

  let pointResolution = getPointResolution(
    projection,
    resolution,
    center,
    pointResolutionUnits
  )

  const DEFAULT_MIN_WIDTH = 65
  const minWidth = (DEFAULT_MIN_WIDTH * DEFAULT_DPI) / DEFAULT_DPI

  const nominalCount = minWidth * pointResolution
  let suffix = ''

  if (nominalCount < 0.001) {
    suffix = 'μm'
    pointResolution *= 1000000
  } else if (nominalCount < 1) {
    suffix = 'mm'
    pointResolution *= 1000
  } else if (nominalCount < 1000) {
    suffix = 'm'
  } else {
    suffix = 'km'
    pointResolution /= 1000
  }

  return suffix
}

/**
 * Get name coded concept from content item.
 *
 * @param {object} contentItem
 *
 * @returns {object} The concept name coded concept
 *
 * @memberof utils
 */
const getContentItemNameCodedConcept = (contentItem) =>
  contentItem.ConceptNameCodeSequence[0]

/**
 * Check whether coded concepts are equal.
 *
 * @param {object} codedConcept1
 * @param {object} codedConcept2
 *
 * @returns {boolean} yes/no answer
 *
 * @memberof utils
 */
const areCodedConceptsEqual = (codedConcept1, codedConcept2) => {
  if (
    codedConcept2.CodeValue === codedConcept1.CodeValue &&
    codedConcept2.CodingSchemeDesignator ===
      codedConcept1.CodingSchemeDesignator
  ) {
    if (
      codedConcept2.CodingSchemeVersion &&
      codedConcept1.CodingSchemeVersion
    ) {
      return (
        codedConcept2.CodingSchemeVersion === codedConcept1.CodingSchemeVersion
      )
    }
    return true
  }
  return false
}

/**
 * Check wether two content items match.
 *
 * @param {object} contentItem1
 * @param {object} contentItem2
 *
 * @returns {boolean} yes/no answer
 *
 * @memberof utils
 */
const doContentItemsMatch = (contentItem1, contentItem2) => {
  const contentItem1NameCodedConcept = getContentItemNameCodedConcept(
    contentItem1
  )
  const contentItem2NameCodedConcept = getContentItemNameCodedConcept(
    contentItem2
  )
  return contentItem1NameCodedConcept.equals
    ? contentItem1NameCodedConcept.equals(contentItem2NameCodedConcept)
    : areCodedConceptsEqual(
      contentItem1NameCodedConcept,
      contentItem2NameCodedConcept
    )
}

/**
 * Fetch bulkdata.
 *
 * @param {object} options
 * @param {object} options.client - DICOMweb client @param {object}
 * options.reference - Data Element in DICOM JSON format containing "vr" and
 * "BulkDataURI" fields
 *
 * @returns {Promise<TypedArray>} bulkdata
 *
 * @private
 */
async function _fetchBulkdata ({ client, reference }) {
  const retrieveOptions = { BulkDataURI: reference.BulkDataURI }
  return await client.retrieveBulkData(retrieveOptions).then(data => {
    const byteArray = new Uint8Array(data[0])
    if (reference.vr === 'OB') {
      return byteArray
    } else if (reference.vr === 'OW') {
      return new Uint16Array(
        byteArray.buffer,
        byteArray.byteOffset,
        byteArray.byteLength / 2
      )
    } else if (reference.vr === 'OL') {
      return new Int32Array(
        byteArray.buffer,
        byteArray.byteOffset,
        byteArray.byteLength / 4
      )
    } else if (reference.vr === 'OV') {
      // There is no Int64Array, so we represent data as Float64Array instead
      return new Float64Array(
        byteArray.buffer,
        byteArray.byteOffset,
        byteArray.byteLength / 8
      )
    } else if (reference.vr === 'OF') {
      return new Float32Array(
        byteArray.buffer,
        byteArray.byteOffset,
        byteArray.byteLength / 4
      )
    } else if (reference.vr === 'OD') {
      return new Float64Array(
        byteArray.buffer,
        byteArray.byteOffset,
        byteArray.byteLength / 8
      )
    } else {
      throw new Error(
        `Unexpected Value Representation "${reference.vr}" for ` +
        `bulkdata element with URI "${reference.BulkDataURI}".`
      )
    }
  })
}

/**
 * Convert RGB color triplet into hex code.
 *
 * @param {Number[]} values - RGB triplet
 * @returns {String} Hex code
 *
 * @private
 */
function rgb2hex (values) {
  const r = values[0]
  const g = values[1]
  const b = values[2]
  return '#' + (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1)
}

export {
  _getUnitSuffix,
  applyInverseTransform,
  applyTransform,
  buildInverseTransform,
  buildTransform,
  computeRotation,
  createWindow,
  _fetchBulkdata,
  _generateUID,
  mapPixelCoordToSlideCoord,
  mapSlideCoordToPixelCoord,
  areNumbersAlmostEqual,
  are1DArraysAlmostEqual,
  are2DArraysAlmostEqual,
  doContentItemsMatch,
  areCodedConceptsEqual,
  getContentItemNameCodedConcept,
  rgb2hex,
  rescale
}