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
25 changes: 23 additions & 2 deletions src/io/readWriteImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ 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, webWorker?: Worker | null) => {
if (file.name.endsWith('.vti'))
Expand All @@ -21,16 +23,35 @@ export const readImage = async (file: File, webWorker?: Worker | null) => {
export const writeImage = async (
format: string,
image: vtkImageData,
webWorker?: Worker | null
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: 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;
8 changes: 4 additions & 4 deletions src/store/segmentGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { normalizeForStore, removeFromArray } from '@/src/utils';
import { SegmentMask } from '@/src/types/segment';
import { DEFAULT_SEGMENT_MASKS, CATEGORICAL_COLORS } from '@/src/config';
import { createWebWorker } from 'itk-wasm';
import { readImage, writeImage } from '@/src/io/readWriteImage';
import { readImage, writeSegmentation } from '@/src/io/readWriteImage';
import {
type DataSelection,
getImage,
Expand Down Expand Up @@ -484,12 +484,12 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => {
// save labelmap images — fresh worker per write to avoid heap accumulation
await Promise.all(
serialized.map(async ({ id, path }) => {
const vtkImage = dataIndex[id];
const worker = await createWebWorker(null);
try {
const serializedImage = await writeImage(
const serializedImage = await writeSegmentation(
saveFormat.value,
vtkImage,
dataIndex[id],
metadataByID[id],
worker
);
zip.file(path, serializedImage);
Expand Down
31 changes: 1 addition & 30 deletions tests/specs/save-large-labelmap.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as zlib from 'node:zlib';
import { cleanuptotal } from 'wdio-cleanuptotal-service';
import { volViewPage } from '../pageobjects/volview.page';
import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf';
import { writeManifestToFile } from './utils';
import { writeManifestToFile, waitForFileExists } from './utils';

// 268M voxels — labelmap at this size triggers Array.from OOM
const DIM_X = 1024;
Expand Down Expand Up @@ -58,35 +58,6 @@ const createUint8NiftiGz = () => {
return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 });
};

const waitForFileExists = (filePath: string, timeout: number) =>
new Promise<void>((resolve, reject) => {
const dir = path.dirname(filePath);
const basename = path.basename(filePath);

const watcher = fs.watch(dir, (eventType, filename) => {
if (eventType === 'rename' && filename === basename) {
clearTimeout(timerId);
watcher.close();
resolve();
}
});

const timerId = setTimeout(() => {
watcher.close();
reject(
new Error(`File ${filePath} not created within ${timeout}ms timeout`)
);
}, timeout);

fs.access(filePath, fs.constants.R_OK, (err) => {
if (!err) {
clearTimeout(timerId);
watcher.close();
resolve();
}
});
});

describe('Save large labelmap', function () {
this.timeout(180_000);

Expand Down
126 changes: 126 additions & 0 deletions tests/specs/seg-nrrd-export.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as zlib from 'node:zlib';
import JSZip from 'jszip';
import { volViewPage } from '../pageobjects/volview.page';
import { TEMP_DIR } from '../../wdio.shared.conf';
import { waitForFileExists } from './utils';
import { ONE_CT_SLICE_DICOM, openConfigAndDataset } from './configTestUtils';

/**
* Parse NRRD header key-value pairs from a buffer (handles gzip).
*/
const parseNrrdHeader = (buf: Buffer): Map<string, string> => {
const raw = buf[0] === 0x1f && buf[1] === 0x8b ? zlib.gunzipSync(buf) : buf;
const text = raw.toString('ascii', 0, Math.min(raw.length, 16384));
const headerEnd = text.indexOf('\n\n');
const headerText = headerEnd >= 0 ? text.slice(0, headerEnd) : text;

const entries = new Map<string, string>();
headerText.split('\n').forEach((line) => {
const sepIdx = line.indexOf(':=');
if (sepIdx >= 0) {
entries.set(line.slice(0, sepIdx).trim(), line.slice(sepIdx + 2).trim());
return;
}
const colonIdx = line.indexOf(':');
if (colonIdx >= 0 && !line.startsWith('#') && !line.startsWith('NRRD')) {
entries.set(
line.slice(0, colonIdx).trim(),
line.slice(colonIdx + 1).trim()
);
}
});
return entries;
};

describe('Slicer-compatible seg.nrrd export', function () {
this.timeout(120_000);

it('session save includes Slicer metadata in seg.nrrd labelmap', async () => {
const config = { io: { segmentGroupSaveFormat: 'seg.nrrd' } };
await openConfigAndDataset(config, 'seg-nrrd-export', ONE_CT_SLICE_DICOM);

// Activate paint tool — creates a segment group
await volViewPage.activatePaint();

// Paint a stroke so the labelmap has data
const views2D = await volViewPage.getViews2D();
const canvas = await views2D[0].$('canvas');
const location = await canvas.getLocation();
const size = await canvas.getSize();
const cx = Math.round(location.x + size.width / 2);
const cy = Math.round(location.y + size.height / 2);

await browser
.action('pointer')
.move({ x: cx, y: cy })
.down()
.move({ x: cx + 20, y: cy })
.up()
.perform();

// Save session — downloads a .volview.zip containing the seg.nrrd
const sessionFileName = await volViewPage.saveSession();
const downloadedPath = path.join(TEMP_DIR, sessionFileName);

await waitForFileExists(downloadedPath, 30_000);

// Wait for file to be fully written
await browser.waitUntil(
() => {
try {
return fs.statSync(downloadedPath).size > 0;
} catch {
return false;
}
},
{
timeout: 10_000,
interval: 500,
timeoutMsg: 'Downloaded session zip remained 0 bytes',
}
);

// Extract the seg.nrrd file from the session zip
const zipData = fs.readFileSync(downloadedPath);
const zip = await JSZip.loadAsync(zipData);

const segNrrdFile = Object.keys(zip.files).find((name) =>
name.endsWith('.seg.nrrd')
);
expect(segNrrdFile).toBeDefined();

const nrrdBuffer = Buffer.from(
await zip.files[segNrrdFile!].async('arraybuffer')
);
const header = parseNrrdHeader(nrrdBuffer);

// Global segmentation fields
expect(header.get('Segmentation_MasterRepresentation')).toBe(
'Binary labelmap'
);
expect(header.get('Segmentation_ContainedRepresentationNames')).toBe(
'Binary labelmap|'
);
expect(header.get('Segmentation_ReferenceImageExtentOffset')).toBe('0 0 0');

// Per-segment fields — default first segment is "Segment 1" with value 1
expect(header.get('Segment0_ID')).toBe('Segment_1');
expect(header.get('Segment0_Name')).toBe('Segment 1');
expect(header.get('Segment0_LabelValue')).toBe('1');
expect(header.get('Segment0_Layer')).toBe('0');
expect(header.get('Segment0_Extent')).toBeDefined();
expect(header.get('Segment0_Tags')).toBe('|');

// Color should be 3 space-separated floats between 0 and 1
const colorStr = header.get('Segment0_Color');
expect(colorStr).toBeDefined();
const colorParts = colorStr!.split(' ').map(Number);
expect(colorParts).toHaveLength(3);
colorParts.forEach((c) => {
expect(c).toBeGreaterThanOrEqual(0);
expect(c).toBeLessThanOrEqual(1);
});
});
});
33 changes: 1 addition & 32 deletions tests/specs/session-state-lifecycle.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,12 @@ import * as path from 'path';
import * as fs from 'fs';
import JSZip from 'jszip';
import { MINIMAL_501_SESSION } from './configTestUtils';
import { downloadFile } from './utils';
import { downloadFile, waitForFileExists } from './utils';
import { setValueVueInput, volViewPage } from '../pageobjects/volview.page';
import { TEMP_DIR } from '../../wdio.shared.conf';

const SESSION_SAVE_TIMEOUT = 40000;

const waitForFileExists = (filePath: string, timeout: number) =>
new Promise<void>((resolve, reject) => {
const dir = path.dirname(filePath);
const basename = path.basename(filePath);

const watcher = fs.watch(dir, (eventType, filename) => {
if (eventType === 'rename' && filename === basename) {
clearTimeout(timerId);
watcher.close();
resolve();
}
});

const timerId = setTimeout(() => {
watcher.close();
reject(
new Error(
`File ${filePath} did not exist and was not created during timeout of ${timeout}ms`
)
);
}, timeout);

fs.access(filePath, fs.constants.R_OK, (err) => {
if (!err) {
clearTimeout(timerId);
watcher.close();
resolve();
}
});
});

const saveSession = async () => {
const sessionFileName = await volViewPage.saveSession();
const downloadedPath = path.join(TEMP_DIR, sessionFileName);
Expand Down
Loading
Loading