dicom-microscopy-viewer
Version:
Interactive web-based viewer for DICOM Microscopy Images
1,542 lines (1,418 loc) • 190 kB
JavaScript
import 'ol/ol.css'
import Collection from 'ol/Collection'
import Draw, { createBox } from 'ol/interaction/Draw'
import EVENT from './events'
import publish from './eventPublisher'
import Feature from 'ol/Feature'
import Fill from 'ol/style/Fill'
import FullScreen from 'ol/control/FullScreen'
import Icon from 'ol/style/Icon'
import ImageLayer from 'ol/layer/Image'
import Map from 'ol/Map'
import Modify from 'ol/interaction/Modify'
import MousePosition from 'ol/control/MousePosition'
import OverviewMap from 'ol/control/OverviewMap'
import Projection from 'ol/proj/Projection'
import ScaleLine from 'ol/control/ScaleLine'
import Select from 'ol/interaction/Select'
import Snap from 'ol/interaction/Snap'
import Translate from 'ol/interaction/Translate'
import Style from 'ol/style/Style'
import Stroke from 'ol/style/Stroke'
import Circle from 'ol/style/Circle'
import Static from 'ol/source/ImageStatic'
import Cluster from 'ol/source/Cluster'
import Overlay from 'ol/Overlay'
import PointsLayer from 'ol/layer/WebGLPoints'
import TileLayer from 'ol/layer/WebGLTile'
import DataTileSource from 'ol/source/DataTile'
import TileGrid from 'ol/tilegrid/TileGrid'
import VectorSource from 'ol/source/Vector'
import VectorLayer from 'ol/layer/Vector'
import View from 'ol/View'
import DragPan from 'ol/interaction/DragPan'
import DragZoom from 'ol/interaction/DragZoom'
import WebGLHelper from 'ol/webgl/Helper'
import TileDebug from 'ol/source/TileDebug'
import { default as VectorEventType } from 'ol/source/VectorEventType'// eslint-disable-line
import { ZoomSlider, Zoom } from 'ol/control'
import { getCenter, createEmpty, extend, getHeight, getWidth } from 'ol/extent'
import { defaults as defaultInteractions } from 'ol/interaction'
import dcmjs from 'dcmjs'
import { has, debounce } from 'lodash'
import { CustomError, errorTypes } from './customError'
import {
AnnotationGroup,
_fetchGraphicData,
_fetchGraphicIndex,
// _fetchMeasurements,
_getCommonZCoordinate,
_getCoordinateDimensionality
} from './annotation.js'
import {
ColormapNames,
createColormap,
PaletteColorLookupTable,
buildPaletteColorLookupTable
} from './color.js'
import {
groupMonochromeInstances,
groupColorInstances,
VLWholeSlideMicroscopyImage
} from './metadata.js'
import { ParameterMapping, _groupFramesPerMapping } from './mapping.js'
import { ROI } from './roi.js'
import { Segment } from './segment.js'
import {
areCodedConceptsEqual,
applyTransform,
buildInverseTransform,
buildTransform,
computeRotation,
getContentItemNameCodedConcept,
_generateUID,
_getUnitSuffix,
doContentItemsMatch,
createWindow,
rgb2hex
} from './utils.js'
import {
_scoord3dCoordinates2geometryCoordinates,
_scoord3d2Geometry,
getPixelSpacing,
_geometry2Scoord3d,
_geometryCoordinates2scoord3dCoordinates,
_getFeatureLength,
_getFeatureArea
} from './scoord3dUtils'
import { OpticalPath } from './opticalPath.js'
import {
_areImagePyramidsEqual,
_computeImagePyramid,
_createTileLoadFunction,
_fitImagePyramid,
_getIccProfiles
} from './pyramid.js'
import {
getPointFeature,
getPolygonFeature,
getFeaturesFromBulkAnnotations,
getRectangleFeature,
getEllipseFeature
} from './bulkAnnotations/utils'
import Enums from './enums'
import _AnnotationManager from './annotations/_AnnotationManager'
import webWorkerManager from './webWorker/webWorkerManager.js'
import getExtendedROI from './bulkAnnotations/getExtendedROI'
import { getClusterStyleFunc } from './clusterStyles.js'
/**
* Dispose all map layers to free up memory.
*/
function disposeMapLayers (map) {
console.info('dispose map layers...')
map.getAllLayers().forEach(layer => {
disposeLayer(layer, true)
map.getView().dispose()
map.removeLayer(layer)
})
}
/**
* Dispose overview map layers to free up memory.
*/
function disposeOverviewMapLayers (map) {
console.info('dispose overview map layers...')
const overviewMap = map?.getOverviewMap()
if (overviewMap) {
const overviewMapLayers = overviewMap.getLayers()
if (overviewMapLayers) {
overviewMapLayers.forEach(layer => {
disposeLayer(layer, true)
overviewMap.removeLayer(layer)
})
}
}
}
/**
* Dispose layer and its dependencies to free up memory.
*/
export function disposeLayer (layer, disposeSource = false) {
console.info('dispose layer:', layer)
if (typeof layer?.getSource !== 'function') {
return
}
const source = layer.getSource()
if (disposeSource === true && source && source.clear) {
source.clear()
source.dispose()
}
layer.setSource(undefined)
layer.dispose()
}
function _getClient (clientMapping, sopClassUID) {
if (clientMapping[sopClassUID] == null) {
return clientMapping.default
}
return clientMapping[sopClassUID]
}
function _getInteractionBindingCondition (bindings) {
const BUTTONS = {
left: 1,
middle: 4,
right: 2
}
const { mouseButtons, modifierKey } = bindings
const _mouseButtonCondition = (event) => {
/** No mouse button condition set. */
if (!mouseButtons || !mouseButtons.length) {
return true
}
const button = event.pointerEvent
? event.pointerEvent.buttons
: event.originalEvent.buttons
return mouseButtons.some((mb) => BUTTONS[mb] === button)
}
const _modifierKeyCondition = (event) => {
const pointerEvent = event.pointerEvent
? event.pointerEvent
: event.originalEvent
if (!modifierKey) {
/**
* No modifier key, don't pass if key pressed as other
* tool may be using this tool.
*/
return (
!pointerEvent.altKey &&
!pointerEvent.metaKey &&
!pointerEvent.shiftKey &&
!pointerEvent.ctrlKey
)
}
switch (modifierKey) {
case 'alt':
return pointerEvent.altKey === true || pointerEvent.metaKey === true
case 'shift':
return pointerEvent.shiftKey === true
case 'ctrl':
return pointerEvent.ctrlKey === true
default:
/** Invalid modifier key set (ignore requirement as if key not pressed). */
return (
!pointerEvent.altKey &&
!pointerEvent.metaKey &&
!pointerEvent.shiftKey &&
!pointerEvent.ctrlKey
)
}
}
return (event) => {
return _mouseButtonCondition(event) && _modifierKeyCondition(event)
}
}
/**
* Get rotation of image relative to the slide coordinate system.
*
* Determines whether image needs to be rotated relative to slide
* coordinate system based on direction cosines.
* We want to rotate all images such that the X axis of the slide coordinate
* system is the vertical axis (ordinate) of the viewport and the Y axis
* of the slide coordinate system is the horizontal axis (abscissa) of the
* viewport. Note that this is opposite to the Openlayers coordinate system.
* There are only planar rotations, since the total pixel matrix is
* parallel to the slide surface. Here, we further assume that rows and
* columns of total pixel matrix are parallel to the borders of the slide,
* i.e. the X and Y axes of the slide coordinate system.
*
* The row direction (left to right) of the Total Pixel Matrix
* is defined by the first three Image Orientation Slide values.
* The three values specify how the direction changes from the last pixel
* to the first pixel in the row along each of the three axes of the
* slide coordinate system (X, Y, Z), i.e. it express in which direction one
* is moving in the slide coordinate system when the COLUMN index changes.
* The column direction (top to bottom) of the Total Pixel Matrix
* is defined by the second three Image Orientation Slide values.
* The three values specify how the direction changes from the last pixel
* to the first pixel in the column along each of the three axes of the
* slide coordinate system (X, Y, Z), i.e. it express in which direction one
* is moving in the slide coordinate system when the ROW index changes.
*
* @param {metadata.VLWholeSlideMicroscopyImage} metadata - Metadata of a DICOM
* VL Whole Slide Microscopy Image instance
*
* @returns {number} Rotation in radians
*
* @private
*/
function _getRotation (metadata) {
// Angle with respect to the reference orientation
const angle = computeRotation({
orientation: metadata.ImageOrientationSlide
})
// We want the slide oriented horizontally with the label on the right side
const correction = 90 * (Math.PI / 180)
return angle + correction
}
/**
* Map style options to OpenLayers style.
*
* @param {Object} styleOptions - Style options
* @param {Object} styleOptions.stroke - Style options for the outline of the geometry
* @param {number[]} styleOptions.stroke.color - RGBA color of the outline
* @param {number} styleOptions.stroke.width - Width of the outline
* @param {Object} styleOptions.fill - Style options for body the geometry
* @param {number[]} styleOptions.fill.color - RGBA color of the body
* @param {Object} styleOptions.image - Style options for image
* @return {Style} OpenLayers style
*
* @private
*/
function _getOpenLayersStyle (styleOptions) {
const style = new Style()
if ('stroke' in styleOptions) {
const strokeOptions = {
color: styleOptions.stroke.color,
width: styleOptions.stroke.width
}
const stroke = new Stroke(strokeOptions)
style.setStroke(stroke)
}
if ('fill' in styleOptions) {
const fillOptions = {
color: styleOptions.fill.color
}
const fill = new Fill(fillOptions)
style.setFill(fill)
}
if ('image' in styleOptions) {
const { image } = styleOptions
if (image.circle) {
const options = {
radius: image.circle.radius,
stroke: new Stroke(image.circle.stroke),
fill: new Fill(image.circle.fill)
}
const circle = new Circle(options)
style.setImage(circle)
}
if (image.icon) {
const icon = new Icon(image.icon)
style.setImage(icon)
}
}
return style
}
/**
* Add ROI properties to feature in a safe way
*
* @param {Object} feature - The feature instance that represents the ROI
* @param {Object} properties -Valid ROI properties
* @param {Object} properties.measurements - ROI measurements
* @param {Object} properties.evaluations - ROI evaluations
* @param {Object} properties.label - ROI label
* @param {Object} properties.marker - ROI marker (this is used while we don't have presentation states)
* @param {boolean} optSilent - Opt silent update
*
* @private
*/
function _addROIPropertiesToFeature (feature, properties, optSilent) {
const { Label, Measurements, Evaluations, Marker } = Enums.InternalProperties
if (properties[Label]) {
feature.set(Label, properties[Label], optSilent)
}
if (properties[Measurements]) {
feature.set(Measurements, properties[Measurements], optSilent)
}
if (properties[Evaluations]) {
feature.set(Evaluations, properties[Evaluations], optSilent)
}
if (properties[Marker]) {
feature.set(Marker, properties[Marker], optSilent)
}
}
/**
* Wire measurements and qualitative evaluations to generate content items
* based on OpenLayers feature properties and geometry.
*
* @param {Object} map - The map instance
* @param {Object} feature - The feature instance
* @param {Object} pyramid - The pyramid metadata
* @param {number[][]} affine - 3x3 affine transformation matrix
* @returns {void}
*
* @private
*/
function _wireMeasurementsAndQualitativeEvaluationsEvents (
map,
feature,
pyramid,
affine
) {
/**
* Update feature measurement properties first and then measurements
*/
_updateFeatureMeasurements(map, feature, pyramid, affine)
feature.on(Enums.FeatureEvents.CHANGE, (event) => {
_updateFeatureMeasurements(map, event.target, pyramid, affine)
})
/**
* Update feature evaluations
*/
_updateFeatureEvaluations(feature)
feature.on(Enums.FeatureEvents.PROPERTY_CHANGE, (event) =>
_updateFeatureEvaluations(event.target)
)
}
/**
* Update feature evaluations from its properties
*
* @param {Feature} feature
* @returns {void}
*
* @private
*/
function _updateFeatureEvaluations (feature) {
const evaluations = feature.get(Enums.InternalProperties.Evaluations) || []
const label = feature.get(Enums.InternalProperties.Label)
if (!label) return
const evaluation = new dcmjs.sr.valueTypes.TextContentItem({
name: new dcmjs.sr.coding.CodedConcept({
value: '112039',
meaning: 'Tracking Identifier',
schemeDesignator: 'DCM'
}),
value: label,
relationshipType: Enums.RelationshipTypes.HAS_OBS_CONTEXT
})
const index = evaluations.findIndex((e) =>
doContentItemsMatch(e, evaluation)
)
if (index > -1) {
evaluations[index] = evaluation
} else {
evaluations.push(evaluation)
}
feature.set(Enums.InternalProperties.Evaluations, evaluations)
}
/**
* Generate feature measurements from its measurement properties
*
* @param {Object} map - The map instance
* @param {Object} feature - The feature instance
* @param {Object} pyramid - The pyramid metadata
* @returns {void}
*
* @private
*/
function _updateFeatureMeasurements (map, feature, pyramid, affine) {
if (
Enums.Markup.Measurement !== feature.get(Enums.InternalProperties.Markup)
) {
return
}
const measurements = feature.get(Enums.InternalProperties.Measurements) || []
const area = _getFeatureArea(feature, pyramid, affine)
const length = _getFeatureLength(feature, pyramid, affine)
if (area == null && length == null) {
return
}
const unitSuffixToMeaningMap = {
μm: 'micrometer',
μm2: 'square micrometer',
mm: 'millimeter',
mm2: 'square millimeter',
m: 'meters',
m2: 'square meters',
km2: 'square kilometers'
}
let measurement
const view = map.getView()
const unitSuffix = _getUnitSuffix(view)
if (area != null) {
const unitCodedConceptValue = `${unitSuffix}2`
const unitCodedConceptMeaning = unitSuffixToMeaningMap[unitSuffix]
measurement = new dcmjs.sr.valueTypes.NumContentItem({
name: new dcmjs.sr.coding.CodedConcept({
meaning: 'Area',
value: '42798000',
schemeDesignator: 'SCT'
}),
value: area,
unit: new dcmjs.sr.coding.CodedConcept({
value: unitCodedConceptValue,
meaning: unitCodedConceptMeaning,
schemeDesignator: 'SCT'
})
})
}
if (length != null) {
const unitCodedConceptValue = unitSuffix
const unitCodedConceptMeaning = unitSuffixToMeaningMap[unitSuffix]
measurement = new dcmjs.sr.valueTypes.NumContentItem({
name: new dcmjs.sr.coding.CodedConcept({
meaning: 'Length',
value: '410668003',
schemeDesignator: 'SCT'
}),
value: length,
unit: new dcmjs.sr.coding.CodedConcept({
value: unitCodedConceptValue,
meaning: unitCodedConceptMeaning,
schemeDesignator: 'SCT'
})
})
}
if (measurement) {
const index = measurements.findIndex((m) => (
doContentItemsMatch(m, measurement)
))
if (index > -1) {
measurements[index] = measurement
} else {
measurements.push(measurement)
}
feature.set(Enums.InternalProperties.Measurements, measurements)
}
}
/**
* Updates the style of a feature.
*
* @param {Object} styleOptions - Style options
* @param {Object} styleOptions.stroke - Style options for the outline of the geometry
* @param {number[]} styleOptions.stroke.color - RGBA color of the outline
* @param {number} styleOptions.stroke.width - Width of the outline
* @param {Object} styleOptions.fill - Style options for body the geometry
* @param {number[]} styleOptions.fill.color - RGBA color of the body
* @param {Object} styleOptions.image - Style options for image
*
* @private
*/
function _setFeatureStyle (feature, styleOptions) {
if (styleOptions !== undefined) {
const style = _getOpenLayersStyle(styleOptions)
feature.setStyle(style)
/**
* styleOptions is used internally by internal styled components like markers.
* This allows them to take priority over styling since OpenLayers swaps the styles
* completely in case of a setStyle happens.
*/
feature.set(Enums.InternalProperties.StyleOptions, styleOptions)
}
}
/**
* Build OpenLayers style expression for coloring a WebGL TileLayer.
*
* @param {Object} styleOptions - Style options
* @param {number} styleOptions.windowCenter - Center of the window used for contrast stretching
* @param {number} styleOptions.windowWidth - Width of the window used for contrast stretching
* @param {number[][]} styleOptions.colormap - RGB color triplets
*
* @returns {Object} color style expression and corresponding variables
*
* @private
*/
function _getColorPaletteStyleForTileLayer ({
windowCenter,
windowWidth,
colormap
}) {
/*
* The Palette Color Lookup Table applies to the index values in the range
* [0, n] that are obtained by scaling stored pixel values between the lower
* and upper value of interest (VOI) defined by the window center and width.
*/
const minIndexValue = 0
const maxIndexValue = colormap.length - 1
const indexExpression = [
'clamp',
[
'+',
[
'*',
[
'+',
[
'/',
[
'-',
['band', 1],
[
'-',
['var', 'windowCenter'],
0.5
]
],
[
'-',
['var', 'windowWidth'],
1
]
],
0.5
],
[
'-',
maxIndexValue,
minIndexValue
]
],
minIndexValue
],
minIndexValue,
maxIndexValue
]
const expression = [
'palette',
indexExpression,
colormap
]
const variables = {
windowCenter,
windowWidth
}
return { color: expression, variables }
}
/**
* Build OpenLayers style expression for coloring a WebGL PointLayer.
*
* @param {Object} styleOptions - Style options
* @param {string} styleOptions.key - Name of a property for which values
* should be colorized
* @param {number} styleOptions.minValue - Mininum value of the output range
* @param {number} styleOptions.maxValue - Maxinum value of the output range
* @param {number[]} styleOptions.color - RGB color triplet
*
* @returns {Object} color style expression and corresponding variables
*
* @private
*/
function _getColorInterpolationStyleForPointLayer ({
key,
minValue,
maxValue,
color
}) {
const minIndexValue = 0
const maxIndexValue = 1
const indexExpression = [
'+',
[
'/',
[
'*',
[
'-',
['get', key],
minValue
],
[
'-',
maxIndexValue,
minIndexValue
]
],
[
'-',
maxValue,
minValue
]
],
minIndexValue
]
const expression = [
'interpolate',
['linear'],
indexExpression,
0,
[255, 255, 255, 1],
1,
color
]
return { color: expression }
}
/**
* Build OpenLayers style expression for coloring a WebGL TileLayer.
*
* @param {Object} styleOptions - Style options
* @param {number} styleOptions.windowCenter - Center of the window used for contrast stretching
* @param {number} styleOptions.windowWidth - Width of the window used for contrast stretching
* @param {number[]} styleOptions.color - RGB color triplet
*
* @returns {Object} color style expression and corresponding variables
*
* @private
*/
function _getColorInterpolationStyleForTileLayer ({
windowCenter,
windowWidth,
color
}) {
/*
* If no Palette Color Lookup Table is available, don't create one
* but let WebGL interpolate colors for improved performance.
*/
const expression = [
'interpolate',
['linear'],
[
'+',
[
'/',
[
'-',
['band', 1],
['var', 'windowCenter']
],
['var', 'windowWidth']
],
0.5
],
0,
[0, 0, 0, 1],
1,
['color', ['var', 'red'], ['var', 'green'], ['var', 'blue'], 1]
]
const variables = {
red: color[0],
green: color[1],
blue: color[2],
windowCenter,
windowWidth
}
return { color: expression, variables }
}
const _errorInterceptor = Symbol('errorInterceptor')
const _retrievedBulkdata = Symbol('retrievedBulkdata')
const _affine = Symbol.for('affine')
const _affineInverse = Symbol('affineInverse')
const _annotationManager = Symbol('annotationManager')
const _annotationGroups = Symbol('annotationGroups')
const _areIccProfilesFetched = Symbol('areIccProfilesFetched')
const _clients = Symbol('clients')
const _controls = Symbol('controls')
const _drawingLayer = Symbol('drawingLayer')
const _drawingSource = Symbol('drawingSource')
const _features = Symbol('features')
const _imageLayer = Symbol('imageLayer')
const _interactions = Symbol.for('interactions')
const _map = Symbol.for('map')
const _mappings = Symbol('mappings')
const _metadata = Symbol('metadata')
const _opticalPaths = Symbol('opticalPaths')
const _options = Symbol('options')
const _overlays = Symbol('overlays')
const _overviewMap = Symbol('overviewMap')
const _projection = Symbol('projection')
const _pyramid = Symbol('pyramid')
const _segments = Symbol('segments')
const _rotation = Symbol('rotation')
const _tileGrid = Symbol('tileGrid')
const _updateOverviewMapSize = Symbol('updateOverviewMapSize')
const _annotationOptions = Symbol('annotationOptions')
const _isICCProfilesEnabled = Symbol('isICCProfilesEnabled')
const _iccProfiles = Symbol('iccProfiles')
const _container = Symbol('container')
const _highResSources = Symbol('highResSources')
const _pointsSources = Symbol('pointsSources')
const _clustersSources = Symbol('clustersSources')
/**
* Interactive viewer for DICOM VL Whole Slide Microscopy Image instances
* with Image Type VOLUME.
*
* @class
* @memberof viewer
*/
class VolumeImageViewer {
/**
* Create a viewer instance for displaying VOLUME images.
*
* @param {Object} options
* @param {metadata.VLWholeSlideMicroscopyImage[]} options.metadata -
* Metadata of DICOM VL Whole Slide Microscopy Image instances that should be
* diplayed.
* @param {Object} [options.client] - A DICOMwebClient instance for search for
* and retrieve data from an origin server over HTTP
* @param {Object} [options.clientMapping] - Mapping of SOP Class UIDs to
* DICOMwebClient instances to search for and retrieve data from different
* origin servers, depending on the type of DICOM object. Using a mapping can
* be usedful, for example, if images, image annotations, or image analysis
* results are stored in different archives.
* @param {number} [options.preload=0] - Number of resolution levels that
* should be preloaded
* @param {string[]} [options.controls=[]] - Names of viewer control elements
* that should be included in the viewport
* @param {boolean} [options.debug=false] - Whether debug features should be
* turned on (e.g., display of tile boundaries)
* @param {number} [options.tilesCacheSize=1000] - Number of tiles that should
* be cached to avoid repeated retrieval for the DICOMweb server
* @param {number[]} [options.primaryColor=[255, 234, 0]] - Primary color of
* the application
* @param {number[]} [options.highlightColor=[140, 184, 198]] - Color that
* should be used to highlight things that get selected by the user
* @param {object} [options.annotationOptions] - Annotation options
* @param {errorInterceptor} [options.errorInterceptor] - Callback for
* intercepting errors
* @param {number[]} [options.mapViewResolutions] Map's view list of
* resolutions.
* @param {boolean} [options.useTileGridResolutions=true] If false,
* zoom will not be limited and the image will fit the viewport extent (no clipping).
* This option will be ignored if there are no thumbnail images available or there's just one image.
*/
constructor (options) {
this[_options] = options
this[_retrievedBulkdata] = {}
this[_annotationOptions] = {}
this[_clients] = {}
this[_errorInterceptor] = options.errorInterceptor || (error => error)
this[_isICCProfilesEnabled] = true
this[_container] = null
this[_clients] = {}
this[_iccProfiles] = []
this[_highResSources] = {}
this[_pointsSources] = {}
this[_clustersSources] = {}
this._onBulkAnnotationsFeaturesLoadStart = this._onBulkAnnotationsFeaturesLoadStart.bind(this)
this._onBulkAnnotationsFeaturesLoadEnd = this._onBulkAnnotationsFeaturesLoadEnd.bind(this)
this._onBulkAnnotationsFeaturesLoadError = this._onBulkAnnotationsFeaturesLoadError.bind(this)
this.segmentOverlay = new Overlay({
element: document.createElement('div'),
offset: [7, 5]
})
if (this[_options].client) {
this[_clients].default = this[_options].client
} else {
if (this[_options].clientMapping == null) {
const error = new CustomError(
errorTypes.ENCODINGANDDECODING,
'Either option "client" or option "clientMapping" must be provided .'
)
throw this[_options].errorInterceptor(error)
}
if (!(typeof this[_options].clientMapping === 'object')) {
const error = new CustomError(
errorTypes.ENCODINGANDDECODING,
'Option "clientMapping" must be an object.'
)
throw this[_options].errorInterceptor(error)
}
if (this[_options].clientMapping.default == null) {
const error = new CustomError(
errorTypes.ENCODINGANDDECODING,
'Option "clientMapping" must contain "default" key.'
)
throw this[_options].errorInterceptor(error)
}
for (const key in this[_options].clientMapping) {
this[_clients][key] = this[_options].clientMapping[key]
}
}
if (this[_options].annotationOptions) {
this[_annotationOptions] = this[_options].annotationOptions
}
if (this[_options].errorInterceptor == null) {
this[_options].errorInterceptor = error => error
}
if (this[_options].useTileGridResolutions == null) {
this[_options].useTileGridResolutions = true
}
if (this[_options].debug == null) {
this[_options].debug = false
} else {
this[_options].debug = true
}
if (this[_options].preload == null) {
this[_options].preload = false
} else {
this[_options].preload = true
}
if (this[_options].tilesCacheSize == null) {
this[_options].tilesCacheSize = 1000
}
if (this[_options].controls == null) {
this[_options].controls = []
}
this[_options].controls = new Set(this[_options].controls)
if (this[_options].primaryColor == null) {
this[_options].primaryColor = [255, 234, 0]
}
if (this[_options].highlightColor == null) {
this[_options].highlightColor = [140, 184, 198]
}
// Collection of Openlayers "TileLayer" instances
this[_segments] = {}
this[_mappings] = {}
this[_annotationGroups] = {}
this[_areIccProfilesFetched] = false
// Collection of Openlayers "Feature" instances
this[_features] = new Collection([], { unique: true })
// Add unique identifier to each created "Feature" instance
this[_features].on('add', (e) => {
// The ID may have already been set when drawn. However, features could
// have also been added without a draw event.
if (e.element.getId() === undefined) {
e.element.setId(_generateUID())
}
this[_annotationManager].onAdd(e.element)
})
this[_features].on('remove', (e) => {
this[_annotationManager].onRemove(e.element)
})
if (this[_options].metadata.constructor.name !== 'Array') {
const error = new CustomError(
errorTypes.ENCODINGANDDECODING,
'Input metadata must be an array.'
)
throw this[_options].errorInterceptor(error)
}
if (this[_options].metadata.length === 0) {
const error = new CustomError(
errorTypes.ENCODINGANDDECODING,
'Input metadata array is empty.'
)
throw this[_options].errorInterceptor(error)
}
if (this[_options].metadata.some((item) => typeof item !== 'object')) {
const error = new CustomError(
errorTypes.ENCODINGANDDECODING,
'Input metadata must be an array of objects.'
)
throw this[_options].errorInterceptor(error)
}
const ImageFlavors = {
VOLUME: 'VOLUME',
LABEL: 'LABEL',
OVERVIEW: 'OVERVIEW',
THUMBNAIL: 'THUMBNAIL'
}
const hasImageFlavor = (image, imageFlavor) => {
return image.ImageType[2] === imageFlavor
}
/*
* Only include THUMBNAIL image into metadata if no other VOLUME image
* exists with the same resolution
*/
const filterImagesByResolution = (metadata) => {
const pyramidBaseMetadata = metadata[metadata.length - 1]
const filteredMetadata = metadata.filter(image => {
if (hasImageFlavor(image, ImageFlavors.THUMBNAIL)) {
const hasThumbnailEquivalentVolumeImage = metadata.some(
(img) =>
hasImageFlavor(img, ImageFlavors.VOLUME) &&
pyramidBaseMetadata.TotalPixelMatrixColumns / img.TotalPixelMatrixColumns ===
pyramidBaseMetadata.TotalPixelMatrixColumns / image.TotalPixelMatrixColumns
)
if (hasThumbnailEquivalentVolumeImage) {
console.debug('Thumbnail image has equivalent volume image resolution, skipping thumbnail.', image.SOPInstanceUID)
return false
}
return true
} else {
return true
}
})
return filteredMetadata
}
// We also accept metadata in raw JSON format for backwards compatibility
const filteredMetadata = filterImagesByResolution(this[_options].metadata)
if (filteredMetadata[0].SOPClassUID != null) {
this[_metadata] = filteredMetadata
} else {
this[_metadata] = filteredMetadata.map(instance => {
return new VLWholeSlideMicroscopyImage({ metadata: instance })
})
}
// Group color images by opticalPathIdentifier
const colorGroups = groupColorInstances(this[_metadata])
const colorImageInformation = {}
let colorOpticalPathIdentifiers = Object.keys(colorGroups)
if (colorOpticalPathIdentifiers.length > 0) {
const id = colorOpticalPathIdentifiers[0]
if (colorOpticalPathIdentifiers.length > 1) {
console.warn(
'Volume Image Viewer detected more than one color image, ' +
'but only one color image can be loaded and visualized at a time. ' +
'Only the first detected color image will be loaded.'
)
colorOpticalPathIdentifiers = [id]
}
colorImageInformation[id] = {
metadata: colorGroups[id],
opticalPath: this[_metadata][0].OpticalPathSequence[0]
}
}
const monochromeGroups = groupMonochromeInstances(this[_metadata])
const monochromeOpticalPathIdentifiers = Object.keys(monochromeGroups)
const monochromeImageInformation = {}
monochromeOpticalPathIdentifiers.forEach(id => {
const refImage = monochromeGroups[id][0]
const opticalPath = refImage.OpticalPathSequence.find(item => {
return item.OpticalPathIdentifier === id
})
monochromeImageInformation[id] = {
metadata: monochromeGroups[id],
opticalPath
}
})
const numChannels = monochromeOpticalPathIdentifiers.length
const numColorImages = colorOpticalPathIdentifiers.length
if (numChannels === 0 && numColorImages === 0) {
const error = new CustomError(
errorTypes.VISUALIZATION,
'Could not find any channels or color images.'
)
throw this[_options].errorInterceptor(error)
}
if (numChannels > 0 && numColorImages > 0) {
const error = new CustomError(
errorTypes.VISUALIZATION,
'Found both channels and color images.'
)
throw this[_options].errorInterceptor(error)
}
if (numColorImages > 1) {
const error = new CustomError(
errorTypes.VISUALIZATION,
'Found more than one color image.'
)
throw this[_options].errorInterceptor(error)
}
/*
* For blending we have to make some assumptions
* 1) all channels should have the same origins, resolutions, grid sizes,
* tile sizes and pixel spacings (i.e. same TileGrid).
* These are arrays with number of element equal the number of pyramid
* levels. All channels shall have the same number of levels.
* 2) given (1), we calculcate the tileGrid, projection and rotation objects
* using the metadata of the first channel and subsequently apply them to
* all the other channels.
* 3) If the parameters in (1) are different, it means that we would have to
* perfom registration, which (at least for now) is out of scope.
*/
if (numChannels > 0) {
const opticalPathIdentifier = monochromeOpticalPathIdentifiers[0]
const info = monochromeImageInformation[opticalPathIdentifier]
this[_pyramid] = _computeImagePyramid({ metadata: info.metadata })
} else {
const opticalPathIdentifier = colorOpticalPathIdentifiers[0]
const info = colorImageInformation[opticalPathIdentifier]
this[_pyramid] = _computeImagePyramid({ metadata: info.metadata })
}
const metadata = this[_pyramid].metadata[this[_pyramid].metadata.length - 1]
const origin = metadata.TotalPixelMatrixOriginSequence[0]
const orientation = metadata.ImageOrientationSlide
const spacing = getPixelSpacing(metadata)
const offset = [
Number(origin.XOffsetInSlideCoordinateSystem),
Number(origin.YOffsetInSlideCoordinateSystem)
]
this[_affine] = buildTransform({
offset,
orientation,
spacing
})
this[_affineInverse] = buildInverseTransform({
offset,
orientation,
spacing
})
this[_rotation] = _getRotation(this[_pyramid].metadata[0])
/*
* Specify projection to prevent default automatic projection
* with the default Mercator projection.
*/
this[_projection] = new Projection({
code: 'DICOM',
units: 'm',
global: true,
extent: this[_pyramid].extent,
getPointResolution: (pixelRes, point) => {
/*
* DICOM Pixel Spacing has millimeter unit while the projection has
* meter unit.
*/
const spacing = getPixelSpacing(
this[_pyramid].metadata[this[_pyramid].metadata.length - 1]
)[0]
return pixelRes * spacing / 10 ** 3
}
})
/*
* We need to specify the tile grid, since DICOM allows tiles to
* have different sizes at each resolution level and a different zoom
* factor between individual levels.
*/
this[_tileGrid] = new TileGrid({
extent: this[_pyramid].extent,
origins: this[_pyramid].origins,
resolutions: this[_pyramid].resolutions,
sizes: this[_pyramid].gridSizes,
tileSizes: this[_pyramid].tileSizes
})
let mapViewResolutions =
this[_options].useTileGridResolutions === false
? undefined
: this[_tileGrid].getResolutions()
/**
* If there are no thumbnail images, dont use any resolutions so we can create a thumbnail image by
* loading the the tiles of the lowest resolution and show the entire extent.
* Using resolutions will cause the viewer to clip the image to the extent of the viewport. This is
* not what we want for a thumbnail image.
*/
if (!this[_metadata].find((image) => image.ImageType[2] === ImageFlavors.THUMBNAIL) && this[_metadata].length > 1) {
mapViewResolutions = undefined
}
if (has(this[_options], 'mapViewResolutions')) {
mapViewResolutions = this[_options].mapViewResolutions
}
const view = new View({
center: getCenter(this[_pyramid].extent),
projection: this[_projection],
rotation: this[_rotation],
constrainOnlyCenter: false,
resolutions: mapViewResolutions,
smoothResolutionConstraint: true,
showFullExtent: true,
extent: this[_pyramid].extent
})
const layers = []
const overviewLayers = []
this[_opticalPaths] = {}
if (numChannels > 0) {
const helper = new WebGLHelper()
const overviewHelper = new WebGLHelper()
for (const opticalPathIdentifier in monochromeImageInformation) {
const info = monochromeImageInformation[opticalPathIdentifier]
const pyramid = _computeImagePyramid({ metadata: info.metadata })
console.info(`channel "${opticalPathIdentifier}"`, pyramid)
const bitsAllocated = info.metadata[0].BitsAllocated
const minStoredValue = 0
const maxStoredValue = Math.pow(2, bitsAllocated) - 1
let paletteColorLookupTableUID
let paletteColorLookupTable
if (info.opticalPath.PaletteColorLookupTableSequence) {
const item = info.opticalPath.PaletteColorLookupTableSequence[0]
paletteColorLookupTableUID = (
item.PaletteColorLookupTableUID
? item.PaletteColorLookupTableUID
: _generateUID()
)
/*
* TODO: If the LUT Data are large, the elements may be bulkdata and
* then have to be retrieved separately. However, for optical paths
* they are typically communicated as Segmented LUT Data and thus
* relatively small.
*/
paletteColorLookupTable = new PaletteColorLookupTable({
uid: item.PaletteColorLookupTableUID,
redDescriptor: item.RedPaletteColorLookupTableDescriptor,
greenDescriptor: item.GreenPaletteColorLookupTableDescriptor,
blueDescriptor: item.BluePaletteColorLookupTableDescriptor,
redData: item.RedPaletteColorLookupTableData,
greenData: item.GreenPaletteColorLookupTableData,
blueData: item.BluePaletteColorLookupTableData,
redSegmentedData: item.SegmentedRedPaletteColorLookupTableData,
greenSegmentedData: item.SegmentedGreenPaletteColorLookupTableData,
blueSegmentedData: item.SegmentedBluePaletteColorLookupTableData
})
}
const defaultOpticalPathStyle = {
opacity: 1,
limitValues: [minStoredValue, maxStoredValue]
}
if (paletteColorLookupTable) {
defaultOpticalPathStyle.paletteColorLookupTable = paletteColorLookupTable
} else {
defaultOpticalPathStyle.color = [255, 255, 255]
}
const opticalPath = {
opticalPathIdentifier,
opticalPath: new OpticalPath({
identifier: opticalPathIdentifier,
description: info.opticalPath.OpticalPathDescription,
isMonochromatic: true,
illuminationType: info.opticalPath.IlluminationTypeCodeSequence[0],
illuminationWaveLength: info.opticalPath.IlluminationWaveLength,
illuminationColor: (
info.opticalPath.IlluminationColorCodeSequence
? info.opticalPath.IlluminationColorCodeSequence[0]
: undefined
),
studyInstanceUID: info.metadata[0].StudyInstanceUID,
seriesInstanceUID: info.metadata[0].SeriesInstanceUID,
sopInstanceUIDs: pyramid.metadata.map(element => {
return element.SOPInstanceUID
}),
paletteColorLookupTableUID
}),
pyramid,
style: { ...defaultOpticalPathStyle },
defaultStyle: defaultOpticalPathStyle,
bitsAllocated,
minStoredValue,
maxStoredValue,
loaderParams: {
pyramid,
client: _getClient(
this[_clients],
Enums.SOPClassUIDs.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE
),
channel: opticalPathIdentifier
},
hasLoader: false
}
const areImagePyramidsEqual = _areImagePyramidsEqual(
opticalPath.pyramid,
this[_pyramid]
)
if (!areImagePyramidsEqual) {
const error = new CustomError(
errorTypes.VISUALIZATION,
`Pyramid of optical path "${opticalPathIdentifier}" ` +
'is different from reference pyramid.'
)
throw this[_options].errorInterceptor(error)
}
const source = new DataTileSource({
tileGrid: this[_tileGrid],
projection: this[_projection],
wrapX: false,
transition: 0,
bandCount: 1
})
source.on('tileloaderror', (event) => {
console.error(
`error loading tile of optical path "${opticalPathIdentifier}"`,
event.tile?.error_?.message || event
)
const error = new CustomError(
errorTypes.VISUALIZATION,
`error loading tile of optical path "${opticalPathIdentifier}": ${event.tile?.error_?.message || event.message}`
)
this[_options].errorInterceptor(error)
})
const [windowCenter, windowWidth] = createWindow(
opticalPath.style.limitValues[0],
opticalPath.style.limitValues[1]
)
let layerStyle
if (opticalPath.style.paletteColorLookupTable) {
layerStyle = _getColorPaletteStyleForTileLayer({
windowCenter,
windowWidth,
colormap: opticalPath.style.paletteColorLookupTable.data
})
} else {
layerStyle = _getColorInterpolationStyleForTileLayer({
windowCenter,
windowWidth,
color: opticalPath.style.color
})
}
opticalPath.layer = new TileLayer({
source,
extent: pyramid.extent,
preload: this[_options].preload ? 1 : 0,
style: layerStyle,
visible: false,
useInterimTilesOnError: false,
cacheSize: this[_options].tilesCacheSize
})
opticalPath.layer.helper = helper
opticalPath.layer.on('precompose', (event) => {
const gl = event.context
gl.enable(gl.BLEND)
gl.blendEquation(gl.FUNC_ADD)
gl.blendFunc(gl.SRC_COLOR, gl.ONE)
})
opticalPath.layer.on('error', (event) => {
console.error(
`error rendering optical path "${opticalPathIdentifier}"`,
event
)
const error = new CustomError(
errorTypes.VISUALIZATION,
`error rendering optical path "${opticalPathIdentifier}": ${event.message}`
)
this[_options].errorInterceptor(error)
})
opticalPath.overviewLayer = new TileLayer({
source,
extent: pyramid.extent,
preload: 0,
style: layerStyle,
visible: false,
useInterimTilesOnError: false
})
opticalPath.overviewLayer.helper = overviewHelper
opticalPath.overviewLayer.on('precompose', (event) => {
const gl = event.context
gl.enable(gl.BLEND)
gl.blendEquation(gl.FUNC_ADD)
gl.blendFunc(gl.SRC_COLOR, gl.ONE)
})
this[_opticalPaths][opticalPathIdentifier] = opticalPath
}
} else {
const opticalPathIdentifier = colorOpticalPathIdentifiers[0]
const info = colorImageInformation[opticalPathIdentifier]
const pyramid = _computeImagePyramid({ metadata: info.metadata })
const defaultOpticalPathStyle = { opacity: 1 }
const opticalPath = {
opticalPathIdentifier,
opticalPath: new OpticalPath({
identifier: opticalPathIdentifier,
description: info.opticalPath.OpticalPathDescription,
illuminationType: info.opticalPath.IlluminationTypeCodeSequence[0],
isMonochromatic: false,
studyInstanceUID: info.metadata[0].StudyInstanceUID,
seriesInstanceUID: info.metadata[0].SeriesInstanceUID,
sopInstanceUIDs: pyramid.metadata.map(element => {
return element.SOPInstanceUID
})
}),
style: { ...defaultOpticalPathStyle },
defaultStyle: defaultOpticalPathStyle,
pyramid,
bitsAllocated: 8,
minStoredValue: 0,
maxStoredValue: 255,
loaderParams: {
pyramid,
client: _getClient(
this[_clients],
Enums.SOPClassUIDs.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE
),
channel: opticalPathIdentifier
},
hasLoader: false
}
const source = new DataTileSource({
tileGrid: this[_tileGrid],
projection: this[_projection],
wrapX: false,
transition: 0,
bandCount: 3
})
source.on('tileloaderror', (event) => {
console.error(
`error loading tile of optical path "${opticalPathIdentifier}"`,
event.tile?.error_?.message || event
)
const error = new CustomError(
errorTypes.VISUALIZATION,
`error loading tile of optical path "${opticalPathIdentifier}": ${event.tile?.error_?.message || event.message}`
)
this[_options].errorInterceptor(error)
})
opticalPath.layer = new TileLayer({
source,
extent: this[_tileGrid].extent,
preload: this[_options].preload ? 1 : 0,
useInterimTilesOnError: false,
cacheSize: this[_options].tilesCacheSize
})
opticalPath.layer.on('error', (event) => {
console.error(
`error rendering optical path "${opticalPathIdentifier}"`,
event
)
const error = new CustomError(
errorTypes.VISUALIZATION,
`error rendering optical path "${opticalPathIdentifier}": ${event.message}`
)
this[_options].errorInterceptor(error)
})
opticalPath.overviewLayer = new TileLayer({
source,
extent: pyramid.extent,
preload: 0,
useInterimTilesOnError: false
})
layers.push(opticalPath.layer)
overviewLayers.push(opticalPath.overviewLayer)
this[_opticalPaths][opticalPathIdentifier] = opticalPath
}
if (this[_options].debug) {
const tileDebugSource = new TileDebug({
projection: this[_projection],
extent: this[_pyramid].extent,
tileGrid: this[_tileGrid],
wrapX: false,
template: ' '
})
const tileDebugLayer = new TileLayer({
source: tileDebugSource,
extent: this[_pyramid].extent,
projection: this[_projection]
})
layers.push(tileDebugLayer)
}
const noThumbnails = !this[_metadata].find((image) => image.ImageType[2] === ImageFlavors.THUMBNAIL) && this[_metadata].length > 1
if (Math.max(...this[_pyramid].gridSizes[0]) <= 10 || noThumbnails) {
const center = getCenter(this[_projection].getExtent())
this[_overviewMap] = new OverviewMap({
view: new View({
projection: this[_projection],
rotation: this[_rotation],
constrainOnlyCenter: true,
resolutions: [this[_tileGrid].getResolution(0)],
extent: center.concat(center),
showFullExtent: true
}),
layers: overviewLayers,
collapsed: false,
collapsible: true,
rotateWithView: true
})
this[_updateOverviewMapSize] = () => {
const degrees = this[_rotation] / Math.PI * 180
const isRotated = !(
Math.abs(degrees - 180) < 0.01 || Math.abs(degrees - 0) < 0.01
)
const viewport = this[_map].getViewport()
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const viewportHeightFraction = 0.45
const viewportWidthFraction = 0.25
const targetHeight = viewportHeight * viewportHeightFraction
const targetWidth = viewportWidth * viewportWidthFraction
const extent = this[_projection].getExtent()
let height
let width
let resolution
if (isRotated) {
if (targetWidth > targetHeight) {
height = targetHeight
width = (height * getHeight(extent)) / getWidth(extent)
resolution = getWidth(extent) / height
} else {
width = targetWidth
height = (width * getWidth(extent)) / getHeight(extent)
resolution = getHeight(extent) / width
}
} else {
if (targetHeight > targetWidth) {
width = targetWidth
height = (width * getHeight(extent)) / getWidth(extent)
resolution = getWidth(extent) / width
} else {
height = targetHeight
width = (height * getWidth(extent)) / getHeight(extent)
resolution = getHeight(extent) / height
}
}
const center = getCenter(extent)
const overviewView = new View({
projection: this[_projection],
rotation: this[_rotation],
constrainOnlyCenter: true,
minResolution: resolution,
maxResolution: resolution,
extent: center.concat(center),
showFullExtent: true
})
const map = this[_overviewMap].getOverviewMap()
const overviewElement = this[_overviewMap].element
const overviewmapElement = Object.values(overviewElement.children).find(
c => c.className === 'ol-overviewmap-map'
)
// TODO: color "ol-overviewmap-map-box" using primary color
overviewmapElement.style.width = `${widt