UNPKG

dicom-microscopy-viewer

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