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 PointGeometry from 'ol/geom/Point'
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 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, getHeight, getWidth } from 'ol/extent'
import { defaults as defaultInteractions } from 'ol/interaction'
import dcmjs from 'dcmjs'
import {
AnnotationGroup,
_fetchGraphicData,
_fetchGraphicIndex,
_fetchMeasurements,
_getCentroid,
_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 Enums from './enums'
import _AnnotationManager from './annotations/_AnnotationManager'
import webWorkerManager from './webWorker/webWorkerManager.js'
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 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 }
}
/**
* Build OpenLayers style expression for coloring a WebGL PointLayer.
*
* @param {Object} styleOptions - Style options
* @param {string} styleOptions.name - 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.colormap - RGB color triplets
*
* @returns {Object} color style expression
*
* @private
*/
function _getColorPaletteStyleForPointLayer ({
key,
minValue,
maxValue,
colormap
}) {
const minIndexValue = 0
const maxIndexValue = colormap.length - 1
const indexExpression = [
'clamp',
[
'round',
[
'+',
[
'/',
[
'*',
[
'-',
['get', key],
minValue
],
[
'-',
maxIndexValue,
minIndexValue
]
],
[
'-',
maxValue,
minValue
]
],
minIndexValue
]
],
minIndexValue,
maxIndexValue
]
const expression = [
'palette',
indexExpression,
colormap
]
return { color: expression }
}
const _affine = Symbol('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('interactions')
const _map = Symbol('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')
/**
* 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=[0, 126, 163]] - 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
*/
constructor (options) {
this[_options] = options
this[_clients] = {}
if (this[_options].client) {
this[_clients].default = this[_options].client
} else {
if (this[_options].clientMapping == null) {
throw new Error(
'Either option "client" or option "clientMapping" must be provided.'
)
}
if (!(typeof this[_options].clientMapping === 'object')) {
throw new Error('Option "clientMapping" must be an object.')
}
if (this[_options].clientMapping.default == null) {
throw new Error('Option "clientMapping" must contain "default" key.')
}
for (const key in this[_options].clientMapping) {
this[_clients][key] = this[_options].clientMapping[key]
}
}
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 = [0, 126, 163]
}
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') {
throw new Error('Input metadata must be an array.')
}
if (this[_options].metadata.length === 0) {
throw new Error('Input metadata array is empty.')
}
if (this[_options].metadata.some((item) => typeof item !== 'object')) {
throw new Error('Input metadata must be an array of objects.')
}
// We also accept metadata in raw JSON format for backwards compatibility
if (this[_options].metadata[0].SOPClassUID != null) {
this[_metadata] = this[_options].metadata
} else {
this[_metadata] = this[_options].metadata.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) {
throw new Error('Could not find any channels or color images.')
}
if (numChannels > 0 && numColorImages > 0) {
throw new Error('Found both channels and color images.')
}
if (numColorImages > 1) {
throw new Error('Found more than one color image.')
}
/*
* 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
})
const view = new View({
center: getCenter(this[_pyramid].extent),
projection: this[_projection],
resolutions: this[_tileGrid].getResolutions(),
rotation: this[_rotation],
constrainOnlyCenter: false,
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) {
throw new Error(
`Pyramid of optical path "${opticalPathIdentifier}" ` +
'is different from reference pyramid.'
)
}
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
)
})
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
)
})
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
)
})
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
)
})
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)
}
if (Math.max(...this[_pyramid].gridSizes[0]) <= 10) {
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 = `${width}px`
overviewmapElement.style.height = `${height}px`
map.updateSize()
map.setView(overviewView)
this[_map].removeControl(this[_overviewMap])
this[_map].addControl(this[_overviewMap])
}
} else {
this[_overviewMap] = null
this[_updateOverviewMapSize] = () => {}
}
this[_drawingSource] = new VectorSource({
tileGrid: this[_tileGrid],
projection: this[_projection],
features: this[_features],
wrapX: false
})
this[_drawingLayer] = new VectorLayer({
extent: this[_pyramid].extent,
source: this[_drawingSource],
projection: this[_projection],
updateWhileAnimating: true,
updateWhileInteracting: true
})
layers.push(this[_drawingLayer])
this[_map] = new Map({
layers,
view,
controls: [],
keyboardEventTarget: document,
interactions: defaultInteractions({
altShiftDragRotate: true,
doubleClickZoom: false,
mouseWheelZoom: true,
keyboard: false,
shiftDragZoom: true,
dragPan: true,
pinchRotate: true,
pinchZoom: true
})
})
view.fit(this[_projection].getExtent(), { size: this[_map].getSize() })
/**
* OpenLayer's map has default active interactions.
* We need to reuse them here to avoid duplications.
* Enabling or disabling interactions could cause side effects on
* OverviewMap since it also uses the same interactions in the map
* @private
*/
this[_interactions] = {
draw: undefined,
select: undefined,
translate: undefined,
modify: undefined,
snap: undefined,
dragPan: this[_map].getInteractions().getArray().find((i) => {
return i instanceof DragPan
})
}
this[_controls] = {
scale: new ScaleLine({
units: 'metric',
className: ''
})
}
if (this[_options].controls.has('fullscreen')) {
this[_controls].fullscreen = new FullScreen()
}
if (this[_options].controls.has('zoom')) {
this[_controls].zoom = new Zoom()
this[_controls].zoomslider = new ZoomSlider()
}
if (this[_options].controls.has('overview')) {
if (this[_overviewMap]) {
this[_controls].overview = this[_overviewMap]
}
}
if (this[_options].controls.has('position')) {
this[_controls].position = new MousePosition({
projection: this[_projection],
coordinateFormat: (imageCoordinates) => {
const slideCoordinates = _geometryCoordinates2scoord3dCoordinates(
imageCoordinates,
this[_pyramid].metadata,
this[_affine]
)
/*
* This assumes that the image is aligned with the X and Y axes
* of the slide (frame of reference).
* If one would ever change the orientation (rotation), this may
* need to be changed accordingly. The values would not become wrong,
* but the X and Y axes of the slide would no longer align with the
* vertical and horizontal axes of the viewport, respectively.
*/
const x = slideCoordinates[0].toFixed(5)
const y = slideCoordinates[1].toFixed(5)
return `(${x}, ${y})`
}
})
}
for (const name in this[_controls]) {
console.info(`add control "${name}"`)
this[_map].addControl(this[_controls][name])
}
this[_annotationManager] = new _AnnotationManager({
map: this[_map],
pyramid: this[_pyramid].metadata,
affine: this[_affine],
drawingSource: this[_drawingSource]
})
this[_overlays] = {}
}
/**
* Set the style of an optical path.
*
* The style determine how grayscale stored values of a MONOCHROME2 image
* will be transformed into pseudo-color display values.
* Grayscale stored values are first transformed into normalized grayscale
* display values, which are subsequently transformed into pseudo-color
* values in RGB color space.
*
* The input to the first transformation are grayscale stored values in the
* range defined by parameter "limitValues", which specify a window for
* optimizing display value intensity and contrast. The resulting normalized
* grayscale display values are then used as input to the second
* transformation, which maps them to pseudo-color values ranging from black
* color (R=0, G=0, B=0) to the color defined by parameter "color" using
* linear interpolation. Alternatively, a palette color lookup table can be
* provided to perform more sophisticated pseudo-coloring.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
* @param {Object} styleOptions
* @param {number[]} [styleOptions.color] - RGB color triplet
* @param {number[]} [styleOptions.paletteColorLookupTable] - palette color
* lookup table
* @param {number} [styleOptions.opacity] - Opacity
* @param {number[]} [styleOptions.limitValues] - Upper and lower windowing
* limits
*/
setOpticalPathStyle (opticalPathIdentifier, styleOptions = {}) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath === undefined) {
throw new Error(
'Cannot set optical path style. Could not find optical path ' +
`"${opticalPathIdentifier}".`
)
}
if (Object.entries(styleOptions).length === 0) {
return
}
console.info(
`set style for optical path "${opticalPathIdentifier}"`,
styleOptions
)
if (styleOptions.opacity != null) {
opticalPath.style.opacity = styleOptions.opacity
opticalPath.layer.setOpacity(styleOptions.opacity)
opticalPath.overviewLayer.setOpacity(styleOptions.opacity)
}
if (opticalPath.opticalPath.isMonochromatic) {
if (styleOptions.limitValues != null) {
opticalPath.style.limitValues = [
Math.max(styleOptions.limitValues[0], opticalPath.minStoredValue),
Math.min(styleOptions.limitValues[1], opticalPath.maxStoredValue)
]
}
const [windowCenter, windowWidth] = createWindow(
opticalPath.style.limitValues[0],
opticalPath.style.limitValues[1]
)
if (styleOptions.paletteColorLookupTable != null) {
opticalPath.style.paletteColorLookupTable = styleOptions.paletteColorLookupTable
const style = _getColorPaletteStyleForTileLayer({
windowCenter,
windowWidth,
colormap: styleOptions.paletteColorLookupTable.data
})
opticalPath.layer.setStyle(style)
opticalPath.overviewLayer.setStyle(style)
} else if (styleOptions.color != null) {
opticalPath.style.color = styleOptions.color
if (opticalPath.style.paletteColorLookupTable) {
const style = _getColorInterpolationStyleForTileLayer({
windowCenter,
windowWidth,
color: opticalPath.style.color
})
opticalPath.style.paletteColorLookupTable = undefined
opticalPath.layer.setStyle(style)
opticalPath.overviewLayer.setStyle(style)
} else {
const styleVariables = {
windowCenter,
windowWidth,
red: opticalPath.style.color[0],
green: opticalPath.style.color[1],
blue: opticalPath.style.color[2]
}
opticalPath.layer.updateStyleVariables(styleVariables)
opticalPath.overviewLayer.updateStyleVariables(styleVariables)
}
} else {
const styleVariables = { windowCenter, windowWidth }
opticalPath.layer.updateStyleVariables(styleVariables)
opticalPath.overviewLayer.updateStyleVariables(styleVariables)
}
}
}
/**
* Determine whether an optical path is colorable.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
* @return {boolean} yes/no answer
*/
isOpticalPathColorable (opticalPathIdentifier) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath == null) {
return false
}
return opticalPath.opticalPath.isColorable
}
/**
* Determine whether an optical path is monochromatic.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
* @return {boolean} yes/no answer
*/
isOpticalPathMonochromatic (opticalPathIdentifier) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath == null) {
return false
}
return opticalPath.opticalisMonochromatic
}
/**
* Get the default style of an optical path.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
* @return {Object} Default style of optical path
*/
getOpticalPathDefaultStyle (opticalPathIdentifier) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath == null) {
throw new Error(
'Cannot get default style of optical path. ' +
`Could not find optical path "${opticalPathIdentifier}".`
)
}
if (opticalPath.opticalPath.isMonochromatic) {
if (opticalPath.defaultStyle.paletteColorLookupTable) {
return {
paletteColorLookupTable: opticalPath.defaultStyle.paletteColorLookupTable,
opacity: opticalPath.defaultStyle.opacity,
limitValues: opticalPath.defaultStyle.limitValues
}
}
return {
color: opticalPath.defaultStyle.color,
opacity: opticalPath.defaultStyle.opacity,
limitValues: opticalPath.defaultStyle.limitValues
}
}
return { opacity: opticalPath.defaultStyle.opacity }
}
/**
* Get the style of an optical path.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
* @return {Object} Style of optical path
*/
getOpticalPathStyle (opticalPathIdentifier) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath == null) {
throw new Error(
'Cannot get style of optical path. ' +
`Could not find optical path "${opticalPathIdentifier}".`
)
}
if (opticalPath.opticalPath.isMonochromatic) {
if (opticalPath.style.paletteColorLookupTable) {
return {
paletteColorLookupTable: opticalPath.style.paletteColorLookupTable,
opacity: opticalPath.style.opacity,
limitValues: opticalPath.style.limitValues
}
}
return {
color: opticalPath.style.color,
opacity: opticalPath.style.opacity,
limitValues: opticalPath.style.limitValues
}
}
return { opacity: opticalPath.style.opacity }
}
/**
* Get image metadata for an optical path.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
* @returns {metadata.VLWholeSlideMicroscopyImage[]} Slide microscopy image
* metadata
*/
getOpticalPathMetadata (opticalPathIdentifier) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath === undefined) {
throw new Error(
'Cannot get image metadata optical path. ' +
`Could not find optical path "${opticalPathIdentifier}".`
)
}
return opticalPath.pyramid.metadata
}
/**
* Get all optical paths.
*
* @return {opticalPath.OpticalPath[]}
*/
getAllOpticalPaths () {
const opticalPaths = []
for (const opticalPathIdentifier in this[_opticalPaths]) {
opticalPaths.push(this[_opticalPaths][opticalPathIdentifier].opticalPath)
}
return opticalPaths.sort(item => (item.OpticalPathIdentifier))
}
/**
* Activate an optical path.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
*/
activateOpticalPath (opticalPathIdentifier) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath === undefined) {
throw new Error(
'Cannot activate optical path. Could not find optical path ' +
`"${opticalPathIdentifier}".`
)
}
if (!this.isOpticalPathActive(opticalPathIdentifier)) {
/*
* Add layer to the bottom of the layer stack to ensure that vector
* graphics are overlayed ontop of the raster graphics.
*/
this[_map].getLayers().insertAt(
0,
opticalPath.layer
)
if (this[_overviewMap]) {
this[_overviewMap].getOverviewMap().getLayers().insertAt(
0,
opticalPath.overviewLayer
)
}
}
}
/**
* Deactivate an optical path.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
*/
deactivateOpticalPath (opticalPathIdentifier) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath === undefined) {
throw new Error(
'Cannot deactivate optical path. Could not find optical path ' +
`"${opticalPathIdentifier}".`
)
}
if (!this.isOpticalPathActive(opticalPathIdentifier)) {
return
}
this[_map].removeLayer(opticalPath.layer)
if (this[_overviewMap]) {
this[_overviewMap].getOverviewMap().removeLayer(opticalPath.overviewLayer)
}
}
/**
* Determine whether an optical path is active.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
* @return {boolean} active
*/
isOpticalPathActive (opticalPathIdentifier) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath == null) {
return false
}
const layers = this[_map].getLayers()
const match = layers.getArray().find(layer => {
return layer.ol_uid === opticalPath.layer.ol_uid
})
if (this[_overviewMap] != null) {
const overviewLayers = this[_overviewMap].getOverviewMap().getLayers()
const overviewMatch = overviewLayers.getArray().find(layer => {
return layer.ol_uid === opticalPath.overviewLayer.ol_uid
})
return match != null || overviewMatch != null
} else {
return match != null
}
}
/**
* Show an optical path.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
* @param {Object} [styleOptions]
* @param {number[]} [styleOptions.color] - RGB color triplet
* @param {number[]} [styleOptions.paletteColorLookupTable] - palette color
* lookup table
* @param {number} [styleOptions.opacity] - Opacity
* @param {number[]} [styleOptions.limitValues] - Upper and lower windowing
* limits
*/
showOpticalPath (opticalPathIdentifier, styleOptions = {}) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath === undefined) {
throw new Error(
'Cannot show optical path. Could not find optical path ' +
`"${opticalPathIdentifier}".`
)
}
console.info(`show optical path ${opticalPathIdentifier}`)
this.activateOpticalPath(opticalPathIdentifier)
const container = this[_map].getTargetElement()
if (container && !opticalPath.hasLoader) {
const metadata = opticalPath.pyramid.metadata
const client = _getClient(
this[_clients],
Enums.SOPClassUIDs.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE
)
_getIccProfiles(metadata, client).then(profiles => {
const loader = _createTileLoadFunction({
targetElement: container,
iccProfiles: profiles,
...opticalPath.loaderParams
})
const source = opticalPath.layer.getSource()
source.setLoader(loader)
})
}
opticalPath.layer.setVisible(true)
opticalPath.overviewLayer.setVisible(true)
this.setOpticalPathStyle(opticalPathIdentifier, styleOptions)
}
/**
* Hide an optical path.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
*/
hideOpticalPath (opticalPathIdentifier) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath === undefined) {
throw new Error(
'Cannot hide optical path. Could not find optical path ' +
`"${opticalPathIdentifier}".`
)
}
console.info(`hide optical path ${opticalPathIdentifier}`)
opticalPath.layer.setVisible(false)
opticalPath.overviewLayer.setVisible(false)
}
/**
* Determine if an optical path is visible.
*
* @param {string} opticalPathIdentifier - Optical Path Identifier
* @returns {boolean}
*/
isOpticalPathVisible (opticalPathIdentifier) {
const opticalPath = this[_opticalPaths][opticalPathIdentifier]
if (opticalPath === undefined) {
throw new Error(
'Cannot show optical path. Could not find optical path ' +
`"${opticalPathIdentifier}".`
)
}
return opticalPath.layer.getVisible()
}
/**
* Resize the viewer to fit the viewport.
*
* @returns {void}
*/
resize () {
this[_map].updateSize()
this[_updateOverviewMapSize]()
}
/**
* Size of the viewport.
*
* @type {number[]}
*/
get size () {
return this[_map].getSize()
}
/**
* Clean up.
*
* Release allocated memory and clear the viewport.
*/
cleanup () {
console.info('cleanup')
const itemsRequiringDisposal = [
...Object.values(this[_opticalPaths]),
...Object.values(this[_segments]),
...Object.values(this[_mappings]),
...Object.values(this[_annotationGroups])
]
itemsRequiringDisposal.forEach(item => {
item.layer.dispose()
this[_map].removeLayer(item.layer)
if (item.overlay) {
item.overlay.dispose()
this[_map].removeOverlay(item.overlay)
}
this[_features].clear()
})
webWorkerManager.terminateAllWebWorkers()
}
/**
* Render the images in the specified viewport container.
*
* @param {Object} options - Rendering options.
* @param {(string|HTMLElement)} options.container - HTML Element in which the viewer should be injected.
*/
render ({ container }) {
if (container == null) {
console.error('container must be provided for rendering images')
return
}
const itemsRequiringDecodersAndTransformers = [
...Object.values(this[_opticalPaths]),
...Object.values(this[_segments]),
...Object.values(this[_mappings])
]
const styleControlElement = (element) => {
element.style.backgroundColor = 'rgba(255,255,255,.75)'
element.style.color = 'black'
element.style.padding = '2px'
element.style.margin = '1px'
}
itemsRequiringDecodersAndTransformers.forEach(item => {
const metadata = item.pyramid.metadata
const client = _getClient(
this[_clients],
Enums.SOPClassUIDs.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE
)
_getIccProfiles(metadata, client).then(profiles => {
const source = item.layer.getSource()
const loader = _createTileLoadFunction({
targetElement: container,
iccProfiles: profiles,
...item.loaderParams
})
source.setLoader(loader)
item.hasLoader = true
this[_map].setTarget(container)
const view = this[_map].getView()
const projection = view.getProjection()
view.fit(projection.getExtent(), { size: this[_map].getSize() })
this[_updateOverviewMapSize]()
this[_drawingSource].on(VectorEventType.ADDFEATURE, (e) => {
publish(
container,
EVENT.ROI_ADDED,
this._getROIFromFeature(e.feature, metadata, this[_affine])
)
})
this[_drawingSource].on(VectorEventType.CHANGEFEATURE, (e) => {
if (e.feature !== undefined || e.feature !== null) {
const geometry = e.feature.getGeometry()
const type = geometry.getType()
/*
* The first and last point of a polygon must be identical. The
* last point is an implementation detail and is hidden from the
* user in the graphical interface. However, we must update the
* last point in case the first point has been modified by the
* user.
*/
if (type === 'Polygon') {
/*
* Polygon in GeoJSON format contains an array of geometries,
* where the first element represents the coordinates of the
* outer ring and the second element represents the coordinates
* of the inner ring (in our case the inner ring should not be
* present).
*/
const layout = geometry.getLayout()
const coordinates = geometry.getCoordinates()
const firstPoint = coordinates[0][0]
const lastPoint = coordinates[0][coordinates[0].length - 1]
if (
firstPoint[0] !== lastPoint[0] ||
firstPoint[1] !== lastPoint[1]
) {
coordinates[0][coordinates[0].length - 1] = firstPoint
geometry.setCoordinates(coordinates, layout)
e.feature.setGeometry(geometry)
}
}
}
publish(
container,
EVENT.ROI_MODIFIED,
this._getROIFromFeature(e.feature, metadata, this[_affine])
)
})
this[_drawingSource].on(VectorEventType.REMOVEFEATURE, (e) => {
publish(
container,
EVENT.ROI_REMOVED,
this._getROIFromFeature(e.feature, metadata, this[_affine])
)
})
if (this[_controls].overview && this[_overviewMap]) {
// Style overview element (overriding Openlayers CSS "ol-overviewmap")
const overviewElement = this[_controls].overview.element
const overviewChildren = overviewElement.children
const overviewmapElement = Object.values(overviewChildren).find(
c => c.className === 'ol-overviewmap-map'
)
const buttonElement = Object.values(overviewChildren).find(
c => c.type === 'button'
)
if (buttonElement) {
buttonElement.title = 'Overview'
buttonElement.style.border = '0.25px solid black'
buttonElement.style.backgroundColor = 'white'
buttonElement.style.cursor = 'pointer'
const spanElement = buttonElement.children[0]
spanElement.style.color = 'black'
spanElement.style.backgroundColor = 'white'
}
styleControlElement(overviewmapElement)
overviewmapElement.style.border = '1px solid black'
overviewmapElement.style.color = 'black'
this[_updateOverviewMapSize]()
}
})
})
// Style scale element (overriding Openlayers CSS "ol-scale-line")
const scaleElement = this[_controls].scale.element
scaleElement.style.position = 'absolute'
scaleElement.style.right = '.5em'
scaleElement.style.bottom = '.5em'
scaleElement.style.left = 'auto'
scaleElement.style.borderRadius = '4px'
styleControlElement(scaleElement)
const scaleInnerElement = this[_controls].scale.innerElement_
scaleInnerElement.style.color = 'black'
scaleInnerElement.style.fontWeight = '600'
scaleInnerElement.style.fontSize = '10px'
scaleInnerElement.style.textAlign = 'center'
scaleInnerElement.style.borderWidth = '1.5px'
scaleInnerElement.style.borderStyle = 'solid'
scaleInnerElement.style.borderTop = 'none'
scaleInnerElement.style.borderRightColor = 'black'
scaleInnerElement.style.borderLeftColor = 'black'
scaleInnerElement.style.borderBottomColor = 'black'
scaleInnerElement.style.margin = '1px'
scaleInnerElement.style.willChange = 'contents,width'
// Style position element (overriding Openlayers CSS "ol-mouse-position")
if (this[_controls].position != null) {
const positionElement = this[_controls].position.element
positionElement.style.position = 'absolute'
positionElement.style.right = '.5em'
positionElement.style.top = '.5em'
positionElement.style.left = 'auto'
positionElement.style.bottom = 'auto'
positionElement.style.fontWeight = '600'
positionElement.style.fontSize = '10px'
positionElement.style.textAlign = 'center'
positionElement.style.borderRadius = '4px'
styleControlElement(positionElement)
}
}
/**
* Number of zoom levels.
*
* @type number
*/
get numLevels () {
return this[_pyramid].pixelSpacings.length
}
/**
* Frame of Reference UID.
*
* @type string
*/
get frameOfReferenceUID () {
return this[_pyramid].metadata[0].FrameOfReferenceUID
}
/**
* Get the pixel spacing at a given zoom level.
*
* @param {Object} options - Options.
* @param {number} options.level - Zoom level.
* @returns {number[]} Spacing between the centers of two neighboring pixels
*/
getPixelSpacing (level) {
return this[_pyramid].pixelSpacings[level].slice(0)
}
/**
* Physical offset of images.
* Offset along the X and Y axes of the slide coordinate system in
* millimeter unit.
*
* @type number[]
*/
get physicalOffset () {
const point = applyTransform({
coordinate: [0, 0],
affine: this[_affine]
})
return [point[0], point[1], 0]
}
/**
* Physical size of images.
* Length along the X and Y axes of the slide coordinate system in
* millimeter unit.
*
* @type number[]
*/
get physicalSize () {
const offset = this.physicalOffset
const metadata = this[_pyramid].metadata[this[_pyramid].metadata.length - 1]
const point = applyTransform({
coordinate: [
metadata.TotalPixelMatrixColumns,
metadata.TotalPixelMatrixRows
],
affine: this[_affine]
})
return [
Math.abs(point[0] - offset[0]),
Math.abs(point[1] - offset[1])
]
}
/**
* Bounding box that contains the images.
*
* @type number[][]
*/
get boundingBox () {
const startPoint = this.physicalOffset
const metadata = this[_pyramid].metadata[this[_pyramid].metadata.length - 1]
const endPoint = applyTransform({
coordinate: [
metadata.TotalPixelMatrixColumns,
metadata.TotalPixelMatrixRows
],
affine: this[_affine]
})
const offset = [
Math.min(startPoint[0], endPoint[0]),
Math.min(startPoint[1], endPoint[1])
]
const size = this.physicalSize
return [offset, size]
}
/**
* Navigate the view to a spatial position or resolution level.
*
* @param {Object} options - Options.
* @param {number} options.level - Zoom level.
* @param {number[]} options.position - X, Y coordinates in slide coordinate system.
*/
navigate ({ level, position }) {
if (level > this.numLevels) {
throw new Error('Argument "level" exceeds number of resolution levels.')
}
let coordinates
if (position != null) {
coordinates = _scoord3dCoordinates2geometryCoordinates(
position,
this[_pyramid],
this[_affineInverse]
)
}
const view = this[_map].getView()
view.animate({ zoom: level, center: coordinates })
}
/**
* Activate the draw interaction for graphic annotation of regions of interest.
*
* @param {Object} options - Drawing options
* @param {string} options.geometryType - Name of the geometry type (point, circle, box, polygon, freehandpolygon, line, freehandline)
* @param {number} [options.maxPoints] - Maximum number of points for "line"
* geometry
* @param {number} [options.minPoints] - Mininum number of points for "line"
* geometry
* @param {Object} [options.styleOptions] - Style options
* @param {Object} [styleOptions.stroke] - Style options for the contour of
* the geometry
* @param {number[]} styleOptions.stroke.color - RGBA color of the contour
* @param {number} styleOptions.stroke.width - Width of the contour
* @param {Object} [styleOptions.fill] - Style options for the body of the
* geometry
* @param {number[]} styleOptions.fill.color - RGBA color of the body
*/
activateDrawInteraction (options) {
this.deactivateDrawInteraction()
console.info('activate "draw" interaction')
const geometryOptionsMapping = {
point: {
type: 'Point',
geometryName: 'Point'
},
circle: {
type: 'Circle',
geometryName: 'Circle'
},
box: {
type: 'Circle',
geometryName: 'Box',
geometryFunction: createBox()
},
polygon: {
type: 'Polygon',
geometryName: 'Polygon',
freehand: false
},
freehandpolygon: {
type: 'Polygon',
geometryName: 'FreeHandPolygon',
freehand: true
},
line: {
type: 'LineString',
geometryName: 'Line',
freehand: false,
maxPoints: options.maxPoints,
minPoints: options.minPoints
},
freehandline: {
type: 'LineString',
geometryName: 'FreeHandLine',
freehand: true
}
}
if (!('geometryType' in options)) {
console.error('geometry type must be specified for drawing interaction')
}
if (!(options.geometryType in geometryOptionsMapping)) {
console.error(`unsupported geometry type "${options.geometryType}"`)
}
const internalDrawOptions = { source: this[_drawingSource] }
const geometryDrawOptions = geometryOptionsMapping[options.geometryType]
const builtInDrawOptions = {
[Enums.InternalProperties.Marker]:
options[Enums.InternalProperties.Marker],
[Enums.InternalProperties.Markup]:
options[Enums.InternalProperties.Markup],
[Enums.InternalProperties.Label]: options[Enums.InternalProperties.Label]
}
const drawOptions = Object.assign(
internalDrawOptions,
geometryDrawOptions,
builtInDrawOptions
)
/**
* This used to define which mouse buttons will fire the action.
*
* bindings: {
* mouseButtons can be 'left', 'right' and/or 'middle'. if absent, the action is bound to all mouse buttons.
* mouseButtons: ['left', 'right'],
* modifierKey can be 'shift', 'ctrl' or 'alt'. If not present, the action is bound to no modifier key.
* modifierKey: 'ctrl' // The modifier
* },
*/
if (options.bindings) {
drawOptions.condition = _getInteractionBindingCondition(options.bindings)
}
this[_interactions].draw = new Draw(drawOptions)
const container = this[_map].getTargetElement()
this[_interactions].draw.on(Enums.InteractionEvents.DRAW_START, (event) => {
event.feature.setProperties(builtInDrawOptions, true)
event.feature.setId(_generateUID())
/** Set external styles before calling internal annotation hooks */
_setFeatureStyle(
event.feature,
options[Enums.InternalProperties.StyleOptions]
)
this[_annotationManager].onDrawStart(event)
_wireMeasurementsAndQualitativeEvaluationsEvents(
this[_map],
event.feature,
this[_pyramid].metadata,
this[_affine]
)
})
this[_interactions].draw.on(Enums.InteractionEvents.DRAW_ABORT, (event) => {
this[_annotationManager].onDrawAbort(event)
})
this[_interactions].draw.on(Enums.InteractionEvents.DRAW_END, (event) => {
this[_annotationManager].onDrawEnd(event)
publish(
container,
EVENT.ROI_DRAWN,
this._getROIFromFeature(
event.feature,
this[_pyramid].metadata,
this[_affine]
)
)
})
this[_map].addInteraction(this[_interactions].draw)
}
/**
* Deactivate draw interaction.
*/
deactivateDrawInteraction () {
console.info('deactivate "draw" interaction')
if (this[_interactions].draw !== undefined) {
this[_map].removeInteraction(this[_interactions].draw)
this[_interactions].draw = undefined
}
}
/**
* Whether draw interaction is active
*
* @type boolean
*/
get isDrawInteractionActive () {
return this[_interactions].draw !== undefined
}
/**
* Whether drag pan interaction is active.
*
* @type boolean
*/
get isDragPanInteractionActive () {
return this[_interactions].dragPan !== undefined
}
/**
* Whether drag zoom interaction is active.
*
* @type boolean
*/
get isDragZoomInteractionActive () {
return this[_interactions].dragZoom !== undefined
}
/**
* Whether translate interaction is active.
*
* @type boolean
*/
get isTranslateInteractionActive () {
return this[_interactions].translate !== undefined
}
/**
* Activate translate interaction.
*
* @param {Object} options - Translation options.
*/
activateTranslateInteraction (options = {}) {
this.deactivateTranslateInteraction()
console.info('activate "translate" interaction')
const translateOptions = { layers: [this[_drawingLayer]] }
/**
* Get conditional mouse bindings
* See "options.binding" comment in activateDrawInteraction() definition.
*/
if (options.bindings) {
translateOptions.condition = _getInteractionBindingCondition(
options.bindings
)
}
this[_interactions].translate = new Translate(translateOptions)
this[_map].addInteraction(this[_interactions].translate)
}
/**
* Extract and transform the region of interest (ROI).
*
* @param {Object} feature - Openlayers Feature
* @param {Object[]} pyramid - Metadata for resolution levels of image pyramid
* @param {number[][]} affine - 3x3 affine transformation matrix
* @param {Object} context - Context
* @returns {roi.ROI} Region of interest
* @private
*/
_getROIFromFeature (feature, pyramid, affine) {
let scoord3d
try {
scoord3d = _geometry2Scoord3d(feature, pyramid, affine)
} catch (error) {
const uid = feature.getId()
this.removeROI(uid)
throw error
}
const featureProperties = feature.getProperties()
const properties = {
measurements: featureProperties.measurements,
evaluations: featureProperties.evaluations
}
const uid = feature.getId()
return new ROI({ scoord3d, properties, uid })
}
/**
* Toggle overview map.
*
* @returns {void}
*/
toggleOverviewMap () {
if (this[_overviewMap]) {
const controls = this[_map].getControls()
const overview = controls.getArray().find((c) => c === this[_overviewMap])
if (overview) {
this[_map].removeControl(this[_overviewMap])
delete this[_controls].overview
} else {
this[_controls].overview = this[_overviewMap]
this[_map].addControl(this[_overviewMap])
const map = this[_overviewMap].getOverviewMap()
const view = map.getView()
const projection = view.getProjection()
view.fit(projection.getExtent(), { size: map.getSize() })
}
}
}
/**
* Deactivate translate interaction.
*
* @returns {void}
*/
deactivateTranslateInteraction () {
console.info('deactivate "translate" interaction')
if (this[_interactions].translate) {
this[_map].removeInteraction(this[_interactions].translate)
this[_interactions].translate = undefined
}
}
/**
* Activate drag zoom interaction.
*
* @param {Object} options - Options.
*/
activateDragZoomInteraction (options = {}) {
this.deactivateDragZoomInteraction()
console.info('activate "dragZoom" interaction')
const dragZoomOptions = { layers: [this[_drawingLayer]] }
/**
* Get conditional mouse bindings
* See "options.binding" comment in activateDrawInteraction() definition.
*/
if (options.bindings) {
dragZoomOptions.condition = _getInteractionBindingCondition(
options.bindings
)
}
this[_interactions].dragZoom = new DragZoom(dragZoomOptions)
this[_map].addInteraction(this[_interactions].dragZoom)
}
/**
* Deactivate drag zoom interaction.
*/
deactivateDragZoomInteraction () {
console.info('deactivate "dragZoom" interaction')
if (this[_interactions].dragZoom) {
this[_map].removeInteraction(this[_interactions].dragZoom)
this[_interactions].dragZoom = undefined
}
}
/**
* Activate select interaction.
*
* @param {Object} options selection options.
*/
activateSelectInteraction (options = {}) {
this.deactivateSelectInteraction()
console.info('activate "select" interaction')
const selectOptions = { layers: [this[_drawingLayer]] }
/**
* Get conditional mouse bindings
* See "options.binding" comment in activateDrawInteraction() definition.
*/
if (options.bindings) {
selectOptions.condition = _getInteractionBindingCondition(
options.bindings
)
}
this[_interactions].select = new Select(selectOptions)
const container = this[_map].getTargetElement()
this[_interactions].select.on('select', (e) => {
publish(
container,
EVENT.ROI_SELECTED,
this._getROIFromFeature(
e.selected[0],
this[_pyramid].metadata,
this[_affine]
)
)
})
this[_map].addInteraction(this[_interactions].select)
}
/**
* Deactivate select interaction.
*/
deactivateSelectInteraction () {
console.info('deactivate "select" interaction')
if (this[_interactions].select) {
this[_map].removeInteraction(this[_interactions].select)
this[_interactions].select = undefined
}
}
/**
* Activate drag pan interaction.
*
* @param {Object} options - Options.
*/
activateDragPanInteraction (options = {}) {
this.deactivateDragPanInteraction()
console.info('activate "drag pan" interaction')
const dragPanOptions = {
features: this[_features]
}
/**
* Get conditional mouse bindings
* See "options.binding" comment in activateDrawInteraction() definition.
*/
if (options.bindings) {
dragPanOptions.condition = _getInteractionBindingCondition(
options.bindings
)
}
this[_interactions].dragPan = new DragPan(dragPanOptions)
this[_map].addInteraction(this[_interactions].dragPan)
}
/**
* Deactivate drag pan interaction.
*/
deactivateDragPanInteraction () {
console.info('deactivate "drag pan" interaction')
if (this[_interactions].dragPan) {
this[_map].removeInteraction(this[_interactions].dragPan)
this[_interactions].dragPan = undefined
}
}
/**
* Activate snap interaction.
*
* @param {Object} options - Snap options.
*/
activateSnapInteraction (options = {}) {
this.deactivateSnapInteraction()
console.info('activate "snap" interaction')
this[_interactions].snap = new Snap({
source: this[_drawingSource]
})
this[_map].addInteraction(this[_interactions].snap)
}
/**
* Deactivate snap interaction.
*/
deactivateSnapInteraction () {
console.info('deactivate "snap" interaction')
if (this[_interactions].snap) {
this[_map].removeInteraction(this[_interactions].snap)
this[_interactions].snap = undefined
}
}
/**
* Whether select interaction is active.
*
* @type boolean
*/
get isSelectInteractionActive () {
return this[_interactions].select !== undefined
}
/**
* Activate modify interaction.
*
* @param {Object} options - Modification options.
*/
activateModifyInteraction (options = {}) {
this.deactivateModifyInteraction()
console.info('activate "modify" interaction')
const modifyOptions = {
features: this[_features],
insertVertexCondition: (event) => true
}
/**
* Get conditional mouse bindings
* See "options.binding" comment in activateDrawInteraction() definition.
*/
if (options.bindings) {
modifyOptions.condition = _getInteractionBindingCondition(
options.bindings
)
}
this[_interactions].modify = new Modify(modifyOptions)
const container = this[_map].getTargetElement()
this[_interactions].modify.on(Enums.InteractionEvents.MODIFY_END, (event) => {
const feature = event.features.item(0)
_updateFeatureMeasurements(
this[_map], feature,
this[_pyramid].metadata,
this[_affine]
)
this[_annotationManager].onUpdate(feature)
publish(
container,
EVENT.ROI_MODIFIED,
this._getROIFromFeature(
feature,
this[_pyramid].metadata,
this[_affine]
)
)
})
this[_map].addInteraction(this[_interactions].modify)
}
/**
* Deactivate modify interaction.
*/
deactivateModifyInteraction () {
console.info('deactivate "modify" interaction')
if (this[_interactions].modify) {
this[_map].removeInteraction(this[_interactions].modify)
this[_interactions].modify = undefined
}
}
/**
* Whether modify interaction is active.
*
* @type boolean
*/
get isModifyInteractionActive () {
return this[_interactions].modify !== undefined
}
/**
* Get all annotated regions of interest.
*
* @returns {roi.ROI[]} Array of regions of interest.
*/
getAllROIs () {
console.info('get all ROIs')
const rois = []
this[_features].forEach((item) => {
rois.push(this.getROI(item.getId()))
})
return rois
}
collapseOverviewMap () {
if (this[_overviewMap]) {
this[_overviewMap].setCollapsed(true)
}
}
expandOverviewMap () {
if (this[_overviewMap]) {
this[_overviewMap].setCollapsed(true)
}
}
/**
* Number of annotated regions of interest.
*
* @return {number}
*/
get numberOfROIs () {
return this[_features].getLength()
}
/**
* Get an individual annotated region of interest.
*
* @param {string} uid - Unique identifier of the region of interest
* @returns {roi.ROI} Region of interest.
*/
getROI (uid) {
console.debug(`get ROI ${uid}`)
const feature = this[_drawingSource].getFeatureById(uid)
if (feature == null) {
throw new Error(`Could not find a ROI with UID "${uid}".`)
}
return this._getROIFromFeature(
feature,
this[_pyramid].metadata,
this[_affine]
)
}
/**
* Add a measurement to a region of interest.
*
* @param {string} uid - Unique identifier of the region of interest
* @param {Object} item - NUM content item representing a measurement
*/
addROIMeasurement (uid, item) {
const meaning = item.ConceptNameCodeSequence[0].CodeMeaning
console.info(`add measurement "${meaning}" to ROI ${uid}`)
this[_features].forEach((feature) => {
const id = feature.getId()
if (id === uid) {
const properties = feature.getProperties()
if (!(Enums.InternalProperties.Measurements in properties)) {
properties[Enums.InternalProperties.Measurements] = [item]
} else {
properties[Enums.InternalProperties.Measurements].push(item)
}
feature.setProperties(properties, true)
}
})
}
/**
* Add a qualitative evaluation to a region of interest.
*
* @param {string} uid - Unique identifier of the region of interest
* @param {Object} item - CODE content item representing a qualitative evaluation
*/
addROIEvaluation (uid, item) {
const meaning = item.ConceptNameCodeSequence[0].CodeMeaning
console.info(`add qualitative evaluation "${meaning}" to ROI ${uid}`)
this[_features].forEach((feature) => {
const id = feature.getId()
if (id === uid) {
const properties = feature.getProperties()
if (!(Enums.InternalProperties.Evaluations in properties)) {
properties[Enums.InternalProperties.Evaluations] = [item]
} else {
properties[Enums.InternalProperties.Evaluations].push(item)
}
feature.setProperties(properties, true)
}
})
}
/**
* Pop the most recently annotated regions of interest.
*
* @returns {roi.ROI} Regions of interest.
*/
popROI () {
console.info('pop ROI')
const feature = this[_features].pop()
return this._getROIFromFeature(
feature,
this[_pyramid].metadata,
this[_affine]
)
}
/**
* Add a regions of interest.
*
* @param {roi.ROI} roi - Regions of interest
* @param {Object} [styleOptions] - Style options
* @param {Object} [styleOptions.stroke] - Style options for the contour of
* the geometry
* @param {number[]} styleOptions.stroke.color - RGBA color of the contour
* @param {number} styleOptions.stroke.width - Width of the contour
* @param {Object} [styleOptions.fill] - Style options for the body of the
* geometry
* @param {number[]} styleOptions.fill.color - RGBA color of the body
*/
addROI (roi, styleOptions = {}) {
console.info(`add ROI "${roi.uid}"`)
// Avoid insertion of duplicates
let exists = false
for (let i = 0; i < this[_features].getLength(); i++) {
const feature = this[_features].item(i)
if (feature.getId() === roi.uid) {
exists = true
break
}
}
if (exists) {
console.warn(`ROI "${roi.uid}" not added because it already exists`)
}
const frameOfReferenceUID = this[_pyramid].metadata.FrameOfReferenceUID
if (roi.frameOfReferenceUID !== frameOfReferenceUID) {
throw new Error(
`Frame of Reference UID of ROI ${roi.uid} does not match ` +
'Frame of Reference UID of source images.'
)
}
const geometry = _scoord3d2Geometry(
roi.scoord3d,
this[_pyramid].metadata,
this[_affineInverse]
)
const featureOptions = { geometry }
const feature = new Feature(featureOptions)
_addROIPropertiesToFeature(feature, roi.properties, true)
feature.setId(roi.uid)
_wireMeasurementsAndQualitativeEvaluationsEvents(
this[_map],
feature,
this[_pyramid].metadata,
this[_affine]
)
this[_features].push(feature)
_setFeatureStyle(feature, styleOptions)
const isVisible = Object.keys(styleOptions).length !== 0
this[_annotationManager].setMarkupVisibility(roi.uid, isVisible)
}
/**
* Update properties of a region of interest.
*
* @param {Object} roi - ROI to be updated
* @param {string} roi.uid - Unique identifier of the region of interest
* @param {Object} roi.properties - ROI properties
* @param {Object} [roi.properties.measurements] - ROI measurements
* @param {Object} [roi.properties.evaluations] - ROI evaluations
* @param {Object} [roi.properties.label] - ROI label
* @param {Object} [roi.properties.marker] - ROI marker
*/
updateROI ({ uid, properties = {} }) {
if (!uid) return
console.info(`update ROI ${uid}`)
const feature = this[_drawingSource].getFeatureById(uid)
_addROIPropertiesToFeature(feature, properties)
this[_annotationManager].onUpdate(feature)
}
/**
* Get the style of a region of interest.
*
* @param {string} uid - Unique identifier of the regions of interest
*
* @returns {Object} - Style settings
*/
getROIStyle (uid) {
const feature = this[_features].getArray().find((feature) => {
return feature.getId() === uid
})
if (feature == null) {
throw new Error(`Could not find a ROI with UID "${uid}".`)
}
const style = feature.getStyle()
const stroke = style.getStroke()
const fill = style.getFill()
return {
stroke: {
color: stroke.getColor(),
width: stroke.getWidth()
},
fill: {
color: fill.getColor()
}
}
}
/**
* Set the style of a region of interest.
*
* @param {string} uid - Unique identifier of the regions of interest
* @param {Object} styleOptions - Style options
* @param {Object} [styleOptions.stroke] - Style options for the contour of
* the geometry
* @param {number[]} styleOptions.stroke.color - RGBA color of the contour
* @param {number} styleOptions.stroke.width - Width of the contour
* @param {Object} [styleOptions.fill] - Style options for the body of the
* geometry
* @param {number[]} styleOptions.fill.color - RGBA color of the body
*/
setROIStyle (uid, styleOptions = {}) {
this[_features].forEach((feature) => {
const id = feature.getId()
if (id === uid) {
_setFeatureStyle(feature, styleOptions)
const isVisible = Object.keys(styleOptions).length !== 0
this[_annotationManager].setMarkupVisibility(id, isVisible)
}
})
}
/**
* Add a new viewport overlay.
*
* @param {Object} options - Overlay options
* @param {Object} options.element - The custom overlay HTML element
* @param {number[]} options.coordinates - Offset of the overlay along the X and
* Y axes of the slide coordinate system
* @param {bool} [options.coordinates=false] - Whether the viewer should
* automatically navigate to the element such that it is in focus and fully
* visible
* @param {string} [options.className] - Class to style the overlay container
*/
addViewportOverlay ({ element, coordinates, navigate, className }) {
const offset = _scoord3dCoordinates2geometryCoordinates(
coordinates,
this[_pyramid],
this[_affineInverse]
)
const overlay = new Overlay({
element,
className: (
className != null
? `ol-overlay-container ol-selectable ${className}`
: 'ol-overlay-container ol-selectable'
),
offset,
autoPan: navigate != null ? navigate : false,
stopEvent: false
})
this[_overlays][element.id] = overlay
this[_map].addOverlay(overlay)
}
/**
* Remove an existing viewport overlay.
*
* @param {Object} options - Overlay options
* @param {Object} options.element - The custom overlay HTML element
*/
removeViewportOverlay ({ element }) {
const id = element.id
if (id in this[_overlays]) {
const overlay = this[_overlays][id]
this[_map].removeOverlay(overlay)
delete this[_overlays][id]
}
}
/**
* Remove an individual regions of interest.
*
* @param {string} uid - Unique identifier of the region of interest
*/
removeROI (uid) {
console.info(`remove ROI ${uid}`)
const feature = this[_drawingSource].getFeatureById(uid)
if (feature) {
this[_features].remove(feature)
return
}
/**
* If failed to draw/cache feature in drawing source, call onFailure
* to avoid trash of broken annotations
*/
this[_annotationManager].onFailure(uid)
}
/**
* Remove all annotated regions of interest.
*/
removeAllROIs () {
console.info('remove all ROIs')
this[_features].clear()
}
/**
* Hide annotated regions of interest.
*/
hideROIs () {
console.info('hide ROIs')
this[_drawingLayer].setVisible(false)
this[_annotationManager].setVisible(false)
}
/**
* Show annotated regions of interest.
*/
showROIs () {
console.info('show ROIs')
this[_drawingLayer].setVisible(true)
this[_annotationManager].setVisible(true)
}
/**
* Whether annotated regions of interest are currently visible.
*
* @return {boolean}
*/
get areROIsVisible () {
return this[_drawingLayer].getVisible()
}
/**
* Add annotation groups.
*
* @param {metadata.MicroscopyBulkSimpleAnnotations} metadata - Metadata of a
* DICOM Microscopy Simple Bulk Annotations instance
*/
addAnnotationGroups (metadata) {
const refImage = this[_pyramid].metadata[0]
if (refImage.FrameOfReferenceUID !== metadata.FrameOfReferenceUID) {
throw new Error(
'Microscopy Bulk Simple Annotation instances must have the same ' +
'Frame of Reference UID as the corresponding source images.'
)
}
console.info(
'add annotation groups of Microscopy Bulk Simple Annotation instances ' +
`of series "${metadata.SeriesInstanceUID}"`
)
const defaultAnnotationGroupStyle = {
opacity: 1.0,
color: this[_options].primaryColor
}
const _getROIFromFeature = (feature) => {
const roi = this._getROIFromFeature(
feature,
this[_pyramid].metadata,
this[_affine]
)
const annotationGroupUID = feature.get('annotationGroupUID')
const annotationGroupMetadata = metadata.AnnotationGroupSequence.find(
item => item.AnnotationGroupUID === annotationGroupUID
)
if (annotationGroupUID == null || annotationGroupMetadata == null) {
throw new Error(
'Could not obtain information of annotation from ' +
`annotation group "${annotationGroupUID}".`
)
}
if (annotationGroupMetadata.AnnotationPropertyCategoryCodeSequence != null) {
const findingCategory = (
annotationGroupMetadata.AnnotationPropertyCategoryCodeSequence[0]
)
roi.addEvaluation(
new dcmjs.sr.valueTypes.CodeContentItem({
name: new dcmjs.sr.coding.CodedConcept({
value: '276214006',
meaning: 'Finding category',
schemeDesignator: 'SCT'
}),
value: new dcmjs.sr.coding.CodedConcept({
value: findingCategory.CodeValue,
meaning: findingCategory.CodeMeaning,
schemeDesignator: findingCategory.CodingSchemeDesignator
}),
relationshipType:
dcmjs.sr.valueTypes.RelationshipTypes.HAS_CONCEPT_MOD
})
)
}
if (annotationGroupMetadata.AnnotationPropertyTypeCodeSequence != null) {
const findingType = (
annotationGroupMetadata.AnnotationPropertyTypeCodeSequence[0]
)
roi.addEvaluation(
new dcmjs.sr.valueTypes.CodeContentItem({
name: new dcmjs.sr.coding.CodedConcept({
value: '121071',
meaning: 'Finding',
schemeDesignator: 'DCM'
}),
value: new dcmjs.sr.coding.CodedConcept({
value: findingType.CodeValue,
meaning: findingType.CodeMeaning,
schemeDesignator: findingType.CodingSchemeDesignator
}),
relationshipType:
dcmjs.sr.valueTypes.RelationshipTypes.HAS_CONCEPT_MOD
})
)
}
if (annotationGroupMetadata.MeasurementsSequence != null) {
annotationGroupMetadata.MeasurementsSequence.forEach(
(measurementItem, measurementIndex) => {
const key = `measurementValue${measurementIndex.toString()}`
const value = feature.get(key)
const name = measurementItem.ConceptNameCodeSequence[0]
const unit = measurementItem.MeasurementUnitsCodeSequence[0]
const measurement = new dcmjs.sr.valueTypes.NumContentItem({
value: Number(value),
name: new dcmjs.sr.coding.CodedConcept({
value: name.CodeValue,
meaning: name.CodeMeaning,
schemeDesignator: name.CodingSchemeDesignator
}),
unit: new dcmjs.sr.coding.CodedConcept({
value: unit.CodeValue,
meaning: unit.CodeMeaning,
schemeDesignator: unit.CodingSchemeDesignator
}),
relationshipType: dcmjs.sr.valueTypes.RelationshipTypes.CONTAINS
})
if (measurementItem.ReferencedImageSequence != null) {
const ref = measurementItem.ReferencedImageSequence[0]
const image = new dcmjs.sr.valueTypes.ImageContentItem({
name: new dcmjs.sr.coding.CodedConcept({
value: '121112',
meaning: 'Source of Measurement',
schemeDesignator: 'DCM'
}),
referencedSOPClassUID: ref.ReferencedSOPClassUID,
referencedSOPInstanceUID: ref.ReferencedSOPInstanceUID
})
if (ref.ReferencedOpticalPathIdentifier != null) {
image.ReferencedSOPSequence[0].ReferencedOpticalPathIdentifier = (
ref.ReferencedOpticalPathIdentifier
)
}
measurement.ContentSequence = [image]
}
roi.addMeasurement(measurement)
}
)
}
return roi
}
// We need to bind those variables to constants for the loader function
const client = _getClient(
this[_clients],
Enums.SOPClassUIDs.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE
)
const pyramid = this[_pyramid].metadata
const affineInverse = this[_affineInverse]
metadata.AnnotationGroupSequence.forEach((item, index) => {
const annotationGroupUID = item.AnnotationGroupUID
const algorithm = item.AnnotationGroupAlgorithmIdentificationSequence[0]
const annotationGroup = {
annotationGroup: new AnnotationGroup({
uid: annotationGroupUID,
number: item.AnnotationGroupNumber,
label: item.AnnotationGroupLabel,
algorithmType: item.AnnotationGroupGenerationType,
algorithmName: algorithm.AlgorithmName,
propertyCategory: item.AnnotationPropertyCategoryCodeSequence[0],
propertyType: item.AnnotationPropertyTypeCodeSequence[0],
studyInstanceUID: metadata.StudyInstanceUID,
seriesInstanceUID: metadata.SeriesInstanceUID,
sopInstanceUIDs: [metadata.SOPInstanceUID]
}),
style: { ...defaultAnnotationGroupStyle },
defaultStyle: defaultAnnotationGroupStyle,
metadata
}
if (item.GraphicType === 'POLYLINE') {
/*
* We represent graphics as centroid points, but it's unclear whether
* the centroid of a polyline would be meaningful
*/
console.warn(
`skip annotation group "${annotationGroupUID}" ` +
'with Graphic Type POLYLINE'
)
return
}
/**
* In the loader function "this" is bound to the vector source.
*/
function loader (extent, resolution, projection, success, failure) {
// TODO: figure out how to use "loader" with bbox or tile "strategy"?
const index = annotationGroup.annotationGroup.number - 1
const metadataItem = annotationGroup.metadata.AnnotationGroupSequence[index]
/**
* Bulkdata may not be available, since it's possible that all information
* has been included into the metadata by value as InlineBinary. It must
* only be provided if information has been included by reference as
* BulkDataURI.
*/
const bulkdataReferences = annotationGroup.metadata.bulkdataReferences
let bulkdataItem
if (bulkdataReferences.AnnotationGroupSequence != null) {
bulkdataItem = bulkdataReferences.AnnotationGroupSequence[index]
}
const numberOfAnnotations = Number(metadataItem.NumberOfAnnotations)
const graphicType = metadataItem.GraphicType
const coordinateDimensionality = _getCoordinateDimensionality(
metadataItem
)
const commonZCoordinate = _getCommonZCoordinate(metadataItem)
const features = this.getFeatures()
if (features.length > 0) {
success(features)
return
}
// TODO: Only fetch measurements if required.
const promises = [
_fetchGraphicData({ metadataItem, bulkdataItem, client }),
_fetchGraphicIndex({ metadataItem, bulkdataItem, client }),
_fetchMeasurements({ metadataItem, bulkdataItem, client })
]
Promise.all(promises).then(retrievedBulkdata => {
const graphicData = retrievedBulkdata[0]
const graphicIndex = retrievedBulkdata[1]
const measurements = retrievedBulkdata[2]
console.log('process annotations')
for (let i = 0; i < numberOfAnnotations; i++) {
const point = _getCentroid(
graphicType,
graphicData,
graphicIndex,
coordinateDimensionality,
commonZCoordinate,
i,
numberOfAnnotations
)
const coordinates = _scoord3dCoordinates2geometryCoordinates(
point,
pyramid,
affineInverse
)
const feature = new Feature({
geometry: new PointGeometry(coordinates)
})
feature.set('annotationGroupUID', annotationGroupUID, true)
measurements.forEach((measurementItem, measurementIndex) => {
const key = `measurementValue${measurementIndex.toString()}`
const value = measurementItem.values[i]
// Needed for the WebGL renderer
feature.set(key, value, true)
})
const uid = _generateUID({ value: `${annotationGroupUID}-${i}` })
feature.setId(uid)
features.push(feature)
}
console.info(
`add n=${features.length} annotations ` +
`for annotation group "${annotationGroupUID}"`
)
this.addFeatures(features)
console.info(
'compute statistics for measurement values ' +
`of annotation group "${annotationGroupUID}"`
)
const properties = {}
measurements.forEach((measurementItem, measurementIndex) => {
/*
* Ideally, we would compute quantiles, but that is an expensive
* operation. For now, just compute mininum and maximum.
*/
const min = measurementItem.values.reduce(
(a, b) => Math.min(a, b),
Infinity
)
const max = measurementItem.values.reduce(
(a, b) => Math.max(a, b),
-Infinity
)
const key = `measurementValue${measurementIndex.toString()}`
properties[key] = { min, max }
})
this.setProperties(properties, true)
success(features)
}).catch(error => {
console.error(error)
failure()
})
}
const source = new VectorSource({
loader,
wrapX: false,
rotateWithView: true,
overlaps: false
})
source.on('featuresloadstart', (event) => {
const container = this[_map].getTargetElement()
publish(container, EVENT.LOADING_STARTED)
})
source.on('featuresloadend', (event) => {
const container = this[_map].getTargetElement()
publish(container, EVENT.LOADING_ENDED)
})
source.on('featuresloaderror', (event) => {
const container = this[_map].getTargetElement()
publish(container, EVENT.LOADING_ENDED)
publish(container, EVENT.LOADING_ERROR)
})
/*
* TODO: Determine optimal sizes based on number of zoom levels and
* number of objects, and zoom factor between levels.
* Use style variable(s) that can subsequently be updated.
*/
const style = {
symbol: {
symbolType: 'circle',
size: [
'interpolate',
['linear'],
['zoom'],
1,
2,
this[_pyramid].metadata.length,
15
],
color: annotationGroup.style.color,
opacity: annotationGroup.style.opacity
}
}
annotationGroup.layer = new PointsLayer({
source,
style,
disableHitDetection: false
})
annotationGroup.layer.setVisible(false)
this[_map].addLayer(annotationGroup.layer)
this[_annotationGroups][annotationGroupUID] = annotationGroup
})
let selectedAnnotation = null
this[_map].on('singleclick', (e) => {
if (e != null) {
if (selectedAnnotation != null) {
selectedAnnotation.set('selected', 0)
selectedAnnotation = null
}
const container = this[_map].getTargetElement()
this[_map].forEachFeatureAtPixel(
e.pixel,
(feature) => {
if (feature != null) {
feature.set('selected', 1)
selectedAnnotation = feature
publish(
container,
EVENT.ROI_SELECTED,
_getROIFromFeature(feature)
)
return true
}
return false
},
{
hitTolerance: 1,
layerFilter: (layer) => (layer instanceof PointsLayer)
}
)
}
})
}
/**
* Remove an annotation group.
*
* @param {string} annotationGroupUID - Unique identifier of an annotation
* group
*/
removeAnnotationGroup (annotationGroupUID) {
if (!(annotationGroupUID in this[_annotationGroups])) {
throw new Error(
'Cannot remove annotation group. ' +
`Could not find annotation group "${annotationGroupUID}".`
)
}
const annotationGroup = this[_annotationGroups][annotationGroupUID]
console.info(`remove annotation group ${annotationGroupUID}`)
this[_map].removeLayer(annotationGroup.layer)
annotationGroup.layer.dispose()
delete this[_annotationGroups][annotationGroupUID]
}
/**
* Remove all annotation groups.
*/
removeAllAnnotationGroups () {
Object.keys(this[_annotationGroups]).forEach(annotationGroupUID => {
this.removeAnnotationGroup(annotationGroupUID)
})
}
/**
* Show an annotation group.
*
* @param {string} annotationGroupUID - Unique identifier of an annotation
* group
* @param {Object} styleOptions
* @param {number} [styleOptions.opacity] - Opacity
* @param {number[]} [styleOptions.color] - RGB color triplet
* @param {Object} [styleOptions.measurement] - Selected measurement
*/
showAnnotationGroup (annotationGroupUID, styleOptions = {}) {
if (!(annotationGroupUID in this[_annotationGroups])) {
throw new Error(
'Cannot show annotation group. ' +
`Could not find annotation group "${annotationGroupUID}".`
)
}
const annotationGroup = this[_annotationGroups][annotationGroupUID]
console.info(`show annotation group ${annotationGroupUID}`)
this.setAnnotationGroupStyle(annotationGroupUID, styleOptions)
annotationGroup.layer.setVisible(true)
}
/**
* Hide an annotation group.
*
* @param {string} annotationGroupUID - Unique identifier of an annotation
* group
*/
hideAnnotationGroup (annotationGroupUID) {
if (!(annotationGroupUID in this[_annotationGroups])) {
throw new Error(
'Cannot hide annotation group. ' +
`Could not find annotation group "${annotationGroupUID}".`
)
}
const annotationGroup = this[_annotationGroups][annotationGroupUID]
console.info(`hide annotation group ${annotationGroupUID}`)
annotationGroup.layer.setVisible(false)
}
/**
* Is annotation group visible.
*
* @param {string} annotationGroupUID - Unique identifier of an annotation
* group
*/
isAnnotationGroupVisible (annotationGroupUID) {
if (!(annotationGroupUID in this[_annotationGroups])) {
throw new Error(
'Cannot determine if annotation group is visible. ' +
`Could not find annotation group "${annotationGroupUID}".`
)
}
const annotationGroup = this[_annotationGroups][annotationGroupUID]
return annotationGroup.layer.getVisible()
}
/**
* Set style of an annotation group.
*
* @param {string} annotationGroupUID - Unique identifier of an annotation
* group
* @param {Object} styleOptions - Style options
* @param {number} [styleOptions.opacity] - Opacity
* @param {number[]} [styleOptions.color] - RGB color triplet
* @param {Object} [styleOptions.measurement] - Selected measurement for
* pseudo-coloring of annotations using measurement values
*/
setAnnotationGroupStyle (annotationGroupUID, styleOptions = {}) {
if (!(annotationGroupUID in this[_annotationGroups])) {
throw new Error(
'Cannot set style of annotation group. ' +
`Could not find annotation group "${annotationGroupUID}".`
)
}
const annotationGroup = this[_annotationGroups][annotationGroupUID]
console.info(
`set style for annotation group "${annotationGroupUID}"`,
styleOptions
)
if (styleOptions.opacity != null) {
annotationGroup.style.opacity = styleOptions.opacity
annotationGroup.layer.setOpacity(styleOptions.opacity)
}
if (styleOptions.color != null) {
annotationGroup.style.color = styleOptions.color
}
const metadata = annotationGroup.metadata
const source = annotationGroup.layer.getSource()
const groupItem = metadata.AnnotationGroupSequence.find(item => {
return item.AnnotationGroupUID === annotationGroupUID
})
if (groupItem == null) {
throw new Error(
'Cannot set style of annotation group. ' +
`Could not find metadata of annotation group "${annotationGroupUID}".`
)
}
const markerType = 'circle'
const topLayerIndex = 0
const topLayerPixelSpacing = this[_pyramid].pixelSpacings[topLayerIndex]
const baseLayerIndex = this[_pyramid].metadata.length - 1
const baseLayerPixelSpacing = this[_pyramid].pixelSpacings[baseLayerIndex]
const diameter = 5 * 10 ** -3 // micometer
const markerSize = [
'interpolate',
['exponential', 2],
['zoom'],
1,
Math.max(diameter / topLayerPixelSpacing[0], 1),
this[_pyramid].resolutions.length,
Math.min(diameter / baseLayerPixelSpacing[0], 50)
]
const name = styleOptions.measurement
if (name) {
const measurementIndex = groupItem.MeasurementsSequence.findIndex(item => {
return areCodedConceptsEqual(name, getContentItemNameCodedConcept(item))
})
if (measurementIndex == null) {
throw new Error(
'Cannot set style of annotation group. ' +
`Could not find measurement "${name.CodeMeaning}" ` +
`of annotation group "${annotationGroupUID}".`
)
}
const properties = source.getProperties()
const key = `measurementValue${measurementIndex.toString()}`
if (properties[key]) {
const style = {
symbol: {
symbolType: markerType,
size: markerSize,
opacity: annotationGroup.style.opacity
}
}
const colormap = createColormap({
name: ColormapNames.VIRIDIS,
bins: 50
})
Object.assign(
style.symbol,
_getColorPaletteStyleForPointLayer({
key,
minValue: properties[key].min,
maxValue: properties[key].max,
colormap
})
)
const newLayer = new PointsLayer({
source,
style,
disableHitDetection: false,
visible: false
})
this[_map].addLayer(newLayer)
this[_map].removeLayer(annotationGroup.layer)
annotationGroup.layer.dispose()
annotationGroup.layer = newLayer
}
} else {
if (styleOptions.color != null) {
// Only replace the layer if necessary
const style = {
symbol: {
symbolType: markerType,
size: markerSize,
color: [
'match',
['get', 'selected'],
1,
rgb2hex(this[_options].highlightColor),
rgb2hex(annotationGroup.style.color)
],
opacity: annotationGroup.style.opacity
}
}
const newLayer = new PointsLayer({
source,
style,
disableHitDetection: false,
visible: false
})
this[_map].addLayer(newLayer)
this[_map].removeLayer(annotationGroup.layer)
const isVisible = annotationGroup.layer.getVisible()
annotationGroup.layer.dispose()
annotationGroup.layer = newLayer
annotationGroup.layer.setVisible(isVisible)
}
}
}
/**
* Get default style of an annotation group.
*
* @param {string} annotationGroupUID - Unique identifier of an annotation
* group
*
* @returns {Object} - Default style settings
*/
getAnnotationGroupDefaultStyle (annotationGroupUID) {
if (!(annotationGroupUID in this[_annotationGroups])) {
throw new Error(
'Cannot get default style of annotation group. ' +
`Could not find annotation group "${annotationGroupUID}".`
)
}
const annotationGroup = this[_annotationGroups][annotationGroupUID]
return {
opacity: annotationGroup.defaultStyle.opacity,
color: annotationGroup.defaultStyle.color
}
}
/**
* Get style of an annotation group.
*
* @param {string} annotationGroupUID - Unique identifier of an annotation
* group
*
* @returns {Object} - Style settings
*/
getAnnotationGroupStyle (annotationGroupUID) {
if (!(annotationGroupUID in this[_annotationGroups])) {
throw new Error(
'Cannot get style of annotation group. ' +
`Could not find annotation group "${annotationGroupUID}".`
)
}
const annotationGroup = this[_annotationGroups][annotationGroupUID]
return {
opacity: annotationGroup.style.opacity,
color: annotationGroup.style.color
}
}
/**
* Get all annotation groups.
*
* @returns {annotation.AnnotationGroup[]}
*/
getAllAnnotationGroups () {
const groups = []
for (const annotationGroupUID in this[_annotationGroups]) {
groups.push(this[_annotationGroups][annotationGroupUID].annotationGroup)
}
return groups
}
/**
* Get annotation group metadata.
*
* @param {string} annotationGroupUID - Unique identifier of an annotation group
*
* @returns {metadata.MicroscopyBulkSimpleAnnotations} - Metadata of DICOM
* Microscopy Bulk Simple Annotations instance
*/
getAnnotationGroupMetadata (annotationGroupUID) {
if (!(annotationGroupUID in this[_annotationGroups])) {
throw new Error(
'Cannot get metadata of annotation group. ' +
`Could not find annotation group "${annotationGroupUID}".`
)
}
const annotationGroup = this[_annotationGroups][annotationGroupUID]
return annotationGroup.metadata
}
/**
* Add segments.
*
* @param {metadata.Segmentation[]} metadata - Metadata of one or more DICOM Segmentation instances
*/
addSegments (metadata) {
if (metadata.length === 0) {
throw new Error(
'Metadata of Segmentation instances needs to be provided to ' +
'add segments.'
)
}
const refSegmentation = metadata[0]
const refImage = this[_pyramid].metadata[0]
metadata.forEach(instance => {
if (
instance.TotalPixelMatrixColumns === undefined ||
instance.TotalPixelMatrixRows === undefined
) {
throw new Error(
'Segmentation instances must contain attributes ' +
'"Total Pixel Matrix Rows" and "Total Pixel Matrix Columns".'
)
}
if (refImage.FrameOfReferenceUID !== instance.FrameOfReferenceUID) {
throw new Error(
'Segmentation instances must have the same Frame of Reference UID ' +
'as the corresponding source images.'
)
}
if (refSegmentation.FrameOfReferenceUID !== instance.FrameOfReferenceUID) {
throw new Error(
'Segmentation instances must all have same Frame of Reference UID.'
)
}
if (refSegmentation.SeriesInstanceUID !== instance.SeriesInstanceUID) {
throw new Error(
'Segmentation instances must all have same Series Instance UID.'
)
}
if (
refSegmentation.SegmentSequence.length !==
instance.SegmentSequence.length
) {
throw new Error(
'Segmentation instances must all contain the same number of items ' +
'in the Segment Sequence.'
)
}
})
console.info(
'add segments of Segmentation instances of series ' +
`"${refSegmentation.SeriesInstanceUID}"`
)
const pyramid = _computeImagePyramid({ metadata })
const [fittedPyramid, minZoomLevel, maxZoomLevel] = _fitImagePyramid(
pyramid,
this[_pyramid]
)
const tileGrid = new TileGrid({
extent: fittedPyramid.extent,
origins: fittedPyramid.origins,
resolutions: fittedPyramid.resolutions,
sizes: fittedPyramid.gridSizes,
tileSizes: fittedPyramid.tileSizes
})
let minStoredValue = 0
let maxStoredValue = 255
if (refSegmentation.SegmentationType === 'BINARY') {
minStoredValue = 0
maxStoredValue = 1
}
refSegmentation.SegmentSequence.forEach((item, index) => {
const segmentNumber = Number(item.SegmentNumber)
console.info(`add segment #${segmentNumber}`)
let segmentUID = _generateUID({
value: refSegmentation.SOPInstanceUID + segmentNumber.toString()
})
if (item.TrackingUID != null) {
segmentUID = item.TrackingUID
}
const colormap = createColormap({
name: ColormapNames.VIRIDIS,
bins: Math.pow(2, 8)
})
const defaultSegmentStyle = {
opacity: 0.75,
paletteColorLookupTable: buildPaletteColorLookupTable({
data: colormap,
firstValueMapped: 0
})
}
const segment = {
segment: new Segment({
uid: segmentUID,
number: segmentNumber,
label: item.SegmentLabel,
algorithmType: item.SegmentAlgorithmType,
algorithmName: item.SegmentAlgorithmName || '',
propertyCategory: item.SegmentedPropertyCategoryCodeSequence[0],
propertyType: item.SegmentedPropertyTypeCodeSequence[0],
studyInstanceUID: refSegmentation.StudyInstanceUID,
seriesInstanceUID: refSegmentation.SeriesInstanceUID,
sopInstanceUIDs: pyramid.metadata.map(element => {
return element.SOPInstanceUID
})
}),
pyramid,
style: { ...defaultSegmentStyle },
defaultStyle: defaultSegmentStyle,
overlay: new Overlay({
element: document.createElement('div'),
offset: [5 + 5 * index + 2, 5]
}),
minStoredValue,
maxStoredValue,
minZoomLevel,
maxZoomLevel,
loaderParams: {
pyramid: fittedPyramid,
client: _getClient(this[_clients], Enums.SOPClassUIDs.SEGMENTATION),
channel: segmentNumber
},
hasLoader: false
}
const source = new DataTileSource({
tileGrid,
projection: this[_projection],
wrapX: false,
bandCount: 1,
interpolate: true
})
source.on('tileloaderror', (event) => {
console.error(`error loading tile of segment "${segmentUID}"`, event)
})
const [windowCenter, windowWidth] = createWindow(
minStoredValue,
maxStoredValue
)
segment.layer = new TileLayer({
source,
extent: this[_pyramid].extent,
visible: false,
opacity: 0.9,
preload: this[_options].preload ? 1 : 0,
transition: 0,
style: _getColorPaletteStyleForTileLayer({
windowCenter,
windowWidth,
colormap: segment.style.paletteColorLookupTable.data
}),
useInterimTilesOnError: false,
cacheSize: this[_options].tilesCacheSize,
minResolution: (
minZoomLevel > 0
? this[_pyramid].resolutions[minZoomLevel]
: undefined
)
})
segment.layer.on('error', (event) => {
console.error(`error rendering segment "${segmentUID}"`, event)
})
this[_map].addLayer(segment.layer)
this[_segments][segmentUID] = segment
})
}
/**
* Remove a segment.
*
* @param {string} segmentUID - Unique tracking identifier of a segment
*/
removeSegment (segmentUID) {
if (!(segmentUID in this[_segments])) {
throw new Error(
`Cannot remove segment. Could not find segment "${segmentUID}".`
)
}
const segment = this[_segments][segmentUID]
this[_map].removeLayer(segment.layer)
segment.layer.dispose()
this[_map].removeOverlay(segment.overlay)
delete this[_segments][segmentUID]
}
/**
* Remove all segments.
*/
removeAllSegments () {
Object.keys(this[_segments]).forEach(segmentUID => {
this.removeSegment(segmentUID)
})
}
/**
* Show a segment.
*
* @param {string} segmentUID - Unique tracking identifier of a segment
* @param {Object} [styleOptions]
* @param {number} [styleOptions.opacity] - Opacity
*/
showSegment (segmentUID, styleOptions = {}) {
if (!(segmentUID in this[_segments])) {
throw new Error(
`Cannot show segment. Could not find segment "${segmentUID}".`
)
}
const segment = this[_segments][segmentUID]
console.info(`show segment ${segmentUID}`)
const container = this[_map].getTargetElement()
if (container && !segment.hasLoader) {
const loader = _createTileLoadFunction({
targetElement: container,
iccProfiles: [],
...segment.loaderParams
})
const source = segment.layer.getSource()
source.setLoader(loader)
}
const view = this[_map].getView()
const currentZoomLevel = view.getZoom()
if (
currentZoomLevel < segment.minZoomLevel ||
currentZoomLevel > segment.maxZoomLevel
) {
view.animate({ zoom: segment.minZoomLevel })
}
segment.layer.setVisible(true)
this.setSegmentStyle(segmentUID, styleOptions)
}
/**
* Hide a segment.
*
* @param {string} segmentUID - Unique tracking identifier of a segment
*/
hideSegment (segmentUID) {
if (!(segmentUID in this[_segments])) {
throw new Error(
`Cannot hide segment. Could not find segment "${segmentUID}".`
)
}
const segment = this[_segments][segmentUID]
console.info(`hide segment ${segmentUID}`)
segment.layer.setVisible(false)
this[_map].removeOverlay(segment.overlay)
}
/**
* Determine if segment is visible.
*
* @param {string} segmentUID - Unique tracking identifier of a segment
* @returns {boolean}
*/
isSegmentVisible (segmentUID) {
if (!(segmentUID in this[_segments])) {
throw new Error(
'Cannot determine if segment is visible. ' +
`Could not find segment "${segmentUID}".`
)
}
const segment = this[_segments][segmentUID]
return segment.layer.getVisible()
}
/**
* Set the style of a segment.
*
* @param {string} segmentUID - Unique tracking identifier of segment
* @param {Object} styleOptions - Style options
* @param {number} [styleOptions.opacity] - Opacity
*/
setSegmentStyle (segmentUID, styleOptions = {}) {
if (!(segmentUID in this[_segments])) {
throw new Error(
'Cannot set style of segment. ' +
`Could not find segment "${segmentUID}".`
)
}
const segment = this[_segments][segmentUID]
if (styleOptions.opacity != null) {
segment.style.opacity = styleOptions.opacity
segment.layer.setOpacity(styleOptions.opacity)
}
let title = segment.segment.propertyType.CodeMeaning
const padding = Math.round((16 - title.length) / 2)
title = title.padStart(title.length + padding)
title = title.padEnd(title.length + 2 * padding)
const overlayElement = segment.overlay.getElement()
overlayElement.innerHTML = title
overlayElement.style = {}
overlayElement.style.display = 'flex'
overlayElement.style.flexDirection = 'column'
overlayElement.style.justifyContent = 'center'
overlayElement.style.padding = '4px'
overlayElement.style.backgroundColor = 'rgba(255, 255, 255, .5)'
overlayElement.style.borderRadius = '4px'
overlayElement.style.margin = '1px'
overlayElement.style.color = 'black'
overlayElement.style.fontWeight = '600'
overlayElement.style.fontSize = '12px'
overlayElement.style.textAlign = 'center'
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
const height = 30
const width = 15
context.canvas.height = height
context.canvas.width = width
const colors = segment.style.paletteColorLookupTable.data
for (let j = 0; j < colors.length; j++) {
const color = colors[colors.length - j - 1]
const r = color[0]
const g = color[1]
const b = color[2]
context.fillStyle = `rgb(${r}, ${g}, ${b})`
context.fillRect(0, height / colors.length * j, width, 1)
}
overlayElement.appendChild(canvas)
const parentElement = overlayElement.parentNode
parentElement.style.display = 'inline'
this[_map].addOverlay(segment.overlay)
}
/**
* Get the default style of a segment.
*
* @param {string} segmentUID - Unique tracking identifier of segment
*
* @returns {Object} Default style settings
*/
getSegmentDefaultStyle (segmentUID) {
if (!(segmentUID in this[_segments])) {
throw new Error(
'Cannot get default style of segment. ' +
`Could not find segment "${segmentUID}".`
)
}
const segment = this[_segments][segmentUID]
return {
opacity: segment.defaultStyle.opacity,
paletteColorLookupTable: segment.defaultStyle.paletteColorLookupTable
}
}
/**
* Get the style of a segment.
*
* @param {string} segmentUID - Unique tracking identifier of segment
*
* @returns {Object} Style settings
*/
getSegmentStyle (segmentUID) {
if (!(segmentUID in this[_segments])) {
throw new Error(
'Cannot get style of segment. ' +
`Could not find segment "${segmentUID}".`
)
}
const segment = this[_segments][segmentUID]
return {
opacity: segment.style.opacity,
paletteColorLookupTable: segment.style.paletteColorLookupTable
}
}
/**
* Get image metadata for a segment.
*
* @param {string} segmentUID - Unique tracking identifier of segment
*
* @returns {metadata.Segmentation[]} Metadata of DICOM Segmentation instances
*/
getSegmentMetadata (segmentUID) {
if (!(segmentUID in this[_segments])) {
throw new Error(
'Cannot get image metadata of segment. ' +
`Could not find segment "${segmentUID}".`
)
}
const segment = this[_segments][segmentUID]
return segment.pyramid.metadata
}
/**
* Get all segments.
*
* @return {segment.Segment[]}
*/
getAllSegments () {
const segments = []
for (const segmentUID in this[_segments]) {
segments.push(this[_segments][segmentUID].segment)
}
return segments
}
/**
* Add parameter mappings.
*
* @param {metadata.ParametricMap[]} metadata - Metadata of one or more DICOM Parametric Map instances
*/
addParameterMappings (metadata) {
if (metadata.length === 0) {
throw new Error(
'Metadata of Parametric Map instances needs to be provided to ' +
'add mappings.'
)
}
const refImage = this[_pyramid].metadata[0]
const refParametricMap = metadata[0]
if (refParametricMap.ContentLabel !== 'HEATMAP') {
console.warn(
'skip mappings because value of "Content Label" attribute of ' +
'Parametric Map instances is not "HEATMAP"'
)
return
}
metadata.forEach(instance => {
if (
instance.TotalPixelMatrixColumns === undefined ||
instance.TotalPixelMatrixRows === undefined
) {
throw new Error(
'Parametric Map instances must contain attributes ' +
'"Total Pixel Matrix Rows" and "Total Pixel Matrix Columns".'
)
}
if (refImage.FrameOfReferenceUID !== instance.FrameOfReferenceUID) {
throw new Error(
'Parametric Map instances must have the same Frame of Reference UID ' +
'as the corresponding source images.'
)
}
if (refParametricMap.FrameOfReferenceUID !== instance.FrameOfReferenceUID) {
throw new Error(
'Parametric Map instances must all have same Frame of Reference UID.'
)
}
if (refParametricMap.SeriesInstanceUID !== instance.SeriesInstanceUID) {
throw new Error(
'Parametric Map instances must all have same Series Instance UID.'
)
}
})
console.info(
'add mappings of Parametric Map instances of series ' +
`"${refParametricMap.SeriesInstanceUID}"`
)
const pyramid = _computeImagePyramid({ metadata })
const [fittedPyramid, minZoomLevel, maxZoomLevel] = _fitImagePyramid(
pyramid,
this[_pyramid]
)
const tileGrid = new TileGrid({
extent: fittedPyramid.extent,
origins: fittedPyramid.origins,
resolutions: fittedPyramid.resolutions,
sizes: fittedPyramid.gridSizes,
tileSizes: fittedPyramid.tileSizes
})
const refInstance = pyramid.metadata[0]
const sharedFuncGroup = refInstance.SharedFunctionalGroupsSequence[0]
const frameVOILUT = sharedFuncGroup.FrameVOILUTSequence[0]
if (frameVOILUT === undefined) {
throw new Error(
'The Parametric Map image does not specify a shared frame ' +
'Value of Interest (VOI) lookup table (LUT).'
)
}
const windowCenter = frameVOILUT.WindowCenter
const windowWidth = frameVOILUT.WindowWidth
const { mappingNumberToDescriptions } = _groupFramesPerMapping(refInstance)
let index = 0
for (const mappingNumber in mappingNumberToDescriptions) {
const mappingDescriptions = mappingNumberToDescriptions[mappingNumber]
const refItem = mappingDescriptions[0]
const mappingLabel = refItem.LUTLabel
const mappingExplanation = refItem.LUTExplanation
let mappingUID = _generateUID({
value: refInstance.SOPInstanceUID + mappingLabel
})
if (refItem.TrackingUID != null) {
mappingUID = refItem.TrackingUID
}
const range = [NaN, NaN]
mappingDescriptions.forEach((item, i) => {
if (item.TrackingUID != null) {
if (item.TrackingUID !== mappingUID) {
throw new Error(
`Item #${i + 1} of Real World Value Mapping Sequence ` +
`of frame #${index + 1} has unexpected Tracking UID. ` +
'All items must have the same unique identifier value.'
)
}
}
let firstValueMapped = item.RealWorldValueFirstValueMapped
let lastValueMapped = item.RealWorldValueLastValueMapped
if (firstValueMapped === undefined && lastValueMapped === undefined) {
firstValueMapped = item.DoubleFloatRealWorldValueFirstValueMapped
lastValueMapped = item.DoubleFloatRealWorldValueLastValueMapped
}
const intercept = item.RealWorldValueIntercept
const slope = item.RealWorldValueSlope
const lowerBound = firstValueMapped * slope + intercept
const upperBound = lastValueMapped * slope + intercept
if (i === 0) {
range[0] = lowerBound
range[1] = upperBound
} else {
range[0] = Math.min(range[0], lowerBound)
range[1] = Math.max(range[1], upperBound)
}
})
// TODO: include real world values in legend
if (isNaN(range[0]) || isNaN(range[1])) {
throw new Error('Could not determine range of real world values.')
}
let colormap
const isFloatPixelData = refInstance.BitsAllocated > 16
let minStoredValue = 0
let maxStoredValue = Math.pow(2, refInstance.BitsAllocated) - 1
if (isFloatPixelData) {
minStoredValue = -(Math.pow(2, refInstance.BitsAllocated) - 1) / 2
maxStoredValue = (Math.pow(2, refInstance.BitsAllocated) - 1) / 2
}
if (refInstance.PixelPresentation === 'MONOCHROME') {
colormap = createColormap({
name: ColormapNames.MAGMA,
bins: Math.pow(2, 8)
})
} else {
if (range[0] < 0 && range[1] > 0) {
colormap = createColormap({
name: ColormapNames.BLUE_RED,
bins: Math.pow(2, 8)
})
} else {
colormap = createColormap({
name: ColormapNames.HOT,
bins: Math.pow(2, 8)
})
}
}
const defaultMappingStyle = {
opacity: 1.0,
limitValues: [
Math.ceil(windowCenter - windowWidth / 2),
Math.floor(windowCenter + windowWidth / 2)
],
paletteColorLookupTable: buildPaletteColorLookupTable({
data: colormap,
firstValueMapped: 0
})
}
const mapping = {
mapping: new ParameterMapping({
uid: mappingUID,
number: mappingNumber,
label: mappingLabel,
description: mappingExplanation,
studyInstanceUID: refInstance.StudyInstanceUID,
seriesInstanceUID: refInstance.SeriesInstanceUID,
sopInstanceUIDs: pyramid.metadata.map(element => {
return element.SOPInstanceUID
})
}),
pyramid,
overlay: new Overlay({
element: document.createElement('div'),
offset: [5 + 100 * index + 2, 5]
}),
style: { ...defaultMappingStyle },
defaultStyle: defaultMappingStyle,
minStoredValue,
maxStoredValue,
minZoomLevel,
maxZoomLevel,
loaderParams: {
pyramid: fittedPyramid,
client: _getClient(this[_clients], Enums.SOPClassUIDs.PARAMETRIC_MAP),
channel: mappingNumber
},
hasLoader: false
}
const source = new DataTileSource({
tileGrid,
projection: this[_projection],
wrapX: false,
bandCount: 1,
interpolate: true
})
source.on('tileloaderror', (event) => {
console.error(`error loading tile of mapping "${mappingUID}"`, event)
})
mapping.layer = new TileLayer({
source,
extent: this[_pyramid].extent,
projection: this[_projection],
visible: false,
opacity: 1,
preload: this[_options].preload ? 1 : 0,
transition: 0,
style: _getColorPaletteStyleForTileLayer({
windowCenter,
windowWidth,
colormap: mapping.style.paletteColorLookupTable.data
})
})
mapping.layer.on('error', (event) => {
console.error(`error rendering mapping "${mappingUID}"`, event)
})
this[_map].addLayer(mapping.layer)
this[_mappings][mappingUID] = mapping
index += 1
}
}
/**
* Remove a parameter mapping.
*
* @param {string} mappingUID - Unique tracking identifier of a mapping
*/
removeParameterMapping (mappingUID) {
if (!(mappingUID in this[_mappings])) {
throw new Error(
`Cannot remove mapping. Could not find mapping "${mappingUID}".`
)
}
const mapping = this[_mappings][mappingUID]
this[_map].removeLayer(mapping.layer)
mapping.layer.dispose()
this[_map].removeOverlay(mapping.overlay)
delete this[_mappings][mappingUID]
}
/**
* Remove all parameter mappings.
*/
removeAllParameterMappings () {
Object.keys(this[_mappings]).forEach(mappingUID => {
this.removeParameterMapping(mappingUID)
})
}
/**
* Show a parameter mapping.
*
* @param {string} mappingUID - Unique tracking identifier of a mapping
* @param {Object} [styleOptions]
* @param {number} [styleOptions.opacity] - Opacity
* @param {number[]} [styleOptions.limitValues] - Upper and lower windowing
*/
showParameterMapping (mappingUID, styleOptions = {}) {
if (!(mappingUID in this[_mappings])) {
throw new Error(
`Cannot show mapping. Could not find mapping "${mappingUID}".`
)
}
const mapping = this[_mappings][mappingUID]
console.info(`show mapping ${mappingUID}`)
const container = this[_map].getTargetElement()
if (container && !mapping.hasLoader) {
const loader = _createTileLoadFunction({
targetElement: container,
iccProfiles: [],
...mapping.loaderParams
})
const source = mapping.layer.getSource()
source.setLoader(loader)
}
const view = this[_map].getView()
const currentZoomLevel = view.getZoom()
if (
currentZoomLevel < mapping.minZoomLevel ||
currentZoomLevel > mapping.maxZoomLevel
) {
view.animate({ zoom: mapping.minZoomLevel })
}
mapping.layer.setVisible(true)
this.setParameterMappingStyle(mappingUID, styleOptions)
}
/**
* Hide a parameter mapping.
*
* @param {string} mappingUID - Unique tracking identifier of a mapping
*/
hideParameterMapping (mappingUID) {
if (!(mappingUID in this[_mappings])) {
throw new Error(
`Cannot hide mapping. Could not find mapping "${mappingUID}".`
)
}
const mapping = this[_mappings][mappingUID]
console.info(`hide mapping ${mappingUID}`)
mapping.layer.setVisible(false)
this[_map].removeOverlay(mapping.overlay)
}
/**
* Determine if parameter mapping is visible.
*
* @param {string} mappingUID - Unique tracking identifier of a mapping
* @returns {boolean}
*/
isParameterMappingVisible (mappingUID) {
if (!(mappingUID in this[_mappings])) {
throw new Error(
'Cannot determine if mapping is visible. ' +
`Could not find mapping "${mappingUID}".`
)
}
const mapping = this[_mappings][mappingUID]
return mapping.layer.getVisible()
}
/**
* Set the style of a parameter mapping.
*
* @param {string} mappingUID - Unique tracking identifier of mapping
* @param {Object} styleOptions
* @param {number} [styleOptions.opacity] - Opacity
* @param {number[]} [styleOptions.limitValues] - Upper and lower windowing
*/
setParameterMappingStyle (mappingUID, styleOptions = {}) {
if (!(mappingUID in this[_mappings])) {
throw new Error(
'Cannot set style of mapping. ' +
`Could not find mapping "${mappingUID}".`
)
}
const mapping = this[_mappings][mappingUID]
if (styleOptions.opacity != null) {
mapping.style.opacity = styleOptions.opacity
mapping.layer.setOpacity(styleOptions.opacity)
}
const styleVariables = {}
if (styleOptions.limitValues != null) {
mapping.style.limitValues = [
Math.max(styleOptions.limitValues[0], mapping.minStoredValue),
Math.min(styleOptions.limitValues[1], mapping.maxStoredValue)
]
const [windowCenter, windowWidth] = createWindow(
mapping.style.limitValues[0],
mapping.style.limitValues[1]
)
styleVariables.windowCenter = windowCenter
styleVariables.windowWidth = windowWidth
mapping.layer.updateStyleVariables(styleVariables)
}
let title = mapping.mapping.label
const padding = Math.round((16 - title.length) / 2)
title = title.padStart(title.length + padding)
title = title.padEnd(title.length + 2 * padding)
const overlayElement = mapping.overlay.getElement()
overlayElement.innerHTML = title
overlayElement.style = {}
overlayElement.style.display = 'flex'
overlayElement.style.flexDirection = 'column'
overlayElement.style.justifyContent = 'center'
overlayElement.style.padding = '4px'
overlayElement.style.backgroundColor = 'rgba(255, 255, 255, .5)'
overlayElement.style.borderRadius = '4px'
overlayElement.style.margin = '1px'
overlayElement.style.color = 'black'
overlayElement.style.fontWeight = '600'
overlayElement.style.fontSize = '12px'
overlayElement.style.textAlign = 'center'
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
const height = 30
const width = 15
context.canvas.height = height
context.canvas.width = width
const colors = mapping.style.paletteColorLookupTable.data
for (let j = 0; j < colors.length; j++) {
const color = colors[colors.length - j - 1]
const r = color[0]
const g = color[1]
const b = color[2]
context.fillStyle = `rgb(${r}, ${g}, ${b})`
context.fillRect(0, height / colors.length * j, width, 1)
}
overlayElement.appendChild(canvas)
const parentElement = overlayElement.parentNode
parentElement.style.display = 'inline'
this[_map].addOverlay(mapping.overlay)
}
/**
* Get the default style of a parameter mapping.
*
* @param {string} mappingUID - Unique tracking identifier of mapping
* @returns {Object} Default style Options
*/
getParameterMappingDefaultStyle (mappingUID) {
if (!(mappingUID in this[_mappings])) {
throw new Error(
'Cannot get default style of mapping. ' +
`Could not find mapping "${mappingUID}".`
)
}
const mapping = this[_mappings][mappingUID]
return {
opacity: mapping.defaultStyle.opacity,
limitValues: mapping.defaultStyle.limitValues,
paletteColorLookupTable: mapping.defaultStyle.paletteColorLookupTable
}
}
/**
* Get the style of a parameter mapping.
*
* @param {string} mappingUID - Unique tracking identifier of mapping
* @returns {Object} Style Options
*/
getParameterMappingStyle (mappingUID) {
if (!(mappingUID in this[_mappings])) {
throw new Error(
'Cannot get style of mapping. ' +
`Could not find mapping "${mappingUID}".`
)
}
const mapping = this[_mappings][mappingUID]
return {
opacity: mapping.style.opacity,
limitValues: mapping.style.limitValues,
paletteColorLookupTable: mapping.style.paletteColorLookupTable
}
}
/**
* Get image metadata for a parameter mapping.
*
* @param {string} mappingUID - Unique tracking identifier of mapping
*
* @returns {metadata.ParametricMap[]} Metadata of DICOM Parametric Map
* instances
*/
getParameterMappingMetadata (mappingUID) {
if (!(mappingUID in this[_mappings])) {
throw new Error(
'Cannot get image metadata of mapping. ' +
`Could not find mapping "${mappingUID}".`
)
}
const mapping = this[_mappings][mappingUID]
return mapping.pyramid.metadata
}
/**
* Get all parameter mappings.
*
* @return {mapping.ParameterMapping[]}
*/
getAllParameterMappings () {
const mappings = []
for (const mappingUID in this[_mappings]) {
mappings.push(this[_mappings][mappingUID].mapping)
}
return mappings
}
}
/**
* Static viewer for DICOM VL Whole Slide Microscopy Image instances
* with Image Type other than VOLUME.
*
* @class
* @abstract
* @private
*/
class _NonVolumeImageViewer {
/**
* @param {Object} options
* @param {Object} options.client - A DICOMwebClient instance for interacting
* with an origin server over HTTP.
* @param {Object} options.clientMapping - Mapping of SOP Class UID to
* DICOMwebClient instance for interacting with more than one origin server
* over HTTP. The viewer will select the configured client for searching,
* retrieving, or storing data on a per SOP Class basis.
* @param {metadata.VLWholeSlideMicroscopyImage[]} options.metadata -
* Metadata of DICOM VL Whole Slide Microscopy Image instances
* @param {string} options.orientation - Orientation of the slide (vertical:
* label on top, or horizontal: label on right side).
* @param {number} [options.resizeFactor=1] - To which extent image should be
* reduced in size (fraction).
* @param {boolean} [options.includeIccProfile=false] - Whether ICC Profile
* should be included for correction of image colors.
*/
constructor (options) {
// We also accept metadata in raw JSON format for backwards compatibility
if (options.metadata.SOPClassUID != null) {
this[_metadata] = options.metadata
} else {
this[_metadata] = options.metadata.map(instance => {
return new VLWholeSlideMicroscopyImage({ metadata: instance })
})
}
const imageFlavor = this[_metadata].ImageType[2]
if (imageFlavor === 'VOLUME') {
throw new Error('Viewer cannot render images of type VOLUME.')
}
const resizeFactor = options.resizeFactor ? options.resizeFactor : 1
const height = this[_metadata].TotalPixelMatrixRows * resizeFactor
const width = this[_metadata].TotalPixelMatrixColumns * resizeFactor
const extent = [
0, // min X
-(height + 1), // min Y
width, // max X
-1 // max Y
]
const imageLoadFunction = (image, src) => {
console.info(`load ${imageFlavor} image`)
const mediaType = 'image/png'
const queryParams = {}
if (resizeFactor !== 1) {
queryParams.viewport = [width, height].join(',')
}
// We make this optional because ICC Profiles can be large and
// their inclusion can result in significant overhead.
if (options.includeIccProfile) {
queryParams.iccprofile = 'yes'
}
const retrieveOptions = {
studyInstanceUID: this[_metadata].StudyInstanceUID,
seriesInstanceUID: this[_metadata].SeriesInstanceUID,
sopInstanceUID: this[_metadata].SOPInstanceUID,
mediaTypes: [{ mediaType }],
queryParams
}
options.client.retrieveInstanceRendered(retrieveOptions).then(
(thumbnail) => {
const blob = new Blob([thumbnail], { type: mediaType })// eslint-disable-line
image.getImage().src = window.URL.createObjectURL(blob)
}
)
}
const projection = new Projection({
code: 'DICOM',
units: 'metric',
extent,
getPointResolution: (pixelRes, point) => {
/*
* DICOM Pixel Spacing has millimeter unit while the projection has
* meter unit.
*/
const mmSpacing = getPixelSpacing(this[_metadata])[0]
const spacing = mmSpacing / resizeFactor / 10 ** 3
return pixelRes * spacing
}
})
const source = new Static({
imageExtent: extent,
projection,
imageLoadFunction,
url: '' // will be set by imageLoadFunction()
})
this[_imageLayer] = new ImageLayer({ source })
// The default rotation is 'horizontal' with the slide label on the right
let rotation = _getRotation(this[_metadata])
if (options.orientation === 'vertical') {
// Rotate counterclockwise by 90 degrees to have slide label at the top
rotation -= 90 * (Math.PI / 180)
}
const view = new View({
center: getCenter(extent),
rotation,
projection,
extent,
smoothExtentConstraint: true,
smoothResolutionConstraint: true,
showFullExtent: true
})
// Creates the map with the defined layers and view and renders it.
this[_map] = new Map({
layers: [this[_imageLayer]],
view,
controls: [],
keyboardEventTarget: document
})
view.fit(projection.getExtent(), { size: this[_map].getSize() })
}
/**
* Clean up.
*
* Release allocated memory and clear the viewport.
*/
cleanup () {}
/**
* Render the image in the specified viewport container.
*
* @param {Object} options - Rendering options.
* @param {(string|HTMLElement)} options.container - HTML Element in which the viewer should be injected.
*/
render ({ container }) {
if (container == null) {
console.error('container must be provided for rendering images')
return
}
this[_map].setTarget(container)
const view = this[_map].getView()
const projection = view.getProjection()
view.fit(projection.getExtent(), { size: this[_map].getSize() })
this[_map].getInteractions().forEach((interaction) => {
this[_map].removeInteraction(interaction)
})
}
/**
* DICOM metadata for the displayed VL Whole Slide Microscopy Image instance.
*
* @type {VLWholeSlideMicroscopyImage}
*/
get imageMetadata () {
return this[_metadata]
}
/**
* Frame of Reference UID.
*
* @type string
*/
get frameOfReferenceUID () {
return this[_metadata].FrameOfReferenceUID
}
/**
* Resize the viewer to fit the viewport.
*
* @returns {void}
*/
resize () {
this[_map].updateSize()
if (this[_overviewMap]) {
this[_overviewMap].getOverviewMap().updateSize()
}
}
/**
* Size of the viewport.
*
* @type number[]
*/
get size () {
return this[_map].getSize()
}
}
/**
* Static viewer for DICOM VL Whole Slide Microscopy Image instances
* with Image Type OVERVIEW.
*
* @class
* @memberof viewer
*/
class OverviewImageViewer extends _NonVolumeImageViewer {
/**
* @param {Object} options
* @param {Object} options.client - A DICOMwebClient instance for interacting
* with an origin server over HTTP.
* @param {metadata.VLWholeSlideMicroscopyImage[]} options.metadata -
* Metadata of DICOM VL Whole Slide Microscopy Image instances
* @param {string} [options.orientation='horizontal'] - Orientation of the
* slide (vertical: label on top, or horizontal: label on right side).
* @param {number} [options.resizeFactor=1] - To which extent image should be
* reduced in size (fraction).
* @param {boolean} [options.includeIccProfile=false] - Whether ICC Profile
* should be included for correction of image colors.
*/
constructor (options) {
if (options.orientation === undefined) {
options.orientation = 'horizontal'
}
super(options)
}
}
/**
* Static viewer for DICOM VL Whole Slide Microscopy Image instances
* with Image Type LABEL.
*
* @class
* @memberof viewer
*/
class LabelImageViewer extends _NonVolumeImageViewer {
/**
* @param {Object} options - Options
* @param {Object} options.client - A DICOMwebClient instance for interacting with an origin server over HTTP
* @param {metadata.VLWholeSlideMicroscopyImage[]} options.metadata -
* Metadata of DICOM VL Whole Slide Microscopy Image instances
* @param {string} [options.orientation='vertical'] - Orientation of the
* slide (vertical: label on top, or horizontal: label on right side)
* @param {number} [options.resizeFactor=1] - To which extent image should be
* reduced in size (fraction)
* @param {boolean} [options.includeIccProfile=false] - Whether ICC Profile
* should be included for correction of image colors
*/
constructor (options) {
if (options.orientation === undefined) {
options.orientation = 'vertical'
}
super(options)
}
}
export { LabelImageViewer, OverviewImageViewer, VolumeImageViewer }