Skip to content

Commit 042de68

Browse files
committed
perf(cine): pack RGB in the JPEG decode worker
Have the worker produce a 3-component Uint8Array directly so the transferred buffer is 25% smaller and the main thread publishes frames with a single typed-array copy instead of a per-pixel RGBA→RGB loop. decodeNativeFrame follows suit and writes RGB without the dummy alpha channel.
1 parent 446f955 commit 042de68

4 files changed

Lines changed: 48 additions & 39 deletions

File tree

src/core/cine/DicomCineImage.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,17 @@ export default class DicomCineImage extends BaseProgressiveImage {
175175
const ctx = canvas.getContext('2d');
176176
if (!ctx) return null;
177177
const imageData = ctx.createImageData(cols, rows);
178-
imageData.data.set(frame.rgba);
178+
const dst = imageData.data;
179+
const { rgb } = frame;
180+
const pixels = cols * rows;
181+
for (let i = 0; i < pixels; i++) {
182+
const s = i * 3;
183+
const d = i * 4;
184+
dst[d] = rgb[s];
185+
dst[d + 1] = rgb[s + 1];
186+
dst[d + 2] = rgb[s + 2];
187+
dst[d + 3] = 255;
188+
}
179189
ctx.putImageData(imageData, 0, 0);
180190
return canvas.toDataURL('image/png');
181191
} catch {

src/core/cine/__tests__/frameCache.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ function frame(byteLength: number): DecodedFrame {
1111
return {
1212
width: 1,
1313
height: 1,
14-
rgba: { byteLength } as Uint8ClampedArray,
14+
rgb: { byteLength } as unknown as Uint8Array,
1515
};
1616
}
1717

@@ -58,7 +58,7 @@ describe('decodeNativeFrame', () => {
5858
planarConfiguration: 0,
5959
});
6060

61-
expect(Array.from(decoded.rgba)).toEqual([1, 2, 3, 255, 4, 5, 6, 255]);
61+
expect(Array.from(decoded.rgb)).toEqual([1, 2, 3, 4, 5, 6]);
6262
});
6363

6464
it('decodes plane-interleaved native RGB frames', () => {
@@ -70,6 +70,6 @@ describe('decodeNativeFrame', () => {
7070
planarConfiguration: 1,
7171
});
7272

73-
expect(Array.from(decoded.rgba)).toEqual([1, 2, 3, 255, 4, 5, 6, 255]);
73+
expect(Array.from(decoded.rgb)).toEqual([1, 2, 3, 4, 5, 6]);
7474
});
7575
});

src/core/cine/frameCache.ts

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,27 @@
11
// Decoded-frame cache for cine playback.
22
//
3-
// Holds CPU-visible RGBA bytes (not ImageBitmaps) because VTK's scalar buffer
4-
// needs to copy them in. Bounded by a byte budget; LRU eviction. Per-image
5-
// instance — disposed alongside the DicomCineImage that owns it.
3+
// Holds CPU-visible RGB bytes packed for direct copy into VTK's 3-component
4+
// scalar buffer. Bounded by a byte budget; LRU eviction. Per-image instance —
5+
// disposed alongside the DicomCineImage that owns it.
66

77
import { decodeJpegInWorker } from './jpegDecodePool';
88

99
export type DecodedFrame = {
1010
width: number;
1111
height: number;
12-
// RGBA, 8-bit per channel, row-major (matches OffscreenCanvas getImageData).
13-
rgba: Uint8ClampedArray;
12+
// RGB, 8-bit per channel, row-major. Packed in the decoder (worker for JPEG)
13+
// so the main thread can publish frames with a single buffer copy.
14+
rgb: Uint8Array;
1415
};
1516

16-
// Copy a decoded RGBA frame into a 3-component RGB buffer (VTK scalar shape).
1717
// Returns false when the buffers disagree on pixel count, so callers can skip
1818
// the publish step instead of rendering a partial frame.
1919
export function copyDecodedFrameToRgb(
2020
frame: DecodedFrame,
2121
out: Uint8Array
2222
): boolean {
23-
const { rgba } = frame;
24-
const pixels = out.length / 3;
25-
if (rgba.length !== pixels * 4) return false;
26-
for (let i = 0; i < pixels; i++) {
27-
const src = i * 4;
28-
const dst = i * 3;
29-
out[dst] = rgba[src];
30-
out[dst + 1] = rgba[src + 1];
31-
out[dst + 2] = rgba[src + 2];
32-
}
23+
if (frame.rgb.length !== out.length) return false;
24+
out.set(frame.rgb);
3325
return true;
3426
}
3527

@@ -59,11 +51,11 @@ export class FrameCache {
5951
set(frameIndex: number, frame: DecodedFrame): void {
6052
const existing = this.entries.get(frameIndex);
6153
if (existing) {
62-
this.bytesInUse -= existing.rgba.byteLength;
54+
this.bytesInUse -= existing.rgb.byteLength;
6355
this.entries.delete(frameIndex);
6456
}
6557
this.entries.set(frameIndex, frame);
66-
this.bytesInUse += frame.rgba.byteLength;
58+
this.bytesInUse += frame.rgb.byteLength;
6759
this.evictUntilUnderBudget();
6860
}
6961

@@ -90,7 +82,7 @@ export class FrameCache {
9082
for (const key of this.entries.keys()) {
9183
if (this.bytesInUse <= this.budgetBytes) break;
9284
const entry = this.entries.get(key)!;
93-
this.bytesInUse -= entry.rgba.byteLength;
85+
this.bytesInUse -= entry.rgb.byteLength;
9486
this.entries.delete(key);
9587
}
9688
}
@@ -114,7 +106,7 @@ type NativeFrameLayout = {
114106
planarConfiguration: number;
115107
};
116108

117-
// Convert a raw uncompressed frame to RGBA. Supports the two photometric
109+
// Convert a raw uncompressed frame to packed RGB. Supports the two photometric
118110
// interpretations our sample corpus shows in the native PixelData path:
119111
// - 'RGB' with samplesPerPixel=3 (interleaved RGB)
120112
// - 'MONOCHROME2' with samplesPerPixel=1 (grayscale, replicated to RGB)
@@ -125,17 +117,16 @@ export function decodeNativeFrame(
125117
const { width, height, samplesPerPixel, photometric, planarConfiguration } =
126118
layout;
127119
const pixelCount = width * height;
128-
const out = new Uint8ClampedArray(pixelCount * 4);
120+
const out = new Uint8Array(pixelCount * 3);
129121

130122
if (samplesPerPixel === 1) {
131-
// Grayscale — replicate luminance to R, G, B; alpha = 255.
123+
// Grayscale — replicate luminance to R, G, B.
132124
for (let i = 0; i < pixelCount; i++) {
133125
const v = bytes[i] ?? 0;
134-
const j = i * 4;
126+
const j = i * 3;
135127
out[j] = v;
136128
out[j + 1] = v;
137129
out[j + 2] = v;
138-
out[j + 3] = 255;
139130
}
140131
} else if (
141132
samplesPerPixel === 3 &&
@@ -145,20 +136,17 @@ export function decodeNativeFrame(
145136
// RRRR...GGGG...BBBB...
146137
const plane = pixelCount;
147138
for (let i = 0; i < pixelCount; i++) {
148-
const j = i * 4;
139+
const j = i * 3;
149140
out[j] = bytes[i] ?? 0;
150141
out[j + 1] = bytes[plane + i] ?? 0;
151142
out[j + 2] = bytes[2 * plane + i] ?? 0;
152-
out[j + 3] = 255;
153143
}
154144
} else {
155145
for (let i = 0; i < pixelCount; i++) {
156146
const k = i * 3;
157-
const j = i * 4;
158-
out[j] = bytes[k] ?? 0;
159-
out[j + 1] = bytes[k + 1] ?? 0;
160-
out[j + 2] = bytes[k + 2] ?? 0;
161-
out[j + 3] = 255;
147+
out[k] = bytes[k] ?? 0;
148+
out[k + 1] = bytes[k + 1] ?? 0;
149+
out[k + 2] = bytes[k + 2] ?? 0;
162150
}
163151
}
164152
} else {
@@ -167,5 +155,5 @@ export function decodeNativeFrame(
167155
);
168156
}
169157

170-
return { width, height, rgba: out };
158+
return { width, height, rgb: out };
171159
}

src/core/cine/jpegDecode.worker.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,24 @@ async function decodeJpeg(
2323
const ctx = canvas.getContext('2d');
2424
if (!ctx) throw new Error('OffscreenCanvas 2D context unavailable');
2525
ctx.drawImage(bitmap, 0, 0);
26-
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
26+
const { data: rgba } = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
27+
// Pack RGBA→RGB in-worker so the main thread only does a single
28+
// typed-array copy when publishing the frame.
29+
const pixels = bitmap.width * bitmap.height;
30+
const rgb = new Uint8Array(pixels * 3);
31+
for (let i = 0; i < pixels; i++) {
32+
const s = i * 4;
33+
const d = i * 3;
34+
rgb[d] = rgba[s];
35+
rgb[d + 1] = rgba[s + 1];
36+
rgb[d + 2] = rgba[s + 2];
37+
}
2738
const result: DecodedFrame = {
2839
width: bitmap.width,
2940
height: bitmap.height,
30-
rgba: imageData.data,
41+
rgb,
3142
};
32-
return Comlink.transfer(result, [imageData.data.buffer]);
43+
return Comlink.transfer(result, [rgb.buffer]);
3344
} finally {
3445
bitmap.close();
3546
}

0 commit comments

Comments
 (0)