From 41c8d5807c7e776b51cc194e778dc3d348dabadb Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 10 Sep 2026 23:48:00 -0400 Subject: [PATCH] fix(segmentation): resample mismatched labelmap index grids --- src/io/resample/resample.ts | 17 ++-- src/store/__tests__/segmentImportPath.spec.ts | 77 +++++++++++++++++++ src/utils/__tests__/imageSpace.spec.ts | 69 +++++++++++++++++ src/utils/imageSpace.ts | 24 +++++- 4 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 src/store/__tests__/segmentImportPath.spec.ts create mode 100644 src/utils/__tests__/imageSpace.spec.ts diff --git a/src/io/resample/resample.ts b/src/io/resample/resample.ts index 711bcc734..973b1dbe3 100644 --- a/src/io/resample/resample.ts +++ b/src/io/resample/resample.ts @@ -1,10 +1,9 @@ import { Image } from 'itk-wasm'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; -import { compareImageSpaces } from '@/src/utils/imageSpace'; +import { compareImageIndexGrids } from '@/src/utils/imageSpace'; import { runWasm } from './itkWasmUtils'; - export async function resample(fixed: Image, moving: Image, label = false) { const labelFlag = label ? ['--label'] : []; const { size, spacing, origin, direction } = fixed; @@ -23,14 +22,18 @@ export async function resample(fixed: Image, moving: Image, label = false) { return runWasm('resample', args, [moving]); } -export async function ensureSameSpace(target: vtkImageData, resampleCandidate: vtkImageData, label = false) { - if (compareImageSpaces(target, resampleCandidate)) { - return resampleCandidate; // could still be different pixel dimensions - } +export async function ensureSameSpace( + target: vtkImageData, + resampleCandidate: vtkImageData, + label = false +) { + if (compareImageIndexGrids(target, resampleCandidate)) { + return resampleCandidate; + } const itkImage = await resample( vtkITKHelper.convertVtkToItkImage(target), vtkITKHelper.convertVtkToItkImage(resampleCandidate), label ); return vtkITKHelper.convertItkToVtkImage(itkImage); -} \ No newline at end of file +} diff --git a/src/store/__tests__/segmentImportPath.spec.ts b/src/store/__tests__/segmentImportPath.spec.ts new file mode 100644 index 000000000..ff2b96beb --- /dev/null +++ b/src/store/__tests__/segmentImportPath.spec.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { resolve } from 'node:path'; +import { InterfaceTypes, runPipelineNode, type Image } from 'itk-wasm'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { ensureSameSpace } from '@/src/io/resample/resample'; +import * as wasm from '@/src/io/resample/itkWasmUtils'; + +// Browser workers are unavailable in the test environment. +function useNodeResampling() { + return vi + .spyOn(wasm, 'runWasm') + .mockImplementation(async (pipeline, args, images) => { + const result = await runPipelineNode( + resolve('src/io/resample/emscripten-build', pipeline), + ['0', '0', ...args, '--memory-io'], + [{ type: InterfaceTypes.Image }], + images.map((data: Image) => ({ type: InterfaceTypes.Image, data })) + ); + expect(result.returnValue).toBe(0); + return result.outputs[0].data as Image; + }); +} + +function makeImage() { + const image = vtkImageData.newInstance(); + image.setDimensions(2, 3, 1); + image.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: new Uint8Array(6), + }) + ); + return image; +} + +describe('labelmap import index alignment', () => { + afterEach(() => vi.restoreAllMocks()); + + it('reuses the scalar buffer when the index grids already match', async () => { + const runWasm = useNodeResampling(); + const parent = makeImage(); + const child = makeImage(); + expect(await ensureSameSpace(parent, child, true)).toBe(child); + expect(runWasm).not.toHaveBeenCalled(); + }); + + it('resamples a reflected child onto the parent grid with label interpolation', async () => { + setActivePinia(createPinia()); + const runWasm = useNodeResampling(); + const parent = makeImage(); + const child = makeImage(); + child.setOrigin([0, 2, 0]); + child.setDirection(1, 0, 0, 0, -1, 0, 0, 0, 1); + child.getPointData().getScalars().setComponent(0, 0, 7); + const cache = useImageCacheStore(); + cache.addVTKImageData(parent, 'CTA', { id: 'parent' }); + cache.addVTKImageData(child, 'cta-head-neck-total.seg.nii.gz', { + id: 'child', + }); + + const store = useSegmentGroupStore(); + const [id] = await store.convertImageToLabelmap('child', 'parent'); + const imported = store.dataIndex[id]; + + expect(runWasm).toHaveBeenCalledOnce(); + expect(runWasm.mock.calls[0][1]).toContain('--label'); + expect(imported.getDirection()).toEqual(parent.getDirection()); + expect(imported.getOrigin()).toEqual(parent.getOrigin()); + expect(Array.from(imported.getPointData().getScalars().getData())).toEqual([ + 0, 0, 0, 0, 7, 0, + ]); + }); +}); diff --git a/src/utils/__tests__/imageSpace.spec.ts b/src/utils/__tests__/imageSpace.spec.ts new file mode 100644 index 000000000..3b72725ff --- /dev/null +++ b/src/utils/__tests__/imageSpace.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { compareImageIndexGrids, compareImageSpaces } from '../imageSpace'; + +describe('compareImageIndexGrids', () => { + it('distinguishes a reflected index grid from equivalent physical coverage', () => { + const parent = vtkImageData.newInstance(); + parent.setDimensions(2, 3, 1); + const child = vtkImageData.newInstance(); + child.setDimensions(2, 3, 1); + child.setOrigin([0, 2, 0]); + child.setDirection(1, 0, 0, 0, -1, 0, 0, 0, 1); + + expect(compareImageSpaces(parent, child)).toBe(true); + expect(compareImageIndexGrids(parent, child)).toBe(false); + }); + + it('accepts identical grids and tolerates small transform roundoff', () => { + const a = vtkImageData.newInstance(); + const b = vtkImageData.newInstance(); + a.setDimensions(2, 3, 1); + b.setDimensions(2, 3, 1); + expect(compareImageIndexGrids(a, b)).toBe(true); + b.setOrigin([0.00001, 0, 0]); + expect(compareImageIndexGrids(a, b)).toBe(true); + b.setOrigin([0.01, 0, 0]); + expect(compareImageIndexGrids(a, b)).toBe(false); + }); + + it('rejects different extents even with the same transform', () => { + const a = vtkImageData.newInstance(); + const b = vtkImageData.newInstance(); + a.setExtent(0, 1, 0, 2, 0, 0); + b.setExtent(1, 2, 0, 2, 0, 0); + expect(compareImageIndexGrids(a, b)).toBe(false); + }); + + it('rejects different sampling densities over the same physical box', () => { + const a = vtkImageData.newInstance(); + const b = vtkImageData.newInstance(); + a.setDimensions(3, 3, 3); + b.setDimensions(5, 5, 5); + b.setSpacing([0.5, 0.5, 0.5]); + expect(compareImageSpaces(a, b)).toBe(true); + expect(compareImageIndexGrids(a, b)).toBe(false); + }); + + it('rejects small spacing errors that accumulate across a large grid', () => { + const a = vtkImageData.newInstance(); + const b = vtkImageData.newInstance(); + a.setDimensions(512, 512, 1); + b.setDimensions(512, 512, 1); + a.setSpacing([0.541, 0.541, 1]); + b.setSpacing([0.5419, 0.541, 1]); + + expect(compareImageIndexGrids(a, b)).toBe(false); + }); + + it('rejects an axis rotation with the same physical coverage', () => { + const a = vtkImageData.newInstance(); + const b = vtkImageData.newInstance(); + a.setDimensions(3, 3, 3); + b.setDimensions(3, 3, 3); + b.setOrigin([2, 0, 0]); + b.setDirection(0, 1, 0, -1, 0, 0, 0, 0, 1); + expect(compareImageSpaces(a, b)).toBe(true); + expect(compareImageIndexGrids(a, b)).toBe(false); + }); +}); diff --git a/src/utils/imageSpace.ts b/src/utils/imageSpace.ts index 370d1bbf5..7d310745f 100644 --- a/src/utils/imageSpace.ts +++ b/src/utils/imageSpace.ts @@ -17,7 +17,7 @@ function getImageWorldCorners(im: vtkImageData) { for (let j = 0; j < 2; j++) { for (let k = 0; k < 2; k++) { worldCorners.push( - im.indexToWorld([extent[i], extent[j], extent[k]]) as Vector3 + im.indexToWorld([extent[i], extent[2 + j], extent[4 + k]]) as Vector3 ); } } @@ -43,6 +43,28 @@ export function compareImageSpaces( return corners1.every((p1) => corners2.some((p2) => areEquals(p1, p2, eps))); } +export function compareImageIndexGrids( + im1: vtkImageData, + im2: vtkImageData, + eps = RELAXED_EPSILON +) { + const extent1 = im1.getExtent(); + const extent2 = im2.getExtent(); + if ( + !extent1.every((value, index) => value === extent2[index]) || + !areEquals([...im1.getIndexToWorld()], [...im2.getIndexToWorld()], eps) + ) { + return false; + } + + // Small matrix differences can accumulate into significant voxel displacement. + const corners1 = getImageWorldCorners(im1); + const corners2 = getImageWorldCorners(im2); + return corners1.every((point, index) => + areEquals(point, corners2[index], eps) + ); +} + /** * Convert a world point to image index space. */