Source: metadata.js

import { tagToKeyword } from './dictionary'
import { SOPClassUIDs } from './enums'
import { _groupFramesPerMapping } from './mapping'

const _metadata = Symbol('metadata')
const _bulkdataReferences = Symbol('bulkdataReferences')

function _base64ToUint8Array (value) {
  const blob = window.atob(value)
  const array = new Uint8Array(blob.length)

  for (let i = 0; i < blob.length; i++) {
    array[i] = blob.charCodeAt(i)
  }

  return array
}

function _base64ToUint16Array (value) {
  const blob = window.atob(value)
  const n = Uint16Array.BYTES_PER_ELEMENT
  const length = blob.length / n
  const buffer = new ArrayBuffer(n)
  const view = new DataView(buffer)
  const array = new Uint16Array(length)

  let p = 0
  for (let i = 0; i < length; i++) {
    p = i * n
    for (let j = 0; j < n; j++) {
      view.setUint8(j, blob.charCodeAt(p + j))
    }
    array[i] = view.getUint16(0, true)
  }

  return array
}

function _base64ToUint32Array (value) {
  const blob = window.atob(value)
  const n = Uint32Array.BYTES_PER_ELEMENT
  const length = blob.length / n
  const buffer = new ArrayBuffer(n)
  const view = new DataView(buffer)
  const array = new Uint32Array(length)

  let p = 0
  for (let i = 0; i < length; i++) {
    p = i * n
    for (let j = 0; j < n; j++) {
      view.setUint8(j, blob.charCodeAt(p + j))
    }
    array[i] = view.getUint32(0, true)
  }

  return array
}

function _base64ToFloat32Array (value) {
  const blob = window.atob(value)
  const n = Float32Array.BYTES_PER_ELEMENT
  const length = blob.length / n
  const buffer = new ArrayBuffer(n)
  const view = new DataView(buffer)
  const array = new Float32Array(length)

  let p = 0
  for (let i = 0; i < length; i++) {
    p = i * n
    for (let j = 0; j < n; j++) {
      view.setUint8(j, blob.charCodeAt(p + j))
    }
    array[i] = view.getFloat32(0, true)
  }

  return array
}

function _base64ToFloat64Array (value) {
  const blob = window.atob(value)
  const n = Float64Array.BYTES_PER_ELEMENT
  const length = blob.length / n
  const buffer = new ArrayBuffer(n)
  const view = new DataView(buffer)
  const array = new Float64Array(length)

  let p = 0
  for (let i = 0; i < length; i++) {
    p = i * n
    for (let j = 0; j < n; j++) {
      view.setUint8(j, blob.charCodeAt(p + j))
    }
    array[i] = view.getFloat64(0, true)
  }

  return array
}

/** Determine the mapping of pyramid tile positions to frame numbers.
 *
 * @param {Object} Formatted metadata of a VL Whole Slide Microscopy Image instance
 * @returns {Object} Mapping of pyramid tile position (Row-Column) to frame URI
 * @private
 */
function getFrameMapping (metadata) {
  const rows = metadata.Rows
  const columns = metadata.Columns
  const totalPixelMatrixColumns = metadata.TotalPixelMatrixColumns
  const totalPixelMatrixRows = metadata.TotalPixelMatrixRows
  const sopInstanceUID = metadata.SOPInstanceUID
  const numberOfFrames = Number(metadata.NumberOfFrames || 1)

  /**
   * Handle images that may contain multiple "planes"
   *  - z-planes (VL Whole Slide Microscopy Image)
   *  - optical paths (VL Whole Slide Microscopy Image)
   *  - segments (Segmentation)
   *  - mappings (Parametric Map)
   */
  const numberOfFocalPlanes = Number(metadata.NumberOfFocalPlanes || 1)
  if (numberOfFocalPlanes > 1) {
    throw new Error('Images with multiple focal planes are not yet supported.')
  }

  const {
    mappingNumberToFrameNumbers,
    frameNumberToMappingNumber
  } = _groupFramesPerMapping(metadata)
  let numberOfChannels = 0
  let numberOfOpticalPaths = 0
  let numberOfSegments = 0
  let numberOfMappings = 0
  if (metadata.OpticalPathSequence != null) {
    numberOfOpticalPaths = Number(metadata.NumberOfOpticalPaths || 1)
    numberOfChannels = numberOfOpticalPaths
  } else if (metadata.SegmentSequence != null) {
    numberOfSegments = Number(metadata.SegmentSequence.length)
    numberOfChannels = numberOfSegments
  } else if (Object.keys(mappingNumberToFrameNumbers).length > 0) {
    numberOfMappings = Number(Object.keys(mappingNumberToFrameNumbers).length)
    numberOfChannels = numberOfMappings
  } else {
    throw new Error('Could not determine the number of image channels.')
  }

  const tileColumns = Math.ceil(totalPixelMatrixColumns / columns)
  const tileRows = Math.ceil(totalPixelMatrixRows / rows)
  const frameMapping = {}
  /**
   * The values "TILED_SPARSE" and "TILED_FULL" were introduced in the 2018
   * edition of the standard. Older datasets are equivalent to "TILED_SPARSE".
   */
  const dimensionOrganizationType = (
    metadata.DimensionOrganizationType || 'TILED_SPARSE'
  )
  if (dimensionOrganizationType === 'TILED_FULL') {
    let number = 1
    // Forth, along "channels"
    for (let i = 0; i < numberOfChannels; i++) {
      // Third, along the depth direction from glass slide -> coverslip
      for (let p = 0; p < numberOfFocalPlanes; p++) {
        // Second, along the column direction from top -> bottom
        for (let r = 0; r < tileRows; r++) {
          // First, along the row direction from left -> right
          for (let c = 0; c < tileColumns; c++) {
            /*
             * The standard currently only defines TILED_FULL for optical paths
             * and not any other types of "channels" such as segments or
             * parameter mappings.
             */
            let channelIdentifier
            if (numberOfOpticalPaths > 0) {
              const opticalPath = metadata.OpticalPathSequence[i]
              channelIdentifier = String(opticalPath.OpticalPathIdentifier)
            } else if (numberOfSegments > 0) {
              const segment = metadata.SegmentSequence[i]
              channelIdentifier = String(segment.SegmentNumber)
            } else if (numberOfMappings > 0) {
              // TODO: ensure that frames are mapped accordingly
              channelIdentifier = String(frameNumberToMappingNumber[number])
            } else {
              throw new Error(
                `Could not determine channel of frame #${number}.`
              )
            }
            const key = `${r + 1}-${c + 1}-${channelIdentifier}`
            frameMapping[key] = `${sopInstanceUID}/frames/${number}`
            number += 1
          }
        }
      }
    }
  } else {
    const sharedFuncGroups = metadata.SharedFunctionalGroupsSequence
    const perframeFuncGroups = metadata.PerFrameFunctionalGroupsSequence
    for (let j = 0; j < numberOfFrames; j++) {
      const planePositions = perframeFuncGroups[j].PlanePositionSlideSequence[0]
      const rowPosition = planePositions.RowPositionInTotalImagePixelMatrix
      const columnPosition = planePositions.ColumnPositionInTotalImagePixelMatrix
      const rowIndex = Math.ceil(rowPosition / rows)
      const colIndex = Math.ceil(columnPosition / columns)
      const number = j + 1
      let channelIdentifier
      if (numberOfOpticalPaths === 1) {
        try {
          channelIdentifier = String(
            sharedFuncGroups[0]
              .OpticalPathIdentificationSequence[0]
              .OpticalPathIdentifier
          )
        } catch {
          channelIdentifier = String(
            perframeFuncGroups[j]
              .OpticalPathIdentificationSequence[0]
              .OpticalPathIdentifier
          )
        }
      } else if (numberOfOpticalPaths > 1) {
        channelIdentifier = String(
          perframeFuncGroups[j]
            .OpticalPathIdentificationSequence[0]
            .OpticalPathIdentifier
        )
      } else if (numberOfSegments === 1) {
        try {
          channelIdentifier = String(
            sharedFuncGroups[0]
              .SegmentIdentificationSequence[0]
              .ReferencedSegmentNumber
          )
        } catch {
          channelIdentifier = String(
            perframeFuncGroups[j]
              .SegmentIdentificationSequence[0]
              .ReferencedSegmentNumber
          )
        }
      } else if (numberOfSegments > 1) {
        channelIdentifier = String(
          perframeFuncGroups[j]
            .SegmentIdentificationSequence[0]
            .ReferencedSegmentNumber
        )
      } else if (numberOfMappings > 0) {
        channelIdentifier = String(frameNumberToMappingNumber[number])
      } else {
        throw new Error(`Could not determine channel of frame ${number}.`)
      }
      const key = `${rowIndex}-${colIndex}-${channelIdentifier}`
      const frameNumber = j + 1
      frameMapping[key] = `${sopInstanceUID}/frames/${frameNumber}`
    }
  }
  return {
    frameMapping,
    numberOfChannels
  }
}

/**
 * Format DICOM metadata structured according to the DICOM JSON model.
 *
 * Transforms the DICOM JSON representation into a more human friendly
 * representation, where values of data elements can be directly accessed via
 * their keyword (e.g., "SOPInstanceUID").
 * Bulkdata elements will be extracted and returned as a separate mapping.
 *
 * @param {Object} metadata - Metadata structured according to the DICOM JSON model
 *
 * @returns {Object} Formatted dataset and remaining bulkdata
 *
 * @memberof metadata
 */
function formatMetadata (metadata) {
  const loadJSONDataset = (elements) => {
    const dataset = {}
    const bulkdataReferences = {}
    Object.keys(elements).forEach(tag => {
      const keyword = tagToKeyword[tag]
      const vr = elements[tag].vr
      if ('BulkDataURI' in elements[tag]) {
        bulkdataReferences[keyword] = elements[tag]
      } else if ('Value' in elements[tag]) {
        const value = elements[tag].Value
        if (vr === 'SQ') {
          dataset[keyword] = []
          const mappings = []
          value.forEach(item => {
            const loaded = loadJSONDataset(item)
            dataset[keyword].push(loaded.dataset)
            mappings.push(loaded.bulkdataReferences)
          })
          if (mappings.some(item => Object.keys(item).length > 0)) {
            bulkdataReferences[keyword] = mappings
          }
        } else {
          // Handle value multiplicity.
          if (value.length === 1) {
            if (vr === 'DS' || vr === 'IS') {
              dataset[keyword] = Number(value[0])
            } else {
              dataset[keyword] = value[0]
            }
          } else {
            if (vr === 'DS' || vr === 'IS') {
              dataset[keyword] = value.map(v => Number(v))
            } else {
              dataset[keyword] = value
            }
          }
        }
      } else if ('InlineBinary' in elements[tag]) {
        const value = elements[tag].InlineBinary
        if (vr === 'OB') {
          dataset[keyword] = _base64ToUint8Array(value)
        } else if (vr === 'OW') {
          dataset[keyword] = _base64ToUint16Array(value)
        } else if (vr === 'OL') {
          dataset[keyword] = _base64ToUint32Array(value)
        } else if (vr === 'OF') {
          dataset[keyword] = _base64ToFloat32Array(value)
        } else if (vr === 'OD') {
          dataset[keyword] = _base64ToFloat64Array(value)
        }
      } else {
        if (vr === 'SQ') {
          dataset[keyword] = []
        } else {
          dataset[keyword] = null
        }
      }
    })
    return { dataset, bulkdataReferences }
  }

  const { dataset, bulkdataReferences } = loadJSONDataset(metadata)

  // The top level (lowest resolution) image may be a single frame image in
  // which case the "NumberOfFrames" attribute is optional. We include it for
  // consistency.
  if (dataset === undefined) {
    throw new Error('Could not format metadata: ', metadata)
  }
  if (!('NumberOfFrames' in dataset) && (dataset.Modality === 'SM')) {
    dataset.NumberOfFrames = 1
  }

  return { dataset, bulkdataReferences }
}

/**
 * Group DICOM metadata of monochrome slides by Optical Path Identifier.
 *
 * @param {metadata.VLWholeSlideMicroscopyImage[]} images - DICOM VL Whole
 * Slide Microscopy Image instances.
 *
 * @returns {Object} Groups of DICOM VL Whole Slide Microscopy Image instances
 * @memberof metadata
 */
function groupMonochromeInstances (images) {
  const channels = {}
  images.forEach(img => {
    if (
      img.SamplesPerPixel === 1 &&
      img.PhotometricInterpretation === 'MONOCHROME2' &&
      (img.ImageType[2] === 'VOLUME' || img.ImageType[2] === 'THUMBNAIL')
    ) {
      img.OpticalPathSequence.forEach((opticalPathItem, opticalPathIndex) => {
        const id = opticalPathItem.OpticalPathIdentifier
        if (id in channels) {
          channels[id].push(img)
        } else {
          channels[id] = [img]
        }
      })
    }
  })
  return channels
}

/**
 * Group DICOM metadata of color images slides by Optical Path Identifier.
 *
 * @param {metadata.VLWholeSlideMicroscopyImage[]} images - DICOM VL Whole
 * Slide Microscopy Image instances.
 *
 * @returns {Object} Groups of DICOM VL Whole Slide Microscopy Image instances
 * @memberof metadata
 */
function groupColorInstances (images) {
  const channels = {}
  images.forEach(img => {
    if (
      img.SamplesPerPixel !== 1 &&
      (img.ImageType[2] === 'THUMBNAIL' || img.ImageType[2] === 'VOLUME') &&
      (
        img.PhotometricInterpretation === 'RGB' ||
        img.PhotometricInterpretation.includes('YBR')
      )
    ) {
      const id = img.OpticalPathSequence[0].OpticalPathIdentifier
      if (id in channels) {
        channels[id].push(img)
      } else {
        channels[id] = [img]
      }
    }
  })
  return channels
}

/**
 * DICOM Service Object Pair (SOP) Class.
 *
 * @class
 * @abstract
 * @memberof metadata
 */
class SOPClass {
  /**
   * @param {Object} options
   * @param {Object} options.metadata - Metadata of a DICOM SOP instance in DICOM JSON format
   */
  constructor ({ metadata }) {
    if (metadata == null) {
      throw new Error(
        'Cannot construct SOP Instance because no metadata was provided.'
      )
    }
    const { dataset, bulkdataReferences } = formatMetadata(metadata)
    Object.assign(this, dataset)
    this[_metadata] = metadata
    this[_bulkdataReferences] = bulkdataReferences
    Object.freeze(this)
  }

  /**
   * Get metadata of instance in DICOM JSON format.
   *
   * The metadata may include bulkdata references via "BulkDataURI".
   *
   * @returns {Object} metadata in DICOM JSON format
   */
  get json () {
    return this[_metadata]
  }

  /**
   * Get references to bulkdata of instance in DICOM JSON format.
   *
   * @returns {Object} bulkdata references in DICOM JSON format
   */
  get bulkdataReferences () {
    return this[_bulkdataReferences]
  }
}

/**
 * DICOM VL Whole Slide Microscopy Image instance.
 *
 * @class
 * @extends metadata.SOPClass
 * @memberof metadata
 */
class VLWholeSlideMicroscopyImage extends SOPClass {
  /**
   * @param {Object} options
   * @param {Object} options.metadata - Metadata of a VL Whole Slide Microscopy Image in DICOM JSON format
   */
  constructor ({ metadata }) {
    super({ metadata })
    if (this.SOPClassUID !== SOPClassUIDs.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE) {
      throw new Error(
        'Cannot construct VL Whole Slide Microscopy Image instance ' +
        `given dataset with SOP Class UID "${this.SOPClassUID}"`
      )
    }
  }
}

/**
 * DICOM Comprehensive 3D SR instance.
 *
 * @class
 * @extends metadata.SOPClass
 * @memberof metadata
 */
class Comprehensive3DSR extends SOPClass {
  /**
   * @param {Object} options
   * @param {Object} options.metadata - Metadata of DICOM Structured Report instance in DICOM JSON format
   */
  constructor ({ metadata }) {
    super({ metadata })
    if (this.SOPClassUID !== SOPClassUIDs.COMPREHENSIVE_3D_SR) {
      throw new Error(
        'Cannot construct Comprehensive 3D SR instance ' +
          `given dataset with SOP Class UID "${this.SOPClassUID}"`
      )
    }
  }
}

/**
 * DICOM Microscopy Bulk Simple Annotations instance.
 *
 * @class
 * @extends metadata.SOPClass
 * @memberof metadata
 */
class MicroscopyBulkSimpleAnnotations extends SOPClass {
  /**
   * @param {Object} options
   * @param {Object} options.metadata - Metadata of a DICOM Microscopy Bulk Simple Annotations instance in DICOM JSON format
   */
  constructor ({ metadata }) {
    super({ metadata })
    if (this.SOPClassUID !== SOPClassUIDs.MICROSCOPY_BULK_SIMPLE_ANNOTATIONS) {
      throw new Error(
        'Cannot construct Microscopy Bulk Simple Annotations instance ' +
          `given dataset with SOP Class UID "${this.SOPClassUID}"`
      )
    }
  }
}

/**
 * DICOM Parametric Map instance.
 *
 * @class
 * @extends metadata.SOPClass
 * @memberof metadata
 */
class ParametricMap extends SOPClass {
  /**
   * @param {Object} options
   * @param {Object} options.metadata - Metadata of a DICOM Parametric Map instance in DICOM JSON format
   */
  constructor ({ metadata }) {
    super({ metadata })
    if (this.SOPClassUID !== SOPClassUIDs.PARAMETRIC_MAP) {
      throw new Error(
        'Cannot construct Parametric Map instance ' +
          `given dataset with SOP Class UID "${this.SOPClassUID}"`
      )
    }
  }
}

/**
 * DICOM Segmentation instance.
 *
 * @class
 * @extends metadata.SOPClass
 * @memberof metadata
 */
class Segmentation extends SOPClass {
  /**
   * @param {Object} options
   * @param {Object} options.metadata - Metadata of a DICOM Segmentation instance in DICOM JSON format
   */
  constructor ({ metadata }) {
    super({ metadata })
    if (this.SOPClassUID !== SOPClassUIDs.SEGMENTATION) {
      throw new Error(
        'Cannot construct Segmentation instance ' +
        `given dataset with SOP Class UID "${this.SOPClassUID}"`
      )
    }
  }
}

export {
  Comprehensive3DSR,
  formatMetadata,
  groupMonochromeInstances,
  groupColorInstances,
  getFrameMapping,
  MicroscopyBulkSimpleAnnotations,
  ParametricMap,
  Segmentation,
  VLWholeSlideMicroscopyImage
}