Skip to content

Commit 06a98cd

Browse files
committed
feat: include segment groups labels in NRRD format
1 parent 2dec537 commit 06a98cd

File tree

8 files changed

+241
-71
lines changed

8 files changed

+241
-71
lines changed

src/components/SaveSegmentGroupDialog.vue

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ import { onMounted, ref } from 'vue';
4141
import { onKeyDown } from '@vueuse/core';
4242
import { saveAs } from 'file-saver';
4343
import { useSegmentGroupStore } from '@/src/store/segmentGroups';
44-
import { writeImage } from '@/src/io/readWriteImage';
44+
import { writeSegmentation } from '@/src/io/readWriteImage';
4545
import { useErrorMessage } from '@/src/composables/useErrorMessage';
4646
4747
const EXTENSIONS = [
48+
'seg.nrrd',
4849
'nrrd',
4950
'nii',
5051
'nii.gz',
@@ -76,8 +77,11 @@ async function saveSegmentGroup() {
7677
7778
saving.value = true;
7879
await useErrorMessage('Failed to save segment group', async () => {
79-
const image = segmentGroupStore.dataIndex[props.id];
80-
const serialized = await writeImage(fileFormat.value, image);
80+
const serialized = await writeSegmentation(
81+
fileFormat.value,
82+
segmentGroupStore.dataIndex[props.id],
83+
segmentGroupStore.metadataByID[props.id]
84+
);
8185
saveAs(new Blob([serialized]), `${fileName.value}.${fileFormat.value}`);
8286
});
8387
saving.value = false;

src/io/readWriteImage.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
} from '@itk-wasm/image-io';
88
import { vtiReader, vtiWriter } from '@/src/io/vtk/async';
99
import { getWorker } from '@/src/io/itk/worker';
10+
import type { SegmentGroupMetadata } from '@/src/store/segmentGroups';
11+
import { maybeBuildSegNrrdMetadata } from '@/src/io/segNrrdMetadata';
1012

1113
export const readImage = async (file: File, webWorker?: Worker | null) => {
1214
if (file.name.endsWith('.vti'))
@@ -21,16 +23,34 @@ export const readImage = async (file: File, webWorker?: Worker | null) => {
2123
export const writeImage = async (
2224
format: string,
2325
image: vtkImageData,
24-
webWorker?: Worker | null
26+
options?: { webWorker?: Worker | null; metadata?: Map<string, string> }
2527
) => {
2628
if (format === 'vti') {
2729
return vtiWriter(image);
2830
}
2931
// copyImage so writeImage does not detach live data when passing to worker
3032
const itkImage = copyImage(vtkITKHelper.convertVtkToItkImage(image));
3133

34+
if (options?.metadata) {
35+
itkImage.metadata = options.metadata;
36+
}
37+
3238
const result = await writeImageItk(itkImage, `image.${format}`, {
33-
webWorker: webWorker ?? getWorker(),
39+
webWorker: options?.webWorker ?? getWorker(),
3440
});
3541
return result.serializedImage.data as Uint8Array<ArrayBuffer>;
3642
};
43+
44+
export const writeSegmentation = (
45+
format: string,
46+
image: vtkImageData,
47+
segMetadata: SegmentGroupMetadata,
48+
webWorker?: Worker | null
49+
) => {
50+
const metadata = maybeBuildSegNrrdMetadata(
51+
format,
52+
segMetadata,
53+
image.getDimensions() as [number, number, number]
54+
);
55+
return writeImage(format, image, { metadata, webWorker });
56+
};

src/io/segNrrdMetadata.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { SegmentGroupMetadata } from '@/src/store/segmentGroups';
2+
3+
const toColorString = (r: number, g: number, b: number) =>
4+
[r / 255, g / 255, b / 255].map((c) => c.toFixed(6)).join(' ');
5+
6+
/**
7+
* Builds Slicer-compatible .seg.nrrd metadata entries from VolView segment group metadata.
8+
* Returns a Map suitable for setting on an itk-wasm Image's metadata field.
9+
*
10+
* @param metadata - segment group metadata (names, colors, label values)
11+
* @param dimensions - [x, y, z] voxel dimensions of the labelmap
12+
*/
13+
export const buildSegNrrdMetadata = (
14+
metadata: SegmentGroupMetadata,
15+
dimensions: [number, number, number]
16+
): Map<string, string> => {
17+
const entries = new Map<string, string>();
18+
19+
entries.set('Segmentation_MasterRepresentation', 'Binary labelmap');
20+
entries.set('Segmentation_ContainedRepresentationNames', 'Binary labelmap|');
21+
entries.set('Segmentation_ReferenceImageExtentOffset', '0 0 0');
22+
23+
const extentStr = `0 ${dimensions[0] - 1} 0 ${dimensions[1] - 1} 0 ${dimensions[2] - 1}`;
24+
25+
metadata.segments.order.forEach((segmentValue, index) => {
26+
const segment = metadata.segments.byValue[segmentValue];
27+
if (!segment) return;
28+
29+
const prefix = `Segment${index}`;
30+
const [r, g, b] = segment.color;
31+
32+
entries.set(`${prefix}_ID`, `Segment_${segmentValue}`);
33+
entries.set(`${prefix}_Name`, segment.name);
34+
entries.set(`${prefix}_Color`, toColorString(r, g, b));
35+
entries.set(`${prefix}_LabelValue`, String(segmentValue));
36+
entries.set(`${prefix}_Layer`, '0');
37+
entries.set(`${prefix}_Extent`, extentStr);
38+
entries.set(`${prefix}_Tags`, '|');
39+
});
40+
41+
return entries;
42+
};
43+
44+
export const maybeBuildSegNrrdMetadata = (
45+
format: string,
46+
segMetadata: SegmentGroupMetadata,
47+
dimensions: [number, number, number]
48+
): Map<string, string> | undefined =>
49+
format === 'seg.nrrd'
50+
? buildSegNrrdMetadata(segMetadata, dimensions)
51+
: undefined;

src/store/segmentGroups.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { normalizeForStore, removeFromArray } from '@/src/utils';
1111
import { SegmentMask } from '@/src/types/segment';
1212
import { DEFAULT_SEGMENT_MASKS, CATEGORICAL_COLORS } from '@/src/config';
1313
import { createWebWorker } from 'itk-wasm';
14-
import { readImage, writeImage } from '@/src/io/readWriteImage';
14+
import { readImage, writeSegmentation } from '@/src/io/readWriteImage';
1515
import {
1616
type DataSelection,
1717
getImage,
@@ -484,12 +484,12 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => {
484484
// save labelmap images — fresh worker per write to avoid heap accumulation
485485
await Promise.all(
486486
serialized.map(async ({ id, path }) => {
487-
const vtkImage = dataIndex[id];
488487
const worker = await createWebWorker(null);
489488
try {
490-
const serializedImage = await writeImage(
489+
const serializedImage = await writeSegmentation(
491490
saveFormat.value,
492-
vtkImage,
491+
dataIndex[id],
492+
metadataByID[id],
493493
worker
494494
);
495495
zip.file(path, serializedImage);

tests/specs/save-large-labelmap.e2e.ts

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as zlib from 'node:zlib';
44
import { cleanuptotal } from 'wdio-cleanuptotal-service';
55
import { volViewPage } from '../pageobjects/volview.page';
66
import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf';
7-
import { writeManifestToFile } from './utils';
7+
import { writeManifestToFile, waitForFileExists } from './utils';
88

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

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-
9061
describe('Save large labelmap', function () {
9162
this.timeout(180_000);
9263

tests/specs/seg-nrrd-export.e2e.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import * as zlib from 'node:zlib';
4+
import JSZip from 'jszip';
5+
import { volViewPage } from '../pageobjects/volview.page';
6+
import { TEMP_DIR } from '../../wdio.shared.conf';
7+
import { waitForFileExists } from './utils';
8+
import { ONE_CT_SLICE_DICOM, openConfigAndDataset } from './configTestUtils';
9+
10+
/**
11+
* Parse NRRD header key-value pairs from a buffer (handles gzip).
12+
*/
13+
const parseNrrdHeader = (buf: Buffer): Map<string, string> => {
14+
const raw = buf[0] === 0x1f && buf[1] === 0x8b ? zlib.gunzipSync(buf) : buf;
15+
const text = raw.toString('ascii', 0, Math.min(raw.length, 16384));
16+
const headerEnd = text.indexOf('\n\n');
17+
const headerText = headerEnd >= 0 ? text.slice(0, headerEnd) : text;
18+
19+
const entries = new Map<string, string>();
20+
headerText.split('\n').forEach((line) => {
21+
const sepIdx = line.indexOf(':=');
22+
if (sepIdx >= 0) {
23+
entries.set(line.slice(0, sepIdx).trim(), line.slice(sepIdx + 2).trim());
24+
return;
25+
}
26+
const colonIdx = line.indexOf(':');
27+
if (colonIdx >= 0 && !line.startsWith('#') && !line.startsWith('NRRD')) {
28+
entries.set(
29+
line.slice(0, colonIdx).trim(),
30+
line.slice(colonIdx + 1).trim()
31+
);
32+
}
33+
});
34+
return entries;
35+
};
36+
37+
describe('Slicer-compatible seg.nrrd export', function () {
38+
this.timeout(120_000);
39+
40+
it('session save includes Slicer metadata in seg.nrrd labelmap', async () => {
41+
const config = { io: { segmentGroupSaveFormat: 'seg.nrrd' } };
42+
await openConfigAndDataset(config, 'seg-nrrd-export', ONE_CT_SLICE_DICOM);
43+
44+
// Activate paint tool — creates a segment group
45+
await volViewPage.activatePaint();
46+
47+
// Paint a stroke so the labelmap has data
48+
const views2D = await volViewPage.getViews2D();
49+
const canvas = await views2D[0].$('canvas');
50+
const location = await canvas.getLocation();
51+
const size = await canvas.getSize();
52+
const cx = Math.round(location.x + size.width / 2);
53+
const cy = Math.round(location.y + size.height / 2);
54+
55+
await browser
56+
.action('pointer')
57+
.move({ x: cx, y: cy })
58+
.down()
59+
.move({ x: cx + 20, y: cy })
60+
.up()
61+
.perform();
62+
63+
// Save session — downloads a .volview.zip containing the seg.nrrd
64+
const sessionFileName = await volViewPage.saveSession();
65+
const downloadedPath = path.join(TEMP_DIR, sessionFileName);
66+
67+
await waitForFileExists(downloadedPath, 30_000);
68+
69+
// Wait for file to be fully written
70+
await browser.waitUntil(
71+
() => {
72+
try {
73+
return fs.statSync(downloadedPath).size > 0;
74+
} catch {
75+
return false;
76+
}
77+
},
78+
{
79+
timeout: 10_000,
80+
interval: 500,
81+
timeoutMsg: 'Downloaded session zip remained 0 bytes',
82+
}
83+
);
84+
85+
// Extract the seg.nrrd file from the session zip
86+
const zipData = fs.readFileSync(downloadedPath);
87+
const zip = await JSZip.loadAsync(zipData);
88+
89+
const segNrrdFile = Object.keys(zip.files).find((name) =>
90+
name.endsWith('.seg.nrrd')
91+
);
92+
expect(segNrrdFile).toBeDefined();
93+
94+
const nrrdBuffer = Buffer.from(
95+
await zip.files[segNrrdFile!].async('arraybuffer')
96+
);
97+
const header = parseNrrdHeader(nrrdBuffer);
98+
99+
// Global segmentation fields
100+
expect(header.get('Segmentation_MasterRepresentation')).toBe(
101+
'Binary labelmap'
102+
);
103+
expect(header.get('Segmentation_ContainedRepresentationNames')).toBe(
104+
'Binary labelmap|'
105+
);
106+
expect(header.get('Segmentation_ReferenceImageExtentOffset')).toBe('0 0 0');
107+
108+
// Per-segment fields — default first segment is "Segment 1" with value 1
109+
expect(header.get('Segment0_ID')).toBe('Segment_1');
110+
expect(header.get('Segment0_Name')).toBe('Segment 1');
111+
expect(header.get('Segment0_LabelValue')).toBe('1');
112+
expect(header.get('Segment0_Layer')).toBe('0');
113+
expect(header.get('Segment0_Extent')).toBeDefined();
114+
expect(header.get('Segment0_Tags')).toBe('|');
115+
116+
// Color should be 3 space-separated floats between 0 and 1
117+
const colorStr = header.get('Segment0_Color');
118+
expect(colorStr).toBeDefined();
119+
const colorParts = colorStr!.split(' ').map(Number);
120+
expect(colorParts).toHaveLength(3);
121+
colorParts.forEach((c) => {
122+
expect(c).toBeGreaterThanOrEqual(0);
123+
expect(c).toBeLessThanOrEqual(1);
124+
});
125+
});
126+
});

tests/specs/session-state-lifecycle.e2e.ts

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,12 @@ import * as path from 'path';
22
import * as fs from 'fs';
33
import JSZip from 'jszip';
44
import { MINIMAL_501_SESSION } from './configTestUtils';
5-
import { downloadFile } from './utils';
5+
import { downloadFile, waitForFileExists } from './utils';
66
import { setValueVueInput, volViewPage } from '../pageobjects/volview.page';
77
import { TEMP_DIR } from '../../wdio.shared.conf';
88

99
const SESSION_SAVE_TIMEOUT = 40000;
1010

11-
const waitForFileExists = (filePath: string, timeout: number) =>
12-
new Promise<void>((resolve, reject) => {
13-
const dir = path.dirname(filePath);
14-
const basename = path.basename(filePath);
15-
16-
const watcher = fs.watch(dir, (eventType, filename) => {
17-
if (eventType === 'rename' && filename === basename) {
18-
clearTimeout(timerId);
19-
watcher.close();
20-
resolve();
21-
}
22-
});
23-
24-
const timerId = setTimeout(() => {
25-
watcher.close();
26-
reject(
27-
new Error(
28-
`File ${filePath} did not exist and was not created during timeout of ${timeout}ms`
29-
)
30-
);
31-
}, timeout);
32-
33-
fs.access(filePath, fs.constants.R_OK, (err) => {
34-
if (!err) {
35-
clearTimeout(timerId);
36-
watcher.close();
37-
resolve();
38-
}
39-
});
40-
});
41-
4211
const saveSession = async () => {
4312
const sessionFileName = await volViewPage.saveSession();
4413
const downloadedPath = path.join(TEMP_DIR, sessionFileName);

0 commit comments

Comments
 (0)