From 53f06fe1e22cd9a78438bd247dc62fd0f05a1def Mon Sep 17 00:00:00 2001 From: Bart Diricx Date: Sun, 22 Jun 2025 13:50:58 +0200 Subject: [PATCH 1/4] Add support for dynamically configuring the dicomicc iccOutputType The original implementation assumes the display used to view the WSI images is using the sRGB color space. Now we first check if the display color space is sRGB or Display-P3 (a more recent wide-gamut color space). Based on this dicomicc is configured to either transform colors to the sRGB color space or to the Display-P3 color space. Prerequisite: approval of Pull Request to add support for Display-P3 in libdicomicc (https://github.com/ImagingDataCommons/libdicomicc/pull/6) --- package.json | 2 +- src/decode.js | 12 +++++++---- src/pyramid.js | 4 +++- src/viewer.js | 21 ++++++++++++++++++++ src/webWorker/decodeAndTransformTask.js | 5 +++-- src/webWorker/transformers/transformerICC.js | 8 ++++++-- 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 8ee01107..6f33fc17 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "dicomweb-client": "^0.10.3", "colormap": "^2.3", "dcmjs": "^0.38.1", - "dicomicc": "^0.1", + "dicomicc": "^0.2", "image-type": "^4.1", "mathjs": "^11.2", "ol": "^10.4.0", diff --git a/src/decode.js b/src/decode.js index 0b0fb478..6bba1b07 100644 --- a/src/decode.js +++ b/src/decode.js @@ -10,7 +10,8 @@ function _processDecodeAndTransformTask ( samplesPerPixel, sopInstanceUID, metadata, - iccProfiles + iccProfiles, + iccOutputType = "srgb" // "srgb" or "display-p3" ) { const priority = undefined const transferList = undefined @@ -26,7 +27,8 @@ function _processDecodeAndTransformTask ( samplesPerPixel, sopInstanceUID, metadata, - iccProfiles + iccProfiles, + iccOutputType }, priority, transferList @@ -42,7 +44,8 @@ async function _decodeAndTransformFrame ({ samplesPerPixel, sopInstanceUID, metadata, // metadata of all images (different resolution levels) - iccProfiles // ICC profiles for all images + iccProfiles, // ICC profiles for all images + iccOutputType = "srgb" // "srgb" or "display-p3" }) { const result = await _processDecodeAndTransformTask( frame, @@ -53,7 +56,8 @@ async function _decodeAndTransformFrame ({ samplesPerPixel, sopInstanceUID, metadata, - iccProfiles + iccProfiles, + iccOutputType ) const signed = pixelRepresentation === 1 diff --git a/src/pyramid.js b/src/pyramid.js index 035f0f30..26625b4b 100644 --- a/src/pyramid.js +++ b/src/pyramid.js @@ -362,6 +362,7 @@ function _createTileLoadFunction ({ client, channel, iccProfiles, + iccOutputType, targetElement }) { return async (z, y, x) => { @@ -507,7 +508,8 @@ function _createTileLoadFunction ({ samplesPerPixel, sopInstanceUID, metadata: pyramid.metadata, - iccProfiles + iccProfiles, + iccOutputType }).then(pixelArray => { if (pixelArray.constructor === Float64Array) { // TODO: handle Float64Array using LUT diff --git a/src/viewer.js b/src/viewer.js index c89a0b74..8b974e96 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -769,6 +769,7 @@ const _tileGrid = Symbol('tileGrid') const _updateOverviewMapSize = Symbol('updateOverviewMapSize') const _annotationOptions = Symbol('annotationOptions') const _isICCProfilesEnabled = Symbol('isICCProfilesEnabled') +const _iccOutputType = Symbol('_iccOutputType') const _iccProfiles = Symbol('iccProfiles') const _container = Symbol('container') @@ -819,6 +820,7 @@ class VolumeImageViewer { this[_clients] = {} this[_errorInterceptor] = options.errorInterceptor || (error => error) this[_isICCProfilesEnabled] = true + this[_iccOutputType] = "srgb" this[_container] = null this[_clients] = {} this[_iccProfiles] = [] @@ -1499,6 +1501,24 @@ class VolumeImageViewer { view.fit(this[_projection].getExtent(), { size: this[_map].getSize() }) + /** + * Detect the display color space. + * @returns {string} 'display-p3' or 'srgb' + */ + function detectDisplayColorSpace() { + if (typeof window !== 'undefined' && window.matchMedia) { + if (window.matchMedia("(color-gamut: p3)").matches) { + return 'display-p3'; + } else if (window.matchMedia("(color-gamut: srgb)").matches) { + return 'srgb'; + } + } + return 'srgb'; + } + + this[_iccOutputType] = detectDisplayColorSpace(); + console.log(`Detected display color space: "${this[_iccOutputType]}"`); + /** * OpenLayer's map has default active interactions. * We need to reuse them here to avoid duplications. @@ -2169,6 +2189,7 @@ class VolumeImageViewer { const loaderWithICCProfiles = _createTileLoadFunction({ targetElement: this[_container], iccProfiles: profiles, + iccOutputType: this[_iccOutputType], ...item.loaderParams }) const loaderWithoutICCProfiles = _createTileLoadFunction({ diff --git a/src/webWorker/decodeAndTransformTask.js b/src/webWorker/decodeAndTransformTask.js index 7ce493a4..6bb867c6 100644 --- a/src/webWorker/decodeAndTransformTask.js +++ b/src/webWorker/decodeAndTransformTask.js @@ -27,7 +27,8 @@ function _handler (data, doneCallback) { frame, sopInstanceUID, metadata, - iccProfiles + iccProfiles, + iccOutputType = "srgb" // "srgb" or "display-p3" } = data.data _checkImageTypeAndDecode( @@ -43,7 +44,7 @@ function _handler (data, doneCallback) { if (iccProfiles != null && iccProfiles.length > 0) { // Only instantiate the transformer once and cache it for reuse. if (transformerColor === undefined) { - transformerColor = new ColorTransformer(metadata, iccProfiles) + transformerColor = new ColorTransformer(metadata, iccProfiles, iccOutputType) } // Apply ICC color transform transformerColor.transform( diff --git a/src/webWorker/transformers/transformerICC.js b/src/webWorker/transformers/transformerICC.js index 8aa56bb3..252b6f12 100644 --- a/src/webWorker/transformers/transformerICC.js +++ b/src/webWorker/transformers/transformerICC.js @@ -9,8 +9,9 @@ export default class ColorTransformer extends Transformer { * @param {Array} - Metadata of each * image * @param {Array} - ICC profiles of each image + * @param {number} [iccOutputType="srgb"] - ICC output type ("srgb": sRGB (default) or "display-p3": Display-P3). */ - constructor (metadata, iccProfiles) { + constructor (metadata, iccProfiles, iccOutputType = "srgb") { super() if (metadata.length !== iccProfiles.length) { throw new Error( @@ -22,6 +23,8 @@ export default class ColorTransformer extends Transformer { this.iccProfiles = iccProfiles this.codec = null this.transformers = {} + // ColorManager ICC output type: 0: sRGB, 1: Display-P3 + this.iccOutputType = iccOutputType === "display-p3" ? 1 : 0; } _initialize () { @@ -58,7 +61,8 @@ export default class ColorTransformer extends Transformer { samplesPerPixel, planarConfiguration }, - profile + profile, + this.iccOutputType ) } resolve(this.transformers) From ebdd2ff59640afbe08342472058ec8a625f1778d Mon Sep 17 00:00:00 2001 From: Bart Diricx Date: Tue, 24 Jun 2025 10:56:49 +0200 Subject: [PATCH 2/4] Also set the WebGL drawingBufferColorSpace to the correct color space --- src/viewer.js | 61 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/src/viewer.js b/src/viewer.js index 8b974e96..3bdad0ec 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -1113,6 +1113,24 @@ class VolumeImageViewer { extent: this[_pyramid].extent }) + /** + * Detect the display color space. + * @returns {string} 'display-p3' or 'srgb' + */ + function detectDisplayColorSpace() { + if (typeof window !== 'undefined' && window.matchMedia) { + if (window.matchMedia("(color-gamut: p3)").matches) { + return 'display-p3'; + } else if (window.matchMedia("(color-gamut: srgb)").matches) { + return 'srgb'; + } + } + return 'srgb'; + } + + this[_iccOutputType] = detectDisplayColorSpace(); + console.log(`Detected display color space: "${this[_iccOutputType]}"`); + const layers = [] const overviewLayers = [] this[_opticalPaths] = {} @@ -1261,7 +1279,11 @@ class VolumeImageViewer { }) opticalPath.layer.helper = helper opticalPath.layer.on('precompose', (event) => { - const gl = event.context + const gl = event.context; + if ('drawingBufferColorSpace' in gl) { + gl.drawingBufferColorSpace = this[_iccOutputType] + console.debug("Using color space - layer:", gl.drawingBufferColorSpace) + } gl.enable(gl.BLEND) gl.blendEquation(gl.FUNC_ADD) gl.blendFunc(gl.SRC_COLOR, gl.ONE) @@ -1283,6 +1305,10 @@ class VolumeImageViewer { opticalPath.overviewLayer.helper = overviewHelper opticalPath.overviewLayer.on('precompose', (event) => { const gl = event.context + if ('drawingBufferColorSpace' in gl) { + gl.drawingBufferColorSpace = this[_iccOutputType] + console.debug("Using color space - overviewLayer:", gl.drawingBufferColorSpace) + } gl.enable(gl.BLEND) gl.blendEquation(gl.FUNC_ADD) gl.blendFunc(gl.SRC_COLOR, gl.ONE) @@ -1347,6 +1373,14 @@ class VolumeImageViewer { useInterimTilesOnError: false, cacheSize: this[_options].tilesCacheSize }) + opticalPath.layer.on('precompose', (event) => { + const gl = event.context; + if ('drawingBufferColorSpace' in gl) { + gl.drawingBufferColorSpace = this[_iccOutputType] + console.debug("Using color space - layer:", gl.drawingBufferColorSpace) + } + }) + opticalPath.layer.on('error', (event) => { console.error( `error rendering optical path "${opticalPathIdentifier}"`, @@ -1359,6 +1393,13 @@ class VolumeImageViewer { preload: 0, useInterimTilesOnError: false }) + opticalPath.overviewLayer.on('precompose', (event) => { + const gl = event.context; + if ('drawingBufferColorSpace' in gl) { + gl.drawingBufferColorSpace = this[_iccOutputType] + console.debug("Using color space - overviewLayer:", gl.drawingBufferColorSpace) + } + }) layers.push(opticalPath.layer) overviewLayers.push(opticalPath.overviewLayer) @@ -1501,24 +1542,6 @@ class VolumeImageViewer { view.fit(this[_projection].getExtent(), { size: this[_map].getSize() }) - /** - * Detect the display color space. - * @returns {string} 'display-p3' or 'srgb' - */ - function detectDisplayColorSpace() { - if (typeof window !== 'undefined' && window.matchMedia) { - if (window.matchMedia("(color-gamut: p3)").matches) { - return 'display-p3'; - } else if (window.matchMedia("(color-gamut: srgb)").matches) { - return 'srgb'; - } - } - return 'srgb'; - } - - this[_iccOutputType] = detectDisplayColorSpace(); - console.log(`Detected display color space: "${this[_iccOutputType]}"`); - /** * OpenLayer's map has default active interactions. * We need to reuse them here to avoid duplications. From d9b8ad74c2c35800ecf98522aa42af05df53925b Mon Sep 17 00:00:00 2001 From: Bart Diricx Date: Thu, 7 Aug 2025 22:18:46 +0200 Subject: [PATCH 3/4] Bugfix: the iccOutputType was not set for all required occurences of _createTileLoadFunction --- src/viewer.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/viewer.js b/src/viewer.js index 372182bc..04ab3339 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -1128,6 +1128,8 @@ class VolumeImageViewer { /** * Detect the display color space. + * Note: The WebGLRenderingContext only supports sRGB and Display-P3 + * color spaces, Adobe RGB (1998) and ROMM RGB are not supported. * @returns {string} 'display-p3' or 'srgb' */ function detectDisplayColorSpace() { @@ -2327,6 +2329,7 @@ class VolumeImageViewer { const loader = _createTileLoadFunction({ targetElement: container, iccProfiles: profiles, + iccOutputType: this[_iccOutputType], ...opticalPath.loaderParams }) const source = opticalPath.layer.getSource() @@ -2497,6 +2500,7 @@ class VolumeImageViewer { const loader = _createTileLoadFunction({ targetElement: container, iccProfiles: this[_isICCProfilesEnabled] && profiles.length > 0 ? profiles : null, + iccOutputType: this[_isICCProfilesEnabled] && profiles.length > 0 ? this[_iccOutputType] : null, ...item.loaderParams }) source.setLoader(loader) From fab8da75d74fe09380c0a44e6124723fb1089a46 Mon Sep 17 00:00:00 2001 From: Bart Diricx Date: Thu, 7 Aug 2025 22:21:22 +0200 Subject: [PATCH 4/4] Leverage newly exposed libdicomicc DcmIccOutputType, to avoid hardcoded types --- src/webWorker/transformers/transformerICC.js | 27 +++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/webWorker/transformers/transformerICC.js b/src/webWorker/transformers/transformerICC.js index 252b6f12..88f12262 100644 --- a/src/webWorker/transformers/transformerICC.js +++ b/src/webWorker/transformers/transformerICC.js @@ -9,7 +9,8 @@ export default class ColorTransformer extends Transformer { * @param {Array} - Metadata of each * image * @param {Array} - ICC profiles of each image - * @param {number} [iccOutputType="srgb"] - ICC output type ("srgb": sRGB (default) or "display-p3": Display-P3). + * @param {number} [iccOutputType="srgb"] - ICC output type + * ("srgb": sRGB (default), "display-p3": Display-P3, "adobe-rgb": Adobe RGB (1998), "romm-rgb": ROMM RGB). */ constructor (metadata, iccProfiles, iccOutputType = "srgb") { super() @@ -23,8 +24,7 @@ export default class ColorTransformer extends Transformer { this.iccProfiles = iccProfiles this.codec = null this.transformers = {} - // ColorManager ICC output type: 0: sRGB, 1: Display-P3 - this.iccOutputType = iccOutputType === "display-p3" ? 1 : 0; + this.iccOutputTypeString = iccOutputType; } _initialize () { @@ -53,6 +53,25 @@ export default class ColorTransformer extends Transformer { const planarConfiguration = this.metadata[index].PlanarConfiguration const sopInstanceUID = this.metadata[index].SOPInstanceUID const profile = this.iccProfiles[index] + + // Determine ICC output type using the exposed enum + let iccOutputType + switch (this.iccOutputTypeString) { + case "display-p3": + iccOutputType = this.codec.DcmIccOutputType.DISPLAY_P3 + break + case "adobe-rgb": + iccOutputType = this.codec.DcmIccOutputType.ADOBE_RGB + break + case "romm-rgb": + iccOutputType = this.codec.DcmIccOutputType.ROMM_RGB + break + case "srgb": + default: + iccOutputType = this.codec.DcmIccOutputType.SRGB + break + } + this.transformers[sopInstanceUID] = new this.codec.ColorManager( { columns, @@ -62,7 +81,7 @@ export default class ColorTransformer extends Transformer { planarConfiguration }, profile, - this.iccOutputType + iccOutputType ) } resolve(this.transformers)