Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/components/SaveSegmentGroupDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ import { onMounted, ref } from 'vue';
import { onKeyDown } from '@vueuse/core';
import { saveAs } from 'file-saver';
import { useSegmentGroupStore } from '@/src/store/segmentGroups';
import { writeImage } from '@/src/io/readWriteImage';
import { writeSegmentation } from '@/src/io/readWriteImage';
import { useErrorMessage } from '@/src/composables/useErrorMessage';

const EXTENSIONS = [
'seg.nrrd',
'nrrd',
'nii',
'nii.gz',
Expand Down Expand Up @@ -76,8 +77,11 @@ async function saveSegmentGroup() {

saving.value = true;
await useErrorMessage('Failed to save segment group', async () => {
const image = segmentGroupStore.dataIndex[props.id];
const serialized = await writeImage(fileFormat.value, image);
const serialized = await writeSegmentation(
fileFormat.value,
segmentGroupStore.dataIndex[props.id],
segmentGroupStore.metadataByID[props.id]
);
saveAs(new Blob([serialized]), `${fileName.value}.${fileFormat.value}`);
});
saving.value = false;
Expand Down
6 changes: 6 additions & 0 deletions src/components/SaveSession.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { saveAs } from 'file-saver';
import { onKeyDown } from '@vueuse/core';

import { serialize } from '../io/state-file/serialize';
import { useMessageStore } from '../store/messages';

const DEFAULT_FILENAME = 'session.volview.zip';

Expand All @@ -58,6 +59,11 @@ export default defineComponent({
const blob = await serialize();
saveAs(blob, fileName.value);
props.close();
} catch (err) {
const messageStore = useMessageStore();
messageStore.addError('Failed to save session', {
error: err instanceof Error ? err : new Error(String(err)),
});
} finally {
saving.value = false;
}
Expand Down
35 changes: 31 additions & 4 deletions src/io/readWriteImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,51 @@ import {
} from '@itk-wasm/image-io';
import { vtiReader, vtiWriter } from '@/src/io/vtk/async';
import { getWorker } from '@/src/io/itk/worker';
import type { SegmentGroupMetadata } from '@/src/store/segmentGroups';
import { maybeBuildSegNrrdMetadata } from '@/src/io/segNrrdMetadata';

export const readImage = async (file: File) => {
export const readImage = async (file: File, webWorker?: Worker | null) => {
if (file.name.endsWith('.vti'))
return (await vtiReader(file)) as vtkImageData;

const { image } = await readImageItk(file, { webWorker: getWorker() });
const { image } = await readImageItk(file, {
webWorker: webWorker ?? getWorker(),
});
return vtkITKHelper.convertItkToVtkImage(image);
};

export const writeImage = async (format: string, image: vtkImageData) => {
export const writeImage = async (
format: string,
image: vtkImageData,
options?: { webWorker?: Worker | null; metadata?: Map<string, string> }
) => {
if (format === 'vti') {
return vtiWriter(image);
}
// copyImage so writeImage does not detach live data when passing to worker
const itkImage = copyImage(vtkITKHelper.convertVtkToItkImage(image));

if (options?.metadata) {
itkImage.metadata = options.metadata;
}

const result = await writeImageItk(itkImage, `image.${format}`, {
webWorker: getWorker(),
webWorker: options?.webWorker ?? getWorker(),
useCompression: true,
});
return result.serializedImage.data as Uint8Array<ArrayBuffer>;
};

export const writeSegmentation = (
format: string,
image: vtkImageData,
segMetadata: SegmentGroupMetadata,
webWorker?: Worker | null
) => {
const metadata = maybeBuildSegNrrdMetadata(
format,
segMetadata,
image.getDimensions() as [number, number, number]
);
return writeImage(format, image, { metadata, webWorker });
};
51 changes: 51 additions & 0 deletions src/io/segNrrdMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { SegmentGroupMetadata } from '@/src/store/segmentGroups';

const toColorString = (r: number, g: number, b: number) =>
[r / 255, g / 255, b / 255].map((c) => c.toFixed(6)).join(' ');

/**
* Builds Slicer-compatible .seg.nrrd metadata entries from VolView segment group metadata.
* Returns a Map suitable for setting on an itk-wasm Image's metadata field.
*
* @param metadata - segment group metadata (names, colors, label values)
* @param dimensions - [x, y, z] voxel dimensions of the labelmap
*/
export const buildSegNrrdMetadata = (
metadata: SegmentGroupMetadata,
dimensions: [number, number, number]
): Map<string, string> => {
const entries = new Map<string, string>();

entries.set('Segmentation_MasterRepresentation', 'Binary labelmap');
entries.set('Segmentation_ContainedRepresentationNames', 'Binary labelmap|');
entries.set('Segmentation_ReferenceImageExtentOffset', '0 0 0');

const extentStr = `0 ${dimensions[0] - 1} 0 ${dimensions[1] - 1} 0 ${dimensions[2] - 1}`;

metadata.segments.order.forEach((segmentValue, index) => {
const segment = metadata.segments.byValue[segmentValue];
if (!segment) return;

const prefix = `Segment${index}`;
const [r, g, b] = segment.color;

entries.set(`${prefix}_ID`, `Segment_${segmentValue}`);
entries.set(`${prefix}_Name`, segment.name);
entries.set(`${prefix}_Color`, toColorString(r, g, b));
entries.set(`${prefix}_LabelValue`, String(segmentValue));
entries.set(`${prefix}_Layer`, '0');
entries.set(`${prefix}_Extent`, extentStr);
entries.set(`${prefix}_Tags`, '|');
});

return entries;
};

export const maybeBuildSegNrrdMetadata = (
format: string,
segMetadata: SegmentGroupMetadata,
dimensions: [number, number, number]
): Map<string, string> | undefined =>
format === 'seg.nrrd'
? buildSegNrrdMetadata(segMetadata, dimensions)
: undefined;
35 changes: 34 additions & 1 deletion src/io/vtk/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,39 @@ import vtkDataSet from '@kitware/vtk.js/Common/DataModel/DataSet';
import { vtkObject } from '@kitware/vtk.js/interfaces';
import { StateObject } from './common';

// VTK.js DataArray.getState() calls Array.from() on typed arrays,
// which OOMs for large images (>~180M voxels). This helper temporarily
// swaps each array's data with empty before getState(), then injects
// the original TypedArrays into the resulting state. Structured clone
// (postMessage) handles TypedArrays efficiently, and vtk()
// reconstruction accepts them in DataArray.extend().
const getStateWithTypedArrays = (dataSet: vtkDataSet) => {
const pointData = (dataSet as any).getPointData?.();
const arrays: any[] = pointData?.getArrays?.() ?? [];

const typedArrays = arrays.map((arr: any) => arr.getData());

// Swap to empty so Array.from runs on [] instead of huge TypedArray
arrays.forEach((arr: any) => arr.setData(new Uint8Array(0)));

let state: any;
try {
state = dataSet.getState();
} finally {
arrays.forEach((arr: any, i: number) => arr.setData(typedArrays[i]));
}

// Inject original TypedArrays into the serialized state
state?.pointData?.arrays?.forEach((entry: any, i: number) => {
if (entry?.data) {
entry.data.values = typedArrays[i];
entry.data.size = typedArrays[i].length;
}
});

return state;
};

interface SuccessReadResult {
status: 'success';
obj: StateObject;
Expand Down Expand Up @@ -52,7 +85,7 @@ export const runAsyncVTKWriter =
);
const worker = new PromiseWorker(asyncWorker);
const result = (await worker.postMessage({
obj: dataSet.getState(),
obj: getStateWithTypedArrays(dataSet),
writerName,
})) as WriteResult;
asyncWorker.terminate();
Expand Down
29 changes: 23 additions & 6 deletions src/store/segmentGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { onImageDeleted } from '@/src/composables/onImageDeleted';
import { normalizeForStore, removeFromArray } from '@/src/utils';
import { SegmentMask } from '@/src/types/segment';
import { DEFAULT_SEGMENT_MASKS, CATEGORICAL_COLORS } from '@/src/config';
import { readImage, writeImage } from '@/src/io/readWriteImage';
import { createWebWorker } from 'itk-wasm';
import { readImage, writeSegmentation } from '@/src/io/readWriteImage';
import {
type DataSelection,
getImage,
Expand Down Expand Up @@ -480,12 +481,21 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => {

state.manifest.segmentGroups = serialized;

// save labelmap images
// save labelmap images — fresh worker per write to avoid heap accumulation
await Promise.all(
serialized.map(async ({ id, path }) => {
const vtkImage = dataIndex[id];
const serializedImage = await writeImage(saveFormat.value, vtkImage);
zip.file(path, serializedImage);
const worker = await createWebWorker(null);
try {
const serializedImage = await writeSegmentation(
saveFormat.value,
dataIndex[id],
metadataByID[id],
worker
);
zip.file(path, serializedImage);
} finally {
worker.terminate();
}
})
);
}
Expand Down Expand Up @@ -527,7 +537,14 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => {
const file = stateFiles.find(
(entry) => entry.archivePath === normalize(segmentGroup.path!)
)?.file;
return { image: await readImage(file!) };
// Use a fresh worker per labelmap to avoid WASM heap accumulation.
// The shared worker may already have a large heap from base images.
const worker = await createWebWorker(null);
try {
return { image: await readImage(file!, worker) };
} finally {
worker.terminate();
}
}

const labelmapResults = await Promise.all(
Expand Down
Loading
Loading