Skip to content
Merged
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
17 changes: 10 additions & 7 deletions src/io/resample/resample.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
}
77 changes: 77 additions & 0 deletions src/store/__tests__/segmentImportPath.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
]);
});
});
69 changes: 69 additions & 0 deletions src/utils/__tests__/imageSpace.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
24 changes: 23 additions & 1 deletion src/utils/imageSpace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
Expand All @@ -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.
*/
Expand Down
Loading