diff --git a/src/components/SaveSegmentGroupDialog.vue b/src/components/SaveSegmentGroupDialog.vue index e603b459a..fae9a85da 100644 --- a/src/components/SaveSegmentGroupDialog.vue +++ b/src/components/SaveSegmentGroupDialog.vue @@ -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', @@ -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; diff --git a/src/io/readWriteImage.ts b/src/io/readWriteImage.ts index 5817faa98..a33cb74b6 100644 --- a/src/io/readWriteImage.ts +++ b/src/io/readWriteImage.ts @@ -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')) @@ -21,7 +23,7 @@ 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 } ) => { if (format === 'vti') { return vtiWriter(image); @@ -29,8 +31,27 @@ export const writeImage = async ( // 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; }; + +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 }); +}; diff --git a/src/io/segNrrdMetadata.ts b/src/io/segNrrdMetadata.ts new file mode 100644 index 000000000..f458347d2 --- /dev/null +++ b/src/io/segNrrdMetadata.ts @@ -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 => { + const entries = new Map(); + + 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 | undefined => + format === 'seg.nrrd' + ? buildSegNrrdMetadata(segMetadata, dimensions) + : undefined; diff --git a/src/store/segmentGroups.ts b/src/store/segmentGroups.ts index f29269948..9a3878427 100644 --- a/src/store/segmentGroups.ts +++ b/src/store/segmentGroups.ts @@ -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, @@ -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); diff --git a/tests/specs/save-large-labelmap.e2e.ts b/tests/specs/save-large-labelmap.e2e.ts index f8dfc13ca..801cd3f3d 100644 --- a/tests/specs/save-large-labelmap.e2e.ts +++ b/tests/specs/save-large-labelmap.e2e.ts @@ -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; @@ -58,35 +58,6 @@ const createUint8NiftiGz = () => { return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 }); }; -const waitForFileExists = (filePath: string, timeout: number) => - new Promise((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); diff --git a/tests/specs/seg-nrrd-export.e2e.ts b/tests/specs/seg-nrrd-export.e2e.ts new file mode 100644 index 000000000..684b7d4ab --- /dev/null +++ b/tests/specs/seg-nrrd-export.e2e.ts @@ -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 => { + 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(); + 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); + }); + }); +}); diff --git a/tests/specs/session-state-lifecycle.e2e.ts b/tests/specs/session-state-lifecycle.e2e.ts index ec88a95ce..38424ed76 100644 --- a/tests/specs/session-state-lifecycle.e2e.ts +++ b/tests/specs/session-state-lifecycle.e2e.ts @@ -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((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); diff --git a/tests/specs/utils.ts b/tests/specs/utils.ts index 0c1b809de..2ac51d33e 100644 --- a/tests/specs/utils.ts +++ b/tests/specs/utils.ts @@ -78,6 +78,35 @@ export async function openVolViewPage(fileName: string) { type RemoteResourceType = z.infer & { name: string }; +export const waitForFileExists = (filePath: string, timeout: number) => + new Promise((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(); + } + }); + }); + export async function openUrls(urlsAndNames: Array) { await Promise.all( urlsAndNames.map((resource) => downloadFile(resource.url, resource.name))