Skip to content

Commit b02cd52

Browse files
authored
Merge pull request #856 from Kitware/range-fix
Fix RangeErrors with large images
2 parents 4763ab3 + d53ed45 commit b02cd52

7 files changed

Lines changed: 444 additions & 36 deletions

File tree

package-lock.json

Lines changed: 27 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"@aws-sdk/client-s3": "^3.940.0",
3535
"@eslint/js": "^9.39.1",
3636
"@itk-wasm/dicom": "^7.6.4",
37-
"@itk-wasm/image-io": "1.6.0",
37+
"@itk-wasm/image-io": "1.6.1",
3838
"@itk-wasm/morphological-contour-interpolation": "2.0.0",
3939
"@kitware/vtk.js": "^32.12.1",
4040
"@netlify/edge-functions": "^3.0.2",
@@ -74,7 +74,7 @@
7474
"gl-matrix": "3.4.3",
7575
"globals": "^16.2.0",
7676
"happy-dom": "^20.0.11",
77-
"itk-wasm": "^1.0.0-b.196",
77+
"itk-wasm": "^1.0.0-b.197",
7878
"jszip": "3.10.1",
7979
"lint-staged": "16.2.7",
8080
"mitt": "^3.0.1",

patches/itk-wasm+1.0.0-b.196.patch

Lines changed: 0 additions & 16 deletions
This file was deleted.

src/components/SaveSession.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { saveAs } from 'file-saver';
3636
import { onKeyDown } from '@vueuse/core';
3737
3838
import { serialize } from '../io/state-file/serialize';
39+
import { useMessageStore } from '../store/messages';
3940
4041
const DEFAULT_FILENAME = 'session.volview.zip';
4142
@@ -58,6 +59,11 @@ export default defineComponent({
5859
const blob = await serialize();
5960
saveAs(blob, fileName.value);
6061
props.close();
62+
} catch (err) {
63+
const messageStore = useMessageStore();
64+
messageStore.addError('Failed to save session', {
65+
error: err instanceof Error ? err : new Error(String(err)),
66+
});
6167
} finally {
6268
saving.value = false;
6369
}

src/io/vtk/async.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,39 @@ import vtkDataSet from '@kitware/vtk.js/Common/DataModel/DataSet';
55
import { vtkObject } from '@kitware/vtk.js/interfaces';
66
import { StateObject } from './common';
77

8+
// VTK.js DataArray.getState() calls Array.from() on typed arrays,
9+
// which OOMs for large images (>~180M voxels). This helper temporarily
10+
// swaps each array's data with empty before getState(), then injects
11+
// the original TypedArrays into the resulting state. Structured clone
12+
// (postMessage) handles TypedArrays efficiently, and vtk()
13+
// reconstruction accepts them in DataArray.extend().
14+
const getStateWithTypedArrays = (dataSet: vtkDataSet) => {
15+
const pointData = (dataSet as any).getPointData?.();
16+
const arrays: any[] = pointData?.getArrays?.() ?? [];
17+
18+
const typedArrays = arrays.map((arr: any) => arr.getData());
19+
20+
// Swap to empty so Array.from runs on [] instead of huge TypedArray
21+
arrays.forEach((arr: any) => arr.setData(new Uint8Array(0)));
22+
23+
let state: any;
24+
try {
25+
state = dataSet.getState();
26+
} finally {
27+
arrays.forEach((arr: any, i: number) => arr.setData(typedArrays[i]));
28+
}
29+
30+
// Inject original TypedArrays into the serialized state
31+
state?.pointData?.arrays?.forEach((entry: any, i: number) => {
32+
if (entry?.data) {
33+
entry.data.values = typedArrays[i];
34+
entry.data.size = typedArrays[i].length;
35+
}
36+
});
37+
38+
return state;
39+
};
40+
841
interface SuccessReadResult {
942
status: 'success';
1043
obj: StateObject;
@@ -52,7 +85,7 @@ export const runAsyncVTKWriter =
5285
);
5386
const worker = new PromiseWorker(asyncWorker);
5487
const result = (await worker.postMessage({
55-
obj: dataSet.getState(),
88+
obj: getStateWithTypedArrays(dataSet),
5689
writerName,
5790
})) as WriteResult;
5891
asyncWorker.terminate();
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import * as zlib from 'node:zlib';
4+
import { cleanuptotal } from 'wdio-cleanuptotal-service';
5+
import { volViewPage } from '../pageobjects/volview.page';
6+
import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf';
7+
import { writeManifestToFile } from './utils';
8+
9+
// 268M voxels — labelmap at this size triggers Array.from OOM
10+
const DIM_X = 1024;
11+
const DIM_Y = 1024;
12+
const DIM_Z = 256;
13+
14+
const writeBufferToFile = async (data: Buffer, fileName: string) => {
15+
const filePath = path.join(TEMP_DIR, fileName);
16+
await fs.promises.writeFile(filePath, data);
17+
cleanuptotal.addCleanup(async () => {
18+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
19+
});
20+
return filePath;
21+
};
22+
23+
// UInt8 base image — small compressed size, fast to load
24+
const createUint8NiftiGz = () => {
25+
const header = Buffer.alloc(352);
26+
header.writeInt32LE(348, 0);
27+
header.writeInt16LE(3, 40);
28+
header.writeInt16LE(DIM_X, 42);
29+
header.writeInt16LE(DIM_Y, 44);
30+
header.writeInt16LE(DIM_Z, 46);
31+
header.writeInt16LE(1, 48);
32+
header.writeInt16LE(1, 50);
33+
header.writeInt16LE(1, 52);
34+
header.writeInt16LE(2, 70); // datatype: UINT8
35+
header.writeInt16LE(8, 72); // bitpix
36+
header.writeFloatLE(1, 76);
37+
header.writeFloatLE(1, 80);
38+
header.writeFloatLE(1, 84);
39+
header.writeFloatLE(1, 88);
40+
header.writeFloatLE(352, 108);
41+
header.writeFloatLE(1, 112);
42+
header.writeInt16LE(1, 254);
43+
header.writeFloatLE(1, 280);
44+
header.writeFloatLE(0, 284);
45+
header.writeFloatLE(0, 288);
46+
header.writeFloatLE(0, 292);
47+
header.writeFloatLE(0, 296);
48+
header.writeFloatLE(1, 300);
49+
header.writeFloatLE(0, 304);
50+
header.writeFloatLE(0, 308);
51+
header.writeFloatLE(0, 312);
52+
header.writeFloatLE(0, 316);
53+
header.writeFloatLE(1, 320);
54+
header.writeFloatLE(0, 324);
55+
header.write('n+1\0', 344, 'binary');
56+
57+
const imageData = Buffer.alloc(DIM_X * DIM_Y * DIM_Z);
58+
return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 });
59+
};
60+
61+
const waitForFileExists = (filePath: string, timeout: number) =>
62+
new Promise<void>((resolve, reject) => {
63+
const dir = path.dirname(filePath);
64+
const basename = path.basename(filePath);
65+
66+
const watcher = fs.watch(dir, (eventType, filename) => {
67+
if (eventType === 'rename' && filename === basename) {
68+
clearTimeout(timerId);
69+
watcher.close();
70+
resolve();
71+
}
72+
});
73+
74+
const timerId = setTimeout(() => {
75+
watcher.close();
76+
reject(
77+
new Error(`File ${filePath} not created within ${timeout}ms timeout`)
78+
);
79+
}, timeout);
80+
81+
fs.access(filePath, fs.constants.R_OK, (err) => {
82+
if (!err) {
83+
clearTimeout(timerId);
84+
watcher.close();
85+
resolve();
86+
}
87+
});
88+
});
89+
90+
describe('Save large labelmap', function () {
91+
this.timeout(180_000);
92+
93+
it('saves session without error when labelmap exceeds 200M voxels', async () => {
94+
const prefix = `save-large-${Date.now()}`;
95+
const baseFileName = `${prefix}-u8.nii.gz`;
96+
97+
await writeBufferToFile(createUint8NiftiGz(), baseFileName);
98+
99+
const manifest = { resources: [{ url: `/tmp/${baseFileName}` }] };
100+
const manifestFileName = `${prefix}-manifest.json`;
101+
await writeManifestToFile(manifest, manifestFileName);
102+
103+
await volViewPage.open(`?urls=[tmp/${manifestFileName}]`);
104+
await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6);
105+
106+
// Activate paint tool — creates a segment group
107+
await volViewPage.activatePaint();
108+
109+
// Paint a stroke to allocate the labelmap
110+
const views2D = await volViewPage.getViews2D();
111+
const canvas = await views2D[0].$('canvas');
112+
const location = await canvas.getLocation();
113+
const size = await canvas.getSize();
114+
const cx = Math.round(location.x + size.width / 2);
115+
const cy = Math.round(location.y + size.height / 2);
116+
117+
await browser
118+
.action('pointer')
119+
.move({ x: cx, y: cy })
120+
.down()
121+
.move({ x: cx + 20, y: cy })
122+
.up()
123+
.perform();
124+
125+
const notificationsBefore = await volViewPage.getNotificationsCount();
126+
127+
// Save session — before fix, this throws RangeError: Invalid array length
128+
const sessionFileName = await volViewPage.saveSession();
129+
const downloadedPath = path.join(TEMP_DIR, sessionFileName);
130+
131+
// Wait for either the file to appear (success) or notification (error)
132+
const saveResult = await Promise.race([
133+
waitForFileExists(downloadedPath, 90_000).then(() => 'saved' as const),
134+
browser
135+
.waitUntil(
136+
async () => {
137+
const count = await volViewPage.getNotificationsCount();
138+
return count > notificationsBefore;
139+
},
140+
{ timeout: 90_000, interval: 1000 }
141+
)
142+
.then(() => 'error' as const),
143+
]);
144+
145+
if (saveResult === 'error') {
146+
const errorDetails = await browser.execute(() => {
147+
const app = document.querySelector('#app') as any;
148+
const pinia = app?.__vue_app__?.config?.globalProperties?.$pinia;
149+
if (!pinia) return 'no pinia';
150+
const store = pinia.state.value.message;
151+
if (!store) return 'no message store';
152+
return store.msgList
153+
.map((id: string) => {
154+
const msg = store.byID[id];
155+
return `[${msg.type}] ${msg.title}: ${msg.options?.details?.slice(0, 300)}`;
156+
})
157+
.join('\n');
158+
});
159+
throw new Error(`Save error:\n${errorDetails}`);
160+
}
161+
162+
// Wait for the file to be fully written (Chrome may create it before flushing)
163+
await browser.waitUntil(
164+
() => {
165+
try {
166+
return fs.statSync(downloadedPath).size > 0;
167+
} catch {
168+
return false;
169+
}
170+
},
171+
{
172+
timeout: 30_000,
173+
interval: 500,
174+
timeoutMsg: 'Downloaded file remained 0 bytes',
175+
}
176+
);
177+
const stat = fs.statSync(downloadedPath);
178+
expect(stat.size).toBeGreaterThan(0);
179+
});
180+
});

0 commit comments

Comments
 (0)