Skip to content

Commit c92964c

Browse files
fix palette color image loading (#2610)
* refactor the solution given * fix fetch color tables * important refactors to optimize worker functionality * refactor fetch palette data * fix clearly wrong test values * refactor as reviewer suggestions * fix: Convert 16 bit declared as 8 palette color data * fix: or condition on setting 16 bit palette data --------- Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
1 parent 34d0ecf commit c92964c

File tree

10 files changed

+193
-80
lines changed

10 files changed

+193
-80
lines changed
Lines changed: 70 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ByteArray } from 'dicom-parser';
2-
import { metaData, type Types } from '@cornerstonejs/core';
2+
import type { Types } from '@cornerstonejs/core';
3+
import { fetchLUTForInstance } from './fetchLUTForInstance';
34

45
function convertLUTto8Bit(lut: number[], shift: number) {
56
const numEntries = lut.length;
@@ -12,84 +13,46 @@ function convertLUTto8Bit(lut: number[], shift: number) {
1213
return cleanedLUT;
1314
}
1415

15-
function fetchPaletteData(imageFrame, color, fallback) {
16-
const data = imageFrame[`${color}PaletteColorLookupTableData`];
17-
if (data) {
18-
return Promise.resolve(data);
19-
}
20-
21-
const result = metaData.get('imagePixelModule', imageFrame.imageId);
22-
23-
if (result && typeof result.then === 'function') {
24-
return result.then((module) =>
25-
module ? module[`${color}PaletteColorLookupTableData`] : fallback
26-
);
27-
} else {
28-
return Promise.resolve(
29-
result ? result[`${color}PaletteColorLookupTableData`] : fallback
30-
);
31-
}
32-
}
33-
3416
/**
35-
* Convert pixel data with PALETTE COLOR Photometric Interpretation to RGBA
17+
* Convert pixel data with PALETTE COLOR Photometric Interpretation to RGB/RGBA
18+
* Note: Palette bulkdata must be loaded on the imageFrame before calling this function
3619
*
37-
* @param imageFrame - The ImageFrame to convert
20+
* @param imageFrame - The ImageFrame to convert (must have palette data loaded)
3821
* @param colorBuffer - The buffer to write the converted pixel data to
22+
* @param useRGBA - Whether to output RGBA (true) or RGB (false)
3923
* @returns
4024
*/
41-
export default function (
25+
export default function convertPaletteColor(
4226
imageFrame: Types.IImageFrame,
4327
colorBuffer: ByteArray,
4428
useRGBA: boolean
4529
): void {
4630
const numPixels = imageFrame.columns * imageFrame.rows;
4731
const pixelData = imageFrame.pixelData;
4832

49-
Promise.all([
50-
fetchPaletteData(imageFrame, 'red', null),
51-
fetchPaletteData(imageFrame, 'green', null),
52-
fetchPaletteData(imageFrame, 'blue', null),
53-
]).then(([rData, gData, bData]) => {
54-
if (!rData || !gData || !bData) {
55-
throw new Error(
56-
'The image does not have a complete color palette. R, G, and B palette data are required.'
57-
);
58-
}
33+
const rData = imageFrame.redPaletteColorLookupTableData;
34+
const gData = imageFrame.greenPaletteColorLookupTableData;
35+
const bData = imageFrame.bluePaletteColorLookupTableData;
5936

60-
const len = rData.length;
61-
let palIndex = 0;
62-
let bufferIndex = 0;
63-
64-
const start = imageFrame.redPaletteColorLookupTableDescriptor[1];
65-
const shift =
66-
imageFrame.redPaletteColorLookupTableDescriptor[2] === 8 ? 0 : 8;
67-
68-
const rDataCleaned = convertLUTto8Bit(rData, shift);
69-
const gDataCleaned = convertLUTto8Bit(gData, shift);
70-
const bDataCleaned = convertLUTto8Bit(bData, shift);
71-
72-
if (useRGBA) {
73-
for (let i = 0; i < numPixels; ++i) {
74-
let value = pixelData[palIndex++];
75-
76-
if (value < start) {
77-
value = 0;
78-
} else if (value > start + len - 1) {
79-
value = len - 1;
80-
} else {
81-
value -= start;
82-
}
83-
84-
colorBuffer[bufferIndex++] = rDataCleaned[value];
85-
colorBuffer[bufferIndex++] = gDataCleaned[value];
86-
colorBuffer[bufferIndex++] = bDataCleaned[value];
87-
colorBuffer[bufferIndex++] = 255;
88-
}
37+
if (!rData || !gData || !bData) {
38+
throw new Error(
39+
'The image does not have a complete color palette. R, G, and B palette data are required.'
40+
);
41+
}
8942

90-
return;
91-
}
43+
const len = rData.length;
44+
let palIndex = 0;
45+
let bufferIndex = 0;
46+
47+
const start = imageFrame.redPaletteColorLookupTableDescriptor[1];
48+
const bitsStored = imageFrame.redPaletteColorLookupTableDescriptor[2];
49+
const shift = bitsStored > 8 || rData.some((num) => num > 255) ? 8 : 0;
9250

51+
const rDataCleaned = convertLUTto8Bit(rData, shift);
52+
const gDataCleaned = convertLUTto8Bit(gData, shift);
53+
const bDataCleaned = convertLUTto8Bit(bData, shift);
54+
55+
if (useRGBA) {
9356
for (let i = 0; i < numPixels; ++i) {
9457
let value = pixelData[palIndex++];
9558

@@ -104,6 +67,48 @@ export default function (
10467
colorBuffer[bufferIndex++] = rDataCleaned[value];
10568
colorBuffer[bufferIndex++] = gDataCleaned[value];
10669
colorBuffer[bufferIndex++] = bDataCleaned[value];
70+
colorBuffer[bufferIndex++] = 255;
71+
}
72+
73+
return;
74+
}
75+
76+
for (let i = 0; i < numPixels; ++i) {
77+
let value = pixelData[palIndex++];
78+
79+
if (value < start) {
80+
value = 0;
81+
} else if (value > start + len - 1) {
82+
value = len - 1;
83+
} else {
84+
value -= start;
10785
}
108-
});
86+
87+
colorBuffer[bufferIndex++] = rDataCleaned[value];
88+
colorBuffer[bufferIndex++] = gDataCleaned[value];
89+
colorBuffer[bufferIndex++] = bDataCleaned[value];
90+
}
91+
}
92+
93+
/**
94+
* Convert pixel data with PALETTE COLOR Photometric Interpretation to RGB/RGBA
95+
* with automatic fetching of palette color lookup tables if not already loaded.
96+
* This is useful for users who want to convert palette color frames without
97+
* manually fetching the palette data first.
98+
*
99+
* @param imageFrame - The ImageFrame to convert
100+
* @param colorBuffer - The buffer to write the converted pixel data to
101+
* @param useRGBA - Whether to output RGBA (true) or RGB (false)
102+
* @returns Promise that resolves when conversion is complete
103+
*/
104+
export async function convertPaletteColorWithFetch(
105+
imageFrame: Types.IImageFrame,
106+
colorBuffer: ByteArray,
107+
useRGBA: boolean
108+
): Promise<void> {
109+
// Fetch LUT data if needed (palette, modality, VOI)
110+
await fetchLUTForInstance(imageFrame);
111+
112+
// Call the synchronous conversion function
113+
convertPaletteColor(imageFrame, colorBuffer, useRGBA);
109114
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Types } from '@cornerstonejs/core';
2+
import { metaData } from '@cornerstonejs/core';
3+
import { fetchPaletteData } from './fetchPaletteData';
4+
5+
/**
6+
* Fetches all necessary LUT data (palette, modality, VOI) for an image instance if not already loaded.
7+
* This function only returns a promise if there is actual LUT data to fetch from metadata.
8+
* TODO : Extend this to modality and VOI LUT fetching as needed.
9+
*
10+
* @param imageFrame - The ImageFrame to fetch LUT data for
11+
* @returns Promise that resolves when all LUT data is fetched, or void if no fetching needed
12+
*/
13+
export async function fetchLUTForInstance(
14+
imageFrame: Types.IImageFrame
15+
): Promise<void> | null {
16+
const fetchPromises = [];
17+
18+
// Check if palette color LUTs need to be fetched
19+
const needsPaletteLUT =
20+
!imageFrame.redPaletteColorLookupTableData ||
21+
!imageFrame.greenPaletteColorLookupTableData ||
22+
!imageFrame.bluePaletteColorLookupTableData;
23+
24+
if (needsPaletteLUT) {
25+
// Fetch palette data if not already loaded
26+
const palettePromises = Promise.all([
27+
fetchPaletteData(imageFrame, 'red', null),
28+
fetchPaletteData(imageFrame, 'green', null),
29+
fetchPaletteData(imageFrame, 'blue', null),
30+
]).then(([redData, greenData, blueData]) => {
31+
// Attach palette data to imageFrame
32+
imageFrame.redPaletteColorLookupTableData = redData;
33+
imageFrame.greenPaletteColorLookupTableData = greenData;
34+
imageFrame.bluePaletteColorLookupTableData = blueData;
35+
});
36+
37+
fetchPromises.push(palettePromises);
38+
}
39+
40+
// Only await if there are promises to fetch
41+
if (fetchPromises.length > 0) {
42+
await Promise.all(fetchPromises);
43+
}
44+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Types } from '@cornerstonejs/core';
2+
import { metaData } from '@cornerstonejs/core';
3+
4+
/**
5+
* Fetches the palette color lookup table data for a given color (red, green, or blue) from the image frame.
6+
* If the data is not present on the image frame, it attempts to fetch it from the metadata store.
7+
* @param imageFrame - The image frame containing palette information
8+
* @param color - The color channel to fetch ('red', 'green', or 'blue')
9+
* @param fallback - Value to return if palette data is not found
10+
* @returns Promise resolving to the palette color lookup table data or fallback value
11+
*/
12+
export function fetchPaletteData(
13+
imageFrame: Types.IImageFrame,
14+
color: 'red' | 'green' | 'blue',
15+
fallback
16+
) {
17+
const data = imageFrame[`${color}PaletteColorLookupTableData`];
18+
if (data) {
19+
return Promise.resolve(data);
20+
}
21+
22+
const result = metaData.get('imagePixelModule', imageFrame.imageId);
23+
24+
if (result && typeof result.then === 'function') {
25+
return result.then((module) =>
26+
module ? module[`${color}PaletteColorLookupTableData`] : fallback
27+
);
28+
} else {
29+
return Promise.resolve(
30+
result ? result[`${color}PaletteColorLookupTableData`] : fallback
31+
);
32+
}
33+
}

packages/dicomImageLoader/src/imageLoader/colorSpaceConverters/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@ import { default as convertRGBColorByPlane } from './convertRGBColorByPlane';
33
import { default as convertYBRFullByPixel } from './convertYBRFullByPixel';
44
import { default as convertYBRFullByPlane } from './convertYBRFullByPlane';
55
import { default as convertYBRFull422ByPixel } from './convertYBRFull422ByPixel';
6-
import { default as convertPALETTECOLOR } from './convertPALETTECOLOR';
6+
import {
7+
default as convertPaletteColor,
8+
convertPaletteColorWithFetch,
9+
} from './convertPALETTECOLOR';
10+
import { fetchLUTForInstance } from './fetchLUTForInstance';
711

812
export {
913
convertRGBColorByPixel,
1014
convertRGBColorByPlane,
1115
convertYBRFullByPixel,
1216
convertYBRFullByPlane,
1317
convertYBRFull422ByPixel,
14-
convertPALETTECOLOR,
18+
convertPaletteColor,
19+
convertPaletteColorWithFetch,
20+
fetchLUTForInstance,
1521
};

packages/dicomImageLoader/src/imageLoader/convertColorSpace.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
convertYBRFullByPixel,
55
convertYBRFull422ByPixel,
66
convertYBRFullByPlane,
7-
convertPALETTECOLOR,
7+
convertPaletteColor,
88
} from './colorSpaceConverters/index';
99

1010
function convertRGB(imageFrame, colorBuffer, useRGBA) {
@@ -39,7 +39,7 @@ export default function convertColorSpace(imageFrame, colorBuffer, useRGBA) {
3939
// for those cases.
4040
convertRGBColorByPixel(imageFrame.pixelData, colorBuffer, useRGBA);
4141
} else if (imageFrame.photometricInterpretation === 'PALETTE COLOR') {
42-
convertPALETTECOLOR(imageFrame, colorBuffer, useRGBA);
42+
convertPaletteColor(imageFrame, colorBuffer, useRGBA);
4343
} else if (imageFrame.photometricInterpretation === 'YBR_FULL_422') {
4444
convertYBRFull422ByPixel(imageFrame.pixelData, colorBuffer, useRGBA);
4545
} else if (imageFrame.photometricInterpretation === 'YBR_FULL') {

packages/dicomImageLoader/src/imageLoader/createImage.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ import isColorImageFn from '../shared/isColorImage';
1818
import removeAFromRGBA from './removeAFromRGBA';
1919
import isModalityLUTForDisplay from './isModalityLutForDisplay';
2020
import setPixelDataType from './setPixelDataType';
21+
import { fetchPaletteData } from './colorSpaceConverters/fetchPaletteData';
2122

2223
let lastImageIdDrawn = '';
2324

24-
function createImage(
25+
async function createImage(
2526
imageId: string,
2627
pixelData: ByteArray,
2728
transferSyntax: string,
@@ -51,6 +52,16 @@ function createImage(
5152

5253
options.allowFloatRendering = canRenderFloatTextures();
5354

55+
let redData, greenData, blueData;
56+
// For PALETTE COLOR images, ensure palette bulkdata is loaded before decoding
57+
if (imageFrame.photometricInterpretation === 'PALETTE COLOR') {
58+
[redData, greenData, blueData] = await Promise.all([
59+
fetchPaletteData(imageFrame, 'red', null),
60+
fetchPaletteData(imageFrame, 'green', null),
61+
fetchPaletteData(imageFrame, 'blue', null),
62+
]);
63+
}
64+
5465
// Get the scaling parameters from the metadata
5566
if (options.preScale.enabled) {
5667
const scalingParameters = getScalingParameters(metaData, imageId);
@@ -164,6 +175,14 @@ function createImage(
164175
metaData.get(MetadataModules.CALIBRATION, imageId) || {};
165176
const { rows, columns } = imageFrame;
166177

178+
// For PALETTE COLOR images, assign palette bulkdata after decoding
179+
// to avoid copying unnecessary memory to/from the worker
180+
if (imageFrame.photometricInterpretation === 'PALETTE COLOR') {
181+
imageFrame.redPaletteColorLookupTableData = redData;
182+
imageFrame.greenPaletteColorLookupTableData = greenData;
183+
imageFrame.bluePaletteColorLookupTableData = blueData;
184+
}
185+
167186
if (isColorImage) {
168187
if (isColorConversionRequired(imageFrame)) {
169188
canvas.height = imageFrame.rows;

packages/dicomImageLoader/src/imageLoader/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
convertRGBColorByPlane,
44
convertYBRFullByPixel,
55
convertYBRFullByPlane,
6-
convertPALETTECOLOR,
6+
convertPaletteColor,
7+
convertPaletteColorWithFetch,
78
} from './colorSpaceConverters/index';
89

910
import { default as wadouri } from './wadouri/index';
@@ -29,7 +30,8 @@ const cornerstoneDICOMImageLoader = {
2930
convertRGBColorByPlane,
3031
convertYBRFullByPixel,
3132
convertYBRFullByPlane,
32-
convertPALETTECOLOR,
33+
convertPaletteColor,
34+
convertPaletteColorWithFetch,
3335
wadouri,
3436
wadors,
3537
init,
@@ -54,7 +56,8 @@ export {
5456
convertRGBColorByPlane,
5557
convertYBRFullByPixel,
5658
convertYBRFullByPlane,
57-
convertPALETTECOLOR,
59+
convertPaletteColorWithFetch,
60+
convertPaletteColor,
5861
wadouri,
5962
wadors,
6063
init,

packages/dicomImageLoader/src/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
convertRGBColorByPlane,
44
convertYBRFullByPixel,
55
convertYBRFullByPlane,
6-
convertPALETTECOLOR,
6+
convertPaletteColor,
7+
convertPaletteColorWithFetch,
78
} from './imageLoader/colorSpaceConverters/index';
89

910
import { default as wadouri } from './imageLoader/wadouri/index';
@@ -32,7 +33,8 @@ const cornerstoneDICOMImageLoader = {
3233
convertRGBColorByPlane,
3334
convertYBRFullByPixel,
3435
convertYBRFullByPlane,
35-
convertPALETTECOLOR,
36+
convertPaletteColor,
37+
convertPaletteColorWithFetch,
3638
wadouri,
3739
wadors,
3840
init,
@@ -57,7 +59,8 @@ export {
5759
convertRGBColorByPlane,
5860
convertYBRFullByPixel,
5961
convertYBRFullByPlane,
60-
convertPALETTECOLOR,
62+
convertPaletteColor,
63+
convertPaletteColorWithFetch,
6164
wadouri,
6265
wadors,
6366
init,

0 commit comments

Comments
 (0)