import * as dwc from 'dicomweb-client'
import { _decodeAndTransformFrame } from './decode.js'
import EVENT from './events'
import publish from './eventPublisher'
import { getFrameMapping, VLWholeSlideMicroscopyImage } from './metadata.js'
import { getPixelSpacing } from './scoord3dUtils'
import { are1DArraysAlmostEqual, are2DArraysAlmostEqual, _fetchBulkdata } from './utils.js'
/**
* Get Image ICC profiles.
*
* @param {Array<metadata.VLWholeSlideMicroscopyImage>} pyramid - Metadata of
* VL Whole Slide Microscopy Image instances
* @param {object} client - dicom web client
*
* @returns {Promise<Array<TypedArray>>} image array with ICC profiles
*
* @private
*/
async function _getIccProfiles (metadata, client) {
const profiles = []
for (let i = 0; i < metadata.length; i++) {
const image = metadata[i]
if (image.SamplesPerPixel === 3) {
if (image.bulkdataReferences.OpticalPathSequence == null) {
console.warn(
`no ICC Profile was not found for image "${image.SOPInstanceUID}"`
)
continue
}
const bulkdata = await _fetchBulkdata({
client,
reference: (
image
.bulkdataReferences
.OpticalPathSequence[0]
.ICCProfile
)
})
profiles.push(bulkdata)
}
}
return profiles
}
/**
* Compute image pyramid.
*
* @param {object[]} metadata - Metadata of VL Whole Slide Microscopy Image instances
* @returns {object} Information about the image pyramid
*
* @private
*/
function _computeImagePyramid ({ metadata }) {
if (metadata.length === 0) {
throw new Error(
'No image metadata was provided to computate image pyramid structure.'
)
}
// Sort instances and optionally concatenation parts if present.
metadata.sort((a, b) => {
const sizeDiff = a.TotalPixelMatrixColumns - b.TotalPixelMatrixColumns
if (sizeDiff === 0) {
if (a.ConcatenationFrameOffsetNumber !== undefined) {
return a.ConcatenationFrameOffsetNumber - b.ConcatenationFrameOffsetNumber
}
return sizeDiff
}
return sizeDiff
})
const pyramidMetadata = []
const pyramidFrameMappings = []
let pyramidNumberOfChannels
for (let i = 0; i < metadata.length; i++) {
if (metadata[0].FrameOfReferenceUID !== metadata[i].FrameOfReferenceUID) {
throw new Error(
'Images of pyramid must all have the same Frame of Reference UID.'
)
}
if (metadata[0].ContainerIdentifier !== metadata[i].ContainerIdentifier) {
throw new Error(
'Images of pyramid must all have the same Container Identifier.'
)
}
const numberOfFrames = Number(metadata[i].NumberOfFrames || 1)
const cols = metadata[i].TotalPixelMatrixColumns || metadata[i].Columns
const rows = metadata[i].TotalPixelMatrixRows || metadata[i].Rows
const { frameMapping, numberOfChannels } = getFrameMapping(metadata[i])
if (i > 0) {
if (pyramidNumberOfChannels !== numberOfChannels) {
throw new Error(
'Images of pyramid must all have the same number of channels ' +
'(optical paths, segments, mappings, etc.)'
)
}
} else {
pyramidNumberOfChannels = numberOfChannels
}
/*
* Instances may be broken down into multiple concatentation parts.
* Therefore, we have to re-assemble instance metadata.
*/
let alreadyExists = false
let index = null
for (let j = 0; j < pyramidMetadata.length; j++) {
const c = (
pyramidMetadata[j].TotalPixelMatrixColumns ||
pyramidMetadata[j].Columns
)
const r = (
pyramidMetadata[j].TotalPixelMatrixRows ||
pyramidMetadata[j].Rows
)
if (r === rows && c === cols) {
alreadyExists = true
index = j
}
}
if (alreadyExists) {
Object.assign(pyramidFrameMappings[index], frameMapping)
/*
* Create a new SOP Instance with metadata updated from current
* concatentation part.
*/
const rawMetadata = pyramidMetadata[index].json
rawMetadata['00280008'].Value[0] += numberOfFrames
if ('PerFrameFunctionalGroupsSequence' in metadata[index]) {
rawMetadata['52009230'].Value.push(
...metadata[i].PerFrameFunctionalGroupsSequence
)
}
if (!('SOPInstanceUIDOfConcatenationSource' in metadata[i])) {
throw new Error(
'Multiple image instances for the same channel and ' +
'focal plane have identical dimensions, but the instances ' +
'are not part of a concatenation either. ' +
'The image metadata is probably incorrect.'
)
}
const sopInstanceUID = metadata[i].SOPInstanceUIDOfConcatenationSource
rawMetadata['00080018'].Value[0] = sopInstanceUID
delete rawMetadata['00200242'] // SOPInstanceUIDOfConcatenationSource
delete rawMetadata['00209161'] // ConcatentationUID
delete rawMetadata['00209162'] // InConcatenationNumber
delete rawMetadata['00209228'] // ConcatenationFrameOffsetNumber
pyramidMetadata[index] = new VLWholeSlideMicroscopyImage({
metadata: rawMetadata
})
} else {
pyramidMetadata.push(metadata[i])
pyramidFrameMappings.push(frameMapping)
}
}
const nLevels = pyramidMetadata.length
if (nLevels === 0) {
console.error('empty pyramid - no levels found')
}
const pyramidBaseMetadata = pyramidMetadata[nLevels - 1]
/*
* Collect relevant information from DICOM metadata for each pyramid
* level to construct the Openlayers map.
*/
const pyramidTileSizes = []
const pyramidGridSizes = []
const pyramidResolutions = []
const pyramidOrigins = []
const pyramidPixelSpacings = []
const pyramidImageSizes = []
const pyramidPhysicalSizes = []
const offset = [0, -1]
const baseTotalPixelMatrixColumns = pyramidBaseMetadata.TotalPixelMatrixColumns
const baseTotalPixelMatrixRows = pyramidBaseMetadata.TotalPixelMatrixRows
for (let j = (nLevels - 1); j >= 0; j--) {
const columns = pyramidMetadata[j].Columns
const rows = pyramidMetadata[j].Rows
const totalPixelMatrixColumns = pyramidMetadata[j].TotalPixelMatrixColumns
const totalPixelMatrixRows = pyramidMetadata[j].TotalPixelMatrixRows
const pixelSpacing = getPixelSpacing(pyramidMetadata[j])
const nColumns = Math.ceil(totalPixelMatrixColumns / columns)
const nRows = Math.ceil(totalPixelMatrixRows / rows)
pyramidTileSizes.push([
columns,
rows
])
pyramidGridSizes.push([
nColumns,
nRows
])
pyramidPixelSpacings.push(pixelSpacing)
pyramidImageSizes.push([
totalPixelMatrixColumns,
totalPixelMatrixRows
])
pyramidPhysicalSizes.push([
(totalPixelMatrixColumns * pixelSpacing[1]).toFixed(4),
(totalPixelMatrixRows * pixelSpacing[0]).toFixed(4)
])
/*
* Compute the resolution at each pyramid level, since the zoom
* factor may not be the same between adjacent pyramid levels.
*/
const zoomFactor = Math.round(
baseTotalPixelMatrixColumns / totalPixelMatrixColumns
)
pyramidResolutions.push(zoomFactor)
pyramidOrigins.push(offset)
}
pyramidResolutions.reverse()
pyramidTileSizes.reverse()
pyramidGridSizes.reverse()
pyramidOrigins.reverse()
pyramidPixelSpacings.reverse()
pyramidImageSizes.reverse()
pyramidPhysicalSizes.reverse()
const uniquePhysicalSizes = [
...new Set(pyramidPhysicalSizes.map(v => v.toString()))
].map(v => v.split(','))
if (uniquePhysicalSizes.length > 1) {
console.warn(
'images of the image pyramid have different sizes: ',
'\nsize [mm]: ', pyramidPhysicalSizes,
'\npixel spacing [mm]: ', pyramidPixelSpacings,
'\nsize [pixels]: ', pyramidImageSizes,
'\ntile size [pixels]: ', pyramidTileSizes,
'\ntile grid size [tiles]: ', pyramidGridSizes,
'\nresolution [factors]: ', pyramidResolutions
)
}
/**
* Frames may extend beyond the size of the total pixel matrix.
* The excess pixels may contain garbage and should not be displayed.
* We set the extent to the size of the actual image without taken
* excess pixels into account.
* Note that the vertical axis is flipped in the used tile source,
* i.e., values on the axis lie in the range [-n, -1], where n is the
* number of rows in the total pixel matrix.
*/
const extent = [
0, // min X
-(baseTotalPixelMatrixRows + 1), // min Y
baseTotalPixelMatrixColumns, // max X
-1 // max Y
]
return {
extent,
origins: pyramidOrigins,
resolutions: pyramidResolutions,
gridSizes: pyramidGridSizes,
tileSizes: pyramidTileSizes,
pixelSpacings: pyramidPixelSpacings,
metadata: pyramidMetadata,
frameMappings: pyramidFrameMappings,
numberOfChannels: pyramidNumberOfChannels
}
}
function _areImagePyramidsEqual (pyramid, refPyramid) {
// Check that all the channels have the same pyramid parameters
if (!are2DArraysAlmostEqual(pyramid.extent, refPyramid.extent)) {
console.warn(
'pyramid has different extent as reference pyramid: ',
pyramid.extent,
refPyramid.extent
)
return false
}
if (!are2DArraysAlmostEqual(pyramid.origins, refPyramid.origins)) {
console.warn(
'pyramid has different origins as reference pyramid: ',
pyramid.origins,
refPyramid.origins
)
return false
}
if (!are2DArraysAlmostEqual(pyramid.resolutions, refPyramid.resolutions)) {
console.warn(
'pyramid has different resolutions as reference pyramid: ',
pyramid.resolutions,
refPyramid.resolutions
)
return false
}
if (!are2DArraysAlmostEqual(pyramid.gridSizes, refPyramid.gridSizes)) {
console.warn(
'pyramid has different grid sizes as reference pyramid: ',
pyramid.gridSizes,
refPyramid.gridSizes
)
return false
}
if (!are2DArraysAlmostEqual(pyramid.tileSizes, refPyramid.tileSizes)) {
console.warn(
'pyramid has different tile sizes as reference pyramid: ',
pyramid.tileSizes,
refPyramid.tileSizes
)
return false
}
if (!are2DArraysAlmostEqual(pyramid.pixelSpacings, refPyramid.pixelSpacings)) {
console.warn(
'pyramid has different pixel spacings as reference pyramid: ',
pyramid.pixelSpacings,
refPyramid.pixelSpacings
)
return false
}
return true
}
function _createEmptyTile ({
columns,
rows,
samplesPerPixel,
bitsAllocated,
photometricInterpretation
}) {
let pixelArray
if (bitsAllocated <= 8) {
pixelArray = new Uint8Array(columns * rows * samplesPerPixel)
} else {
pixelArray = new Float32Array(columns * rows * samplesPerPixel)
}
// Fill white in case of color and black in case of monochrome.
let fillValue = Math.pow(2, bitsAllocated) - 1
if (photometricInterpretation === 'MONOCHROME2') {
if (bitsAllocated <= 16) {
fillValue = 0
} else {
// Float pixel data
fillValue = -(Math.pow(2, bitsAllocated) - 1) / 2
}
}
for (let i = 0; i < pixelArray.length; i++) {
pixelArray[i] = fillValue
}
return pixelArray
}
function _createTileLoadFunction ({
pyramid,
client,
channel,
iccProfiles,
targetElement
}) {
return async (z, y, x) => {
let index = (x + 1) + '-' + (y + 1)
index += `-${channel}`
if (pyramid.metadata[z] === undefined) {
throw new Error(
`Could not load tile for channel "${channel}" ` +
`at position (${x + 1}, ${y + 1}) at zoom level ${z} ` +
` because level ${z} does not exist.`
)
}
const studyInstanceUID = pyramid.metadata[z].StudyInstanceUID
const seriesInstanceUID = pyramid.metadata[z].SeriesInstanceUID
const path = pyramid.frameMappings[z][index]
let src
if (path != null) {
src = ''
if (client.wadoURL !== undefined) {
src += client.wadoURL
}
src += (
'/studies/' + studyInstanceUID +
'/series/' + seriesInstanceUID +
'/instances/' + path
)
}
const refImage = pyramid.metadata[z]
const columns = refImage.Columns
const rows = refImage.Rows
const bitsAllocated = refImage.BitsAllocated
const pixelRepresentation = refImage.PixelRepresentation
const samplesPerPixel = refImage.SamplesPerPixel
const photometricInterpretation = refImage.PhotometricInterpretation
const sopClassUID = refImage.SOPClassUID
if (src != null) {
const sopInstanceUID = dwc.utils.getSOPInstanceUIDFromUri(src)
const frameNumbers = dwc.utils.getFrameNumbersFromUri(src)
if (samplesPerPixel === 1) {
console.info(
`retrieve frame ${frameNumbers} of monochrome image ` +
`for channel "${channel}" at tile position (${x + 1}, ${y + 1}) ` +
`at zoom level ${z}`
)
} else {
console.info(
`retrieve frame ${frameNumbers} of color image ` +
`at tile position (${x + 1}, ${y + 1}) at zoom level ${z}`
)
}
const jpegMediaType = 'image/jpeg'
const jpegTransferSyntaxUID = '1.2.840.10008.1.2.4.50'
const jlsMediaType = 'image/jls'
const jlsTransferSyntaxUIDlossless = '1.2.840.10008.1.2.4.80'
const jlsTransferSyntaxUID = '1.2.840.10008.1.2.4.81'
const jp2MediaType = 'image/jp2'
const jp2TransferSyntaxUIDlossless = '1.2.840.10008.1.2.4.90'
const jp2TransferSyntaxUID = '1.2.840.10008.1.2.4.91'
const jpxMediaType = 'image/jpx'
const jpxTransferSyntaxUIDlossless = '1.2.840.10008.1.2.4.92'
const jpxTransferSyntaxUID = '1.2.840.10008.1.2.4.93'
const octetStreamMediaType = 'application/octet-stream'
/*
* Use of the "*" transfer syntax is a hack to work around standard
* compliance issues of the Google Cloud Healthcare API.
* It will return bulkdata encoded with the transfer syntax of the
* stored data set (uncompressed or compressed). The decoder can then not
* rely on the media type specified by the "Content-Type" header in the
* response message, but will need to determine it from the payload.
*/
const octetStreamTransferSyntaxUID = '*'
const mediaTypes = []
mediaTypes.push(...[
{
mediaType: jlsMediaType,
transferSyntaxUID: jlsTransferSyntaxUIDlossless
},
{
mediaType: jlsMediaType,
transferSyntaxUID: jlsTransferSyntaxUID
},
{
mediaType: jp2MediaType,
transferSyntaxUID: jp2TransferSyntaxUIDlossless
},
{
mediaType: jp2MediaType,
transferSyntaxUID: jp2TransferSyntaxUID
},
{
mediaType: jpxMediaType,
transferSyntaxUID: jpxTransferSyntaxUIDlossless
},
{
mediaType: jpxMediaType,
transferSyntaxUID: jpxTransferSyntaxUID
},
{
mediaType: octetStreamMediaType,
transferSyntaxUID: octetStreamTransferSyntaxUID
}
])
if (bitsAllocated <= 8) {
mediaTypes.push({
mediaType: jpegMediaType,
transferSyntaxUID: jpegTransferSyntaxUID
})
}
const frameInfo = {
studyInstanceUID,
seriesInstanceUID,
sopInstanceUID,
sopClassUID,
frameNumber: frameNumbers[0],
channelIdentifier: String(channel)
}
publish(targetElement, EVENT.FRAME_LOADING_STARTED, frameInfo)
const retrieveOptions = {
studyInstanceUID,
seriesInstanceUID,
sopInstanceUID,
frameNumbers,
mediaTypes
}
return client.retrieveInstanceFrames(retrieveOptions).then(
(rawFrames) => {
return _decodeAndTransformFrame({
frame: rawFrames[0],
frameNumber: frameNumbers[0],
bitsAllocated,
pixelRepresentation,
columns,
rows,
samplesPerPixel,
sopInstanceUID,
metadata: pyramid.metadata,
iccProfiles
}).then(pixelArray => {
if (pixelArray.constructor === Float64Array) {
// TODO: handle Float64Array using LUT
throw new Error(
'Double Float Pixel Data is not (yet) supported.'
)
}
publish(
targetElement,
EVENT.FRAME_LOADING_ENDED,
{ pixelArray, ...frameInfo }
)
if (samplesPerPixel === 3 && bitsAllocated === 8) {
// Rendering of color images requires unsigned 8-bit integers
return pixelArray
}
// Rendering of grayscale images requires floating point values
return new Float32Array(
pixelArray,
pixelArray.byteOffset,
pixelArray.byteLength / pixelArray.BYTES_PER_ELEMENT
)
})
}
).catch(
(error) => {
publish(targetElement, EVENT.FRAME_LOADING_ENDED, frameInfo)
publish(targetElement, EVENT.FRAME_LOADING_ERROR, frameInfo)
return Promise.reject(
new Error(
`Failed to load frames ${frameNumbers} ` +
`of SOP instance "${sopInstanceUID}" ` +
`for channel "${channel}" ` +
`at tile position (${x + 1}, ${y + 1}) ` +
`at zoom level ${z}: `,
error
)
)
}
)
} else {
console.warn(
`could not load tile "${index}" at level ${z}, ` +
'this tile does not exist'
)
return _createEmptyTile({
columns,
rows,
samplesPerPixel,
bitsAllocated,
photometricInterpretation
})
}
}
}
function _fitImagePyramid (pyramid, refPyramid) {
const matchingLevelIndices = []
for (let i = 0; i < refPyramid.metadata.length; i++) {
for (let j = 0; j < pyramid.metadata.length; j++) {
const doOriginsMatch = are1DArraysAlmostEqual(
refPyramid.origins[i],
pyramid.origins[j]
)
const doPixelSpacingsMatch = are1DArraysAlmostEqual(
refPyramid.pixelSpacings[i],
pyramid.pixelSpacings[j]
)
if (doOriginsMatch && doPixelSpacingsMatch) {
matchingLevelIndices.push([i, j])
}
}
}
if (matchingLevelIndices.length === 0) {
console.error(pyramid, refPyramid)
throw new Error(
'Image pyramid cannot be fit to reference image pyramid.'
)
}
// Fit the pyramid levels to the reference image pyramid
const fittedPyramid = {
extent: [...refPyramid.extent],
origins: [],
resolutions: [],
gridSizes: [],
tileSizes: [],
pixelSpacings: [],
metadata: [],
frameMappings: []
}
for (let i = 0; i < refPyramid.metadata.length; i++) {
const index = matchingLevelIndices.find(element => element[0] === i)
if (index) {
const j = index[1]
fittedPyramid.origins.push([...pyramid.origins[j]])
fittedPyramid.gridSizes.push([...pyramid.gridSizes[j]])
fittedPyramid.tileSizes.push([...pyramid.tileSizes[j]])
fittedPyramid.resolutions.push(Number(refPyramid.resolutions[i]))
fittedPyramid.pixelSpacings.push([...pyramid.pixelSpacings[j]])
fittedPyramid.metadata.push(pyramid.metadata[j])
fittedPyramid.frameMappings.push(pyramid.frameMappings[j])
}
}
let minZoom = 0
for (let i = 0; i < refPyramid.resolutions.length; i++) {
for (let j = 0; j < fittedPyramid.resolutions.length; j++) {
if (refPyramid.resolutions[i] === fittedPyramid.resolutions[j]) {
minZoom = i
break
}
}
}
let maxZoom = refPyramid.resolutions.length - 1
for (let i = (refPyramid.resolutions.length - 1); i >= minZoom; i--) {
for (let j = (fittedPyramid.resolutions.length - 1); j >= 0; j--) {
if (refPyramid.resolutions[i] === fittedPyramid.resolutions[j]) {
maxZoom = i
break
}
}
}
return [fittedPyramid, minZoom, maxZoom]
}
export {
_areImagePyramidsEqual,
_computeImagePyramid,
_createTileLoadFunction,
_fitImagePyramid,
_getIccProfiles
}