Skip to content

Commit bfe37e4

Browse files
committed
fix: render color image thumbnails correctly
1 parent 8357125 commit bfe37e4

3 files changed

Lines changed: 263 additions & 56 deletions

File tree

‎src/core/thumbnailers/vtk-image.ts‎

Lines changed: 67 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,73 @@ import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
22
import type { TypedArray } from '@kitware/vtk.js/types';
33
import { ThumbnailSlice } from '.';
44

5-
function scalarImageToImageData(
6-
values: TypedArray,
7-
width: number,
8-
height: number,
9-
scaleMin: number,
10-
scaleMax: number
11-
) {
5+
type ThumbnailPixels = {
6+
values: TypedArray;
7+
width: number;
8+
height: number;
9+
components: number;
10+
pixelIndex: (x: number, y: number) => number;
11+
scaleMin: number;
12+
scaleMax: number;
13+
};
14+
15+
function colorChannelScale(values: TypedArray) {
16+
if (values instanceof Uint8Array || values instanceof Uint8ClampedArray) {
17+
return 1;
18+
}
19+
if (values instanceof Uint16Array) return 255 / 65535;
20+
return 255;
21+
}
22+
23+
function imageSliceToImageData({
24+
values,
25+
width,
26+
height,
27+
components,
28+
pixelIndex,
29+
scaleMin,
30+
scaleMax,
31+
}: ThumbnailPixels) {
1232
const im = new ImageData(width, height);
13-
const arr32 = new Uint32Array(im.data.buffer);
14-
// scale to 1 unsigned byte
1533
const factor = 255 / (scaleMax - scaleMin);
16-
for (let i = 0; i < values.length; i += 1) {
17-
const byte = Math.floor((values[i] - scaleMin) * factor);
18-
// ABGR order
19-
20-
arr32[i] = (255 << 24) | (byte << 16) | (byte << 8) | byte;
34+
const byteFactor = colorChannelScale(values);
35+
const toByte = (value: number) => {
36+
if (components >= 3) return value * byteFactor;
37+
if (scaleMax === scaleMin) return 0;
38+
return (value - scaleMin) * factor;
39+
};
40+
for (let y = 0; y < height; y += 1) {
41+
for (let x = 0; x < width; x += 1) {
42+
const source = pixelIndex(x, y) * components;
43+
const target = (y * width + x) * 4;
44+
const grayscale = components < 3;
45+
im.data[target] = toByte(values[source]);
46+
im.data[target + 1] = grayscale
47+
? im.data[target]
48+
: toByte(values[source + 1]);
49+
im.data[target + 2] = grayscale
50+
? im.data[target]
51+
: toByte(values[source + 2]);
52+
im.data[target + 3] =
53+
components === 2 || components === 4
54+
? values[source + components - 1] * byteFactor
55+
: 255;
56+
}
2157
}
2258

2359
return im;
2460
}
2561

26-
/**
27-
* Generates a thumbnail given an image data.
28-
*
29-
* Assumption: image is comprised of single-component scalars
30-
*/
62+
/** Generates a thumbnail from one image plane. */
3163
function generateThumbnail(
3264
imageData: vtkImageData,
3365
axis: 0 | 1 | 2 = 2,
3466
whichSlice = ThumbnailSlice.Middle
3567
) {
3668
const scalars = imageData.getPointData().getScalars();
3769
const data = scalars.getData() as TypedArray;
38-
const dataRange = scalars.getRange();
70+
const components = scalars.getNumberOfComponents();
71+
const [scaleMin, scaleMax] = scalars.getRange(0);
3972
const dims = imageData.getDimensions();
4073

4174
// ThumbnailSlice.First
@@ -46,52 +79,30 @@ function generateThumbnail(
4679
slice = dims[axis] - 1;
4780
}
4881

49-
let sliceData: TypedArray;
5082
let width: number;
5183
let height: number;
84+
let pixelIndex: (x: number, y: number) => number;
5285

5386
if (axis === 0) {
54-
// work-around for typing data.constructor.
55-
// data is not necessarily of type Uint8Array.
56-
sliceData = new (<Uint8ArrayConstructor>data.constructor)(
57-
dims[1] * dims[2]
58-
);
5987
[, width, height] = dims;
60-
for (let k = 0; k < dims[2]; k++) {
61-
for (let j = 0; j < dims[1]; j++) {
62-
const index = slice + j * dims[0] + k * dims[0] * dims[1];
63-
const offset = k * dims[1] + j;
64-
sliceData[offset] = data[index];
65-
}
66-
}
88+
pixelIndex = (x, y) => slice + x * dims[0] + y * dims[0] * dims[1];
6789
} else if (axis === 1) {
68-
sliceData = new (<Uint8ArrayConstructor>data.constructor)(
69-
dims[0] * dims[2]
70-
);
7190
[width, , height] = dims;
72-
for (let k = 0; k < dims[2]; k++) {
73-
for (let i = 0; i < dims[0]; i++) {
74-
const index = i + slice * dims[0] + k * dims[0] * dims[1];
75-
const offset = k * dims[0] + i;
76-
sliceData[offset] = data[index];
77-
}
78-
}
79-
} else if (axis === 2) {
91+
pixelIndex = (x, y) => x + slice * dims[0] + y * dims[0] * dims[1];
92+
} else {
8093
[width, height] = dims;
81-
const skip = dims[0] * dims[1];
82-
const sliceOffset = slice * skip;
83-
sliceData = Array.isArray(data)
84-
? data.slice(sliceOffset, sliceOffset + skip)
85-
: data.subarray(sliceOffset, sliceOffset + skip);
94+
pixelIndex = (x, y) => x + y * dims[0] + slice * dims[0] * dims[1];
8695
}
8796

88-
return scalarImageToImageData(
89-
sliceData!,
90-
width!,
91-
height!,
92-
dataRange[0],
93-
dataRange[1]
94-
);
97+
return imageSliceToImageData({
98+
values: data,
99+
width,
100+
height,
101+
components,
102+
pixelIndex,
103+
scaleMin,
104+
scaleMax,
105+
});
95106
}
96107

97108
export function createVTKImageThumbnailer() {
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { TEMP_DIR } from '../../wdio.shared.conf';
4+
import { volViewPage } from '../pageobjects/volview.page';
5+
import { createRasterFixture } from './rasterThumbnailFixtures';
6+
import { writeManifestToFile } from './utils';
7+
8+
type RGB = [number, number, number];
9+
10+
async function openImages(names: string[], manifestName: string) {
11+
for (const name of names) {
12+
fs.writeFileSync(path.join(TEMP_DIR, name), createRasterFixture(name));
13+
}
14+
await writeManifestToFile(
15+
{ resources: names.map((name) => ({ url: `/tmp/${name}`, name })) },
16+
manifestName
17+
);
18+
await volViewPage.open(`?urls=[tmp/${manifestName}]`);
19+
}
20+
21+
async function thumbnailPixels(name: string, points: [number, number][]) {
22+
await browser.waitUntil(
23+
() =>
24+
browser.execute((imageName) => {
25+
const card = Array.from(
26+
document.querySelectorAll('.image-list-card')
27+
).find((element) => element.textContent?.includes(imageName));
28+
const image = card?.querySelector('img');
29+
return (
30+
image instanceof HTMLImageElement &&
31+
image.complete &&
32+
image.naturalWidth > 0
33+
);
34+
}, name),
35+
{ timeoutMsg: `Expected a rendered thumbnail for ${name}` }
36+
);
37+
38+
return browser.execute(
39+
(imageName, locations) => {
40+
const card = Array.from(
41+
document.querySelectorAll('.image-list-card')
42+
).find((element) => element.textContent?.includes(imageName));
43+
const image = card?.querySelector('img') as HTMLImageElement;
44+
const canvas = document.createElement('canvas');
45+
canvas.width = image.naturalWidth;
46+
canvas.height = image.naturalHeight;
47+
const context = canvas.getContext('2d')!;
48+
context.drawImage(image, 0, 0);
49+
return locations.map(
50+
([x, y]) =>
51+
Array.from(context.getImageData(x, y, 1, 1).data.slice(0, 3)) as RGB
52+
);
53+
},
54+
name,
55+
points
56+
);
57+
}
58+
59+
describe('raster image thumbnails', () => {
60+
it('shows RGB PNG and JPEG colors and a grayscale gradient', async () => {
61+
const names = [
62+
'rgb-thumbnail.png',
63+
'rgb-thumbnail.jpg',
64+
'gray-thumbnail.png',
65+
];
66+
await openImages(names, 'raster-thumbnail-manifest.json');
67+
const expected: RGB[] = [
68+
[128, 64, 64],
69+
[64, 128, 64],
70+
[192, 32, 64],
71+
[32, 192, 64],
72+
];
73+
for (const name of names.slice(0, 2)) {
74+
const colors = (await thumbnailPixels(name, [
75+
[25, 25],
76+
[75, 25],
77+
[25, 75],
78+
[75, 75],
79+
])) as RGB[];
80+
for (let pixel = 0; pixel < expected.length; pixel += 1) {
81+
for (let channel = 0; channel < 3; channel += 1) {
82+
expect(
83+
Math.abs(colors[pixel][channel] - expected[pixel][channel])
84+
).toBeLessThan(16);
85+
}
86+
}
87+
}
88+
89+
const [dark, light] = await thumbnailPixels(names[2], [
90+
[15, 50],
91+
[85, 50],
92+
]);
93+
expect(dark[0]).toBeLessThan(80);
94+
expect(light[0]).toBeGreaterThan(170);
95+
});
96+
97+
it('composites RGBA pixels into the displayed thumbnail', async () => {
98+
const name = 'rgba-thumbnail.png';
99+
await openImages([name], 'rgba-thumbnail-manifest.json');
100+
const [opaque, partial, transparent, yellow] = (await thumbnailPixels(
101+
name,
102+
[
103+
[25, 25],
104+
[75, 25],
105+
[25, 75],
106+
[75, 75],
107+
]
108+
)) as RGB[];
109+
110+
expect(opaque[0]).toBeGreaterThan(200);
111+
expect(opaque[1]).toBeLessThan(50);
112+
expect(opaque[2]).toBeLessThan(50);
113+
expect(partial[0]).toBeLessThan(30);
114+
expect(partial[1]).toBeGreaterThan(100);
115+
expect(partial[1]).toBeLessThan(160);
116+
expect(partial[2]).toBeLessThan(30);
117+
expect(transparent.every((channel) => channel < 30)).toBe(true);
118+
expect(yellow[0]).toBeGreaterThan(200);
119+
expect(yellow[1]).toBeGreaterThan(200);
120+
expect(yellow[2]).toBeLessThan(50);
121+
});
122+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import * as zlib from 'zlib';
2+
3+
const SIZE = 128;
4+
const RGB_JPEG_BASE64 =
5+
'/9j//gARTGF2YzU4LjEzNC4xMDAA/9sAQwAIBAQEBAQFBQUFBQUGBgYGBgYGBgYGBgYGBwcHCAgIBwcHBgYHBwgICAgJCQkICAgICQkKCgoMDAsLDg4OEREU/8QAUQABAQEAAAAAAAAAAAAAAAAAAAUGAQEAAwEBAAAAAAAAAAAAAAAABggEBQcQAQAAAAAAAAAAAAAAAAAAAAARAQAAAAAAAAAAAAAAAAAAAAD/wAARCACAAIADARIAAhIAAxIA/9oADAMBAAIRAxEAPwDJDIJAAAAAAAAAAAAAAAKYyiCAAAAAAAAAAAAAACYNQnYAAAAAAAAAAAAAApjKIIAAAAAAAAAAAAAAJg1CdgAAAAAAAAAAAAACmMoggAAAAAAAAAAAAAAmDUJ2AAAAAAAAAAAAAAKYyiCAAAAAAAAAAAAAACYNQ9oAAAAAAAAAAAAAAGvHFFbAAAAAAAAAAAAAABkB2hZMAAAAAAAAAAAAAAa8cUVsAAAAAAAAAAAAAAGQHaFkwAAAAAAAAAAAAABrxxRWwAAAAAAAAAAAAAAZAdoWTAAAAAAAAAAAAAAGvHFFbAAAAAAAAAAAAAAB/9k=';
6+
7+
function crc32(bytes: Buffer) {
8+
let crc = 0xffffffff;
9+
for (const byte of bytes) {
10+
crc ^= byte;
11+
for (let bit = 0; bit < 8; bit += 1) {
12+
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
13+
}
14+
}
15+
return (crc ^ 0xffffffff) >>> 0;
16+
}
17+
18+
function pngChunk(type: string, data: Buffer) {
19+
const payload = Buffer.concat([Buffer.from(type), data]);
20+
const length = Buffer.alloc(4);
21+
length.writeUInt32BE(data.length);
22+
const checksum = Buffer.alloc(4);
23+
checksum.writeUInt32BE(crc32(payload));
24+
return Buffer.concat([length, payload, checksum]);
25+
}
26+
27+
function createPng(
28+
channels: 1 | 3 | 4,
29+
pixel: (x: number, y: number) => number[]
30+
) {
31+
const rowLength = 1 + SIZE * channels;
32+
const pixels = Buffer.alloc(rowLength * SIZE);
33+
for (let y = 0; y < SIZE; y += 1) {
34+
for (let x = 0; x < SIZE; x += 1) {
35+
pixels.set(pixel(x, y), y * rowLength + 1 + x * channels);
36+
}
37+
}
38+
const header = Buffer.alloc(13);
39+
header.writeUInt32BE(SIZE, 0);
40+
header.writeUInt32BE(SIZE, 4);
41+
header[8] = 8;
42+
const colorTypes = { 1: 0, 3: 2, 4: 6 } as const;
43+
header[9] = colorTypes[channels];
44+
return Buffer.concat([
45+
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
46+
pngChunk('IHDR', header),
47+
pngChunk('IDAT', zlib.deflateSync(pixels)),
48+
pngChunk('IEND', Buffer.alloc(0)),
49+
]);
50+
}
51+
52+
const rgbColors = (x: number, y: number) => {
53+
if (x < SIZE / 2 && y < SIZE / 2) return [128, 64, 64];
54+
if (x >= SIZE / 2 && y < SIZE / 2) return [64, 128, 64];
55+
if (x < SIZE / 2) return [192, 32, 64];
56+
return [32, 192, 64];
57+
};
58+
59+
const colorsWithAlpha = (x: number, y: number) => {
60+
if (x < SIZE / 2 && y < SIZE / 2) return [255, 0, 0, 255];
61+
if (x >= SIZE / 2 && y < SIZE / 2) return [0, 255, 0, 128];
62+
if (x < SIZE / 2) return [0, 0, 255, 0];
63+
return [255, 255, 0, 255];
64+
};
65+
66+
export function createRasterFixture(name: string) {
67+
if (name === 'rgb-thumbnail.png') return createPng(3, rgbColors);
68+
if (name === 'rgba-thumbnail.png') return createPng(4, colorsWithAlpha);
69+
if (name === 'gray-thumbnail.png')
70+
return createPng(1, (x) => [Math.floor((x * 255) / (SIZE - 1))]);
71+
if (name === 'rgb-thumbnail.jpg')
72+
return Buffer.from(RGB_JPEG_BASE64, 'base64');
73+
throw new Error(`Unknown raster fixture: ${name}`);
74+
}

0 commit comments

Comments
 (0)